@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,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
+ }
@@ -0,0 +1,14 @@
1
+ import { activeCacheStore } from './activeCacheStore.ts'
2
+ import { sharedCacheStoreSlot } from './sharedCacheStoreSlot.ts'
3
+ import type { CacheStore } from './types/CacheStore.ts'
4
+
5
+ /*
6
+ Resolves the process-level ("shared") CacheStore that `cache(fn, { shared: true })`
7
+ entries live in. The server entry registers a module-singleton resolver so the
8
+ store survives across requests; the client points it at the active tab store.
9
+ When no resolver is registered it falls back to the active store, so `shared`
10
+ degrades to request/tab-scoped rather than throwing.
11
+ */
12
+ export function sharedCacheStore(): CacheStore {
13
+ return sharedCacheStoreSlot.resolver?.() ?? activeCacheStore()
14
+ }
@@ -0,0 +1,10 @@
1
+ import { createResolverSlot } from './createResolverSlot.ts'
2
+ import type { CacheStore } from './types/CacheStore.ts'
3
+
4
+ /*
5
+ The process-level ("shared") CacheStore slot that `cache(fn, { shared: true })`
6
+ entries live in. The server entry registers a module-singleton store outliving
7
+ every request; the client points it at the active tab store. When no resolver
8
+ is registered, sharedCacheStore() falls back to the active store.
9
+ */
10
+ export const sharedCacheStoreSlot = createResolverSlot<CacheStore>()
@@ -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. */
@@ -2,21 +2,25 @@
2
2
  Options for cache(). The key is always auto-derived (method+url+args for a remote
3
3
  function, producer-reference+args for a plain producer): hoist a producer to a
4
4
  stable reference to share its entry across calls. `ttl` is the
5
- milliseconds-past-resolve that the entry stays live: omitted = forever, 0 =
6
- dedupe only (entry dropped once the promise settles — the mutation idiom:
7
- in-flight coalescing and pending() visibility, nothing retained), any other
8
- number = TTL.
5
+ milliseconds-past-resolve that the entry stays live: 0 = dedupe only (entry
6
+ dropped once the promise settles — the mutation idiom: in-flight coalescing and
7
+ pending() visibility, nothing retained), any other number = TTL. Omitted = forever
8
+ for a producer and for a remote call on the client; a remote call on the SERVER
9
+ with neither `ttl` nor `swr` stated defaults to 0 (coalesce-only — the request is
10
+ the atomic unit, nothing is retained past it; pair with `shared` + an explicit
11
+ `ttl` to memoise across requests).
9
12
  `tags` is an array of free-form labels grouping unrelated calls so one
10
13
  `cache.invalidate({ tags })` drops every entry sharing any of them — list
11
14
  multiple when a call belongs to multiple invalidation groups. A unique tag (e.g.
12
15
  a uuid) shared by a set of calls gives them their own private invalidation group.
13
16
 
14
- `global` opts the entry into the process-level store instead of the default
15
- request-scoped one (server) — so a value computed in one request is reused by
16
- later requests, e.g. memoising an external endpoint the server calls. Omit it
17
- for per-request data: the default keeps a per-user response from leaking across
18
- requests. Write only `global: true`; there is no `false` form. On the client
19
- there is a single tab store, so the flag is a no-op there.
17
+ `shared` opts the entry into the process-level store instead of the default
18
+ request-scoped one (server) — a store that outlives every request. It selects
19
+ the store only; it does NOT retain (pair it with `ttl` to memoise across
20
+ requests). The shared store is keyed by method+url+args, never by user, so do
21
+ not put per-user data in it it would be served to other users. Omit `shared`
22
+ for per-request data. Write only `shared: true`; there is no `false` form. On
23
+ the client there is a single tab store, so the flag is a no-op there.
20
24
 
21
25
  `swr` is stale-while-revalidate: it changes what a `cache.invalidate` hit does
22
26
  to this key. Without it, an invalidate drops the entry and the next read shows
@@ -34,10 +38,18 @@ cache() throws at wrap time on throttle+debounce set at once, on ttl: 0 (nothing
34
38
  retained, nothing to revalidate), and on a non-replayable remote method
35
39
  (replaying a write is a state change disguised as a refresh). Producers are
36
40
  uncheckable — set `swr` only on a producer that is a pure read.
41
+
42
+ `throttle`/`debounce` are root rate-limit windows for the refetch clock. For the
43
+ smart bare call — where SWR is unconditional for replayable reads, so there is no
44
+ `swr` toggle to hang a window off — they cap how often a background revalidation
45
+ fires (leading-edge-then-coalesce, or fire-after-quiet respectively). They pair
46
+ with the same wrap-time guard as the `swr` window: set one, not both.
37
47
  */
38
48
  export type CacheOptions = {
39
49
  ttl?: number
40
50
  tags?: string[]
41
- global?: boolean
51
+ shared?: boolean
42
52
  swr?: boolean | { throttle?: number; debounce?: number }
53
+ throttle?: number
54
+ debounce?: number
43
55
  }
@@ -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
  }
@@ -2,7 +2,7 @@
2
2
  Per-store cache read tallies, frozen into the request's closing log record at
3
3
  settle. hit = a read served from a settled retained entry (including the warm
4
4
  SSR sync path); coalesced = a read that joined an in-flight call; miss = a
5
- read that invoked its producer/remote. Reads against the process-level global
5
+ read that invoked its producer/remote. Reads against the process-level shared
6
6
  store count into the requesting scope's store, so a request's record reflects
7
7
  everything that request asked the cache for.
8
8
  */
@@ -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,36 @@
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. Default: 0 on the server (coalesce-only —
9
+ the request is the atomic unit, nothing is retained past it), Infinity on the
10
+ client (retain until invalidate/refresh — the tab is the atomic unit). On the
11
+ client, N ms marks a staleness deadline: the retained value goes stale after N
12
+ ms and the next access triggers a background revalidation (stale stays visible,
13
+ `refreshing()` true); the display value is never dropped. On the server, N ms
14
+ is a plain expiry and only takes effect in the shared store (pair with `shared`).
15
+ - `tags` — free-form invalidation-group labels (see the selector grammar).
16
+ - `throttle` / `debounce` — rate-limit the background refetch clock (set one, not
17
+ both). Leading-edge-then-coalesce, or fire-after-quiet respectively.
18
+ - `shared` — opts the entry into the process-level store instead of the default
19
+ request-scoped one (server) — a store that outlives every request. It selects
20
+ the store only; it does NOT retain (pair it with `ttl` to memoise across
21
+ requests). The shared store is keyed by method+url+args, never by user, so do
22
+ not put per-user data in it — it would be served to other users. Omit `shared`
23
+ for per-request data. Write only `shared: true`; there is no `false` form. On
24
+ the client there is a single tab store, so the flag is a no-op there.
25
+
26
+ Streaming reads (jsonl/sse):
27
+ - `n` — how many retained frames to replay before going live.
28
+ */
29
+ export type SmartReadOptions = {
30
+ ttl?: number
31
+ tags?: string[]
32
+ throttle?: number
33
+ debounce?: number
34
+ shared?: boolean
35
+ n?: number
36
+ }
@@ -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
+ }