akanjs 3.1.1 → 3.1.2-rc.0

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 (333) hide show
  1. package/README.ko.md +22 -0
  2. package/README.md +22 -0
  3. package/client/cookie.ts +23 -7
  4. package/client/csrTypes.ts +1 -1
  5. package/client/makePageProto.tsx +10 -1
  6. package/client/router.ts +57 -16
  7. package/common/IpLiteral.ts +4 -2
  8. package/common/TrustedProxy.ts +45 -2
  9. package/common/akanNames.ts +169 -0
  10. package/common/codeAgentProfile.ts +51 -4
  11. package/common/index.ts +13 -0
  12. package/common/localeConfig.ts +30 -5
  13. package/common/localePath.ts +26 -0
  14. package/common/markdownSpans.ts +3 -1
  15. package/common/mcpExposure.ts +7 -7
  16. package/common/websocketContract.ts +50 -0
  17. package/constant/index.ts +1 -0
  18. package/constant/inputParser.ts +10 -2
  19. package/constant/requestValue.ts +47 -0
  20. package/dictionary/agent.dictionary.ts +2 -2
  21. package/dictionary/base.dictionary.ts +4 -0
  22. package/dictionary/dictInfo.ts +9 -9
  23. package/document/database.ts +1 -0
  24. package/document/documentQuery.ts +42 -6
  25. package/document/filterMeta.ts +56 -8
  26. package/document/queryEvaluator.ts +13 -8
  27. package/fetch/client/fetchClient.ts +28 -62
  28. package/fetch/client/httpClient.ts +1 -0
  29. package/fetch/client/sliceInitHandle.ts +18 -18
  30. package/fetch/index.ts +1 -0
  31. package/fetch/sessionCookie.ts +37 -0
  32. package/index.ts +8 -0
  33. package/package.json +13 -27
  34. package/server/akanApp.ts +108 -29
  35. package/server/akanServer.ts +18 -7
  36. package/server/assetHeaders.ts +20 -1
  37. package/server/cachePolicy.ts +18 -5
  38. package/server/devtools/depsSerializer.ts +2 -1
  39. package/server/devtools/devtoolsRouter.ts +3 -2
  40. package/server/devtools/signalSerializer.ts +5 -5
  41. package/server/hmr/clientReport.ts +10 -5
  42. package/server/imageOptimizer.ts +117 -22
  43. package/server/lifecycle/buildEnv.ts +11 -0
  44. package/server/lifecycle/loadBuildEnv.ts +3 -0
  45. package/server/logging/logControlSocket.ts +20 -14
  46. package/server/logging/logStreamRoute.ts +12 -8
  47. package/server/logging/rotatingLogWriter.ts +15 -1
  48. package/server/machine/machineToken.ts +4 -0
  49. package/server/mcp/McpAuth.ts +12 -4
  50. package/server/mcp/McpExecutionContext.ts +4 -0
  51. package/server/mcp/McpRouter.ts +25 -13
  52. package/server/mcp/PagePromptComposer.ts +4 -7
  53. package/server/oauth/OAuthClientIdMetadata.ts +29 -6
  54. package/server/oauth/OAuthRegistration.ts +9 -0
  55. package/server/ops/failureLimiter.ts +42 -0
  56. package/server/ops/opsCommand.ts +19 -7
  57. package/server/ops/opsRoute.ts +60 -9
  58. package/server/ops/opsToken.ts +5 -0
  59. package/server/ops/snapshotJobs.ts +3 -1
  60. package/server/ops/snapshotRestore.ts +23 -1
  61. package/server/ops/sqliteSnapshot.ts +98 -18
  62. package/server/ops/statusAccess.ts +37 -19
  63. package/server/proxy/hostBasePathWebProxy.ts +4 -7
  64. package/server/proxy/localeWebProxy.ts +36 -16
  65. package/server/resolver/database.resolver.ts +15 -11
  66. package/server/resolver/service.resolver.ts +17 -2
  67. package/server/resolver/signal.resolver.ts +50 -40
  68. package/server/routeElementComposer.tsx +42 -9
  69. package/server/routeShellGate.ts +42 -0
  70. package/server/routing/apiRouter.ts +64 -1
  71. package/server/routing/serverLimits.ts +151 -0
  72. package/server/routing/soloAppRoutes.ts +23 -12
  73. package/server/rscClient.tsx +5 -3
  74. package/server/rscScrollRestoration.ts +60 -9
  75. package/server/rscWorker.tsx +76 -24
  76. package/server/sitemap.ts +5 -7
  77. package/server/ssrFromRscRenderer.tsx +36 -4
  78. package/server/ssrTypes.ts +5 -2
  79. package/server/staticPath.ts +36 -1
  80. package/server/systemPageDocument.tsx +5 -3
  81. package/server/systemPages.tsx +14 -8
  82. package/server/tunnel/TunnelAgent.ts +19 -2
  83. package/server/tunnel/TunnelDataSocket.ts +4 -2
  84. package/server/tunnel/TunnelHttpStream.ts +7 -2
  85. package/server/tunnel/TunnelTcpStream.ts +6 -3
  86. package/server/tunnel/TunnelWebsocketStream.ts +9 -2
  87. package/server/tunnel/tunnelStream.ts +6 -0
  88. package/server/types.tsx +20 -2
  89. package/server/webRouter.ts +38 -18
  90. package/service/agent.service.ts +9 -7
  91. package/service/injectInfo.ts +2 -1
  92. package/service/liveSyncHub.ts +6 -0
  93. package/service/predefinedAdaptor/anthropicLlm.ts +13 -12
  94. package/service/predefinedAdaptor/database.adaptor.ts +4 -0
  95. package/service/predefinedAdaptor/llm.adaptor.ts +3 -0
  96. package/service/predefinedAdaptor/llmOverflow.ts +5 -1
  97. package/service/predefinedAdaptor/openaiDialect.ts +9 -8
  98. package/service/predefinedAdaptor/searchIndex.ts +21 -1
  99. package/service/predefinedAdaptor/sql/QueryCompiler.ts +28 -4
  100. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +45 -11
  101. package/service/predefinedAdaptor/sql/dialect/postgres.ts +6 -1
  102. package/service/predefinedAdaptor/sql/types.ts +3 -0
  103. package/service/predefinedAdaptor/storage.adaptor.ts +11 -10
  104. package/service/serve.ts +3 -3
  105. package/service/serviceModule.ts +28 -24
  106. package/service/types.ts +3 -0
  107. package/signal/SignalFailure.ts +5 -2
  108. package/signal/agent/AgentCatalogue.ts +3 -2
  109. package/signal/agentMeter.ts +81 -9
  110. package/signal/agentRelayPolicy.ts +53 -2
  111. package/signal/agentTurnStream.ts +12 -1
  112. package/signal/guard.ts +2 -2
  113. package/signal/mcp/McpDocument.ts +3 -3
  114. package/signal/mcp/McpUriTemplate.ts +5 -5
  115. package/signal/middleware.ts +15 -1
  116. package/signal/roomKey.ts +53 -0
  117. package/signal/signalContext.ts +100 -40
  118. package/signal/slice.ts +27 -22
  119. package/signal/trace.ts +17 -6
  120. package/store/action.ts +20 -49
  121. package/store/actionTag.ts +2 -0
  122. package/store/agent/ScreenReader.ts +36 -4
  123. package/store/agent/ScreenTarget.ts +1 -1
  124. package/store/agent/StoreCatalogue.ts +7 -1
  125. package/store/agent/StoreSurfaceSource.ts +5 -0
  126. package/store/agentic/AgentConfirm.ts +30 -0
  127. package/store/agentic/StStateBuilder.ts +2 -2
  128. package/store/agentic/StToolBuilder.ts +7 -2
  129. package/store/agentic/formFields.ts +1 -2
  130. package/store/agentic/index.ts +1 -0
  131. package/store/agentic/useFieldTool.ts +4 -3
  132. package/store/deprecatedNames.ts +6 -0
  133. package/store/draftStore.ts +2 -2
  134. package/store/formUploadOf.ts +6 -5
  135. package/store/index.ts +1 -2
  136. package/store/sliceKeys.ts +9 -32
  137. package/store/state.ts +1 -1
  138. package/store/stateBuilder.ts +1 -1
  139. package/store/store.ts +1 -1
  140. package/store/storeInstance.ts +12 -8
  141. package/store/storeRegistry.ts +14 -2
  142. package/types/client/cookie.d.ts +7 -2
  143. package/types/client/csrTypes.d.ts +1 -1
  144. package/types/client/router.d.ts +4 -1
  145. package/types/common/akanNames.d.ts +149 -0
  146. package/types/common/codeAgentProfile.d.ts +4 -1
  147. package/types/common/index.d.ts +3 -1
  148. package/types/common/localeConfig.d.ts +8 -0
  149. package/types/common/localePath.d.ts +13 -0
  150. package/types/common/websocketContract.d.ts +7 -0
  151. package/types/constant/index.d.ts +1 -0
  152. package/types/constant/requestValue.d.ts +5 -0
  153. package/types/dictionary/base.dictionary.d.ts +1 -1
  154. package/types/dictionary/dictionary.d.ts +3 -3
  155. package/types/document/database.d.ts +1 -0
  156. package/types/document/documentQuery.d.ts +3 -0
  157. package/types/document/filterMeta.d.ts +4 -0
  158. package/types/fetch/client/sliceInitHandle.d.ts +1 -2
  159. package/types/fetch/index.d.ts +1 -0
  160. package/types/fetch/sessionCookie.d.ts +12 -0
  161. package/types/index.d.ts +8 -0
  162. package/types/server/akanApp.d.ts +1 -0
  163. package/types/server/assetHeaders.d.ts +5 -0
  164. package/types/server/cachePolicy.d.ts +2 -0
  165. package/types/server/devtools/devtoolsRouter.d.ts +1 -1
  166. package/types/server/imageOptimizer.d.ts +4 -1
  167. package/types/server/lifecycle/buildEnv.d.ts +2 -0
  168. package/types/server/lifecycle/loadBuildEnv.d.ts +1 -0
  169. package/types/server/logging/logStreamRoute.d.ts +1 -1
  170. package/types/server/logging/rotatingLogWriter.d.ts +3 -0
  171. package/types/server/machine/machineToken.d.ts +2 -0
  172. package/types/server/mcp/McpAuth.d.ts +2 -1
  173. package/types/server/mcp/McpExecutionContext.d.ts +1 -0
  174. package/types/server/oauth/OAuthRegistration.d.ts +2 -0
  175. package/types/server/ops/failureLimiter.d.ts +11 -0
  176. package/types/server/ops/opsRoute.d.ts +4 -2
  177. package/types/server/ops/opsToken.d.ts +2 -0
  178. package/types/server/ops/snapshotRestore.d.ts +3 -1
  179. package/types/server/ops/sqliteSnapshot.d.ts +7 -1
  180. package/types/server/ops/statusAccess.d.ts +8 -4
  181. package/types/server/proxy/hostBasePathWebProxy.d.ts +1 -0
  182. package/types/server/resolver/signal.resolver.d.ts +0 -1
  183. package/types/server/routeElementComposer.d.ts +10 -4
  184. package/types/server/routeShellGate.d.ts +12 -0
  185. package/types/server/routing/apiRouter.d.ts +6 -1
  186. package/types/server/routing/serverLimits.d.ts +23 -0
  187. package/types/server/routing/soloAppRoutes.d.ts +1 -1
  188. package/types/server/rscScrollRestoration.d.ts +1 -0
  189. package/types/server/ssrTypes.d.ts +5 -2
  190. package/types/server/staticPath.d.ts +1 -0
  191. package/types/server/systemPageDocument.d.ts +1 -0
  192. package/types/server/tunnel/TunnelAgent.d.ts +6 -0
  193. package/types/server/tunnel/TunnelDataSocket.d.ts +2 -0
  194. package/types/server/tunnel/TunnelTcpStream.d.ts +1 -1
  195. package/types/server/tunnel/tunnelStream.d.ts +1 -0
  196. package/types/server/types.d.ts +11 -2
  197. package/types/service/agent.service.d.ts +1 -1
  198. package/types/service/liveSyncHub.d.ts +1 -0
  199. package/types/service/predefinedAdaptor/anthropicLlm.d.ts +1 -1
  200. package/types/service/predefinedAdaptor/llm.adaptor.d.ts +1 -0
  201. package/types/service/predefinedAdaptor/openaiDialect.d.ts +1 -1
  202. package/types/service/predefinedAdaptor/searchIndex.d.ts +5 -1
  203. package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +1 -0
  204. package/types/service/predefinedAdaptor/sql/types.d.ts +3 -0
  205. package/types/service/types.d.ts +3 -0
  206. package/types/signal/SignalFailure.d.ts +1 -1
  207. package/types/signal/agentMeter.d.ts +4 -1
  208. package/types/signal/agentRelayPolicy.d.ts +12 -0
  209. package/types/signal/guard.d.ts +1 -1
  210. package/types/signal/roomKey.d.ts +8 -0
  211. package/types/signal/signalContext.d.ts +20 -6
  212. package/types/signal/slice.d.ts +1 -0
  213. package/types/signal/trace.d.ts +4 -0
  214. package/types/store/actionTag.d.ts +2 -0
  215. package/types/store/agent/ScreenReader.d.ts +2 -1
  216. package/types/store/agent/StoreCatalogue.d.ts +5 -0
  217. package/types/store/agentic/AgentConfirm.d.ts +15 -0
  218. package/types/store/agentic/StToolBuilder.d.ts +3 -1
  219. package/types/store/agentic/index.d.ts +1 -0
  220. package/types/store/deprecatedNames.d.ts +5 -0
  221. package/types/store/index.d.ts +1 -2
  222. package/types/store/sliceKeys.d.ts +12 -2
  223. package/types/ui/Agent/agentSessionOf.d.ts +2 -0
  224. package/types/ui/Agent/useAccountBoundSession.d.ts +2 -0
  225. package/types/ui/Agent/useScreenActions.d.ts +1 -1
  226. package/types/ui/Icon.d.ts +15 -0
  227. package/types/ui/animated.d.ts +3 -3
  228. package/types/ui/sliceNamesOf.d.ts +4 -4
  229. package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +10 -0
  230. package/types/vendor/@akanjs/native/packages/cli/src/lib/updates.d.ts +6 -0
  231. package/types/vendor/@akanjs/native/packages/core/src/kernel.d.ts +6 -1
  232. package/types/vendor/@akanjs/native/packages/desktop/src/paths.d.ts +7 -0
  233. package/types/vendor/@akanjs/native/plugins/single-instance/src/socket.d.ts +2 -0
  234. package/types/vendor/@akanjs/native/plugins/updates/src/desktop.d.ts +2 -0
  235. package/types/vendor/use-agentic/AgentSession.d.ts +6 -1
  236. package/types/vendor/use-agentic/ToolRunner.d.ts +7 -2
  237. package/types/vendor/use-agentic/types.d.ts +2 -0
  238. package/types/vendor/use-agentic/useAgentTool.d.ts +2 -1
  239. package/types/webkit/useCsrValues.d.ts +3 -3
  240. package/ui/Agent/Chat.tsx +2 -0
  241. package/ui/Agent/Zone.tsx +2 -0
  242. package/ui/Agent/agentSessionOf.ts +54 -5
  243. package/ui/Agent/useAccountBoundSession.ts +26 -0
  244. package/ui/Agent/useScreenActions.ts +1 -1
  245. package/ui/BottomSheet.tsx +1 -1
  246. package/ui/Button.tsx +5 -5
  247. package/ui/Data/Item.tsx +2 -8
  248. package/ui/Data/ListContainer.tsx +4 -4
  249. package/ui/Data/QueryMaker.tsx +3 -2
  250. package/ui/Data/RefPicker.tsx +3 -2
  251. package/ui/Data/UnlistedEditModal.tsx +2 -1
  252. package/ui/Data/ViewActions.tsx +4 -4
  253. package/ui/Data/dataExport.ts +3 -0
  254. package/ui/Data/useViewModal.ts +10 -15
  255. package/ui/Dialog/LegacyModal.tsx +1 -1
  256. package/ui/Dialog/Provider.tsx +2 -2
  257. package/ui/DragAction.tsx +1 -1
  258. package/ui/Dropdown.tsx +2 -2
  259. package/ui/Field/lightRefCache.ts +2 -2
  260. package/ui/Field/useRelationOptions.ts +3 -10
  261. package/ui/Icon.tsx +87 -0
  262. package/ui/KeyboardAvoiding.tsx +1 -1
  263. package/ui/Layout/Sider.tsx +3 -3
  264. package/ui/Link/Anchor.tsx +4 -1
  265. package/ui/Load/Edit.tsx +2 -2
  266. package/ui/Load/Edit_Client.tsx +3 -3
  267. package/ui/Load/Pagination.tsx +3 -3
  268. package/ui/Load/View.tsx +15 -14
  269. package/ui/Loading/ProgressBar.tsx +1 -1
  270. package/ui/Model/DraftBar.tsx +7 -14
  271. package/ui/Model/EditModal.tsx +9 -40
  272. package/ui/Model/EditWrapper.tsx +3 -5
  273. package/ui/Model/NewWrapper_Client.tsx +3 -7
  274. package/ui/Model/Remove.tsx +2 -2
  275. package/ui/Model/RemoveWrapper.tsx +2 -4
  276. package/ui/Model/SureToRemove.tsx +2 -2
  277. package/ui/Model/ViewEditModal.tsx +9 -20
  278. package/ui/Model/ViewModal.tsx +4 -11
  279. package/ui/Model/ViewWrapper.tsx +4 -6
  280. package/ui/ScreenNavigator.tsx +2 -2
  281. package/ui/Signal/WebSocket.tsx +2 -2
  282. package/ui/System/CSR.tsx +3 -3
  283. package/ui/System/Reconnect.tsx +2 -2
  284. package/ui/System/SSR.tsx +2 -2
  285. package/ui/System/SelectLanguage.tsx +1 -1
  286. package/ui/System/ThemeToggle.tsx +1 -1
  287. package/ui/Tab/Provider.tsx +1 -0
  288. package/ui/Toast.tsx +7 -15
  289. package/ui/index.ts +0 -1
  290. package/ui/sliceNamesOf.ts +25 -15
  291. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeActivity.kt +2 -1
  292. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeKernel.kt +10 -2
  293. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeVectors.kt +4 -0
  294. package/vendor/@akanjs/native/native/desktop/src/acl.rs +166 -0
  295. package/vendor/@akanjs/native/native/desktop/src/lib.rs +25 -6
  296. package/vendor/@akanjs/native/native/desktop/src/navigation.rs +22 -10
  297. package/vendor/@akanjs/native/native/desktop/src/routes.rs +2 -2
  298. package/vendor/@akanjs/native/native/desktop/src/vectors.rs +28 -0
  299. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeBridge.swift +1 -1
  300. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeKernel.swift +10 -2
  301. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeVectors.swift +4 -0
  302. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeViewController.swift +1 -0
  303. package/vendor/@akanjs/native/packages/cli/src/config.ts +10 -0
  304. package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +2 -0
  305. package/vendor/@akanjs/native/packages/cli/src/lib/csp.ts +5 -3
  306. package/vendor/@akanjs/native/packages/cli/src/lib/prepare.ts +4 -0
  307. package/vendor/@akanjs/native/packages/cli/src/lib/publish.ts +2 -0
  308. package/vendor/@akanjs/native/packages/cli/src/lib/updates.ts +30 -0
  309. package/vendor/@akanjs/native/packages/cli/src/lib/vectors.ts +1 -1
  310. package/vendor/@akanjs/native/packages/core/src/kernel.ts +18 -2
  311. package/vendor/@akanjs/native/packages/core/vectors/navigation.json +22 -1
  312. package/vendor/@akanjs/native/packages/core/vectors/routes.json +4 -0
  313. package/vendor/@akanjs/native/packages/desktop/src/main.ts +5 -1
  314. package/vendor/@akanjs/native/packages/desktop/src/paths.ts +24 -1
  315. package/vendor/@akanjs/native/plugins/external-view/src/desktop.ts +4 -5
  316. package/vendor/@akanjs/native/plugins/file-picker/src/desktop.ts +6 -8
  317. package/vendor/@akanjs/native/plugins/filesystem/src/desktop.ts +4 -2
  318. package/vendor/@akanjs/native/plugins/single-instance/src/socket.ts +22 -3
  319. package/vendor/@akanjs/native/plugins/updates/android/UpdatesPlugin.kt +6 -0
  320. package/vendor/@akanjs/native/plugins/updates/ios/UpdatesPlugin.swift +6 -0
  321. package/vendor/@akanjs/native/plugins/updates/src/desktop.ts +106 -18
  322. package/vendor/use-agentic/AgentSession.ts +7 -2
  323. package/vendor/use-agentic/ToolRunner.ts +44 -5
  324. package/vendor/use-agentic/types.ts +2 -0
  325. package/vendor/use-agentic/useAgentTool.ts +4 -2
  326. package/webkit/McpAppAgent.ts +2 -1
  327. package/webkit/bootCsr.tsx +1 -1
  328. package/webkit/useCsrValues.ts +5 -1
  329. package/webkit/usePageTool.tsx +1 -0
  330. package/store/databaseStateNames.ts +0 -24
  331. package/store/formSetterNames.ts +0 -18
  332. package/types/store/databaseStateNames.d.ts +0 -18
  333. package/types/store/formSetterNames.d.ts +0 -11
@@ -13,6 +13,7 @@ import {
13
13
  type AkanI18nConfig,
14
14
  DEFAULT_AKAN_I18N,
15
15
  getBasePathFromPathname,
16
+ LocalePath,
16
17
  Logger,
17
18
  parseSearchParams,
18
19
  } from "akanjs/common";
@@ -57,6 +58,7 @@ import {
57
58
  import { LogForwarder } from "./logging/logForwarder";
58
59
  import { ProcessMetricsCollector } from "./processMetricsCollector";
59
60
  import { RouteElementComposer, type RouteNotFoundInPlace } from "./routeElementComposer";
61
+ import { RouteShellGate } from "./routeShellGate";
60
62
  import {
61
63
  type AkanRscPatchDecision,
62
64
  createAkanRouterState,
@@ -69,7 +71,7 @@ import {
69
71
  resolveAkanRscPatchDecision,
70
72
  } from "./routeState";
71
73
  import { type PagesContext, RouteTreeBuilder } from "./routeTreeBuilder";
72
- import { encodeAkanRedirectDigest } from "./rscHttp";
74
+ import { concatBytes, encodeAkanRedirectDigest } from "./rscHttp";
73
75
  import { RscPagePrompts } from "./rscPagePrompts";
74
76
  import {
75
77
  type CachedRscResult,
@@ -623,6 +625,7 @@ export class RscRenderer {
623
625
  let effectivePatchDecision = safePatchDecision;
624
626
  const notFoundProbe = { hit: false };
625
627
  const notFound = this.#notFoundInPlace(urlObj, notFoundProbe);
628
+ const gate = new RouteShellGate();
626
629
  if (match && safePatchDecision.status === "patch" && safePatchDecision.patch) {
627
630
  const suffixElement = await this.#renderMatchedSuffix(
628
631
  urlObj,
@@ -638,11 +641,11 @@ export class RscRenderer {
638
641
  reason: "suffix-compose-fallback",
639
642
  commonPrefixLength: safePatchDecision.commonPrefixLength,
640
643
  };
641
- element = await this.#renderMatched(urlObj, match, searchParams, notFound, { guarded: true });
644
+ element = await this.#renderMatched(urlObj, match, searchParams, notFound, { guarded: true, gate });
642
645
  } else element = suffixElement;
643
646
  } else if (match)
644
- element = await this.#renderMatched(urlObj, match, searchParams, notFound, { guarded: admitted });
645
- else element = await this.#renderNotFound(urlObj);
647
+ element = await this.#renderMatched(urlObj, match, searchParams, notFound, { guarded: admitted, gate });
648
+ else element = await this.#renderNotFound(urlObj, gate);
646
649
  const traceCacheKey =
647
650
  effectivePatchDecision.status === "patch" ? (patchCacheEntry?.key ?? cacheEntry?.key) : cacheEntry?.key;
648
651
  const ssrBlocking = getRequestFrameState<PageState>()?.ssr === "block";
@@ -662,6 +665,7 @@ export class RscRenderer {
662
665
  status: match ? undefined : 404,
663
666
  trace,
664
667
  notFoundProbe,
668
+ gate,
665
669
  onComplete: ({ chunks, bytes, chunksCount, control, lateControlSent }) => {
666
670
  const cacheState = shouldStoreRouteCache({
667
671
  policy: getRequestPolicy(),
@@ -894,6 +898,7 @@ export class RscRenderer {
894
898
  trace?: RscTraceMetadata;
895
899
  /** Raised by a render that answered `router.notFound()` in place, which React never reports as an error. */
896
900
  notFoundProbe?: { hit: boolean };
901
+ gate?: RouteShellGate;
897
902
  onComplete?: (
898
903
  result: Omit<FlightRenderResult, "cancelled">,
899
904
  ) => Promise<RouteCacheRenderState> | RouteCacheRenderState;
@@ -927,6 +932,9 @@ export class RscRenderer {
927
932
  let sentChunk = false;
928
933
  let lateControlSent = false;
929
934
  const chunks: Uint8Array[] = [];
935
+
936
+ let holding = options.gate?.holding ? options.gate : null;
937
+ let held: Uint8Array | null = null;
930
938
  let reportedTheme: AkanTheme | undefined;
931
939
  const sendMeta = () => {
932
940
  if (!options.requestId || sentMeta) return;
@@ -959,6 +967,24 @@ export class RscRenderer {
959
967
  const readProbe = () => {
960
968
  if (!controlRef.current && options.notFoundProbe?.hit) controlRef.current = { type: "not-found" };
961
969
  };
970
+ const emit = (chunk: Uint8Array) => {
971
+ if (options.collectChunks) chunks.push(chunk);
972
+ if (!options.requestId) return;
973
+ sendMeta();
974
+ sendLateTheme();
975
+ this.#send({ type: "chunk", requestId: options.requestId, data: chunk });
976
+ sentChunk = true;
977
+ };
978
+ const releaseHeld = () => {
979
+ holding = null;
980
+ if (!held) return;
981
+ if (controlRef.current) {
982
+ sendMeta();
983
+ sendLateControl();
984
+ }
985
+ emit(held);
986
+ held = null;
987
+ };
962
988
 
963
989
  const sendLateControl = () => {
964
990
  const control = controlRef.current;
@@ -981,16 +1007,26 @@ export class RscRenderer {
981
1007
  this.#send({ type: "late-not-found", requestId: options.requestId });
982
1008
  };
983
1009
  try {
1010
+ let pendingRead: ReturnType<typeof reader.read> | null = null;
984
1011
  for (;;) {
985
1012
  if (options.requestId && this.#cancelledRenderRequests.has(options.requestId))
986
1013
  return await cancelWith(null, true);
987
- const { value, done } = await reader.read();
1014
+ pendingRead ??= reader.read();
1015
+ if (holding && (await Promise.race([pendingRead.then(() => false), holding.opened.then(() => true)]))) {
1016
+ readProbe();
1017
+ if (controlRef.current?.type === "error") return await cancelWith(controlRef.current, false);
1018
+ releaseHeld();
1019
+ continue;
1020
+ }
1021
+ const { value, done } = await pendingRead;
1022
+ pendingRead = null;
988
1023
  readProbe();
989
- if (controlRef.current && !sentChunk) return await cancelWith(controlRef.current, false);
1024
+ if (controlRef.current && !sentChunk && (!holding || controlRef.current.type === "error"))
1025
+ return await cancelWith(controlRef.current, false);
990
1026
  if (controlRef.current && sentChunk) sendLateControl();
991
1027
  if (done) break;
992
1028
 
993
- if (!sentChunk && options.requestId) {
1029
+ if (chunksCount === 0 && options.requestId) {
994
1030
  await new Promise((resolve) => setTimeout(resolve, 0));
995
1031
  readProbe();
996
1032
  if (controlRef.current) return await cancelWith(controlRef.current, false);
@@ -998,13 +1034,8 @@ export class RscRenderer {
998
1034
  const chunk = value instanceof Uint8Array ? value : new Uint8Array(value as ArrayBufferLike);
999
1035
  bytes += chunk.byteLength;
1000
1036
  chunksCount += 1;
1001
- if (options.collectChunks) chunks.push(chunk);
1002
- if (options.requestId) {
1003
- sendMeta();
1004
- sendLateTheme();
1005
- this.#send({ type: "chunk", requestId: options.requestId, data: chunk });
1006
- sentChunk = true;
1007
- }
1037
+ if (holding) held = held ? concatBytes(held, chunk) : chunk;
1038
+ else emit(chunk);
1008
1039
  }
1009
1040
  } catch (error) {
1010
1041
  if (options.requestId && this.#cancelledRenderRequests.has(options.requestId)) {
@@ -1016,6 +1047,7 @@ export class RscRenderer {
1016
1047
  reader.releaseLock();
1017
1048
  }
1018
1049
  readProbe();
1050
+ if (holding && controlRef.current?.type !== "error") releaseHeld();
1019
1051
  if (controlRef.current && sentChunk) sendLateControl();
1020
1052
  if (controlRef.current && !sentChunk)
1021
1053
  return { chunks, bytes, chunksCount, control: controlRef.current, lateControlSent, cancelled: false };
@@ -1065,6 +1097,7 @@ export class RscRenderer {
1065
1097
  trace?: RscTraceMetadata;
1066
1098
  }): Promise<boolean> {
1067
1099
  try {
1100
+ const gate = new RouteShellGate();
1068
1101
  const element = await this.#renderFallbackDocument({
1069
1102
  kind,
1070
1103
  route,
@@ -1074,9 +1107,17 @@ export class RscRenderer {
1074
1107
  url,
1075
1108
  error: kind === "error" && process.env.NODE_ENV !== "production" ? error : undefined,
1076
1109
  digest: kind === "error" ? "AKAN_RENDER_ERROR" : undefined,
1110
+ gate,
1077
1111
  });
1078
1112
  if (!element) return false;
1079
- return await this.#trySendFlight(requestId, element, clientManifest, kind === "not-found" ? 404 : 500, trace);
1113
+ return await this.#trySendFlight(
1114
+ requestId,
1115
+ element,
1116
+ clientManifest,
1117
+ kind === "not-found" ? 404 : 500,
1118
+ trace,
1119
+ gate,
1120
+ );
1080
1121
  } catch (fallbackError) {
1081
1122
  this.#logger.error(
1082
1123
  `render[${requestId}] custom ${kind} fallback failed: ${
@@ -1116,8 +1157,9 @@ export class RscRenderer {
1116
1157
  clientManifest: ClientManifest,
1117
1158
  status: number,
1118
1159
  trace?: RscTraceMetadata,
1160
+ gate?: RouteShellGate,
1119
1161
  ): Promise<boolean> {
1120
- const result = await this.#renderFlightElement(element, clientManifest, { requestId, status, trace });
1162
+ const result = await this.#renderFlightElement(element, clientManifest, { requestId, status, trace, gate });
1121
1163
  if (result.cancelled) return true;
1122
1164
  if (result.control) return false;
1123
1165
  this.#recordFlight(result);
@@ -1275,6 +1317,7 @@ export class RscRenderer {
1275
1317
  url,
1276
1318
  error,
1277
1319
  digest,
1320
+ gate,
1278
1321
  }: {
1279
1322
  kind: "not-found" | "error";
1280
1323
  route: PathRoute | LayoutFallbackRoute;
@@ -1284,6 +1327,7 @@ export class RscRenderer {
1284
1327
  url: URL;
1285
1328
  error?: unknown;
1286
1329
  digest?: string;
1330
+ gate?: RouteShellGate;
1287
1331
  }): Promise<ReactNode | null> {
1288
1332
  setRequestFrameState(
1289
1333
  await RouteElementComposer.resolveSsrFallbackFrameState({
@@ -1299,6 +1343,7 @@ export class RscRenderer {
1299
1343
  pathname,
1300
1344
  error,
1301
1345
  digest,
1346
+ gate,
1302
1347
  });
1303
1348
  if (!body) return null;
1304
1349
  const viewport = await RscRenderer.#firstRootValue(route.renderRootLayouts, (render) =>
@@ -1322,7 +1367,10 @@ export class RscRenderer {
1322
1367
  {routeHeadSnapshot ? null : this.#renderLocaleAlternates(url)}
1323
1368
  {this.#renderStylesheet(pathname)}
1324
1369
  </head>
1325
- <body key="body">{body}</body>
1370
+ <body key="body">
1371
+ {body}
1372
+ {gate?.holding ? <RouteShellGate.Wait until={gate.opened} /> : null}
1373
+ </body>
1326
1374
  </html>
1327
1375
  );
1328
1376
  }
@@ -1359,7 +1407,7 @@ export class RscRenderer {
1359
1407
  match: { pathRoute: PathRoute; params: Record<string, string> },
1360
1408
  searchParams: Record<string, string | string[]>,
1361
1409
  notFound: RouteNotFoundInPlace,
1362
- { guarded = false }: { guarded?: boolean } = {},
1410
+ { guarded = false, gate }: { guarded?: boolean; gate?: RouteShellGate } = {},
1363
1411
  ): Promise<ReactNode> {
1364
1412
  this.#logger.verbose(
1365
1413
  `composing route element pathname=${url.pathname} search=${url.search || "(none)"} params=${JSON.stringify(match.params)}`,
@@ -1390,6 +1438,7 @@ export class RscRenderer {
1390
1438
  searchParams,
1391
1439
  navKey: url.pathname + url.search,
1392
1440
  notFound,
1441
+ gate,
1393
1442
  });
1394
1443
 
1395
1444
  return (
@@ -1406,7 +1455,10 @@ export class RscRenderer {
1406
1455
  {routeHeadSnapshot ? null : this.#renderLocaleAlternates(url)}
1407
1456
  {this.#renderStylesheet(url.pathname)}
1408
1457
  </head>
1409
- <body key="body">{body}</body>
1458
+ <body key="body">
1459
+ {body}
1460
+ {gate?.holding ? <RouteShellGate.Wait until={gate.opened} /> : null}
1461
+ </body>
1410
1462
  </html>
1411
1463
  );
1412
1464
  }
@@ -1439,7 +1491,7 @@ export class RscRenderer {
1439
1491
  });
1440
1492
  }
1441
1493
 
1442
- async #renderNotFound(url: URL): Promise<ReactNode> {
1494
+ async #renderNotFound(url: URL, gate?: RouteShellGate): Promise<ReactNode> {
1443
1495
  const matchedFallback = RouteTreeBuilder.matchFallback(url.pathname, this.#fallbackRoutes);
1444
1496
  if (matchedFallback) {
1445
1497
  const searchParams = parseSearchParams(url.search);
@@ -1455,6 +1507,7 @@ export class RscRenderer {
1455
1507
  searchParams,
1456
1508
  pathname: url.pathname,
1457
1509
  url,
1510
+ gate,
1458
1511
  });
1459
1512
  if (fallback) return fallback;
1460
1513
  } catch (error) {
@@ -1562,7 +1615,7 @@ export class RscRenderer {
1562
1615
  const publicUrl = RscRenderer.#getPublicRequestUrl(url);
1563
1616
  for (const lang of this.#i18n.locales) {
1564
1617
  const alternateUrl = new URL(publicUrl);
1565
- alternateUrl.pathname = RscRenderer.#replaceLocalePathSegment(publicUrl.pathname, lang);
1618
+ alternateUrl.pathname = RscRenderer.#replaceLocalePathSegment(publicUrl.pathname, lang, this.#i18n);
1566
1619
  languages[lang] = alternateUrl.href;
1567
1620
  }
1568
1621
  const xDefaultUrl = new URL(publicUrl);
@@ -1612,10 +1665,9 @@ export class RscRenderer {
1612
1665
  return publicUrl;
1613
1666
  }
1614
1667
 
1615
- static #replaceLocalePathSegment(pathname: string, lang: string): string {
1668
+ static #replaceLocalePathSegment(pathname: string, lang: string, i18n: AkanI18nConfig): string {
1616
1669
  const parts = pathname.split("/").filter(Boolean);
1617
- if (parts.length === 0) return `/${lang}`;
1618
- return `/${[lang, ...parts.slice(1)].join("/")}`;
1670
+ return LocalePath.join(lang, `/${parts.slice(1).join("/")}`, i18n);
1619
1671
  }
1620
1672
  }
1621
1673
 
package/server/sitemap.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type AkanI18nConfig, trimSlashes, trimTrailingSlashes } from "akanjs/common";
1
+ import { type AkanI18nConfig, LocalePath, trimSlashes, trimTrailingSlashes } from "akanjs/common";
2
2
  import type { RouteSeedEntry } from "./artifact";
3
3
 
4
4
  export interface DefaultSitemapOptions {
@@ -26,7 +26,7 @@ export function createDefaultSitemapXml({ origin, basePath, entries, i18n }: Def
26
26
  export function createDefaultSitemapUrls({ origin, basePath, entries, i18n }: DefaultSitemapOptions): string[] {
27
27
  const normalizedOrigin = trimTrailingSlashes(origin);
28
28
  const normalizedBasePath = normalizeSegment(basePath ?? "");
29
- const paths = entries.flatMap((entry) => expandStaticRoute(entry.pattern, normalizedBasePath, i18n.locales));
29
+ const paths = entries.flatMap((entry) => expandStaticRoute(entry.pattern, normalizedBasePath, i18n));
30
30
  return paths.map((pathname) => `${normalizedOrigin}${pathname}`);
31
31
  }
32
32
 
@@ -46,7 +46,7 @@ export function getSitemapBasePath(
46
46
  return basePaths.map(normalizeSegment).includes(normalizedHeaderBasePath) ? normalizedHeaderBasePath : undefined;
47
47
  }
48
48
 
49
- function expandStaticRoute(pattern: string, basePath: string, locales: readonly string[]): string[] {
49
+ function expandStaticRoute(pattern: string, basePath: string, i18n: AkanI18nConfig): string[] {
50
50
  const segments = pattern.split("/").filter(Boolean);
51
51
  const langIndex = segments.indexOf(":lang");
52
52
  if (langIndex < 0) return [];
@@ -59,10 +59,8 @@ function expandStaticRoute(pattern: string, basePath: string, locales: readonly
59
59
  staticSegments = staticSegments.slice(1);
60
60
  }
61
61
 
62
- return locales.map((locale) => {
63
- const parts = [locale, ...staticSegments].filter(Boolean);
64
- return `/${parts.map(encodePathSegment).join("/")}`;
65
- });
62
+ const path = `/${staticSegments.filter(Boolean).map(encodePathSegment).join("/")}`;
63
+ return i18n.locales.map((locale) => LocalePath.join(locale, path, i18n));
66
64
  }
67
65
 
68
66
  function normalizePathname(pathname: string): string {
@@ -608,7 +608,14 @@ export class SsrFromRscRenderer {
608
608
  inlineBoundaries: input.inlineBoundaries,
609
609
  onError: (error, phase) => SsrFromRscRenderer.#reportRenderError(error, input, phase),
610
610
  });
611
- const htmlStream = await inRequestScope(renderHtml);
611
+ let htmlStream: ReadableStream<Uint8Array>;
612
+ try {
613
+ htmlStream = await inRequestScope(renderHtml);
614
+ } catch (error) {
615
+ stderrSuppressor?.stop();
616
+ void rscForClient.cancel(error).catch(() => {});
617
+ throw error;
618
+ }
612
619
 
613
620
  const withHeadScripts = SsrFromRscRenderer.#injectHeadScriptsIntoHead(htmlStream, {
614
621
  importmap: input.importmap,
@@ -638,7 +645,7 @@ export class SsrFromRscRenderer {
638
645
  const registry = new SsrChunkRegistry<Record<string, unknown>>(
639
646
  parsePositiveInt(process.env.AKAN_SSR_CHUNK_REGISTRY_MAX_ENTRIES) ?? DEFAULT_SSR_CHUNK_REGISTRY_MAX_ENTRIES,
640
647
  );
641
- g.__webpack_chunk_load__ = async (chunkId: string) => {
648
+ const loadChunk = async (chunkId: string) => {
642
649
  if (registry.get(chunkId)) {
643
650
  SsrFromRscRenderer.#chunkRegistryStats.ssrChunkCacheHitCount += 1;
644
651
  return;
@@ -651,6 +658,14 @@ export class SsrFromRscRenderer {
651
658
  SsrFromRscRenderer.#chunkRegistryStats.ssrChunkRegistrySize = registry.size;
652
659
  SsrFromRscRenderer.#chunkRegistryStats.ssrChunkEvictionCount = registry.evictionCount;
653
660
  };
661
+ g.__webpack_chunk_load__ = (chunkId: string) => {
662
+ const load = loadChunk(chunkId);
663
+ const loading = SsrFromRscRenderer.#chunkLoads();
664
+ const settle = () => loading.delete(load);
665
+ loading.add(load);
666
+ load.then(settle, settle);
667
+ return load;
668
+ };
654
669
  g.__webpack_require__ = (id: string) => {
655
670
  const mod = registry.get(id);
656
671
  if (!mod) throw new Error(`[ssrFromRsc] module not loaded yet: ${id}`);
@@ -662,14 +677,31 @@ export class SsrFromRscRenderer {
662
677
  const stream = await renderToReadableStream(root, {
663
678
  bootstrapScriptContent: options.bootstrap,
664
679
 
665
- ...(options.inlineBoundaries ? { progressiveChunkSize: Number.POSITIVE_INFINITY } : {}),
680
+ progressiveChunkSize: Number.POSITIVE_INFINITY,
666
681
  onError: (error) => options.onError(error),
667
682
  });
668
683
  if (options.waitForAllReady || options.inlineBoundaries) await stream.allReady;
669
- else SsrFromRscRenderer.holdPostShellErrors(stream, (error) => options.onError(error, "post-shell"));
684
+ else {
685
+ SsrFromRscRenderer.holdPostShellErrors(stream, (error) => options.onError(error, "post-shell"));
686
+ await SsrFromRscRenderer.#settleChunkLoads();
687
+ }
670
688
  return stream;
671
689
  }
672
690
 
691
+ static async #settleChunkLoads(): Promise<void> {
692
+ const loading = SsrFromRscRenderer.#chunkLoads();
693
+ for (let round = 0; round < 4 && loading.size > 0; round++) {
694
+ await Promise.allSettled(loading);
695
+ for (let turn = 0; turn < 2; turn++) await new Promise((resolve) => setTimeout(resolve, 0));
696
+ }
697
+ }
698
+
699
+ static #chunkLoads(): Set<Promise<void>> {
700
+ const g = globalThis as unknown as { __rsc_ssr_chunk_loads__?: Set<Promise<void>> };
701
+ g.__rsc_ssr_chunk_loads__ ??= new Set();
702
+ return g.__rsc_ssr_chunk_loads__;
703
+ }
704
+
673
705
  static holdPostShellErrors(stream: { allReady: Promise<void> }, report: (error: unknown) => void): void {
674
706
  void stream.allReady.catch(report);
675
707
  }
@@ -63,9 +63,12 @@ export interface SsrFromRscInput {
63
63
  injectThemeInitScript?: boolean;
64
64
  lateControl?: Promise<SsrLateControl | null>;
65
65
  onCancel?: (reason?: unknown) => void;
66
- /** Buffers until `stream.allReady` (`pageConfig.ssr: "block"`); `AKAN_SSR_WAIT_FOR_ALL_READY=1` forces it globally. */
66
+ /**
67
+ * Buffers until `stream.allReady` and writes every Suspense boundary in place (`pageConfig.ssr: "block"`);
68
+ * `AKAN_SSR_WAIT_FOR_ALL_READY=1` forces it globally.
69
+ */
67
70
  waitForAllReady?: boolean;
68
- /** Buffers like `waitForAllReady` and writes every Suspense boundary in place, for a reader that runs no script. */
71
+ /** Buffers like `waitForAllReady`, for a reader that runs no script. */
69
72
  inlineBoundaries?: boolean;
70
73
  }
71
74
 
@@ -1,5 +1,40 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
 
4
+ const isWithin = (child: string, parent: string) =>
5
+ child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
6
+
7
+ const realPathOf = (target: string): string | null => {
8
+ try {
9
+ return fs.realpathSync(target);
10
+ } catch {
11
+ return null;
12
+ }
13
+ };
14
+
15
+ const libLinkTarget = (baseDir: string, filePath: string): string | null => {
16
+ const [libs, lib] = path.relative(baseDir, filePath).split(path.sep);
17
+ if (libs !== "libs" || !lib) return null;
18
+ const target = realPathOf(path.join(baseDir, libs, lib));
19
+ if (!target || path.basename(target) !== path.basename(baseDir)) return null;
20
+ const libDir = path.dirname(target);
21
+ return path.basename(libDir) === lib && path.basename(path.dirname(libDir)) === "libs" ? target : null;
22
+ };
23
+
24
+ export const staysInTree = (baseDir: string, filePath: string): boolean => {
25
+ let real: string;
26
+ try {
27
+ real = fs.realpathSync(filePath);
28
+ } catch (error) {
29
+ const code = (error as NodeJS.ErrnoException).code;
30
+ return code === "ENOENT" || code === "ENOTDIR";
31
+ }
32
+ const base = path.resolve(baseDir);
33
+ if (isWithin(real, realPathOf(base) ?? base)) return true;
34
+ const libTarget = libLinkTarget(base, filePath);
35
+ return !!libTarget && isWithin(real, libTarget);
36
+ };
37
+
3
38
  export const resolveStaticPath = (baseDir: string, urlPath: string): string | null => {
4
39
  let decoded: string;
5
40
  try {
@@ -14,5 +49,5 @@ export const resolveStaticPath = (baseDir: string, urlPath: string): string | nu
14
49
  if (resolved === normalizedBase) return resolved;
15
50
  const baseWithSep = normalizedBase.endsWith(path.sep) ? normalizedBase : normalizedBase + path.sep;
16
51
  if (!resolved.startsWith(baseWithSep)) return null;
17
- return resolved;
52
+ return staysInTree(normalizedBase, resolved) ? resolved : null;
18
53
  };
@@ -1,5 +1,5 @@
1
1
  import type { AkanI18nConfig } from "akanjs/common";
2
- import { DEFAULT_AKAN_I18N, getBasePathFromPathname } from "akanjs/common";
2
+ import { DEFAULT_AKAN_I18N, getBasePathFromPathname, LocalePath } from "akanjs/common";
3
3
  import type { ReactNode } from "react";
4
4
 
5
5
  export type SystemPageKind = "not-found" | "error";
@@ -80,7 +80,7 @@ export const SystemPageMain = (options: Omit<SystemPageOptions, "lang" | "styles
80
80
  <a className="akan-system-action" href={options.homeHref}>
81
81
  {copy.actionLabel}
82
82
  </a>
83
- <a className="akan-system-action akan-system-secondary" href={options.pathname}>
83
+ <a className="akan-system-action akan-system-secondary" href={getSameOriginHref(options.pathname)}>
84
84
  Try again
85
85
  </a>
86
86
  </div>
@@ -122,6 +122,8 @@ export function createSystemPageDocument(options: SystemPageOptions): ReactNode
122
122
  );
123
123
  }
124
124
 
125
+ export const getSameOriginHref = (pathname: string): string => `/${pathname.replace(/^[\s/\\]+/, "")}`;
126
+
125
127
  export function getSystemPageErrorDetails(error: unknown): string {
126
128
  if (error instanceof Error) return error.stack ?? error.message;
127
129
  return String(error);
@@ -144,7 +146,7 @@ export function getSystemPageHomeHref({
144
146
  i18n,
145
147
  headerBasePath,
146
148
  });
147
- return `/${[locale, basePath].filter(Boolean).join("/")}`;
149
+ return LocalePath.join(locale, basePath ? `/${basePath}` : "/", i18n);
148
150
  }
149
151
 
150
152
  export interface SubRouteIndexOptions {
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import { applySecurityHeaders } from "./assetHeaders";
2
3
  import {
3
4
  createSubRouteIndexDocument,
4
5
  createSystemPageDocument,
@@ -38,10 +39,13 @@ export async function createSubRouteIndexResponse(options: SubRouteIndexResponse
38
39
  }
39
40
 
40
41
  export function createSystemPageHeaders(): Headers {
41
- return new Headers({
42
- "Content-Type": "text/html; charset=utf-8",
43
- "Cache-Control": "no-store",
44
- });
42
+ return applySecurityHeaders(
43
+ new Headers({
44
+ "Content-Type": "text/html; charset=utf-8",
45
+ "Cache-Control": "no-store",
46
+ }),
47
+ { html: true },
48
+ );
45
49
  }
46
50
 
47
51
  export function createSystemPageFallbackText(kind: SystemPageKind): string {
@@ -70,10 +74,12 @@ async function renderDocumentResponse({
70
74
  } catch {
71
75
  return new Response(fallbackText(), {
72
76
  status,
73
- headers: {
74
- "Content-Type": "text/plain; charset=utf-8",
75
- "Cache-Control": "no-store",
76
- },
77
+ headers: applySecurityHeaders(
78
+ new Headers({
79
+ "Content-Type": "text/plain; charset=utf-8",
80
+ "Cache-Control": "no-store",
81
+ }),
82
+ ),
77
83
  });
78
84
  }
79
85
  }
@@ -10,12 +10,18 @@ import { TunnelDataSocket } from "./TunnelDataSocket";
10
10
  export interface TunnelAgentOptions {
11
11
  /** The gateway, as `wss://tunnel.akanjs.com` — scheme included, no path, no trailing slash. */
12
12
  gatewayUrl: string;
13
+ /** Accepts a `ws://` gateway off loopback, whose frames and token would cross the network in cleartext. */
14
+ allowInsecureGateway?: boolean;
13
15
  token: string;
14
16
  hostnames: string[];
15
17
  /** The local app, as `http://localhost:8282`. */
16
18
  origin: string;
17
19
  /** Where a `tcp` stream dials. Local by construction: an agent is a door into one machine, not a router. */
18
20
  tcpHost?: string;
21
+ /** The only ports a `tcp` stream may dial; none by default, so the gateway can reach nothing but `origin`. */
22
+ tcpPorts?: readonly number[];
23
+ /** The data sockets this agent ever holds, whatever the gateway asks for (default 128). */
24
+ maxSockets?: number;
19
25
  name?: string;
20
26
  /** Attempts on a link that has never gone ready before the agent gives up (default 10). */
21
27
  retryLimit?: number;
@@ -55,9 +61,18 @@ export class TunnelAgent {
55
61
  #stopped = false;
56
62
 
57
63
  constructor(options: TunnelAgentOptions) {
64
+ TunnelAgent.#assertGateway(options.gatewayUrl, options.allowInsecureGateway ?? false);
58
65
  this.#options = options;
59
66
  }
60
67
 
68
+ static #assertGateway(gatewayUrl: string, allowInsecure: boolean) {
69
+ const url = URL.parse(gatewayUrl);
70
+ if (url?.protocol === "wss:") return;
71
+ if (url?.protocol === "ws:" && (allowInsecure || ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
72
+ return;
73
+ throw new Error(`Tunnel gateway must be a wss:// URL (ws:// only on loopback), not ${gatewayUrl}`);
74
+ }
75
+
61
76
  get ready() {
62
77
  return this.#ready;
63
78
  }
@@ -220,18 +235,20 @@ export class TunnelAgent {
220
235
  const ready = this.#ready;
221
236
  if (!ready || this.#stopped) return;
222
237
  const idle = [...this.#sockets].filter((socket) => !socket.busy).length;
223
- const room = Math.min(ready.idle - idle, ready.maxSockets - this.#sockets.size);
238
+ const ceiling = Math.min(ready.maxSockets, this.#options.maxSockets ?? tunnelWireContract.defaultMaxSockets);
239
+ const room = Math.min(ready.idle - idle, ceiling - this.#sockets.size);
224
240
  for (let opened = 0; opened < room; opened += 1) this.#open(ready.sessionId);
225
241
  }
226
242
 
227
243
  #open(sessionId: string) {
228
- const { gatewayUrl, token, origin, tcpHost = "127.0.0.1" } = this.#options;
244
+ const { gatewayUrl, token, origin, tcpHost = "127.0.0.1", tcpPorts = [] } = this.#options;
229
245
  const socket = new TunnelDataSocket({
230
246
  gatewayUrl,
231
247
  token,
232
248
  sessionId,
233
249
  origin,
234
250
  tcpHost,
251
+ tcpPorts,
235
252
  logger: this.logger,
236
253
  onIdle: () => this.#grow(),
237
254
  onBusy: () => this.#grow(),
@@ -12,6 +12,8 @@ export interface TunnelDataSocketOptions {
12
12
  origin: string;
13
13
  /** Where a `tcp` stream dials; the port comes from the open frame. */
14
14
  tcpHost: string;
15
+ /** The ports an open frame may name; any other is refused. */
16
+ tcpPorts: readonly number[];
15
17
  logger: Logger;
16
18
  onIdle: (socket: TunnelDataSocket) => void;
17
19
  onBusy: (socket: TunnelDataSocket) => void;
@@ -114,7 +116,7 @@ export class TunnelDataSocket {
114
116
  }
115
117
 
116
118
  #start(open: TunnelOpenFrame) {
117
- const { origin, tcpHost } = this.#options;
119
+ const { origin, tcpHost, tcpPorts } = this.#options;
118
120
  const link: TunnelStreamLink = {
119
121
  sendFrame: (frame) => this.#send(frame),
120
122
  sendPayload: (payload) => this.#sendPayload(payload),
@@ -125,7 +127,7 @@ export class TunnelDataSocket {
125
127
  open.kind === "websocket"
126
128
  ? new TunnelWebsocketStream(open, link, origin)
127
129
  : open.kind === "tcp"
128
- ? new TunnelTcpStream(open, link, tcpHost)
130
+ ? new TunnelTcpStream(open, link, tcpHost, tcpPorts)
129
131
  : new TunnelHttpStream(open, link, origin);
130
132
  this.#options.onBusy(this);
131
133
  void this.#stream.run();
@@ -1,5 +1,5 @@
1
1
  import { type TunnelOpenFrame, tunnelWireContract } from "akanjs/common";
2
- import { type TunnelStream, type TunnelStreamLink, tunnelResetCodeOf } from "./tunnelStream";
2
+ import { type TunnelStream, type TunnelStreamLink, tunnelResetCodeOf, tunnelTargetUrl } from "./tunnelStream";
3
3
 
4
4
  const reframedHeaders = new Set(["content-encoding", "content-length"]);
5
5
 
@@ -26,8 +26,13 @@ export class TunnelHttpStream implements TunnelStream {
26
26
 
27
27
  async run() {
28
28
  const { streamId, method = "GET", path = "/", headers = [] } = this.#open;
29
+ const target = tunnelTargetUrl(this.#origin, path);
30
+ if (!target) {
31
+ this.#link.sendFrame({ type: "reset", streamId, code: "protocol", message: "path leaves the shared origin" });
32
+ return;
33
+ }
29
34
  try {
30
- const response = await fetch(`${this.#origin}${path}`, {
35
+ const response = await fetch(target, {
31
36
  method,
32
37
  headers: tunnelWireContract.headersOf(headers),
33
38
  body: this.#bodyStream,