@abide/abide 0.42.0 → 0.43.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 (146) hide show
  1. package/AGENTS.md +234 -300
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +50 -93
  4. package/package.json +3 -2
  5. package/src/abideResolverPlugin.ts +104 -14
  6. package/src/buildCli.ts +1 -1
  7. package/src/discoveryEntry.ts +3 -3
  8. package/src/lib/bundle/BundleMenuItem.ts +1 -1
  9. package/src/lib/cli/createClient.ts +12 -12
  10. package/src/lib/cli/dispatchCommand.ts +1 -1
  11. package/src/lib/cli/printSessionStatus.ts +1 -1
  12. package/src/lib/cli/resolveCliTarget.ts +1 -1
  13. package/src/lib/cli/runCli.ts +1 -1
  14. package/src/lib/cli/types/CliManifest.ts +1 -1
  15. package/src/lib/cli/types/CliManifestEntry.ts +2 -2
  16. package/src/lib/mcp/annotationsForMethod.ts +5 -5
  17. package/src/lib/mcp/createMcpServer.ts +3 -3
  18. package/src/lib/mcp/mcpSurface.ts +14 -14
  19. package/src/lib/mcp/types/McpServerOptions.ts +1 -1
  20. package/src/lib/server/DELETE.ts +4 -4
  21. package/src/lib/server/GET.ts +4 -4
  22. package/src/lib/server/HEAD.ts +4 -4
  23. package/src/lib/server/PATCH.ts +4 -4
  24. package/src/lib/server/POST.ts +4 -4
  25. package/src/lib/server/PUT.ts +4 -4
  26. package/src/lib/server/agent.ts +4 -4
  27. package/src/lib/server/env.ts +1 -1
  28. package/src/lib/server/error.ts +49 -7
  29. package/src/lib/server/json.ts +1 -1
  30. package/src/lib/server/rpc/buildErrorConstructors.ts +19 -0
  31. package/src/lib/server/rpc/{defineVerb.ts → defineRpc.ts} +52 -34
  32. package/src/lib/server/rpc/{dispatchVerbInProcess.ts → dispatchRpcInProcess.ts} +3 -3
  33. package/src/lib/server/rpc/fieldErrorsFromIssues.ts +23 -0
  34. package/src/lib/server/rpc/{findVerbByCommandName.ts → findRpcByCommandName.ts} +5 -5
  35. package/src/lib/server/rpc/parseArgs.ts +6 -6
  36. package/src/lib/server/rpc/readBodyWithinLimit.ts +2 -2
  37. package/src/lib/server/rpc/registerRpc.ts +6 -0
  38. package/src/lib/server/rpc/{verbRegistry.ts → rpcRegistry.ts} +4 -4
  39. package/src/lib/server/rpc/{runWithVerbTimeout.ts → runWithRpcTimeout.ts} +4 -4
  40. package/src/lib/server/rpc/types/RemoteHandler.ts +9 -3
  41. package/src/lib/server/rpc/types/{VerbHelper.ts → RpcHelper.ts} +57 -23
  42. package/src/lib/server/rpc/types/{VerbRegistryEntry.ts → RpcRegistryEntry.ts} +3 -3
  43. package/src/lib/server/rpc/types/TypedResponse.ts +2 -2
  44. package/src/lib/server/rpc/unprocessed.ts +6 -7
  45. package/src/lib/server/rpc/validationError.ts +17 -0
  46. package/src/lib/server/runtime/buildInspectorSurface.ts +7 -7
  47. package/src/lib/server/runtime/buildOpenApiSpec.ts +6 -6
  48. package/src/lib/server/runtime/createRouteDispatcher.ts +7 -7
  49. package/src/lib/server/runtime/createServer.ts +4 -4
  50. package/src/lib/server/runtime/crossOriginForbidden.ts +1 -1
  51. package/src/lib/server/runtime/crossOriginGate.ts +4 -4
  52. package/src/lib/server/runtime/logExposedSurfaces.ts +4 -4
  53. package/src/lib/server/runtime/registryManifests.ts +2 -2
  54. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  55. package/src/lib/server/runtime/types/InspectorRpc.ts +27 -0
  56. package/src/lib/server/runtime/types/InspectorSurface.ts +4 -4
  57. package/src/lib/server/runtime/types/RequestStore.ts +1 -1
  58. package/src/lib/server/runtime/warnUnguardedMcp.ts +2 -2
  59. package/src/lib/server/sockets/createSocketDispatcher.ts +15 -14
  60. package/src/lib/server/sockets/types/SocketOperation.ts +2 -2
  61. package/src/lib/shared/HttpError.ts +15 -1
  62. package/src/lib/shared/UNREACHABLE_STATUSES.ts +13 -0
  63. package/src/lib/shared/buildRpcRequest.ts +5 -5
  64. package/src/lib/shared/cache.ts +50 -168
  65. package/src/lib/shared/carriesBodyArgs.ts +4 -4
  66. package/src/lib/shared/createCacheStore.ts +12 -0
  67. package/src/lib/shared/createRemoteFunction.ts +11 -6
  68. package/src/lib/shared/decodeResponse.ts +2 -2
  69. package/src/lib/shared/detectRpcMethod.ts +17 -0
  70. package/src/lib/shared/emitLogRecord.ts +3 -3
  71. package/src/lib/shared/extraForwardHeaders.ts +1 -1
  72. package/src/lib/shared/findExportCallSite.ts +30 -273
  73. package/src/lib/shared/forwardHeaders.ts +2 -2
  74. package/src/lib/shared/httpErrorFor.ts +26 -0
  75. package/src/lib/shared/isAsciiWhitespace.ts +5 -0
  76. package/src/lib/shared/isIdentPart.ts +9 -0
  77. package/src/lib/shared/isIdentStart.ts +8 -0
  78. package/src/lib/shared/isReadOnlyMethod.ts +4 -4
  79. package/src/lib/shared/keyForRemoteCall.ts +4 -4
  80. package/src/lib/shared/log.ts +1 -1
  81. package/src/lib/shared/outboxProbeSlot.ts +20 -0
  82. package/src/lib/shared/pending.ts +4 -1
  83. package/src/lib/shared/prepareRpcModule.ts +96 -14
  84. package/src/lib/shared/probeRegistries.ts +17 -4
  85. package/src/lib/shared/queryStringFromArgs.ts +1 -1
  86. package/src/lib/shared/remoteMetaStore.ts +2 -2
  87. package/src/lib/shared/resolveClientFlags.ts +1 -1
  88. package/src/lib/shared/skipNonCode.ts +230 -0
  89. package/src/lib/shared/streamResponse.ts +9 -6
  90. package/src/lib/shared/stripImport.ts +3 -3
  91. package/src/lib/shared/types/CacheEntry.ts +2 -4
  92. package/src/lib/shared/types/CacheOnContext.ts +1 -11
  93. package/src/lib/shared/types/CacheStore.ts +13 -7
  94. package/src/lib/shared/types/ClientFlags.ts +1 -1
  95. package/src/lib/shared/types/ErrorConstructors.ts +17 -0
  96. package/src/lib/shared/types/ErrorDescriptor.ts +10 -0
  97. package/src/lib/shared/types/ErrorSpec.ts +11 -0
  98. package/src/lib/shared/types/HttpMethod.ts +1 -0
  99. package/src/lib/shared/types/Outbox.ts +9 -0
  100. package/src/lib/shared/types/OutboxEntry.ts +27 -0
  101. package/src/lib/shared/types/RawRemoteFunction.ts +2 -2
  102. package/src/lib/shared/types/RemoteCallable.ts +2 -2
  103. package/src/lib/shared/types/RemoteFunction.ts +21 -6
  104. package/src/lib/shared/types/RequestScopeInfo.ts +1 -1
  105. package/src/lib/shared/types/RpcErrorGuard.ts +40 -0
  106. package/src/lib/shared/types/RpcInvoker.ts +1 -1
  107. package/src/lib/shared/types/StandardSchemaV1.ts +1 -1
  108. package/src/lib/shared/types/ValidationErrorData.ts +20 -0
  109. package/src/lib/shared/url.ts +3 -3
  110. package/src/lib/shared/writeRpcDts.ts +7 -7
  111. package/src/lib/shared/writeTestRpcDts.ts +3 -3
  112. package/src/lib/test/createTestApp.ts +10 -10
  113. package/src/lib/ui/compile/awaitPlan.ts +48 -0
  114. package/src/lib/ui/compile/compileShadow.ts +75 -21
  115. package/src/lib/ui/compile/desugarSignals.ts +42 -6
  116. package/src/lib/ui/compile/generateBuild.ts +43 -60
  117. package/src/lib/ui/compile/generateSSR.ts +84 -54
  118. package/src/lib/ui/compile/ifPlan.ts +30 -0
  119. package/src/lib/ui/compile/parseTemplate.ts +41 -11
  120. package/src/lib/ui/compile/stripEffects.ts +17 -9
  121. package/src/lib/ui/compile/switchPlan.ts +22 -0
  122. package/src/lib/ui/compile/tryPlan.ts +29 -0
  123. package/src/lib/ui/compile/types/TemplateAttr.ts +8 -2
  124. package/src/lib/ui/compile/types/TemplateNode.ts +6 -2
  125. package/src/lib/ui/createScope.ts +20 -2
  126. package/src/lib/ui/dom/disposeRange.ts +6 -10
  127. package/src/lib/ui/dom/hydrate.ts +2 -5
  128. package/src/lib/ui/dom/mount.ts +1 -2
  129. package/src/lib/ui/dom/withScope.ts +6 -9
  130. package/src/lib/ui/effect.ts +4 -2
  131. package/src/lib/ui/navigate.ts +40 -13
  132. package/src/lib/ui/outbox.ts +30 -110
  133. package/src/lib/ui/remoteProxy.ts +160 -10
  134. package/src/lib/ui/router.ts +3 -3
  135. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +281 -0
  136. package/src/lib/ui/rpcOutbox/outboxRegistry.ts +69 -0
  137. package/src/lib/ui/socketChannel.ts +7 -1
  138. package/src/lib/ui/types/Scope.ts +20 -5
  139. package/template/CLAUDE.md +1 -1
  140. package/template/src/server/rpc/getHello.ts +3 -3
  141. package/template/test/app.test.ts +2 -2
  142. package/src/lib/server/rpc/registerVerb.ts +0 -6
  143. package/src/lib/server/runtime/types/InspectorVerb.ts +0 -27
  144. package/src/lib/shared/detectVerbMethod.ts +0 -17
  145. package/src/lib/shared/types/HttpVerb.ts +0 -1
  146. package/src/lib/ui/types/Outbox.ts +0 -14
@@ -1,6 +1,6 @@
1
1
  import { abideLog } from '../../shared/abideLog.ts'
2
2
  import type { Pages } from '../../ui/types/Pages.ts'
3
- import { verbRegistry } from '../rpc/verbRegistry.ts'
3
+ import { rpcRegistry } from '../rpc/rpcRegistry.ts'
4
4
  import { socketRegistry } from '../sockets/socketRegistry.ts'
5
5
  import { ensureRegistriesLoaded } from './registryManifests.ts'
6
6
 
@@ -15,7 +15,7 @@ const redden = (text: string): string =>
15
15
  hasColor ? `${Bun.color('red', 'ansi-256')}${text}\x1b[39m` : text
16
16
 
17
17
  /*
18
- A declared inputSchema is what makes mcp/cli safe to advertise (see defineVerb /
18
+ A declared inputSchema is what makes mcp/cli safe to advertise (see defineRpc /
19
19
  defineSocket), so a missing schema gets a red `·` to flag the declaration whose
20
20
  machine surfaces are gated behind it.
21
21
  */
@@ -127,12 +127,12 @@ export async function logExposedSurfaces(routing: { pages: Pages }): Promise<voi
127
127
  */
128
128
  const methodWidth = Math.max(
129
129
  'http'.length,
130
- ...Array.from(verbRegistry.values(), (entry) => entry.remote.method.length),
130
+ ...Array.from(rpcRegistry.values(), (entry) => entry.remote.method.length),
131
131
  )
132
132
  const withMethod = (method: string, identifier: string): string =>
133
133
  method.padEnd(methodWidth + COLUMN_GAP) + identifier
134
134
 
135
- const rpcRows = Array.from(verbRegistry.values(), (entry) => [
135
+ const rpcRows = Array.from(rpcRegistry.values(), (entry) => [
136
136
  withMethod(entry.remote.method, entry.remote.url),
137
137
  schemaCell(Boolean(entry.inputSchema)),
138
138
  flag(entry.clients.browser),
@@ -7,7 +7,7 @@ Process-wide slot for the rpc + sockets + prompts manifests. createServer
7
7
  assigns once at boot (right after the route table is built); the MCP
8
8
  server, the OpenAPI emitter, and prompt enumeration read it on first
9
9
  request so they can lazy-import every module and walk the
10
- verb/socket/prompt registries.
10
+ rpc/socket/prompt registries.
11
11
 
12
12
  The slot pattern (mirrors getActiveServer) lets the framework-generated
13
13
  McpServer bind to the manifests at module scope while the loaders stay
@@ -29,7 +29,7 @@ export function setRegistryManifests(value: RegistryManifests): void {
29
29
 
30
30
  /*
31
31
  On first call, eagerly imports every rpc + socket + prompt module so
32
- defineVerb / defineSocket / definePrompt fire and populate the
32
+ defineRpc / defineSocket / definePrompt fire and populate the
33
33
  registries. Idempotent — repeat calls reuse the same in-flight promise,
34
34
  so concurrent first requests (e.g. /openapi.json + an MCP tools/list)
35
35
  trigger exactly one load instead of racing to fire the full import set
@@ -9,7 +9,7 @@ export type InspectorCacheEntry = {
9
9
  key: string
10
10
  /* Lifecycle: 'settled' | 'in-flight' | 'refreshing'. */
11
11
  status: string
12
- /* True when the entry stores a wire Response (a remote verb), false for a plain producer value. */
12
+ /* True when the entry stores a wire Response (a remote rpc), false for a plain producer value. */
13
13
  remote: boolean
14
14
  /* Retention ttl in ms; undefined = forever, 0 = dedupe-only. */
15
15
  ttl: number | undefined
@@ -0,0 +1,27 @@
1
+ /*
2
+ One RPC rpc projected for the inspector: the registry entry reduced to the
3
+ serializable facts the UI renders — where it mounts, its method, which
4
+ non-browser surfaces advertise it, and its argument/result shapes as JSON
5
+ Schema. Schemas project through jsonSchemaForSchema (same as MCP/OpenAPI), so
6
+ a rpc whose library can't render a schema still lists with an opaque shape.
7
+ */
8
+ export type InspectorRpc = {
9
+ /* Registry key — the rpc URL the rpc mounts at (e.g. /rpc/users/create). */
10
+ url: string
11
+ /* HTTP method bound to the rpc. */
12
+ method: string
13
+ /* Which surfaces advertise it (browser/mcp/cli), from the rpc's ClientFlags. */
14
+ clients: Record<string, boolean>
15
+ /* Argument-bag shape as JSON Schema; undefined when the rpc declares none. */
16
+ inputSchema: Record<string, unknown> | undefined
17
+ /* Success-body shape as JSON Schema; undefined when the rpc declares none. */
18
+ outputSchema: Record<string, unknown> | undefined
19
+ /* True when the rpc declares a filesSchema — it accepts multipart File parts. */
20
+ files: boolean
21
+ /* Per-rpc handler deadline in ms; undefined = no deadline. */
22
+ timeout: number | undefined
23
+ /* Per-rpc received-body cap in bytes; undefined = Bun's server-wide ceiling. */
24
+ maxBodySize: number | undefined
25
+ /* True when the rpc opts out of the same-origin CSRF gate. */
26
+ crossOrigin: boolean | undefined
27
+ }
@@ -1,16 +1,16 @@
1
1
  import type { InspectorPrompt } from './InspectorPrompt.ts'
2
+ import type { InspectorRpc } from './InspectorRpc.ts'
2
3
  import type { InspectorSocket } from './InspectorSocket.ts'
3
- import type { InspectorVerb } from './InspectorVerb.ts'
4
4
 
5
5
  /*
6
6
  The app's machine surface projected for the inspector — the static catalog the
7
- UI renders. Built by reading the verb, socket, and prompt registries after
7
+ UI renders. Built by reading the rpc, socket, and prompt registries after
8
8
  they're eager-loaded, so a freshly-booted server lists its whole surface, not
9
- just the verbs hit so far. Pages stay out: they're the human surface, already
9
+ just the rpcs hit so far. Pages stay out: they're the human surface, already
10
10
  navigable.
11
11
  */
12
12
  export type InspectorSurface = {
13
- verbs: InspectorVerb[]
13
+ rpcs: InspectorRpc[]
14
14
  sockets: InspectorSocket[]
15
15
  prompts: InspectorPrompt[]
16
16
  }
@@ -3,7 +3,7 @@ import type { TraceContext } from '../../../shared/types/TraceContext.ts'
3
3
 
4
4
  /*
5
5
  Per-request state propagated through AsyncLocalStorage. Every field is
6
- populated once at the server's fetch boundary; helpers and verb-defined
6
+ populated once at the server's fetch boundary; helpers and rpc-defined
7
7
  remote functions read from it without threading arguments through user code.
8
8
  The inbound request's AbortSignal is reached via `req.signal` rather than a
9
9
  separate field.
@@ -1,5 +1,5 @@
1
1
  import { abideLog } from '../../shared/abideLog.ts'
2
- import { verbRegistry } from '../rpc/verbRegistry.ts'
2
+ import { rpcRegistry } from '../rpc/rpcRegistry.ts'
3
3
  import { socketRegistry } from '../sockets/socketRegistry.ts'
4
4
  import { ensureRegistriesLoaded } from './registryManifests.ts'
5
5
 
@@ -19,7 +19,7 @@ export async function warnUnguardedMcp(): Promise<void> {
19
19
  return
20
20
  }
21
21
  const isMcpExposed = (entry: { clients: { mcp: boolean } }): boolean => entry.clients.mcp
22
- const exposed = [...verbRegistry.values(), ...socketRegistry.values()].filter(
22
+ const exposed = [...rpcRegistry.values(), ...socketRegistry.values()].filter(
23
23
  isMcpExposed,
24
24
  ).length
25
25
  if (exposed === 0) {
@@ -68,9 +68,10 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
68
68
 
69
69
  /*
70
70
  Loads a socket module on first use and reads its registry entry — the
71
- one statement of the load/lookup failure semantics (a throwing module is
72
- logged here and stays cached as failed by ensureLoaded). Each face maps
73
- the failure kind to its own rendering: sub emits err+end frames, pub
71
+ one statement of the load/lookup failure semantics. A throwing module is
72
+ logged here; memoizeByKey evicts the rejected load so the next frame
73
+ retries (a transient import failure doesn't poison the name). Each face
74
+ maps the failure kind to its own rendering: sub emits err+end frames, pub
74
75
  drops silently, rest answers with HTTP errors.
75
76
  */
76
77
  async function resolveEntry(
@@ -200,10 +201,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
200
201
  send(ws, { type: 'end', sub: frame.sub })
201
202
  }
202
203
 
203
- async function handlePub(
204
- ws: ServerWebSocket<unknown>,
205
- frame: Extract<SocketClientFrame, { type: 'pub' }>,
206
- ): Promise<void> {
204
+ async function handlePub(frame: Extract<SocketClientFrame, { type: 'pub' }>): Promise<void> {
207
205
  const resolution = await resolveEntry(frame.socket)
208
206
  if ('failure' in resolution) {
209
207
  return
@@ -233,11 +231,6 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
233
231
  } catch (publishError) {
234
232
  abideLog.error(publishError)
235
233
  }
236
- /*
237
- ws parameter retained for future per-ws auth context (cookies on
238
- upgrade) the canPublish hook would consult.
239
- */
240
- void ws
241
234
  }
242
235
 
243
236
  /*
@@ -252,7 +245,8 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
252
245
  clientPublish policy and validated against its schema.
253
246
 
254
247
  Loads the socket module on first hit (same cache the ws path uses) so
255
- its defineSocket call populates the registry.
248
+ its defineSocket call populates the registry. A socket exposed to neither
249
+ mcp nor cli answers 404 here — its `clients` flags gate this surface.
256
250
  */
257
251
  async function rest(req: Request, name: string): Promise<Response> {
258
252
  return socketsLog.trace(`socket-rest ${name}`, () => restImpl(req, name))
@@ -266,6 +260,13 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
266
260
  : error(404)
267
261
  }
268
262
  const { entry } = resolution
263
+ /* The REST face is the CLI/MCP transport (the browser uses the ws multiplex). A
264
+ socket advertised to neither non-browser surface has no REST consumer, so its
265
+ `clients` flags are access control here: 404 as if unmounted, not 403, so a
266
+ browser-only socket's existence doesn't leak over this surface. */
267
+ if (!entry.clients.mcp && !entry.clients.cli) {
268
+ return error(404)
269
+ }
269
270
  const tailParam = new URL(req.url).searchParams.get('tail')
270
271
  const parsedTail = tailParam !== null ? Number(tailParam) : undefined
271
272
  // A non-numeric ?tail= yields NaN; treat it as absent rather than letting
@@ -338,7 +339,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
338
339
  return
339
340
  }
340
341
  if (frame.type === 'pub') {
341
- void handlePub(ws, frame)
342
+ void handlePub(frame)
342
343
  return
343
344
  }
344
345
  },
@@ -1,4 +1,4 @@
1
- import type { HttpVerb } from '../../../shared/types/HttpVerb.ts'
1
+ import type { HttpMethod } from '../../../shared/types/HttpMethod.ts'
2
2
 
3
3
  /*
4
4
  One operation a socket exposes to the non-browser surfaces. A socket
@@ -18,5 +18,5 @@ export type SocketOperation = {
18
18
  // HTTP face of the operation: `/__abide/sockets/<name>`.
19
19
  restUrl: string
20
20
  // GET for tail, POST for publish.
21
- method: HttpVerb
21
+ method: HttpMethod
22
22
  }
@@ -9,12 +9,26 @@ export class HttpError extends Error {
9
9
  readonly status: number
10
10
  readonly statusText: string
11
11
  readonly response: Response
12
+ /* Set when the handler returned a typed error (`error(errors.x(...))`) or a
13
+ validation 422: `kind` is the declared error name (or 'validation'), `data` the
14
+ payload it carried — parsed off the `{ $abideError, data }` body by decodeResponse.
15
+ `data` is typed `unknown` (a throw can't carry the rpc's per-kind type to the
16
+ catch); narrow it yourself — for `kind: 'validation'` the shape is the exported
17
+ `ValidationErrorData` (`{ issues, fields }`). Both undefined for a plain `error(status, text)`.
18
+ The framework also reserves `kind: 'queued'` for a durable (`outbox: true`) call
19
+ parked because the server was unreachable — `data` then holds the parked OutboxEntry,
20
+ so `(error.data as OutboxEntry).settled` awaits the eventual delivered result or
21
+ server refusal (the entry's own `error` carries the underlying cause). */
22
+ readonly kind?: string
23
+ readonly data?: unknown
12
24
 
13
- constructor(response: Response) {
25
+ constructor(response: Response, kind?: string, data?: unknown) {
14
26
  super(`HTTP ${response.status} ${response.statusText || 'error'}`)
15
27
  this.name = 'HttpError'
16
28
  this.status = response.status
17
29
  this.statusText = response.statusText
18
30
  this.response = response
31
+ this.kind = kind
32
+ this.data = data
19
33
  }
20
34
  }
@@ -0,0 +1,13 @@
1
+ /*
2
+ HTTP statuses that mean "the server didn't process this request" — the gateway /
3
+ availability family. A response with one of these is treated like a transport failure
4
+ by a durable RPC: the call still throws (the error framework is unchanged), and the
5
+ request is parked for replay on recovery. Everything else (4xx, 500, …) means the
6
+ server received and handled it — that flows to the error framework, never the outbox.
7
+
8
+ 502 Bad Gateway · 503 Service Unavailable · 504 Gateway Timeout (abide's own client
9
+ timeout surfaces here too) · 520–527, 530 — Cloudflare/CDN origin-unreachable.
10
+ */
11
+ export const UNREACHABLE_STATUSES: ReadonlySet<number> = new Set([
12
+ 502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530,
13
+ ])
@@ -2,11 +2,11 @@ import { carriesBodyArgs } from './carriesBodyArgs.ts'
2
2
  import { encodeRefJson } from './encodeRefJson.ts'
3
3
  import { queryStringFromArgs } from './queryStringFromArgs.ts'
4
4
  import { REF_JSON_HEADER } from './REF_JSON_HEADER.ts'
5
- import type { HttpVerb } from './types/HttpVerb.ts'
5
+ import type { HttpMethod } from './types/HttpMethod.ts'
6
6
 
7
7
  /*
8
- Builds the Request a verb helper uses to invoke its handler. Same shape on
9
- both sides (server defineVerb + client remoteProxy) so the cache key
8
+ Builds the Request a rpc helper uses to invoke its handler. Same shape on
9
+ both sides (server defineRpc + client remoteProxy) so the cache key
10
10
  derivation and SSR snapshot round-trip identically. $rpc URLs are flat
11
11
  (no `:name` segments): GET/DELETE/HEAD serialise args onto the query
12
12
  string; POST/PUT/PATCH send them as application/json.
@@ -24,7 +24,7 @@ export function buildRpcRequest({
24
24
  baseUrl,
25
25
  headers,
26
26
  }: {
27
- method: HttpVerb
27
+ method: HttpMethod
28
28
  url: string
29
29
  args: unknown
30
30
  baseUrl: string
@@ -64,7 +64,7 @@ export function buildRpcRequest({
64
64
  })
65
65
  }
66
66
 
67
- function appendQuery(method: HttpVerb, url: string, args: unknown): string {
67
+ function appendQuery(method: HttpMethod, url: string, args: unknown): string {
68
68
  if (args === undefined) {
69
69
  return url
70
70
  }
@@ -180,8 +180,8 @@ export function cache<Args, Return>(
180
180
  }
181
181
  /*
182
182
  Warm path: a value pre-decoded onto the entry — by the SSR cache
183
- snapshot the client seeds its store from, or by a cache.on().patch
184
- broadcast — is served without a network round-trip. It resolves on a
183
+ snapshot the client seeds its store from is served without a network
184
+ round-trip. It resolves on a
185
185
  microtask (a settled Promise), not synchronously, so every cache() read
186
186
  is uniformly `Promise<Return>` and `.then`/`.catch`/`.finally` chain
187
187
  cleanly. Raw callers take the Response path; after an invalidate the
@@ -337,7 +337,7 @@ function invokeRemote<Args>(
337
337
  const request = getRemoteMeta(promise)
338
338
  if (!request) {
339
339
  throw new Error(
340
- '[abide] cache() received a function whose call did not record metadata — was it produced by a verb helper?',
340
+ '[abide] cache() received a function whose call did not record metadata — was it produced by a rpc helper?',
341
341
  )
342
342
  }
343
343
  registerEntry(store, key, promise, options, request, () => rawFn(args as Args))
@@ -512,24 +512,30 @@ function invalidate<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args
512
512
  const matches = selectorMatcher(arg, args, prefix)
513
513
  invalidateTripwire(selectorLabel(arg, args, prefix))
514
514
  for (const store of cacheStores()) {
515
+ const matched: string[] = []
515
516
  const affected: string[] = []
516
517
  /* Deleting the current entry mid-iteration is spec-safe on a Map; no snapshot needed. */
517
518
  for (const entry of store.entries.values()) {
518
519
  if (!matches(entry)) {
519
520
  continue
520
521
  }
522
+ matched.push(entry.key)
521
523
  if (entry.invalidation) {
522
524
  scheduleInvalidationRefetch(store, entry)
523
525
  } else {
524
526
  store.entries.delete(entry.key)
525
- /* Mark so the next read of this key reports as a reload via refreshing(). */
526
- store.pendingRefresh.add(entry.key)
527
+ /* Flag the next read a reload (refreshing()) but only if a reader is
528
+ holding the value now; with none on screen the next read is a first
529
+ load, and an ungated add would linger forever on the tab store. */
530
+ if (store.hasReader(entry.key)) {
531
+ store.pendingRefresh.add(entry.key)
532
+ }
527
533
  affected.push(entry.key)
528
534
  }
529
- store.markLifecycle(entry.key)
530
535
  }
531
- emit(store, affected)
532
- store.markLifecycle()
536
+ /* Every match changed state (probes re-derive); only the dropped subset
537
+ changed its visible value (readers re-read) — swr matches stay put. */
538
+ notify(store, matched, affected)
533
539
  }
534
540
  }
535
541
 
@@ -555,94 +561,6 @@ function selectorLabel<Args, Return>(
555
561
 
556
562
  cache.invalidate = invalidate
557
563
 
558
- type EntryWrite = { store: CacheStore; entry: CacheEntry; prior: unknown; next: unknown }
559
-
560
- /*
561
- Core value-fold shared by the authoritative (cache.on context.patch) and
562
- optimistic (cache.patch) write paths. Folds `updater` into every decoded remote
563
- entry matching the selector, writing the result to entry.value so the warm-sync
564
- read path serves it and emitting the keys so readers re-run (ADR-0007). Only
565
- entry.value is written; entry.promise is left untouched so raw readers of the
566
- same key keep reading the wire Response. Producer entries (no request) are
567
- skipped — patching is a decoded-value operation. The current value is entry.value
568
- when warm (hydrated or already patched), else the entry's settled Response decoded
569
- — hence async; the first fold of a live-fetched entry hops a decode, subsequent
570
- ones are synchronous. Returns the keys touched (the cache.on context registers
571
- them for reconnect resync) and a `restore` that reverts each write iff it still
572
- stands — the optimistic path runs it on a rejected call, the authoritative path
573
- discards it (a broadcast is truth, never undone).
574
- */
575
- async function foldEntries<Args, Return>(
576
- arg: CacheSelector<Args, Return>,
577
- updater: (current: Return) => Return,
578
- args: Args | undefined,
579
- prefix: string | undefined,
580
- ): Promise<{ touched: string[]; restore: () => void }> {
581
- const matches = selectorMatcher(arg, args, prefix ?? selectorPrefix(arg, args))
582
- const touched: string[] = []
583
- const writes: EntryWrite[] = []
584
- for (const store of cacheStores()) {
585
- const affected: string[] = []
586
- for (const entry of store.entries.values()) {
587
- if (!matches(entry) || entry.request === undefined) {
588
- continue
589
- }
590
- const prior = entry.value
591
- const current = (prior ??
592
- (await decodeResponse(
593
- await shareable(entry.promise as Promise<Response>),
594
- ))) as Return
595
- const next = structuredClone(updater(current))
596
- entry.value = next
597
- entry.settled = true
598
- entry.refreshing = false
599
- store.markLifecycle(entry.key)
600
- affected.push(entry.key)
601
- writes.push({ store, entry, prior, next })
602
- }
603
- emit(store, affected)
604
- store.markLifecycle()
605
- touched.push(...affected)
606
- }
607
- return { touched, restore: () => revertWrites(writes) }
608
- }
609
-
610
- /*
611
- Reverts each optimistic write iff entry.value still holds it — a refetch or a
612
- later write that already replaced it is the newer truth and is left intact — then
613
- notifies readers of the reverted keys per store.
614
- */
615
- function revertWrites(writes: EntryWrite[]): void {
616
- const reverted = new Map<CacheStore, string[]>()
617
- for (const { store, entry, prior, next } of writes) {
618
- if (store.entries.get(entry.key) !== entry || entry.value !== next) {
619
- continue
620
- }
621
- entry.value = prior
622
- store.markLifecycle(entry.key)
623
- reverted.set(store, [...(reverted.get(store) ?? []), entry.key])
624
- }
625
- for (const [store, keys] of reverted) {
626
- emit(store, keys)
627
- store.markLifecycle()
628
- }
629
- }
630
-
631
- /*
632
- The authoritative-broadcast fold: a cache.on frame is the truth, so foldEntries'
633
- write stands (no rollback) and only the touched keys are returned for coverage.
634
- */
635
- async function patchEntries<Args, Return>(
636
- arg: CacheSelector<Args, Return>,
637
- updater: (current: Return) => Return,
638
- args?: Args,
639
- /* The caller (on().patch) resolves the prefix for its coverage label; reuse it so selectorPrefix runs once per fold. */
640
- prefix?: string,
641
- ): Promise<string[]> {
642
- const { touched } = await foldEntries(arg, updater, args, prefix)
643
- return touched
644
- }
645
-
646
564
  /*
647
565
  Event-driven cache maintenance: subscribes to a Subscribable (socket or rpc
648
566
  stream) and runs `handler` once per frame — the declarative home for "this
@@ -685,21 +603,6 @@ function on<T>(
685
603
  coverage.set(selectorLabel(arg, args), () => invalidate(arg, args))
686
604
  invalidate(arg, args)
687
605
  },
688
- /*
689
- Register the selector (not the delta) for reconnect: a discarded delta
690
- can't be replayed, so a transport gap resyncs the patched keys by full
691
- invalidate — reusing the same coverage machinery as invalidate above.
692
- */
693
- patch<Args, Return>(
694
- arg: CacheSelector<Args, Return>,
695
- updater: (current: Return) => Return,
696
- args?: Args,
697
- ): Promise<string[]> {
698
- /* Resolve the prefix once; the coverage label and patchEntries both consume it. */
699
- const prefix = selectorPrefix(arg, args)
700
- coverage.set(selectorLabel(arg, args, prefix), () => invalidate(arg, args))
701
- return patchEntries(arg, updater, args, prefix)
702
- },
703
606
  signal: controller.signal,
704
607
  }
705
608
  /* `let`: the reconnect path swaps in a fresh iterator; dispose closes the current one. */
@@ -741,56 +644,6 @@ function on<T>(
741
644
 
742
645
  cache.on = on
743
646
 
744
- /*
745
- Optimistic write: applies `updater` as a prediction now — the reactive read
746
- shows it immediately — runs `call`, then reconciles. On resolve the server is
747
- the truth: the prediction is dropped and the selector invalidated, so the value
748
- refetches authoritatively, coalesced per the read's own swr window
749
- (cache(fn, { swr: { throttle } }) bounds an optimistic-write storm with no
750
- extra knob here; without swr it is a plain drop-and-refetch). On reject the
751
- prediction rolls back. The returned promise is transparent over `call` —
752
- resolves to `call`'s value (the mutation result, e.g. a created id), rejects
753
- with its error, settling only after the cache reflects the reconciled state: an
754
- explicit await reads truth, an ignored call is fire-and-forget (a pre-attached
755
- catch keeps an un-awaited rejection from surfacing as unhandled, while the await
756
- still receives it). `call` is required — a global authoritative write with no
757
- reconciling op would let a caller author cache values, breaking the
758
- producer-is-the-source invariant (ADR-0001); that path stays cache.on's
759
- context.patch. Single-flight per key: rollback restores by snapshot, so keep one
760
- mutation per key in flight (disable the trigger while pending) — concurrent
761
- same-key optimism wants a layered entry value, deferred (ADR-0009). The reactive
762
- read holds the value; the return carries the mutation result — separate by
763
- design (ADR-0009).
764
- */
765
- function patch<Args, Return, Result>(
766
- arg: CacheSelector<Args, Return>,
767
- updater: (current: Return) => Return,
768
- call: Promise<Result>,
769
- args?: Args,
770
- ): Promise<Result> {
771
- const prefix = selectorPrefix(arg, args)
772
- const folded = foldEntries(arg, updater, args, prefix)
773
- const settled = (async () => {
774
- try {
775
- const result = await call
776
- /* Wait for the prediction to land before reconciling to server truth. */
777
- await folded
778
- invalidate(arg, args)
779
- return result
780
- } catch (error) {
781
- const { restore } = await folded
782
- restore()
783
- throw error
784
- }
785
- })()
786
- /* Ignore-safe: an un-awaited rejection must not report unhandled; an explicit
787
- await still receives it (this no-op handler and the await both fire). */
788
- settled.catch(() => undefined)
789
- return settled
790
- }
791
-
792
- cache.patch = patch
793
-
794
647
  /*
795
648
  Schedules a coalesced refetch per the entry's swr policy. No window (swr: true):
796
649
  fire immediately (throttle defaults to 0, so the leading-edge branch always
@@ -852,8 +705,9 @@ function fireRefetch(store: CacheStore, entry: CacheEntry): void {
852
705
  }
853
706
  entry.refreshing = true
854
707
  policy.lastFiredAt = Date.now()
855
- /* Ping lifecycle so refreshing() re-derives when revalidation begins; the settle handlers ping again when it ends. */
856
- store.markLifecycle(entry.key)
708
+ /* Mark, don't emit: refreshing() re-derives when revalidation begins, but the
709
+ stale value is still on screen — the settle handlers emit once it lands. */
710
+ notify(store, [entry.key], [])
857
711
  const inflight = policy.refetch()
858
712
  inflight.then(
859
713
  (result) => {
@@ -876,8 +730,8 @@ function fireRefetch(store: CacheStore, entry: CacheEntry): void {
876
730
  if (entry.ttl !== undefined && entry.ttl !== 0) {
877
731
  armTtlExpiry(store, entry, entry.ttl)
878
732
  }
879
- store.markLifecycle(entry.key)
880
- emit(store, [entry.key])
733
+ /* Fresh value landed — mark and emit so readers re-read. */
734
+ notify(store, [entry.key], [entry.key])
881
735
  },
882
736
  (error) => {
883
737
  entry.refreshing = false
@@ -925,11 +779,16 @@ so a live read replaces it and surfaces the proper error once.
925
779
  function settleRefetchFailure(store: CacheStore, entry: CacheEntry, status?: number): void {
926
780
  if (status === 404) {
927
781
  evictIfCurrent(store, entry)
928
- store.pendingRefresh.add(entry.key)
929
- emit(store, [entry.key])
782
+ /* Same reader-gating as invalidate: only flag a reload if one is on screen. */
783
+ if (store.hasReader(entry.key)) {
784
+ store.pendingRefresh.add(entry.key)
785
+ }
786
+ /* Value gone — mark and emit so the next read replaces it. */
787
+ notify(store, [entry.key], [entry.key])
930
788
  return
931
789
  }
932
- store.markLifecycle(entry.key)
790
+ /* Mark, don't emit: stale value kept, only the refreshing flag cleared. */
791
+ notify(store, [entry.key], [])
933
792
  }
934
793
 
935
794
  /* Folds new tags into an entry's existing set without duplicating them. */
@@ -970,6 +829,29 @@ function attachPolicy(
970
829
  entry.invalidation = { refetch, throttle: policy.throttle, debounce: policy.debounce }
971
830
  }
972
831
 
832
+ /*
833
+ The single notification seam, holding the cache's freshness invariant in one
834
+ place instead of by hand at every mutation site. Two reader audiences, two
835
+ channels: every key in `marked` had its state change, so the lifecycle channels
836
+ fire and the pending()/refreshing() probes re-derive; only the keys in `emitted`
837
+ had their VISIBLE value change, so the 'invalidate' event fires and the reading
838
+ scope re-reads. The two sets diverge by design — a refetch start marks but does
839
+ not emit (the stale value is still on screen), an invalidate-drop does both, an
840
+ swr invalidate marks the whole match set while emitting only the dropped subset.
841
+ The trailing store-wide mark fires even when nothing matched, so a bare probe
842
+ still re-derives (to the same value); marks coalesce per microtask, so a key in
843
+ both lists is not double work.
844
+ */
845
+ function notify(store: CacheStore, marked: string[], emitted: string[]): void {
846
+ marked.forEach((key) => {
847
+ store.markLifecycle(key)
848
+ })
849
+ if (emitted.length > 0) {
850
+ emit(store, emitted)
851
+ }
852
+ store.markLifecycle()
853
+ }
854
+
973
855
  function emit(store: CacheStore, keys: string[]): void {
974
856
  if (keys.length === 0) {
975
857
  return
@@ -1,13 +1,13 @@
1
- import type { HttpVerb } from './types/HttpVerb.ts'
1
+ import type { HttpMethod } from './types/HttpMethod.ts'
2
2
 
3
3
  /*
4
- Whether a verb carries its args in the request body (POST/PUT/PATCH) vs
4
+ Whether a rpc carries its args in the request body (POST/PUT/PATCH) vs
5
5
  on the query string (GET/DELETE/HEAD). Single source for the split so the
6
6
  synthesized Request (buildRpcRequest), the handler-side parse (parseArgs),
7
7
  the cache key (keyForRemoteCall), and the OpenAPI doc can't disagree.
8
8
  */
9
- const BODY_METHODS = new Set<HttpVerb>(['POST', 'PUT', 'PATCH'])
9
+ const BODY_METHODS = new Set<HttpMethod>(['POST', 'PUT', 'PATCH'])
10
10
 
11
- export function carriesBodyArgs(method: HttpVerb): boolean {
11
+ export function carriesBodyArgs(method: HttpMethod): boolean {
12
12
  return BODY_METHODS.has(method)
13
13
  }
@@ -44,6 +44,13 @@ export function createCacheStore(): CacheStore {
44
44
  events.removeEventListener('invalidate', onInvalidate)
45
45
  if (subscribers.get(key) === registered) {
46
46
  subscribers.delete(key)
47
+ /* The reload marker is only ever consumed by the NEXT read of this
48
+ key (registerEntry). With the last reactive reader gone there is
49
+ no scope left to show refreshing() for it, and a future remount
50
+ reads with nothing on screen — a first-ever load, not a reload.
51
+ Drop the marker so the tab-scoped store can't accrete one per
52
+ invalidated-but-never-reread key over a session. */
53
+ pendingRefresh.delete(key)
47
54
  }
48
55
  }
49
56
  })
@@ -96,6 +103,11 @@ export function createCacheStore(): CacheStore {
96
103
  entries,
97
104
  events,
98
105
  subscribe,
106
+ /* True while a reactive scope is reading this key — i.e. someone is holding
107
+ its value on screen. invalidate() gates its reload marker on this: a key
108
+ with no live reader has nothing to reload into, so the next read is a
109
+ first-ever load, not a refresh. */
110
+ hasReader: (key: string) => subscribers.has(key),
99
111
  trackLifecycle,
100
112
  markLifecycle,
101
113
  pendingRefresh,