@abide/abide 0.43.0 → 0.44.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 (160) hide show
  1. package/AGENTS.md +293 -219
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +191 -67
  4. package/package.json +2 -2
  5. package/src/abideLsp.ts +8 -4
  6. package/src/abideResolverPlugin.ts +11 -1
  7. package/src/build.ts +9 -0
  8. package/src/buildDisconnected.ts +2 -0
  9. package/src/devEntry.ts +1 -0
  10. package/src/lib/bundle/probeAbideServer.ts +1 -1
  11. package/src/lib/cli/parseArgvForRpc.ts +4 -2
  12. package/src/lib/cli/types/CliManifest.ts +6 -4
  13. package/src/lib/mcp/annotationsForMethod.ts +1 -1
  14. package/src/lib/mcp/toolResultFromResponse.ts +1 -1
  15. package/src/lib/mcp/types/JsonRpcRequest.ts +3 -3
  16. package/src/lib/server/AppModule.ts +1 -1
  17. package/src/lib/server/cli/handleCliDownload.ts +7 -5
  18. package/src/lib/server/cli/installScript.ts +3 -3
  19. package/src/lib/server/prompts/promptRegistry.ts +1 -1
  20. package/src/lib/server/prompts/types/Prompt.ts +2 -1
  21. package/src/lib/server/rpc/dispatchRpcInProcess.ts +2 -2
  22. package/src/lib/server/rpc/types/RpcHelper.ts +1 -1
  23. package/src/lib/server/runtime/createPublicAssetServer.ts +2 -2
  24. package/src/lib/server/runtime/createRouteDispatcher.ts +1 -1
  25. package/src/lib/server/runtime/createServer.ts +21 -0
  26. package/src/lib/server/runtime/createUiPageRenderer.ts +7 -11
  27. package/src/lib/server/runtime/crossOriginForbidden.ts +2 -2
  28. package/src/lib/server/runtime/crossOriginGate.ts +1 -1
  29. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -2
  30. package/src/lib/server/runtime/disableIdleTimeoutForStream.ts +1 -1
  31. package/src/lib/server/runtime/logExposedSurfaces.ts +3 -4
  32. package/src/lib/server/runtime/serializeCacheSnapshot.ts +3 -8
  33. package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -7
  34. package/src/lib/server/runtime/streamFromIterator.ts +2 -2
  35. package/src/lib/server/runtime/types/InspectorRpc.ts +2 -2
  36. package/src/lib/server/socket.ts +1 -1
  37. package/src/lib/server/sockets/createSocketDispatcher.ts +2 -2
  38. package/src/lib/server/sockets/socketRegistry.ts +2 -2
  39. package/src/lib/server/sockets/types/SocketClientFrame.ts +3 -3
  40. package/src/lib/shared/CACHE_WRAPPED.ts +3 -2
  41. package/src/lib/shared/REMOTE_FUNCTION.ts +1 -1
  42. package/src/lib/shared/STREAMING_CONTENT_TYPES.ts +3 -2
  43. package/src/lib/shared/bodyValueForKind.ts +35 -0
  44. package/src/lib/shared/cache.ts +1 -1
  45. package/src/lib/shared/cacheEntryFromSnapshot.ts +23 -18
  46. package/src/lib/shared/createPushIterator.ts +1 -1
  47. package/src/lib/shared/decodeResponse.ts +11 -8
  48. package/src/lib/shared/hasReplayableRequest.ts +17 -0
  49. package/src/lib/shared/html.ts +7 -26
  50. package/src/lib/shared/manifestModule.ts +2 -2
  51. package/src/lib/shared/online.ts +1 -1
  52. package/src/lib/shared/outboxProbeSlot.ts +1 -1
  53. package/src/lib/shared/pageUrlForFile.ts +1 -1
  54. package/src/lib/shared/routeParamsShape.ts +20 -0
  55. package/src/lib/shared/snapshotShippable.ts +22 -0
  56. package/src/lib/shared/types/CacheEntry.ts +1 -1
  57. package/src/lib/shared/types/CacheInvalidation.ts +3 -3
  58. package/src/lib/shared/types/ClientFlags.ts +5 -3
  59. package/src/lib/shared/types/LastConnection.ts +1 -1
  60. package/src/lib/shared/url.ts +4 -1
  61. package/src/lib/shared/withBase.ts +1 -2
  62. package/src/lib/shared/writeDts.ts +2 -2
  63. package/src/lib/shared/writeRoutesDts.ts +3 -31
  64. package/src/lib/test/createTestSocketChannel.ts +3 -3
  65. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +1 -1
  66. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +40 -6
  67. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +1 -1
  68. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +49 -37
  69. package/src/lib/ui/compile/abideUiPlugin.ts +1 -1
  70. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +3 -1
  71. package/src/lib/ui/compile/awaitPlan.ts +14 -2
  72. package/src/lib/ui/compile/catchBinding.ts +8 -0
  73. package/src/lib/ui/compile/classStyleMergePlan.ts +108 -0
  74. package/src/lib/ui/compile/collectAbideDiagnostics.ts +11 -0
  75. package/src/lib/ui/compile/compileComponent.ts +4 -4
  76. package/src/lib/ui/compile/compileModule.ts +20 -6
  77. package/src/lib/ui/compile/compileSSR.ts +12 -10
  78. package/src/lib/ui/compile/compileShadow.ts +107 -16
  79. package/src/lib/ui/compile/composeProps.ts +3 -3
  80. package/src/lib/ui/compile/createShadowLanguageService.ts +30 -2
  81. package/src/lib/ui/compile/createShadowProgram.ts +2 -1
  82. package/src/lib/ui/compile/createShadowScope.ts +42 -0
  83. package/src/lib/ui/compile/decodeHtmlEntities.ts +4 -0
  84. package/src/lib/ui/compile/desugarSignals.ts +12 -7
  85. package/src/lib/ui/compile/eachPlan.ts +61 -0
  86. package/src/lib/ui/compile/elementPlan.ts +55 -0
  87. package/src/lib/ui/compile/encodeSemanticTokens.ts +24 -1
  88. package/src/lib/ui/compile/generateBuild.ts +150 -127
  89. package/src/lib/ui/compile/generateSSR.ts +112 -73
  90. package/src/lib/ui/compile/ifPlan.ts +7 -1
  91. package/src/lib/ui/compile/interpolatedTemplateLiteral.ts +25 -0
  92. package/src/lib/ui/compile/isPlainIdentifier.ts +7 -0
  93. package/src/lib/ui/compile/isTextLeaf.ts +8 -7
  94. package/src/lib/ui/compile/lowerContext.ts +36 -46
  95. package/src/lib/ui/compile/lowerDocAccess.ts +2 -2
  96. package/src/lib/ui/compile/lowerScript.ts +1 -1
  97. package/src/lib/ui/compile/markupTokens.ts +313 -0
  98. package/src/lib/ui/compile/pagePropsType.ts +23 -0
  99. package/src/lib/ui/compile/parseTemplate.ts +131 -61
  100. package/src/lib/ui/compile/reactiveBinding.ts +47 -0
  101. package/src/lib/ui/compile/renameSignalRefs.ts +44 -10
  102. package/src/lib/ui/compile/skeletonable.ts +5 -5
  103. package/src/lib/ui/compile/snippetPlan.ts +28 -0
  104. package/src/lib/ui/compile/spreadExcludedNames.ts +4 -4
  105. package/src/lib/ui/compile/structuralBlockTokens.ts +3 -4
  106. package/src/lib/ui/compile/switchPlan.ts +7 -1
  107. package/src/lib/ui/compile/tryPlan.ts +8 -1
  108. package/src/lib/ui/compile/types/AnalyzedComponent.ts +2 -2
  109. package/src/lib/ui/compile/types/Binding.ts +24 -0
  110. package/src/lib/ui/compile/types/CompiledShadow.ts +13 -1
  111. package/src/lib/ui/compile/types/ElementPlan.ts +43 -0
  112. package/src/lib/ui/compile/types/ShadowKind.ts +9 -0
  113. package/src/lib/ui/compile/types/ShadowScope.ts +19 -0
  114. package/src/lib/ui/compile/types/TemplateAttr.ts +7 -0
  115. package/src/lib/ui/compile/types/TemplateNode.ts +8 -8
  116. package/src/lib/ui/compile/withBindings.ts +45 -0
  117. package/src/lib/ui/createScope.ts +2 -2
  118. package/src/lib/ui/dom/MATHML_NAMESPACE.ts +2 -2
  119. package/src/lib/ui/dom/SVG_NAMESPACE.ts +3 -2
  120. package/src/lib/ui/dom/appendText.ts +1 -1
  121. package/src/lib/ui/dom/applyResolved.ts +1 -1
  122. package/src/lib/ui/dom/buildDetachedRange.ts +4 -3
  123. package/src/lib/ui/dom/cloneStatic.ts +3 -2
  124. package/src/lib/ui/dom/each.ts +26 -7
  125. package/src/lib/ui/dom/foreignWrapperTag.ts +2 -2
  126. package/src/lib/ui/dom/mountChild.ts +8 -1
  127. package/src/lib/ui/dom/readCall.ts +6 -4
  128. package/src/lib/ui/dom/scopeLabel.ts +3 -3
  129. package/src/lib/ui/dom/switchBlock.ts +3 -1
  130. package/src/lib/ui/dom/types/EachRow.ts +4 -0
  131. package/src/lib/ui/dom/types/SkeletonHoles.ts +4 -2
  132. package/src/lib/ui/history.ts +1 -2
  133. package/src/lib/ui/html.ts +28 -0
  134. package/src/lib/ui/installHotBridge.ts +0 -2
  135. package/src/lib/ui/renderChain.ts +16 -3
  136. package/src/lib/ui/router.ts +66 -15
  137. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +1 -1
  138. package/src/lib/ui/runtime/CHILD_PRESENT.ts +8 -0
  139. package/src/lib/ui/runtime/OUTLET_MARKER.ts +2 -2
  140. package/src/lib/ui/runtime/RANGE_MARKER.ts +1 -1
  141. package/src/lib/ui/runtime/REACTIVE_CONTEXT.ts +9 -3
  142. package/src/lib/ui/runtime/captureModelDoc.ts +1 -1
  143. package/src/lib/ui/runtime/createDoc.ts +5 -1
  144. package/src/lib/ui/runtime/createEffectNode.ts +5 -5
  145. package/src/lib/ui/runtime/flushEffects.ts +22 -9
  146. package/src/lib/ui/runtime/historyEntries.ts +1 -1
  147. package/src/lib/ui/runtime/track.ts +4 -2
  148. package/src/lib/ui/runtime/trigger.ts +34 -20
  149. package/src/lib/ui/runtime/types/Route.ts +2 -2
  150. package/src/lib/ui/runtime/types/UiComponent.ts +3 -2
  151. package/src/lib/ui/runtime/types/UiProps.ts +9 -12
  152. package/src/lib/ui/runtime/walkPath.ts +15 -3
  153. package/src/lib/ui/startClient.ts +41 -14
  154. package/src/lib/ui/state.ts +3 -3
  155. package/src/lib/ui/types/Scope.ts +3 -3
  156. package/src/serverBuildPlugins.ts +11 -5
  157. package/src/zodCjsPlugin.ts +36 -0
  158. package/template/.zed/settings.json +7 -1
  159. package/template/package.json +3 -0
  160. package/template/src/ui/pages/layout.abide +3 -5
@@ -3,7 +3,7 @@ import type { PromptRegistryEntry } from './types/PromptRegistryEntry.ts'
3
3
  /*
4
4
  Process-wide registry of every prompt declared in the app. definePrompt
5
5
  inserts on first construction (eagerly when the registry loader walks the
6
- prompts manifest at MCP boot). The MCP server reads this to build its
6
+ prompts manifest on first enumeration request). The MCP server reads this to build its
7
7
  `prompts/list` + `prompts/get` responses.
8
8
  */
9
9
  export const promptRegistry = new Map<string, PromptRegistryEntry>()
@@ -2,7 +2,8 @@
2
2
  An MCP prompt declared by a markdown file under `src/mcp/prompts/`. The
3
3
  resolver plugin parses the file's frontmatter + body and generates a call
4
4
  to definePrompt, stamping in the `name` from the file path; `render(args)`
5
- interpolates the body's `{{name}}` placeholders into the single user
5
+ interpolates the body's `{{name}}` placeholders and returns the rendered
6
+ string; `renderPrompt` in mcpSurface.ts then wraps it as the single user
6
7
  message returned by `prompts/get`. Prompts are MCP-only — there is no
7
8
  client-side counterpart, so the shape carries no ClientFlags.
8
9
  */
@@ -7,8 +7,8 @@ import { runWithRequestScope } from '../runtime/runWithRequestScope.ts'
7
7
  Runs a rpc in-process: synthesizes the rpc Request from the remote's own
8
8
  method + url and pipes it through remote.fetch — the same handler/validation/
9
9
  error path the HTTP router uses, no network hop. The single in-process
10
- dispatch every consumer surface (the CLI client, the MCP tool dispatcher, and
11
- the test client) routes through, so they can't drift on how a rpc is invoked.
10
+ dispatch every consumer surface (the CLI client and the MCP tool dispatcher)
11
+ routes through, so they can't drift on how a rpc is invoked.
12
12
  Takes the RemoteFunction directly — invocation never reads the registry
13
13
  entry's schemas/clients (validation is closed over inside the remote), so the
14
14
  entry is not a dependency here. `baseUrl` gives the synthetic Request its
@@ -34,7 +34,7 @@ type MutatingRpcOpts = RpcBaseOpts & {
34
34
  }
35
35
 
36
36
  /*
37
- Shared signature for every rpc helper (GET / POST / …). Three overloads:
37
+ Shared signature for every rpc helper (GET / POST / …). Four overloads:
38
38
 
39
39
  - `Rpc(fn, { inputSchema, outputSchema?, clients? })` — `Args` infers
40
40
  from `InferInput<InputSchema>`, the handler receives
@@ -18,8 +18,8 @@ sources, picked at construction:
18
18
 
19
19
  Returns a server fn that resolves to `undefined` when no public file
20
20
  matches the request path, so the caller falls through to its own 404 /
21
- middleware path. The path-traversal guard mirrors serveStaticAsset's
22
- defence against encoded `..` segments in the raw URL.
21
+ middleware path. The path-traversal guard uses the same `containsTraversal`
22
+ check against encoded `..` segments in the raw URL.
23
23
 
24
24
  Async because disk mode globs `publicDir` once at construction to build a
25
25
  Set of the available paths: every page nav and RPC falls through here, so
@@ -36,7 +36,7 @@ function methodNotAllowed(allow: string): Response {
36
36
 
37
37
  /*
38
38
  Owns route dispatch: deciding, per registered URL, whether a request hits an
39
- rpc rpc, a page render, or nothing — and the method-matching that picks the
39
+ an rpc, a page render, or nothing — and the method-matching that picks the
40
40
  status. Page URLs (under src/ui/pages/) serve GET/HEAD by rendering; rpc
41
41
  URLs (under src/server/rpc/, `/rpc/...`) dispatch to the single declared rpc,
42
42
  405 on method mismatch; an unregistered URL is 404. Page and rpc URLs are
@@ -481,6 +481,27 @@ export async function createServer({
481
481
  /* Component hot module — the browser imports one edited `.abide`'s
482
482
  hot build here instead of reloading (dev component HMR). */
483
483
  if (clientFingerprint !== undefined && url.pathname.startsWith(DEV_HOT_PREFIX)) {
484
+ /* This endpoint serves `application/javascript` for the browser's
485
+ `import()`. A TOP-LEVEL NAVIGATION to it — clicking the module link in a
486
+ stack trace, or opening the URL — would DOWNLOAD the file, since browsers
487
+ can't render JS as a document. A navigation sends `Accept: text/html`;
488
+ `import()` sends a wildcard Accept. Redirect a navigation back to a real
489
+ page (the referring page when same-origin, else the mount root) so the
490
+ error surfaces in context as a normal render instead of saving a file. */
491
+ if ((req.headers.get('accept') ?? '').includes('text/html')) {
492
+ const referer = req.headers.get('referer')
493
+ const page =
494
+ referer !== null &&
495
+ URL.canParse(referer) &&
496
+ new URL(referer).origin === url.origin &&
497
+ !new URL(referer).pathname.startsWith(DEV_HOT_PREFIX)
498
+ ? referer
499
+ : base || '/'
500
+ return new Response(null, {
501
+ status: 302,
502
+ headers: { Location: page, 'Cache-Control': NO_STORE },
503
+ })
504
+ }
484
505
  return devHotModuleResponse(
485
506
  decodeURIComponent(url.pathname.slice(DEV_HOT_PREFIX.length)),
486
507
  )
@@ -1,10 +1,9 @@
1
1
  import { appNameSlot } from '../../shared/appNameSlot.ts'
2
2
  import { SSR_CACHE_CONTROL } from '../../shared/CACHE_CONTROL_VALUES.ts'
3
3
  import { formatTraceparent } from '../../shared/formatTraceparent.ts'
4
- import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
5
4
  import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
6
5
  import { safeJsonForScript } from '../../shared/safeJsonForScript.ts'
7
- import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
6
+ import { snapshotShippable } from '../../shared/snapshotShippable.ts'
8
7
  import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
9
8
  import type { StreamedResolution } from '../../shared/types/StreamedResolution.ts'
10
9
  import { renderChain } from '../../ui/renderChain.ts'
@@ -23,6 +22,9 @@ type LoadPage = () => Promise<{ default: UiComponent }>
23
22
 
24
23
  const SSR_MARKER = /<!--ssr:(head|body|state)-->/g
25
24
  const BODY_MARKER = '<!--ssr:body-->'
25
+ /* Compiled once — both fill the shell per page render. */
26
+ const HEAD_STATE_MARKER = /<!--ssr:(head|state)-->/g
27
+ const HEAD_CLOSE_TAG = /<\/head>/i
26
28
 
27
29
  function wantsJson(request: Request): boolean {
28
30
  return (request.headers.get('accept') ?? '').includes('application/json')
@@ -140,7 +142,7 @@ export function createUiPageRenderer({
140
142
  build-time injector). A no-op when there are none or the shell carries no </head>. */
141
143
  function injectRoutePreloads(html: string, routeUrl: string): string {
142
144
  const links = routePreloadLinks(routeUrl)
143
- return links === '' ? html : html.replace(/<\/head>/i, `${links}</head>`)
145
+ return links === '' ? html : html.replace(HEAD_CLOSE_TAG, `${links}</head>`)
144
146
  }
145
147
 
146
148
  async function renderPage(
@@ -212,9 +214,7 @@ export function createUiPageRenderer({
212
214
  `<script>${SSR_SWAP_SCRIPT}${CACHE_RESOLVE_SCRIPT}</script>` +
213
215
  `${await stateTag(routeUrl, params, store, inline)}`
214
216
  const filled = injectRoutePreloads(
215
- shell.replace(/<!--ssr:(head|state)-->/g, (_match, key: string) =>
216
- key === 'head' ? head : '',
217
- ),
217
+ shell.replace(HEAD_STATE_MARKER, (_match, key: string) => (key === 'head' ? head : '')),
218
218
  routeUrl,
219
219
  )
220
220
  const [before, after] = filled.split(BODY_MARKER)
@@ -245,11 +245,7 @@ export function createUiPageRenderer({
245
245
  bundle, so startClient seeds the store and the block's subscription read
246
246
  is warm. Skip keys already shipped inline in __SSR__. */
247
247
  const streamedEntries = Array.from(store.cache.entries.values()).filter(
248
- (entry: CacheEntry) =>
249
- entry.settled === true &&
250
- entry.request !== undefined &&
251
- isReplayableMethod(entry.request.method.toUpperCase()) &&
252
- !inlinedKeys.has(entry.key),
248
+ (entry) => snapshotShippable(entry) && !inlinedKeys.has(entry.key),
253
249
  )
254
250
  for await (const resolution of streamCacheResolutions(
255
251
  store.cache,
@@ -3,8 +3,8 @@ import { TEXT_PLAIN } from '../../shared/TEXT_PLAIN.ts'
3
3
 
4
4
  /*
5
5
  The framework's CSRF refusal, shared by every same-origin gate — the socket
6
- upgrade, the socket REST publish face, the MCP endpoint, and mutating rpc
7
- rpcs — so the 403 body can't drift between surfaces. `hint` appends a
6
+ upgrade, the socket REST publish face, the MCP endpoint, and mutating rpcs
7
+ — so the 403 body can't drift between surfaces. `hint` appends a
8
8
  surface-specific remedy; rpc names its `crossOrigin: true` opt-out so the
9
9
  first developer this 403s can self-serve.
10
10
  */
@@ -6,7 +6,7 @@ import { isCrossOriginRequest } from './isCrossOriginRequest.ts'
6
6
  /*
7
7
  The framework's CSRF/CSWSH posture in one place: a cross-origin browser
8
8
  request to a mutating framework endpoint is refused. Every endpoint that
9
- parses bodies ignoring Content-Type (rpc rpcs, socket publish, MCP JSON-RPC)
9
+ parses bodies ignoring Content-Type (rpcs, socket publish, MCP JSON-RPC)
10
10
  must gate here — a hostile page's text/plain form trick could otherwise
11
11
  smuggle a payload in with the visitor's ambient cookies; non-browser clients
12
12
  send no Origin and pass. Returns the 403 to send, or undefined to proceed.
@@ -11,8 +11,8 @@ mode — runtime sourced from `window.__abide`, self-invoking `hotReplace` — a
11
11
  transpiles the embedded author TypeScript to plain JS the browser can import
12
12
  directly (the normal pipeline relies on the bundler's `ts` loader for this). The
13
13
  browser imports this in place of a reload; on load it swaps the component's live
14
- instances. The id is guarded as a project-relative `.abide` under cwd; a bad or
15
- missing file 404s so the client falls back to a reload.
14
+ instances. The id is guarded as a project-relative `.abide` under cwd; a bad
15
+ module ID returns 400; a missing file 404s so the client falls back to a reload.
16
16
  */
17
17
  export async function devHotModuleResponse(moduleId: string): Promise<Response> {
18
18
  const root = process.cwd()
@@ -13,7 +13,7 @@ than `body instanceof ReadableStream`, since every bodied Response exposes one.
13
13
  A streamed SSR page (`text/html`) is deliberately not opted out: it inherits the
14
14
  configured `idleTimeout` as a bounded cap on how long it can hold the connection,
15
15
  and the client re-fetches any placeholder left unresolved by a cut stream (see
16
- flushUnresolvedPlaceholders). Non-stream responses pass through.
16
+ streamCacheResolutions, which yields { key, miss } markers the client re-fetches). Non-stream responses pass through.
17
17
  */
18
18
  export function disableIdleTimeoutForStream(
19
19
  server: Server<unknown>,
@@ -83,7 +83,7 @@ exposed on, so abide's routing and multimodal-by-default exposure are auditable
83
83
  rather than implicit. Three aligned tables — scan a column to spot a missing
84
84
  surface, a row to see one declaration's reach:
85
85
 
86
- - pages: each route with the nearest layout and error boundary wrapping it.
86
+ - pages: each route key.
87
87
  - sockets: name + client surfaces (schema/browser/mcp/cli) and whether clients
88
88
  may publish.
89
89
  - rpcs: method+path (headed `http` since http/openapi are unconditional) +
@@ -95,9 +95,8 @@ then the surface columns, so the glyphs line up vertically across the tables.
95
95
  rpc folds its method into a left-aligned prefix of the identifier cell, so paths
96
96
  still start at a shared column. For sockets and rpcs the `schema`
97
97
  column leads: it's what unlocks the non-browser surfaces, so a missing schema
98
- reddens to flag the gated declaration. Loads the full registry, so it runs once
99
- at boot and only under `abide` debug logging (DEBUG=abide) to avoid forcing
100
- eager imports in production. Best-effort: enumeration failures are swallowed,
98
+ reddens to flag the gated declaration. Loads the full registry. Runs once at boot; suppressed only via DEBUG=-abide
99
+ (the always-on logging opt-out). Best-effort: enumeration failures are swallowed,
101
100
  this is diagnostic only.
102
101
  */
103
102
  export async function logExposedSurfaces(routing: { pages: Pages }): Promise<void> {
@@ -1,11 +1,11 @@
1
- import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
1
+ import { snapshotShippable } from '../../shared/snapshotShippable.ts'
2
2
  import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
3
3
  import type { CacheStore } from '../../shared/types/CacheStore.ts'
4
4
  import { snapshotEntryFromCache } from './snapshotEntryFromCache.ts'
5
5
 
6
6
  /*
7
7
  Snapshots the request-scoped cache for SSR at a single instant: every replayable
8
- (GET/DELETE) entry settled by now, serialized to a wire-safe CacheSnapshotEntry the
8
+ (GET-only) entry settled by now, serialized to a wire-safe CacheSnapshotEntry the
9
9
  client seeds its store from. Unsettled and non-replayable entries are skipped; a body
10
10
  that can't ship (binary / streaming / rejected) drops out via snapshotEntryFromCache.
11
11
 
@@ -21,12 +21,7 @@ over the wire — see its post-stream `__abideResolve` pass. So for a streaming
21
21
  render-return snapshot is typically empty; the warm cache arrives over the stream.
22
22
  */
23
23
  export async function serializeCacheSnapshot(store: CacheStore): Promise<CacheSnapshotEntry[]> {
24
- const settled = Array.from(store.entries.values()).filter(
25
- (entry) =>
26
- entry.settled === true &&
27
- entry.request !== undefined &&
28
- isReplayableMethod(entry.request.method.toUpperCase()),
29
- )
24
+ const settled = Array.from(store.entries.values()).filter(snapshotShippable)
30
25
  const snapshots = await Promise.all(
31
26
  settled.map((entry) => snapshotEntryFromCache(store, entry)),
32
27
  )
@@ -1,9 +1,10 @@
1
1
  import { contentTypeOf } from '../../shared/contentTypeOf.ts'
2
- import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
2
+ import { hasReplayableRequest } from '../../shared/hasReplayableRequest.ts'
3
3
  import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
4
4
  import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
5
5
  import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
6
6
  import type { CacheStore } from '../../shared/types/CacheStore.ts'
7
+ import type { ReplayableMethod } from '../../shared/types/ReplayableMethod.ts'
7
8
 
8
9
  /*
9
10
  Awaits one cache entry and turns it into a wire-safe snapshot, or undefined
@@ -25,14 +26,16 @@ export async function snapshotEntryFromCache(
25
26
  store: CacheStore,
26
27
  entry: CacheEntry,
27
28
  ): Promise<CacheSnapshotEntry | undefined> {
28
- /* Producer entries have no wire request to replay they never snapshot. */
29
- if (!entry.request) {
30
- return undefined
31
- }
32
- const method = entry.request.method.toUpperCase()
33
- if (!isReplayableMethod(method)) {
29
+ /* The request half of the shared shippability gate: a replayable wire request.
30
+ Producer entries (no request) and non-replayable methods never snapshot — the
31
+ streaming-drain caller hands still-pending entries here, so `settled` is NOT gated
32
+ (the caller's snapshotShippable filter already required it for the entries it picks). */
33
+ if (!hasReplayableRequest(entry) || !entry.request) {
34
34
  return undefined
35
35
  }
36
+ /* `hasReplayableRequest` already verified the uppercased method is replayable; assert it
37
+ for the snapshot's typed `method` field rather than re-running the same Set check. */
38
+ const method = entry.request.method.toUpperCase() as ReplayableMethod
36
39
  const response = await readSettled(entry.promise as Promise<Response>)
37
40
  if (!response) {
38
41
  return undefined
@@ -4,7 +4,7 @@ import { messageFromError } from '../../shared/messageFromError.ts'
4
4
  Shared body builder for the streaming respond helpers (`jsonl`, `sse`).
5
5
  Both flow the same shape — pull from an AsyncIterator, encode each frame
6
6
  to bytes, emit a sentinel `error` frame on a generator throw, and route
7
- ReadableStream's `cancel` into `iter.return()` so the handler's
7
+ ReadableStream's `cancel` into `iterator.return()` so the handler's
8
8
  `for await` exits via its normal control path. Only the per-frame
9
9
  encoding and the optional keepalive payload differ between the two.
10
10
 
@@ -85,7 +85,7 @@ export function streamFromIterator<T>(
85
85
  rejection from its cleanup: a `finally` that throws on `.return()`
86
86
  would otherwise surface as an unhandled rejection (process-fatal
87
87
  under Bun's default) on a client disconnect — a path every
88
- sse/jsonl/resolve stream hits routinely.
88
+ sse/jsonl stream hits routinely.
89
89
  */
90
90
  return iterator.return?.(reason)?.then(
91
91
  () => undefined,
@@ -1,6 +1,6 @@
1
1
  /*
2
- One RPC rpc projected for the inspector: the registry entry reduced to the
3
- serializable facts the UI renders — where it mounts, its method, which
2
+ One declared RPC projected for the inspector: the registry entry reduced to
3
+ the serializable facts the UI renders — where it mounts, its method, which
4
4
  non-browser surfaces advertise it, and its argument/result shapes as JSON
5
5
  Schema. Schemas project through jsonSchemaForSchema (same as MCP/OpenAPI), so
6
6
  a rpc whose library can't render a schema still lists with an opaque shape.
@@ -6,7 +6,6 @@ import type { SocketOptions } from './sockets/types/SocketOptions.ts'
6
6
  Declares a Socket inside a file under `src/server/sockets/`. Each file contains
7
7
  exactly one export, named after the file (e.g. `chat.ts` →
8
8
  `export const chat = socket<ChatMessage>(...)`). The bundler reads the
9
- // @documentation sockets
10
9
  export name from the filename and the socket name from the file path
11
10
  under `src/server/sockets/`, then rewrites this call to bind the name into the
12
11
  runtime implementation (defineSocket on the server, socketProxy on the
@@ -21,6 +20,7 @@ to browser-only when schemaless, all surfaces when a schema is present.
21
20
  This function exists only for the type signature; calling it directly
22
21
  means the bundler plugin didn't process the file, which throws.
23
22
  */
23
+ // @documentation sockets
24
24
  export function socket<Schema extends StandardSchemaV1>(
25
25
  opts: SocketOptions<Schema> & { schema: Schema },
26
26
  ): Socket<StandardSchemaV1.InferOutput<Schema>>
@@ -220,7 +220,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
220
220
  }
221
221
  /*
222
222
  publish() runs the topic's optional Standard Schema synchronously
223
- and throws on failure (see defineSocket.validateSync). The
223
+ and throws on failure (see validateSync in defineSocket.ts). The
224
224
  dispatcher invokes us via `void handlePub(...)`, so an unhandled
225
225
  throw would surface as an unhandled promise rejection on every
226
226
  malformed client frame. Catch + log so a buggy client can't take
@@ -317,7 +317,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
317
317
  try {
318
318
  /* ref-json from abide's own client; a non-abide client sends a plain-JSON
319
319
  frame, which decodeRefJson rejects (a frame is always an object, never the
320
- `[root, slots]` envelope) — fall back to JSON.parse so raw WS clients work. */
320
+ `[rootValue, slots]` envelope) — fall back to JSON.parse so raw WS clients work. */
321
321
  try {
322
322
  frame = decodeRefJson(text) as SocketClientFrame
323
323
  } catch {
@@ -3,7 +3,7 @@ import type { SocketRegistryEntry } from './types/SocketRegistryEntry.ts'
3
3
  /*
4
4
  Process-wide registry of every Socket declared in the app. defineSocket
5
5
  inserts on first construction; the dispatcher reads on every `sub` /
6
- `pub` frame so it can find the right Socket by name and check the
7
- opted-in `allowClientPublish` policy.
6
+ `pub` frame and every REST request so it can find the right Socket by
7
+ name and check the opted-in `allowClientPublish` policy.
8
8
  */
9
9
  export const socketRegistry = new Map<string, SocketRegistryEntry>()
@@ -9,9 +9,9 @@ the dispatcher checks the topic's `clientPublish` flag before fanning
9
9
  out.
10
10
 
11
11
  `sub` is the per-subscription id minted client-side; the server treats
12
- it as opaque and routes inbound `msg|err|end` frames back to the same
13
- id so one ws can multiplex many subscriptions to the same or different
14
- sockets.
12
+ it as opaque and routes inbound `replay|err|end` frames back to the same
13
+ id `msg` frames are broadcast keyed by socket name; the client
14
+ demuxes them against every local sub for that socket.
15
15
  */
16
16
  export type SocketClientFrame =
17
17
  | { type: 'sub'; sub: string; socket: string; replay?: number }
@@ -2,7 +2,8 @@
2
2
  Brand on the invoker cache() returns; its property value is the wrapped fn,
3
3
  so inspection shows what a wrapper wraps. Detection is certain (set by us,
4
4
  never heuristic), which is why misuse throws instead of warning: a wrapper
5
- used as a selector matches nothing (no url/method, no producer id), and a
6
- re-wrapped wrapper silently downgrades a remote to an anonymous producer.
5
+ used as a selector matches nothing (no url/method, no producer id), and
6
+ without detection a re-wrapped wrapper would silently downgrade a remote to
7
+ an anonymous producer (no url/method, no shared key, no SSR snapshot).
7
8
  */
8
9
  export const CACHE_WRAPPED: unique symbol = Symbol('abide.cacheWrapped')
@@ -3,6 +3,6 @@ Brand set by createRemoteFunction on both the decoded callable and its raw
3
3
  sibling — the exact answer to "is this a abide remote?" where structural
4
4
  checks ('url' in fn) would misclassify any user function that happens to
5
5
  carry a url property. Read by cache()'s remote/producer discriminator,
6
- selectorMatcher's identity branch, and the route dispatcher's export scan.
6
+ selectorPrefix's identity branch, and the route dispatcher's export scan.
7
7
  */
8
8
  export const REMOTE_FUNCTION: unique symbol = Symbol('abide.remoteFunction')
@@ -1,8 +1,9 @@
1
1
  /*
2
2
  Content-Type prefixes abide treats as streaming bodies — SSE for the
3
3
  `sse()` helper, JSONL / NDJSON for the `jsonl()` helper. Used by
4
- decodeResponse to refuse a buffered decode and by streamResponse to
5
- choose the frame parser.
4
+ contentBodyKind (called by decodeResponse and the one-shot path in
5
+ streamResponse) to classify content, and by streamResponse to select the
6
+ JSONL frame parser.
6
7
  */
7
8
  export const STREAMING_CONTENT_TYPES = [
8
9
  'text/event-stream',
@@ -0,0 +1,35 @@
1
+ import type { ContentBodyKind } from './contentBodyKind.ts'
2
+
3
+ /*
4
+ The single body-kind → value mapping shared by the live read (decodeResponse) and
5
+ the synchronous warm read (warmValueFromSnapshot), so neither can disagree about
6
+ which kind yields what — the divergence behind the streaming-guard-in-one-but-not-
7
+ the-other bug.
8
+
9
+ Generic over the extraction (`Value`) so both async and sync callers reuse it:
10
+ the live read passes Promise-returning accessors (response.json()/text()) and
11
+ handles the deferred kinds (streaming → throw, binary → blob); the warm read
12
+ passes sync accessors (JSON.parse(body)/body) and treats every deferred kind as
13
+ no-warm-value, leaving the live async path to throw/blob exactly as a fresh call
14
+ would. The json/text branches — the only kinds that produce a directly-usable
15
+ value on both sides — are decided here, once.
16
+
17
+ Returns the DEFER sentinel for `streaming` and `binary`: kinds whose handling is
18
+ side-specific (the live read throws on streaming and blobs on binary; the warm
19
+ read has no synchronous equivalent for either), so the caller branches on it.
20
+ */
21
+ export const DEFER: unique symbol = Symbol('defer')
22
+
23
+ export function bodyValueForKind<Value>(
24
+ kind: ContentBodyKind,
25
+ json: () => Value,
26
+ text: () => Value,
27
+ ): Value | typeof DEFER {
28
+ if (kind === 'json') {
29
+ return json()
30
+ }
31
+ if (kind === 'text') {
32
+ return text()
33
+ }
34
+ return DEFER
35
+ }
@@ -92,7 +92,7 @@ request-scoped on the server, which keeps per-user data from leaking across
92
92
  requests; on the client there is one tab store either way, so it is a no-op.
93
93
 
94
94
  Reactivity is implicit: the invoker calls `store.subscribe(key)`, which
95
- registers the surrounding $derived / $effect scope. Invalidating the key
95
+ registers the surrounding scope().computed() / scope().effect() scope. Invalidating the key
96
96
  then re-runs that scope, which calls cache() again and gets a fresh entry.
97
97
  Outside a tracking scope subscribe() is a no-op, so cache() works the same
98
98
  in server code and plain client code.
@@ -1,3 +1,4 @@
1
+ import { bodyValueForKind, DEFER } from './bodyValueForKind.ts'
1
2
  import { contentBodyKind } from './contentBodyKind.ts'
2
3
  import { contentTypeOf } from './contentTypeOf.ts'
3
4
  import type { CacheEntry } from './types/CacheEntry.ts'
@@ -32,31 +33,35 @@ export function cacheEntryFromSnapshot(entry: CacheSnapshotEntry): CacheEntry {
32
33
 
33
34
  /*
34
35
  Synchronously decodes a snapshot body so the warm entry reads without a
35
- microtask hop on first render. A strict subset of `decodeResponse`: it warms
36
- only the `json`/`text` kinds (the same `contentBodyKind` the live read switches
37
- on, so the two cannot disagree about a body), returning a value identical to
38
- what awaiting the Response would yield. Every other kind non-2xx, 204,
39
- streaming, binary yields no warm value and defers to the async path, which
40
- throws HttpError / returns the streaming error / blobs exactly as a live call
41
- would.
36
+ microtask hop on first render. The json/text branches go through the shared
37
+ `bodyValueForKind` the same mapping the live read (`decodeResponse`) uses — so
38
+ the two cannot disagree about how a body becomes a value, returning a value
39
+ identical to what awaiting the Response would yield. Every non-warmable case
40
+ non-2xx, 204 (the status gate below), streaming, binary (the DEFER sentinel)
41
+ yields no warm value and defers to the async path, which throws HttpError /
42
+ returns the streaming error / blobs exactly as a live call would.
42
43
  */
43
44
  function warmValueFromSnapshot(status: number, headers: Headers, body: string): unknown {
45
+ /* Status gate is side-specific: an error/204 has no warm value here, but the live
46
+ read throws HttpError / returns undefined for the same status — so it stays out of
47
+ the shared kind mapping. */
44
48
  if (status === 204 || status < 200 || status >= 300) {
45
49
  return undefined
46
50
  }
47
51
  const kind = contentBodyKind(contentTypeOf(headers))
48
- if (kind === 'json') {
52
+ const value = bodyValueForKind(
53
+ kind,
49
54
  /* The body may still be malformed JSON; fall back to the async path rather than
50
55
  throwing a SyntaxError synchronously during hydration. */
51
- try {
52
- return JSON.parse(body)
53
- } catch {
54
- return undefined
55
- }
56
- }
57
- if (kind === 'text') {
58
- return body
59
- }
56
+ () => {
57
+ try {
58
+ return JSON.parse(body)
59
+ } catch {
60
+ return undefined
61
+ }
62
+ },
63
+ () => body,
64
+ )
60
65
  /* streaming and binary defer to the async path (the live decode throws / blobs). */
61
- return undefined
66
+ return value === DEFER ? undefined : value
62
67
  }
@@ -1,5 +1,5 @@
1
1
  /*
2
- Single-slot-mailbox AsyncIterator factory shared by the in-process
2
+ Bounded-queue AsyncIterator factory shared by the in-process
3
3
  socket fan-out (defineSocket) and the client-side ws proxy
4
4
  (socketProxy). Callers push values, signal end, signal an error, or
5
5
  signal a transport disconnect; the iterator drains a queue then awaits
@@ -1,3 +1,4 @@
1
+ import { bodyValueForKind, DEFER } from './bodyValueForKind.ts'
1
2
  import { contentBodyKind } from './contentBodyKind.ts'
2
3
  import { contentTypeOf } from './contentTypeOf.ts'
3
4
  import { httpErrorFor } from './httpErrorFor.ts'
@@ -6,7 +7,7 @@ import { httpErrorFor } from './httpErrorFor.ts'
6
7
  Decodes a Response into the natural body value based on Content-Type:
7
8
  application/json (or `*\/+json`) → parsed JSON
8
9
  text/* → string
9
- 204 No Content / empty body → undefined
10
+ 204 No Content → undefined
10
11
  everything else → Blob
11
12
 
12
13
  Non-2xx responses throw HttpError so the happy path never has to check
@@ -39,11 +40,13 @@ export async function decodeResponse(response: Response): Promise<unknown> {
39
40
  `[abide] response at ${response.url} is a stream (${contentType}) — use tail(fn.stream(args)) for a reactive view, or fn.stream(args) for per-call iteration, instead of awaiting the bare call or cache()`,
40
41
  )
41
42
  }
42
- if (kind === 'json') {
43
- return response.json()
44
- }
45
- if (kind === 'text') {
46
- return response.text()
47
- }
48
- return response.blob()
43
+ /* json/text go through the shared mapping warmValueFromSnapshot also uses, so a warm
44
+ seed reads byte-identically to this live read; binary (the only DEFER kind left,
45
+ streaming already threw above) blobs. */
46
+ const value = bodyValueForKind(
47
+ kind,
48
+ () => response.json(),
49
+ () => response.text(),
50
+ )
51
+ return value === DEFER ? response.blob() : value
49
52
  }
@@ -0,0 +1,17 @@
1
+ import { isReplayableMethod } from './isReplayableMethod.ts'
2
+ import type { CacheEntry } from './types/CacheEntry.ts'
3
+
4
+ /*
5
+ Whether a cache entry carries a wire request that can be replayed from a warm
6
+ snapshot — a replayable (GET-only) method, so the seed never re-fires a write or
7
+ loses a body. Producer entries (no request) and write methods fail it.
8
+
9
+ The request half of snapshot shippability, factored out so the two gates that
10
+ need it cannot drift: `snapshotShippable` composes it with `settled` for the
11
+ store-filter sites, and `snapshotEntryFromCache` calls it alone — its
12
+ streaming-drain caller hands it still-pending entries it then awaits, so it can't
13
+ require `settled` up front.
14
+ */
15
+ export function hasReplayableRequest(entry: CacheEntry): boolean {
16
+ return entry.request !== undefined && isReplayableMethod(entry.request.method.toUpperCase())
17
+ }
@@ -1,36 +1,17 @@
1
- const RAW_HTML = Symbol.for('abide.rawHtml')
1
+ /* The registered brand for trusted raw markup. `Symbol.for` resolves through the
2
+ global registry, so this binding is the same symbol across module/bundle copies;
3
+ `html` (`abide/ui/html`) imports it to brand, `rawHtmlString` reads it back. */
4
+ export const RAW_HTML = Symbol.for('abide.rawHtml')
2
5
 
3
6
  /* A value marked as trusted raw markup — a `{expr}` interpolation inserts it
4
7
  verbatim (not escaped) on both sides. The brand is a registered Symbol, so it
5
- survives across module/bundle copies. */
8
+ survives across module/bundle copies. Branded by `html` (`abide/ui/html`); read
9
+ back by `rawHtmlString` here — the isomorphic reader both runtimes consume. */
6
10
  export type RawHtml = { readonly [RAW_HTML]: string }
7
11
 
8
- /*
9
- Marks a string as trusted raw HTML so a `{expr}` interpolation inserts its nodes
10
- instead of escaped text — the abide idiom for raw markup (no `{@html}` mustache).
11
- Works two ways:
12
-
13
- html(trustedString) // plain call — insert the string verbatim
14
- html`<b>${name}</b>` // tagged — concatenate parts verbatim
15
-
16
- Calling `html` is the explicit opt-in to raw insertion; plain `{value}` always
17
- escapes. The tag does NOT auto-escape interpolations (it's raw by intent), so only
18
- build markup from values you trust, or escape them yourself.
19
- */
20
- // @documentation templating
21
- export function html(strings: TemplateStringsArray | string, ...values: unknown[]): RawHtml {
22
- if (typeof strings === 'string') {
23
- return { [RAW_HTML]: strings }
24
- }
25
- let markup = strings[0] ?? ''
26
- for (let index = 0; index < values.length; index += 1) {
27
- markup += String(values[index]) + (strings[index + 1] ?? '')
28
- }
29
- return { [RAW_HTML]: markup }
30
- }
31
-
32
12
  /* The raw markup of a `html`-branded value, or undefined for anything else
33
13
  (so text bindings can fast-path plain values and only branded ones go raw). */
14
+ // @documentation plumbing
34
15
  export function rawHtmlString(value: unknown): string | undefined {
35
16
  return value !== null && typeof value === 'object' && RAW_HTML in value
36
17
  ? (value as RawHtml)[RAW_HTML]