@finesoft/front 0.5.2 → 0.5.4

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.
Files changed (56) hide show
  1. package/dist/{browser-kFMjlLGT.d.mts → browser-CrZcKSpr.d.mts} +4 -4
  2. package/dist/{browser-DIU6Sxl3.mjs → browser-D6If-9VF.mjs} +84 -4
  3. package/dist/browser.d.mts +7 -7
  4. package/dist/browser.mjs +5 -5
  5. package/dist/{cookies-Bpf9VayB.d.mts → cookies-BXCwZoS_.d.mts} +12 -5
  6. package/dist/{fetch-policy-BHT8RtrL.mjs → fetch-policy-Cxk7F-Cu.mjs} +2 -2
  7. package/dist/{host-guard-DDWxLpFL.mjs → host-guard-DX4e3-45.mjs} +5 -1
  8. package/dist/{http-B6CJqDyf.d.mts → http-B8r7MWao.d.mts} +2 -2
  9. package/dist/http.d.mts +1 -1
  10. package/dist/index-node.d.mts +11 -11
  11. package/dist/index-node.mjs +8 -8
  12. package/dist/index.d.mts +15 -15
  13. package/dist/index.mjs +7 -7
  14. package/dist/{native-contract-DuR25hYB.d.mts → native-contract-B_2pn5ST.d.mts} +2 -2
  15. package/dist/native-contract.d.mts +1 -1
  16. package/dist/{node-D9hB4dsz.d.mts → node-DbGzbatF.d.mts} +4 -4
  17. package/dist/node.d.mts +1 -1
  18. package/dist/node.mjs +1 -1
  19. package/dist/{path-CXT6xGPO.d.mts → path-C-b1ChKq.d.mts} +6 -1
  20. package/dist/portable.d.mts +9 -9
  21. package/dist/portable.mjs +7 -7
  22. package/dist/proxy-BkKVP3Ha.mjs +7520 -0
  23. package/dist/{react-DhwBRw01.d.mts → react-C8AnI9sx.d.mts} +2 -2
  24. package/dist/react.d.mts +2 -2
  25. package/dist/{secure-fetch-Xlht2jd7.d.mts → secure-fetch--QwYr5uJ.d.mts} +5 -1
  26. package/dist/{server-controller-proxy-BkVuVWVD.d.mts → server-controller-proxy-CFdU_nGs.d.mts} +3 -3
  27. package/dist/{session-DnB4ZC3x.d.mts → session-BR1vd3yY.d.mts} +3 -3
  28. package/dist/{src-qwx7Vw8g.mjs → src-D_Bzbr80.mjs} +35 -8
  29. package/dist/{ssr-C8xnYXoY.mjs → ssr-DqwPGqOd.mjs} +3 -3
  30. package/dist/{ssr-BEUNDvbj.d.mts → ssr-lzuGJ2ux.d.mts} +6 -9
  31. package/dist/ssr.d.mts +1 -1
  32. package/dist/ssr.mjs +2 -2
  33. package/dist/{svelte-Dr5to3SE.d.mts → svelte-CMFFDLBi.d.mts} +2 -2
  34. package/dist/svelte.d.mts +2 -2
  35. package/dist/{types-BuaZHRG7.mjs → types-DD09OkGZ.mjs} +3 -3
  36. package/dist/{vite-Cj4SPA8D.d.mts → vite-BjWtbemg.d.mts} +10 -4
  37. package/dist/vite.d.mts +2 -2
  38. package/dist/vite.mjs +218 -44
  39. package/dist/{vue-DGmzuKho.d.mts → vue-DsZIYP1c.d.mts} +2 -2
  40. package/dist/vue.d.mts +2 -2
  41. package/dist/web.d.mts +6 -6
  42. package/dist/web.mjs +4 -4
  43. package/dist/worker.d.mts +1 -1
  44. package/docs/02-routing-and-controllers.md +2 -0
  45. package/docs/04-rendering-and-hydration.md +2 -0
  46. package/docs/06-http-client.md +4 -0
  47. package/docs/09-server-and-deployment.md +13 -0
  48. package/docs/11-navigation.md +2 -0
  49. package/docs/advanced/multi-tenant-scopes.md +24 -0
  50. package/docs/pitfalls/ssr-hydration-mismatch.md +10 -0
  51. package/docs/zh/02-routing-and-controllers.md +2 -0
  52. package/docs/zh/04-rendering-and-hydration.md +2 -0
  53. package/docs/zh/09-server-and-deployment.md +13 -0
  54. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +10 -0
  55. package/package.json +22 -22
  56. package/dist/proxy-1SphZ7x7.mjs +0 -436
@@ -205,6 +205,8 @@ Admission receives `from`, candidate `tree`, stable `transitionId`, active `exec
205
205
 
206
206
  A denial returns an uncommitted snapshot with `rejection`, including for empty trees. Session restore rejects it before replacing scopes or slices. Initial browser denial renders the error without committing navigation or recording a page visit; later denial preserves the displayed draft. Both flat and navigation SSR honor these policies and emit no rejected page data or public-cache permission. CSR shells still defer navigation to the browser.
207
207
 
208
+ If any visible page fails during initial navigation, the whole candidate stays uncommitted and only its error presentation is exposed. Successful siblings are withheld from the native view and SSR hydration data until a successful retry passes the commit boundary. Initial browser redirects that hand off to an external site likewise withhold successful siblings while the browser leaves. Page failures do not invoke `beforeCommit`; later failed navigation preserves the previously committed view.
209
+
208
210
  Owned back/forward rejection compensates to the committed history entry and restores its scroll identity. Additive history metadata preserves ownership and position across reloads. Entries without compatible ownership metadata are diagnosed; the framework cannot infer a safe traversal distance for external entries.
209
211
 
210
212
  Low-level hosts configuring `createWebSession({ createContext })` return a `NavigationContext` directly. The execution supplies its DI container and cancellation signal; request cookies and headers stay in the host context. The session uses the application's `getErrorPage` unless explicitly overridden.
@@ -15,3 +15,27 @@ await runtime.execute(operation, input, {
15
15
  ```
16
16
 
17
17
  Use `provide({ token, lifetime: "scope", create, dispose })` for per-request resources. Concurrent initialization deduplicates and a failed initialization may retry. Runtime-scoped providers cannot depend on request-scoped values. Externally supplied values remain external unless owned explicitly; created values are released in dependency order. A command is not retried or cached automatically; cancellation does not roll back an already applied mutation.
18
+
19
+ ## Query cache partitions
20
+
21
+ Cached queries with invocation bindings or an invocation-specific `fetch` default to execution-local reuse, even with `scope: "runtime"`. Bindings can contain opaque resources and tenant-specific providers, so the runtime does not serialize them into a shared key. Web and SSR hosts also supply bindings and therefore use this isolation by default. Queries without invocation bindings/fetch retain runtime reuse partitioned by identity, locale and input.
22
+
23
+ To share completed results across executions, explicitly declare the complete trusted security partition:
24
+
25
+ ```ts
26
+ const tenantSummary = defineOperation({
27
+ id: "tenant-summary",
28
+ kind: "query",
29
+ cache: {
30
+ ttlMs: 10_000,
31
+ partition: (context) => {
32
+ const tenant = context.bindings.tenant;
33
+ if (typeof tenant !== "string") throw new ExecutionError("configuration");
34
+ return tenant;
35
+ },
36
+ },
37
+ handler: loadTenantSummary,
38
+ });
39
+ ```
40
+
41
+ The partition must cover every result-affecting binding and fetch authority in addition to the existing identity, locale and input key. A constant partition asserts that the result is independent of all other invocation state. `cache.key(input)` still controls only the input key; it cannot enable shared caching by itself. Policies run before every cache hit, invalidation still applies across partitions, and `scope: "execution"` always remains local.
@@ -5,3 +5,13 @@ Share the same page definition, App and Outlet view registry between browser and
5
5
  Hydrate the server snapshot before restoring persisted state. Avoid random values, time or browser globals during the first render. Do not await ready before mounting or manually mutate Outlet's child tree.
6
6
 
7
7
  Declare explicit public projections; nested data needs a nested declaration or codec. Wire/buildId mismatches trigger fresh loading. Inspect browser warnings, DOM, requests and entry identity when diagnosing a mismatch.
8
+
9
+ Declare the site's locale in `defineWebApp({ configuration: { locale: "en" }, ... })` so SSR and the browser use the same value. The SSR assembler writes `lang` and `dir` on `<html>`. The browser also scopes those attributes to the selected app target, together with `data-fs-app`, so embedded applications can retain their own locale without changing the containing document. A static `<html lang="en">` alone does not configure the server runtime's locale.
10
+
11
+ `injectSSRContent` also stamps a compact fingerprint of the data script's parent container. `createBrowserApp` compares that fingerprint immediately before returning the native mount handle, after consuming valid SSR data. This detects changed attributes, text, comments and structure without identifying browser extensions. The comparison follows parsed HTML rather than raw HTML strings; custom element hosts and their contents are opaque to this check. Root attributes and nodes outside the app are outside its scope.
12
+
13
+ When the DOM changed and replacement is safe, `shouldHydrate` is false: the existing React/Vue/Svelte template mounts a fresh native root using the SSR page data. It does not restart the page controller. The host logs `source: "hydration", code: "dom-changed", recovery: "native-mount"`. No plugin rules, global console filters or repeated recovery loop are installed. Normal markup continues to hydrate, and SPA navigation does not repeat the check.
14
+
15
+ The host keeps native hydration when it detects existing focus, selection, edited controls, scrolling, editable content, active media, custom elements, nested apps or opaque browser contexts. That path logs `recovery: "deferred"`; native warnings and native hydration behavior remain in effect. It avoids an additional destructive remount, but cannot guarantee preservation if the native renderer itself fails hydration. Manually assembled HTML without a fingerprint, or a data script outside the selected target, retains the usual native hydration behavior.
16
+
17
+ This is a startup recovery boundary, not extension isolation or a security check. A changed fingerprint does not identify the cause: an extension, application script or HTML rewriter can all modify DOM. Head styles, opaque component internals, changes after inspection and errors in an extension's own execution context are not repaired. Native mismatches with an unchanged server DOM are still reported normally.
@@ -150,6 +150,8 @@ export class AccountController extends BaseServerController {
150
150
 
151
151
  服务端 Controller 模块只导出 Controller 类和类型。该模块的本地运行时依赖也属于服务端边界;客户端直接导入这些依赖会报错。共享类型请使用 `import type`,共享的客户端实现放在独立模块。构造函数内的依赖创建属于受保护模块;不要在共享的 `create` 工厂参数、共享配置或页面组件中写入机密。
152
152
 
153
+ 编译器识别模块级类声明、绑定到变量或默认导出的类表达式、括号/类型断言、本地别名、命名空间导入及静态转导出。涉及原始服务端基类但无法安全代理的工厂或计算属性形式会令浏览器构建报错,不再直接返回原始源码;出现该诊断时改用模块级类。纯转导出模块和使用生成代理的普通业务模块仍可共享。
154
+
153
155
  生产客户端 JS / source map 不包含被替换的实现。开发服务拒绝 Controller 的原始资源导入和受保护依赖的源码请求。模块与依赖索引在启动时建立,后续缓存 AST,只重新分析变更文件;开发期间已识别的私有依赖保持保护,若要将它迁回共享模块,应重启开发服务。动态计算的文件路径、`public/` 资源和另行复制的源码不属于静态导入边界。开发服务器仍只用于受信任的开发环境。
154
156
 
155
157
  使用框架 Vite 插件及具有 SSR 请求处理能力的 Node / Worker 等宿主。纯静态托管没有远程执行端点。此入口用于注册的服务端页面 Controller,不会自动发布任意方法或代替登录、注册等命令端点。
@@ -8,4 +8,6 @@ Outlet 订阅稳定的 `AppSnapshot`,以 EntryId 保持外层元素,以 Entr
8
8
 
9
9
  SSR 在释放请求资源前物化公开投影,原生组件只渲染一次。水合协议明确包含 `{ tree, pages }`,每份页面数据关联 entry。协议版本或 buildId 不匹配时重新加载;会话持久化另有版本。被守卫拒绝的页面数据不进入 wire。
10
10
 
11
+ 标准 SSR 组装流程会生成初始 DOM 的校验值。原生挂载前发现 DOM 已变化,且没有需要保留的浏览器状态时,框架会使用同一份 SSR 数据选择重新挂载,无需按插件编写规则。详见[水合恢复及其边界](./pitfalls/ssr-hydration-mismatch.md)。
12
+
11
13
  CSR 返回 HTML shell。prerender 输出静态 HTML;运行时公开 HTML 缓存仍先运行本次守卫与渲染,不能视为跳过业务执行。清理时应用先等待会话 dispose,再卸载自己的原生根。
@@ -77,6 +77,19 @@ Node 主机需要 `@hono/node-server`。可移植 Worker 图无需 Node 兼容
77
77
 
78
78
  当 `setup` 是模块路径时,该模块必须通过 `export default` 导出 setup 函数。开发、预览与生成的部署主机统一使用这个导出,不再自动猜测命名函数。
79
79
 
80
+ `setup` 先于声明式代理运行,先注册的 `app.use(...)` 鉴权中间件因此也保护代理请求。请先注册鉴权,再注册终止请求的处理器;setup 中同路径的终止路由会覆盖代理。配置的 setup 加载失败时,预览启动失败。代理的 `auth` 仅提供上游凭据,不认证调用者;未安装应用鉴权的代理仍公开可用。
81
+
82
+ Cloudflare 适配器没有连接时 DNS 校验能力,因此 `SAFE_FETCH` 默认拒绝任意域名。访问已知可信 API 时,显式配置完整来源:
83
+
84
+ ```ts
85
+ import { cloudflareAdapter, finesoftFrontViteConfig } from "@finesoft/front";
86
+ finesoftFrontViteConfig({
87
+ adapter: cloudflareAdapter({ trustedOrigins: ["https://api.example.com"] }),
88
+ });
89
+ ```
90
+
91
+ 这是对该来源的显式信任例外,不是 DNS 地址固定。来源包含协议和端口,不支持路径、通配符或前缀匹配;重定向逐跳重查,私有 IP 字面量仍被拒绝。相对路径的进程内 API 调用保持可用。`"cloudflare"` 字符串快捷方式使用空的可信来源列表。
92
+
80
93
  ## 静态托管边界
81
94
 
82
95
  `staticAdapter` 默认读取构建产物的 `render.routes`,通过同一 SSR host 生成 HTML 并等待释放;`dynamicRoutes` 提供具体动态路径,`routesExport` 仅作显式扩展。发现路由或渲染失败会令构建失败。纯 HTML 不能表达重定向、错误状态、Set-Cookie 或自定义 HTTP 响应头,因此适配器拒绝这些响应;需要它们时选择 Node/Worker 等请求主机。
@@ -5,3 +5,13 @@
5
5
  先水合服务器快照,再恢复持久化状态。避免首次渲染读取随机数、时间或浏览器独有全局。不要在挂载前等待 ready,也不要手动改动 Outlet 的子树。
6
6
 
7
7
  显式声明公开数据投影;嵌套对象需要嵌套声明或 codec。wire/buildId 不匹配时重新加载。排查时同时检查实际浏览器警告、页面 DOM、网络请求和 entry 身份。
8
+
9
+ 整站语言应在 `defineWebApp({ configuration: { locale: "en" }, ... })` 中声明,让 SSR 与浏览器使用同一个值。SSR 组装器在 `<html>` 上输出 `lang`、`dir`;浏览器还会在所选应用容器上设置这两个属性和 `data-fs-app`,使嵌入应用能使用自己的语言而不改动宿主文档。仅在静态模板中写 `<html lang="en">`,不会配置服务端运行时的 locale。
10
+
11
+ `injectSSRContent` 会在数据脚本上附带其父容器的 DOM 校验值。`createBrowserApp` 消费有效的 SSR 数据后,在返回原生挂载句柄前核对校验值,检测属性、文本、注释和结构变化,不识别具体浏览器插件。比较对象是解析后的 HTML,不是原始 HTML 字符串;自定义元素的宿主属性及内部内容由组件自己管理,不进入比较。根容器自身的属性和应用外部节点也不在检查范围内。
12
+
13
+ DOM 已变化且可以安全替换时,`shouldHydrate` 返回 false,现有 React/Vue/Svelte 模板使用 SSR 页面数据重新挂载原生根,不重新执行页面控制器。宿主记录 `source: "hydration", code: "dom-changed", recovery: "native-mount"`。没有插件名单、全局日志过滤或反复重建循环。正常 DOM 继续水合,SPA 导航不重复检查。
14
+
15
+ 存在焦点、选区、已修改表单、滚动、可编辑内容、活动媒体、自定义元素、嵌套应用或不透明的浏览器上下文时,宿主继续原生水合,记录 `recovery: "deferred"`,保留原生水合行为及诊断。这避免框架额外强制重建,但原生渲染器自身水合失败时仍不能保证状态保留。手工组装 HTML 未附带校验值,或数据脚本不直接位于所选 target 内时,保持通常的原生水合流程。
16
+
17
+ 这是启动恢复边界,不是插件隔离或安全校验。校验值变化不能确定来源:插件、应用脚本及 HTML 改写服务都可能修改 DOM。它不修复 head 中的样式、组件自己的内部结构、检查之后发生的改动或扩展自身运行环境中的错误。服务端 DOM 未变化时,应用自身的水合错误仍正常报告。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@finesoft/front",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Application runtime, routing, native UI and platform hosts through one public API",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,15 +12,15 @@
12
12
  "finesoft-types": "./dist/typegen-cli.mjs"
13
13
  },
14
14
  "files": [
15
- "dist/browser-DIU6Sxl3.mjs",
16
- "dist/browser-kFMjlLGT.d.mts",
15
+ "dist/browser-CrZcKSpr.d.mts",
16
+ "dist/browser-D6If-9VF.mjs",
17
17
  "dist/browser.d.mts",
18
18
  "dist/browser.mjs",
19
19
  "dist/controller-types-CgmJ6-le.d.mts",
20
- "dist/cookies-Bpf9VayB.d.mts",
21
- "dist/fetch-policy-BHT8RtrL.mjs",
22
- "dist/host-guard-DDWxLpFL.mjs",
23
- "dist/http-B6CJqDyf.d.mts",
20
+ "dist/cookies-BXCwZoS_.d.mts",
21
+ "dist/fetch-policy-Cxk7F-Cu.mjs",
22
+ "dist/host-guard-DX4e3-45.mjs",
23
+ "dist/http-B8r7MWao.d.mts",
24
24
  "dist/http-CaxrMD1A.d.mts",
25
25
  "dist/http-D70PL72H.mjs",
26
26
  "dist/http.d.mts",
@@ -35,36 +35,36 @@
35
35
  "dist/load-portable.mjs",
36
36
  "dist/lru-map-BKoUAySU.mjs",
37
37
  "dist/messages-CAt2QdGr.mjs",
38
- "dist/native-contract-DuR25hYB.d.mts",
38
+ "dist/native-contract-B_2pn5ST.d.mts",
39
39
  "dist/native-contract.d.mts",
40
40
  "dist/native-contract.mjs",
41
- "dist/node-D9hB4dsz.d.mts",
41
+ "dist/node-DbGzbatF.d.mts",
42
42
  "dist/node.d.mts",
43
43
  "dist/node.mjs",
44
44
  "dist/Outlet.svelte",
45
45
  "dist/Outlet.svelte.d.ts",
46
+ "dist/path-C-b1ChKq.d.mts",
46
47
  "dist/path-CGFl2w7D.mjs",
47
- "dist/path-CXT6xGPO.d.mts",
48
48
  "dist/portable-CaxrMD1A.d.mts",
49
49
  "dist/portable.d.mts",
50
50
  "dist/portable.mjs",
51
- "dist/proxy-1SphZ7x7.mjs",
52
51
  "dist/proxy-2dSWO-Xw.d.mts",
52
+ "dist/proxy-BkKVP3Ha.mjs",
53
53
  "dist/public-types-BcJM-AYc.mjs",
54
- "dist/react-DhwBRw01.d.mts",
54
+ "dist/react-C8AnI9sx.d.mts",
55
55
  "dist/react.d.mts",
56
56
  "dist/react.mjs",
57
57
  "dist/rolldown-runtime-B4iAMlE-.mjs",
58
- "dist/secure-fetch-Xlht2jd7.d.mts",
59
- "dist/server-controller-proxy-BkVuVWVD.d.mts",
60
- "dist/session-DnB4ZC3x.d.mts",
58
+ "dist/secure-fetch--QwYr5uJ.d.mts",
59
+ "dist/server-controller-proxy-CFdU_nGs.d.mts",
60
+ "dist/session-BR1vd3yY.d.mts",
61
+ "dist/src-D_Bzbr80.mjs",
61
62
  "dist/src-Ftl_0rhu.mjs",
62
- "dist/src-qwx7Vw8g.mjs",
63
- "dist/ssr-BEUNDvbj.d.mts",
64
- "dist/ssr-C8xnYXoY.mjs",
63
+ "dist/ssr-DqwPGqOd.mjs",
64
+ "dist/ssr-lzuGJ2ux.d.mts",
65
65
  "dist/ssr.d.mts",
66
66
  "dist/ssr.mjs",
67
- "dist/svelte-Dr5to3SE.d.mts",
67
+ "dist/svelte-CMFFDLBi.d.mts",
68
68
  "dist/svelte.d.mts",
69
69
  "dist/svelte.mjs",
70
70
  "dist/typegen-C-WeJCtf.d.mts",
@@ -72,12 +72,12 @@
72
72
  "dist/typegen-cli.mjs",
73
73
  "dist/typegen.d.mts",
74
74
  "dist/typegen.mjs",
75
- "dist/types-BuaZHRG7.mjs",
75
+ "dist/types-DD09OkGZ.mjs",
76
76
  "dist/undici-CPfL25Hr.mjs",
77
- "dist/vite-Cj4SPA8D.d.mts",
77
+ "dist/vite-BjWtbemg.d.mts",
78
78
  "dist/vite.d.mts",
79
79
  "dist/vite.mjs",
80
- "dist/vue-DGmzuKho.d.mts",
80
+ "dist/vue-DsZIYP1c.d.mts",
81
81
  "dist/vue.d.mts",
82
82
  "dist/vue.mjs",
83
83
  "dist/web.d.mts",
@@ -1,436 +0,0 @@
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 "&amp;";
51
- case "<": return "&lt;";
52
- case ">": return "&gt;";
53
- case "\"": return "&quot;";
54
- default: return "&#39;";
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