@abide/abide 0.41.1 → 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 (167) hide show
  1. package/AGENTS.md +234 -294
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +51 -95
  4. package/package.json +3 -2
  5. package/src/abideLsp.ts +46 -8
  6. package/src/abideResolverPlugin.ts +107 -19
  7. package/src/buildCli.ts +1 -1
  8. package/src/discoveryEntry.ts +3 -3
  9. package/src/lib/bundle/BundleMenuItem.ts +1 -1
  10. package/src/lib/cli/createClient.ts +12 -12
  11. package/src/lib/cli/dispatchCommand.ts +1 -1
  12. package/src/lib/cli/printSessionStatus.ts +1 -1
  13. package/src/lib/cli/resolveCliTarget.ts +1 -1
  14. package/src/lib/cli/runCli.ts +1 -1
  15. package/src/lib/cli/types/CliManifest.ts +1 -1
  16. package/src/lib/cli/types/CliManifestEntry.ts +2 -2
  17. package/src/lib/mcp/annotationsForMethod.ts +5 -5
  18. package/src/lib/mcp/createMcpServer.ts +3 -3
  19. package/src/lib/mcp/mcpSurface.ts +14 -14
  20. package/src/lib/mcp/types/McpServerOptions.ts +1 -1
  21. package/src/lib/server/DELETE.ts +4 -4
  22. package/src/lib/server/GET.ts +4 -4
  23. package/src/lib/server/HEAD.ts +4 -4
  24. package/src/lib/server/PATCH.ts +4 -4
  25. package/src/lib/server/POST.ts +4 -4
  26. package/src/lib/server/PUT.ts +4 -4
  27. package/src/lib/server/agent.ts +4 -4
  28. package/src/lib/server/env.ts +1 -1
  29. package/src/lib/server/error.ts +49 -7
  30. package/src/lib/server/json.ts +1 -1
  31. package/src/lib/server/rpc/buildErrorConstructors.ts +19 -0
  32. package/src/lib/server/rpc/{defineVerb.ts → defineRpc.ts} +52 -34
  33. package/src/lib/server/rpc/{dispatchVerbInProcess.ts → dispatchRpcInProcess.ts} +3 -3
  34. package/src/lib/server/rpc/fieldErrorsFromIssues.ts +23 -0
  35. package/src/lib/server/rpc/{findVerbByCommandName.ts → findRpcByCommandName.ts} +5 -5
  36. package/src/lib/server/rpc/parseArgs.ts +6 -6
  37. package/src/lib/server/rpc/readBodyWithinLimit.ts +2 -2
  38. package/src/lib/server/rpc/registerRpc.ts +6 -0
  39. package/src/lib/server/rpc/{verbRegistry.ts → rpcRegistry.ts} +4 -4
  40. package/src/lib/server/rpc/{runWithVerbTimeout.ts → runWithRpcTimeout.ts} +4 -4
  41. package/src/lib/server/rpc/types/RemoteHandler.ts +9 -3
  42. package/src/lib/server/rpc/types/{VerbHelper.ts → RpcHelper.ts} +57 -23
  43. package/src/lib/server/rpc/types/{VerbRegistryEntry.ts → RpcRegistryEntry.ts} +3 -3
  44. package/src/lib/server/rpc/types/TypedResponse.ts +2 -2
  45. package/src/lib/server/rpc/unprocessed.ts +6 -7
  46. package/src/lib/server/rpc/validationError.ts +17 -0
  47. package/src/lib/server/runtime/buildInspectorSurface.ts +7 -7
  48. package/src/lib/server/runtime/buildOpenApiSpec.ts +6 -6
  49. package/src/lib/server/runtime/createRouteDispatcher.ts +7 -7
  50. package/src/lib/server/runtime/createServer.ts +4 -4
  51. package/src/lib/server/runtime/crossOriginForbidden.ts +1 -1
  52. package/src/lib/server/runtime/crossOriginGate.ts +4 -4
  53. package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
  54. package/src/lib/server/runtime/logExposedSurfaces.ts +4 -4
  55. package/src/lib/server/runtime/registryManifests.ts +2 -2
  56. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  57. package/src/lib/server/runtime/types/InspectorRpc.ts +27 -0
  58. package/src/lib/server/runtime/types/InspectorSurface.ts +4 -4
  59. package/src/lib/server/runtime/types/RequestStore.ts +1 -1
  60. package/src/lib/server/runtime/warnUnguardedMcp.ts +2 -2
  61. package/src/lib/server/sockets/createSocketDispatcher.ts +15 -14
  62. package/src/lib/server/sockets/types/SocketOperation.ts +2 -2
  63. package/src/lib/shared/HttpError.ts +15 -1
  64. package/src/lib/shared/UNREACHABLE_STATUSES.ts +13 -0
  65. package/src/lib/shared/buildRpcRequest.ts +5 -5
  66. package/src/lib/shared/cache.ts +50 -168
  67. package/src/lib/shared/carriesBodyArgs.ts +4 -4
  68. package/src/lib/shared/createCacheStore.ts +12 -0
  69. package/src/lib/shared/createRemoteFunction.ts +11 -6
  70. package/src/lib/shared/decodeResponse.ts +2 -2
  71. package/src/lib/shared/detectRpcMethod.ts +17 -0
  72. package/src/lib/shared/emitLogRecord.ts +3 -3
  73. package/src/lib/shared/escapeRegex.ts +9 -0
  74. package/src/lib/shared/extraForwardHeaders.ts +1 -1
  75. package/src/lib/shared/fileName.ts +9 -0
  76. package/src/lib/shared/fileStem.ts +3 -1
  77. package/src/lib/shared/findExportCallSite.ts +30 -273
  78. package/src/lib/shared/forwardHeaders.ts +2 -2
  79. package/src/lib/shared/httpErrorFor.ts +26 -0
  80. package/src/lib/shared/isAsciiWhitespace.ts +5 -0
  81. package/src/lib/shared/isIdentPart.ts +9 -0
  82. package/src/lib/shared/isIdentStart.ts +8 -0
  83. package/src/lib/shared/isReadOnlyMethod.ts +4 -4
  84. package/src/lib/shared/keyForRemoteCall.ts +4 -4
  85. package/src/lib/shared/log.ts +1 -1
  86. package/src/lib/shared/outboxProbeSlot.ts +20 -0
  87. package/src/lib/shared/pending.ts +4 -1
  88. package/src/lib/shared/prepareRpcModule.ts +96 -14
  89. package/src/lib/shared/probeRegistries.ts +17 -4
  90. package/src/lib/shared/programNameForPackage.ts +3 -1
  91. package/src/lib/shared/queryStringFromArgs.ts +1 -1
  92. package/src/lib/shared/remoteMetaStore.ts +2 -2
  93. package/src/lib/shared/resolveClientFlags.ts +1 -1
  94. package/src/lib/shared/skipNonCode.ts +230 -0
  95. package/src/lib/shared/streamResponse.ts +9 -6
  96. package/src/lib/shared/stripImport.ts +6 -4
  97. package/src/lib/shared/types/CacheEntry.ts +2 -4
  98. package/src/lib/shared/types/CacheOnContext.ts +1 -11
  99. package/src/lib/shared/types/CacheStore.ts +13 -7
  100. package/src/lib/shared/types/ClientFlags.ts +1 -1
  101. package/src/lib/shared/types/ErrorConstructors.ts +17 -0
  102. package/src/lib/shared/types/ErrorDescriptor.ts +10 -0
  103. package/src/lib/shared/types/ErrorSpec.ts +11 -0
  104. package/src/lib/shared/types/HttpMethod.ts +1 -0
  105. package/src/lib/shared/types/Outbox.ts +9 -0
  106. package/src/lib/shared/types/OutboxEntry.ts +27 -0
  107. package/src/lib/shared/types/RawRemoteFunction.ts +2 -2
  108. package/src/lib/shared/types/RemoteCallable.ts +2 -2
  109. package/src/lib/shared/types/RemoteFunction.ts +21 -6
  110. package/src/lib/shared/types/RequestScopeInfo.ts +1 -1
  111. package/src/lib/shared/types/RpcErrorGuard.ts +40 -0
  112. package/src/lib/shared/types/RpcInvoker.ts +1 -1
  113. package/src/lib/shared/types/StandardSchemaV1.ts +1 -1
  114. package/src/lib/shared/types/ValidationErrorData.ts +20 -0
  115. package/src/lib/shared/url.ts +3 -3
  116. package/src/lib/shared/writeRpcDts.ts +7 -7
  117. package/src/lib/shared/writeTestRpcDts.ts +3 -3
  118. package/src/lib/test/createTestApp.ts +10 -10
  119. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
  120. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  121. package/src/lib/ui/compile/awaitPlan.ts +48 -0
  122. package/src/lib/ui/compile/compileShadow.ts +117 -31
  123. package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
  124. package/src/lib/ui/compile/desugarSignals.ts +42 -6
  125. package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
  126. package/src/lib/ui/compile/generateBuild.ts +45 -62
  127. package/src/lib/ui/compile/generateSSR.ts +90 -60
  128. package/src/lib/ui/compile/ifPlan.ts +30 -0
  129. package/src/lib/ui/compile/makeVarNamer.ts +10 -0
  130. package/src/lib/ui/compile/offsetToPosition.ts +9 -0
  131. package/src/lib/ui/compile/parseTemplate.ts +565 -115
  132. package/src/lib/ui/compile/stripEffects.ts +17 -9
  133. package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
  134. package/src/lib/ui/compile/switchPlan.ts +22 -0
  135. package/src/lib/ui/compile/tryPlan.ts +29 -0
  136. package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
  137. package/src/lib/ui/compile/types/TemplateAttr.ts +8 -2
  138. package/src/lib/ui/compile/types/TemplateNode.ts +15 -2
  139. package/src/lib/ui/createScope.ts +20 -2
  140. package/src/lib/ui/dom/appendText.ts +1 -5
  141. package/src/lib/ui/dom/applyResolved.ts +1 -5
  142. package/src/lib/ui/dom/disposeRange.ts +6 -10
  143. package/src/lib/ui/dom/hydrate.ts +2 -5
  144. package/src/lib/ui/dom/isComment.ts +6 -0
  145. package/src/lib/ui/dom/mount.ts +1 -2
  146. package/src/lib/ui/dom/withScope.ts +6 -9
  147. package/src/lib/ui/effect.ts +4 -2
  148. package/src/lib/ui/navigate.ts +40 -13
  149. package/src/lib/ui/outbox.ts +30 -110
  150. package/src/lib/ui/remoteProxy.ts +160 -10
  151. package/src/lib/ui/router.ts +3 -3
  152. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +281 -0
  153. package/src/lib/ui/rpcOutbox/outboxRegistry.ts +69 -0
  154. package/src/lib/ui/runtime/createDoc.ts +3 -3
  155. package/src/lib/ui/runtime/pathSegments.ts +10 -0
  156. package/src/lib/ui/socketChannel.ts +7 -1
  157. package/src/lib/ui/types/Scope.ts +20 -5
  158. package/template/.zed/settings.json +4 -0
  159. package/template/CLAUDE.md +1 -1
  160. package/template/src/server/rpc/getHello.ts +3 -3
  161. package/template/src/ui/pages/page.abide +4 -4
  162. package/template/test/app.test.ts +2 -2
  163. package/src/lib/server/rpc/registerVerb.ts +0 -6
  164. package/src/lib/server/runtime/types/InspectorVerb.ts +0 -27
  165. package/src/lib/shared/detectVerbMethod.ts +0 -17
  166. package/src/lib/shared/types/HttpVerb.ts +0 -1
  167. package/src/lib/ui/types/Outbox.ts +0 -14
@@ -6,7 +6,7 @@ import { request } from './request.ts'
6
6
  The in-app agent surface. `agent(engine, messages)` runs a model engine
7
7
  against the app's own MCP surface and returns the engine's frame stream —
8
8
  it does NOT pick a transport. The handler wraps it in `jsonl()` or `sse()`,
9
- so consumption is the app's choice, same as any other streaming verb:
9
+ so consumption is the app's choice, same as any other streaming rpc:
10
10
 
11
11
  // src/server/rpc/chat.ts
12
12
  import { agent } from '@abide/abide/server/agent'
@@ -18,10 +18,10 @@ so consumption is the app's choice, same as any other streaming verb:
18
18
 
19
19
  The engine — provider-specific, lives in a `@abide/<provider>` package —
20
20
  only sees the surface in and yields frames out, so swapping providers never
21
- touches the verb or the UI.
21
+ touches the rpc or the UI.
22
22
 
23
23
  Permission is decided server-side, not negotiated at runtime: the surface
24
- is already gated by each verb's `clients.mcp` declaration plus its own
24
+ is already gated by each rpc's `clients.mcp` declaration plus its own
25
25
  per-call handler auth, and any provider built-ins (e.g. Claude Code's bash
26
26
  tool) are fenced by static rules in the engine's config.
27
27
  */
@@ -71,7 +71,7 @@ export type AgentEngine = (input: {
71
71
 
72
72
  /*
73
73
  Runs an engine against the current request's MCP surface and returns its
74
- AgentFrame stream. Must be called inside a verb's request scope —
74
+ AgentFrame stream. Must be called inside a rpc's request scope —
75
75
  mcpSurface() forwards the caller's auth into every tool dispatch. The
76
76
  handler chooses the transport: `jsonl(agent(engine, messages))` or
77
77
  `sse(agent(engine, messages))`.
@@ -14,7 +14,7 @@ deep inside a handler:
14
14
 
15
15
  Reads `Bun.env` (the process environment plus any `.env` Bun loaded), so any
16
16
  Standard Schema library — zod, valibot, arktype — works without an adapter,
17
- same as the verb helpers. Coercion (e.g. a numeric PORT) lives in the schema.
17
+ same as the rpc helpers. Coercion (e.g. a numeric PORT) lives in the schema.
18
18
 
19
19
  The schema is registered (envSchemaStore) so the bundle launcher can project
20
20
  the first-run setup form from the same declaration. When the launcher imports
@@ -1,5 +1,6 @@
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
4
  import type { TypedResponse } from './rpc/types/TypedResponse.ts'
4
5
  import { withResponseDefaults } from './runtime/withResponseDefaults.ts'
5
6
 
@@ -57,17 +58,58 @@ branch carries (`TypedResponse<{user}> | TypedResponse<never>` → Return
57
58
  = {user}).
58
59
  */
59
60
  // @documentation response
60
- export function error(status: number, message?: string, init?: ResponseInit): TypedResponse<never> {
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,
65
+ message?: string,
66
+ init?: ResponseInit,
67
+ ): 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
61
86
  const body = message ?? STATUS_TEXT[status] ?? `HTTP ${status}`
62
87
  return new Response(
63
88
  body,
64
- withResponseDefaults(
65
- init,
66
- {
67
- 'Content-Type': TEXT_PLAIN,
68
- 'Cache-Control': NO_STORE,
69
- },
89
+ withStatusText(
90
+ withResponseDefaults(
91
+ init,
92
+ {
93
+ 'Content-Type': TEXT_PLAIN,
94
+ 'Cache-Control': NO_STORE,
95
+ },
96
+ status,
97
+ ),
70
98
  status,
71
99
  ),
72
100
  ) as TypedResponse<never>
73
101
  }
102
+
103
+ /*
104
+ Stamps the status's standard reason phrase onto `init.statusText` so the wire
105
+ status line reads `404 Not Found` and the client's `HttpError.statusText` is
106
+ populated — Bun's `Response` never derives statusText from the code. A caller's
107
+ explicit `init.statusText` (preserved through withResponseDefaults) still wins;
108
+ an unlisted code leaves it untouched.
109
+ */
110
+ function withStatusText(init: ResponseInit, status: number): ResponseInit {
111
+ if (init.statusText === undefined && STATUS_TEXT[status] !== undefined) {
112
+ return { ...init, statusText: STATUS_TEXT[status] }
113
+ }
114
+ return init
115
+ }
@@ -13,7 +13,7 @@ own per-request cache handles in-process dedupe.
13
13
  json(await db.getOrder(id)),
14
14
  )
15
15
 
16
- The return type carries `T` as a phantom brand so the verb helper can
16
+ The return type carries `T` as a phantom brand so the rpc helper can
17
17
  infer the caller-facing `Return` from the handler body — no need to
18
18
  annotate `GET<Args, Return>` just to type the response shape.
19
19
 
@@ -0,0 +1,19 @@
1
+ import type { ErrorConstructors } from '../../shared/types/ErrorConstructors.ts'
2
+ import type { ErrorSpec } from '../../shared/types/ErrorSpec.ts'
3
+
4
+ /*
5
+ Turns a rpc's declared `ErrorSpec` into the constructor object handed to the
6
+ handler (`(args, { errors })`). Each constructor stamps its name + status onto an
7
+ `ErrorDescriptor` carrying the call's `data`, which `error()` serializes as the
8
+ `{ $abideError, data }` body. Receiver-agnostic on data: a nullary error ignores
9
+ the (absent) argument.
10
+ */
11
+ export function buildErrorConstructors<Spec extends ErrorSpec>(
12
+ spec: Spec,
13
+ ): ErrorConstructors<Spec> {
14
+ const entries = Object.entries(spec).map(([name, { status }]) => [
15
+ name,
16
+ (data: unknown) => ({ $abideError: name, status, data }),
17
+ ])
18
+ return Object.fromEntries(entries) as ErrorConstructors<Spec>
19
+ }
@@ -5,34 +5,36 @@ 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 { HttpVerb } from '../../shared/types/HttpVerb.ts'
8
+ import type { ErrorSpec } from '../../shared/types/ErrorSpec.ts'
9
+ import type { HttpMethod } from '../../shared/types/HttpMethod.ts'
9
10
  import type { RemoteFunction } from '../../shared/types/RemoteFunction.ts'
10
11
  import type { StandardSchemaV1 } from '../../shared/types/StandardSchemaV1.ts'
11
- import { json } from '../json.ts'
12
12
  import { requestContext } from '../runtime/requestContext.ts'
13
+ import { buildErrorConstructors } from './buildErrorConstructors.ts'
13
14
  import { parseArgs } from './parseArgs.ts'
14
- import { registerVerb } from './registerVerb.ts'
15
- import { runWithVerbTimeout } from './runWithVerbTimeout.ts'
15
+ import { registerRpc } from './registerRpc.ts'
16
+ import { runWithRpcTimeout } from './runWithRpcTimeout.ts'
16
17
  import type { RemoteHandler } from './types/RemoteHandler.ts'
18
+ import { validationError } from './validationError.ts'
17
19
 
18
20
  /*
19
- Stash for the per-request AbortController a timed verb composes into the
21
+ Stash for the per-request AbortController a timed rpc composes into the
20
22
  inbound signal — read back in invoke's deadline callback to fire it. Lives on
21
- the scope's Request (one verb per .fetch — network or in-process dispatch) so
23
+ the scope's Request (one rpc per .fetch — network or in-process dispatch) so
22
24
  an SSR pass's many in-process cache reads, which call invoke() directly and
23
25
  never reach parseArgsForFetch, can't cross-cancel.
24
26
  */
25
- const VERB_TIMEOUT_ABORT = Symbol('abideVerbTimeoutAbort')
27
+ const RPC_TIMEOUT_ABORT = Symbol('abideRpcTimeoutAbort')
26
28
 
27
- /* Verb dispatch + validation spans, opt-in via DEBUG=abide:rpc. Reveals an
29
+ /* Rpc dispatch + validation spans, opt-in via DEBUG=abide:rpc. Reveals an
28
30
  in-process RPC→RPC call (same request scope, same trace) as a nested span. */
29
31
  const rpcLog = abideLog.channel('abide:rpc')
30
32
 
31
33
  /*
32
- Builds a RemoteFunction from an HTTP verb + RPC URL + handler. The bundler
33
- rewrites every `export const VERB = handler(fn)` inside an `$rpc/**` module
34
- so the verb (from the export name) and the URL (from the file path under
35
- `src/server/rpc/`, with `/rpc/` prefix) are threaded into defineVerb.
34
+ Builds a RemoteFunction from an HTTP method + RPC URL + handler. The bundler
35
+ rewrites every `export const METHOD = handler(fn)` inside an `$rpc/**` module
36
+ so the rpc (from the export name) and the URL (from the file path under
37
+ `src/server/rpc/`, with `/rpc/` prefix) are threaded into defineRpc.
36
38
 
37
39
  The plain call (`fn(args)`) resolves to the Content-Type-decoded body;
38
40
  non-2xx responses throw HttpError. `.raw(args)` returns the underlying
@@ -45,32 +47,48 @@ Every raw invocation records the synthesized Request against the returned
45
47
  promise so cache() can stash it on the entry without re-building.
46
48
  */
47
49
  // @documentation plumbing
48
- export function defineVerb<Args, Return>(
49
- method: HttpVerb,
50
+ export function defineRpc<Args, Return>(
51
+ method: HttpMethod,
50
52
  url: string,
51
53
  handler: RemoteHandler<Args, Return>,
52
54
  opts?: {
53
55
  inputSchema?: StandardSchemaV1
54
56
  outputSchema?: StandardSchemaV1
55
57
  filesSchema?: StandardSchemaV1
58
+ errors?: ErrorSpec
56
59
  clients?: Partial<ClientFlags>
57
60
  crossOrigin?: boolean
58
- /* Per-verb cap on actual received body bytes (413 past it); omitted = Bun's server-wide maxRequestBodySize. */
61
+ /* Per-rpc cap on actual received body bytes (413 past it); omitted = Bun's server-wide maxRequestBodySize. */
59
62
  maxBodySize?: number
60
- /* Per-verb handler deadline (ms): a 504 once exceeded, on every surface (SSR/MCP/CLI/network). */
63
+ /* Per-rpc handler deadline (ms): a 504 once exceeded, on every surface (SSR/MCP/CLI/network). */
61
64
  timeout?: number
65
+ /* Durable delivery: on an unreachable server (transport failure / 502/503/504/52x)
66
+ the client proxy parks the request for replay instead of just throwing. The
67
+ parked write drains on `rpc.outbox.retry()` (no auto-drain). Mutating methods
68
+ only. */
69
+ outbox?: boolean
62
70
  },
63
71
  ): RemoteFunction<Args, Return> {
72
+ /* `outbox: true` is a mutation contract — a read RPC has nothing to durably deliver,
73
+ so reject it at declaration rather than silently ignore. The server handler itself
74
+ is unaffected; durability is a client-proxy concern. */
75
+ if (opts?.outbox === true && isReadOnlyMethod(method)) {
76
+ throw new Error(
77
+ `[abide] outbox: true is only valid on mutating RPCs (POST/PUT/PATCH/DELETE), not ${method}`,
78
+ )
79
+ }
64
80
  const timeout = opts?.timeout
65
81
  const inputSchema = opts?.inputSchema
66
82
  const outputSchema = opts?.outputSchema
67
83
  const filesSchema = opts?.filesSchema
84
+ /* The declared error constructors handed to the handler as its `{ errors }` ctx. */
85
+ const errors = buildErrorConstructors(opts?.errors ?? {})
68
86
  /*
69
87
  An input schema makes the handler safe to advertise to non-browser
70
- surfaces. CLI flips on for any verb with one (a human/script invokes it
71
- deliberately). MCP only auto-exposes read-only verbs (GET/HEAD) — a
88
+ surfaces. CLI flips on for any rpc with one (a human/script invokes it
89
+ deliberately). MCP only auto-exposes read-only rpcs (GET/HEAD) — a
72
90
  model shouldn't be able to mutate/delete just because the handler
73
- carries a schema, so mutating verbs require an explicit clients.mcp.
91
+ carries a schema, so mutating rpcs require an explicit clients.mcp.
74
92
  Explicit `clients` always wins.
75
93
  */
76
94
  const hasSchema = inputSchema !== undefined
@@ -97,13 +115,13 @@ export function defineVerb<Args, Return>(
97
115
  async function runHandler(args: Args | undefined): Promise<Response> {
98
116
  return rpcLog.trace(
99
117
  `rpc ${method} ${url}`,
100
- () => handler(args as Args) as unknown as Response,
118
+ () => handler(args as Args, { errors }) as unknown as Response,
101
119
  )
102
120
  }
103
121
 
104
122
  /*
105
123
  Validates the parsed args against inputSchema (text fields), then — when the
106
- verb declares filesSchema — validates the File parts parseArgs split onto
124
+ rpc declares filesSchema — validates the File parts parseArgs split onto
107
125
  the request store and merges them into the args bag the handler receives.
108
126
  Either schema's issues become a 422. Files stay out of inputSchema so its
109
127
  JSON-Schema projection (OpenAPI/MCP/CLI) never has to model a binary.
@@ -115,7 +133,7 @@ export function defineVerb<Args, Return>(
115
133
  inputSchema['~standard'].validate(value),
116
134
  )
117
135
  if (result.issues) {
118
- return json({ issues: result.issues }, { status: 422 })
136
+ return validationError(result.issues)
119
137
  }
120
138
  value = result.value
121
139
  }
@@ -123,7 +141,7 @@ export function defineVerb<Args, Return>(
123
141
  const files = requestContext.getStore()?.files ?? {}
124
142
  const result = await filesSchema['~standard'].validate(files)
125
143
  if (result.issues) {
126
- return json({ issues: result.issues }, { status: 422 })
144
+ return validationError(result.issues)
127
145
  }
128
146
  value = { ...(value as object), ...(result.value as object) }
129
147
  }
@@ -138,11 +156,11 @@ export function defineVerb<Args, Return>(
138
156
  SSR call.
139
157
  */
140
158
  /* Abort the controller parseArgsForFetch stashed on store.req; a no-op when none was stashed (SSR cache reads). */
141
- function abortVerbTimeout(): void {
159
+ function abortRpcTimeout(): void {
142
160
  const req = requestContext.getStore()?.req as
143
- | (Request & { [VERB_TIMEOUT_ABORT]?: AbortController })
161
+ | (Request & { [RPC_TIMEOUT_ABORT]?: AbortController })
144
162
  | undefined
145
- req?.[VERB_TIMEOUT_ABORT]?.abort(new DOMException('handler timeout', 'TimeoutError'))
163
+ req?.[RPC_TIMEOUT_ABORT]?.abort(new DOMException('handler timeout', 'TimeoutError'))
146
164
  }
147
165
 
148
166
  function invoke(args: Args | undefined): Promise<Response> {
@@ -153,9 +171,9 @@ export function defineVerb<Args, Return>(
153
171
  /*
154
172
  On the deadline, fire the controller parseArgsForFetch composed into
155
173
  request().signal (absent on the SSR cache-read path, so a sibling
156
- verb's outbound fetch is never cancelled) — then 504.
174
+ rpc's outbound fetch is never cancelled) — then 504.
157
175
  */
158
- return runWithVerbTimeout(work, timeout, abortVerbTimeout)
176
+ return runWithRpcTimeout(work, timeout, abortRpcTimeout)
159
177
  }
160
178
 
161
179
  const remote = createRemoteFunction<Args, Return>({
@@ -168,17 +186,17 @@ export function defineVerb<Args, Return>(
168
186
  parseArgsForFetch: async (request) => {
169
187
  const args = await parseArgs(method, request, opts?.maxBodySize)
170
188
  /*
171
- Compose this verb's deadline into request().signal so a handler's
189
+ Compose this rpc's deadline into request().signal so a handler's
172
190
  fetch(ext, { signal: request().signal }) is cancelled when the
173
191
  timeout fires — not just abandoned. Applied after parseArgs onto the
174
- scope's *final* request: a maxBodySize verb swaps store.req for a
192
+ scope's *final* request: a maxBodySize rpc swaps store.req for a
175
193
  buffered copy (readBodyWithinLimit) and an app.handle hook may
176
194
  rewrite it, so composing onto the inbound `request` would leave
177
- request() — and abortVerbTimeout, which reads store.req — pointed at
195
+ request() — and abortRpcTimeout, which reads store.req — pointed at
178
196
  an un-cancellable signal. Only the signal is shadowed; the body
179
197
  stays readable. The store always exists here (network + in-process
180
198
  dispatch both run inside runWithRequestScope); SSR cache reads call
181
- invoke() directly, never this path, so a sibling verb is never
199
+ invoke() directly, never this path, so a sibling rpc is never
182
200
  cross-cancelled.
183
201
  */
184
202
  if (timeout !== undefined) {
@@ -187,7 +205,7 @@ export function defineVerb<Args, Return>(
187
205
  const controller = new AbortController()
188
206
  const composed = AbortSignal.any([req.signal, controller.signal])
189
207
  Object.defineProperty(req, 'signal', { value: composed, configurable: true })
190
- Object.defineProperty(req, VERB_TIMEOUT_ABORT, {
208
+ Object.defineProperty(req, RPC_TIMEOUT_ABORT, {
191
209
  value: controller,
192
210
  configurable: true,
193
211
  })
@@ -196,7 +214,7 @@ export function defineVerb<Args, Return>(
196
214
  return args as Args | undefined
197
215
  },
198
216
  })
199
- registerVerb({
217
+ registerRpc({
200
218
  remote: remote as RemoteFunction<unknown, unknown>,
201
219
  inputSchema,
202
220
  outputSchema,
@@ -4,11 +4,11 @@ import type { AppModule } from '../AppModule.ts'
4
4
  import { runWithRequestScope } from '../runtime/runWithRequestScope.ts'
5
5
 
6
6
  /*
7
- Runs a verb in-process: synthesizes the rpc Request from the remote's own
7
+ Runs a rpc in-process: synthesizes the rpc Request from the remote's own
8
8
  method + url and pipes it through remote.fetch — the same handler/validation/
9
9
  error path the HTTP router uses, no network hop. The single in-process
10
10
  dispatch every consumer surface (the CLI client, the MCP tool dispatcher, and
11
- the test client) routes through, so they can't drift on how a verb is invoked.
11
+ the test client) routes through, so they can't drift on how a rpc is invoked.
12
12
  Takes the RemoteFunction directly — invocation never reads the registry
13
13
  entry's schemas/clients (validation is closed over inside the remote), so the
14
14
  entry is not a dependency here. `baseUrl` gives the synthetic Request its
@@ -22,7 +22,7 @@ resolution, and the app's handleError (or the 500 fallback) on a throw. The
22
22
  synthesized Request is shared between the scope store and the handler fetch so
23
23
  request() returns the same Request parseArgs read from.
24
24
  */
25
- export function dispatchVerbInProcess({
25
+ export function dispatchRpcInProcess({
26
26
  remote,
27
27
  args,
28
28
  baseUrl,
@@ -0,0 +1,23 @@
1
+ import type { StandardSchemaV1 } from '../../shared/types/StandardSchemaV1.ts'
2
+
3
+ /*
4
+ Flattens Standard Schema issues into a top-level-field → first-message map, the
5
+ form-friendly companion to the raw `issues` on a 422. A path segment is either a
6
+ bare `PropertyKey` or a `{ key }` wrapper (the spec allows both), so normalize
7
+ before reading. First message wins per field (a form shows one per input); the
8
+ raw `issues` keep every message and the full path. Issues with no string field
9
+ (root-level refinements) are omitted — they live only in `issues`.
10
+ */
11
+ export function fieldErrorsFromIssues(
12
+ issues: readonly StandardSchemaV1.Issue[],
13
+ ): Record<string, string> {
14
+ const fields: Record<string, string> = {}
15
+ for (const issue of issues) {
16
+ const segment = issue.path?.[0]
17
+ const key = typeof segment === 'object' ? segment.key : segment
18
+ if (typeof key === 'string' && !(key in fields)) {
19
+ fields[key] = issue.message
20
+ }
21
+ }
22
+ return fields
23
+ }
@@ -1,15 +1,15 @@
1
1
  import { commandNameForUrl } from '../../shared/commandNameForUrl.ts'
2
- import type { VerbRegistryEntry } from './types/VerbRegistryEntry.ts'
3
- import { verbRegistry } from './verbRegistry.ts'
2
+ import { rpcRegistry } from './rpcRegistry.ts'
3
+ import type { RpcRegistryEntry } from './types/RpcRegistryEntry.ts'
4
4
 
5
5
  /*
6
- Finds the registered verb whose URL maps to a given command name (folder
6
+ Finds the registered rpc whose URL maps to a given command name (folder
7
7
  segments joined with `-`, per commandNameForUrl). The CLI client proxy and
8
8
  the MCP tool dispatcher both key off this name, so the scan lives here once
9
9
  rather than being re-implemented — and reused — at each call site.
10
10
  */
11
- export function findVerbByCommandName(name: string): VerbRegistryEntry | undefined {
12
- for (const entry of verbRegistry.values()) {
11
+ export function findRpcByCommandName(name: string): RpcRegistryEntry | undefined {
12
+ for (const entry of rpcRegistry.values()) {
13
13
  if (commandNameForUrl(entry.remote.url) === name) {
14
14
  return entry
15
15
  }
@@ -3,7 +3,7 @@ import { contentTypeOf } from '../../shared/contentTypeOf.ts'
3
3
  import { decodeRefJson } from '../../shared/decodeRefJson.ts'
4
4
  import { HttpError } from '../../shared/HttpError.ts'
5
5
  import { REF_JSON_HEADER } from '../../shared/REF_JSON_HEADER.ts'
6
- import type { HttpVerb } from '../../shared/types/HttpVerb.ts'
6
+ import type { HttpMethod } from '../../shared/types/HttpMethod.ts'
7
7
  import { error } from '../error.ts'
8
8
  import { requestContext } from '../runtime/requestContext.ts'
9
9
  import { readBodyWithinLimit } from './readBodyWithinLimit.ts'
@@ -41,7 +41,7 @@ function splitFormData(form: FormData): Record<string, unknown> {
41
41
  }
42
42
 
43
43
  /*
44
- Parses + merges every source of args available for a verb-defined handler:
44
+ Parses + merges every source of args available for a rpc-defined handler:
45
45
  - body (json or form-encoded, ignored for GET/DELETE/HEAD)
46
46
  - url query string
47
47
 
@@ -54,12 +54,12 @@ to layer the query into, and the framework's args type is a single bag rather
54
54
  than a `{body, query}` envelope. Returns undefined when no source contributes
55
55
  any key.
56
56
 
57
- `maxBodySize` (per-verb, opt-in) bounds the body's actual received bytes
57
+ `maxBodySize` (per-rpc, opt-in) bounds the body's actual received bytes
58
58
  before any parse — see readBodyWithinLimit. Omitted = no abide-level check;
59
59
  Bun.serve's server-wide maxRequestBodySize is the ceiling.
60
60
  */
61
61
  export async function parseArgs(
62
- method: HttpVerb,
62
+ method: HttpMethod,
63
63
  request: Request,
64
64
  maxBodySize?: number,
65
65
  ): Promise<unknown> {
@@ -129,12 +129,12 @@ export async function parseArgs(
129
129
  /*
130
130
  TODO(query-coercion): query params arrive as strings, so a numeric/boolean
131
131
  field reaches schema validation as `'2'`/`'true'`. Deferred deliberately:
132
- parseArgs has no access to the verb's inputSchema (it lives in defineVerb),
132
+ parseArgs has no access to the rpc's inputSchema (it lives in defineRpc),
133
133
  and Standard Schema exposes no type structure to drive type-aware coercion.
134
134
  Blind value-shape coercion is unsafe — it would corrupt legitimately
135
135
  string-typed fields whose value looks numeric/boolean (ids, zip codes,
136
136
  version strings like '1.0'), silently breaking GET validation. A correct fix
137
- needs the schema threaded in here (or a coercing schema adapter at the verb).
137
+ needs the schema threaded in here (or a coercing schema adapter at the rpc).
138
138
  */
139
139
  const bodyObject = (body ?? {}) as Record<string, unknown>
140
140
  const merged = { ...Object.fromEntries(url.searchParams), ...bodyObject }
@@ -2,14 +2,14 @@ import { HttpError } from '../../shared/HttpError.ts'
2
2
  import { error } from '../error.ts'
3
3
 
4
4
  /*
5
- Enforces a verb's maxBodySize on the actual received bytes, not the
5
+ Enforces a rpc's maxBodySize on the actual received bytes, not the
6
6
  Content-Length header (absent on chunked bodies, trivially spoofed). A
7
7
  declared Content-Length over the limit rejects before reading anything;
8
8
  otherwise the body streams into a buffer that throws 413 the moment the
9
9
  limit is crossed — aborting the read, so the remaining bytes are never
10
10
  consumed. Returns a reassembled Request carrying the buffered body and the
11
11
  original headers, so the parse path's .text()/.formData() work unchanged.
12
- Only called when the verb declares maxBodySize; the default ceiling is
12
+ Only called when the rpc declares maxBodySize; the default ceiling is
13
13
  Bun.serve's server-wide maxRequestBodySize, which Bun enforces natively.
14
14
  */
15
15
  export async function readBodyWithinLimit(request: Request, maxBytes: number): Promise<Request> {
@@ -0,0 +1,6 @@
1
+ import { rpcRegistry } from './rpcRegistry.ts'
2
+ import type { RpcRegistryEntry } from './types/RpcRegistryEntry.ts'
3
+
4
+ export function registerRpc(entry: RpcRegistryEntry): void {
5
+ rpcRegistry.set(entry.remote.url, entry)
6
+ }
@@ -1,11 +1,11 @@
1
- import type { VerbRegistryEntry } from './types/VerbRegistryEntry.ts'
1
+ import type { RpcRegistryEntry } from './types/RpcRegistryEntry.ts'
2
2
 
3
3
  /*
4
- Process-wide registry of every verb-bound RPC declared in the app.
5
- defineVerb inserts on first construction (which happens at module-load
4
+ Process-wide registry of every rpc-bound RPC declared in the app.
5
+ defineRpc inserts on first construction (which happens at module-load
6
6
  time inside the rpc dispatcher cache or eagerly when MCP / CLI walks the
7
7
  rpc manifest). MCP server reads this to build its tools list; the CLI
8
8
  binary reads it to generate subcommands. The browser path never touches
9
9
  this — the client stub has no schema or clients metadata to register.
10
10
  */
11
- export const verbRegistry = new Map<string, VerbRegistryEntry>()
11
+ export const rpcRegistry = new Map<string, RpcRegistryEntry>()
@@ -1,12 +1,12 @@
1
1
  import { error } from '../error.ts'
2
2
 
3
3
  /*
4
- Bounds a verb handler's execution: races the in-flight `work` against `ms`.
4
+ Bounds a rpc handler's execution: races the in-flight `work` against `ms`.
5
5
  On the deadline it resolves a 504 — so the caller (an SSR cache read, an
6
6
  MCP/CLI invocation, or the network response) is unblocked in time with an
7
7
  honest status — and calls `onTimeout` to cancel any
8
- cooperating outbound work (the network path composes the verb's deadline into
9
- request().signal; see defineVerb).
8
+ cooperating outbound work (the network path composes the rpc's deadline into
9
+ request().signal; see defineRpc).
10
10
 
11
11
  `work` keeps running after the deadline — JS can't cancel a running async
12
12
  function, only stop awaiting it — so its eventual settlement is swallowed to
@@ -14,7 +14,7 @@ avoid an unhandled rejection, and a late-resolved streaming Response has its
14
14
  body cancelled to release the underlying source. A handler that wants its own
15
15
  outbound I/O torn down should pass request().signal to it.
16
16
  */
17
- export function runWithVerbTimeout(
17
+ export function runWithRpcTimeout(
18
18
  work: Promise<Response>,
19
19
  ms: number,
20
20
  onTimeout: () => void,
@@ -1,7 +1,9 @@
1
+ import type { ErrorConstructors } from '../../../shared/types/ErrorConstructors.ts'
2
+ import type { ErrorSpec } from '../../../shared/types/ErrorSpec.ts'
1
3
  import type { TypedResponse } from './TypedResponse.ts'
2
4
 
3
5
  /*
4
- Handler signature for verb-defined remote functions. Args is `undefined` for
6
+ Handler signature for rpc-defined remote functions. Args is `undefined` for
5
7
  GETs/DELETEs with no query, JSON-shaped objects for json bodies, and
6
8
  form-shaped objects for form-encoded bodies. For a multipart upload it's the
7
9
  text fields (`inputSchema`) intersected with the validated File parts
@@ -13,7 +15,7 @@ decoding (a parsed object for JSON, a string for text/*, a Blob otherwise,
13
15
  `undefined` for 204). The handler must return a Response at runtime; the
14
16
  `TypedResponse<Return>` brand on `json`/`error`/`redirect`/`jsonl`/`sse`
15
17
  carries the body shape through the function's inferred return type so the
16
- verb helper can infer `Return` automatically — no need to annotate
18
+ rpc helper can infer `Return` automatically — no need to annotate
17
19
  `GET<Args, Return>` when the handler returns one of the respond helpers.
18
20
  A bare `new Response(...)` is still acceptable: the brand is optional, so
19
21
  untagged Responses fall back to `Return = unknown`.
@@ -22,6 +24,10 @@ Handlers that need the inbound Request (headers, `request.signal`, …) read
22
24
  it via `request()` from `abide/server` rather than a handler parameter, so
23
25
  the signature stays a single parsed-`args` bag.
24
26
  */
25
- export type RemoteHandler<Args, Return> = (
27
+ export type RemoteHandler<Args, Return, Errors extends ErrorSpec = Record<never, never>> = (
26
28
  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> },
27
33
  ) => TypedResponse<Return> | Promise<TypedResponse<Return>>