@abide/abide 0.42.0 → 0.43.1

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 +41 -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 +89 -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 +45 -15
  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
@@ -0,0 +1,20 @@
1
+ /*
2
+ Internal slot the browser outbox registry registers its prober into, so the
3
+ shared pending() probe can count parked durable-rpc writes without shared/
4
+ importing browser/. Mirrors tailProbeSlot. The prober reads the doc-backed
5
+ queue entries (reactive inside $derived / $effect) and reports whether any
6
+ undelivered entry matches the selector: a durable rpc selector (carries a
7
+ `url`) narrows to its own queue, the bare form spans every registered queue,
8
+ optional args narrow to one parked call by structural compare. Outbox state is
9
+ pending-only — a parked write has no value, so it never contributes to
10
+ refreshing(); probeRegistries reads this slot only on its `field === 'pending'`
11
+ branch, which is why the slot mirrors tailProbeSlot but reports a bare boolean
12
+ rather than a { pending, refreshing } pair. When no prober is registered (server
13
+ render, or no durable rpc was ever used) pending() sees no parked writes,
14
+ exactly as on the server where there are no client queues.
15
+ */
16
+ export const outboxProbeSlot: {
17
+ probe: ((selector: unknown, args: unknown) => boolean) | undefined
18
+ } = {
19
+ probe: undefined,
20
+ }
@@ -15,9 +15,12 @@ tail registry (streams). Pending means "no value yet":
15
15
  pending(subscribable) → that stream awaiting its first frame
16
16
  (tail.status === 'pending'; true when nothing
17
17
  is reading yet — there is no value either way)
18
+ pending(durableRpc) → that durable rpc's parked writes (a parked write
19
+ has no value yet); the bare form counts them too
18
20
  Probes report, never act: reading one opens no fetch and no stream. SSR
19
21
  loading state is driven by {#await}, not this. Scan semantics (tap order,
20
- selector grammar, registry spans) live in probeRegistries.
22
+ selector grammar, registry spans, the parked-write term) live in
23
+ probeRegistries.
21
24
  */
22
25
  // @documentation probes
23
26
  export function pending<Args, Return>(
@@ -1,24 +1,34 @@
1
1
  import { findExportCallSite } from './findExportCallSite.ts'
2
2
  import { importNamesToStrip } from './importNamesToStrip.ts'
3
+ import { isReadOnlyMethod } from './isReadOnlyMethod.ts'
4
+ import { skipNonCode } from './skipNonCode.ts'
3
5
  import { stripImport } from './stripImport.ts'
4
- import type { HttpVerb } from './types/HttpVerb.ts'
6
+ import type { HttpMethod } from './types/HttpMethod.ts'
5
7
 
6
- const VERB_NAMES = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const
7
- const VERB_SET = new Set<string>(VERB_NAMES)
8
+ const RPC_NAMES = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const
9
+ const RPC_SET = new Set<string>(RPC_NAMES)
8
10
 
9
11
  const SINGLE_EXPORT_ERROR =
10
- '[abide] $rpc module contains more than one `<VERB>(...)` export — each file must declare exactly one remote function'
12
+ '[abide] $rpc module contains more than one `<METHOD>(...)` export — each file must declare exactly one remote function'
11
13
 
12
14
  export type PreparedRpcModule = {
13
- verb: HttpVerb
15
+ method: HttpMethod
16
+ /* `outbox: true` in the opts — the client proxy is emitted durable. */
17
+ durable: boolean
14
18
  exportName: string
15
19
  rewriteForServer: (url: string) => string
16
20
  }
17
21
 
22
+ /* The `outbox` opts key plus its value's leading token (up to the next comma / whitespace /
23
+ closing brace). `outbox` is a BUILD-TIME flag: the client bundle is rewritten durable or
24
+ not from this scan, before any handler runs — so the value must be a literal the scan can
25
+ read, not a computed expression. */
26
+ const OUTBOX_OPT = /\boutbox\s*:\s*([^,\s}]+)/
27
+
18
28
  /*
19
- Scans an `$rpc/**` module once and returns its declared verb + export
29
+ Scans an `$rpc/**` module once and returns its declared method + export
20
30
  name plus a closure that, given the route URL, emits the server-side
21
- rewrite (`__abideDefineVerb__("VERB", "<url>", … )` spliced into the
31
+ rewrite (`__abideDefineRpc__("METHOD", "<url>", … )` spliced into the
22
32
  original source). The single scan replaces the prior separate
23
33
  extract + rewrite passes, so the resolver plugin only walks each source
24
34
  character-by-character once.
@@ -26,34 +36,106 @@ character-by-character once.
26
36
  A regex pass would be tidier but it can't tell a `GET` mention inside a
27
37
  docstring or template literal from the real call, and it can't follow
28
38
  nested generics like `GET<Map<K, V>>(`.
39
+
40
+ The two build-time `outbox` invariants live here, not at runtime, because the
41
+ client bundle's durability is decided here — making this the single source of
42
+ truth (the server-side defineRpc guard stays as a cheap backstop):
43
+ - `outbox` must be a literal `true`/`false`; a computed value can't be read at
44
+ bundle time, so it's rejected loudly instead of silently shipping a
45
+ non-durable client proxy.
46
+ - `outbox: true` is mutating-only — a read RPC (GET/HEAD) has nothing to
47
+ durably deliver.
29
48
  */
30
49
  export function prepareRpcModule(
31
50
  source: string,
32
51
  importName: string,
33
52
  ): PreparedRpcModule | undefined {
34
53
  /*
35
- The "no barrels" surface places each verb at its own path
54
+ The "no barrels" surface places each method at its own path
36
55
  (`abide/server/GET`, `abide/server/POST`, …) — strip every one so
37
- the user's verb import doesn't linger and side-effect-load the
56
+ the user's method import doesn't linger and side-effect-load the
38
57
  stub module into the server bundle. The user may import under the
39
58
  project's chosen name or the canonical package name, so strip both.
40
59
  */
41
60
  const stripped = importNamesToStrip(importName).reduce(
42
61
  (current, name) =>
43
- VERB_NAMES.reduce((acc, verb) => stripImport(acc, `${name}/server/${verb}`), current),
62
+ RPC_NAMES.reduce(
63
+ (acc, method) => stripImport(acc, `${name}/server/${method}`),
64
+ current,
65
+ ),
44
66
  source,
45
67
  )
46
- const site = findExportCallSite(stripped, (ident) => VERB_SET.has(ident), SINGLE_EXPORT_ERROR)
68
+ const site = findExportCallSite(stripped, (ident) => RPC_SET.has(ident), SINGLE_EXPORT_ERROR)
47
69
  if (!site) {
48
70
  return undefined
49
71
  }
50
- const verb = site.ident as HttpVerb
72
+ const method = site.ident as HttpMethod
73
+ const durable = detectDurable(stripped, site.parenStart, site.parenEnd, method)
51
74
  return {
52
- verb,
75
+ method,
76
+ durable,
53
77
  exportName: site.exportName,
54
78
  rewriteForServer(url: string): string {
55
- const binding = `__abideDefineVerb__(${JSON.stringify(verb)}, ${JSON.stringify(url)}, `
79
+ const binding = `__abideDefineRpc__(${JSON.stringify(method)}, ${JSON.stringify(url)}, `
56
80
  return stripped.slice(0, site.callStart) + binding + stripped.slice(site.parenStart + 1)
57
81
  },
58
82
  }
59
83
  }
84
+
85
+ /* Reads the `outbox` flag off the call's opts object (the trailing argument), enforcing the
86
+ two build-time invariants. Scoping to the opts object keeps the scan off the handler body,
87
+ so a handler that mentions `outbox:` doesn't misfire. */
88
+ function detectDurable(
89
+ source: string,
90
+ parenStart: number,
91
+ parenEnd: number,
92
+ method: HttpMethod,
93
+ ): boolean {
94
+ const opts = lastArgText(source, parenStart, parenEnd)
95
+ const match = opts === undefined ? null : OUTBOX_OPT.exec(opts)
96
+ if (match === null) {
97
+ return false
98
+ }
99
+ const value = match[1]
100
+ if (value !== 'true' && value !== 'false') {
101
+ throw new Error(
102
+ `[abide] \`outbox\` must be a literal \`true\` or \`false\` (got \`${value}\`) — it's a build-time flag the client bundle reads, so it can't be a computed expression`,
103
+ )
104
+ }
105
+ const durable = value === 'true'
106
+ if (durable && isReadOnlyMethod(method)) {
107
+ throw new Error(
108
+ `[abide] outbox: true is only valid on mutating RPCs (POST/PUT/PATCH/DELETE), not ${method}`,
109
+ )
110
+ }
111
+ return durable
112
+ }
113
+
114
+ /*
115
+ The text of the call's final argument — the opts object for a `METHOD(handler, opts)` call.
116
+ Walks the arg list depth-aware, skipping strings / templates / comments / regex (skipNonCode)
117
+ so their commas and braces don't miscount, and returns the slice after the last top-level
118
+ comma. undefined when the call has a single argument (a bare handler, no opts).
119
+ */
120
+ function lastArgText(source: string, parenStart: number, parenEnd: number): string | undefined {
121
+ let depth = 0
122
+ let lastComma = -1
123
+ let i = parenStart + 1
124
+ while (i < parenEnd) {
125
+ const skipped = skipNonCode(source, i)
126
+ if (skipped !== undefined) {
127
+ i = skipped
128
+ continue
129
+ }
130
+ const c = source[i]
131
+ if (c === '(' || c === '{' || c === '[') {
132
+ depth++
133
+ } else if (c === ')' || c === '}' || c === ']') {
134
+ depth--
135
+ } else if (c === ',' && depth === 0) {
136
+ lastComma = i
137
+ }
138
+ i++
139
+ }
140
+ return lastComma === -1 ? undefined : source.slice(lastComma + 1, parenEnd)
141
+ }
@@ -1,5 +1,6 @@
1
1
  import { cacheStores } from './cacheStores.ts'
2
2
  import { isSubscribable } from './isSubscribable.ts'
3
+ import { outboxProbeSlot } from './outboxProbeSlot.ts'
3
4
  import { selectorMatcher } from './selectorMatcher.ts'
4
5
  import { selectorPrefix } from './selectorPrefix.ts'
5
6
  import { tailProbeSlot } from './tailProbeSlot.ts'
@@ -22,9 +23,20 @@ cache() marks that channel, the re-derive resolves the prefix, and the scope
22
23
  re-tracks narrowed. `matchesEntry` is the probe's cache-side question;
23
24
  `field` selects the stream-side answer; `unprobed` is the answer for a
24
25
  stream no prober can see (server, or tail never imported) — matching what
25
- tail() itself reports there. The bare form (no selector) spans both
26
- registries; fn/tag selectors are cache identities only. Outside a tracking
27
- scope the taps are no-ops and the current value returns.
26
+ tail() itself reports there. The bare form (no selector) spans every
27
+ registry; fn/tag selectors are cache identities only (plus the parked-write
28
+ queue a durable rpc selector narrows to). Outside a tracking scope the taps
29
+ are no-ops and the current value returns.
30
+
31
+ A parked durable (`outbox`) write has no value yet, so it counts as pending —
32
+ but never refreshing (it's holding nothing to supersede), hence the term is
33
+ guarded to `field === 'pending'`. The outbox prober (registered into
34
+ outboxProbeSlot by browser/, empty on the server) reads its doc-backed queue,
35
+ which is the term's own reactive tap. It sits last under `||`: when a matching
36
+ cache call or stream already answers true the queue isn't read, but that's
37
+ monotonic-safe — the parked write can't flip a true result, and the moment the
38
+ cache/stream term falls back to false its lifecycle tap re-derives and the
39
+ queue is read then.
28
40
  */
29
41
  export function probeRegistries<Args, Return>(
30
42
  arg: CacheSelector<Args, Return> | Subscribable<unknown> | undefined,
@@ -46,7 +58,8 @@ export function probeRegistries<Args, Return>(
46
58
  const matches = selectorMatcher(arg, args, prefix)
47
59
  return (
48
60
  stores.some((store) => anyEntryMatches(store, matchesEntry, matches)) ||
49
- streams?.[field] === true
61
+ streams?.[field] === true ||
62
+ (field === 'pending' && (outboxProbeSlot.probe?.(arg, args) ?? false))
50
63
  )
51
64
  }
52
65
 
@@ -1,5 +1,5 @@
1
1
  /*
2
- Canonical query-string encoding for a query-carrying verb's args bag. The
2
+ Canonical query-string encoding for a query-carrying rpc's args bag. The
3
3
  single definition both the synthesized Request (buildRpcRequest) and its
4
4
  cache key (keyForRemoteCall) build their query from, so the request URL and
5
5
  the key encode every value identically and can't drift. `undefined` values
@@ -1,10 +1,10 @@
1
1
  /*
2
- WeakMap that records how to obtain the synthesized Request for a verb
2
+ WeakMap that records how to obtain the synthesized Request for a rpc
3
3
  call. The cache layer reads it to populate an entry's stored request
4
4
  without rebuilding from scratch.
5
5
 
6
6
  Stored as a thunk rather than the Request itself so SSR pages that fire
7
- dozens of in-process verb calls without ever reaching cache() don't pay
7
+ dozens of in-process rpc calls without ever reaching cache() don't pay
8
8
  the URL + Headers + Request allocation per call. The thunk memoises its
9
9
  own first call inside createRemoteFunction, so cache() and any future
10
10
  meta reader see the same Request instance.
@@ -4,7 +4,7 @@ import type { ClientFlags } from './types/ClientFlags.ts'
4
4
  Fills in the missing keys of a user-supplied `clients` option. Browser
5
5
  always defaults to true (the historical surface). The mcp/cli auto-on
6
6
  defaults are decided by the caller and passed in, since the safe default
7
- differs per declaration: a read-only verb may auto-expose to MCP while a
7
+ differs per declaration: a read-only rpc may auto-expose to MCP while a
8
8
  mutating one must not, and sockets gate differently again. Explicit
9
9
  values in `flags` always win over the computed defaults.
10
10
  */
@@ -0,0 +1,230 @@
1
+ import { isAsciiWhitespace } from './isAsciiWhitespace.ts'
2
+ import { isIdentPart } from './isIdentPart.ts'
3
+
4
+ /*
5
+ The shared "skip a non-code construct" primitive for the source scanners
6
+ (findExportCallSite, prepareRpcModule's arg walker, …). When `i` sits on the
7
+ start of a string, template literal, line/block comment, or regex literal, it
8
+ returns the index immediately after that construct; undefined when `source[i]`
9
+ begins ordinary code — including a `/` that's division rather than a regex.
10
+
11
+ Centralising the string / template / regex / comment rules here is what lets
12
+ every scanner walk source depth-aware without re-deriving them and getting the
13
+ regex-vs-division or `${…}` edge cases subtly wrong (the bug that made a naive
14
+ arg walker miscount a `{ ratio: a / b }` or a regex literal in the opts).
15
+ */
16
+ export function skipNonCode(source: string, i: number): number | undefined {
17
+ const c = source[i]
18
+ if (c === '"' || c === "'") {
19
+ return skipString(source, i + 1, c)
20
+ }
21
+ if (c === '`') {
22
+ return skipTemplate(source, i + 1)
23
+ }
24
+ if (c === '/') {
25
+ return skipSlashConstruct(source, i)
26
+ }
27
+ return undefined
28
+ }
29
+
30
+ function skipString(source: string, start: number, quote: string): number {
31
+ let i = start
32
+ while (i < source.length) {
33
+ const c = source[i]
34
+ if (c === '\\') {
35
+ i += 2
36
+ continue
37
+ }
38
+ if (c === quote) {
39
+ return i + 1
40
+ }
41
+ if (c === '\n') {
42
+ return i
43
+ }
44
+ i++
45
+ }
46
+ return source.length
47
+ }
48
+
49
+ function skipTemplate(source: string, start: number): number {
50
+ let i = start
51
+ while (i < source.length) {
52
+ const c = source[i]
53
+ if (c === '\\') {
54
+ i += 2
55
+ continue
56
+ }
57
+ if (c === '`') {
58
+ return i + 1
59
+ }
60
+ if (c === '$' && source[i + 1] === '{') {
61
+ i = skipTemplateExpression(source, i + 2)
62
+ continue
63
+ }
64
+ i++
65
+ }
66
+ return source.length
67
+ }
68
+
69
+ function skipTemplateExpression(source: string, start: number): number {
70
+ let depth = 1
71
+ let i = start
72
+ while (i < source.length && depth > 0) {
73
+ const c = source[i]
74
+ if (c === '{') {
75
+ depth++
76
+ i++
77
+ continue
78
+ }
79
+ if (c === '}') {
80
+ depth--
81
+ i++
82
+ continue
83
+ }
84
+ const skipped = skipNonCode(source, i)
85
+ if (skipped !== undefined) {
86
+ i = skipped
87
+ continue
88
+ }
89
+ i++
90
+ }
91
+ return i
92
+ }
93
+
94
+ /*
95
+ When `i` sits on a `/` introducing a line comment, block comment, or regex
96
+ literal, returns the index immediately after the construct; undefined when the
97
+ `/` is division (or `i` isn't a `/`). One statement of the skip rules shared by
98
+ every scanner loop so they can't drift.
99
+ */
100
+ function skipSlashConstruct(source: string, i: number): number | undefined {
101
+ if (source[i] !== '/') {
102
+ return undefined
103
+ }
104
+ const next = source[i + 1]
105
+ if (next === '/') {
106
+ const newline = source.indexOf('\n', i + 2)
107
+ return newline === -1 ? source.length : newline + 1
108
+ }
109
+ if (next === '*') {
110
+ const end = source.indexOf('*/', i + 2)
111
+ return end === -1 ? source.length : end + 2
112
+ }
113
+ if (isRegexContext(source, i)) {
114
+ return skipRegex(source, i + 1)
115
+ }
116
+ return undefined
117
+ }
118
+
119
+ /*
120
+ A `/` starts a regex literal when the prior expression context expects an
121
+ expression rather than a value — after an open delimiter, operator, or
122
+ expression-prefix keyword (return, typeof, instanceof, in, of, delete, void,
123
+ await, yield, new, throw, case, do). Otherwise `/` is division. Without this
124
+ disambiguation a regex like `/^\//` reads as `/` (division), then `^`, `\`,
125
+ `/`, `/` — and the final `//` pair fakes a line comment that swallows the rest
126
+ of the line, eating any `)` that closes the enclosing call.
127
+ */
128
+ const REGEX_PREFIX_KEYWORDS = new Set([
129
+ 'return',
130
+ 'typeof',
131
+ 'instanceof',
132
+ 'in',
133
+ 'of',
134
+ 'delete',
135
+ 'void',
136
+ 'await',
137
+ 'yield',
138
+ 'new',
139
+ 'throw',
140
+ 'case',
141
+ 'do',
142
+ ])
143
+
144
+ const REGEX_PUNCTUATION = new Set([
145
+ '(',
146
+ '[',
147
+ '{',
148
+ ',',
149
+ ';',
150
+ ':',
151
+ '?',
152
+ '!',
153
+ '&',
154
+ '|',
155
+ '^',
156
+ '~',
157
+ '+',
158
+ '-',
159
+ '*',
160
+ '%',
161
+ '<',
162
+ '>',
163
+ '=',
164
+ '/',
165
+ ])
166
+
167
+ function isRegexContext(source: string, slashIndex: number): boolean {
168
+ let i = slashIndex - 1
169
+ while (i >= 0 && isAsciiWhitespace(source[i])) {
170
+ i--
171
+ }
172
+ if (i < 0) {
173
+ return true
174
+ }
175
+ const prev = source[i] as string
176
+ if (REGEX_PUNCTUATION.has(prev)) {
177
+ return true
178
+ }
179
+ if (isIdentPart(prev)) {
180
+ let start = i
181
+ while (start > 0 && isIdentPart(source[start - 1])) {
182
+ start--
183
+ }
184
+ return REGEX_PREFIX_KEYWORDS.has(source.slice(start, i + 1))
185
+ }
186
+ return false
187
+ }
188
+
189
+ /*
190
+ Walks past a regex literal body, respecting character classes (`[...]` where
191
+ `/` is literal) and backslash escapes, then consumes trailing flag identifiers.
192
+ Returns the index immediately after the regex. An unterminated regex (newline
193
+ before the closing `/`) returns the newline position so the outer scanner can
194
+ resume normally on the next line.
195
+ */
196
+ function skipRegex(source: string, start: number): number {
197
+ let i = start
198
+ let inClass = false
199
+ while (i < source.length) {
200
+ const c = source[i]
201
+ if (c === '\\') {
202
+ i += 2
203
+ continue
204
+ }
205
+ if (c === '\n') {
206
+ return i
207
+ }
208
+ if (inClass) {
209
+ if (c === ']') {
210
+ inClass = false
211
+ }
212
+ i++
213
+ continue
214
+ }
215
+ if (c === '[') {
216
+ inClass = true
217
+ i++
218
+ continue
219
+ }
220
+ if (c === '/') {
221
+ let j = i + 1
222
+ while (j < source.length && isIdentPart(source[j])) {
223
+ j++
224
+ }
225
+ return j
226
+ }
227
+ i++
228
+ }
229
+ return source.length
230
+ }
@@ -1,6 +1,6 @@
1
1
  import { contentTypeOf } from './contentTypeOf.ts'
2
2
  import { decodeResponse } from './decodeResponse.ts'
3
- import { HttpError } from './HttpError.ts'
3
+ import { httpErrorFor } from './httpErrorFor.ts'
4
4
  import { jsonlErrorFrame } from './jsonlErrorFrame.ts'
5
5
  import { STREAMING_CONTENT_TYPES } from './STREAMING_CONTENT_TYPES.ts'
6
6
  import { sseErrorFrame } from './sseErrorFrame.ts'
@@ -24,11 +24,12 @@ identically. Three shapes are handled:
24
24
  handler, not just the streaming ones.
25
25
 
26
26
  Non-2xx responses surface as a thrown HttpError on the first pull,
27
- mirroring the plain `fn(args)` decode path.
27
+ mirroring the plain `fn(args)` decode path — typed-error body included
28
+ (`.kind` / `.data`), parsed by httpErrorFor when the generator is pulled.
28
29
  */
29
30
  export function streamResponse<T>(response: Response): AsyncIterable<T> {
30
31
  if (!response.ok) {
31
- return errorIterable<T>(new HttpError(response))
32
+ return errorIterable<T>(response)
32
33
  }
33
34
  const contentType = contentTypeOf(response.headers)
34
35
  if (contentType.startsWith('text/event-stream')) {
@@ -40,10 +41,12 @@ export function streamResponse<T>(response: Response): AsyncIterable<T> {
40
41
  return oneShot<T>(response)
41
42
  }
42
43
 
43
- /* Surfaces a non-2xx response (or any pre-stream failure) as a thrown error on the first pull. */
44
+ /* Surfaces a non-2xx response as a thrown HttpError on the first pull — awaiting the
45
+ typed-error body parse here (not in the sync streamResponse body) is what lets the
46
+ stream path carry `.kind` / `.data` like the plain decode path. */
44
47
  // biome-ignore lint/correctness/useYield: throws on first pull; the generator shape is intentional so callers iterate it uniformly
45
- async function* errorIterable<T>(error: Error): AsyncGenerator<T> {
46
- throw error
48
+ async function* errorIterable<T>(response: Response): AsyncGenerator<T> {
49
+ throw await httpErrorFor(response)
47
50
  }
48
51
 
49
52
  /*
@@ -3,9 +3,9 @@ import { escapeRegex } from './escapeRegex.ts'
3
3
  /*
4
4
  Strips the user's `import { … } from '<moduleName>'` declaration from a
5
5
  module source. Used by the $rpc / $sockets rewriters to remove the
6
- verb / `socket` import after its call site has been replaced by the
7
- runtime-injected binding (defineVerb / defineSocket). Without this
8
- strip the dead import would still side-effect-load the verb/socket
6
+ rpc / `socket` import after its call site has been replaced by the
7
+ runtime-injected binding (defineRpc / defineSocket). Without this
8
+ strip the dead import would still side-effect-load the rpc/socket
9
9
  helper module into the server bundle for every $rpc / $sockets file.
10
10
 
11
11
  The braced body is `[^}]*` rather than `[\s\S]*?` so the lazy match
@@ -16,10 +16,8 @@ settles).
16
16
  `value` is the decoded warm value served synchronously by the read path
17
17
  (cloned per read). It is set at hydration — the SSR snapshot body is
18
18
  pre-decoded so the first client render reads it without a microtask hop and
19
- byte-matches the SSR DOM and also by cache.on's `context.patch`, which folds
20
- an authoritative frame delta straight onto it without a refetch (ADR-0007).
21
- Live fetches leave it undefined and take the async decode path until a patch
22
- populates it.
19
+ byte-matches the SSR DOM. Live fetches leave it undefined and take the async
20
+ decode path.
23
21
 
24
22
  `hydrated` marks an entry built from the SSR snapshot, which ships no wrap
25
23
  options — the first read consumes the flag and adopts its call site's `ttl`
@@ -6,20 +6,10 @@ inside a binding. `invalidate` is the binding-scoped copy of cache.invalidate
6
6
  (same selector grammar, same effect): calls through it are recorded in the
7
7
  binding's coverage set by function identity, so attribution survives awaits
8
8
  and the coverage replays after a reconnect gap; the global cache.invalidate
9
- still works inside a handler but is not covered. `patch` folds an
10
- authoritative delta carried by the frame into the matching cached entries
11
- without a refetch (ADR-0007) — `updater` maps the current decoded value to the
12
- next; it resolves to the touched keys and is covered for reconnect resync the
13
- same way (the gap re-invalidates, since a delta can't be replayed). Await it to
14
- keep deltas ordered under sequential delivery. `signal` aborts when the binding
9
+ still works inside a handler but is not covered. `signal` aborts when the binding
15
10
  is disposed, so an async handler mid-flight can bail or cancel its own fetches.
16
11
  */
17
12
  export type CacheOnContext = {
18
13
  invalidate: <Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args) => void
19
- patch: <Args, Return>(
20
- arg: CacheSelector<Args, Return>,
21
- updater: (current: Return) => Return,
22
- args?: Args,
23
- ) => Promise<string[]>
24
14
  signal: AbortSignal
25
15
  }
@@ -22,16 +22,22 @@ export type CacheStore = {
22
22
  entries: Map<string, CacheEntry>
23
23
  events: EventTarget
24
24
  subscribe: (key: string) => void
25
+ /* True while a reactive scope is reading `key` (a value held on screen).
26
+ invalidate() gates its reload marker on this — see pendingRefresh. */
27
+ hasReader: (key: string) => boolean
25
28
  trackLifecycle: (keyPrefix?: string) => void
26
29
  markLifecycle: (key?: string) => void
27
30
  /*
28
- Keys dropped by a (policy-less) invalidate, awaiting their next read. The
29
- drop erases the entry, so the next cache() call is a cold miss with no memory
30
- it followed an invalidate; this set carries that signal across the gap so the
31
- replacement entry is flagged a reload (refreshing() true) rather than a
32
- first-ever load. Consumed when that entry is created; a key invalidated but
33
- never re-read just lingers (bounded by distinct such keys; the server's
34
- request-scoped store discards it with the response).
31
+ Keys dropped by a (policy-less) invalidate while a reader was holding the
32
+ value, awaiting their next read. The drop erases the entry, so the next
33
+ cache() call is a cold miss with no memory it followed an invalidate; this set
34
+ carries that signal across the gap so the replacement entry is flagged a reload
35
+ (refreshing() true) rather than a first-ever load. Consumed when that entry is
36
+ created. Bounded on the long-lived tab store from both ends: a marker is only
37
+ added when `hasReader(key)` (no on-screen value → the next read is a first
38
+ load, not a reload, so no marker), and a marker whose reader tears down before
39
+ re-reading is pruned by the subscriber's cleanup. The server's request-scoped
40
+ store discards the whole set with the response.
35
41
  */
36
42
  pendingRefresh: Set<string>
37
43
  /* Read tallies for the closing log record + Server-Timing; see CacheStats. */
@@ -1,5 +1,5 @@
1
1
  /*
2
- Which client surfaces a verb or socket is exposed to. Browser is the
2
+ Which client surfaces a rpc or socket is exposed to. Browser is the
3
3
  historical default; MCP and CLI flip on automatically when the
4
4
  declaration carries a Standard Schema (the schema is what makes the
5
5
  non-browser surfaces safe to advertise). Explicit values always win.
@@ -0,0 +1,17 @@
1
+ import type { ErrorDescriptor } from './ErrorDescriptor.ts'
2
+ import type { ErrorSpec } from './ErrorSpec.ts'
3
+ import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
4
+
5
+ /*
6
+ The constructors handed to the handler via its second arg (`(args, { errors })`),
7
+ derived from the rpc's `ErrorSpec`. An entry with a `data` schema makes its
8
+ constructor require that schema's inferred input; an entry without one is nullary.
9
+ Each returns a typed `ErrorDescriptor` to pass to `error()`.
10
+ */
11
+ export type ErrorConstructors<Spec extends ErrorSpec> = {
12
+ [Name in keyof Spec & string]: Spec[Name]['data'] extends StandardSchemaV1
13
+ ? (
14
+ data: StandardSchemaV1.InferInput<Spec[Name]['data']>,
15
+ ) => ErrorDescriptor<Name, StandardSchemaV1.InferInput<Spec[Name]['data']>>
16
+ : () => ErrorDescriptor<Name, undefined>
17
+ }
@@ -0,0 +1,10 @@
1
+ /*
2
+ What a rpc error constructor returns: a plain descriptor (NOT a Response), so it
3
+ flows through the single `error()` funnel — `return error(errors.invalidCoupon({…}))`.
4
+ `error()` reads `status` off it and serializes `{ $abideError: name, data }` as the body.
5
+ */
6
+ export type ErrorDescriptor<Name extends string = string, Data = unknown> = {
7
+ readonly $abideError: Name
8
+ readonly status: number
9
+ readonly data: Data
10
+ }