@abide/abide 0.44.0 → 0.45.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 (119) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +171 -181
  4. package/package.json +2 -1
  5. package/src/abideResolverPlugin.ts +6 -1
  6. package/src/devEntry.ts +43 -9
  7. package/src/lib/cli/parseArgvForRpc.ts +21 -24
  8. package/src/lib/cli/runCli.ts +9 -24
  9. package/src/lib/cli/tokenizeArgvFlags.ts +84 -0
  10. package/src/lib/server/error.ts +48 -53
  11. package/src/lib/server/json.ts +4 -3
  12. package/src/lib/server/jsonl.ts +1 -1
  13. package/src/lib/server/rpc/defineRpc.ts +11 -9
  14. package/src/lib/server/rpc/types/RemoteHandler.ts +5 -7
  15. package/src/lib/server/rpc/types/RpcHelper.ts +125 -35
  16. package/src/lib/server/rpc/types/TypedError.ts +18 -0
  17. package/src/lib/server/rpc/validationError.ts +3 -3
  18. package/src/lib/server/runtime/STATUS_TEXT.ts +22 -0
  19. package/src/lib/server/runtime/createServer.ts +36 -12
  20. package/src/lib/server/runtime/devHotModuleResponse.ts +1 -1
  21. package/src/lib/server/runtime/gzipResponse.ts +14 -16
  22. package/src/lib/server/runtime/installAmbientScopeStore.ts +33 -0
  23. package/src/lib/server/runtime/runWithRequestScope.ts +4 -1
  24. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -0
  25. package/src/lib/server/runtime/typedErrorResponse.ts +26 -0
  26. package/src/lib/server/runtime/types/RequestStore.ts +25 -0
  27. package/src/lib/server/runtime/withResponseDefaults.ts +8 -3
  28. package/src/lib/server/sockets/defineSocket.ts +5 -1
  29. package/src/lib/server/sse.ts +1 -1
  30. package/src/lib/shared/HttpError.ts +1 -1
  31. package/src/lib/shared/RPC_ARGS_TYPE.ts +7 -0
  32. package/src/lib/shared/activeCacheStore.ts +5 -14
  33. package/src/lib/shared/activePage.ts +5 -19
  34. package/src/lib/shared/augmentModule.ts +18 -0
  35. package/src/lib/shared/basePath.ts +6 -5
  36. package/src/lib/shared/baseResolver.ts +10 -0
  37. package/src/lib/shared/baseSlot.ts +6 -12
  38. package/src/lib/shared/cache.ts +46 -4
  39. package/src/lib/shared/cacheEntryFromSnapshot.ts +21 -1
  40. package/src/lib/shared/cacheKeyOf.ts +7 -0
  41. package/src/lib/shared/cacheKeyStore.ts +8 -0
  42. package/src/lib/shared/cacheStoreResolver.ts +12 -0
  43. package/src/lib/shared/cacheStoreSlot.ts +5 -13
  44. package/src/lib/shared/changeAffectsClient.ts +35 -0
  45. package/src/lib/shared/createResolverSlot.ts +37 -0
  46. package/src/lib/shared/debugGate.ts +90 -0
  47. package/src/lib/shared/emitLogRecord.ts +18 -4
  48. package/src/lib/shared/globalCacheStoreResolver.ts +12 -0
  49. package/src/lib/shared/globalCacheStoreSlot.ts +6 -11
  50. package/src/lib/shared/httpErrorFor.ts +1 -1
  51. package/src/lib/shared/isDebugEnabled.ts +3 -10
  52. package/src/lib/shared/isDebugNegated.ts +4 -6
  53. package/src/lib/shared/pageResolver.ts +16 -0
  54. package/src/lib/shared/pageSlot.ts +5 -14
  55. package/src/lib/shared/recordCacheKey.ts +6 -0
  56. package/src/lib/shared/requestScopeResolver.ts +12 -0
  57. package/src/lib/shared/requestScopeSlot.ts +7 -12
  58. package/src/lib/shared/responseBodyKind.ts +35 -0
  59. package/src/lib/shared/setBaseResolver.ts +2 -4
  60. package/src/lib/shared/setCacheStoreResolver.ts +3 -5
  61. package/src/lib/shared/setGlobalCacheStoreResolver.ts +3 -5
  62. package/src/lib/shared/setPageResolver.ts +2 -5
  63. package/src/lib/shared/setRequestScopeResolver.ts +3 -5
  64. package/src/lib/shared/toTagSet.ts +3 -3
  65. package/src/lib/shared/types/CacheEntry.ts +15 -0
  66. package/src/lib/shared/types/CacheOptions.ts +5 -5
  67. package/src/lib/shared/types/CacheSnapshotEntry.ts +5 -0
  68. package/src/lib/shared/types/DebugGate.ts +10 -0
  69. package/src/lib/shared/types/ErrorSpec.ts +6 -6
  70. package/src/lib/shared/types/RemoteFunction.ts +8 -4
  71. package/src/lib/shared/types/ResolverSlot.ts +10 -0
  72. package/src/lib/shared/writeDts.ts +8 -1
  73. package/src/lib/shared/writePublicAssetsDts.ts +5 -9
  74. package/src/lib/shared/writeRoutesDts.ts +12 -16
  75. package/src/lib/shared/writeRpcDts.ts +13 -12
  76. package/src/lib/shared/writeTestRpcDts.ts +11 -11
  77. package/src/lib/shared/writeTestSocketsDts.ts +9 -9
  78. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  79. package/src/lib/ui/compile/abideUiPlugin.ts +9 -7
  80. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +24 -12
  81. package/src/lib/ui/compile/compileModule.ts +37 -16
  82. package/src/lib/ui/compile/compileShadow.ts +75 -1
  83. package/src/lib/ui/compile/createShadowLanguageService.ts +29 -3
  84. package/src/lib/ui/compile/generateBuild.ts +7 -1
  85. package/src/lib/ui/compile/generateSSR.ts +10 -3
  86. package/src/lib/ui/compile/parseTemplate.ts +4 -1
  87. package/src/lib/ui/compile/types/TemplateNode.ts +1 -1
  88. package/src/lib/ui/createScope.ts +11 -4
  89. package/src/lib/ui/deferResume.ts +29 -0
  90. package/src/lib/ui/dom/appendText.ts +19 -4
  91. package/src/lib/ui/dom/awaitBlock.ts +74 -26
  92. package/src/lib/ui/dom/eachAsync.ts +11 -18
  93. package/src/lib/ui/dom/mountSwappableRange.ts +79 -0
  94. package/src/lib/ui/dom/skeleton.ts +10 -1
  95. package/src/lib/ui/dom/switchBlock.ts +15 -63
  96. package/src/lib/ui/dom/when.ts +11 -64
  97. package/src/lib/ui/installHotBridge.ts +2 -0
  98. package/src/lib/ui/matchRoute.ts +18 -1
  99. package/src/lib/ui/remoteProxy.ts +11 -1
  100. package/src/lib/ui/renderToStream.ts +13 -8
  101. package/src/lib/ui/resumeSeedScript.ts +2 -2
  102. package/src/lib/ui/runtime/CURRENT_SCOPE.ts +14 -1
  103. package/src/lib/ui/runtime/RESUME.ts +6 -0
  104. package/src/lib/ui/runtime/ambientScopeBacking.ts +26 -0
  105. package/src/lib/ui/runtime/createDoc.ts +102 -12
  106. package/src/lib/ui/runtime/createEffectNode.ts +7 -1
  107. package/src/lib/ui/runtime/flushEffects.ts +18 -0
  108. package/src/lib/ui/runtime/generationGuard.ts +40 -0
  109. package/src/lib/ui/runtime/runNode.ts +9 -1
  110. package/src/lib/ui/runtime/types/SsrRender.ts +4 -3
  111. package/src/lib/ui/scope.ts +6 -8
  112. package/src/lib/ui/tryEncodeResume.ts +5 -2
  113. package/src/lib/ui/types/Scope.ts +3 -4
  114. package/src/zodCjsPlugin.ts +16 -1
  115. package/src/lib/server/rpc/buildErrorConstructors.ts +0 -19
  116. package/src/lib/shared/matchesDebugPattern.ts +0 -16
  117. package/src/lib/shared/parseDebugPatterns.ts +0 -21
  118. package/src/lib/shared/types/ErrorConstructors.ts +0 -17
  119. package/src/lib/shared/types/ErrorDescriptor.ts +0 -10
package/src/devEntry.ts CHANGED
@@ -6,6 +6,7 @@ import { DEV_READY_MESSAGE } from './lib/server/runtime/DEV_READY_MESSAGE.ts'
6
6
  import { DEV_REBUILD_MESSAGE } from './lib/server/runtime/DEV_REBUILD_MESSAGE.ts'
7
7
  import { findOpenPort } from './lib/server/runtime/findOpenPort.ts'
8
8
  import { abideLog } from './lib/shared/abideLog.ts'
9
+ import { changeAffectsClient } from './lib/shared/changeAffectsClient.ts'
9
10
 
10
11
  /*
11
12
  Dev orchestrator. Replaces `bun --watch` (which only watches the import graph,
@@ -174,30 +175,51 @@ async function replaceServer(port: number): Promise<void> {
174
175
 
175
176
  let building = false
176
177
  let queued = false
178
+ // True once any change collapsed into the queued run needs the client rebuilt;
179
+ // a client-affecting change can never be downgraded to a server-only restart.
180
+ let queuedNeedsClient = false
177
181
 
178
182
  /*
179
- Rebuild the client, then (on success) swap in a fresh server child. Serialized:
180
- a change arriving mid-build sets `queued` so exactly one more rebuild runs
181
- after, collapsing any further changes in between. A failed build leaves the
182
- current child untouched the error is logged and the last-good server keeps
183
- serving.
183
+ Apply a change: rebuild the client (unless `skipClientBuild` a server/MCP-only
184
+ change leaves the client bundle byte-identical), then on success swap in a fresh
185
+ server child. The worker restart always runs: SSR renders through Bun's module
186
+ cache, so a new process is the only reliable way to reflect any source edit,
187
+ client-affecting or not.
188
+
189
+ Serialized: a change arriving mid-build sets `queued` so exactly one more run
190
+ follows, collapsing further changes in between; `queuedNeedsClient` records
191
+ whether any of them needs the client, so the collapsed run never skips a client
192
+ rebuild a queued change required. A failed build leaves the current child
193
+ untouched — the error is logged and the last-good server keeps serving.
184
194
  */
185
- async function rebuild(port: number): Promise<void> {
195
+ async function rebuild(port: number, skipClientBuild = false): Promise<void> {
186
196
  if (building) {
187
197
  queued = true
198
+ queuedNeedsClient ||= !skipClientBuild
188
199
  return
189
200
  }
190
201
  building = true
191
202
  try {
192
- const succeeded = await build(buildOptions)
203
+ const succeeded = skipClientBuild ? true : await build(buildOptions)
193
204
  if (succeeded) {
194
205
  await replaceServer(port)
195
206
  }
207
+ /* A server/MCP-only change reuses the existing client bundle. That's correct for
208
+ an rpc/socket handler BODY edit, but changing an rpc's method, its export name,
209
+ or `outbox: true` changes the client proxy stub too — the stale bundle then
210
+ calls with the old shape (e.g. a 405). Flag the skip so that case isn't silent. */
211
+ if (skipClientBuild) {
212
+ abideLog.info(
213
+ 'server-only change — kept the existing client bundle; if you changed an rpc method, export name, or outbox, save a client file (or restart dev) for a full rebuild',
214
+ )
215
+ }
196
216
  } finally {
197
217
  building = false
198
218
  if (queued) {
199
219
  queued = false
200
- void rebuild(port)
220
+ const needsClient = queuedNeedsClient
221
+ queuedNeedsClient = false
222
+ void rebuild(port, !needsClient)
201
223
  }
202
224
  }
203
225
  }
@@ -223,14 +245,26 @@ an agent editing the app's own source — isn't yanked mid-run by a save.
223
245
  const manualRebuild = Bun.env.ABIDE_DEV_NO_WATCH === '1'
224
246
 
225
247
  let debounce: ReturnType<typeof setTimeout> | undefined
248
+ /*
249
+ True only while every change collapsed into the pending debounce is server/MCP-
250
+ only; one client-affecting change in the burst flips it false (latches until the
251
+ debounce fires), so a multi-file save that touches the client never skips the
252
+ client rebuild.
253
+ */
254
+ let pendingSkipClientBuild = true
226
255
  const watcher = manualRebuild
227
256
  ? undefined
228
257
  : watch(SOURCE_DIR, { recursive: true }, (_event, filename) => {
229
258
  if (!filename || isGenerated(filename)) {
230
259
  return
231
260
  }
261
+ pendingSkipClientBuild &&= !changeAffectsClient(filename)
232
262
  clearTimeout(debounce)
233
- debounce = setTimeout(() => void rebuild(port), REBUILD_DEBOUNCE_MS)
263
+ debounce = setTimeout(() => {
264
+ const skipClientBuild = pendingSkipClientBuild
265
+ pendingSkipClientBuild = true
266
+ void rebuild(port, skipClientBuild)
267
+ }, REBUILD_DEBOUNCE_MS)
234
268
  })
235
269
  if (manualRebuild) {
236
270
  abideLog.info(
@@ -1,3 +1,5 @@
1
+ import { tokenizeArgvFlags } from './tokenizeArgvFlags.ts'
2
+
1
3
  /*
2
4
  Parses an argv tail into the JSON args bag for an RPC. The JSON Schema
3
5
  on the manifest entry (when present) drives flag typing:
@@ -47,44 +49,39 @@ export async function parseArgvForRpc(
47
49
  }
48
50
  }
49
51
 
50
- for (let index = 0; index < argv.length; index++) {
51
- const token = argv[index] as string
52
- if (token === '--json') {
53
- const next = argv[++index]
54
- if (!next) {
52
+ /* The shared tokenizer owns the flag-consumption grammar (boolean / inline /
53
+ `--json` / `--no-` negation). This loop layers RPC value semantics on each
54
+ yielded token: JSON-blob merge, Number coercion, array accumulation. */
55
+ for (const token of tokenizeArgvFlags(argv, jsonSchema)) {
56
+ if (token.positional !== undefined) {
57
+ throw new Error(`unexpected positional argument: ${token.positional}`)
58
+ }
59
+ if (token.isJson) {
60
+ if (token.missingValue || token.value === undefined) {
55
61
  throw new Error('--json requires a value')
56
62
  }
57
- const parsed = JSON.parse(next)
63
+ const parsed = JSON.parse(token.value)
58
64
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
59
65
  throw new Error('--json value must be a JSON object')
60
66
  }
61
67
  Object.assign(args, parsed)
62
68
  continue
63
69
  }
64
- if (!token.startsWith('--')) {
65
- throw new Error(`unexpected positional argument: ${token}`)
70
+ // The tokenizer treats `--help` / `-h` as a help token; in RPC parsing
71
+ // they are an unrecognised flag, surfaced loudly like any other.
72
+ if (token.isHelp || token.name === undefined) {
73
+ throw new Error('unexpected --help flag')
66
74
  }
67
- const stripped = token.slice('--'.length)
68
- const [literalName, eqValue] = stripped.includes('=')
69
- ? [stripped.slice(0, stripped.indexOf('=')), stripped.slice(stripped.indexOf('=') + 1)]
70
- : [stripped, undefined]
71
- /* `--no-x` negates only a known boolean property x; otherwise the literal
72
- name wins, so a property legitimately named `no-…` stays reachable. */
73
- const negatedName = literalName.startsWith('no-')
74
- ? literalName.slice('no-'.length)
75
- : undefined
76
- const isNegated = negatedName !== undefined && properties[negatedName]?.type === 'boolean'
77
- const name = isNegated ? (negatedName as string) : literalName
78
- const prop = properties[name]
79
- const propType = prop?.type
75
+ const name = token.name
76
+ const propType = properties[name]?.type
80
77
  if (propType === 'boolean') {
81
- args[name] = !isNegated
78
+ args[name] = !token.negated
82
79
  continue
83
80
  }
84
- const value = eqValue ?? argv[++index]
85
- if (value === undefined) {
81
+ if (token.missingValue || token.value === undefined) {
86
82
  throw new Error(`--${name} requires a value`)
87
83
  }
84
+ const value = token.value
88
85
  if (propType === 'number' || propType === 'integer') {
89
86
  // Reject a blank value explicitly — `Number('')` / `Number(' ')` is 0,
90
87
  // not NaN, so the NaN guard alone would silently coerce it to zero.
@@ -9,6 +9,7 @@ import { printTrimmed } from './printTrimmed.ts'
9
9
  import { resolveCliTarget } from './resolveCliTarget.ts'
10
10
  import { runSession } from './runSession.ts'
11
11
  import { startLocalInstance } from './startLocalInstance.ts'
12
+ import { tokenizeArgvFlags } from './tokenizeArgvFlags.ts'
12
13
  import type { CliManifest } from './types/CliManifest.ts'
13
14
  import type { CliTarget } from './types/CliTarget.ts'
14
15
 
@@ -16,37 +17,21 @@ const isHelpFlag = (arg: string): boolean => arg === '--help' || arg === '-h'
16
17
 
17
18
  /*
18
19
  Whether a command's argv tail genuinely requests help, i.e. a help flag appears
19
- at a flag position rather than as the value of a value-expecting flag. Mirrors
20
- parseArgvForRpc's flag rule: boolean props (and `--name=…`) consume no following
21
- token, everything else consumes the next token as its value so `--title --help`
22
- passes `--help` as the title value, not a help request.
20
+ at a flag position rather than as the value of a value-expecting flag. Shares
21
+ tokenizeArgvFlags' grammar with parseArgvForRpc so the two can't drift: the
22
+ tokenizer applies the flag-consumption rule (boolean / `--name=…` / `--json`
23
+ consume their value), so a `--help` that was a flag's value is already swallowed
24
+ and never surfaces as a help token — `--title --help` passes `--help` as the
25
+ title value, not a help request.
23
26
  */
24
27
  function commandArgvRequestsHelp(
25
28
  argvTail: string[],
26
29
  jsonSchema: Record<string, unknown> | undefined,
27
30
  ): boolean {
28
- const properties =
29
- (jsonSchema?.properties as Record<string, { type?: string }> | undefined) ?? {}
30
- for (let index = 0; index < argvTail.length; index += 1) {
31
- const token = argvTail[index] as string
32
- if (isHelpFlag(token)) {
31
+ for (const token of tokenizeArgvFlags(argvTail, jsonSchema)) {
32
+ if (token.isHelp) {
33
33
  return true
34
34
  }
35
- if (!token.startsWith('--')) {
36
- continue
37
- }
38
- if (token === '--json') {
39
- index += 1 // consumes its value
40
- continue
41
- }
42
- const rawName = token.slice('--'.length)
43
- if (rawName.includes('=')) {
44
- continue // inline value, consumes no following token
45
- }
46
- const name = rawName.startsWith('no-') ? rawName.slice('no-'.length) : rawName
47
- if (properties[name]?.type !== 'boolean') {
48
- index += 1 // a value-expecting flag consumes the next token
49
- }
50
35
  }
51
36
  return false
52
37
  }
@@ -0,0 +1,84 @@
1
+ /*
2
+ The single argv-tail flag grammar shared by help-detection (runCli) and RPC arg
3
+ parsing (parseArgvForRpc) so the two can never drift. Walks the tail and yields
4
+ one token per flag occurrence, honouring the schema-driven consumption rule:
5
+
6
+ - `--help` / `-h` → { isHelp: true }, consumes no following token
7
+ - `--json <blob>` → { isJson: true, value }, consumes the next token
8
+ (the value is the verbatim JSON args bag)
9
+ - boolean props (and `--no-x`) → { name, value: undefined }, consumes no token
10
+ - `--name=value` → { name, value }, inline, consumes no token
11
+ - everything else → { name, value }, consumes the next token
12
+
13
+ `--no-x` negates only a known boolean property x; otherwise the literal `no-…`
14
+ name is kept so a property legitimately named `no-…` stays reachable. Reports
15
+ `negated` and `missingValue` so the parser can apply boolean/coercion semantics
16
+ and throw precise errors. A bare positional (no leading `--`) yields
17
+ { positional: token } so each consumer rejects or ignores it as it sees fit.
18
+
19
+ Pure; reads the schema's `properties` only to classify boolean props.
20
+ */
21
+ export function* tokenizeArgvFlags(
22
+ argvTail: string[],
23
+ jsonSchema: Record<string, unknown> | undefined,
24
+ ): Generator<{
25
+ isHelp?: boolean
26
+ isJson?: boolean
27
+ positional?: string
28
+ name?: string
29
+ value?: string
30
+ negated?: boolean
31
+ missingValue?: boolean
32
+ }> {
33
+ const properties =
34
+ (jsonSchema?.properties as Record<string, { type?: string }> | undefined) ?? {}
35
+ for (let index = 0; index < argvTail.length; index += 1) {
36
+ const token = argvTail[index] as string
37
+ if (token === '--help' || token === '-h') {
38
+ yield { isHelp: true }
39
+ continue
40
+ }
41
+ if (!token.startsWith('--')) {
42
+ yield { positional: token }
43
+ continue
44
+ }
45
+ // `--json <blob>`: the next token is the verbatim JSON args bag.
46
+ if (token === '--json') {
47
+ const value = argvTail[index + 1]
48
+ if (value === undefined) {
49
+ yield { isJson: true, missingValue: true }
50
+ } else {
51
+ index += 1
52
+ yield { isJson: true, value }
53
+ }
54
+ continue
55
+ }
56
+ const stripped = token.slice('--'.length)
57
+ const [literalName, inlineValue] = stripped.includes('=')
58
+ ? [stripped.slice(0, stripped.indexOf('=')), stripped.slice(stripped.indexOf('=') + 1)]
59
+ : [stripped, undefined]
60
+ /* `--no-x` negates only a known boolean property x; otherwise the literal
61
+ name wins, so a property legitimately named `no-…` stays reachable. */
62
+ const negatedName = literalName.startsWith('no-')
63
+ ? literalName.slice('no-'.length)
64
+ : undefined
65
+ const negated = negatedName !== undefined && properties[negatedName]?.type === 'boolean'
66
+ const name = negated ? (negatedName as string) : literalName
67
+ // Boolean props and inline `--name=value` consume no following token.
68
+ if (properties[name]?.type === 'boolean') {
69
+ yield { name, negated }
70
+ continue
71
+ }
72
+ if (inlineValue !== undefined) {
73
+ yield { name, value: inlineValue }
74
+ continue
75
+ }
76
+ const value = argvTail[index + 1]
77
+ if (value === undefined) {
78
+ yield { name, missingValue: true }
79
+ } else {
80
+ index += 1
81
+ yield { name, value }
82
+ }
83
+ }
84
+ }
@@ -1,7 +1,10 @@
1
1
  import { NO_STORE } from '../shared/CACHE_CONTROL_VALUES.ts'
2
2
  import { TEXT_PLAIN } from '../shared/TEXT_PLAIN.ts'
3
- import type { ErrorDescriptor } from '../shared/types/ErrorDescriptor.ts'
3
+ import type { StandardSchemaV1 } from '../shared/types/StandardSchemaV1.ts'
4
+ import type { TypedError } from './rpc/types/TypedError.ts'
4
5
  import type { TypedResponse } from './rpc/types/TypedResponse.ts'
6
+ import { STATUS_TEXT } from './runtime/STATUS_TEXT.ts'
7
+ import { typedErrorResponse } from './runtime/typedErrorResponse.ts'
5
8
  import { withResponseDefaults } from './runtime/withResponseDefaults.ts'
6
9
 
7
10
  /*
@@ -18,37 +21,15 @@ text/plain so intermediaries don't try to render or sniff it. A final
18
21
  `ResponseInit` adds headers (e.g. `Retry-After` on a 429); the positional
19
22
  `status` always wins over any `init.status`.
20
23
 
21
- To short-circuit a handler instead of returning, `throw new Error(...)`
22
- or `throw new HttpError(error(...))` the framework's `app.handleError`
23
- hook catches thrown errors. This helper deliberately returns a Response
24
- rather than throwing one so a single `return error(...)` is the
25
- expected pattern, with the same control flow as `return json(...)`.
24
+ For a NAMED, typed error the client can branch on, declare a constructor with
25
+ `error.typed(name, status, schema?)` and return it (see below). To short-circuit
26
+ a handler instead of returning, `throw new Error(...)` or
27
+ `throw new HttpError(error(...))` the framework's `app.handleError` hook
28
+ catches thrown errors. This helper deliberately returns a Response rather than
29
+ throwing one so a single `return error(...)` is the expected pattern, with the
30
+ same control flow as `return json(...)`.
26
31
  */
27
32
 
28
- /*
29
- Standard reason phrases for the statuses error() is realistically called
30
- with. Maintained explicitly because Bun's `Response` does not populate
31
- `statusText` from the status code, so there's no platform table to read.
32
- Unlisted codes fall back to `HTTP <status>`.
33
- */
34
- const STATUS_TEXT: Record<number, string> = {
35
- 400: 'Bad Request',
36
- 401: 'Unauthorized',
37
- 403: 'Forbidden',
38
- 404: 'Not Found',
39
- 405: 'Method Not Allowed',
40
- 409: 'Conflict',
41
- 410: 'Gone',
42
- 413: 'Content Too Large',
43
- 422: 'Unprocessable Content',
44
- 429: 'Too Many Requests',
45
- 500: 'Internal Server Error',
46
- 501: 'Not Implemented',
47
- 502: 'Bad Gateway',
48
- 503: 'Service Unavailable',
49
- 504: 'Gateway Timeout',
50
- }
51
-
52
33
  /*
53
34
  Body type is `never` because `error()` only travels the non-2xx path on
54
35
  the wire — the caller's `await fn(args)` throws `HttpError` and never
@@ -57,32 +38,11 @@ the union of branches in a handler narrow to whatever the success
57
38
  branch carries (`TypedResponse<{user}> | TypedResponse<never>` → Return
58
39
  = {user}).
59
40
  */
60
- // @documentation response
61
- export function error(descriptor: ErrorDescriptor): TypedResponse<never>
62
- export function error(status: number, message?: string, init?: ResponseInit): TypedResponse<never>
63
- export function error(
64
- statusOrDescriptor: number | ErrorDescriptor,
41
+ function errorResponse(
42
+ status: number,
65
43
  message?: string,
66
44
  init?: ResponseInit,
67
45
  ): TypedResponse<never> {
68
- /* A typed-error descriptor (`error(errors.invalidCoupon({…}))`) serializes as a
69
- JSON `{ $abideError, data }` body at the descriptor's status — the client
70
- parses it back onto the thrown HttpError's `.kind` / `.data`. */
71
- if (typeof statusOrDescriptor === 'object') {
72
- const descriptor = statusOrDescriptor
73
- return new Response(
74
- JSON.stringify({ $abideError: descriptor.$abideError, data: descriptor.data }),
75
- withStatusText(
76
- withResponseDefaults(
77
- undefined,
78
- { 'Content-Type': 'application/json', 'Cache-Control': NO_STORE },
79
- descriptor.status,
80
- ),
81
- descriptor.status,
82
- ),
83
- ) as TypedResponse<never>
84
- }
85
- const status = statusOrDescriptor
86
46
  const body = message ?? STATUS_TEXT[status] ?? `HTTP ${status}`
87
47
  return new Response(
88
48
  body,
@@ -113,3 +73,38 @@ function withStatusText(init: ResponseInit, status: number): ResponseInit {
113
73
  }
114
74
  return init
115
75
  }
76
+
77
+ /*
78
+ Declares a reusable, typed error as a single constructor. With a `data` schema
79
+ the constructor requires that input; without one it's nullary. Returning the
80
+ constructor from a handler IS the error — it serializes a `{ $abideError, data }`
81
+ body at `status`, and the rpc reads the constructor's branded return type to
82
+ expose the error on `rpc.isError(e, 'name')` (`.kind` and typed `.data`). No
83
+ `errors:` option, no set to register — compose by returning whichever you want:
84
+
85
+ const duplicateSlug = error.typed('duplicateSlug', 409, z.object({ slug: z.string() }))
86
+ const rateLimited = error.typed('rateLimited', 429) // nullary
87
+
88
+ export const createPost = POST(({ slug }) =>
89
+ taken(slug) ? duplicateSlug({ slug }) : json(save(slug)),
90
+ )
91
+
92
+ `name` is the wire identity and the `isError` key, so it's an explicit string
93
+ (a const can't read its own variable name); `schema` types `.data` and is never
94
+ validated at runtime here.
95
+ */
96
+ function typed<Name extends string, Schema extends StandardSchemaV1>(
97
+ name: Name,
98
+ status: number,
99
+ schema: Schema,
100
+ ): (data: StandardSchemaV1.InferInput<Schema>) => TypedError<Name, { status: number; data: Schema }>
101
+ function typed<Name extends string>(
102
+ name: Name,
103
+ status: number,
104
+ ): () => TypedError<Name, { status: number; data?: undefined }>
105
+ function typed(name: string, status: number, _schema?: StandardSchemaV1) {
106
+ return (data?: unknown) => typedErrorResponse(name, status, data)
107
+ }
108
+
109
+ // @documentation response
110
+ export const error = Object.assign(errorResponse, { typed })
@@ -9,13 +9,14 @@ unless the caller overrides it. Intermediary caches (browsers, CDNs,
9
9
  shared proxies) shouldn't cache rpc replies by default; the framework's
10
10
  own per-request cache handles in-process dedupe.
11
11
 
12
- export const getOrder = GET<{ id: string }>(async ({ id }) =>
12
+ export const getOrder = GET(async ({ id }: { id: string }) =>
13
13
  json(await db.getOrder(id)),
14
14
  )
15
15
 
16
16
  The return type carries `T` as a phantom brand so the rpc helper can
17
- infer the caller-facing `Return` from the handler body — no need to
18
- annotate `GET<Args, Return>` just to type the response shape.
17
+ infer the caller-facing `Return` from the handler body — you type the
18
+ handler's parameter and let the body infer; there are no `<Args, Return>`
19
+ call generics (a stray one is a compile error).
19
20
 
20
21
  For non-default cache policy pass `init.headers`; explicit
21
22
  `cache-control` wins over the default.
@@ -4,7 +4,7 @@ Wraps an AsyncIterable<Frame> in a Response whose body is JSON Lines
4
4
  inside an rpc handler to turn a generator into a streaming HTTP response
5
5
  that `tail(fn.stream(args))` consumes frame-by-frame on the client.
6
6
 
7
- export const orderFeed = GET<Args>((args) =>
7
+ export const orderFeed = GET((args: Args) =>
8
8
  jsonl(async function* () {
9
9
  for await (const order of db.watchOrders(args)) yield order
10
10
  }())
@@ -5,12 +5,10 @@ import { forwardHeaders } from '../../shared/forwardHeaders.ts'
5
5
  import { isReadOnlyMethod } from '../../shared/isReadOnlyMethod.ts'
6
6
  import { resolveClientFlags } from '../../shared/resolveClientFlags.ts'
7
7
  import type { ClientFlags } from '../../shared/types/ClientFlags.ts'
8
- import type { ErrorSpec } from '../../shared/types/ErrorSpec.ts'
9
8
  import type { HttpMethod } from '../../shared/types/HttpMethod.ts'
10
9
  import type { RemoteFunction } from '../../shared/types/RemoteFunction.ts'
11
10
  import type { StandardSchemaV1 } from '../../shared/types/StandardSchemaV1.ts'
12
11
  import { requestContext } from '../runtime/requestContext.ts'
13
- import { buildErrorConstructors } from './buildErrorConstructors.ts'
14
12
  import { parseArgs } from './parseArgs.ts'
15
13
  import { registerRpc } from './registerRpc.ts'
16
14
  import { runWithRpcTimeout } from './runWithRpcTimeout.ts'
@@ -55,7 +53,6 @@ export function defineRpc<Args, Return>(
55
53
  inputSchema?: StandardSchemaV1
56
54
  outputSchema?: StandardSchemaV1
57
55
  filesSchema?: StandardSchemaV1
58
- errors?: ErrorSpec
59
56
  clients?: Partial<ClientFlags>
60
57
  crossOrigin?: boolean
61
58
  /* Per-rpc cap on actual received body bytes (413 past it); omitted = Bun's server-wide maxRequestBodySize. */
@@ -81,8 +78,6 @@ export function defineRpc<Args, Return>(
81
78
  const inputSchema = opts?.inputSchema
82
79
  const outputSchema = opts?.outputSchema
83
80
  const filesSchema = opts?.filesSchema
84
- /* The declared error constructors handed to the handler as its `{ errors }` ctx. */
85
- const errors = buildErrorConstructors(opts?.errors ?? {})
86
81
  /*
87
82
  An input schema makes the handler safe to advertise to non-browser
88
83
  surfaces. CLI flips on for any rpc with one (a human/script invokes it
@@ -100,7 +95,15 @@ export function defineRpc<Args, Return>(
100
95
  function buildRequest(args: Args | undefined): Request {
101
96
  const store = requestContext.getStore()
102
97
  const baseUrl = store ? store.url.href : 'http://localhost/'
103
- const headers = store ? forwardHeaders(store.req.headers) : new Headers()
98
+ /* The forwarded allowlist is request-invariant derive it once per scope
99
+ and clone per call (buildRpcRequest mutates the Headers). */
100
+ let headers: Headers
101
+ if (store) {
102
+ store.forwardedHeaders ??= forwardHeaders(store.req.headers)
103
+ headers = new Headers(store.forwardedHeaders)
104
+ } else {
105
+ headers = new Headers()
106
+ }
104
107
  return buildRpcRequest({ method, url, args, baseUrl, headers })
105
108
  }
106
109
 
@@ -115,7 +118,7 @@ export function defineRpc<Args, Return>(
115
118
  async function runHandler(args: Args | undefined): Promise<Response> {
116
119
  return rpcLog.trace(
117
120
  `rpc ${method} ${url}`,
118
- () => handler(args as Args, { errors }) as unknown as Response,
121
+ () => handler(args as Args) as unknown as Response,
119
122
  )
120
123
  }
121
124
 
@@ -158,8 +161,7 @@ export function defineRpc<Args, Return>(
158
161
  /* Abort the controller parseArgsForFetch stashed on store.req; a no-op when none was stashed (SSR cache reads). */
159
162
  function abortRpcTimeout(): void {
160
163
  const req = requestContext.getStore()?.req as
161
- | (Request & { [RPC_TIMEOUT_ABORT]?: AbortController })
162
- | undefined
164
+ (Request & { [RPC_TIMEOUT_ABORT]?: AbortController }) | undefined
163
165
  req?.[RPC_TIMEOUT_ABORT]?.abort(new DOMException('handler timeout', 'TimeoutError'))
164
166
  }
165
167
 
@@ -1,5 +1,3 @@
1
- import type { ErrorConstructors } from '../../../shared/types/ErrorConstructors.ts'
2
- import type { ErrorSpec } from '../../../shared/types/ErrorSpec.ts'
3
1
  import type { TypedResponse } from './TypedResponse.ts'
4
2
 
5
3
  /*
@@ -23,11 +21,11 @@ untagged Responses fall back to `Return = unknown`.
23
21
  Handlers that need the inbound Request (headers, `request.signal`, …) read
24
22
  it via `request()` from `abide/server` rather than a handler parameter, so
25
23
  the signature stays a single parsed-`args` bag.
24
+
25
+ A typed error is raised by returning an `error.typed(name, status, schema?)`
26
+ constructor (`return outOfStock({…})`) — there is no `ctx` param; the rpc reads
27
+ the error surface off the returned constructors' brands.
26
28
  */
27
- export type RemoteHandler<Args, Return, Errors extends ErrorSpec = Record<never, never>> = (
29
+ export type RemoteHandler<Args, Return> = (
28
30
  args: Args,
29
- /* The rpc's declared error constructors (`error(errors.invalidCoupon({…}))`), typed
30
- from its `errors` opt; an empty object when none declared. A handler that takes only
31
- `args` is still assignable here (fewer params is assignable to more). */
32
- ctx: { errors: ErrorConstructors<Errors> },
33
31
  ) => TypedResponse<Return> | Promise<TypedResponse<Return>>