@abide/abide 0.46.0 → 0.47.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 (185) hide show
  1. package/AGENTS.md +365 -320
  2. package/CHANGELOG.md +80 -0
  3. package/README.md +203 -168
  4. package/package.json +12 -8
  5. package/src/abideLsp.ts +11 -12
  6. package/src/abideResolverPlugin.ts +9 -4
  7. package/src/lib/bundle/disconnected.abide +3 -0
  8. package/src/lib/cli/printCommandHelp.ts +43 -0
  9. package/src/lib/cli/printSessionHelp.ts +2 -1
  10. package/src/lib/cli/{printHelp.ts → printTopLevelHelp.ts} +3 -40
  11. package/src/lib/cli/runCli.ts +2 -1
  12. package/src/lib/mcp/buildPrompts.ts +25 -0
  13. package/src/lib/mcp/dispatchMcpRequest.ts +8 -6
  14. package/src/lib/mcp/mcpResourceServerSlot.ts +5 -10
  15. package/src/lib/mcp/mcpSurface.ts +13 -252
  16. package/src/lib/mcp/mcpTools.ts +187 -0
  17. package/src/lib/mcp/renderPrompt.ts +25 -0
  18. package/src/lib/mcp/types/McpSurface.ts +15 -0
  19. package/src/lib/mcp/types/PromptDescriptor.ts +5 -0
  20. package/src/lib/mcp/types/PromptMessage.ts +2 -0
  21. package/src/lib/mcp/types/ToolDescriptor.ts +7 -0
  22. package/src/lib/mcp/types/ToolResult.ts +2 -0
  23. package/src/lib/server/agent.ts +1 -1
  24. package/src/lib/server/jsonl.ts +2 -2
  25. package/src/lib/server/rpc/defineRpc.ts +5 -0
  26. package/src/lib/server/runtime/containsTraversal.ts +21 -17
  27. package/src/lib/server/runtime/createAppAssetServer.ts +11 -16
  28. package/src/lib/server/runtime/createServer.ts +101 -71
  29. package/src/lib/server/runtime/createUiPageRenderer.ts +23 -4
  30. package/src/lib/server/runtime/devClientFingerprint.ts +3 -3
  31. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -1
  32. package/src/lib/server/runtime/finalizeResponse.ts +32 -0
  33. package/src/lib/server/runtime/snapshotEntryFromCache.ts +8 -17
  34. package/src/lib/server/socket.ts +1 -1
  35. package/src/lib/server/sockets/createSocketDispatcher.ts +28 -5
  36. package/src/lib/server/sockets/defineSocket.ts +26 -7
  37. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +1 -1
  38. package/src/lib/server/sockets/types/SocketRoutes.ts +1 -1
  39. package/src/lib/server/sse.ts +2 -2
  40. package/src/lib/shared/DEFER.ts +8 -0
  41. package/src/lib/shared/activeCacheStore.ts +2 -2
  42. package/src/lib/shared/activePage.ts +2 -2
  43. package/src/lib/shared/attachRpcSelectorMethods.ts +42 -0
  44. package/src/lib/shared/attachSocketSelectorMethods.ts +34 -0
  45. package/src/lib/shared/basePath.ts +2 -2
  46. package/src/lib/shared/baseSlot.ts +6 -5
  47. package/src/lib/shared/bodyValueForKind.ts +1 -2
  48. package/src/lib/shared/buildSocketOverChannel.ts +40 -4
  49. package/src/lib/shared/cache.ts +477 -110
  50. package/src/lib/shared/cacheEntryFromSnapshot.ts +3 -22
  51. package/src/lib/shared/cacheStoreSlot.ts +9 -5
  52. package/src/lib/{server/runtime → shared}/createReachable.ts +2 -2
  53. package/src/lib/shared/createRemoteFunction.ts +58 -9
  54. package/src/lib/shared/createResolverSlot.ts +24 -31
  55. package/src/lib/shared/createSocketSubRegistry.ts +2 -2
  56. package/src/lib/shared/decodeRefJson.ts +4 -2
  57. package/src/lib/shared/decodeResponse.ts +8 -6
  58. package/src/lib/shared/done.ts +14 -0
  59. package/src/lib/shared/encodeRefJson.ts +4 -2
  60. package/src/lib/shared/globalCacheStoreSlot.ts +9 -6
  61. package/src/lib/shared/hydratingSlot.ts +12 -0
  62. package/src/lib/shared/isLayoutFile.ts +12 -0
  63. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  64. package/src/lib/shared/keyPrefixForRemote.ts +12 -0
  65. package/src/lib/shared/matchRoute.ts +175 -0
  66. package/src/lib/shared/normalizePathname.ts +11 -0
  67. package/src/lib/shared/pageSlot.ts +14 -5
  68. package/src/lib/shared/pageUrlForFile.ts +3 -3
  69. package/src/lib/shared/parseRouteSegments.ts +16 -7
  70. package/src/lib/shared/patch.ts +41 -0
  71. package/src/lib/shared/peek.ts +35 -0
  72. package/src/lib/shared/prepareRemoteExport.ts +40 -0
  73. package/src/lib/shared/prepareRpcModule.ts +98 -16
  74. package/src/lib/shared/prepareSocketModule.ts +11 -15
  75. package/src/lib/shared/queryStringFromArgs.ts +11 -0
  76. package/src/lib/shared/reachable.ts +102 -0
  77. package/src/lib/shared/refresh.ts +22 -0
  78. package/src/lib/shared/requestScopeSlot.ts +10 -7
  79. package/src/lib/shared/routeParamsShape.ts +9 -9
  80. package/src/lib/shared/rpcErrorRegistry.ts +69 -0
  81. package/src/lib/shared/selectorPrefix.ts +3 -10
  82. package/src/lib/shared/setOwnProperty.ts +19 -0
  83. package/src/lib/shared/snippet.ts +1 -1
  84. package/src/lib/shared/subscribableProbes.ts +111 -0
  85. package/src/lib/shared/tailProbeSlot.ts +8 -1
  86. package/src/lib/shared/types/CacheEntry.ts +9 -15
  87. package/src/lib/shared/types/CacheOptions.ts +8 -0
  88. package/src/lib/shared/types/CacheSnapshotEntry.ts +0 -5
  89. package/src/lib/shared/types/RemoteCallable.ts +25 -7
  90. package/src/lib/shared/types/RemoteFunction.ts +48 -13
  91. package/src/lib/shared/types/ResolverSlot.ts +7 -4
  92. package/src/lib/shared/types/RpcError.ts +26 -0
  93. package/src/lib/shared/types/SmartReadOptions.ts +32 -0
  94. package/src/lib/shared/types/Socket.ts +54 -0
  95. package/src/lib/shared/types/SsrBootState.ts +27 -0
  96. package/src/lib/shared/types/SsrPayload.ts +21 -0
  97. package/src/lib/shared/types/Subscribable.ts +13 -13
  98. package/src/lib/shared/types/TailHooks.ts +2 -1
  99. package/src/lib/shared/url.ts +29 -14
  100. package/src/lib/shared/wakeHydrationPeeks.ts +20 -0
  101. package/src/lib/shared/writeTestSocketsDts.ts +1 -1
  102. package/src/lib/test/createScriptedSurface.ts +1 -1
  103. package/src/lib/test/createTestApp.ts +2 -2
  104. package/src/lib/test/createTestSocketChannel.ts +3 -3
  105. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +5 -6
  106. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +10 -7
  107. package/src/lib/ui/compile/abideUiPlugin.ts +2 -2
  108. package/src/lib/ui/compile/analyzeComponent.ts +28 -6
  109. package/src/lib/ui/compile/compileModule.ts +137 -11
  110. package/src/lib/ui/compile/compileShadow.ts +101 -84
  111. package/src/lib/ui/compile/desugarSignals.ts +120 -107
  112. package/src/lib/ui/compile/generateBuild.ts +48 -16
  113. package/src/lib/ui/compile/generateSSR.ts +117 -14
  114. package/src/lib/ui/compile/identifierReferencePattern.ts +13 -0
  115. package/src/lib/ui/compile/lowerContext.ts +10 -4
  116. package/src/lib/ui/compile/lowerScript.ts +72 -5
  117. package/src/lib/ui/compile/parseTemplate.ts +1 -14
  118. package/src/lib/ui/compile/prepareNestedScript.ts +31 -17
  119. package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
  120. package/src/lib/ui/compile/signalCallee.ts +24 -0
  121. package/src/lib/ui/compile/stripEffects.ts +14 -11
  122. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  123. package/src/lib/ui/compile/types/TemplateNode.ts +0 -5
  124. package/src/lib/ui/dom/appendStatic.ts +9 -1
  125. package/src/lib/ui/dom/appendText.ts +15 -3
  126. package/src/lib/ui/dom/assertClaimedText.ts +20 -0
  127. package/src/lib/ui/dom/awaitBlock.ts +19 -83
  128. package/src/lib/ui/dom/bindSelectValue.ts +48 -0
  129. package/src/lib/ui/dom/each.ts +12 -1
  130. package/src/lib/ui/dom/hydrate.ts +10 -0
  131. package/src/lib/ui/dom/mountChild.ts +2 -5
  132. package/src/lib/ui/dom/mountRange.ts +0 -75
  133. package/src/lib/ui/effect.ts +4 -3
  134. package/src/lib/ui/installHotBridge.ts +4 -2
  135. package/src/lib/ui/remoteProxy.ts +18 -2
  136. package/src/lib/ui/renderToStream.ts +9 -13
  137. package/src/lib/ui/resumeSeedScript.ts +2 -2
  138. package/src/lib/ui/router.ts +21 -2
  139. package/src/lib/ui/runtime/RESUME.ts +0 -6
  140. package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
  141. package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
  142. package/src/lib/ui/scope.ts +6 -3
  143. package/src/lib/ui/seedBootState.ts +53 -0
  144. package/src/lib/ui/socketChannel.ts +2 -2
  145. package/src/lib/ui/socketProxy.ts +9 -3
  146. package/src/lib/ui/startClient.ts +16 -30
  147. package/src/lib/ui/state.ts +44 -13
  148. package/src/lib/ui/sync.ts +11 -5
  149. package/src/lib/ui/tryEncodeResume.ts +2 -5
  150. package/src/lib/ui/types/Scope.ts +14 -10
  151. package/src/lib/ui/watch.ts +140 -0
  152. package/src/serverEntry.ts +6 -6
  153. package/template/CLAUDE.md +1 -1
  154. package/template/package.json +1 -1
  155. package/template/src/server/rpc/getHello.ts +3 -3
  156. package/template/src/ui/pages/page.abide +2 -2
  157. package/template/test/app.test.ts +1 -1
  158. package/src/lib/server/reachable.ts +0 -45
  159. package/src/lib/server/sockets/types/Socket.ts +0 -23
  160. package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
  161. package/src/lib/shared/DEFER_MIN_ARRAY_LENGTH.ts +0 -14
  162. package/src/lib/shared/baseResolver.ts +0 -10
  163. package/src/lib/shared/cacheKeyOf.ts +0 -7
  164. package/src/lib/shared/cacheKeyStore.ts +0 -8
  165. package/src/lib/shared/cacheStoreResolver.ts +0 -12
  166. package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
  167. package/src/lib/shared/pageResolver.ts +0 -16
  168. package/src/lib/shared/recordCacheKey.ts +0 -6
  169. package/src/lib/shared/requestScopeResolver.ts +0 -12
  170. package/src/lib/shared/setBaseResolver.ts +0 -4
  171. package/src/lib/shared/setCacheStoreResolver.ts +0 -4
  172. package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
  173. package/src/lib/shared/setPageResolver.ts +0 -4
  174. package/src/lib/shared/setRequestScopeResolver.ts +0 -4
  175. package/src/lib/shared/toBunRoutePattern.ts +0 -28
  176. package/src/lib/shared/types/TailOptions.ts +0 -10
  177. package/src/lib/ui/deferResume.ts +0 -36
  178. package/src/lib/ui/dom/firstElementBetween.ts +0 -14
  179. package/src/lib/ui/matchRoute.ts +0 -106
  180. package/src/lib/ui/runtime/scheduleWake.ts +0 -28
  181. package/src/lib/ui/runtime/whenIdle.ts +0 -21
  182. package/src/lib/ui/runtime/whenVisible.ts +0 -105
  183. package/src/lib/ui/tail.ts +0 -324
  184. /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
  185. /package/src/lib/{server/sockets → shared}/types/SocketServerFrame.ts +0 -0
@@ -1,9 +1,10 @@
1
- import type { BunRequest, Server } from 'bun'
1
+ import type { Server } from 'bun'
2
2
  import { createMcpResourceServer } from '../../mcp/createMcpResourceServer.ts'
3
- import { setMcpResourceServer } from '../../mcp/mcpResourceServerSlot.ts'
3
+ import { mcpResourceServerSlot } from '../../mcp/mcpResourceServerSlot.ts'
4
4
  import type { McpServer } from '../../mcp/types/McpServer.ts'
5
5
  import { abideLog } from '../../shared/abideLog.ts'
6
6
  import { basePathFromAppUrl } from '../../shared/basePathFromAppUrl.ts'
7
+ import { baseSlot } from '../../shared/baseSlot.ts'
7
8
  import { NO_STORE } from '../../shared/CACHE_CONTROL_VALUES.ts'
8
9
  import { CLI_PATH } from '../../shared/CLI_PATH.ts'
9
10
  import { DEV_HOT_PREFIX } from '../../shared/DEV_HOT_PREFIX.ts'
@@ -15,15 +16,15 @@ import { IDENTITY_PATH } from '../../shared/IDENTITY_PATH.ts'
15
16
  import { INSPECTOR_PATH } from '../../shared/INSPECTOR_PATH.ts'
16
17
  import { isDebugNegated } from '../../shared/isDebugNegated.ts'
17
18
  import { logClosingRecord } from '../../shared/logClosingRecord.ts'
19
+ import { matchRoute } from '../../shared/matchRoute.ts'
20
+ import { normalizePathname } from '../../shared/normalizePathname.ts'
18
21
  import { OFFLINE_HEADER } from '../../shared/OFFLINE_HEADER.ts'
19
22
  import { parseBoundedEnvInt } from '../../shared/parseBoundedEnvInt.ts'
20
- import { responseBodyKind } from '../../shared/responseBodyKind.ts'
23
+ import { parseRouteSegments } from '../../shared/parseRouteSegments.ts'
24
+ import { requestScopeSlot } from '../../shared/requestScopeSlot.ts'
21
25
  import { SOCKETS_PATH } from '../../shared/SOCKETS_PATH.ts'
22
26
  import { setAppName } from '../../shared/setAppName.ts'
23
- import { setBaseResolver } from '../../shared/setBaseResolver.ts'
24
- import { setRequestScopeResolver } from '../../shared/setRequestScopeResolver.ts'
25
27
  import { TEXT_PLAIN } from '../../shared/TEXT_PLAIN.ts'
26
- import { toBunRoutePattern } from '../../shared/toBunRoutePattern.ts'
27
28
  import type { Layouts } from '../../ui/types/Layouts.ts'
28
29
  import type { Pages } from '../../ui/types/Pages.ts'
29
30
  import type { AppModule } from '../AppModule.ts'
@@ -49,6 +50,7 @@ import { devClientFingerprint } from './devClientFingerprint.ts'
49
50
  import { devHotModuleResponse } from './devHotModuleResponse.ts'
50
51
  import { devReloadResponse } from './devReloadResponse.ts'
51
52
  import { disableIdleTimeoutForStream } from './disableIdleTimeoutForStream.ts'
53
+ import { finalizeResponse } from './finalizeResponse.ts'
52
54
  import { gzipResponse } from './gzipResponse.ts'
53
55
  import { installAmbientScopeStore } from './installAmbientScopeStore.ts'
54
56
  import { internalErrorResponse } from './internalErrorResponse.ts'
@@ -166,7 +168,7 @@ export async function createServer({
166
168
  `await`s they suspend on (see installAmbientScopeStore / CURRENT_SCOPE).
167
169
  */
168
170
  installAmbientScopeStore()
169
- setRequestScopeResolver(() => {
171
+ requestScopeSlot.resolver = () => {
170
172
  const store = requestContext.getStore()
171
173
  if (!store) {
172
174
  return undefined
@@ -179,7 +181,7 @@ export async function createServer({
179
181
  /* The calling client's reported connectivity — drives server-side online(). Absent header = online. */
180
182
  online: !store.req.headers.has(OFFLINE_HEADER),
181
183
  }
182
- })
184
+ }
183
185
  /*
184
186
  health() during an SSR render marks its request through this slot; the
185
187
  renderer stamps the health payload into __SSR__ only for marked requests,
@@ -206,10 +208,10 @@ export async function createServer({
206
208
  the server-side resolver so url() prefixes SSR-generated links, and rewrite
207
209
  the shell's framework `/_app` entry + css refs to carry the base — relative
208
210
  code-split chunks inherit it from the entry's own URL. '' (root mount) is a
209
- no-op on both. See setBaseResolver / startClient for the client half.
211
+ no-op on both. See seedBootState / startClient for the client half.
210
212
  */
211
213
  const base = basePathFromAppUrl(process.env.APP_URL)
212
- setBaseResolver(() => base)
214
+ baseSlot.resolver = () => base
213
215
  // Rebase the shell's rooted `/_app/` entry refs onto the mount base, matching
214
216
  // either quote style so a custom app.html using single quotes still rewrites.
215
217
  const activeShell = base
@@ -237,7 +239,7 @@ export async function createServer({
237
239
  buildPreloadManifest({ distDir, assets }),
238
240
  ])
239
241
  setRegistryManifests({ rpc, sockets, prompts })
240
- setMcpResourceServer(createMcpResourceServer({ resourcesDir, mcpResources }))
242
+ mcpResourceServerSlot.server = createMcpResourceServer({ resourcesDir, mcpResources })
241
243
  const cliName = cliProgramName ?? 'app'
242
244
  /* The app's public identity, shared by the identity probe and the OpenAPI spec. */
243
245
  const appName = appInfo?.name ?? cliName
@@ -309,54 +311,40 @@ export async function createServer({
309
311
  /*
310
312
  Route dispatch — rpc-vs-page-vs-404 resolution and method matching — lives
311
313
  behind createRouteDispatcher; renderPage is injected so those decisions stay
312
- testable without SSR. buildRoutes() below binds the returned handler per URL.
314
+ testable without SSR. The fetch handler resolves a request URL to a handler
315
+ through the shared matchRoute — the same matcher the client router runs —
316
+ so params decode and route precedence agree across the sides by
317
+ construction (no Bun routes table with its own pattern semantics).
313
318
  */
314
319
  const buildRouteHandler = createRouteDispatcher({ pages, rpc, renderPage })
315
320
 
316
321
  /*
317
- Page URLs (folder paths, e.g. `/media/[id]`) get translated to Bun's
318
- pattern syntax (`/media/:id`) at registration. Bun's `*` wildcard
319
- matches but does not capture into req.params, so for `[...rest]`
320
- routes the catch-all value is reconstructed from the request URL by
321
- slicing the pathname segments after the catch-all's pattern index.
322
- The reconstructed value is set under the original name (e.g. `rest`)
323
- so the page component's $props destructure stays consistent with the
324
- file path. Page URLs and rpc URLs (always `/rpc/...`, flat) are
325
- disjoint by construction, so a plain object needs no deduplication.
322
+ Handlers pre-bound per registered URL. rpc URLs are flat literals (always
323
+ `/rpc/...`), so they dispatch by direct lookup; page URLs carry `[name]` /
324
+ `[[name]]` / `[...rest]` segments and resolve through matchRoute. Page and
325
+ rpc URLs are disjoint by construction, so a request lands in exactly one.
326
326
  */
327
- const routes: Record<string, (req: BunRequest) => Promise<Response>> = {}
328
- for (const routeUrl of Object.keys(pages)) {
329
- const handler = buildRouteHandler(routeUrl)
330
- const { pattern, catchAllName } = toBunRoutePattern(routeUrl)
331
- const catchAllIndex = catchAllName
332
- ? routeUrl.split('/').findIndex((segment) => segment.startsWith('[...'))
333
- : -1
334
- /* Only catch-all routes copy req.params (to write the reconstructed
335
- segment); plain routes pass it through — it's never mutated downstream. */
336
- routes[pattern] =
337
- catchAllName && catchAllIndex !== -1
338
- ? (req) => {
339
- const pathParams = {
340
- ...((req.params as Record<string, string> | undefined) ?? {}),
341
- }
342
- const url = new URL(req.url)
343
- pathParams[catchAllName] = url.pathname
344
- .split('/')
345
- .slice(catchAllIndex)
346
- .join('/')
347
- return dispatchRequest(req, pathParams, handler, url)
348
- }
349
- : (req) =>
350
- dispatchRequest(
351
- req,
352
- (req.params as Record<string, string> | undefined) ?? {},
353
- handler,
354
- )
355
- }
327
+ const rpcHandlers = new Map<string, ReturnType<typeof buildRouteHandler>>()
356
328
  for (const routeUrl of Object.keys(rpc)) {
357
- const handler = buildRouteHandler(routeUrl)
358
- routes[routeUrl] = (req) => dispatchRequest(req, {}, handler)
329
+ rpcHandlers.set(routeUrl, buildRouteHandler(routeUrl))
330
+ }
331
+ const pageHandlers = new Map<string, ReturnType<typeof buildRouteHandler>>()
332
+ for (const routeUrl of Object.keys(pages)) {
333
+ /* A `[...rest]` consumes every remaining segment, so segments after it
334
+ can never constrain matching — the route would silently serve paths
335
+ it shouldn't. Fail at boot instead. */
336
+ const segments = parseRouteSegments(routeUrl)
337
+ const catchAllIndex = segments.findIndex(
338
+ (segment) => segment.kind === 'param' && segment.catchAll,
339
+ )
340
+ if (catchAllIndex !== -1 && catchAllIndex !== segments.length - 1) {
341
+ throw new Error(
342
+ `[abide] invalid page route ${routeUrl}: a [...name] catch-all must be the last segment`,
343
+ )
344
+ }
345
+ pageHandlers.set(routeUrl, buildRouteHandler(routeUrl))
359
346
  }
347
+ const pageRouteUrls = Object.keys(pages)
360
348
 
361
349
  function dispatchRequest(
362
350
  req: Request,
@@ -366,26 +354,15 @@ export async function createServer({
366
354
  pathParams: Record<string, string>,
367
355
  store: RequestStore,
368
356
  ) => Promise<Response>,
369
- /* Pre-parsed by the fetch fallback; routes-table callers omit it. */
370
- url?: URL,
357
+ url: URL,
371
358
  ): Promise<Response> {
372
359
  return runWithRequestScope(req, { app, logRequests, url }, async (store) => {
373
360
  const response = app?.handle
374
361
  ? await app.handle(req, (next) => handler(next, pathParams, store))
375
362
  : await handler(req, pathParams, store)
376
- /* Classify the body once (S2) and thread it into both downstream
377
- steps gzip + the closing-record stream monitorinstead of
378
- each re-deriving from the Content-Type. */
379
- const kind = responseBodyKind(response)
380
- store.responseStreaming = kind === 'streaming'
381
- // Streaming bodies (sse/jsonl, socket tail) opt out of the idle timeout.
382
- if (kind === 'streaming') {
383
- server.timeout(req, 0)
384
- }
385
- /* Gzip compressible dynamic bodies (SSR HTML, rpc/json, 404) when the
386
- client accepts it; streaming frame protocols and static assets are
387
- passed through untouched (see gzipResponse). */
388
- return gzipResponse(req, response, kind)
363
+ /* Wire handling classify once, mark the stream monitor, exempt
364
+ streams from the idle timeout, gziplives in finalizeResponse. */
365
+ return finalizeResponse(req, response, store, () => server.timeout(req, 0))
389
366
  })
390
367
  }
391
368
 
@@ -431,8 +408,6 @@ export async function createServer({
431
408
  },
432
409
  },
433
410
 
434
- routes,
435
-
436
411
  async fetch(req, bunServer) {
437
412
  const url = new URL(req.url)
438
413
  /*
@@ -521,7 +496,7 @@ export async function createServer({
521
496
  })
522
497
  }
523
498
  return devHotModuleResponse(
524
- decodeURIComponent(url.pathname.slice(DEV_HOT_PREFIX.length)),
499
+ decodePathSegment(url.pathname.slice(DEV_HOT_PREFIX.length)),
525
500
  )
526
501
  }
527
502
  /*
@@ -568,7 +543,7 @@ export async function createServer({
568
543
  if (publishForbidden) {
569
544
  return publishForbidden
570
545
  }
571
- const name = decodeURIComponent(url.pathname.slice(SOCKETS_REST_PREFIX.length))
546
+ const name = decodePathSegment(url.pathname.slice(SOCKETS_REST_PREFIX.length))
572
547
  return dispatchRequest(
573
548
  req,
574
549
  {},
@@ -596,6 +571,49 @@ export async function createServer({
596
571
  url,
597
572
  )
598
573
  }
574
+ /*
575
+ App routes — rpc by flat lookup, pages through the shared
576
+ matcher (the client router runs the same one). Matched AFTER
577
+ the `/__abide/*` plumbing above (a reserved namespace no app
578
+ route occupies) and BEFORE the root-level framework surfaces
579
+ (/openapi.json, /_app/, public/ files), so a page route
580
+ shadows a same-path public file — the precedence the Bun
581
+ routes table used to impose implicitly, now pinned here.
582
+ */
583
+ const rpcHandler = rpcHandlers.get(url.pathname)
584
+ if (rpcHandler) {
585
+ return dispatchRequest(req, {}, rpcHandler, url)
586
+ }
587
+ /*
588
+ Pages match only in canonical slash form; a non-canonical
589
+ request (`/admin/`, `//admin`) that would match is 308'd to the
590
+ canonical URL instead of served. Serving it directly would hand
591
+ app.handle — the auth seam — a pathname the matcher silently
592
+ normalized away, so an exact-match guard on `/admin` never sees
593
+ the request it's guarding (the old Bun routes table 404'd these
594
+ forms; the redirect keeps the guard sound AND the URL friendly).
595
+ rpc URLs stay exact-match strict, as they always were.
596
+ */
597
+ const canonicalPathname = normalizePathname(url.pathname)
598
+ if (canonicalPathname !== url.pathname) {
599
+ if (matchRoute(pageRouteUrls, canonicalPathname)) {
600
+ return new Response(null, {
601
+ status: 308,
602
+ headers: {
603
+ Location: `${canonicalPathname}${url.search}`,
604
+ 'Cache-Control': NO_STORE,
605
+ },
606
+ })
607
+ }
608
+ } else {
609
+ const matchedPage = matchRoute(pageRouteUrls, url.pathname)
610
+ if (matchedPage) {
611
+ const pageHandler = pageHandlers.get(matchedPage.route)
612
+ if (pageHandler) {
613
+ return dispatchRequest(req, matchedPage.params, pageHandler, url)
614
+ }
615
+ }
616
+ }
599
617
  if (url.pathname === OPENAPI_PATH) {
600
618
  return dispatchRequest(
601
619
  req,
@@ -734,3 +752,15 @@ export async function createServer({
734
752
  }
735
753
  return server
736
754
  }
755
+
756
+ /* Lenient percent-decode for the internal-route names above (dev hot-module
757
+ paths, socket names) — the same leniency matchRoute applies to page params.
758
+ A malformed escape (`/%`) keeps the raw text so the downstream lookup misses
759
+ naturally instead of a URIError escaping the fetch handler as a 500. */
760
+ function decodePathSegment(segment: string): string {
761
+ try {
762
+ return decodeURIComponent(segment)
763
+ } catch {
764
+ return segment
765
+ }
766
+ }
@@ -5,6 +5,7 @@ import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
5
5
  import { safeJsonForScript } from '../../shared/safeJsonForScript.ts'
6
6
  import { snapshotShippable } from '../../shared/snapshotShippable.ts'
7
7
  import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
8
+ import type { SsrPayload } from '../../shared/types/SsrPayload.ts'
8
9
  import type { StreamedResolution } from '../../shared/types/StreamedResolution.ts'
9
10
  import { renderChain } from '../../ui/renderChain.ts'
10
11
  import { renderToStream } from '../../ui/renderToStream.ts'
@@ -115,7 +116,7 @@ export function createUiPageRenderer({
115
116
  app: appNameSlot.name,
116
117
  health,
117
118
  clientTimeout,
118
- })
119
+ } satisfies SsrPayload)
119
120
  return `<script>window.__SSR__ = ${payload};</script>`
120
121
  }
121
122
 
@@ -142,7 +143,22 @@ export function createUiPageRenderer({
142
143
  build-time injector). A no-op when there are none or the shell carries no </head>. */
143
144
  function injectRoutePreloads(html: string, routeUrl: string): string {
144
145
  const links = routePreloadLinks(routeUrl)
145
- return links === '' ? html : html.replace(HEAD_CLOSE_TAG, `${links}</head>`)
146
+ return links === '' ? html : html.replace(HEAD_CLOSE_TAG, () => `${links}</head>`)
147
+ }
148
+
149
+ /* The layout chain for a route is a pure function of routeUrl and the fixed `layouts`
150
+ map, so memoise it per route (like `preloadLinkCache`) instead of re-scanning and
151
+ re-sorting every layout key on every request. */
152
+ const layoutKeys = Object.keys(layouts)
153
+ const chainKeyCache = new Map<string, string[]>()
154
+ function chainKeysForRoute(routeUrl: string): string[] {
155
+ const cached = chainKeyCache.get(routeUrl)
156
+ if (cached !== undefined) {
157
+ return cached
158
+ }
159
+ const chain = layoutChainForRoute(routeUrl, layoutKeys)
160
+ chainKeyCache.set(routeUrl, chain)
161
+ return chain
146
162
  }
147
163
 
148
164
  async function renderPage(
@@ -166,7 +182,7 @@ export function createUiPageRenderer({
166
182
  }
167
183
  /* Outermost layout → … → page: load every applicable layout chunk plus the
168
184
  page, then render the chain as one document (shared block-id pass). */
169
- const chainKeys = layoutChainForRoute(routeUrl, Object.keys(layouts))
185
+ const chainKeys = chainKeysForRoute(routeUrl)
170
186
  const views = await Promise.all([
171
187
  ...chainKeys.map((key) => layouts[key]?.().then((module) => module.default)),
172
188
  loadPage().then((module) => module.default),
@@ -193,9 +209,12 @@ export function createUiPageRenderer({
193
209
  ),
194
210
  routeUrl,
195
211
  )
212
+ /* Function replacer: the state script carries user cache data, and a string
213
+ replacement would interpret `$&`/`$'`-style patterns inside it. */
214
+ const state = await stateTag(routeUrl, params, store, inline)
196
215
  const withState = html.replace(
197
216
  '</body>',
198
- `${resumeSeedScript(ssr.resume)}${await stateTag(routeUrl, params, store, inline)}</body>`,
217
+ () => `${resumeSeedScript(ssr.resume)}${state}</body>`,
199
218
  )
200
219
  return new Response(withState, {
201
220
  headers: {
@@ -1,5 +1,6 @@
1
1
  import { relative } from 'node:path'
2
2
  import { fileName } from '../../shared/fileName.ts'
3
+ import { isLayoutFile } from '../../shared/isLayoutFile.ts'
3
4
  import { analyzeComponent } from '../../ui/compile/analyzeComponent.ts'
4
5
  import { compileComponent } from '../../ui/compile/compileComponent.ts'
5
6
  import { nearestProjectRoot } from '../../ui/compile/nearestProjectRoot.ts'
@@ -20,8 +21,7 @@ const GENERATED = /(^|\/)\.abide\//
20
21
  // page.abide / layout.abide are router-mounted, not `mountChild`-tracked, so they
21
22
  // can't hot-swap — they fold into `structure` (a reload) instead.
22
23
  function isPageOrLayout(moduleId: string): boolean {
23
- const file = fileName(moduleId)
24
- return file === 'page.abide' || file === 'layout.abide'
24
+ return fileName(moduleId) === 'page.abide' || isLayoutFile(moduleId)
25
25
  }
26
26
 
27
27
  /*
@@ -81,7 +81,7 @@ export async function devClientFingerprint({
81
81
  let bodyHash: string
82
82
  let hot = false
83
83
  try {
84
- const isLayout = moduleId.endsWith('layout.abide')
84
+ const isLayout = isLayoutFile(moduleId)
85
85
  bodyHash = Bun.hash(compileComponent(source, isLayout, moduleId)).toString(36)
86
86
  hot = analyzeComponent(source).imports === '' && !isPageOrLayout(moduleId)
87
87
  } catch {
@@ -1,5 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
  import { NO_STORE } from '../../shared/CACHE_CONTROL_VALUES.ts'
3
+ import { isLayoutFile } from '../../shared/isLayoutFile.ts'
3
4
  import { compileModule } from '../../ui/compile/compileModule.ts'
4
5
 
5
6
  // Reused across requests — strips the embedded author TypeScript to browser JS.
@@ -27,7 +28,7 @@ export async function devHotModuleResponse(moduleId: string): Promise<Response>
27
28
  return new Response('not found', { status: 404 })
28
29
  }
29
30
  const { code } = compileModule(source, {
30
- isLayout: moduleId.endsWith('layout.abide'),
31
+ isLayout: isLayoutFile(moduleId),
31
32
  moduleId,
32
33
  hot: true,
33
34
  })
@@ -0,0 +1,32 @@
1
+ import { responseBodyKind } from '../../shared/responseBodyKind.ts'
2
+ import { gzipResponse } from './gzipResponse.ts'
3
+ import type { RequestStore } from './types/RequestStore.ts'
4
+
5
+ /*
6
+ The wire-handling step every dynamic route's response crosses after its
7
+ handler (and app.handle) returns. The body is classified once
8
+ (responseBodyKind) and that one decision is threaded into every consumer, in
9
+ order: the closing-record stream monitor (store.responseStreaming), the idle
10
+ timeout — a streaming body (SSE / JSONL, socket tail) is opted out so a quiet
11
+ stream isn't reaped mid-flight — and gzip, which compresses compressible
12
+ dynamic bodies (SSR HTML, rpc/json, 404) when the client accepts it and
13
+ passes streaming frame protocols and opaque bodies through untouched.
14
+ `exemptIdleTimeout` is the server capability (server.timeout(req, 0))
15
+ injected by createServer, so the sequencing is testable without a live
16
+ socket. Framework plumbing endpoints answered before the request scope
17
+ (inspector / dev-reload SSE) don't cross this seam — they opt out via
18
+ disableIdleTimeoutForStream directly.
19
+ */
20
+ export function finalizeResponse(
21
+ req: Request,
22
+ response: Response,
23
+ store: RequestStore,
24
+ exemptIdleTimeout: () => void,
25
+ ): Response {
26
+ const kind = responseBodyKind(response)
27
+ store.responseStreaming = kind === 'streaming'
28
+ if (kind === 'streaming') {
29
+ exemptIdleTimeout()
30
+ }
31
+ return gzipResponse(req, response, kind)
32
+ }
@@ -1,3 +1,4 @@
1
+ import { contentBodyKind } from '../../shared/contentBodyKind.ts'
1
2
  import { contentTypeOf } from '../../shared/contentTypeOf.ts'
2
3
  import { hasReplayableRequest } from '../../shared/hasReplayableRequest.ts'
3
4
  import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
@@ -51,8 +52,13 @@ export async function snapshotEntryFromCache(
51
52
  if (isStreamingResponse(response)) {
52
53
  return undefined
53
54
  }
54
- const contentType = contentTypeOf(response.headers)
55
- if (!isTextual(contentType)) {
55
+ /* Ship only a body the client's WARM read can consume — `json`/`text` per the single
56
+ `contentBodyKind` classification the live decoder uses. Gating on the same table (not a
57
+ private textual list) keeps warm read ≡ live read (ADR-0011): the private list shipped
58
+ `application/xml`, which `contentBodyKind` buckets as `binary` and the warm read defers
59
+ on, so that snapshot could never be consumed warm — a silent divergence. */
60
+ const kind = contentBodyKind(contentTypeOf(response.headers))
61
+ if (kind !== 'json' && kind !== 'text') {
56
62
  return undefined
57
63
  }
58
64
  /* Read a CLONE, not the original: a reader that captured this same `entry.promise`
@@ -74,8 +80,6 @@ export async function snapshotEntryFromCache(
74
80
  statusText: response.statusText,
75
81
  headers: Array.from(response.headers.entries()),
76
82
  body,
77
- /* Deferred by the SSR resume path → seed the shipped body lazily (no hydration decode). */
78
- lazy: entry.deferred === true ? true : undefined,
79
83
  }
80
84
  }
81
85
 
@@ -86,16 +90,3 @@ async function readSettled(promise: Promise<Response>): Promise<Response | undef
86
90
  return undefined
87
91
  }
88
92
  }
89
-
90
- function isTextual(contentType: string): boolean {
91
- if (contentType.startsWith('text/')) {
92
- return true
93
- }
94
- if (contentType.includes('json')) {
95
- return true
96
- }
97
- if (contentType.includes('xml')) {
98
- return true
99
- }
100
- return false
101
- }
@@ -1,5 +1,5 @@
1
+ import type { Socket } from '../shared/types/Socket.ts'
1
2
  import type { StandardSchemaV1 } from '../shared/types/StandardSchemaV1.ts'
2
- import type { Socket } from './sockets/types/Socket.ts'
3
3
  import type { SocketOptions } from './sockets/types/SocketOptions.ts'
4
4
 
5
5
  /*
@@ -4,14 +4,14 @@ import { decodeRefJson } from '../../shared/decodeRefJson.ts'
4
4
  import { encodeRefJson } from '../../shared/encodeRefJson.ts'
5
5
  import { memoizeByKey } from '../../shared/memoizeByKey.ts'
6
6
  import { messageFromError } from '../../shared/messageFromError.ts'
7
+ import type { SocketClientFrame } from '../../shared/types/SocketClientFrame.ts'
8
+ import type { SocketServerFrame } from '../../shared/types/SocketServerFrame.ts'
7
9
  import { error } from '../error.ts'
8
10
  import { json } from '../json.ts'
9
11
  import { sse } from '../sse.ts'
10
12
  import { lookupSocket } from './lookupSocket.ts'
11
- import type { SocketClientFrame } from './types/SocketClientFrame.ts'
12
13
  import type { SocketRegistryEntry } from './types/SocketRegistryEntry.ts'
13
14
  import type { SocketRoutes } from './types/SocketRoutes.ts'
14
- import type { SocketServerFrame } from './types/SocketServerFrame.ts'
15
15
 
16
16
  // Reused across every inbound binary frame rather than allocated per message.
17
17
  const textDecoder = new TextDecoder()
@@ -36,6 +36,12 @@ so we only `ws.unsubscribe` when the last local sub drops.
36
36
  type ConnectionState = {
37
37
  subToSocket: Map<string, string>
38
38
  socketSubs: Map<string, Set<string>>
39
+ /* Latest claim token per `sub` id. handleSub awaits the module load before
40
+ registering, so an unsub (delete) or a newer sub (higher token) landing
41
+ during that await must win — the resumed handler re-checks its token and
42
+ bails instead of registering a zombie subscription. */
43
+ subEpochs: Map<string, number>
44
+ epoch: number
39
45
  }
40
46
 
41
47
  /*
@@ -150,8 +156,19 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
150
156
  send(ws, { type: 'err', sub: frame.sub, message })
151
157
  send(ws, { type: 'end', sub: frame.sub })
152
158
  }
159
+ /* Claim the sub id synchronously — the await below yields to other
160
+ frames on this connection. */
161
+ const token = ++state.epoch
162
+ state.subEpochs.set(frame.sub, token)
153
163
  const resolution = await resolveEntry(frame.socket)
164
+ if (state.subEpochs.get(frame.sub) !== token) {
165
+ /* An unsub (already answered with `end`) or a newer sub for this id
166
+ won the race while the module loaded — registering now would leak
167
+ a subscription the client can never cancel. */
168
+ return
169
+ }
154
170
  if ('failure' in resolution) {
171
+ state.subEpochs.delete(frame.sub)
155
172
  return fail(resolution.message)
156
173
  }
157
174
  const { entry } = resolution
@@ -194,6 +211,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
194
211
  state: ConnectionState,
195
212
  frame: Extract<SocketClientFrame, { type: 'unsub' }>,
196
213
  ): void {
214
+ state.subEpochs.delete(frame.sub)
197
215
  const emptied = removeSub(state, frame.sub)
198
216
  if (emptied) {
199
217
  ws.unsubscribe(`socket:${emptied}`)
@@ -227,7 +245,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
227
245
  the process down.
228
246
  */
229
247
  try {
230
- entry.socket.publish(frame.message)
248
+ entry.socket.broadcast(frame.message)
231
249
  } catch (publishError) {
232
250
  abideLog.error(publishError)
233
251
  }
@@ -291,7 +309,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
291
309
  }
292
310
  try {
293
311
  // publish() validates against the socket schema and throws on a bad payload.
294
- entry.socket.publish(message)
312
+ entry.socket.broadcast(message)
295
313
  } catch (publishError) {
296
314
  return error(422, messageFromError(publishError))
297
315
  }
@@ -304,7 +322,12 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
304
322
  rest,
305
323
 
306
324
  open(ws) {
307
- connections.set(ws, { subToSocket: new Map(), socketSubs: new Map() })
325
+ connections.set(ws, {
326
+ subToSocket: new Map(),
327
+ socketSubs: new Map(),
328
+ subEpochs: new Map(),
329
+ epoch: 0,
330
+ })
308
331
  },
309
332
 
310
333
  message(ws, data) {
@@ -1,13 +1,14 @@
1
+ import { attachSocketSelectorMethods } from '../../shared/attachSocketSelectorMethods.ts'
1
2
  import { createPushIterator } from '../../shared/createPushIterator.ts'
2
3
  import { encodeRefJson } from '../../shared/encodeRefJson.ts'
3
4
  import { resolveClientFlags } from '../../shared/resolveClientFlags.ts'
4
5
  import { socketTapSlot } from '../../shared/socketTapSlot.ts'
6
+ import type { Socket } from '../../shared/types/Socket.ts'
7
+ import type { SocketServerFrame } from '../../shared/types/SocketServerFrame.ts'
5
8
  import type { TailHooks } from '../../shared/types/TailHooks.ts'
6
9
  import { getActiveServer } from '../runtime/getActiveServer.ts'
7
10
  import { registerSocket } from './registerSocket.ts'
8
- import type { Socket } from './types/Socket.ts'
9
11
  import type { SocketOptions } from './types/SocketOptions.ts'
10
- import type { SocketServerFrame } from './types/SocketServerFrame.ts'
11
12
 
12
13
  /*
13
14
  Server-side construction of a Socket. The bundler rewrites every
@@ -23,14 +24,18 @@ is the live stream — no replay. `chat.tail(count)` opens a subscription
23
24
  seeded with the last `count` retained frames (no-arg = the whole
24
25
  retained tail, clamped to the declared `tail` size). When `ttl` is set,
25
26
  retained frames older than `ttl` ms are evicted lazily on every
26
- read/append — no timer runs in the background. `chat.publish(m)` is isomorphic —
27
+ read/append — no timer runs in the background. `chat.broadcast(m)` is isomorphic —
27
28
  called server-side it both notifies in-process iterators and broadcasts
28
29
  to remote subscribers; called client-side (via socketProxy) it sends a
29
30
  `pub` frame the dispatcher validates and forwards.
30
31
  */
31
32
  // @documentation plumbing
32
33
  export function defineSocket<T>(name: string, opts: SocketOptions = {}): Socket<T> {
33
- const retention = opts.tail ?? 0
34
+ /* Retention defaults to 1: the socket keeps its last frame so a late joiner, a
35
+ reconnect, and `.peek()` all have the current value to seed from. Bare iteration
36
+ still replays nothing (iterate(0) below) — a real-time `watch(socket, …)` reaction
37
+ must see only live frames, never re-process the retained one. `tail: 0` opts out. */
38
+ const retention = opts.tail ?? 1
34
39
  const ttl = opts.ttl
35
40
  const schema = opts.schema
36
41
  /*
@@ -150,8 +155,10 @@ export function defineSocket<T>(name: string, opts: SocketOptions = {}): Socket<
150
155
  }
151
156
  })
152
157
  pruneExpired(Date.now())
158
+ /* Floor: a fractional count (`?tail=1.5` over REST) must not
159
+ produce a fractional buffer index. */
153
160
  const replayCount =
154
- replay === 'all' ? buffer.length : Math.min(replay, buffer.length)
161
+ replay === 'all' ? buffer.length : Math.min(Math.floor(replay), buffer.length)
155
162
  if (replayCount > 0) {
156
163
  const start = buffer.length - replayCount
157
164
  for (let index = start; index < buffer.length; index++) {
@@ -168,13 +175,25 @@ export function defineSocket<T>(name: string, opts: SocketOptions = {}): Socket<
168
175
  }
169
176
  }
170
177
 
171
- const self: Socket<T> = {
178
+ const self = {
172
179
  name,
173
180
  clients,
174
- publish,
181
+ /* `broadcast` is the public fan-out; `publish` remains the internal function name. */
182
+ broadcast: publish,
175
183
  tail: (count?: number, hooks?: TailHooks) => iterate(count ?? 'all', hooks),
184
+ /* The latest retained frame (after ttl pruning), or undefined when none is held. */
185
+ peek: () => {
186
+ pruneExpired(Date.now())
187
+ return buffer.length > 0 ? (buffer[buffer.length - 1] as BufferEntry).value : undefined
188
+ },
189
+ /* No-op on the server: it is the source of truth, so there is nothing to re-pull. */
190
+ refresh: () => undefined,
191
+ /* Inert on the server: reaction is a client-only concern, and an author `socket.watch(…)`
192
+ that survives the SSR effect-strip must be a safe no-op here. Returns a disposer. */
193
+ watch: () => () => {},
176
194
  [Symbol.asyncIterator]: () => iterate(0)[Symbol.asyncIterator](),
177
195
  }
196
+ attachSocketSelectorMethods(self)
178
197
  registerSocket({
179
198
  socket: self as Socket<unknown>,
180
199
  allowClientPublish: opts.clientPublish ?? false,
@@ -1,6 +1,6 @@
1
1
  import type { ClientFlags } from '../../../shared/types/ClientFlags.ts'
2
+ import type { Socket } from '../../../shared/types/Socket.ts'
2
3
  import type { StandardSchemaV1 } from '../../../shared/types/StandardSchemaV1.ts'
3
- import type { Socket } from './Socket.ts'
4
4
 
5
5
  /*
6
6
  Per-socket registry record. The Socket itself stays uniform between