@abide/abide 0.46.0 → 0.48.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 (194) hide show
  1. package/AGENTS.md +370 -320
  2. package/CHANGELOG.md +94 -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/buildCacheSnapshot.ts +6 -6
  27. package/src/lib/server/runtime/containsTraversal.ts +21 -17
  28. package/src/lib/server/runtime/createAppAssetServer.ts +11 -16
  29. package/src/lib/server/runtime/createServer.ts +101 -71
  30. package/src/lib/server/runtime/createUiPageRenderer.ts +23 -4
  31. package/src/lib/server/runtime/devClientFingerprint.ts +3 -3
  32. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -1
  33. package/src/lib/server/runtime/finalizeResponse.ts +32 -0
  34. package/src/lib/server/runtime/snapshotEntryFromCache.ts +8 -17
  35. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  36. package/src/lib/server/runtime/types/InspectorCacheSnapshot.ts +4 -4
  37. package/src/lib/server/runtime/types/InspectorContext.ts +1 -1
  38. package/src/lib/server/socket.ts +1 -1
  39. package/src/lib/server/sockets/createSocketDispatcher.ts +28 -5
  40. package/src/lib/server/sockets/defineSocket.ts +26 -7
  41. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +1 -1
  42. package/src/lib/server/sockets/types/SocketRoutes.ts +1 -1
  43. package/src/lib/server/sse.ts +2 -2
  44. package/src/lib/shared/DEFER.ts +8 -0
  45. package/src/lib/shared/activeCacheStore.ts +2 -2
  46. package/src/lib/shared/activePage.ts +2 -2
  47. package/src/lib/shared/attachRpcSelectorMethods.ts +42 -0
  48. package/src/lib/shared/attachSocketSelectorMethods.ts +34 -0
  49. package/src/lib/shared/basePath.ts +2 -2
  50. package/src/lib/shared/baseSlot.ts +6 -5
  51. package/src/lib/shared/bodyValueForKind.ts +1 -2
  52. package/src/lib/shared/buildSocketOverChannel.ts +40 -4
  53. package/src/lib/shared/cache.ts +522 -117
  54. package/src/lib/shared/cacheEntryFromSnapshot.ts +3 -22
  55. package/src/lib/shared/cacheStoreSlot.ts +9 -5
  56. package/src/lib/shared/cacheStores.ts +3 -3
  57. package/src/lib/{server/runtime → shared}/createReachable.ts +2 -2
  58. package/src/lib/shared/createRemoteFunction.ts +58 -9
  59. package/src/lib/shared/createResolverSlot.ts +24 -31
  60. package/src/lib/shared/createSocketSubRegistry.ts +2 -2
  61. package/src/lib/shared/decodeRefJson.ts +4 -2
  62. package/src/lib/shared/decodeResponse.ts +8 -6
  63. package/src/lib/shared/done.ts +14 -0
  64. package/src/lib/shared/encodeRefJson.ts +4 -2
  65. package/src/lib/shared/hydratingSlot.ts +12 -0
  66. package/src/lib/shared/isLayoutFile.ts +12 -0
  67. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  68. package/src/lib/shared/keyPrefixForRemote.ts +12 -0
  69. package/src/lib/shared/matchRoute.ts +175 -0
  70. package/src/lib/shared/normalizePathname.ts +11 -0
  71. package/src/lib/shared/pageSlot.ts +14 -5
  72. package/src/lib/shared/pageUrlForFile.ts +3 -3
  73. package/src/lib/shared/parseRouteSegments.ts +16 -7
  74. package/src/lib/shared/patch.ts +41 -0
  75. package/src/lib/shared/peek.ts +35 -0
  76. package/src/lib/shared/prepareRemoteExport.ts +40 -0
  77. package/src/lib/shared/prepareRpcModule.ts +98 -16
  78. package/src/lib/shared/prepareSocketModule.ts +11 -15
  79. package/src/lib/shared/queryStringFromArgs.ts +11 -0
  80. package/src/lib/shared/reachable.ts +102 -0
  81. package/src/lib/shared/refresh.ts +22 -0
  82. package/src/lib/shared/requestScopeSlot.ts +10 -7
  83. package/src/lib/shared/routeParamsShape.ts +9 -9
  84. package/src/lib/shared/rpcErrorRegistry.ts +69 -0
  85. package/src/lib/shared/selectorPrefix.ts +3 -10
  86. package/src/lib/shared/setOwnProperty.ts +19 -0
  87. package/src/lib/shared/sharedCacheStore.ts +14 -0
  88. package/src/lib/shared/sharedCacheStoreSlot.ts +10 -0
  89. package/src/lib/shared/snippet.ts +1 -1
  90. package/src/lib/shared/subscribableProbes.ts +111 -0
  91. package/src/lib/shared/tailProbeSlot.ts +8 -1
  92. package/src/lib/shared/types/CacheEntry.ts +9 -15
  93. package/src/lib/shared/types/CacheOptions.ts +23 -11
  94. package/src/lib/shared/types/CacheSnapshotEntry.ts +0 -5
  95. package/src/lib/shared/types/CacheStats.ts +1 -1
  96. package/src/lib/shared/types/RemoteCallable.ts +25 -7
  97. package/src/lib/shared/types/RemoteFunction.ts +48 -13
  98. package/src/lib/shared/types/ResolverSlot.ts +7 -4
  99. package/src/lib/shared/types/RpcError.ts +26 -0
  100. package/src/lib/shared/types/SmartReadOptions.ts +36 -0
  101. package/src/lib/shared/types/Socket.ts +54 -0
  102. package/src/lib/shared/types/SsrBootState.ts +27 -0
  103. package/src/lib/shared/types/SsrPayload.ts +21 -0
  104. package/src/lib/shared/types/Subscribable.ts +13 -13
  105. package/src/lib/shared/types/TailHooks.ts +2 -1
  106. package/src/lib/shared/url.ts +29 -14
  107. package/src/lib/shared/wakeHydrationPeeks.ts +20 -0
  108. package/src/lib/shared/writeTestSocketsDts.ts +1 -1
  109. package/src/lib/test/createScriptedSurface.ts +1 -1
  110. package/src/lib/test/createTestApp.ts +8 -8
  111. package/src/lib/test/createTestSocketChannel.ts +3 -3
  112. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +5 -6
  113. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +10 -7
  114. package/src/lib/ui/compile/abideUiPlugin.ts +2 -2
  115. package/src/lib/ui/compile/analyzeComponent.ts +28 -6
  116. package/src/lib/ui/compile/compileModule.ts +137 -11
  117. package/src/lib/ui/compile/compileShadow.ts +101 -84
  118. package/src/lib/ui/compile/desugarSignals.ts +120 -107
  119. package/src/lib/ui/compile/generateBuild.ts +48 -16
  120. package/src/lib/ui/compile/generateSSR.ts +117 -14
  121. package/src/lib/ui/compile/identifierReferencePattern.ts +13 -0
  122. package/src/lib/ui/compile/lowerContext.ts +10 -4
  123. package/src/lib/ui/compile/lowerScript.ts +72 -5
  124. package/src/lib/ui/compile/parseTemplate.ts +1 -14
  125. package/src/lib/ui/compile/prepareNestedScript.ts +31 -17
  126. package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
  127. package/src/lib/ui/compile/signalCallee.ts +24 -0
  128. package/src/lib/ui/compile/stripEffects.ts +14 -11
  129. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  130. package/src/lib/ui/compile/types/TemplateNode.ts +0 -5
  131. package/src/lib/ui/dom/appendStatic.ts +9 -1
  132. package/src/lib/ui/dom/appendText.ts +15 -3
  133. package/src/lib/ui/dom/assertClaimedText.ts +20 -0
  134. package/src/lib/ui/dom/awaitBlock.ts +19 -83
  135. package/src/lib/ui/dom/bindSelectValue.ts +48 -0
  136. package/src/lib/ui/dom/each.ts +12 -1
  137. package/src/lib/ui/dom/hydrate.ts +10 -0
  138. package/src/lib/ui/dom/mountChild.ts +2 -5
  139. package/src/lib/ui/dom/mountRange.ts +0 -75
  140. package/src/lib/ui/effect.ts +4 -3
  141. package/src/lib/ui/installHotBridge.ts +4 -2
  142. package/src/lib/ui/remoteProxy.ts +18 -2
  143. package/src/lib/ui/renderToStream.ts +9 -13
  144. package/src/lib/ui/resumeSeedScript.ts +2 -2
  145. package/src/lib/ui/router.ts +21 -2
  146. package/src/lib/ui/runtime/RESUME.ts +0 -6
  147. package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
  148. package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
  149. package/src/lib/ui/scope.ts +6 -3
  150. package/src/lib/ui/seedBootState.ts +53 -0
  151. package/src/lib/ui/socketChannel.ts +2 -2
  152. package/src/lib/ui/socketProxy.ts +9 -3
  153. package/src/lib/ui/startClient.ts +17 -31
  154. package/src/lib/ui/state.ts +44 -13
  155. package/src/lib/ui/sync.ts +11 -5
  156. package/src/lib/ui/tryEncodeResume.ts +2 -5
  157. package/src/lib/ui/types/Scope.ts +14 -10
  158. package/src/lib/ui/watch.ts +140 -0
  159. package/src/serverEntry.ts +13 -11
  160. package/template/CLAUDE.md +1 -1
  161. package/template/package.json +1 -1
  162. package/template/src/server/rpc/getHello.ts +3 -3
  163. package/template/src/ui/pages/page.abide +2 -2
  164. package/template/test/app.test.ts +1 -1
  165. package/src/lib/server/reachable.ts +0 -45
  166. package/src/lib/server/sockets/types/Socket.ts +0 -23
  167. package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
  168. package/src/lib/shared/DEFER_MIN_ARRAY_LENGTH.ts +0 -14
  169. package/src/lib/shared/baseResolver.ts +0 -10
  170. package/src/lib/shared/cacheKeyOf.ts +0 -7
  171. package/src/lib/shared/cacheKeyStore.ts +0 -8
  172. package/src/lib/shared/cacheStoreResolver.ts +0 -12
  173. package/src/lib/shared/globalCacheStore.ts +0 -15
  174. package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
  175. package/src/lib/shared/globalCacheStoreSlot.ts +0 -9
  176. package/src/lib/shared/pageResolver.ts +0 -16
  177. package/src/lib/shared/recordCacheKey.ts +0 -6
  178. package/src/lib/shared/requestScopeResolver.ts +0 -12
  179. package/src/lib/shared/setBaseResolver.ts +0 -4
  180. package/src/lib/shared/setCacheStoreResolver.ts +0 -4
  181. package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
  182. package/src/lib/shared/setPageResolver.ts +0 -4
  183. package/src/lib/shared/setRequestScopeResolver.ts +0 -4
  184. package/src/lib/shared/toBunRoutePattern.ts +0 -28
  185. package/src/lib/shared/types/TailOptions.ts +0 -10
  186. package/src/lib/ui/deferResume.ts +0 -36
  187. package/src/lib/ui/dom/firstElementBetween.ts +0 -14
  188. package/src/lib/ui/matchRoute.ts +0 -106
  189. package/src/lib/ui/runtime/scheduleWake.ts +0 -28
  190. package/src/lib/ui/runtime/whenIdle.ts +0 -21
  191. package/src/lib/ui/runtime/whenVisible.ts +0 -105
  192. package/src/lib/ui/tail.ts +0 -324
  193. /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
  194. /package/src/lib/{server/sockets → shared}/types/SocketServerFrame.ts +0 -0
@@ -1,21 +1,30 @@
1
1
  export type RouteSegment =
2
2
  | { kind: 'literal'; value: string }
3
- | { kind: 'param'; name: string; catchAll: boolean }
3
+ | { kind: 'param'; name: string; catchAll: boolean; optional: boolean }
4
4
 
5
5
  /*
6
6
  Splits a abide route URL into typed segments. `[name]` becomes a param,
7
- `[...rest]` becomes a catch-all param, anything else is a literal. Used
8
- by toBunRoutePattern (server-side Bun pattern emission) and writeRoutesDts
9
- (client-side `Routes` type augmentation) so the two consumers can't drift
10
- on what counts as a param.
7
+ `[[name]]` an optional param, `[...rest]` a catch-all param, anything else a
8
+ literal. Used by matchRoute (both the server's fetch dispatch and the client
9
+ router) and writeRoutesDts (client-side `Routes` type augmentation) so the
10
+ consumers can't drift on what counts as a param.
11
11
  */
12
12
  export function parseRouteSegments(routeUrl: string): RouteSegment[] {
13
13
  return routeUrl.split('/').map((segment) => {
14
+ if (segment.startsWith('[[') && segment.endsWith(']]')) {
15
+ const name = segment.slice(2, -2)
16
+ /* `[[...rest]]` is redundant — a catch-all already matches zero
17
+ segments — but normalizing beats mis-parsing brackets into the name. */
18
+ if (name.startsWith('...')) {
19
+ return { kind: 'param', name: name.slice(3), catchAll: true, optional: false }
20
+ }
21
+ return { kind: 'param', name, catchAll: false, optional: true }
22
+ }
14
23
  if (segment.startsWith('[...') && segment.endsWith(']')) {
15
- return { kind: 'param', name: segment.slice(4, -1), catchAll: true }
24
+ return { kind: 'param', name: segment.slice(4, -1), catchAll: true, optional: false }
16
25
  }
17
26
  if (segment.startsWith('[') && segment.endsWith(']')) {
18
- return { kind: 'param', name: segment.slice(1, -1), catchAll: false }
27
+ return { kind: 'param', name: segment.slice(1, -1), catchAll: false, optional: false }
19
28
  }
20
29
  return { kind: 'literal', value: segment }
21
30
  })
@@ -0,0 +1,41 @@
1
+ import { cache } from './cache.ts'
2
+ import type { CacheOptions } from './types/CacheOptions.ts'
3
+ import type { CacheSelector } from './types/CacheSelector.ts'
4
+ import type { RemoteFunction } from './types/RemoteFunction.ts'
5
+
6
+ /*
7
+ Mutate the retained value of the matching cached read(s) in place — reactive
8
+ (readers re-render), no network. The optimistic-update / real-time primitive:
9
+ patch a cached list from a socket frame (`on(chat, m => getList.patch(l => [...l,
10
+ m]))`) or apply an optimistic edit before a write lands. The updater receives the
11
+ current decoded value and returns the next.
12
+
13
+ patch(getFoo, args, updater) → that exact call
14
+ patch(getFoo, updater) → every args-variant of that rpc
15
+ patch({ tags }, updater) → every entry sharing a tag
16
+
17
+ Instance sugar `getFoo.patch(args?, updater)` ≡ `patch(getFoo, args, updater)`.
18
+ The updater is always the last argument; a not-yet-read key has nothing to patch.
19
+ */
20
+ // @documentation cache
21
+ export function patch<Args, Return>(
22
+ fn: RemoteFunction<Args, Return> | ((args?: Args) => Promise<Return>),
23
+ args: Args | undefined,
24
+ updater: (current: Return) => Return,
25
+ ): void
26
+ export function patch<Args, Return>(
27
+ fn: RemoteFunction<Args, Return> | ((args?: Args) => Promise<Return>),
28
+ updater: (current: Return) => Return,
29
+ ): void
30
+ export function patch<Return>(
31
+ selector: Pick<CacheOptions, 'tags'>,
32
+ updater: (current: Return) => Return,
33
+ ): void
34
+ export function patch(selector: unknown, argsOrUpdater: unknown, maybeUpdater?: unknown): void {
35
+ /* Updater is always last: 3 args → (selector, args, updater); 2 args → (selector, updater). */
36
+ const updater = (maybeUpdater !== undefined ? maybeUpdater : argsOrUpdater) as (
37
+ current: unknown,
38
+ ) => unknown
39
+ const args = maybeUpdater !== undefined ? argsOrUpdater : undefined
40
+ cache.patch(selector as CacheSelector<unknown, unknown>, args, updater)
41
+ }
@@ -0,0 +1,35 @@
1
+ import { cache } from './cache.ts'
2
+ import type { RemoteFunction } from './types/RemoteFunction.ts'
3
+ import type { Socket } from './types/Socket.ts'
4
+
5
+ /*
6
+ The value member of the probe family: the currently-retained value, synchronously,
7
+ without triggering anything — `T | undefined` (undefined when nothing is retained
8
+ yet). For a cached read it is the retained cache value (reactive in a tracking
9
+ scope — re-runs when a refresh lands or a patch mutates it; a one-shot snapshot
10
+ otherwise). For a subscribable (socket / stream) it is the latest frame, read off
11
+ the source's own `.peek()`. Instance sugar `getFoo.peek(args?)` ≡ `peek(getFoo,
12
+ args?)`, `socket.peek()` ≡ `peek(socket)`.
13
+
14
+ peek(getFoo, args?) → the retained value for that call
15
+ peek(socket) → the latest frame
16
+ */
17
+ // @documentation probes
18
+ export function peek<Args, Return>(
19
+ fn: RemoteFunction<Args, Return>,
20
+ args?: Args,
21
+ ): Return | undefined
22
+ export function peek<T>(source: Socket<T>): T | undefined
23
+ export function peek(source: unknown, args?: unknown): unknown {
24
+ /* A subscribable (socket/stream) carries its own latest-frame probe; an rpc does not
25
+ have Symbol.asyncIterator, so this cleanly splits the two even though both expose a
26
+ `.peek` method. */
27
+ if (
28
+ source !== null &&
29
+ typeof source === 'object' &&
30
+ typeof (source as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function'
31
+ ) {
32
+ return (source as { peek: () => unknown }).peek()
33
+ }
34
+ return cache.peek(source as RemoteFunction<unknown, unknown>, args as never)
35
+ }
@@ -0,0 +1,40 @@
1
+ import { type ExportCallSite, findExportCallSite } from './findExportCallSite.ts'
2
+ import { importNamesToStrip } from './importNamesToStrip.ts'
3
+ import { stripImport } from './stripImport.ts'
4
+
5
+ export type RemoteExport = {
6
+ /* The source with the user's dead server-helper import removed. */
7
+ stripped: string
8
+ /* The one exported `helper(...)` call the surface must rewrite. */
9
+ site: ExportCallSite
10
+ }
11
+
12
+ /*
13
+ The scan-once envelope every file-based remote surface ($rpc, $sockets) shares:
14
+ strip the user's server-helper import under both the project's chosen name and
15
+ the canonical package name (so the dead import can't side-effect-load the stub
16
+ into the server bundle), then locate the single exported helper call. Each
17
+ surface passes the subpaths it places under a name (`server/GET`…/`server/socket`)
18
+ and the ident predicate that recognizes its call; the character-by-character
19
+ walk still happens exactly once. Returns undefined when the module declares no
20
+ matching export (a plain `.ts` helper alongside the surface), and throws the
21
+ surface's own single-export error when it declares more than one.
22
+ */
23
+ export function prepareRemoteExport(
24
+ source: string,
25
+ importName: string,
26
+ subpathsForName: (name: string) => string[],
27
+ identMatches: (ident: string) => boolean,
28
+ singleExportError: string,
29
+ ): RemoteExport | undefined {
30
+ const stripped = importNamesToStrip(importName).reduce(
31
+ (current, name) =>
32
+ subpathsForName(name).reduce((acc, subpath) => stripImport(acc, subpath), current),
33
+ source,
34
+ )
35
+ const site = findExportCallSite(stripped, identMatches, singleExportError)
36
+ if (!site) {
37
+ return undefined
38
+ }
39
+ return { stripped, site }
40
+ }
@@ -1,8 +1,6 @@
1
- import { findExportCallSite } from './findExportCallSite.ts'
2
- import { importNamesToStrip } from './importNamesToStrip.ts'
3
1
  import { isReadOnlyMethod } from './isReadOnlyMethod.ts'
2
+ import { prepareRemoteExport } from './prepareRemoteExport.ts'
4
3
  import { skipNonCode } from './skipNonCode.ts'
5
- import { stripImport } from './stripImport.ts'
6
4
  import type { HttpMethod } from './types/HttpMethod.ts'
7
5
 
8
6
  const RPC_NAMES = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const
@@ -15,6 +13,9 @@ export type PreparedRpcModule = {
15
13
  method: HttpMethod
16
14
  /* `outbox: true` in the opts — the client proxy is emitted durable. */
17
15
  durable: boolean
16
+ /* The handler calls jsonl()/sse() — the client proxy is emitted streaming (bare call
17
+ returns the Subscribable). Congruent with the RemoteCallable conditional by construction. */
18
+ streaming: boolean
18
19
  exportName: string
19
20
  rewriteForServer: (url: string) => string
20
21
  }
@@ -52,32 +53,43 @@ export function prepareRpcModule(
52
53
  ): PreparedRpcModule | undefined {
53
54
  /*
54
55
  The "no barrels" surface places each method at its own path
55
- (`abide/server/GET`, `abide/server/POST`, …) strip every one so
56
- the user's method import doesn't linger and side-effect-load the
57
- stub module into the server bundle. The user may import under the
58
- project's chosen name or the canonical package name, so strip both.
56
+ (`abide/server/GET`, `abide/server/POST`, …), so the strip subpaths are
57
+ every method under each import name — the user's method import must not
58
+ linger and side-effect-load the stub module into the server bundle.
59
59
  */
60
- const stripped = importNamesToStrip(importName).reduce(
61
- (current, name) =>
62
- RPC_NAMES.reduce(
63
- (acc, method) => stripImport(acc, `${name}/server/${method}`),
64
- current,
65
- ),
60
+ const prepared = prepareRemoteExport(
66
61
  source,
62
+ importName,
63
+ (name) => RPC_NAMES.map((method) => `${name}/server/${method}`),
64
+ (ident) => RPC_SET.has(ident),
65
+ SINGLE_EXPORT_ERROR,
67
66
  )
68
- const site = findExportCallSite(stripped, (ident) => RPC_SET.has(ident), SINGLE_EXPORT_ERROR)
69
- if (!site) {
67
+ if (!prepared) {
70
68
  return undefined
71
69
  }
70
+ const { stripped, site } = prepared
72
71
  const method = site.ident as HttpMethod
73
72
  const durable = detectDurable(stripped, site.parenStart, site.parenEnd, method)
73
+ const streaming = detectStreaming(stripped, site.parenStart, site.parenEnd)
74
74
  return {
75
75
  method,
76
76
  durable,
77
+ streaming,
77
78
  exportName: site.exportName,
78
79
  rewriteForServer(url: string): string {
79
80
  const binding = `__abideDefineRpc__(${JSON.stringify(method)}, ${JSON.stringify(url)}, `
80
- return stripped.slice(0, site.callStart) + binding + stripped.slice(site.parenStart + 1)
81
+ const head = stripped.slice(0, site.callStart) + binding
82
+ if (!streaming) {
83
+ return head + stripped.slice(site.parenStart + 1)
84
+ }
85
+ /* Inject the build-time streaming flag into the handler's opts, preserving any author
86
+ opts by spread. Split the call's top-level args (handler + optional opts) — dropping
87
+ a trailing-comma empty part — so a trailing comma or absent opts can't produce
88
+ `...()`. `head` ends after the METHOD( paren; keep everything from `)` onward. */
89
+ const parts = splitTopLevelArgs(stripped, site.parenStart, site.parenEnd)
90
+ const [handler, opts] = parts
91
+ const injected = opts ? `{ streaming: true, ...(${opts}) }` : '{ streaming: true }'
92
+ return `${head}${handler}, ${injected}${stripped.slice(site.parenEnd)}`
81
93
  },
82
94
  }
83
95
  }
@@ -111,6 +123,76 @@ function detectDurable(
111
123
  return durable
112
124
  }
113
125
 
126
+ const STREAM_HELPERS = new Set(['jsonl', 'sse'])
127
+ const IDENT_START = /[A-Za-z_$]/
128
+ const IDENT_PART = /[A-Za-z0-9_$]/
129
+
130
+ /* True when the handler returns a streaming body — it calls abide's jsonl()/sse(), the only
131
+ constructors of a `TypedResponse<AsyncIterable<Frame>>`. Congruent by construction with the
132
+ RemoteCallable conditional (Return is an AsyncIterable iff the handler calls jsonl/sse). Scans
133
+ the whole call-arg region depth-blind (the opts never mention jsonl/sse), skipping
134
+ strings/comments/regex via skipNonCode so a mention in a literal can't misfire. Same
135
+ literal-only limit as `outbox`: an indirection through a wrapper function isn't seen. */
136
+ function detectStreaming(source: string, parenStart: number, parenEnd: number): boolean {
137
+ let i = parenStart + 1
138
+ while (i < parenEnd) {
139
+ const skipped = skipNonCode(source, i)
140
+ if (skipped !== undefined) {
141
+ i = skipped
142
+ continue
143
+ }
144
+ if (IDENT_START.test(source[i] ?? '') && !IDENT_PART.test(source[i - 1] ?? '')) {
145
+ let j = i + 1
146
+ while (j < parenEnd && IDENT_PART.test(source[j] ?? '')) {
147
+ j += 1
148
+ }
149
+ if (STREAM_HELPERS.has(source.slice(i, j))) {
150
+ let k = j
151
+ while (k < parenEnd && /\s/.test(source[k] ?? '')) {
152
+ k += 1
153
+ }
154
+ /* `jsonl(` a call, `jsonl<` a generic call — either is the streaming constructor. */
155
+ if (source[k] === '(' || source[k] === '<') {
156
+ return true
157
+ }
158
+ }
159
+ i = j
160
+ continue
161
+ }
162
+ i += 1
163
+ }
164
+ return false
165
+ }
166
+
167
+ /* The call's top-level arguments, trimmed, with empty parts (a trailing comma) dropped —
168
+ `[handler]` or `[handler, opts]`. Depth-aware and skips strings/comments/regex so commas
169
+ inside the handler body or opts don't miscount. */
170
+ function splitTopLevelArgs(source: string, parenStart: number, parenEnd: number): string[] {
171
+ const parts: string[] = []
172
+ let depth = 0
173
+ let start = parenStart + 1
174
+ let i = parenStart + 1
175
+ while (i < parenEnd) {
176
+ const skipped = skipNonCode(source, i)
177
+ if (skipped !== undefined) {
178
+ i = skipped
179
+ continue
180
+ }
181
+ const c = source[i]
182
+ if (c === '(' || c === '{' || c === '[') {
183
+ depth += 1
184
+ } else if (c === ')' || c === '}' || c === ']') {
185
+ depth -= 1
186
+ } else if (c === ',' && depth === 0) {
187
+ parts.push(source.slice(start, i).trim())
188
+ start = i + 1
189
+ }
190
+ i += 1
191
+ }
192
+ parts.push(source.slice(start, parenEnd).trim())
193
+ return parts.filter((part) => part.length > 0)
194
+ }
195
+
114
196
  /*
115
197
  The text of the call's final argument — the opts object for a `METHOD(handler, opts)` call.
116
198
  Walks the arg list depth-aware, skipping strings / templates / comments / regex (skipNonCode)
@@ -1,6 +1,4 @@
1
- import { findExportCallSite } from './findExportCallSite.ts'
2
- import { importNamesToStrip } from './importNamesToStrip.ts'
3
- import { stripImport } from './stripImport.ts'
1
+ import { prepareRemoteExport } from './prepareRemoteExport.ts'
4
2
 
5
3
  const SINGLE_EXPORT_ERROR =
6
4
  '[abide] $sockets module contains more than one `socket(...)` export — each file must declare exactly one socket'
@@ -14,27 +12,25 @@ export type PreparedSocketModule = {
14
12
  Scans a `$sockets/**` module once and returns its declared export name
15
13
  plus a closure that, given the socket name, emits the server-side
16
14
  rewrite (`__abideDefineSocket__("<name>"[, opts])` spliced into the
17
- original source). The single scan replaces the prior separate
18
- extract + rewrite passes, so the resolver plugin only walks each source
19
- character-by-character once.
15
+ original source). The strip + find envelope is the shared prepareRemoteExport
16
+ scan; this module only supplies the socket import subpath and ident, then
17
+ splices its own binding.
20
18
  */
21
19
  export function prepareSocketModule(
22
20
  source: string,
23
21
  importName: string,
24
22
  ): PreparedSocketModule | undefined {
25
- /*
26
- Strip the user's `socket` import under the project's chosen name and the
27
- canonical package name so the dead import can't side-effect-load the
28
- socket helper into the server bundle.
29
- */
30
- const stripped = importNamesToStrip(importName).reduce(
31
- (current, name) => stripImport(current, `${name}/server/socket`),
23
+ const prepared = prepareRemoteExport(
32
24
  source,
25
+ importName,
26
+ (name) => [`${name}/server/socket`],
27
+ (ident) => ident === 'socket',
28
+ SINGLE_EXPORT_ERROR,
33
29
  )
34
- const site = findExportCallSite(stripped, (ident) => ident === 'socket', SINGLE_EXPORT_ERROR)
35
- if (!site) {
30
+ if (!prepared) {
36
31
  return undefined
37
32
  }
33
+ const { stripped, site } = prepared
38
34
  return {
39
35
  exportName: site.exportName,
40
36
  rewriteForServer(name: string): string {
@@ -11,6 +11,10 @@ is stable under arg reordering. Returns the query body without a leading
11
11
  walk — no entries / filtered / URLSearchParams intermediates — to keep the
12
12
  hot GET-cache path allocation-light. Callers validate that `args` is a
13
13
  plain object first; this assumes that.
14
+
15
+ A non-primitive VALUE throws: String() would flatten every object to
16
+ '[object Object]', so distinct calls would collide onto one request URL and
17
+ one cache key — the first call's data silently served for the second.
14
18
  */
15
19
  export function queryStringFromArgs(args: Record<string, unknown>, sort: boolean): string {
16
20
  const keys = sort ? Object.keys(args).sort() : Object.keys(args)
@@ -20,6 +24,13 @@ export function queryStringFromArgs(args: Record<string, unknown>, sort: boolean
20
24
  if (value === undefined) {
21
25
  continue
22
26
  }
27
+ const kind = typeof value
28
+ if (value !== null && (kind === 'object' || kind === 'function' || kind === 'symbol')) {
29
+ const got = Array.isArray(value) ? 'array' : kind
30
+ throw new TypeError(
31
+ `[abide] query arg "${key}" must be a primitive — got ${got} (query-carrying methods can't encode structured values; move the arg to a POST body)`,
32
+ )
33
+ }
23
34
  query += query ? '&' : ''
24
35
  query += `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`
25
36
  }
@@ -0,0 +1,102 @@
1
+ import { createReachable } from './createReachable.ts'
2
+ import { parseBoundedEnvInt } from './parseBoundedEnvInt.ts'
3
+
4
+ /*
5
+ Isomorphic outbound reachability. `await reachable(host)` HEADs the host's
6
+ origin: the first call awaits a real probe (faithful — a down host costs the
7
+ full timeout, an up host one handshake) and starts a background poll that
8
+ re-probes every TTL, so every later call resolves instantly off the warm
9
+ value, fresh within one TTL. A host going down is caught within ~failureLimit
10
+ polls; recovery flips it back automatically.
11
+
12
+ if (!(await reachable('api.example.com'))) return error(503)
13
+
14
+ No host asks about the app's own backend: constant true on the server (the
15
+ server IS the backend — health()'s rule) and on a loopback origin (`abide
16
+ dev`, a desktop bundle's embedded server — this machine, reachable by
17
+ construction even with the network gone); a deployed origin probes like any
18
+ other host.
19
+
20
+ A bare host defaults to https; pass an explicit http://… for a non-TLS host.
21
+ Answers "can I connect to this host," NOT "is my endpoint healthy": any
22
+ completed HTTP response (even 4xx/5xx, even a 405 to HEAD) counts as reachable;
23
+ only a connection failure or timeout reads as unreachable.
24
+
25
+ Same callable both sides. The server has no ambient connectivity signal, so
26
+ this is the honest way to fail a doomed outbound call fast — see online() for
27
+ the inbound/client-reported counterpart. The browser probes no-cors (a
28
+ completed opaque response proves connectivity without the host's CORS
29
+ blessing) and composes navigator.onLine in at read time, so a lost network
30
+ reports false instantly instead of waiting out the warm value — except for
31
+ loopback hosts, which need no network.
32
+
33
+ ABIDE_REACHABLE_TTL (poll cadence / freshness, ms) and ABIDE_REACHABLE_TIMEOUT
34
+ (per-HEAD bound, ms) tune the server defaults; the browser has no env, so it
35
+ runs the defaults. The timeout is deliberately generous so a healthy-but-
36
+ distant host over a slow link is not mis-read as down.
37
+ */
38
+ const env = typeof process === 'undefined' ? undefined : process.env
39
+ const TTL_MS = parseBoundedEnvInt(env?.ABIDE_REACHABLE_TTL, 1_000, 600_000) ?? 30_000
40
+ const TIMEOUT_MS = parseBoundedEnvInt(env?.ABIDE_REACHABLE_TIMEOUT, 100, 60_000) ?? 3_000
41
+ /* Stop polling a host nobody has read in a few TTLs; the next read restarts it cold. */
42
+ const IDLE_MS = TTL_MS * 3
43
+
44
+ /* Status-agnostic HEAD: a completed response proves connectivity; reject/timeout does not.
45
+ The browser probes no-cors — an opaque response still completes, so a foreign origin
46
+ without CORS headers can't mis-read an up host as down. */
47
+ async function probeOrigin(origin: string): Promise<boolean> {
48
+ try {
49
+ await fetch(origin, {
50
+ method: 'HEAD',
51
+ mode: typeof window === 'undefined' ? undefined : 'no-cors',
52
+ signal: AbortSignal.timeout(TIMEOUT_MS),
53
+ })
54
+ return true
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ const registry = createReachable({
61
+ probe: probeOrigin,
62
+ intervalMs: TTL_MS,
63
+ idleMs: IDLE_MS,
64
+ })
65
+
66
+ /* Loopback = this machine: no network between, so no probe and no offline gate. */
67
+ function isLoopbackHost(hostname: string): boolean {
68
+ return (
69
+ hostname === 'localhost' ||
70
+ hostname.endsWith('.localhost') ||
71
+ hostname.startsWith('127.') ||
72
+ hostname === '[::1]'
73
+ )
74
+ }
75
+
76
+ /* Mirrors the registry's normalization: a bare host defaults to https. */
77
+ function hostnameOf(host: string | URL): string {
78
+ const url = typeof host === 'string' && !/^https?:\/\//i.test(host) ? `https://${host}` : host
79
+ return new URL(url).hostname
80
+ }
81
+
82
+ // @documentation observability
83
+ export function reachable(host?: string | URL): Promise<boolean> {
84
+ if (typeof window === 'undefined') {
85
+ /* No host = the app's own backend, and the server IS the backend. */
86
+ return host === undefined ? Promise.resolve(true) : registry.reachable(host)
87
+ }
88
+ if (host === undefined) {
89
+ /* The app's own backend = the page's origin. Loopback served this very page
90
+ from this machine — reachable by construction, never worth a poll. */
91
+ if (isLoopbackHost(location.hostname)) {
92
+ return Promise.resolve(true)
93
+ }
94
+ host = location.origin
95
+ }
96
+ /* window-gated because Bun defines a partial navigator; `=== false` because only an
97
+ explicit offline report is reliable. A loopback host is exempt — no network needed. */
98
+ if (navigator.onLine === false && !isLoopbackHost(hostnameOf(host))) {
99
+ return Promise.resolve(false)
100
+ }
101
+ return registry.reachable(host)
102
+ }
@@ -0,0 +1,22 @@
1
+ import { cache } from './cache.ts'
2
+ import type { CacheSelector } from './types/CacheSelector.ts'
3
+
4
+ /*
5
+ Refetch every cached read matching the selector, keeping the stale value visible
6
+ until the fresh one swaps in (refreshing() true meanwhile) — the smart-call
7
+ refetch. Because the smart read retains its value on the client (SWR
8
+ unconditional there), a refresh always refetches-and-swaps; it never drops to a
9
+ pending blank. Follows the shared selector grammar:
10
+
11
+ refresh(getFoo, args) → that exact call
12
+ refresh(getFoo) → every args-variant of that rpc
13
+ refresh({ tags }) → every entry sharing a tag
14
+ refresh() → every entry
15
+
16
+ Instance sugar `getFoo.refresh(args?)` ≡ `refresh(getFoo, args?)`. Reports, never
17
+ retains a spinner — pair with `refreshing()` to surface the in-flight reload.
18
+ */
19
+ // @documentation cache
20
+ export function refresh<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args): void {
21
+ cache.refresh(arg, args)
22
+ }
@@ -1,10 +1,13 @@
1
- import { requestScopeResolver } from './requestScopeResolver.ts'
1
+ import { createResolverSlot } from './createResolverSlot.ts'
2
+ import type { RequestScopeInfo } from './types/RequestScopeInfo.ts'
2
3
 
3
4
  /*
4
- Internal slot the runtime entries register their request-scope resolver into (see
5
- requestScopeResolver). Exposed so callers read `.resolver?.()` and test helpers
6
- snapshot/poke `.resolver` directly. Undefined resolver — or one returning
7
- undefined outside any request means "no scope": trace() returns undefined and
8
- log lines print without the context prefix.
5
+ The request-scope slot. The server installs an ALS-backed resolver
6
+ (createServer, reading the RequestStore); the client a module-singleton seeded
7
+ from __SSR__ (startClient). No fallback creator an unset resolver (or one
8
+ returning undefined outside any request) means "no scope": callers read
9
+ `.resolver?.()` and treat undefined as absent (trace() returns undefined and
10
+ log lines print without the context prefix). Test helpers snapshot/poke
11
+ `.resolver` directly.
9
12
  */
10
- export const requestScopeSlot = requestScopeResolver.slot
13
+ export const requestScopeSlot = createResolverSlot<RequestScopeInfo>()
@@ -3,18 +3,18 @@ import { parseRouteSegments } from './parseRouteSegments.ts'
3
3
  /*
4
4
  The TypeScript type literal for a route's path params — `{ "id": string }` for
5
5
  `/post/[id]`, `Record<string, never>` for a param-less route. Walks the `[name]` /
6
- `[...rest]` segments; catch-all segments map to `string` under their declared name
7
- (the server's toBunRoutePattern renames Bun's `*` key back to that name when
8
- dispatching, so the page sees `params.rest`, not `params['*']`). The single source of
9
- truth shared by `writeRoutesDts` (page.params typing) and the `.abide` check shadow
10
- (page `props()` typing).
6
+ `[[name]]` / `[...rest]` segments; a catch-all maps to `string` under its declared
7
+ name (matchRoute captures it there, `''` when it consumed nothing), and an
8
+ `[[optional]]` maps to an optional key (absent from params when unmatched). The
9
+ single source of truth shared by `writeRoutesDts` (page.params typing) and the
10
+ `.abide` check shadow (page `props()` typing).
11
11
  */
12
12
  export function routeParamsShape(routeUrl: string): string {
13
- const keys = parseRouteSegments(routeUrl)
13
+ const entries = parseRouteSegments(routeUrl)
14
14
  .filter((segment) => segment.kind === 'param')
15
- .map((segment) => segment.name)
16
- if (keys.length === 0) {
15
+ .map((segment) => `${JSON.stringify(segment.name)}${segment.optional ? '?' : ''}: string`)
16
+ if (entries.length === 0) {
17
17
  return 'Record<string, never>'
18
18
  }
19
- return `{ ${keys.map((key) => `${JSON.stringify(key)}: string`).join('; ')} }`
19
+ return `{ ${entries.join('; ')} }`
20
20
  }
@@ -0,0 +1,69 @@
1
+ import { createSignalNode } from '../ui/runtime/createSignalNode.ts'
2
+ import { track } from '../ui/runtime/track.ts'
3
+ import { trigger } from '../ui/runtime/trigger.ts'
4
+
5
+ /*
6
+ The rpc's error memory — the last error per call identity (the keyForRemoteCall
7
+ key), recorded at the rpc call boundary and cleared on a later success or
8
+ invalidate. Orthogonal to the cache entry lifecycle (design Fork 1): the cache
9
+ still evicts on error exactly as before; this remembers the error for reactive
10
+ reads. One shared signal node backs reactivity — readers `track` it, writers
11
+ `trigger` it — the same coarse lifecycle-channel pattern the cache probes use.
12
+ */
13
+ const node = createSignalNode(undefined)
14
+ const errors = new Map<string, unknown>()
15
+
16
+ /* Insertion order = recency; a re-record moves the key to the tail so readAny returns latest. */
17
+ function bump(key: string, error: unknown): void {
18
+ errors.delete(key)
19
+ errors.set(key, error)
20
+ }
21
+
22
+ export const rpcErrorRegistry = {
23
+ record(key: string, error: unknown): void {
24
+ /* Client-only: on the server the boundary runs per request, and this Map is a single
25
+ module global — recording would leak errors across requests/users and grow unbounded.
26
+ SSR errors surface through `{:catch}`; the reactive probe is a client feature, so the
27
+ server keeps the Map empty (reads return undefined there). */
28
+ if (typeof window === 'undefined') {
29
+ return
30
+ }
31
+ bump(key, error)
32
+ trigger(node)
33
+ },
34
+ clear(key: string): void {
35
+ if (errors.delete(key)) {
36
+ trigger(node)
37
+ }
38
+ },
39
+ /* Clears every error whose key is, or is prefixed by, the selector's `method url` —
40
+ so `invalidate(fn)` resets a whole rpc's errors, and `invalidate(fn, args)` (an exact
41
+ key prefix) resets just that call. Covers bare-call errors that never became entries. */
42
+ clearMatching(prefix: string): void {
43
+ let changed = false
44
+ for (const key of errors.keys()) {
45
+ if (key === prefix || key.startsWith(`${prefix} `)) {
46
+ errors.delete(key)
47
+ changed = true
48
+ }
49
+ }
50
+ if (changed) {
51
+ trigger(node)
52
+ }
53
+ },
54
+ read(key: string): unknown {
55
+ track(node)
56
+ return errors.get(key)
57
+ },
58
+ /* Most-recent error whose key is, or is prefixed by, the fn selector's `method url`. */
59
+ readAny(prefix: string): unknown {
60
+ track(node)
61
+ let latest: unknown
62
+ for (const [key, error] of errors) {
63
+ if (key === prefix || key.startsWith(`${prefix} `)) {
64
+ latest = error
65
+ }
66
+ }
67
+ return latest
68
+ },
69
+ }