@abide/abide 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/AGENTS.md +365 -320
  2. package/CHANGELOG.md +80 -0
  3. package/README.md +203 -168
  4. package/package.json +12 -8
  5. package/src/abideLsp.ts +11 -12
  6. package/src/abideResolverPlugin.ts +9 -4
  7. package/src/lib/bundle/disconnected.abide +3 -0
  8. package/src/lib/cli/printCommandHelp.ts +43 -0
  9. package/src/lib/cli/printSessionHelp.ts +2 -1
  10. package/src/lib/cli/{printHelp.ts → printTopLevelHelp.ts} +3 -40
  11. package/src/lib/cli/runCli.ts +2 -1
  12. package/src/lib/mcp/buildPrompts.ts +25 -0
  13. package/src/lib/mcp/dispatchMcpRequest.ts +8 -6
  14. package/src/lib/mcp/mcpResourceServerSlot.ts +5 -10
  15. package/src/lib/mcp/mcpSurface.ts +13 -252
  16. package/src/lib/mcp/mcpTools.ts +187 -0
  17. package/src/lib/mcp/renderPrompt.ts +25 -0
  18. package/src/lib/mcp/types/McpSurface.ts +15 -0
  19. package/src/lib/mcp/types/PromptDescriptor.ts +5 -0
  20. package/src/lib/mcp/types/PromptMessage.ts +2 -0
  21. package/src/lib/mcp/types/ToolDescriptor.ts +7 -0
  22. package/src/lib/mcp/types/ToolResult.ts +2 -0
  23. package/src/lib/server/agent.ts +1 -1
  24. package/src/lib/server/jsonl.ts +2 -2
  25. package/src/lib/server/rpc/defineRpc.ts +5 -0
  26. package/src/lib/server/runtime/containsTraversal.ts +21 -17
  27. package/src/lib/server/runtime/createAppAssetServer.ts +11 -16
  28. package/src/lib/server/runtime/createServer.ts +101 -71
  29. package/src/lib/server/runtime/createUiPageRenderer.ts +23 -4
  30. package/src/lib/server/runtime/devClientFingerprint.ts +3 -3
  31. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -1
  32. package/src/lib/server/runtime/finalizeResponse.ts +32 -0
  33. package/src/lib/server/runtime/snapshotEntryFromCache.ts +8 -17
  34. package/src/lib/server/socket.ts +1 -1
  35. package/src/lib/server/sockets/createSocketDispatcher.ts +28 -5
  36. package/src/lib/server/sockets/defineSocket.ts +26 -7
  37. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +1 -1
  38. package/src/lib/server/sockets/types/SocketRoutes.ts +1 -1
  39. package/src/lib/server/sse.ts +2 -2
  40. package/src/lib/shared/DEFER.ts +8 -0
  41. package/src/lib/shared/activeCacheStore.ts +2 -2
  42. package/src/lib/shared/activePage.ts +2 -2
  43. package/src/lib/shared/attachRpcSelectorMethods.ts +42 -0
  44. package/src/lib/shared/attachSocketSelectorMethods.ts +34 -0
  45. package/src/lib/shared/basePath.ts +2 -2
  46. package/src/lib/shared/baseSlot.ts +6 -5
  47. package/src/lib/shared/bodyValueForKind.ts +1 -2
  48. package/src/lib/shared/buildSocketOverChannel.ts +40 -4
  49. package/src/lib/shared/cache.ts +477 -110
  50. package/src/lib/shared/cacheEntryFromSnapshot.ts +3 -22
  51. package/src/lib/shared/cacheStoreSlot.ts +9 -5
  52. package/src/lib/{server/runtime → shared}/createReachable.ts +2 -2
  53. package/src/lib/shared/createRemoteFunction.ts +58 -9
  54. package/src/lib/shared/createResolverSlot.ts +24 -31
  55. package/src/lib/shared/createSocketSubRegistry.ts +2 -2
  56. package/src/lib/shared/decodeRefJson.ts +4 -2
  57. package/src/lib/shared/decodeResponse.ts +8 -6
  58. package/src/lib/shared/done.ts +14 -0
  59. package/src/lib/shared/encodeRefJson.ts +4 -2
  60. package/src/lib/shared/globalCacheStoreSlot.ts +9 -6
  61. package/src/lib/shared/hydratingSlot.ts +12 -0
  62. package/src/lib/shared/isLayoutFile.ts +12 -0
  63. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  64. package/src/lib/shared/keyPrefixForRemote.ts +12 -0
  65. package/src/lib/shared/matchRoute.ts +175 -0
  66. package/src/lib/shared/normalizePathname.ts +11 -0
  67. package/src/lib/shared/pageSlot.ts +14 -5
  68. package/src/lib/shared/pageUrlForFile.ts +3 -3
  69. package/src/lib/shared/parseRouteSegments.ts +16 -7
  70. package/src/lib/shared/patch.ts +41 -0
  71. package/src/lib/shared/peek.ts +35 -0
  72. package/src/lib/shared/prepareRemoteExport.ts +40 -0
  73. package/src/lib/shared/prepareRpcModule.ts +98 -16
  74. package/src/lib/shared/prepareSocketModule.ts +11 -15
  75. package/src/lib/shared/queryStringFromArgs.ts +11 -0
  76. package/src/lib/shared/reachable.ts +102 -0
  77. package/src/lib/shared/refresh.ts +22 -0
  78. package/src/lib/shared/requestScopeSlot.ts +10 -7
  79. package/src/lib/shared/routeParamsShape.ts +9 -9
  80. package/src/lib/shared/rpcErrorRegistry.ts +69 -0
  81. package/src/lib/shared/selectorPrefix.ts +3 -10
  82. package/src/lib/shared/setOwnProperty.ts +19 -0
  83. package/src/lib/shared/snippet.ts +1 -1
  84. package/src/lib/shared/subscribableProbes.ts +111 -0
  85. package/src/lib/shared/tailProbeSlot.ts +8 -1
  86. package/src/lib/shared/types/CacheEntry.ts +9 -15
  87. package/src/lib/shared/types/CacheOptions.ts +8 -0
  88. package/src/lib/shared/types/CacheSnapshotEntry.ts +0 -5
  89. package/src/lib/shared/types/RemoteCallable.ts +25 -7
  90. package/src/lib/shared/types/RemoteFunction.ts +48 -13
  91. package/src/lib/shared/types/ResolverSlot.ts +7 -4
  92. package/src/lib/shared/types/RpcError.ts +26 -0
  93. package/src/lib/shared/types/SmartReadOptions.ts +32 -0
  94. package/src/lib/shared/types/Socket.ts +54 -0
  95. package/src/lib/shared/types/SsrBootState.ts +27 -0
  96. package/src/lib/shared/types/SsrPayload.ts +21 -0
  97. package/src/lib/shared/types/Subscribable.ts +13 -13
  98. package/src/lib/shared/types/TailHooks.ts +2 -1
  99. package/src/lib/shared/url.ts +29 -14
  100. package/src/lib/shared/wakeHydrationPeeks.ts +20 -0
  101. package/src/lib/shared/writeTestSocketsDts.ts +1 -1
  102. package/src/lib/test/createScriptedSurface.ts +1 -1
  103. package/src/lib/test/createTestApp.ts +2 -2
  104. package/src/lib/test/createTestSocketChannel.ts +3 -3
  105. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +5 -6
  106. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +10 -7
  107. package/src/lib/ui/compile/abideUiPlugin.ts +2 -2
  108. package/src/lib/ui/compile/analyzeComponent.ts +28 -6
  109. package/src/lib/ui/compile/compileModule.ts +137 -11
  110. package/src/lib/ui/compile/compileShadow.ts +101 -84
  111. package/src/lib/ui/compile/desugarSignals.ts +120 -107
  112. package/src/lib/ui/compile/generateBuild.ts +48 -16
  113. package/src/lib/ui/compile/generateSSR.ts +117 -14
  114. package/src/lib/ui/compile/identifierReferencePattern.ts +13 -0
  115. package/src/lib/ui/compile/lowerContext.ts +10 -4
  116. package/src/lib/ui/compile/lowerScript.ts +72 -5
  117. package/src/lib/ui/compile/parseTemplate.ts +1 -14
  118. package/src/lib/ui/compile/prepareNestedScript.ts +31 -17
  119. package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
  120. package/src/lib/ui/compile/signalCallee.ts +24 -0
  121. package/src/lib/ui/compile/stripEffects.ts +14 -11
  122. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  123. package/src/lib/ui/compile/types/TemplateNode.ts +0 -5
  124. package/src/lib/ui/dom/appendStatic.ts +9 -1
  125. package/src/lib/ui/dom/appendText.ts +15 -3
  126. package/src/lib/ui/dom/assertClaimedText.ts +20 -0
  127. package/src/lib/ui/dom/awaitBlock.ts +19 -83
  128. package/src/lib/ui/dom/bindSelectValue.ts +48 -0
  129. package/src/lib/ui/dom/each.ts +12 -1
  130. package/src/lib/ui/dom/hydrate.ts +10 -0
  131. package/src/lib/ui/dom/mountChild.ts +2 -5
  132. package/src/lib/ui/dom/mountRange.ts +0 -75
  133. package/src/lib/ui/effect.ts +4 -3
  134. package/src/lib/ui/installHotBridge.ts +4 -2
  135. package/src/lib/ui/remoteProxy.ts +18 -2
  136. package/src/lib/ui/renderToStream.ts +9 -13
  137. package/src/lib/ui/resumeSeedScript.ts +2 -2
  138. package/src/lib/ui/router.ts +21 -2
  139. package/src/lib/ui/runtime/RESUME.ts +0 -6
  140. package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
  141. package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
  142. package/src/lib/ui/scope.ts +6 -3
  143. package/src/lib/ui/seedBootState.ts +53 -0
  144. package/src/lib/ui/socketChannel.ts +2 -2
  145. package/src/lib/ui/socketProxy.ts +9 -3
  146. package/src/lib/ui/startClient.ts +16 -30
  147. package/src/lib/ui/state.ts +44 -13
  148. package/src/lib/ui/sync.ts +11 -5
  149. package/src/lib/ui/tryEncodeResume.ts +2 -5
  150. package/src/lib/ui/types/Scope.ts +14 -10
  151. package/src/lib/ui/watch.ts +140 -0
  152. package/src/serverEntry.ts +6 -6
  153. package/template/CLAUDE.md +1 -1
  154. package/template/package.json +1 -1
  155. package/template/src/server/rpc/getHello.ts +3 -3
  156. package/template/src/ui/pages/page.abide +2 -2
  157. package/template/test/app.test.ts +1 -1
  158. package/src/lib/server/reachable.ts +0 -45
  159. package/src/lib/server/sockets/types/Socket.ts +0 -23
  160. package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
  161. package/src/lib/shared/DEFER_MIN_ARRAY_LENGTH.ts +0 -14
  162. package/src/lib/shared/baseResolver.ts +0 -10
  163. package/src/lib/shared/cacheKeyOf.ts +0 -7
  164. package/src/lib/shared/cacheKeyStore.ts +0 -8
  165. package/src/lib/shared/cacheStoreResolver.ts +0 -12
  166. package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
  167. package/src/lib/shared/pageResolver.ts +0 -16
  168. package/src/lib/shared/recordCacheKey.ts +0 -6
  169. package/src/lib/shared/requestScopeResolver.ts +0 -12
  170. package/src/lib/shared/setBaseResolver.ts +0 -4
  171. package/src/lib/shared/setCacheStoreResolver.ts +0 -4
  172. package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
  173. package/src/lib/shared/setPageResolver.ts +0 -4
  174. package/src/lib/shared/setRequestScopeResolver.ts +0 -4
  175. package/src/lib/shared/toBunRoutePattern.ts +0 -28
  176. package/src/lib/shared/types/TailOptions.ts +0 -10
  177. package/src/lib/ui/deferResume.ts +0 -36
  178. package/src/lib/ui/dom/firstElementBetween.ts +0 -14
  179. package/src/lib/ui/matchRoute.ts +0 -106
  180. package/src/lib/ui/runtime/scheduleWake.ts +0 -28
  181. package/src/lib/ui/runtime/whenIdle.ts +0 -21
  182. package/src/lib/ui/runtime/whenVisible.ts +0 -105
  183. package/src/lib/ui/tail.ts +0 -324
  184. /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
  185. /package/src/lib/{server/sockets → shared}/types/SocketServerFrame.ts +0 -0
@@ -1,5 +1,5 @@
1
- import { CACHE_WRAPPED } from './CACHE_WRAPPED.ts'
2
1
  import { keyForRemoteCall } from './keyForRemoteCall.ts'
2
+ import { keyPrefixForRemote } from './keyPrefixForRemote.ts'
3
3
  import { producerKey } from './producerKey.ts'
4
4
  import { REMOTE_FUNCTION } from './REMOTE_FUNCTION.ts'
5
5
  import type { CacheSelector } from './types/CacheSelector.ts'
@@ -13,9 +13,7 @@ one call instead — derived through the same encoders the read path uses
13
13
  (keyForRemoteCall / producerKey format), so selector and entry can't
14
14
  disagree. Undefined when no prefix exists — bare and tag selectors (they
15
15
  scan, not prefix-match) and producers never cached (no id was minted;
16
- minting one here would leak identities for probe-only reads). The
17
- cache()-wrapper throw lives here so both consumers — the matcher and the
18
- probes' channel tap — reject it before subscribing to anything.
16
+ minting one here would leak identities for probe-only reads).
19
17
  */
20
18
  export function selectorPrefix<Args, Return>(
21
19
  arg: CacheSelector<Args, Return> | undefined,
@@ -24,15 +22,10 @@ export function selectorPrefix<Args, Return>(
24
22
  if (typeof arg !== 'function') {
25
23
  return undefined
26
24
  }
27
- if (CACHE_WRAPPED in arg) {
28
- throw new Error(
29
- '[abide] a cache() wrapper is not a selector — pass the function it wraps, e.g. pending(getPost), not pending(cache(getPost))',
30
- )
31
- }
32
25
  const remote = REMOTE_FUNCTION in arg ? (arg as RawRemoteFunction<Args>) : undefined
33
26
  if (remote) {
34
27
  return args === undefined
35
- ? `${remote.method} ${remote.url}`
28
+ ? keyPrefixForRemote(remote.method, remote.url)
36
29
  : keyForRemoteCall(remote.method, remote.url, args)
37
30
  }
38
31
  return producerKey.existing(arg, args)
@@ -0,0 +1,19 @@
1
+ /*
2
+ Assignment can't create an own `__proto__` key — it invokes the
3
+ Object.prototype accessor instead, silently dropping the key or (on decode of
4
+ attacker-controlled wire data) swapping the object's prototype. defineProperty
5
+ always writes an own enumerable data property, so `__proto__` round-trips as a
6
+ plain key. Plain assignment stays on the fast path for every other key.
7
+ */
8
+ export function setOwnProperty(target: Record<string, unknown>, key: string, value: unknown): void {
9
+ if (key === '__proto__') {
10
+ Object.defineProperty(target, key, {
11
+ value,
12
+ writable: true,
13
+ enumerable: true,
14
+ configurable: true,
15
+ })
16
+ return
17
+ }
18
+ target[key] = value
19
+ }
@@ -1,6 +1,6 @@
1
1
  const SNIPPET = Symbol.for('abide.snippet')
2
2
 
3
- /* A `<template name args>` snippet, called like a function: it carries a payload a
3
+ /* A `{#snippet name(args)}` snippet, called like a function: it carries a payload a
4
4
  `{expr}` interpolation mounts in place — a DOM builder `(host) => void` on the
5
5
  client, the pre-rendered HTML string on the server. The brand is a registered
6
6
  Symbol so it survives across module/bundle copies (same idiom as `html\`\``). */
@@ -0,0 +1,111 @@
1
+ import { createLifecycleChannel } from './createLifecycleChannel.ts'
2
+ import { tailProbeSlot } from './tailProbeSlot.ts'
3
+
4
+ type StreamStatus = 'pending' | 'open' | 'done' | 'error'
5
+
6
+ /* One registered consumption of a named subscribable — the stream-side analog of a
7
+ cache-store entry. Populated by the consume path (cache.on / watch(socket)), read
8
+ by the pending()/refreshing()/done()/error() probes. No window/value here: the
9
+ value flows through the consumer (watch handler / for-await), the registry only
10
+ holds probe state. */
11
+ type StreamEntry = {
12
+ source: string
13
+ status: StreamStatus
14
+ refreshing: boolean
15
+ error: Error | undefined
16
+ /* Reconnect only counts as refreshing once a value has been seen (probe contract:
17
+ value held, fresher source in flight — never a first-ever open). */
18
+ everFramed: boolean
19
+ }
20
+
21
+ const registry = new Set<StreamEntry>()
22
+
23
+ /*
24
+ Registry-wide lifecycle channel for the subscribable probes — the stream-side
25
+ counterpart of the cache store's. Probes match entries by source name (or all)
26
+ without creating them, so they tap one "membership or state changed" signal and
27
+ re-derive by scanning.
28
+ */
29
+ const lifecycle = createLifecycleChannel()
30
+
31
+ /*
32
+ Installs the prober the shared probes call for a Subscribable (probeRegistries →
33
+ tailProbeSlot). Register-on-consume: an entry exists only while something is
34
+ consuming the stream, exactly as a cache entry exists only while a call is
35
+ retained. A name with no entry reads as "no value yet" (pending) without opening
36
+ anything — probes report, never act. This install replaces tail()'s: the registrar
37
+ moved from the (removed) tail() reader to the consume path.
38
+ */
39
+ tailProbeSlot.probe = (name) => {
40
+ lifecycle.track()
41
+ const entries = [...registry].filter((entry) => name === undefined || entry.source === name)
42
+ return {
43
+ pending:
44
+ (name !== undefined && entries.length === 0) ||
45
+ entries.some((entry) => entry.status === 'pending'),
46
+ refreshing: entries.some((entry) => entry.refreshing),
47
+ /* Terminal only when there is at least one entry and all of them have closed. */
48
+ done: entries.length > 0 && entries.every((entry) => entry.status === 'done'),
49
+ error: entries.find((entry) => entry.error !== undefined)?.error,
50
+ }
51
+ }
52
+
53
+ export type StreamProbe = {
54
+ frame(): void
55
+ reconnecting(): void
56
+ done(): void
57
+ errored(error: Error): void
58
+ close(): void
59
+ }
60
+
61
+ /*
62
+ Opens a probe entry for a consumed named subscribable and returns the handle the
63
+ consume loop drives: `frame()` on each delivered value (pending → open), plus the
64
+ terminal/lifecycle transitions. `close()` evicts on teardown. Reactivity is the
65
+ registry lifecycle channel, marked on every transition.
66
+ */
67
+ export function openStreamProbe(source: string): StreamProbe {
68
+ const entry: StreamEntry = {
69
+ source,
70
+ status: 'pending',
71
+ refreshing: false,
72
+ error: undefined,
73
+ everFramed: false,
74
+ }
75
+ registry.add(entry)
76
+ lifecycle.mark()
77
+ return {
78
+ frame() {
79
+ /* Only ping on a real transition (pending→open, or refreshing clearing) so a
80
+ chatty stream doesn't re-derive every bare probe reader on each value. */
81
+ const transitioned = entry.status !== 'open' || entry.refreshing
82
+ entry.status = 'open'
83
+ entry.refreshing = false
84
+ entry.everFramed = true
85
+ if (transitioned) {
86
+ lifecycle.mark()
87
+ }
88
+ },
89
+ reconnecting() {
90
+ entry.refreshing = entry.everFramed
91
+ lifecycle.mark()
92
+ },
93
+ done() {
94
+ if (entry.status !== 'error') {
95
+ entry.status = 'done'
96
+ }
97
+ entry.refreshing = false
98
+ lifecycle.mark()
99
+ },
100
+ errored(error: Error) {
101
+ entry.error = error
102
+ entry.status = 'error'
103
+ entry.refreshing = false
104
+ lifecycle.mark()
105
+ },
106
+ close() {
107
+ registry.delete(entry)
108
+ lifecycle.mark()
109
+ },
110
+ }
111
+ }
@@ -10,7 +10,14 @@ itself gives on the server: a named stream has no value yet (pending true),
10
10
  and nothing is reconnecting (refreshing false).
11
11
  */
12
12
  export const tailProbeSlot: {
13
- probe: ((name?: string) => { pending: boolean; refreshing: boolean }) | undefined
13
+ probe:
14
+ | ((name?: string) => {
15
+ pending: boolean
16
+ refreshing: boolean
17
+ done: boolean
18
+ error: Error | undefined
19
+ })
20
+ | undefined
14
21
  } = {
15
22
  probe: undefined,
16
23
  }
@@ -59,26 +59,20 @@ export type CacheEntry = {
59
59
  ttl: number | undefined
60
60
  expiresAt: number | undefined
61
61
  value?: unknown
62
- /*
63
- Lazy warm materializer (Tier 2 deferred seed): a memoized thunk that decodes the
64
- shipped body into the warm value on first call, so a deferred snapshot pays no decode
65
- at hydration — only when a read actually needs the value. `value` stays undefined until
66
- then; the read gate treats an entry with `warm` set as warm without invoking it. Eager
67
- snapshots set `value` and leave this undefined.
68
- */
69
- warm?: () => unknown
70
62
  tags?: Set<string>
71
63
  settled?: boolean
72
64
  hydrated?: boolean
73
- /*
74
- Marks that the SSR resume path deferred this entry's `{#await cache()}` value — shipped a
75
- `{ defer, key }` marker instead of inlining it. The post-stream snapshot drain reads it to
76
- ship the body as a LAZY seed (decoded on first read, not at hydration). Set server-side
77
- only, on the request-scoped entry, during the stream.
78
- */
79
- deferred?: boolean
80
65
  refreshing?: boolean
81
66
  invalidation?: InvalidationState
67
+ /*
68
+ Set by the smart bare call (a replayable read routed through cache.read): the
69
+ display value is retained unconditionally and `ttl` marks a staleness deadline
70
+ (the next read past it revalidates in the background, stale stays visible) rather
71
+ than driving the hard eviction an explicit cache() ttl triggers. Distinguishes the
72
+ smart-read lifecycle from the explicit cache()/invalidate old surface, which keeps
73
+ its drop-on-ttl / drop-on-invalidate behaviour.
74
+ */
75
+ retain?: boolean
82
76
  }
83
77
 
84
78
  /* Per-key invalidate coalescing: the throttle/debounce policy plus the timer/in-flight state. */
@@ -34,10 +34,18 @@ cache() throws at wrap time on throttle+debounce set at once, on ttl: 0 (nothing
34
34
  retained, nothing to revalidate), and on a non-replayable remote method
35
35
  (replaying a write is a state change disguised as a refresh). Producers are
36
36
  uncheckable — set `swr` only on a producer that is a pure read.
37
+
38
+ `throttle`/`debounce` are root rate-limit windows for the refetch clock. For the
39
+ smart bare call — where SWR is unconditional for replayable reads, so there is no
40
+ `swr` toggle to hang a window off — they cap how often a background revalidation
41
+ fires (leading-edge-then-coalesce, or fire-after-quiet respectively). They pair
42
+ with the same wrap-time guard as the `swr` window: set one, not both.
37
43
  */
38
44
  export type CacheOptions = {
39
45
  ttl?: number
40
46
  tags?: string[]
41
47
  global?: boolean
42
48
  swr?: boolean | { throttle?: number; debounce?: number }
49
+ throttle?: number
50
+ debounce?: number
43
51
  }
@@ -14,9 +14,4 @@ export type CacheSnapshotEntry = {
14
14
  statusText: string
15
15
  headers: Array<[string, string]>
16
16
  body: string
17
- /* Deferred seed (Tier 2): the client stores the body but does NOT decode it at boot —
18
- the warm value is materialized lazily on the first read, off the hydration path. Set
19
- by the server for a deferred `{#await cache()}` whose value ships via a `{defer,key}`
20
- resume marker instead of inline, so hydration touches neither copy's decode. */
21
- lazy?: boolean
22
17
  }
@@ -1,4 +1,5 @@
1
1
  import type { RpcOptions } from './RpcOptions.ts'
2
+ import type { Subscribable } from './Subscribable.ts'
2
3
 
3
4
  /*
4
5
  Call signature shared by RemoteFunction and RawRemoteFunction. The base
@@ -8,14 +9,31 @@ optional-arg signature lets call sites write `fn()` instead of
8
9
  `fn(undefined)`. Intersection rather than a bare conditional so the type
9
10
  stays callable while `Args` is still generic (cache() invokes producers
10
11
  before `Args` resolves). FormData is the multipart upload escape hatch —
11
- see RemoteFunction. The optional trailing `opts` carries per-call transport
12
- options (signal/keepalive/priority/cache/headers); the server ignores them,
13
- so the callable stays isomorphic.
12
+ see RemoteFunction. The trailing `Opts` differs by variant: the smart bare
13
+ call (RemoteFunction) carries cache/stream options (SmartReadOptions), while
14
+ `.raw` (RawRemoteFunction) carries per-call transport options (RpcOptions:
15
+ signal/keepalive/priority/cache/headers). The server ignores either, so the
16
+ callable stays isomorphic.
17
+
18
+ A streaming `Resolved` (an `AsyncIterable<Frame>`, branded by jsonl()/sse()) makes
19
+ the bare call return a `Subscribable<Frame>` synchronously — the iterable IS the
20
+ value, consumed by `for await` or `state(fn(args))`, no `.stream()`. Every other
21
+ `Resolved` stays `Promise<Resolved>`. `await`-ing a streaming call is a compile
22
+ error (a Subscribable is not awaitable), which replaces the old runtime guard.
14
23
  */
15
- export type RemoteCallable<Args, Resolved> = ((
24
+ /* `[Resolved] extends [never]` first: a handler that only ever returns error() resolves to
25
+ `never`, and `never extends AsyncIterable` is vacuously true — without this guard its call
26
+ would mistype as a Subscribable. A real streaming rpc's `Resolved` is `AsyncIterable<Frame>`. */
27
+ type CallResult<Resolved> = [Resolved] extends [never]
28
+ ? Promise<Resolved>
29
+ : Resolved extends AsyncIterable<infer Frame>
30
+ ? Subscribable<Frame>
31
+ : Promise<Resolved>
32
+
33
+ export type RemoteCallable<Args, Resolved, Opts = RpcOptions> = ((
16
34
  args: Args | FormData,
17
- opts?: RpcOptions,
18
- ) => Promise<Resolved>) &
35
+ opts?: Opts,
36
+ ) => CallResult<Resolved>) &
19
37
  (undefined extends Args
20
- ? (args?: Args | FormData, opts?: RpcOptions) => Promise<Resolved>
38
+ ? (args?: Args | FormData, opts?: Opts) => CallResult<Resolved>
21
39
  : unknown)
@@ -4,8 +4,9 @@ import type { HttpMethod } from './HttpMethod.ts'
4
4
  import type { Outbox } from './Outbox.ts'
5
5
  import type { RawRemoteFunction } from './RawRemoteFunction.ts'
6
6
  import type { RemoteCallable } from './RemoteCallable.ts'
7
+ import type { RpcError } from './RpcError.ts'
7
8
  import type { RpcErrorGuard } from './RpcErrorGuard.ts'
8
- import type { Subscribable } from './Subscribable.ts'
9
+ import type { SmartReadOptions } from './SmartReadOptions.ts'
9
10
 
10
11
  /*
11
12
  Remote function reference produced by GET/POST/... inside an `$rpc/**`
@@ -19,12 +20,12 @@ RemoteFunction whose call resolves to the underlying Response — same
19
20
  method, same url, same args, no decode. Pass `fn.raw` to cache() to
20
21
  memoise raw Responses against the same cache key as `fn` (both share one
21
22
  stored entry — the decode just happens on the way out for callers of
22
- `fn`). `.stream(args)` returns an iterable view of the Response body:
23
- SSE / JSONL handlers yield each frame; non-streaming handlers yield the
24
- decoded body once then complete. The result is a Subscribable, so it
25
- can be passed to tail() and shared across reactive consumers.
26
- For sustained broadcast / pub-sub use the `abide/server/socket` primitive —
27
- HTTP rpc isn't the place for long-lived multi-publisher subscriptions.
23
+ `fn`). A streaming handler (jsonl()/sse()) makes the bare call return a
24
+ `Subscribable<Frame>` directly (the iterable IS the value) consume it with
25
+ `for await (… of fn(args))` or `state(fn(args))`; there is no `.stream()`, and
26
+ `await`-ing a streaming call is a compile error. For sustained broadcast /
27
+ pub-sub use the `abide/server/socket` primitive — HTTP rpc isn't the place for
28
+ long-lived multi-publisher subscriptions.
28
29
  `.fetch(req)` is the framework's request-dispatch entry point — used by
29
30
  the router to invoke the handler from an incoming HTTP request, not
30
31
  for user code.
@@ -44,23 +45,57 @@ export type RemoteFunction<
44
45
  Return,
45
46
  Errors extends ErrorSpec = Record<never, never>,
46
47
  Durable extends boolean = false,
47
- > = RemoteCallable<Args, Return> & {
48
+ > = RemoteCallable<Args, Return, SmartReadOptions> & {
48
49
  readonly method: HttpMethod
49
50
  readonly url: string
50
51
  readonly clients: ClientFlags
51
52
  readonly crossOrigin?: boolean
52
53
  readonly raw: RawRemoteFunction<Args>
53
- stream(args?: Args | FormData): Subscribable<Return>
54
54
  fetch(request: Request): Promise<Response>
55
55
  /* Type-guard a caught error against this rpc's declared `errors` (plus the framework
56
56
  `'validation'` / `'queued'`): narrows `.kind` and, for a known kind, `.data` — the
57
57
  per-rpc replacement for a global guard, since the error name → data type mapping
58
58
  lives in the rpc's own spec. */
59
59
  readonly isError: RpcErrorGuard<Errors>
60
- } /* `outbox` presence follows the `outbox: true` opt: the mutating helper threads `Durable`
60
+ /* Pre-bound selector sugar: `fn.pending(args?)` `pending(fn, args?)`, and likewise for
61
+ refreshing / refresh / peek — the rpc is the leading selector, bound in. The argument is
62
+ this rpc's typed `Args` (the by-args refinement); tags / cross-cutting selection stay on
63
+ the globals. The bare call `fn(args, opts)` IS the cached read (was `fn.cache`), and
64
+ `refresh(args?)` refetches keeping the stale value visible (was `invalidate`, which
65
+ dropped). `peek(args?)` reads the retained value synchronously (a streaming rpc peeks its
66
+ latest frame). `patch` is fetch-only, so it is omitted for a streaming rpc (a stream isn't
67
+ a memoized value) via the intersection below. */
68
+ pending(args?: Args): boolean
69
+ refreshing(args?: Args): boolean
70
+ refresh(args?: Args): void
71
+ peek(args?: Args): ([Return] extends [AsyncIterable<infer Frame>] ? Frame : Return) | undefined
72
+ /* This rpc's last error, typed off `Errors` (a discriminated union already narrowed on
73
+ `.kind`/`.data`). `args` scopes to one call; omitted aggregates the most-recent across
74
+ this rpc. Truthiness is the "isError" check. Instance-only — no bare global (it would
75
+ shadow the server `error()` thrower). Reads the rpc error registry captured at the call
76
+ boundary; the cache is untouched. */
77
+ error(args?: Args): RpcError<Errors> | undefined
78
+ /* Client-only reaction sugar: `fn.watch(handler)` / `fn.watch(args, handler)` ≡
79
+ `watch(fn, …)` — runs the smart read reactively and pipes each resolved value to the
80
+ handler, returning a scope-tied disposer. Reaction is a client concern (`watch` is a ui
81
+ primitive), so this is an inert no-op server-side; an author `fn.watch(…)` surviving the
82
+ SSR effect-strip (which leaves member calls intact) is a safe no-op there. The real method
83
+ is attached client-side by remoteProxy. */
84
+ watch(handler: (value: Return) => void): () => void
85
+ watch(args: Args, handler: (value: Return) => void): () => void
86
+ } /* `patch` is fetch-only: a streaming rpc has no single memoized value to mutate, so the
87
+ method is present only when `Return` is not an AsyncIterable. Two signatures: with args
88
+ (`patch(args, updater)`) and without (`patch(updater)` — every args-variant). Tuple-wrapped
89
+ so the conditional doesn't distribute over a `never` Return (an error-only rpc). */ & ([
90
+ Return,
91
+ ] extends [AsyncIterable<unknown>]
92
+ ? Record<never, never>
93
+ : {
94
+ patch(args: Args | undefined, updater: (current: Return) => Return): void
95
+ patch(updater: (current: Return) => Return): void
96
+ }) /* `outbox` presence follows the `outbox: true` opt: the mutating helper threads `Durable`
61
97
  into the return type so a DURABLE rpc's `.outbox` is the required queue face (no optional
62
98
  chain). A non-durable rpc keeps it optional — assignable to a durable one everywhere a
63
99
  bare `RemoteFunction<Args, Return>` slot (cache selectors, registries) is expected, since
64
- required→optional widens cleanly and no call site had to learn the `Durable` bit. */ & (Durable extends true
65
- ? { readonly outbox: Outbox<Args> }
66
- : { readonly outbox?: Outbox<Args> })
100
+ required→optional widens cleanly and no call site had to learn the `Durable` bit. */ &
101
+ (Durable extends true ? { readonly outbox: Outbox<Args> } : { readonly outbox?: Outbox<Args> })
@@ -1,10 +1,13 @@
1
1
  /*
2
- The mutable cell a runtime entry registers its resolver into, plus the single
3
- lazy/value fallback used when no resolver is registered. createServer and
4
- startClient install side-specific resolvers; isolated tests poke `.resolver` /
5
- `.fallback` directly, so both fields stay public and mutable.
2
+ One resolved runtime value's whole seam: the mutable cell a runtime entry
3
+ registers its resolver into, the single lazy/value fallback used when no
4
+ resolver is registered, and the read. createServer and startClient install
5
+ side-specific resolvers by assigning `.resolver`; isolated tests poke
6
+ `.resolver` / `.fallback` directly, so both fields stay public and mutable.
6
7
  */
7
8
  export type ResolverSlot<T> = {
8
9
  resolver: (() => T | undefined) | undefined
9
10
  fallback: T | undefined
11
+ // The resolved value: the registered resolver's, else the (lazy) fallback.
12
+ get(): T | undefined
10
13
  }
@@ -0,0 +1,26 @@
1
+ import type { HttpError } from '../HttpError.ts'
2
+ import type { ErrorSpec } from './ErrorSpec.ts'
3
+ import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
4
+
5
+ /* Payload a declared error name carries: its data schema's inferred input, or `unknown`
6
+ for a nullary error (no data schema) — mirrors RpcErrorGuard's DeclaredErrorData. */
7
+ type DeclaredData<
8
+ Errors extends ErrorSpec,
9
+ Name extends keyof Errors,
10
+ > = Errors[Name]['data'] extends StandardSchemaV1
11
+ ? StandardSchemaV1.InferInput<Errors[Name]['data']>
12
+ : unknown
13
+
14
+ /*
15
+ The typed error value `fn.error()` returns: the discriminated union of this rpc's declared
16
+ errors (name → data), each an HttpError narrowed on `.kind` / `.data`. The read-side mirror
17
+ of RpcErrorGuard's narrowing, as a value rather than a guard — so `getUser.error()?.name`
18
+ is already narrowed with no guard call. `never` (collapses to `undefined`-only at the call
19
+ site) for an rpc that declares no errors.
20
+ */
21
+ export type RpcError<Errors extends ErrorSpec> = {
22
+ [Name in keyof Errors & string]: HttpError & {
23
+ kind: Name
24
+ data: DeclaredData<Errors, Name>
25
+ }
26
+ }[keyof Errors & string]
@@ -0,0 +1,32 @@
1
+ /*
2
+ Second-argument options for the smart bare rpc call (`getFoo(args, opts)`). The
3
+ call is cached/coalesced/reactive with SWR always on for replayable reads, so
4
+ these govern retention and the refetch clock — NOT transport. Per-call transport
5
+ options (signal/keepalive/priority/cache/headers) live on `.raw(args, init)`.
6
+
7
+ Fetch reads:
8
+ - `ttl` — retention/staleness in ms: undefined = retain forever (no auto-refetch),
9
+ N ms = the retained value goes stale after N ms and the next access triggers a
10
+ background revalidation (stale stays visible, `refreshing()` true). The display
11
+ value is never dropped; ttl drives staleness, not eviction.
12
+ - `tags` — free-form invalidation-group labels (see the selector grammar).
13
+ - `throttle` / `debounce` — rate-limit the background refetch clock (set one, not
14
+ both). Leading-edge-then-coalesce, or fire-after-quiet respectively.
15
+ - `global` — store the entry in the process-level store instead of the default
16
+ request-scoped one (server), so a value computed in one request is reused by
17
+ later requests — the memoise-an-external-endpoint case. Omit it for per-user
18
+ data: the default keeps a per-user response from leaking across requests.
19
+ Write only `global: true`; there is no `false` form. On the client there is a
20
+ single tab store, so the flag is a no-op there.
21
+
22
+ Streaming reads (jsonl/sse):
23
+ - `n` — how many retained frames to replay before going live.
24
+ */
25
+ export type SmartReadOptions = {
26
+ ttl?: number
27
+ tags?: string[]
28
+ throttle?: number
29
+ debounce?: number
30
+ global?: boolean
31
+ n?: number
32
+ }
@@ -0,0 +1,54 @@
1
+ import type { CacheOnContext } from './CacheOnContext.ts'
2
+ import type { ClientFlags } from './ClientFlags.ts'
3
+ import type { TailHooks } from './TailHooks.ts'
4
+
5
+ /*
6
+ Bidirectional named broadcast primitive. Declared once with `socket<T>()`
7
+ inside a file under `src/server/sockets/`; the same import resolves to a server-side
8
+ fan-out and a client-side ws proxy by build target. Iterating the socket
9
+ is the live stream — no replay. `.tail(count)` opens a subscription seeded
10
+ with the last `count` frames of the retained tail (declared via
11
+ `{ tail: n }`; no-arg = the whole retained tail) before going live — the
12
+ optional Subscribable retention capability a reactive `watch(socket, …)`
13
+ seeds from. `broadcast` is isomorphic: server code broadcasts in-process
14
+ and fans out to remote subscribers; client code sends a `pub` frame the
15
+ dispatcher validates against the topic's `clientPublish` flag. `clients`
16
+ exposes which adapter surfaces (browser / mcp / cli) advertise this
17
+ socket.
18
+ */
19
+ export interface Socket<T> extends AsyncIterable<T> {
20
+ readonly name: string
21
+ readonly clients: ClientFlags
22
+ /* Send a frame to every subscriber (server always; client only when `clientPublish` is
23
+ set). Isomorphic: server code fans out in-process + to remote subscribers; client code
24
+ sends a validated `pub` frame. */
25
+ broadcast(message: T): void
26
+ tail(count?: number, hooks?: TailHooks): AsyncIterable<T>
27
+ /* The latest retained frame, synchronously — `T | undefined` when none. The value
28
+ member of the probe family for a stream; `peek(socket)` routes here. */
29
+ peek(): T | undefined
30
+ /* Reactive probe sugar mirroring the rpc instance — the socket is the pre-bound stream
31
+ selector: `socket.pending()` ≡ `pending(socket)` (no frame yet), `socket.refreshing()` ≡
32
+ `refreshing(socket)` (a held frame revalidating, e.g. across a reconnect gap),
33
+ `socket.done()` ≡ `done(socket)` (the stream closed). Client-reactive; on the server (and
34
+ the raw test builder) they read the same fallbacks the globals give with no tail prober
35
+ registered (pending true until a frame, refreshing/done false). */
36
+ pending(): boolean
37
+ refreshing(): boolean
38
+ done(): boolean
39
+ /* The stream's terminal error, or undefined. Instance-only — like the rpc's `.error`, since a
40
+ bare `error` global would shadow the server `error()` thrower. Reactive off the tail prober's
41
+ error field; undefined on the server. */
42
+ error(): Error | undefined
43
+ /* Drop local frames and re-pull the server's retained tail — the socket analog of
44
+ getFoo.refresh(), for a ttl-expired frame or a reconnect resync. Server-side it is a
45
+ no-op (the server IS the source). */
46
+ refresh(): void
47
+ /* Client-only reaction sugar: `socket.watch(handler)` ≡ `watch(socket, handler)` — the
48
+ handler runs per delivered frame with reconnect-replay and returns a scope-tied disposer.
49
+ Reaction is a client concern (`watch` is a ui primitive that never rides into a server
50
+ bundle), so server-side — and in the raw test builder — this is an inert no-op like
51
+ `.refresh`; the SSR effect-strip leaves member calls intact, so an author `socket.watch(…)`
52
+ reaching the server is a safe no-op. The real method is attached client-side by socketProxy. */
53
+ watch(handler: (frame: T, context: CacheOnContext) => void | Promise<void>): () => void
54
+ }
@@ -0,0 +1,27 @@
1
+ /*
2
+ The subset of the `__SSR__` payload the client seeds into ambient slots at boot: the
3
+ mount base, the default log channel (app name), the warm health payload, and the
4
+ env-configured RPC client timeout. Every field here is stamped by the server's SSR
5
+ state tag and MUST be seeded client-side by `seedBootState`, whose seed map is keyed
6
+ EXHAUSTIVELY off this type — so a field added here fails to compile until both sides
7
+ wire it. That compile error is the guard closing the silent stamped-but-unseeded gap
8
+ (the bug that left `ABIDE_CLIENT_TIMEOUT` inoperative in the browser while every test
9
+ still passed).
10
+ */
11
+ export type SsrBootState = {
12
+ /* The mount base (`base || undefined`); seeds the base resolver so call keys and
13
+ navigation resolve rooted paths. Seeded first — the cache seed that follows keys
14
+ through it. */
15
+ base?: string
16
+ /* The app name — the default log channel; falls back to `'app'` when absent. */
17
+ app?: string
18
+ /* The health payload, so `health()`'s first client probe is warm rather than cold. */
19
+ health?: Record<string, unknown>
20
+ /* The env-configured RPC client timeout (`ABIDE_CLIENT_TIMEOUT`), shipped per request;
21
+ absent → unbounded client fetches. */
22
+ clientTimeout?: number
23
+ /* The SSR request's W3C traceparent; seeds the browser request scope so trace(), log
24
+ prefixes, and RPC traceparent headers continue the document's trace. Absent → a
25
+ fresh sampled trace is minted at boot. */
26
+ trace?: string
27
+ }
@@ -0,0 +1,21 @@
1
+ import type { CacheSnapshotEntry } from './CacheSnapshotEntry.ts'
2
+ import type { SsrBootState } from './SsrBootState.ts'
3
+
4
+ /*
5
+ The `window.__SSR__` payload: the single contract between the server's SSR state tag
6
+ (`createUiPageRenderer.stateTag`) and the client entry (`startClient`). Both sides
7
+ import THIS type — the server stamps its object with `satisfies SsrPayload` (an unknown
8
+ or mis-typed field is a compile error) and the client reads through it — so the
9
+ write-set and read-set can't drift apart on a matching-string-key handshake.
10
+
11
+ Three partitions:
12
+ - `SsrBootState` fields seed ambient slots via the exhaustive `seedBootState` map;
13
+ - `cache` is the inline warm-seed partition, drained through `seedResolved`;
14
+ - `route`/`params` are stamped for inspectability and are NOT read by the client
15
+ (the router re-resolves the route from the URL).
16
+ */
17
+ export type SsrPayload = SsrBootState & {
18
+ route: string
19
+ params: Record<string, string>
20
+ cache?: CacheSnapshotEntry[]
21
+ }
@@ -1,24 +1,24 @@
1
1
  import type { TailHooks } from './TailHooks.ts'
2
2
 
3
3
  /*
4
- The thing `tail()` reads from: an AsyncIterable carrying a stable `name`
5
- used as the subscription registry key. Both `Socket<T>` (the declared
6
- broadcast primitive) and the result of `fn.stream(args)` (per-call HTTP
7
- stream consumer) satisfy this shape, so tail() can share one iterator
8
- across multiple readers regardless of source.
4
+ A named async stream: an AsyncIterable carrying a stable `name` used as the
5
+ subscription registry key. Both `Socket<T>` (the declared broadcast
6
+ primitive) and a streaming rpc's bare call (a `jsonl`/`sse` handler makes
7
+ the call return the stream itself) satisfy this shape, so the consume path
8
+ (`watch` `cache.on`) and the stream probes (`pending`/`refreshing`/
9
+ `done`/`error`) handle either source through one contract.
9
10
 
10
11
  The name on a Socket comes from the file path under `src/server/sockets/`.
11
- The name on an fn.stream(args) result is `keyForRemoteCall(method, url,
12
- args)` — the same key cache() uses — so two readers of the same remote-call
13
- args dedupe to one underlying fetch.
12
+ The name on a streaming rpc call is `keyForRemoteCall(method, url, args)` —
13
+ the same key the smart call's cache store uses — so two readers of the same
14
+ remote-call args dedupe to one underlying fetch.
14
15
 
15
16
  `tail` is the optional retention capability: a source that keeps a tail of
16
17
  recent frames hands back an iterable seeded with at most the last `count`
17
- before going live. Sockets implement it verbatim; one-shot rpc streams omit
18
- it. The tail() consumer uses it to bound replay to what the reader will
19
- keep it never requires it. Implementers must signal `hooks.replayed`
20
- in-band once the seed portion is delivered (even when empty) so window
21
- readers can commit atomically; see TailHooks.
18
+ before going live. Sockets implement it verbatim (it also backs the socket's
19
+ HTTP/SSE read face); one-shot rpc streams omit it. Implementers must signal
20
+ `hooks.replayed` in-band once the seed portion is delivered (even when
21
+ empty) so a seeded reader can commit its window atomically; see TailHooks.
22
22
  */
23
23
  export interface Subscribable<T> extends AsyncIterable<T> {
24
24
  readonly name: string
@@ -1,5 +1,6 @@
1
1
  /*
2
- Hooks a tail() reader passes to a Subscribable's retention capability.
2
+ Hooks a `tail(count, hooks)` caller passes to a Subscribable's retention
3
+ capability.
3
4
  `replayed` must be signalled in-band exactly once per iteration — after the
4
5
  last replayed frame, before any live frame, and even when nothing was
5
6
  replayed — so a window reader can commit its seed atomically instead of