@abide/abide 0.45.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 (176) hide show
  1. package/AGENTS.md +365 -320
  2. package/CHANGELOG.md +96 -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 +7 -1
  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 +47 -11
  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/prepareNestedScript.ts +31 -17
  118. package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
  119. package/src/lib/ui/compile/signalCallee.ts +24 -0
  120. package/src/lib/ui/compile/stripEffects.ts +14 -11
  121. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  122. package/src/lib/ui/dom/appendStatic.ts +9 -1
  123. package/src/lib/ui/dom/appendText.ts +15 -3
  124. package/src/lib/ui/dom/assertClaimedText.ts +20 -0
  125. package/src/lib/ui/dom/awaitBlock.ts +17 -56
  126. package/src/lib/ui/dom/bindSelectValue.ts +48 -0
  127. package/src/lib/ui/dom/each.ts +12 -1
  128. package/src/lib/ui/dom/hydrate.ts +10 -0
  129. package/src/lib/ui/effect.ts +4 -3
  130. package/src/lib/ui/installHotBridge.ts +4 -2
  131. package/src/lib/ui/remoteProxy.ts +18 -2
  132. package/src/lib/ui/renderToStream.ts +9 -13
  133. package/src/lib/ui/resumeSeedScript.ts +2 -2
  134. package/src/lib/ui/router.ts +21 -2
  135. package/src/lib/ui/runtime/RESUME.ts +0 -6
  136. package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
  137. package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
  138. package/src/lib/ui/scope.ts +6 -3
  139. package/src/lib/ui/seedBootState.ts +53 -0
  140. package/src/lib/ui/socketChannel.ts +2 -2
  141. package/src/lib/ui/socketProxy.ts +9 -3
  142. package/src/lib/ui/startClient.ts +16 -30
  143. package/src/lib/ui/state.ts +44 -13
  144. package/src/lib/ui/sync.ts +11 -5
  145. package/src/lib/ui/tryEncodeResume.ts +2 -5
  146. package/src/lib/ui/types/Scope.ts +14 -10
  147. package/src/lib/ui/watch.ts +140 -0
  148. package/src/serverEntry.ts +6 -6
  149. package/template/CLAUDE.md +1 -1
  150. package/template/package.json +1 -1
  151. package/template/src/server/rpc/getHello.ts +3 -3
  152. package/template/src/ui/pages/page.abide +2 -2
  153. package/template/test/app.test.ts +1 -1
  154. package/src/lib/server/reachable.ts +0 -45
  155. package/src/lib/server/sockets/types/Socket.ts +0 -23
  156. package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
  157. package/src/lib/shared/baseResolver.ts +0 -10
  158. package/src/lib/shared/cacheKeyOf.ts +0 -7
  159. package/src/lib/shared/cacheKeyStore.ts +0 -8
  160. package/src/lib/shared/cacheStoreResolver.ts +0 -12
  161. package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
  162. package/src/lib/shared/pageResolver.ts +0 -16
  163. package/src/lib/shared/recordCacheKey.ts +0 -6
  164. package/src/lib/shared/requestScopeResolver.ts +0 -12
  165. package/src/lib/shared/setBaseResolver.ts +0 -4
  166. package/src/lib/shared/setCacheStoreResolver.ts +0 -4
  167. package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
  168. package/src/lib/shared/setPageResolver.ts +0 -4
  169. package/src/lib/shared/setRequestScopeResolver.ts +0 -4
  170. package/src/lib/shared/toBunRoutePattern.ts +0 -28
  171. package/src/lib/shared/types/TailOptions.ts +0 -10
  172. package/src/lib/ui/deferResume.ts +0 -29
  173. package/src/lib/ui/matchRoute.ts +0 -106
  174. package/src/lib/ui/tail.ts +0 -324
  175. /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
  176. /package/src/lib/{server/sockets → shared}/types/SocketServerFrame.ts +0 -0
@@ -1,6 +1,7 @@
1
- import { bodyValueForKind, DEFER } from './bodyValueForKind.ts'
1
+ import { bodyValueForKind } from './bodyValueForKind.ts'
2
2
  import { contentBodyKind } from './contentBodyKind.ts'
3
3
  import { contentTypeOf } from './contentTypeOf.ts'
4
+ import { DEFER } from './DEFER.ts'
4
5
  import type { CacheEntry } from './types/CacheEntry.ts'
5
6
  import type { CacheSnapshotEntry } from './types/CacheSnapshotEntry.ts'
6
7
 
@@ -19,38 +20,18 @@ export function cacheEntryFromSnapshot(entry: CacheSnapshotEntry): CacheEntry {
19
20
  statusText: entry.statusText,
20
21
  headers,
21
22
  })
22
- /* Deferred seed: decode nothing now — hand back a memoized materializer the first read
23
- invokes, so hydration pays no payload decode. Eager seed: decode up front as before. */
24
- const warm = entry.lazy
25
- ? memoizeWarm(() => warmValueFromSnapshot(entry.status, headers, entry.body))
26
- : undefined
27
23
  return {
28
24
  key: entry.key,
29
25
  promise: Promise.resolve(response),
30
26
  request: new Request(entry.url, { method: entry.method }),
31
27
  ttl: undefined,
32
28
  expiresAt: undefined,
33
- value: entry.lazy ? undefined : warmValueFromSnapshot(entry.status, headers, entry.body),
34
- warm,
29
+ value: warmValueFromSnapshot(entry.status, headers, entry.body),
35
30
  settled: true,
36
31
  hydrated: true,
37
32
  }
38
33
  }
39
34
 
40
- /* Wraps a warm decode so it runs at most once — the materialized value (including a
41
- legitimate undefined for a non-warmable status) is cached after the first call. */
42
- function memoizeWarm(decode: () => unknown): () => unknown {
43
- let materialized = false
44
- let value: unknown
45
- return () => {
46
- if (!materialized) {
47
- value = decode()
48
- materialized = true
49
- }
50
- return value
51
- }
52
- }
53
-
54
35
  /*
55
36
  Synchronously decodes a snapshot body so the warm entry reads without a
56
37
  microtask hop on first render. The json/text branches go through the shared
@@ -1,8 +1,12 @@
1
- import { cacheStoreResolver } from './cacheStoreResolver.ts'
1
+ import { createCacheStore } from './createCacheStore.ts'
2
+ import { createResolverSlot } from './createResolverSlot.ts'
3
+ import type { CacheStore } from './types/CacheStore.ts'
2
4
 
3
5
  /*
4
- Internal slot the runtime entries register their resolver into (see
5
- cacheStoreResolver). Exposed so test helpers snapshot/poke `.resolver` and
6
- `.fallback` directly.
6
+ The active-CacheStore slot. The server entry installs an ALS-backed resolver
7
+ (request-scoped); the client entry a module-singleton one. With no resolver
8
+ registered, a single fallback store is created lazily so isolated tests work
9
+ without booting the runtime; test helpers snapshot/poke `.resolver` and
10
+ `.fallback` directly. activeCacheStore is the public read.
7
11
  */
8
- export const cacheStoreSlot = cacheStoreResolver.slot
12
+ export const cacheStoreSlot = createResolverSlot<CacheStore>(createCacheStore)
@@ -1,7 +1,7 @@
1
- import { createLivenessWatch } from '../../shared/createLivenessWatch.ts'
1
+ import { createLivenessWatch } from './createLivenessWatch.ts'
2
2
 
3
3
  /*
4
- The reachability registry behind `abide/server/reachable`. A probe transport
4
+ The reachability registry behind `abide/shared/reachable`. A probe transport
5
5
  and the timings are injected so the public name wires them from env + a real
6
6
  HEAD while tests drive scripted outcomes on tiny intervals.
7
7
 
@@ -1,9 +1,12 @@
1
- import { decodeResponse } from './decodeResponse.ts'
1
+ import { attachRpcSelectorMethods } from './attachRpcSelectorMethods.ts'
2
+ import { cache } from './cache.ts'
2
3
  import { HttpError } from './HttpError.ts'
3
4
  import { keyForRemoteCall } from './keyForRemoteCall.ts'
4
5
  import { REMOTE_FUNCTION } from './REMOTE_FUNCTION.ts'
5
6
  import { recordRemoteMeta } from './recordRemoteMeta.ts'
7
+ import { rpcErrorRegistry } from './rpcErrorRegistry.ts'
6
8
  import { subscribableFromResponse } from './subscribableFromResponse.ts'
9
+ import type { CacheOptions } from './types/CacheOptions.ts'
7
10
  import type { ClientFlags } from './types/ClientFlags.ts'
8
11
  import type { HttpMethod } from './types/HttpMethod.ts'
9
12
  import type { RawRemoteFunction } from './types/RawRemoteFunction.ts'
@@ -47,8 +50,21 @@ export function createRemoteFunction<Args, Return>(opts: {
47
50
  opts?: RpcOptions,
48
51
  ) => Promise<Response>
49
52
  parseArgsForFetch?: (request: Request) => Promise<Args | undefined>
53
+ /* A streaming rpc (handler returns jsonl()/sse()): the bare call returns the Subscribable
54
+ directly (the iterable IS the value) rather than decoding one Response body. Emitted by
55
+ the bundler's syntactic scan; false/undefined keeps the decode-a-Response path. */
56
+ streaming?: boolean
50
57
  }): RemoteFunction<Args, Return> {
51
- const { method, url, clients, crossOrigin, buildRequest, invoke, parseArgsForFetch } = opts
58
+ const {
59
+ method,
60
+ url,
61
+ clients,
62
+ crossOrigin,
63
+ buildRequest,
64
+ invoke,
65
+ parseArgsForFetch,
66
+ streaming,
67
+ } = opts
52
68
 
53
69
  /*
54
70
  Dispatch is the one-stop entry for both the plain call (no prebuilt
@@ -90,19 +106,51 @@ export function createRemoteFunction<Args, Return>(opts: {
90
106
  Object.defineProperty(rawCall, REMOTE_FUNCTION, { value: true })
91
107
  const raw = rawCall as RawRemoteFunction<Args>
92
108
 
93
- function callable(args: Args | FormData, opts?: RpcOptions): Promise<Return> {
94
- return raw(args, opts).then(decodeResponse) as Promise<Return>
109
+ function callable(
110
+ args: Args | FormData,
111
+ opts?: RpcOptions,
112
+ ): Promise<Return> | Subscribable<Return> {
113
+ /* A streaming rpc (jsonl/sse) returns the Subscribable directly — the iterable IS the
114
+ value (for await / state(fn(args))). Deferred fetch, keyForRemoteCall-keyed so tail()
115
+ dedupes readers; no decode, so the error-capture path below doesn't apply. */
116
+ if (streaming) {
117
+ return subscribableFromResponse(keyForRemoteCall(method, url, args), () =>
118
+ raw(args as Args, opts),
119
+ )
120
+ }
121
+ /* The bare call IS the smart read: route through the cache store's smart-read
122
+ path so a replayable read is coalesced, retained (SWR unconditional), and
123
+ reactive, while a write is coalesce-only — the raw fetch moves to `.raw`.
124
+ `cache.read(callable, …)` brand-reads `callable.raw` for the undecoded variant
125
+ and decodes on the way out, so pass the callable (which carries `.raw`), not
126
+ `raw`. `opts` supplies cache options (ttl/tags/throttle/debounce); per-call
127
+ transport options live on `.raw` now. */
128
+ const key = keyForRemoteCall(method, url, args)
129
+ return cache
130
+ .read(callable as RemoteFunction<Args, Return>, args as Args, opts as CacheOptions)
131
+ .then(
132
+ /* Capture the rejection into the rpc error registry (design Part 4) keyed by call
133
+ identity, and clear it on success — the reactive `fn.error()` probe reads it. */
134
+ (value) => {
135
+ rpcErrorRegistry.clear(key)
136
+ return value as Return
137
+ },
138
+ (error: unknown) => {
139
+ /* A parked durable write throws a `kind: 'queued'` sentinel — it's pending
140
+ retry, not a failure. Don't record it, so fn.error() stays undefined for a
141
+ merely-parked write (pending() already reflects it via the outbox). */
142
+ if (!(error instanceof HttpError && error.kind === 'queued')) {
143
+ rpcErrorRegistry.record(key, error)
144
+ }
145
+ throw error
146
+ },
147
+ )
95
148
  }
96
149
  callable.method = method
97
150
  callable.url = url
98
151
  callable.clients = clients
99
152
  callable.crossOrigin = crossOrigin
100
153
  callable.raw = raw
101
- callable.stream = (args?: Args | FormData): Subscribable<Return> => {
102
- return subscribableFromResponse(keyForRemoteCall(method, url, args), () =>
103
- raw(args as Args),
104
- )
105
- }
106
154
  /* Uniform runtime guard for every rpc — the per-rpc data typing lives entirely in the
107
155
  RpcErrorGuard<Errors> signature RemoteFunction projects onto it (Errors flows from the
108
156
  rpc helper's declared type, not from here). */
@@ -132,5 +180,6 @@ export function createRemoteFunction<Args, Return>(opts: {
132
180
  : (request: Request): Promise<Response> => {
133
181
  return dispatch(undefined, undefined, request)
134
182
  }
183
+ attachRpcSelectorMethods(callable as RemoteFunction<Args, Return>)
135
184
  return callable as RemoteFunction<Args, Return>
136
185
  }
@@ -1,37 +1,30 @@
1
1
  import type { ResolverSlot } from './types/ResolverSlot.ts'
2
2
 
3
3
  /*
4
- The slot/resolver/reader triple the runtime entries share for cache store,
5
- page, and mount-base state. A runtime entry registers a side-specific resolver
6
- via `set` (ALS-backed on the server, a module singleton on the client); `get`
7
- returns the resolved value, falling back to a single lazily-created fallback
8
- when no resolver is registered so isolated tests work without booting the
9
- runtime. `slot` stays exposed so test helpers can snapshot/poke `.resolver` and
10
- `.fallback` directly. Pass `createFallback` for a lazily-built, cached fallback
11
- (cache store, page); omit it for a slot whose fallback is a plain value set
12
- directly (mount base) — `get` then returns `T | undefined`.
4
+ The resolver-slot seam the runtime entries share for cache store, page,
5
+ request-scope, and mount-base state. A runtime entry registers a side-specific
6
+ resolver by assigning `.resolver` (ALS-backed on the server, a module
7
+ singleton on the client); `get()` returns the resolved value, falling back to
8
+ a single lazily-created fallback when no resolver is registered so isolated
9
+ tests work without booting the runtime. Pass `createFallback` for a
10
+ lazily-built, cached fallback (cache store, page); omit it for a slot whose
11
+ fallback is a plain value set directly (mount base) `get()` then returns
12
+ `T | undefined`.
13
13
  */
14
- export function createResolverSlot<T>(createFallback?: () => T): {
15
- slot: ResolverSlot<T>
16
- set: (fn: () => T | undefined) => void
17
- get: () => T | undefined
18
- } {
19
- const slot: ResolverSlot<T> = { resolver: undefined, fallback: undefined }
20
-
21
- function set(fn: () => T | undefined): void {
22
- slot.resolver = fn
23
- }
24
-
25
- function get(): T | undefined {
26
- const fromResolver = slot.resolver?.()
27
- if (fromResolver !== undefined) {
28
- return fromResolver
29
- }
30
- if (createFallback && slot.fallback === undefined) {
31
- slot.fallback = createFallback()
32
- }
33
- return slot.fallback
14
+ export function createResolverSlot<T>(createFallback?: () => T): ResolverSlot<T> {
15
+ const slot: ResolverSlot<T> = {
16
+ resolver: undefined,
17
+ fallback: undefined,
18
+ get(): T | undefined {
19
+ const fromResolver = slot.resolver?.()
20
+ if (fromResolver !== undefined) {
21
+ return fromResolver
22
+ }
23
+ if (createFallback && slot.fallback === undefined) {
24
+ slot.fallback = createFallback()
25
+ }
26
+ return slot.fallback
27
+ },
34
28
  }
35
-
36
- return { slot, set, get }
29
+ return slot
37
30
  }
@@ -1,6 +1,6 @@
1
- import type { SocketClientFrame } from '../server/sockets/types/SocketClientFrame.ts'
2
- import type { SocketServerFrame } from '../server/sockets/types/SocketServerFrame.ts'
3
1
  import type { SocketChannel } from './types/SocketChannel.ts'
2
+ import type { SocketClientFrame } from './types/SocketClientFrame.ts'
3
+ import type { SocketServerFrame } from './types/SocketServerFrame.ts'
4
4
  import type { SocketSubCallbacks } from './types/SocketSubCallbacks.ts'
5
5
 
6
6
  /*
@@ -6,7 +6,8 @@ them. The shell must exist before its contents are filled so a back-reference to
6
6
  ancestor resolves to the already-allocated object — which is what reconnects a cycle.
7
7
  Inline primitives need no shell; they decode directly where they sit.
8
8
  */
9
- import { REF_JSON_TAGS } from './REF_JSON_TAGS'
9
+ import { REF_JSON_TAGS } from './REF_JSON_TAGS.ts'
10
+ import { setOwnProperty } from './setOwnProperty.ts'
10
11
 
11
12
  export function decodeRefJson(text: string): unknown {
12
13
  const parsed = JSON.parse(text)
@@ -68,7 +69,8 @@ function fillShell(slot: unknown, shell: unknown, shells: unknown[]): void {
68
69
  const target = shell as Record<string, unknown>
69
70
  const record = slot as Record<string, unknown>
70
71
  for (const key of Object.keys(record)) {
71
- target[key] = resolveValue(record[key], shells)
72
+ // setOwnProperty: a wire `__proto__` key must not hijack the shell's prototype.
73
+ setOwnProperty(target, key, resolveValue(record[key], shells))
72
74
  }
73
75
  return
74
76
  }
@@ -1,6 +1,7 @@
1
- import { bodyValueForKind, DEFER } from './bodyValueForKind.ts'
1
+ import { bodyValueForKind } from './bodyValueForKind.ts'
2
2
  import { contentBodyKind } from './contentBodyKind.ts'
3
3
  import { contentTypeOf } from './contentTypeOf.ts'
4
+ import { DEFER } from './DEFER.ts'
4
5
  import { httpErrorFor } from './httpErrorFor.ts'
5
6
 
6
7
  /*
@@ -17,10 +18,11 @@ propagation), and the success path types as Promise<Return> cleanly.
17
18
  Streaming Content-Types (SSE / JSONL / NDJSON) throw a clear error
18
19
  rather than silently doing the wrong thing: response.text() would hang
19
20
  forever on a never-ending body and response.json() would fail mid-parse.
20
- The error points callers at the right tools `tail(fn.stream(args))`
21
- for a shared reactive view, or `fn.stream(args)` directly for a fresh
22
- per-call AsyncIterable both of which know how to consume the body
23
- frame-by-frame.
21
+ A streaming rpc's bare call already returns a Subscribable (the type makes
22
+ `await fn(args)` a compile error), so this is a backstop for the paths that
23
+ still decode a raw Response `cache()` and the one-shot stream reader. The
24
+ error points callers at the right tools: `state(fn(args))` for a reactive
25
+ view, or `for await (… of fn(args))` for direct iteration.
24
26
 
25
27
  Callers that need headers, streaming, or per-status branching should use
26
28
  the `.raw(args)` escape hatch on the remote function instead — that
@@ -37,7 +39,7 @@ export async function decodeResponse(response: Response): Promise<unknown> {
37
39
  const kind = contentBodyKind(contentType)
38
40
  if (kind === 'streaming') {
39
41
  throw new Error(
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()`,
42
+ `[abide] response at ${response.url} is a stream (${contentType}) — a streaming rpc's bare call already returns a Subscribable: use state(fn(args)) for a reactive view or \`for await (… of fn(args))\` for iteration, not await/cache()`,
41
43
  )
42
44
  }
43
45
  /* json/text go through the shared mapping warmValueFromSnapshot also uses, so a warm
@@ -0,0 +1,14 @@
1
+ import { tailProbeSlot } from './tailProbeSlot.ts'
2
+ import type { Subscribable } from './types/Subscribable.ts'
3
+
4
+ /*
5
+ Reactive terminal-state reader for a stream: true once the source has closed (its
6
+ tail entry status === 'done'). Stream-only — a cache entry's "done" is just
7
+ `!pending && !refreshing`, so there is no cache-selector form. The residual bit the
8
+ `pending` / `refreshing` / `error` probes don't cover (see design Part 4). No prober
9
+ registered (server render, or tail never imported) reads as not-done.
10
+ */
11
+ // @documentation probes
12
+ export function done(subscribable: Subscribable<unknown>): boolean {
13
+ return tailProbeSlot.probe?.(subscribable.name)?.done ?? false
14
+ }
@@ -11,7 +11,8 @@ symbols can't be serialised and encode as undefined, matching JSON.stringify. Ou
11
11
  shape is `[rootValue, slots]`. Decode with decodeRefJson. Not streaming — the whole
12
12
  graph is walked up front.
13
13
  */
14
- import { REF_JSON_TAGS } from './REF_JSON_TAGS'
14
+ import { REF_JSON_TAGS } from './REF_JSON_TAGS.ts'
15
+ import { setOwnProperty } from './setOwnProperty.ts'
15
16
 
16
17
  export function encodeRefJson(value: unknown): string {
17
18
  // Hoisted containers only; slots[i] is addressed by ['~r', i]. Primitives stay inline.
@@ -86,7 +87,8 @@ export function encodeRefJson(value: unknown): string {
86
87
  // (no intermediate pairs array / closures) on this per-object always-hot path.
87
88
  const encoded: Record<string, unknown> = {}
88
89
  for (const key of Object.keys(record)) {
89
- encoded[key] = encodeValue(record[key])
90
+ // setOwnProperty: an own `__proto__` key must reach the wire, not the accessor.
91
+ setOwnProperty(encoded, key, encodeValue(record[key]))
90
92
  }
91
93
  return encoded
92
94
  }
@@ -1,9 +1,12 @@
1
- import { globalCacheStoreResolver } from './globalCacheStoreResolver.ts'
1
+ import { createResolverSlot } from './createResolverSlot.ts'
2
+ import type { CacheStore } from './types/CacheStore.ts'
2
3
 
3
4
  /*
4
- Slot for the process-level cache store resolver (see globalCacheStoreResolver).
5
- Exposed so test helpers snapshot/poke `.resolver` directly. Unset means no global
6
- store is registered, in which case globalCacheStore() falls back to the active
7
- (request/tab) store.
5
+ Slot for the process-level cache store used by cache() entries opting into
6
+ `global: true`. The server entry registers a module-singleton store outliving
7
+ any one request; the client entry points it at its single tab store so
8
+ `global` is a no-op there. No fallback creator — unset means no global store
9
+ is registered, in which case globalCacheStore() falls back to the active
10
+ (request/tab) store. Test helpers snapshot/poke `.resolver` directly.
8
11
  */
9
- export const globalCacheStoreSlot = globalCacheStoreResolver.slot
12
+ export const globalCacheStoreSlot = createResolverSlot<CacheStore>()
@@ -0,0 +1,12 @@
1
+ /*
2
+ Set true only while the client adopts the server-rendered DOM (the hydration
3
+ render pass — see the router's hydrate branch and `hydrate`). Read by `peek`: the
4
+ server materializes no cache value (materializeRetained/cacheEntryFromSnapshot are
5
+ client-only), so server-side peek is uniformly undefined and the SSR render always
6
+ shows the fallback. A snapshot-seeded warm value surfacing DURING hydration would
7
+ diverge from that server text and corrupt the claimed text node, so peek withholds
8
+ it until the pass ends — `wakeHydrationPeeks` then re-runs the scope on the now-
9
+ congruent value. A plain boolean, save/restore-nested so a child hydrate can't clear
10
+ an outer pass early; false on the server (no hydration) and after boot.
11
+ */
12
+ export const hydratingSlot: { active: boolean } = { active: false }
@@ -0,0 +1,12 @@
1
+ import { fileName } from './fileName.ts'
2
+
3
+ /*
4
+ Whether a module path is a router `layout.abide` — an EXACT leaf-filename match, the
5
+ one predicate every site shares. `endsWith('layout.abide')` is wrong: it also matches
6
+ `cardlayout.abide`, which the bundler's loader (exact match) compiles as a plain
7
+ component, so an `endsWith` site would hot-compile / fingerprint it as a layout and
8
+ diverge from the shipped bundle. Mirrors the loader so classification can't drift.
9
+ */
10
+ export function isLayoutFile(path: string): boolean {
11
+ return fileName(path) === 'layout.abide'
12
+ }
@@ -1,5 +1,6 @@
1
1
  import { canonicalJson } from './canonicalJson.ts'
2
2
  import { carriesBodyArgs } from './carriesBodyArgs.ts'
3
+ import { keyPrefixForRemote } from './keyPrefixForRemote.ts'
3
4
  import { queryStringFromArgs } from './queryStringFromArgs.ts'
4
5
  import type { HttpMethod } from './types/HttpMethod.ts'
5
6
 
@@ -12,7 +13,7 @@ synthesized Request can't disagree); POST/PUT/PATCH join args after a space as
12
13
  canonical JSON. The rpc split mirrors buildRpcRequest exactly.
13
14
  */
14
15
  export function keyForRemoteCall(method: HttpMethod, url: string, args: unknown): string {
15
- const prefix = `${method} ${url}`
16
+ const prefix = keyPrefixForRemote(method, url)
16
17
  if (!carriesBodyArgs(method)) {
17
18
  if (args && typeof args === 'object' && !Array.isArray(args)) {
18
19
  const search = queryStringFromArgs(args as Record<string, unknown>, true)
@@ -0,0 +1,12 @@
1
+ import type { HttpMethod } from './types/HttpMethod.ts'
2
+
3
+ /*
4
+ The cache/error-registry key prefix a remote function's entries share: `${method} ${url}`
5
+ (method + route template). The single definition of the prefix grammar — keyForRemoteCall
6
+ appends `?query`/` body` onto it, and keyMatchesPrefix extends it — so every consumer that
7
+ prefix-matches (`fn.error()`, the cache selector prefix, the MCP tool labels) composes the
8
+ SAME prefix and can't silently mismatch if the format ever changes.
9
+ */
10
+ export function keyPrefixForRemote(method: HttpMethod, url: string): string {
11
+ return `${method} ${url}`
12
+ }
@@ -0,0 +1,175 @@
1
+ import { normalizePathname } from './normalizePathname.ts'
2
+ import type { RouteSegment } from './parseRouteSegments.ts'
3
+ import { parseRouteSegments } from './parseRouteSegments.ts'
4
+
5
+ /*
6
+ Per-route parse cache. The route set is stable across a session, so parsing a
7
+ pattern into segments is done once per route rather than on every match. Keyed
8
+ by the pattern string; entries never need eviction since routes don't churn.
9
+ */
10
+ const PARSED_ROUTES = new Map<string, RouteSegment[]>()
11
+
12
+ function parsedRoute(route: string): RouteSegment[] {
13
+ let segments = PARSED_ROUTES.get(route)
14
+ if (segments === undefined) {
15
+ segments = parseRouteSegments(route)
16
+ PARSED_ROUTES.set(route, segments)
17
+ }
18
+ return segments
19
+ }
20
+
21
+ /* One pattern segment's specificity rank — lower is more specific. */
22
+ function segmentRank(segment: RouteSegment): number {
23
+ if (segment.kind === 'literal') {
24
+ return 0
25
+ }
26
+ if (segment.catchAll) {
27
+ return 3
28
+ }
29
+ return segment.optional ? 2 : 1
30
+ }
31
+
32
+ /* Positional specificity between two routes that both match a path: walk both
33
+ patterns left to right, and at the first position where the segment kinds
34
+ differ the more specific kind wins — literal > `[name]` > `[[name]]` >
35
+ `[...rest]`. So `/a/[b]` beats `/[a]/b`, and a literal head beats a param
36
+ head even against a catch-all tail (matching how Bun's own router ranked).
37
+ Prefix-equal patterns: the shorter one is the exact match. A full tie keeps
38
+ the earlier-registered route — deterministic, unlike count-based scoring,
39
+ because kind sequences that differ anywhere are ordered at that position. */
40
+ function moreSpecific(candidate: RouteSegment[], best: RouteSegment[]): boolean {
41
+ const shared = Math.min(candidate.length, best.length)
42
+ for (let index = 0; index < shared; index += 1) {
43
+ const candidateRank = segmentRank(candidate[index] as RouteSegment)
44
+ const bestRank = segmentRank(best[index] as RouteSegment)
45
+ if (candidateRank !== bestRank) {
46
+ return candidateRank < bestRank
47
+ }
48
+ }
49
+ return candidate.length < best.length
50
+ }
51
+
52
+ /*
53
+ The route matcher — the single grammar of record on both sides: the server's
54
+ fetch dispatch and the client router resolve a pathname through this same
55
+ function, so params decode and precedence agree by construction. Given the
56
+ registered route patterns and a pathname, returns the matching route + decoded
57
+ params, or undefined. Segments are literal / `[name]` / `[[name]]` (optional) /
58
+ `[...rest]` (catch-all), via the shared `parseRouteSegments`. The most specific
59
+ match wins (see moreSpecific).
60
+ */
61
+ export function matchRoute(
62
+ routes: string[],
63
+ pathname: string,
64
+ ): { route: string; params: Record<string, string> } | undefined {
65
+ /* Match the canonical slash form — `//users` and `/users/` both match
66
+ `/users` — otherwise the extra empty segment rejects the path or lets
67
+ `/users/[id]` capture an empty id. The server redirects a request whose
68
+ raw pathname differs from this form before dispatching (see createServer),
69
+ so its auth middleware guards the same string this matcher routes. */
70
+ const normalized = normalizePathname(pathname)
71
+ const pathSegments = normalized.split('/')
72
+ let best: { route: string; params: Record<string, string>; parsed: RouteSegment[] } | undefined
73
+ for (const route of routes) {
74
+ const parsed = parsedRoute(route)
75
+ const params = matchSegments(parsed, pathSegments)
76
+ if (params === undefined) {
77
+ continue
78
+ }
79
+ if (best === undefined || moreSpecific(parsed, best.parsed)) {
80
+ best = { route, params, parsed }
81
+ }
82
+ }
83
+ return best === undefined ? undefined : { route: best.route, params: best.params }
84
+ }
85
+
86
+ /* Percent-decodes a captured value. Lenient — a malformed sequence (`/%E0%A4%A`)
87
+ keeps the raw text rather than throwing, so a bad escape in a navigation or
88
+ request can't crash matching; the downstream lookup just misses naturally. */
89
+ function decodeParam(value: string): string {
90
+ try {
91
+ return decodeURIComponent(value)
92
+ } catch {
93
+ return value
94
+ }
95
+ }
96
+
97
+ /* Matches one parsed pattern against the path's segments, capturing decoded
98
+ params; undefined on mismatch. */
99
+ function matchSegments(
100
+ segments: RouteSegment[],
101
+ pathSegments: string[],
102
+ ): Record<string, string> | undefined {
103
+ const params: Record<string, string> = {}
104
+ return matchFrom(segments, pathSegments, 0, 0, params) ? params : undefined
105
+ }
106
+
107
+ /*
108
+ Depth-first walk over the pattern. An optional segment first tries to consume a
109
+ path segment (greedy — `/[[a]]/[[b]]` on `/x` captures `a`), and on a deeper
110
+ mismatch backtracks to try absence, un-capturing its param so a failed branch
111
+ can't leak into the succeeding one. Recursion depth is bounded by the pattern's
112
+ segment count, not the request path. A catch-all consumes every remaining
113
+ segment (possibly none), decoding each sub-segment while keeping `/` separators
114
+ — so an encoded `%2F` stays inside one sub-segment.
115
+ */
116
+ function matchFrom(
117
+ segments: RouteSegment[],
118
+ pathSegments: string[],
119
+ segmentIndex: number,
120
+ pathIndex: number,
121
+ params: Record<string, string>,
122
+ ): boolean {
123
+ if (segmentIndex === segments.length) {
124
+ /* Pattern exhausted — the path must be too (no extra segments). */
125
+ return pathIndex === pathSegments.length
126
+ }
127
+ const segment = segments[segmentIndex]
128
+ if (segment === undefined) {
129
+ return false
130
+ }
131
+ if (segment.kind === 'param' && segment.catchAll) {
132
+ params[segment.name] = pathSegments.slice(pathIndex).map(decodeParam).join('/')
133
+ return true
134
+ }
135
+ const value = pathSegments[pathIndex]
136
+ if (segment.kind === 'literal') {
137
+ if (value !== segment.value) {
138
+ return false
139
+ }
140
+ return matchFrom(segments, pathSegments, segmentIndex + 1, pathIndex + 1, params)
141
+ }
142
+ if (segment.optional) {
143
+ /* Consume first; on failure restore the prior capture (a same-named
144
+ earlier `[name]` must survive the backtrack) and retry as absent. An
145
+ optional never captures an empty segment. */
146
+ if (value !== undefined && value !== '') {
147
+ const previous = params[segment.name]
148
+ params[segment.name] = decodeParam(value)
149
+ if (matchFrom(segments, pathSegments, segmentIndex + 1, pathIndex + 1, params)) {
150
+ return true
151
+ }
152
+ if (previous === undefined) {
153
+ delete params[segment.name]
154
+ } else {
155
+ params[segment.name] = previous
156
+ }
157
+ }
158
+ if (matchFrom(segments, pathSegments, segmentIndex + 1, pathIndex, params)) {
159
+ return true
160
+ }
161
+ /* Absent at the bare root: `/` splits into ['', ''] and slash collapsing
162
+ can't remove that dangling empty segment, so an absent optional also
163
+ swallows it — `/[[lang]]` must match `/` (the path url() generates). */
164
+ if (value === '') {
165
+ return matchFrom(segments, pathSegments, segmentIndex + 1, pathIndex + 1, params)
166
+ }
167
+ return false
168
+ }
169
+ /* A required `[name]` param never captures an empty segment (e.g. `/users//5`). */
170
+ if (value === undefined || value === '') {
171
+ return false
172
+ }
173
+ params[segment.name] = decodeParam(value)
174
+ return matchFrom(segments, pathSegments, segmentIndex + 1, pathIndex + 1, params)
175
+ }
@@ -0,0 +1,11 @@
1
+ /*
2
+ The canonical slash form of a pathname: duplicate slashes collapsed, one
3
+ trailing slash stripped (except root) — so `//users/` canonicalizes to
4
+ `/users`. matchRoute matches this form on both sides, and the server 308s a
5
+ page request whose raw pathname differs (see createServer), so the app.handle
6
+ auth seam always guards exactly the string the matcher routes.
7
+ */
8
+ export function normalizePathname(pathname: string): string {
9
+ const collapsed = pathname.includes('//') ? pathname.replace(/\/{2,}/g, '/') : pathname
10
+ return collapsed.length > 1 && collapsed.endsWith('/') ? collapsed.slice(0, -1) : collapsed
11
+ }
@@ -1,8 +1,17 @@
1
- import { pageResolver } from './pageResolver.ts'
1
+ import { createResolverSlot } from './createResolverSlot.ts'
2
+ import type { PageSnapshot } from './types/PageSnapshot.ts'
2
3
 
3
4
  /*
4
- Internal slot the runtime entries register their page resolver into (see
5
- pageResolver). Exposed so test helpers snapshot/poke `.resolver` and
6
- `.fallback` directly.
5
+ The active-page slot. The server entry installs an ALS-backed resolver
6
+ (request-scoped, so concurrent and streaming renders never share state); the
7
+ client entry a module-singleton one. With no resolver registered, a single
8
+ empty snapshot is created lazily so isolated tests work; test helpers
9
+ snapshot/poke `.resolver` and `.fallback` directly. activePage is the public
10
+ read.
7
11
  */
8
- export const pageSlot = pageResolver.slot
12
+ export const pageSlot = createResolverSlot<PageSnapshot>(() => ({
13
+ route: '',
14
+ params: {},
15
+ url: new URL('http://localhost/'),
16
+ navigating: false,
17
+ }))