@finesoft/front 0.5.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/Outlet.svelte +39 -0
- package/dist/Outlet.svelte.d.ts +7 -0
- package/dist/browser-DIU6Sxl3.mjs +1237 -0
- package/dist/browser-kFMjlLGT.d.mts +262 -0
- package/dist/browser.d.mts +7 -2
- package/dist/browser.mjs +9 -1
- package/dist/controller-types-CgmJ6-le.d.mts +16 -0
- package/dist/cookies-Bpf9VayB.d.mts +779 -0
- package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
- package/dist/host-guard-DDWxLpFL.mjs +222 -0
- package/dist/http-B6CJqDyf.d.mts +46 -0
- package/dist/http-CaxrMD1A.d.mts +1 -0
- package/dist/http-D70PL72H.mjs +257 -0
- package/dist/http.d.mts +3 -0
- package/dist/http.mjs +2 -0
- package/dist/index-node.d.mts +15 -0
- package/dist/index-node.mjs +15 -0
- package/dist/index.d.mts +48 -697
- package/dist/index.mjs +44 -261
- package/dist/load-node.d.mts +5 -0
- package/dist/load-node.mjs +10 -0
- package/dist/load-portable.d.mts +5 -0
- package/dist/load-portable.mjs +8 -0
- package/dist/lru-map-BKoUAySU.mjs +50 -0
- package/dist/messages-CAt2QdGr.mjs +140 -0
- package/dist/native-contract-DuR25hYB.d.mts +14 -0
- package/dist/native-contract.d.mts +2 -0
- package/dist/native-contract.mjs +1 -0
- package/dist/node-D9hB4dsz.d.mts +35 -0
- package/dist/node.d.mts +2 -0
- package/dist/node.mjs +59 -0
- package/dist/path-CGFl2w7D.mjs +113 -0
- package/dist/path-CXT6xGPO.d.mts +261 -0
- package/dist/portable-CaxrMD1A.d.mts +1 -0
- package/dist/portable.d.mts +11 -0
- package/dist/portable.mjs +12 -0
- package/dist/proxy-1SphZ7x7.mjs +436 -0
- package/dist/proxy-2dSWO-Xw.d.mts +53 -0
- package/dist/public-types-BcJM-AYc.mjs +835 -0
- package/dist/react-DhwBRw01.d.mts +16 -0
- package/dist/react.d.mts +3 -0
- package/dist/react.mjs +29 -0
- package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
- package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
- package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
- package/dist/session-DnB4ZC3x.d.mts +1279 -0
- package/dist/src-Ftl_0rhu.mjs +28 -0
- package/dist/src-qwx7Vw8g.mjs +3807 -0
- package/dist/ssr-BEUNDvbj.d.mts +210 -0
- package/dist/ssr-C8xnYXoY.mjs +357 -0
- package/dist/ssr.d.mts +3 -0
- package/dist/ssr.mjs +3 -0
- package/dist/svelte-Dr5to3SE.d.mts +16 -0
- package/dist/svelte.d.mts +3 -0
- package/dist/svelte.mjs +13 -0
- package/dist/typegen-C-WeJCtf.d.mts +12 -0
- package/dist/typegen-cli.d.mts +1 -0
- package/dist/typegen-cli.mjs +11 -0
- package/dist/typegen.d.mts +3 -0
- package/dist/typegen.mjs +2 -0
- package/dist/types-BuaZHRG7.mjs +402 -0
- package/dist/undici-CPfL25Hr.mjs +22262 -0
- package/dist/vite-Cj4SPA8D.d.mts +277 -0
- package/dist/vite.d.mts +4 -0
- package/dist/vite.mjs +2354 -0
- package/dist/vue-DGmzuKho.d.mts +32 -0
- package/dist/vue.d.mts +3 -0
- package/dist/vue.mjs +56 -0
- package/dist/web.d.mts +6 -0
- package/dist/web.mjs +8 -0
- package/dist/worker.d.mts +2 -0
- package/dist/worker.mjs +2 -0
- package/docs/01-getting-started.md +67 -199
- package/docs/02-routing-and-controllers.md +163 -241
- package/docs/03-middleware.md +10 -212
- package/docs/04-rendering-and-hydration.md +6 -333
- package/docs/05-i18n.md +7 -237
- package/docs/06-http-client.md +20 -263
- package/docs/07-di-container.md +23 -257
- package/docs/08-observability.md +6 -286
- package/docs/09-server-and-deployment.md +59 -219
- package/docs/10-features-platform-pwa.md +7 -231
- package/docs/11-navigation.md +143 -288
- package/docs/12-session-restoration.md +6 -214
- package/docs/README.md +7 -7
- package/docs/advanced/custom-action-handler.md +29 -229
- package/docs/advanced/custom-adapter.md +7 -259
- package/docs/advanced/custom-event-recorder.md +7 -312
- package/docs/advanced/inline-proxy-codegen.md +6 -185
- package/docs/advanced/multi-tenant-scopes.md +10 -323
- package/docs/engineering/ci-release-flow.md +35 -222
- package/docs/engineering/project-structure.md +34 -277
- package/docs/engineering/testing.md +8 -310
- package/docs/pitfalls/container-scope-leak.md +2 -214
- package/docs/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/docs/zh/01-getting-started.md +67 -199
- package/docs/zh/02-routing-and-controllers.md +166 -244
- package/docs/zh/03-middleware.md +10 -212
- package/docs/zh/04-rendering-and-hydration.md +6 -333
- package/docs/zh/05-i18n.md +7 -237
- package/docs/zh/06-http-client.md +20 -263
- package/docs/zh/07-di-container.md +23 -257
- package/docs/zh/08-observability.md +6 -283
- package/docs/zh/09-server-and-deployment.md +59 -219
- package/docs/zh/10-features-platform-pwa.md +7 -231
- package/docs/zh/11-navigation.md +130 -290
- package/docs/zh/12-session-restoration.md +6 -214
- package/docs/zh/README.md +4 -4
- package/docs/zh/advanced/custom-action-handler.md +29 -229
- package/docs/zh/advanced/custom-adapter.md +7 -259
- package/docs/zh/advanced/custom-event-recorder.md +7 -312
- package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
- package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
- package/docs/zh/engineering/ci-release-flow.md +35 -222
- package/docs/zh/engineering/project-structure.md +34 -277
- package/docs/zh/engineering/testing.md +8 -310
- package/docs/zh/pitfalls/container-scope-leak.md +2 -214
- package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/package.json +118 -20
- package/dist/browser-BHhVWXik.mjs +0 -2
- package/dist/browser-BV2BBXm7.d.mts +0 -2811
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { Bt as fetchWithRedirects, _ as SERVER_CONTROLLER_PATH, kt as getLocaleAttributes, t as parseCookieString } from "./src-qwx7Vw8g.mjs";
|
|
2
|
+
import { t as LruMap } from "./lru-map-BKoUAySU.mjs";
|
|
3
|
+
import "./src-Ftl_0rhu.mjs";
|
|
4
|
+
//#region ../ssr/src/inject.ts
|
|
5
|
+
/**
|
|
6
|
+
* injectSSRContent — 将 SSR 渲染结果注入 HTML 模板
|
|
7
|
+
*/
|
|
8
|
+
/** SSR HTML 模板占位符常量 */
|
|
9
|
+
const SSR_PLACEHOLDERS = {
|
|
10
|
+
HEAD: "<!--ssr-head-->",
|
|
11
|
+
BODY: "<!--ssr-body-->",
|
|
12
|
+
DATA: "<!--ssr-data-->"
|
|
13
|
+
};
|
|
14
|
+
/** 匹配所有 <!--ssr-xxx--> 占位符(含内置与自定义) */
|
|
15
|
+
const PLACEHOLDER_REGEX = /<!--ssr-([a-z][a-z0-9-]*)-->/g;
|
|
16
|
+
function injectSSRContent(options) {
|
|
17
|
+
const { template, head, css, html, serializedData, slots, locale } = options;
|
|
18
|
+
const replacements = {
|
|
19
|
+
head: `${head}\n${css ? `<style>${css}</style>` : ""}`,
|
|
20
|
+
body: html,
|
|
21
|
+
data: `<script data-fs-server-data type="application/json">${serializedData}<\/script>`,
|
|
22
|
+
...slots
|
|
23
|
+
};
|
|
24
|
+
let result = template.replace(PLACEHOLDER_REGEX, (_, name) => replacements[name] ?? "");
|
|
25
|
+
if (locale) result = applyLocaleToHtml(result, locale);
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* CSR 空壳注入 — 清空所有占位符
|
|
30
|
+
* 用于 renderMode === "csr" 的路由
|
|
31
|
+
*
|
|
32
|
+
* @param locale - 可选的 locale 属性,注入到 `<html lang="" dir="">`
|
|
33
|
+
*/
|
|
34
|
+
function injectCSRShell(template, locale) {
|
|
35
|
+
let result = template.replace(PLACEHOLDER_REGEX, () => "");
|
|
36
|
+
if (locale) result = applyLocaleToHtml(result, locale);
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
/** 将 lang/dir 注入到 <html> 标签(支持双引号 / 单引号 / 无引号属性值) */
|
|
40
|
+
const HTML_LANG_PATTERN = /\s+lang=("[^"]*"|'[^']*'|[^\s>]+)/gi;
|
|
41
|
+
const HTML_DIR_PATTERN = /\s+dir=("[^"]*"|'[^']*'|[^\s>]+)/gi;
|
|
42
|
+
function applyLocaleToHtml(html, locale) {
|
|
43
|
+
return html.replace(/(<html)([^>]*)(>)/i, (_match, open, attrs, close) => {
|
|
44
|
+
return `${open}${attrs.replace(HTML_LANG_PATTERN, "").replace(HTML_DIR_PATTERN, "")} lang="${escapeAttribute(locale.lang)}" dir="${escapeAttribute(locale.dir)}"${close}`;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function escapeAttribute(value) {
|
|
48
|
+
return value.replace(/[&<>"']/g, (character) => {
|
|
49
|
+
switch (character) {
|
|
50
|
+
case "&": return "&";
|
|
51
|
+
case "<": return "<";
|
|
52
|
+
case ">": return ">";
|
|
53
|
+
case "\"": return """;
|
|
54
|
+
default: return "'";
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region ../server/src/internal-fetch.ts
|
|
60
|
+
/**
|
|
61
|
+
* createInternalFetch — SSR 内部路由回环 fetch 包装器
|
|
62
|
+
*
|
|
63
|
+
* 将相对路径(/api/…)请求转为 Hono app.fetch 内存调用,
|
|
64
|
+
* 绝对 URL 和非字符串 input 走 globalThis.fetch(真实网络)。
|
|
65
|
+
*
|
|
66
|
+
* 递归深度保护采用请求头传递:每次 SSR 回环在请求头中写入深度值,
|
|
67
|
+
* SSR catch-all 读取深度判断是否超限。对比闭包计数器方案:
|
|
68
|
+
* - 并发安全:无共享可变状态
|
|
69
|
+
* - 跨渲染准确:深度随请求在 Hono 路由链中传递
|
|
70
|
+
*/
|
|
71
|
+
const SSR_DEPTH_HEADER = "x-ssr-depth";
|
|
72
|
+
/**
|
|
73
|
+
* 创建请求级 internal fetch
|
|
74
|
+
*
|
|
75
|
+
* @param appFetch - Hono app.fetch(父级路由)
|
|
76
|
+
* @param depth - 当前 SSR 深度(由 catch-all handler 从请求头读取后 +1 传入)
|
|
77
|
+
*/
|
|
78
|
+
function createInternalFetch(appFetch, depth = 1, context) {
|
|
79
|
+
return ((input, init) => {
|
|
80
|
+
if (typeof input === "string" && input.startsWith("/")) {
|
|
81
|
+
const base = context?.request.url ?? "http://localhost";
|
|
82
|
+
const target = new URL(input, base);
|
|
83
|
+
if (target.origin !== new URL(base).origin) throw new TypeError("Internal fetch must stay on the request origin");
|
|
84
|
+
const signal = context ? AbortSignal.any([context.request.signal, ...init?.signal ? [init.signal] : []]) : init?.signal;
|
|
85
|
+
const request = new Request(target, {
|
|
86
|
+
...init,
|
|
87
|
+
signal
|
|
88
|
+
});
|
|
89
|
+
request.headers.set(SSR_DEPTH_HEADER, String(depth));
|
|
90
|
+
return Promise.resolve(appFetch(request, context?.bindings));
|
|
91
|
+
}
|
|
92
|
+
const signal = context ? AbortSignal.any([
|
|
93
|
+
context.request.signal,
|
|
94
|
+
...input instanceof Request ? [input.signal] : [],
|
|
95
|
+
...init?.signal ? [init.signal] : []
|
|
96
|
+
]) : init?.signal;
|
|
97
|
+
return globalThis.fetch(input, signal ? {
|
|
98
|
+
...init,
|
|
99
|
+
signal
|
|
100
|
+
} : init);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region ../server/src/ssr-cache.ts
|
|
105
|
+
/** Shared HTML must be explicitly public and carry no request-specific response metadata. */
|
|
106
|
+
function isPublicSSRResult(result, headers = new Headers(result.headers)) {
|
|
107
|
+
return result.cache === "public" && !result.redirect && !result.rewriteUrl && (result.status ?? 200) === 200 && headers.keys().next().done === true;
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region ../server/src/ssr-handler.ts
|
|
111
|
+
function matchRenderModeOverride(url, renderModes) {
|
|
112
|
+
const path = url.split("?")[0];
|
|
113
|
+
if (renderModes?.[path]) return renderModes[path];
|
|
114
|
+
for (const [pattern, mode] of Object.entries(renderModes ?? {})) if (pattern.includes("*")) {
|
|
115
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
116
|
+
if (new RegExp("^" + escaped.replace(/\*/g, ".*") + "$").test(path)) return mode;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Standard Request/Response HTML assembly; owns no Node, Hono, filesystem or Vite state. */
|
|
120
|
+
function createSSRHandler(options) {
|
|
121
|
+
const cache = options.cache ?? new LruMap(1e3);
|
|
122
|
+
const ownRenderers = options.ownRenderers ?? false;
|
|
123
|
+
const owners = /* @__PURE__ */ new Set();
|
|
124
|
+
if (ownRenderers && "render" in options) owners.add(options.render);
|
|
125
|
+
const active = /* @__PURE__ */ new Set();
|
|
126
|
+
let closing;
|
|
127
|
+
const respond = async (request, bindings = {}) => {
|
|
128
|
+
const parsed = new URL(request.url);
|
|
129
|
+
const url = parsed.pathname + parsed.search;
|
|
130
|
+
const depth = Number(request.headers.get("x-ssr-depth") ?? 0);
|
|
131
|
+
const remote = parsed.pathname === SERVER_CONTROLLER_PATH;
|
|
132
|
+
if (!Number.isInteger(depth) || depth < 0 || depth >= 5) return new Response("SSR recursion loop detected", { status: 508 });
|
|
133
|
+
if (remote && (request.method !== "POST" || request.headers.get("origin") !== parsed.origin || request.headers.get("x-finesoft-controller") !== "1" || request.headers.get("sec-fetch-site") === "cross-site" || request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json")) return new Response("Forbidden", {
|
|
134
|
+
status: 403,
|
|
135
|
+
headers: { "cache-control": "no-store" }
|
|
136
|
+
});
|
|
137
|
+
if (!remote && request.method !== "GET" && request.method !== "HEAD") return new Response("Method not allowed", {
|
|
138
|
+
status: 405,
|
|
139
|
+
headers: { Allow: "GET, HEAD" }
|
|
140
|
+
});
|
|
141
|
+
const requestState = {
|
|
142
|
+
request,
|
|
143
|
+
responseHeaders: new Headers(),
|
|
144
|
+
remote,
|
|
145
|
+
cookies: parseCookieString(request.headers.get("cookie") ?? "")
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
request.signal.throwIfAborted();
|
|
149
|
+
const template = remote ? "" : typeof options.template === "string" ? options.template : await options.template(request);
|
|
150
|
+
const override = matchRenderModeOverride(url, options.renderModes);
|
|
151
|
+
const defaultLocale = options.defaultLocale ? getLocaleAttributes(options.defaultLocale) : void 0;
|
|
152
|
+
const respond = (html, status = 200, headers = new Headers()) => {
|
|
153
|
+
if (!headers.has("content-type")) headers.set("content-type", "text/html; charset=utf-8");
|
|
154
|
+
return new Response(request.method === "HEAD" || status === 204 || status === 304 ? null : html, {
|
|
155
|
+
status,
|
|
156
|
+
headers
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
if (!remote && override === "csr") return respond(injectCSRShell(template, defaultLocale));
|
|
160
|
+
const publicRequest = !request.headers.has("cookie") && !request.headers.has("authorization");
|
|
161
|
+
const module = "loadModule" in options ? await options.loadModule(request) : options;
|
|
162
|
+
if (ownRenderers) owners.add(module.render);
|
|
163
|
+
const context = {
|
|
164
|
+
request,
|
|
165
|
+
bindings,
|
|
166
|
+
requestState,
|
|
167
|
+
safeFetch: options.safeFetch,
|
|
168
|
+
fetch: options.fetch ? createInternalFetch(options.fetch, depth + 1, {
|
|
169
|
+
request,
|
|
170
|
+
bindings
|
|
171
|
+
}) : void 0
|
|
172
|
+
};
|
|
173
|
+
let result;
|
|
174
|
+
if (remote) {
|
|
175
|
+
if (!module.render.controller) return new Response("Not found", {
|
|
176
|
+
status: 404,
|
|
177
|
+
headers: { "cache-control": "no-store" }
|
|
178
|
+
});
|
|
179
|
+
const input = await readControllerRequest(request);
|
|
180
|
+
if (!input) return new Response("Invalid controller request", {
|
|
181
|
+
status: 400,
|
|
182
|
+
headers: { "cache-control": "no-store" }
|
|
183
|
+
});
|
|
184
|
+
result = await module.render.controller(input, context);
|
|
185
|
+
} else result = await module.render(url, context);
|
|
186
|
+
request.signal.throwIfAborted();
|
|
187
|
+
const headers = requestState.responseHeaders;
|
|
188
|
+
if (result.headers && result.headers !== headers) {
|
|
189
|
+
const supplied = new Headers(result.headers);
|
|
190
|
+
for (const [key, value] of supplied) if (key !== "set-cookie") headers.set(key, value);
|
|
191
|
+
for (const cookie of supplied.getSetCookie()) headers.append("set-cookie", cookie);
|
|
192
|
+
}
|
|
193
|
+
if (remote) {
|
|
194
|
+
headers.set("content-type", "application/json");
|
|
195
|
+
headers.set("cache-control", "no-store");
|
|
196
|
+
if (result.rewriteUrl) headers.set("content-location", result.rewriteUrl);
|
|
197
|
+
const body = result.redirect ? JSON.stringify({ redirect: result.redirect }) : result.status && result.status >= 400 ? JSON.stringify({ rejection: {
|
|
198
|
+
status: result.status,
|
|
199
|
+
message: result.status === 401 ? "Authentication required" : result.status === 403 ? "Access denied" : result.status === 404 ? "Page not found" : "Page load failed"
|
|
200
|
+
} }) : module.serializeServerData(result.serverData);
|
|
201
|
+
return new Response(body, { headers });
|
|
202
|
+
}
|
|
203
|
+
if (result.redirect) {
|
|
204
|
+
headers.set("location", result.redirect.url);
|
|
205
|
+
return new Response(null, {
|
|
206
|
+
status: result.redirect.status,
|
|
207
|
+
headers
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const locale = typeof result.locale === "string" ? getLocaleAttributes(result.locale) : result.locale ?? defaultLocale;
|
|
211
|
+
if (result.rewriteUrl) headers.set("content-location", result.rewriteUrl);
|
|
212
|
+
const mode = override ?? result.renderMode;
|
|
213
|
+
const eligible = mode === "prerender" && publicRequest && isPublicSSRResult(result, headers);
|
|
214
|
+
const cacheKey = JSON.stringify([
|
|
215
|
+
parsed.href,
|
|
216
|
+
locale?.lang,
|
|
217
|
+
locale?.dir
|
|
218
|
+
]);
|
|
219
|
+
const cached = eligible ? await cache.get(cacheKey) : void 0;
|
|
220
|
+
if (cached !== void 0 && cached !== null) return respond(cached, 200, new Headers(options.publicCacheHeaders));
|
|
221
|
+
const html = mode === "csr" ? injectCSRShell(template, locale) : injectSSRContent({
|
|
222
|
+
template,
|
|
223
|
+
html: result.html,
|
|
224
|
+
head: result.head,
|
|
225
|
+
css: result.css,
|
|
226
|
+
serializedData: module.serializeServerData(result.serverData),
|
|
227
|
+
slots: result.slots,
|
|
228
|
+
locale
|
|
229
|
+
});
|
|
230
|
+
if (eligible) {
|
|
231
|
+
await cache.set(cacheKey, html);
|
|
232
|
+
for (const [key, value] of Object.entries(options.publicCacheHeaders ?? {})) headers.set(key, value);
|
|
233
|
+
}
|
|
234
|
+
return respond(html, result.status, headers);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
try {
|
|
237
|
+
options.onError?.(error);
|
|
238
|
+
} catch {}
|
|
239
|
+
const headers = new Headers(requestState.responseHeaders);
|
|
240
|
+
headers.set("content-type", "text/plain; charset=utf-8");
|
|
241
|
+
headers.set("cache-control", "no-store");
|
|
242
|
+
return new Response(request.signal.aborted ? "Execution cancelled" : "Internal Server Error", {
|
|
243
|
+
status: request.signal.aborted ? 499 : 500,
|
|
244
|
+
headers
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
return {
|
|
249
|
+
fetch(request, bindings) {
|
|
250
|
+
if (closing) return Promise.resolve(new Response("Application closed", { status: 503 }));
|
|
251
|
+
const work = respond(request, bindings);
|
|
252
|
+
active.add(work);
|
|
253
|
+
work.then(() => active.delete(work), () => active.delete(work));
|
|
254
|
+
return work;
|
|
255
|
+
},
|
|
256
|
+
dispose: () => closing ??= (async () => {
|
|
257
|
+
while (active.size) await Promise.allSettled(active);
|
|
258
|
+
const results = await Promise.allSettled([...owners].map(async (render) => {
|
|
259
|
+
await render.dispose?.();
|
|
260
|
+
}));
|
|
261
|
+
owners.clear();
|
|
262
|
+
const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
263
|
+
if (errors.length) throw new AggregateError(errors, "SSR cleanup failed");
|
|
264
|
+
})()
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
async function readControllerRequest(request) {
|
|
268
|
+
const reader = request.body?.getReader();
|
|
269
|
+
if (!reader) return;
|
|
270
|
+
const chunks = [];
|
|
271
|
+
let size = 0;
|
|
272
|
+
try {
|
|
273
|
+
for (;;) {
|
|
274
|
+
const { value, done } = await reader.read();
|
|
275
|
+
if (done) break;
|
|
276
|
+
size += value.byteLength;
|
|
277
|
+
if (size > 65536) {
|
|
278
|
+
await reader.cancel();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
chunks.push(value);
|
|
282
|
+
}
|
|
283
|
+
const bytes = new Uint8Array(size);
|
|
284
|
+
let offset = 0;
|
|
285
|
+
for (const chunk of chunks) {
|
|
286
|
+
bytes.set(chunk, offset);
|
|
287
|
+
offset += chunk.length;
|
|
288
|
+
}
|
|
289
|
+
const value = JSON.parse(new TextDecoder().decode(bytes));
|
|
290
|
+
const record = (item) => item !== null && typeof item === "object" && !Array.isArray(item);
|
|
291
|
+
if (!record(value) || typeof value.intent !== "string" || !value.intent || typeof value.url !== "string" || !record(value.params) || !record(value.query)) return;
|
|
292
|
+
if (value.url && (!value.url.startsWith("/") || new URL(value.url, request.url).origin !== new URL(request.url).origin)) return;
|
|
293
|
+
return {
|
|
294
|
+
intent: value.intent,
|
|
295
|
+
params: value.params,
|
|
296
|
+
query: value.query,
|
|
297
|
+
url: value.url
|
|
298
|
+
};
|
|
299
|
+
} catch {
|
|
300
|
+
return;
|
|
301
|
+
} finally {
|
|
302
|
+
reader.releaseLock();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
//#endregion
|
|
306
|
+
//#region ../server/src/proxy.ts
|
|
307
|
+
/**
|
|
308
|
+
* 框架级声明式代理路由
|
|
309
|
+
*
|
|
310
|
+
* 将代理路由从业务层手写 Hono 路由,改为框架级配置。
|
|
311
|
+
* 框架统一执行路径校验(SSRF 防护)、Host 限制、错误处理、响应头控制。
|
|
312
|
+
*/
|
|
313
|
+
/** 代理路径最大长度 */
|
|
314
|
+
const MAX_PROXY_PATH_LENGTH = 2048;
|
|
315
|
+
/** 代理响应最大体积(10 MB) */
|
|
316
|
+
const MAX_RESPONSE_SIZE = 10485760;
|
|
317
|
+
/**
|
|
318
|
+
* 校验代理路径,防止 SSRF(协议相对 URL 绕过、编码绕过)。
|
|
319
|
+
* 返回规范化的路径,或 null 表示非法。
|
|
320
|
+
*
|
|
321
|
+
* 策略保守:拒绝任何含编码字符的路径,避免上游对 %2F 等解码差异导致绕过。
|
|
322
|
+
* 副作用:合法的 %20、%E4%B8%AD(Unicode)也会被拒。
|
|
323
|
+
* 如需放宽,应在上层路由前自行 decode,或为该代理单独提供 sanitizer 选项。
|
|
324
|
+
*/
|
|
325
|
+
function sanitizeProxyPath(raw) {
|
|
326
|
+
if (raw.length > MAX_PROXY_PATH_LENGTH) return null;
|
|
327
|
+
try {
|
|
328
|
+
if (decodeURIComponent(raw) !== raw) return null;
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
if (raw.startsWith("//")) return null;
|
|
333
|
+
if (!/^[/\w.\-~%:@!$&'()*+,;=]*$/.test(raw)) return null;
|
|
334
|
+
return raw.startsWith("/") ? raw : `/${raw}`;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* 校验代理配置合法性。
|
|
338
|
+
* 在注册时(启动阶段)调用,非法配置直接抛错阻止启动。
|
|
339
|
+
*/
|
|
340
|
+
function validateConfig(config) {
|
|
341
|
+
if (!config.prefix.startsWith("/")) throw new Error(`[proxy] prefix must start with "/": "${config.prefix}"`);
|
|
342
|
+
const isHttps = config.target.startsWith("https://");
|
|
343
|
+
const isHttp = config.target.startsWith("http://");
|
|
344
|
+
if (!isHttps && !isHttp) throw new Error(`[proxy] target must start with "https://" or "http://": "${config.target}"`);
|
|
345
|
+
if (isHttp) console.warn(`[proxy] ⚠ target "${config.target}" uses plain HTTP — traffic will not be encrypted. Use HTTPS in production to prevent data interception.`);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* 注册声明式代理路由到 Hono app(运行时使用:Vite dev / preview / generated hosts)
|
|
349
|
+
*/
|
|
350
|
+
function registerProxyRoutes(app, configs) {
|
|
351
|
+
for (const config of configs) {
|
|
352
|
+
validateConfig(config);
|
|
353
|
+
const methods = config.methods ?? ["all"];
|
|
354
|
+
const pattern = `${config.prefix}/*`;
|
|
355
|
+
const handler = createProxyHandler(config);
|
|
356
|
+
for (const method of methods) app[method](pattern, handler);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function createProxyHandler(config) {
|
|
360
|
+
return async (c) => {
|
|
361
|
+
const subPath = sanitizeProxyPath(c.req.path.replace(config.prefix, ""));
|
|
362
|
+
if (!subPath) return c.text("Invalid path", 400);
|
|
363
|
+
const targetUrl = new URL(subPath, config.target);
|
|
364
|
+
const expectedOrigin = new URL(config.target).origin;
|
|
365
|
+
if (targetUrl.origin !== expectedOrigin) return c.text("Invalid proxy target", 400);
|
|
366
|
+
new URL(c.req.url).searchParams.forEach((v, k) => targetUrl.searchParams.set(k, v));
|
|
367
|
+
const headers = { ...config.headers };
|
|
368
|
+
if (config.auth) {
|
|
369
|
+
const token = globalThis.process?.env?.[config.auth.envKey];
|
|
370
|
+
if (!token) console.warn(`[Proxy ${config.prefix}] Auth env var "${config.auth.envKey}" is not set`);
|
|
371
|
+
else headers.Authorization = config.auth.type === "bearer" ? `Bearer ${token}` : `Basic ${token}`;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
const resp = await fetchWithRedirects(fetch, targetUrl.toString(), {
|
|
375
|
+
headers,
|
|
376
|
+
redirect: config.followRedirects ? "follow" : "manual"
|
|
377
|
+
}, (url) => {
|
|
378
|
+
if (new URL(url).origin !== expectedOrigin) throw new TypeError("Invalid proxy redirect target");
|
|
379
|
+
});
|
|
380
|
+
const contentLength = resp.headers.get("Content-Length");
|
|
381
|
+
if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_SIZE) {
|
|
382
|
+
resp.body?.cancel().catch(() => {});
|
|
383
|
+
return c.text("Proxy response too large", 502);
|
|
384
|
+
}
|
|
385
|
+
const body = await readProxyBody(resp);
|
|
386
|
+
if (!body) return c.text("Proxy response too large", 502);
|
|
387
|
+
const respHeaders = { "Content-Type": resp.headers.get("Content-Type") ?? "application/json" };
|
|
388
|
+
if (config.cache) respHeaders["Cache-Control"] = config.cache;
|
|
389
|
+
return c.newResponse(body, resp.status, respHeaders);
|
|
390
|
+
} catch (e) {
|
|
391
|
+
console.error(`[Proxy ${config.prefix}]`, e);
|
|
392
|
+
return c.json({ error: "Proxy request failed" }, 502);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/** Bound allocation while preserving binary bytes, even without Content-Length. */
|
|
397
|
+
async function readProxyBody(response) {
|
|
398
|
+
if (!response.body) return /* @__PURE__ */ new ArrayBuffer(0);
|
|
399
|
+
const reader = response.body.getReader();
|
|
400
|
+
const chunks = [];
|
|
401
|
+
let size = 0;
|
|
402
|
+
try {
|
|
403
|
+
for (;;) {
|
|
404
|
+
const { done, value } = await reader.read();
|
|
405
|
+
if (done) break;
|
|
406
|
+
size += value.byteLength;
|
|
407
|
+
if (size > MAX_RESPONSE_SIZE) {
|
|
408
|
+
reader.cancel().catch(() => {});
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
chunks.push(value);
|
|
412
|
+
}
|
|
413
|
+
} finally {
|
|
414
|
+
reader.releaseLock();
|
|
415
|
+
}
|
|
416
|
+
const body = new Uint8Array(size);
|
|
417
|
+
let offset = 0;
|
|
418
|
+
for (const chunk of chunks) {
|
|
419
|
+
body.set(chunk, offset);
|
|
420
|
+
offset += chunk.byteLength;
|
|
421
|
+
}
|
|
422
|
+
return body.buffer;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* 生成代理路由注册代码(用于 serverless/edge 入口)。
|
|
426
|
+
* 生成的入口从公开 SSR 边界导入 registerProxyRoutes,因此安全策略只有一个运行时实现。
|
|
427
|
+
*/
|
|
428
|
+
function generateProxyCode(configs) {
|
|
429
|
+
if (!configs || configs.length === 0) return "";
|
|
430
|
+
for (const config of configs) validateConfig(config);
|
|
431
|
+
return `// ─── 框架声明式代理路由 ───\nregisterProxyRoutes(app, ${JSON.stringify(configs)});`;
|
|
432
|
+
}
|
|
433
|
+
//#endregion
|
|
434
|
+
export { isPublicSSRResult as a, injectSSRContent as c, matchRenderModeOverride as i, registerProxyRoutes as n, SSR_PLACEHOLDERS as o, createSSRHandler as r, injectCSRShell as s, generateProxyCode as t };
|
|
435
|
+
|
|
436
|
+
//# sourceMappingURL=proxy-1SphZ7x7.mjs.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//#region ../server/src/proxy.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* 框架级声明式代理路由
|
|
4
|
+
*
|
|
5
|
+
* 将代理路由从业务层手写 Hono 路由,改为框架级配置。
|
|
6
|
+
* 框架统一执行路径校验(SSRF 防护)、Host 限制、错误处理、响应头控制。
|
|
7
|
+
*/
|
|
8
|
+
/** 代理路由认证配置 */
|
|
9
|
+
interface ProxyAuthConfig {
|
|
10
|
+
/** 认证类型 */
|
|
11
|
+
type: "bearer" | "basic";
|
|
12
|
+
/** 环境变量名称(运行时从 process.env 读取) */
|
|
13
|
+
envKey: string;
|
|
14
|
+
}
|
|
15
|
+
/** 声明式代理路由配置 */
|
|
16
|
+
interface ProxyRouteConfig {
|
|
17
|
+
/** URL 前缀,如 "/api/apple"(必须以 "/" 开头) */
|
|
18
|
+
prefix: string;
|
|
19
|
+
/** 代理目标地址(以 "https://" 或 "http://" 开头,推荐 HTTPS) */
|
|
20
|
+
target: string;
|
|
21
|
+
/** HTTP 方法(默认 ["all"]) */
|
|
22
|
+
methods?: ("all" | "get" | "post" | "put" | "delete" | "patch")[];
|
|
23
|
+
/** 附加到代理请求的头部 */
|
|
24
|
+
headers?: Record<string, string>;
|
|
25
|
+
/** 认证配置 */
|
|
26
|
+
auth?: ProxyAuthConfig;
|
|
27
|
+
/** Cache-Control 响应头 */
|
|
28
|
+
cache?: string;
|
|
29
|
+
/** 是否跟随同源重定向(默认 false);跨源重定向始终拒绝。 */
|
|
30
|
+
followRedirects?: boolean;
|
|
31
|
+
}
|
|
32
|
+
type ProxyRouteRegistrar = (path: string, handler: (context: any) => Promise<Response>) => unknown;
|
|
33
|
+
/** The registration surface used by Hono; portable SSR declarations need no Hono peer. */
|
|
34
|
+
interface ProxyRouter {
|
|
35
|
+
all: ProxyRouteRegistrar;
|
|
36
|
+
get: ProxyRouteRegistrar;
|
|
37
|
+
post: ProxyRouteRegistrar;
|
|
38
|
+
put: ProxyRouteRegistrar;
|
|
39
|
+
delete: ProxyRouteRegistrar;
|
|
40
|
+
patch: ProxyRouteRegistrar;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 注册声明式代理路由到 Hono app(运行时使用:Vite dev / preview / generated hosts)
|
|
44
|
+
*/
|
|
45
|
+
declare function registerProxyRoutes(app: ProxyRouter, configs: ProxyRouteConfig[]): void;
|
|
46
|
+
/**
|
|
47
|
+
* 生成代理路由注册代码(用于 serverless/edge 入口)。
|
|
48
|
+
* 生成的入口从公开 SSR 边界导入 registerProxyRoutes,因此安全策略只有一个运行时实现。
|
|
49
|
+
*/
|
|
50
|
+
declare function generateProxyCode(configs: ProxyRouteConfig[]): string;
|
|
51
|
+
//#endregion
|
|
52
|
+
export { registerProxyRoutes as a, generateProxyCode as i, ProxyRouteConfig as n, ProxyRouter as r, ProxyAuthConfig as t };
|
|
53
|
+
//# sourceMappingURL=proxy-2dSWO-Xw.d.mts.map
|