Refactor server structure and enhance middleware functionality

- Consolidated middleware setup into a dedicated setup file for better organization.
- Introduced API proxy middleware to handle requests to the backend API.
- Registered well-known, merge, and SSR routes in separate files for improved modularity.
- Removed unused HTML and SSR layout files to streamline the codebase.
- Implemented a utility for HTML escaping to prevent XSS vulnerabilities.
- Updated the main server entry point to utilize the new middleware and route structure.
This commit is contained in:
2026-02-26 18:38:37 +07:00
parent 00bbe0f503
commit ff1d4902bc
11 changed files with 488 additions and 465 deletions

View File

@@ -1,161 +1,24 @@
import { serializeQueryCache } from '@pinia/colada';
import { renderSSRHead } from '@unhead/vue/server';
import { Hono } from 'hono';
import { contextStorage } from 'hono/context-storage';
import { cors } from "hono/cors";
import { streamText } from 'hono/streaming';
import isMobile from 'is-mobile';
import { renderToWebStream } from 'vue/server-renderer';
import { buildBootstrapScript } from './lib/manifest';
import { styleTags } from './lib/primePassthrough';
import { createApp } from './main';
import { useAuthStore } from './stores/auth';
// @ts-ignore
import Base from '@primevue/core/base';
import { baseAPIURL } from './api/httpClientAdapter.server';
import { createManifest, getListFiles, saveManifest, validateChunkUrls } from './server/modules/merge';
const app = new Hono()
const defaultNames = ['primitive', 'semantic', 'global', 'base', 'ripple-directive']
// app.use(renderer)
app.use('*', contextStorage());
app.use(cors(), async (c, next) => {
c.set("fetch", app.request.bind(app));
const ua = c.req.header("User-Agent")
if (!ua) {
return c.json({ error: "User-Agent header is missing" }, 400);
};
c.set("isMobile", isMobile({ ua }));
await next();
}, async (c, next) => {
const path = c.req.path
if (path !== '/r' && !path.startsWith('/r/')) {
return await next()
}
const url = new URL(c.req.url)
url.host = baseAPIURL.replace(/^https?:\/\//, '')
url.protocol = 'https:'
url.pathname = path.replace(/^\/r/, '') || '/'
url.port = ''
// console.log("url", url.toString())
// console.log("c.req.raw", c.req.raw)
const headers = new Headers(c.req.header());
headers.delete("host");
headers.delete("connection");
import { setupMiddlewares } from './server/middlewares/setup';
import { apiProxyMiddleware } from './server/middlewares/apiProxy';
import { registerWellKnownRoutes } from './server/routes/wellKnown';
import { registerMergeRoutes } from './server/routes/merge';
import { registerManifestRoutes } from './server/routes/manifest';
import { registerSSRRoutes } from './server/routes/ssr';
return fetch(url.toString(), {
method: c.req.method,
headers: headers,
body: c.req.raw.body,
// @ts-ignore
duplex: 'half',
credentials: 'include'
});
});
app.get("/.well-known/*", (c) => {
return c.json({ ok: true });
});
app.post('/merge', async (c, next) => {
const headers = new Headers(c.req.header());
headers.delete("host");
headers.delete("connection");
return fetch(`${baseAPIURL}/me`, {
method: 'GET',
headers: headers,
credentials: 'include'
}).then(res => res.json()).then((r) => {
if (r.data?.user) {
return next();
}
else {
throw new Error("Unauthorized");
}}).catch(() => {
return c.json({ error: "Unauthorized" }, 401);
});
}, async (c) => {
try {
const body = await c.req.json()
const { filename, chunks } = body
if (!filename || !Array.isArray(chunks) || chunks.length === 0) {
return c.json({ error: 'invalid payload' }, 400)
}
const hostError = validateChunkUrls(chunks)
if (hostError) return c.json({ error: hostError }, 400)
const app = new Hono();
const manifest = createManifest(filename, chunks)
await saveManifest(manifest)
// Global middlewares
setupMiddlewares(app);
return c.json({
status: 'ok',
id: manifest.id,
filename: manifest.filename,
total_parts: manifest.total_parts,
})
} catch (e: any) {
return c.json({ error: e?.message ?? String(e) }, 500)
}
})
app.get('/manifest/:id', async (c) => {
const manifest = await getListFiles()
if (!manifest) {
return c.json({ error: "Manifest not found" }, 404)
}
return c.json(manifest)
})
app.get("*", async (c) => {
const nonce = crypto.randomUUID();
const url = new URL(c.req.url);
const { app, router, head, pinia, bodyClass, queryCache } = createApp();
app.provide("honoContext", c);
const auth = useAuthStore();
auth.$reset();
// auth.initialized = false;
await auth.init();
await router.push(url.pathname);
await router.isReady();
let usedStyles = new Set<String>();
Base.setLoadedStyleName = async (name: string) => usedStyles.add(name)
return streamText(c, async (stream) => {
c.header("Content-Type", "text/html; charset=utf-8");
c.header("Content-Encoding", "Identity");
const ctx: Record<string, any> = {};
const appStream = renderToWebStream(app, ctx);
// console.log("ctx: ", );
await stream.write("<!DOCTYPE html><html lang='en'><head>");
await stream.write("<base href='" + url.origin + "'/>");
// API proxy middleware (handles /r/*)
app.use(apiProxyMiddleware);
await renderSSRHead(head).then((headString) => stream.write(headString.headTags.replace(/\n/g, "")));
// await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"rel="stylesheet"></link>`);
await stream.write(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap" rel="stylesheet">`);
await stream.write('<link rel="icon" href="/favicon.ico" />');
await stream.write(buildBootstrapScript());
if (usedStyles.size > 0) {
defaultNames.forEach(name => usedStyles.add(name));
}
await Promise.all(styleTags.filter(tag => usedStyles.has(tag.name.replace(/-(variables|style)$/, ""))).map(tag => stream.write(`<style type="text/css" data-primevue-style-id="${tag.name}">${tag.value}</style>`)));
await stream.write(`</head><body class='${bodyClass}'>`);
// await stream.pipe(createTextTransformStreamClass(appStream, (text) => text.replace('<div id="anchor-header" class="p-4"></div>', `<div id="anchor-header" class="p-4">${ctx.teleports["#anchor-header"] || ""}</div>`).replace('<div id="anchor-top"></div>', `<div id="anchor-top">${ctx.teleports["#anchor-top"] || ""}</div>`)));
await stream.pipe(appStream);
delete ctx.teleports
delete ctx.__teleportBuffers
delete ctx.modules;
Object.assign(ctx, { $p: pinia.state.value, $colada: serializeQueryCache(queryCache) });
await stream.write(`<script type="application/json" data-ssr="true" id="__APP_DATA__" nonce="${nonce}">${htmlEscape((JSON.stringify(ctx)))}</script>`);
await stream.write("</body></html>");
});
})
const ESCAPE_LOOKUP: { [match: string]: string } = {
"&": "\\u0026",
">": "\\u003e",
"<": "\\u003c",
"\u2028": "\\u2028",
"\u2029": "\\u2029",
};
// Routes
registerWellKnownRoutes(app);
registerMergeRoutes(app);
registerManifestRoutes(app);
registerSSRRoutes(app);
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
function htmlEscape(str: string): string {
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
}
export default app
export default app;

View File

@@ -0,0 +1,29 @@
import { baseAPIURL } from '@/api/httpClientAdapter.server';
import type { Context, Next } from 'hono';
export async function apiProxyMiddleware(c: Context, next: Next) {
const path = c.req.path;
if (path !== '/r' && !path.startsWith('/r/')) {
return await next();
}
const url = new URL(c.req.url);
url.host = baseAPIURL.replace(/^https?:\/\//, '');
url.protocol = 'https:';
url.pathname = path.replace(/^\/r/, '') || '/';
url.port = '';
const headers = new Headers(c.req.header());
headers.delete("host");
headers.delete("connection");
return fetch(url.toString(), {
method: c.req.method,
headers: headers,
body: c.req.raw.body,
// @ts-ignore
duplex: 'half',
credentials: 'include'
});
}

View File

@@ -0,0 +1,20 @@
import { contextStorage } from 'hono/context-storage';
import { cors } from 'hono/cors';
import isMobile from 'is-mobile';
import type { Hono } from 'hono';
export function setupMiddlewares(app: Hono) {
app.use('*', contextStorage());
app.use(cors(), async (c, next) => {
c.set("fetch", app.request.bind(app));
const ua = c.req.header("User-Agent");
if (!ua) {
return c.json({ error: "User-Agent header is missing" }, 400);
}
c.set("isMobile", isMobile({ ua }));
await next();
});
}

View File

@@ -0,0 +1,12 @@
import { getListFiles } from '@/server/modules/merge';
import type { Hono } from 'hono';
export function registerManifestRoutes(app: Hono) {
app.get('/manifest/:id', async (c) => {
const manifest = await getListFiles();
if (!manifest) {
return c.json({ error: "Manifest not found" }, 404);
}
return c.json(manifest);
});
}

View File

@@ -0,0 +1,57 @@
import { baseAPIURL } from '@/api/httpClientAdapter.server';
import {
createManifest,
saveManifest,
validateChunkUrls
} from '@/server/modules/merge';
import type { Hono, MiddlewareHandler } from 'hono';
const authMiddleware: MiddlewareHandler = async (c, next) => {
const headers = new Headers(c.req.header());
headers.delete("host");
headers.delete("connection");
try {
const res = await fetch(`${baseAPIURL}/me`, {
method: 'GET',
headers: headers,
credentials: 'include'
});
const data = await res.json();
if (data.data?.user) {
return await next();
}
throw new Error("Unauthorized");
} catch {
return c.json({ error: "Unauthorized" }, 401);
}
};
export function registerMergeRoutes(app: Hono) {
app.post('/merge', authMiddleware, async (c) => {
try {
const body = await c.req.json();
const { filename, chunks } = body;
if (!filename || !Array.isArray(chunks) || chunks.length === 0) {
return c.json({ error: 'invalid payload' }, 400);
}
const hostError = validateChunkUrls(chunks);
if (hostError) return c.json({ error: hostError }, 400);
const manifest = createManifest(filename, chunks);
await saveManifest(manifest);
return c.json({
status: 'ok',
id: manifest.id,
filename: manifest.filename,
total_parts: manifest.total_parts,
});
} catch (e: any) {
return c.json({ error: e?.message ?? String(e) }, 500);
}
});
}

97
src/server/routes/ssr.ts Normal file
View File

@@ -0,0 +1,97 @@
import { serializeQueryCache } from '@pinia/colada';
import { renderSSRHead } from '@unhead/vue/server';
import { streamText } from 'hono/streaming';
import { renderToWebStream } from 'vue/server-renderer';
// @ts-ignore
import Base from '@primevue/core/base';
import { createApp } from '@/main';
import { useAuthStore } from '@/stores/auth';
import { buildBootstrapScript } from '@/lib/manifest';
import { styleTags } from '@/lib/primePassthrough';
import { htmlEscape } from '@/server/utils/htmlEscape';
import type { Hono } from 'hono';
const DEFAULT_STYLE_NAMES = ['primitive', 'semantic', 'global', 'base', 'ripple-directive'];
export function registerSSRRoutes(app: Hono) {
app.get("*", async (c) => {
const nonce = crypto.randomUUID();
const url = new URL(c.req.url);
const { app: vueApp, router, head, pinia, bodyClass, queryCache } = createApp();
vueApp.provide("honoContext", c);
const auth = useAuthStore();
auth.$reset();
await auth.init();
await router.push(url.pathname);
await router.isReady();
const usedStyles = new Set<string>();
Base.setLoadedStyleName = async (name: string) => usedStyles.add(name);
return streamText(c, async (stream) => {
c.header("Content-Type", "text/html; charset=utf-8");
c.header("Content-Encoding", "Identity");
const ctx: Record<string, any> = {};
const appStream = renderToWebStream(vueApp, ctx);
// HTML Head
await stream.write("<!DOCTYPE html><html lang='en'><head>");
await stream.write("<base href='" + url.origin + "'/>");
// SSR Head tags
const headResult = await renderSSRHead(head);
await stream.write(headResult.headTags.replace(/\n/g, ""));
// Fonts & Favicon
await stream.write(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap" rel="stylesheet">`);
await stream.write('<link rel="icon" href="/favicon.ico" />');
// Bootstrap scripts
await stream.write(buildBootstrapScript());
// PrimeVue styles
if (usedStyles.size > 0) {
DEFAULT_STYLE_NAMES.forEach(name => usedStyles.add(name));
}
const activeStyles = styleTags.filter(tag =>
usedStyles.has(tag.name.replace(/-(variables|style)$/, ""))
);
for (const tag of activeStyles) {
await stream.write(`<style type="text/css" data-primevue-style-id="${tag.name}">${tag.value}</style>`);
}
// Body start
await stream.write(`</head><body class='${bodyClass}'>`);
// App content
await stream.pipe(appStream);
// Cleanup context
delete ctx.teleports;
delete ctx.__teleportBuffers;
delete ctx.modules;
// Inject state
Object.assign(ctx, {
$p: pinia.state.value,
$colada: serializeQueryCache(queryCache)
});
// App data script
const appDataScript = `<script type="application/json" data-ssr="true" id="__APP_DATA__" nonce="${nonce}">${htmlEscape(JSON.stringify(ctx))}</script>`;
await stream.write(appDataScript);
// Close HTML
await stream.write("</body></html>");
});
});
}

View File

@@ -0,0 +1,7 @@
import type { Hono } from 'hono';
export function registerWellKnownRoutes(app: Hono) {
app.get("/.well-known/*", (c) => {
return c.json({ ok: true });
});
}

View File

@@ -0,0 +1,13 @@
const ESCAPE_LOOKUP: { [match: string]: string } = {
"&": "\\u0026",
">": "\\u003e",
"<": "\\u003c",
"\u2028": "\\u2028",
"\u2029": "\\u2029",
};
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
export function htmlEscape(str: string): string {
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
}

View File

@@ -1,56 +0,0 @@
/**
* @module
* html Helper for Hono.
*/
import { escapeToBuffer, raw, resolveCallbackSync, stringBufferToString } from 'hono/utils/html'
import type { HtmlEscaped, HtmlEscapedString, StringBufferWithCallbacks } from 'hono/utils/html'
export const html = (
strings: TemplateStringsArray,
...values: unknown[]
): HtmlEscapedString | Promise<HtmlEscapedString> => {
const buffer: StringBufferWithCallbacks = [''] as StringBufferWithCallbacks
for (let i = 0, len = strings.length - 1; i < len; i++) {
buffer[0] += strings[i]
const children = Array.isArray(values[i])
? (values[i] as Array<unknown>).flat(Infinity)
: [values[i]]
for (let i = 0, len = children.length; i < len; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const child = children[i] as any
if (typeof child === 'string') {
escapeToBuffer(child, buffer)
} else if (typeof child === 'number') {
;(buffer[0] as string) += child
} else if (typeof child === 'boolean' || child === null || child === undefined) {
continue
} else if (typeof child === 'object' && (child as HtmlEscaped).isEscaped) {
if ((child as HtmlEscapedString).callbacks) {
buffer.unshift('', child)
} else {
const tmp = child.toString()
if (tmp instanceof Promise) {
buffer.unshift('', tmp)
} else {
buffer[0] += tmp
}
}
} else if (child instanceof Promise) {
buffer.unshift('', child)
} else {
escapeToBuffer(child.toString(), buffer)
}
}
}
buffer[0] += strings.at(-1) as string
return buffer.length === 1
? 'callbacks' in buffer
? raw(resolveCallbackSync(raw(buffer[0], buffer.callbacks)))
: raw(buffer[0])
: stringBufferToString(buffer, buffer.callbacks)
}

View File

@@ -1,65 +0,0 @@
import { createContext, jsx, Suspense } from "hono/jsx";
import { renderToReadableStream, StreamingContext } from "hono/jsx/streaming";
import { HtmlEscapedCallback, HtmlEscapedString, raw } from "hono/utils/html";
// import { jsxs } from "hono/jsx-renderer";
import { Context } from "hono";
import type {
FC,
Context as JSXContext,
JSXNode
} from "hono/jsx";
import { jsxTemplate } from "hono/jsx/jsx-runtime";
export const RequestContext: JSXContext<Context<any, any, {}> | null> =
createContext<Context | null>(null);
export function renderSSRLayout(c: Context, appStream: ReadableStream) {
const body = jsxTemplate`${raw("<!DOCTYPE html>")}${_c(
RequestContext.Provider,
{ value: c },
// currentLayout as any
_c(
"html",
{ lang: "en" },
_c(
"head",
null,
raw('<meta charset="UTF-8"/>'),
raw(
'<meta name="viewport" content="width=device-width, initial-scale=1.0"/>'
),
raw('<link rel="icon" href="/favicon.ico" />'),
raw(`<base href="${new URL(c.req.url).origin}/"/>`)
),
_c(
"body",
{
class:
"font-sans bg-[#f9fafd] text-gray-800 antialiased flex flex-col",
},
_c(
StreamingContext,
{ value: { scriptNonce: "random-nonce-value" } },
_c(
Suspense,
{ fallback: _c("div", { class: "loading" }, raw("Loading...")) },
raw(appStream.getReader())
)
),
_c("script", {
dangerouslySetInnerHTML: {
__html: `window.__SSR_STATE__ = ${JSON.stringify(
JSON.stringify(c.get("ssrContext") || {})
)};`,
},
})
)
)
)}`;
return renderToReadableStream(body);
}
function _c(
tag: string | FC<any>,
props: any,
...children: (JSXNode | HtmlEscapedCallback | HtmlEscapedString | null)[]
): JSXNode {
return jsx(tag, props, ...(children as any));
}