@abide/abide 0.44.1 → 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 (58) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +171 -181
  4. package/package.json +2 -1
  5. package/src/lib/server/error.ts +48 -53
  6. package/src/lib/server/json.ts +4 -3
  7. package/src/lib/server/jsonl.ts +1 -1
  8. package/src/lib/server/rpc/defineRpc.ts +2 -8
  9. package/src/lib/server/rpc/types/RemoteHandler.ts +5 -7
  10. package/src/lib/server/rpc/types/RpcHelper.ts +125 -35
  11. package/src/lib/server/rpc/types/TypedError.ts +18 -0
  12. package/src/lib/server/rpc/validationError.ts +3 -3
  13. package/src/lib/server/runtime/STATUS_TEXT.ts +22 -0
  14. package/src/lib/server/runtime/createServer.ts +7 -0
  15. package/src/lib/server/runtime/installAmbientScopeStore.ts +33 -0
  16. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -0
  17. package/src/lib/server/runtime/typedErrorResponse.ts +26 -0
  18. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  19. package/src/lib/server/sse.ts +1 -1
  20. package/src/lib/shared/HttpError.ts +1 -1
  21. package/src/lib/shared/cache.ts +26 -4
  22. package/src/lib/shared/cacheEntryFromSnapshot.ts +21 -1
  23. package/src/lib/shared/cacheKeyOf.ts +7 -0
  24. package/src/lib/shared/cacheKeyStore.ts +8 -0
  25. package/src/lib/shared/httpErrorFor.ts +1 -1
  26. package/src/lib/shared/recordCacheKey.ts +6 -0
  27. package/src/lib/shared/toTagSet.ts +3 -3
  28. package/src/lib/shared/types/CacheEntry.ts +15 -0
  29. package/src/lib/shared/types/CacheOptions.ts +5 -5
  30. package/src/lib/shared/types/CacheSnapshotEntry.ts +5 -0
  31. package/src/lib/shared/types/ErrorSpec.ts +6 -6
  32. package/src/lib/shared/types/RemoteFunction.ts +8 -4
  33. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  34. package/src/lib/ui/compile/generateSSR.ts +10 -3
  35. package/src/lib/ui/createScope.ts +11 -4
  36. package/src/lib/ui/deferResume.ts +29 -0
  37. package/src/lib/ui/dom/appendText.ts +19 -4
  38. package/src/lib/ui/dom/awaitBlock.ts +74 -26
  39. package/src/lib/ui/dom/eachAsync.ts +11 -18
  40. package/src/lib/ui/installHotBridge.ts +2 -0
  41. package/src/lib/ui/matchRoute.ts +18 -1
  42. package/src/lib/ui/remoteProxy.ts +11 -1
  43. package/src/lib/ui/renderToStream.ts +13 -8
  44. package/src/lib/ui/resumeSeedScript.ts +2 -2
  45. package/src/lib/ui/runtime/CURRENT_SCOPE.ts +14 -1
  46. package/src/lib/ui/runtime/RESUME.ts +6 -0
  47. package/src/lib/ui/runtime/ambientScopeBacking.ts +26 -0
  48. package/src/lib/ui/runtime/createEffectNode.ts +7 -1
  49. package/src/lib/ui/runtime/flushEffects.ts +18 -0
  50. package/src/lib/ui/runtime/generationGuard.ts +40 -0
  51. package/src/lib/ui/runtime/runNode.ts +9 -1
  52. package/src/lib/ui/runtime/types/SsrRender.ts +4 -3
  53. package/src/lib/ui/scope.ts +6 -8
  54. package/src/lib/ui/tryEncodeResume.ts +5 -2
  55. package/src/lib/ui/types/Scope.ts +3 -4
  56. package/src/lib/server/rpc/buildErrorConstructors.ts +0 -19
  57. package/src/lib/shared/types/ErrorConstructors.ts +0 -17
  58. package/src/lib/shared/types/ErrorDescriptor.ts +0 -10
@@ -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
@@ -123,7 +118,7 @@ export function defineRpc<Args, Return>(
123
118
  async function runHandler(args: Args | undefined): Promise<Response> {
124
119
  return rpcLog.trace(
125
120
  `rpc ${method} ${url}`,
126
- () => handler(args as Args, { errors }) as unknown as Response,
121
+ () => handler(args as Args) as unknown as Response,
127
122
  )
128
123
  }
129
124
 
@@ -166,8 +161,7 @@ export function defineRpc<Args, Return>(
166
161
  /* Abort the controller parseArgsForFetch stashed on store.req; a no-op when none was stashed (SSR cache reads). */
167
162
  function abortRpcTimeout(): void {
168
163
  const req = requestContext.getStore()?.req as
169
- | (Request & { [RPC_TIMEOUT_ABORT]?: AbortController })
170
- | undefined
164
+ (Request & { [RPC_TIMEOUT_ABORT]?: AbortController }) | undefined
171
165
  req?.[RPC_TIMEOUT_ABORT]?.abort(new DOMException('handler timeout', 'TimeoutError'))
172
166
  }
173
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>>
@@ -2,7 +2,61 @@ import type { ClientFlags } from '../../../shared/types/ClientFlags.ts'
2
2
  import type { ErrorSpec } from '../../../shared/types/ErrorSpec.ts'
3
3
  import type { RemoteFunction } from '../../../shared/types/RemoteFunction.ts'
4
4
  import type { StandardSchemaV1 } from '../../../shared/types/StandardSchemaV1.ts'
5
- import type { RemoteHandler } from './RemoteHandler.ts'
5
+ import type { TypedError } from './TypedError.ts'
6
+ import type { TypedResponse } from './TypedResponse.ts'
7
+
8
+ /*
9
+ The success body carried by a handler's return type `R`. Error branches
10
+ (`TypedError`, checked first since they're also Responses) drop to `never` and
11
+ union away, so `Return` is the body of the success `TypedResponse` members alone
12
+ — an untagged `Response` falls back to `unknown`, matching hand-built responses.
13
+ */
14
+ type SuccessBody<R> =
15
+ R extends TypedError<string, ErrorSpec[string]>
16
+ ? never
17
+ : R extends TypedResponse<infer Body>
18
+ ? Body
19
+ : unknown
20
+
21
+ /*
22
+ The error spec a handler's return type `R` declares — rebuilt name→entry from the
23
+ `TypedError` brands among its branches (distributes over the union; no error
24
+ branches → `{}`). This is what gives `rpc.isError` its typed surface with no
25
+ `errors:` option: the errors a handler RETURNS are the errors it can raise.
26
+ */
27
+ type ErrorBrand<R> =
28
+ R extends TypedError<infer Name, infer Entry> ? { name: Name; entry: Entry } : never
29
+ type InferredErrors<R> = { [Brand in ErrorBrand<R> as Brand['name']]: Brand['entry'] }
30
+
31
+ /*
32
+ The handler every bare (schemaless) overload accepts: any args in, a Response (or a
33
+ Promise of one) out. Collapsing the old `<Args, R>` pair into this single inferred
34
+ function type is deliberate. TypeScript has no partial type-argument inference, so
35
+ `GET<Args>(fn)` used to drop `R` to its `Response` default and silently erase the body
36
+ to `unknown`. With one generic constrained to `RpcFn`, that same call is instead a loud
37
+ `does not satisfy the constraint 'RpcFn'`, and `Args`/`Return`/`Errors` all read
38
+ structurally off the handler — args from its parameter, body + errors from its return —
39
+ which is where a normal TS function declares them anyway (no mainstream rpc framework
40
+ parameterises the call with generics). `any` is load-bearing: `never`/`unknown` in the
41
+ parameter position break return inference by contextually widening the handler's body.
42
+ */
43
+ type RpcFn = (args: any) => Response | Promise<Response>
44
+
45
+ /* The handler's declared args: its first parameter, or `undefined` for a nullary handler. */
46
+ type RpcArgs<F extends RpcFn> = Parameters<F> extends [infer Args, ...unknown[]] ? Args : undefined
47
+
48
+ /*
49
+ The RemoteFunction a bare overload produces from handler `F`. `SuccessBody`/`InferredErrors`
50
+ run over `Awaited<ReturnType<F>>` INLINE (not via an intermediate `extends RpcFn`-constrained
51
+ alias) — such an alias resolves `ReturnType` against the constraint bound and degrades the
52
+ body to `any`. `Durable` threads the outbox bit through for the durable overloads.
53
+ */
54
+ type RpcOf<F extends RpcFn, Durable extends boolean = false> = RemoteFunction<
55
+ RpcArgs<F>,
56
+ SuccessBody<Awaited<ReturnType<F>>>,
57
+ InferredErrors<Awaited<ReturnType<F>>>,
58
+ Durable
59
+ >
6
60
 
7
61
  /*
8
62
  Options every rpc overload accepts: the OpenAPI 200 `outputSchema`, the
@@ -34,14 +88,17 @@ type MutatingRpcOpts = RpcBaseOpts & {
34
88
  }
35
89
 
36
90
  /*
37
- Shared signature for every rpc helper (GET / POST / …). Four overloads:
91
+ Shared signature for every rpc helper (GET / POST / …). The handler's return
92
+ type is inferred whole (`Awaited<ReturnType<F>>`), then split: `SuccessBody`
93
+ becomes the caller's `Return`, `InferredErrors` becomes the rpc's `Errors`
94
+ (driving `isError`). Typed errors are raised by returning an
95
+ `error.typed(name, status, schema?)` constructor — there is no `errors:` opt.
96
+ Four overloads by argument source:
38
97
 
39
98
  - `Rpc(fn, { inputSchema, outputSchema?, clients? })` — `Args` infers
40
99
  from `InferInput<InputSchema>`, the handler receives
41
- `InferOutput<InputSchema>`. Generic order is `<Return, InputSchema>` so
42
- users can override `Return` while letting `InputSchema` infer from
43
- `opts.inputSchema`. `outputSchema` is an optional Standard Schema for
44
- the success body — it feeds the OpenAPI 200 response and the MCP tool
100
+ `InferOutput<InputSchema>`. `outputSchema` is an optional Standard Schema
101
+ for the success body it feeds the OpenAPI 200 response and the MCP tool
45
102
  `outputSchema`. JSON Schema is projected from each schema's own
46
103
  `toJSONSchema()` (wrap with withJsonSchema if the library lacks one).
47
104
  `clients` controls which surfaces (browser / mcp / cli) expose this rpc.
@@ -57,9 +114,13 @@ Shared signature for every rpc helper (GET / POST / …). Four overloads:
57
114
  just abandoned.
58
115
  - `Rpc(fn, { clients })` — schemaless but with explicit client
59
116
  targeting (e.g. server-internal RPC with `clients: { browser: false }`).
60
- - `Rpc(fn)` — bare handler. `Args` and `Return` come from the handler
61
- type; `Return` is usually inferred via the `TypedResponse<T>` brand on
62
- `json`/`error`/`redirect`/`jsonl`/`sse`.
117
+ - `Rpc(fn)` — bare handler, a single `F extends RpcFn` generic. Everything
118
+ is read off the handler: `Args` from its parameter (annotate it
119
+ `POST((a: { id: string }) => …)` — or leave it nullary for `undefined`),
120
+ `Return` from the `TypedResponse<T>` brand on
121
+ `json`/`error`/`redirect`/`jsonl`/`sse`, `Errors` from any
122
+ `error.typed(...)` branches. You never pass `<Args, Return>` generics; a
123
+ stray one is a loud constraint error, not a silent `unknown` body.
63
124
  */
64
125
  type RpcHelperOf<Opts> = {
65
126
  /*
@@ -72,47 +133,76 @@ type RpcHelperOf<Opts> = {
72
133
  jsonSchemaForSchema) — so only inputSchema feeds MCP/CLI/OpenAPI.
73
134
  */
74
135
  <
75
- Return = unknown,
136
+ R extends Response,
76
137
  InputSchema extends StandardSchemaV1 = StandardSchemaV1,
77
138
  FilesSchema extends StandardSchemaV1 = StandardSchemaV1,
78
- Errors extends ErrorSpec = Record<string, never>,
79
139
  >(
80
- fn: RemoteHandler<
81
- StandardSchemaV1.InferOutput<InputSchema> & StandardSchemaV1.InferOutput<FilesSchema>,
82
- Return,
83
- Errors
84
- >,
140
+ fn: (
141
+ args: StandardSchemaV1.InferOutput<InputSchema> &
142
+ StandardSchemaV1.InferOutput<FilesSchema>,
143
+ ) => R | Promise<R>,
85
144
  opts: Opts & {
86
145
  inputSchema: InputSchema
87
146
  filesSchema: FilesSchema
88
- errors?: Errors
89
147
  },
90
- ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return, Errors>
91
- <
92
- Return = unknown,
93
- InputSchema extends StandardSchemaV1 = StandardSchemaV1,
94
- Errors extends ErrorSpec = Record<string, never>,
95
- >(
96
- fn: RemoteHandler<StandardSchemaV1.InferOutput<InputSchema>, Return, Errors>,
97
- opts: Opts & { inputSchema: InputSchema; errors?: Errors },
98
- ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return, Errors>
99
- <Args = undefined, Return = unknown, Errors extends ErrorSpec = Record<never, never>>(
100
- fn: RemoteHandler<Args, Return, Errors>,
101
- opts: Opts & { errors?: Errors },
102
- ): RemoteFunction<Args, Return, Errors>
103
- <Args = undefined, Return = unknown>(
104
- fn: RemoteHandler<Args, Return>,
105
- ): RemoteFunction<Args, Return>
148
+ ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, SuccessBody<R>, InferredErrors<R>>
149
+ <R extends Response, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
150
+ fn: (args: StandardSchemaV1.InferOutput<InputSchema>) => R | Promise<R>,
151
+ opts: Opts & { inputSchema: InputSchema },
152
+ ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, SuccessBody<R>, InferredErrors<R>>
153
+ <F extends RpcFn>(fn: F, opts: Opts): RpcOf<F>
154
+ <F extends RpcFn>(fn: F): RpcOf<F>
106
155
  }
107
156
 
108
157
  /* The read helpers (GET/HEAD): no `outbox` — a read has nothing to durably deliver. */
109
158
  export type RpcHelper = RpcHelperOf<RpcBaseOpts>
110
159
 
160
+ /*
161
+ Durable-call overloads: an `outbox: true` opt returns a RemoteFunction whose `Durable` bit
162
+ is set, so `rpc.outbox` is the queue face rather than optionally-undefined. Mirrors the
163
+ opts-bearing base overloads (multipart-upload, schema'd, schemaless); the bare `Rpc(fn)`
164
+ form has no opts to carry `outbox`, so it stays non-durable. Intersected AHEAD of the base
165
+ set in MutatingRpcHelper so an `outbox: true` literal resolves here first.
166
+ */
167
+ type DurableMutatingRpcHelper = {
168
+ <
169
+ R extends Response,
170
+ InputSchema extends StandardSchemaV1 = StandardSchemaV1,
171
+ FilesSchema extends StandardSchemaV1 = StandardSchemaV1,
172
+ >(
173
+ fn: (
174
+ args: StandardSchemaV1.InferOutput<InputSchema> &
175
+ StandardSchemaV1.InferOutput<FilesSchema>,
176
+ ) => R | Promise<R>,
177
+ opts: MutatingRpcOpts & {
178
+ inputSchema: InputSchema
179
+ filesSchema: FilesSchema
180
+ outbox: true
181
+ },
182
+ ): RemoteFunction<
183
+ StandardSchemaV1.InferInput<InputSchema>,
184
+ SuccessBody<R>,
185
+ InferredErrors<R>,
186
+ true
187
+ >
188
+ <R extends Response, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
189
+ fn: (args: StandardSchemaV1.InferOutput<InputSchema>) => R | Promise<R>,
190
+ opts: MutatingRpcOpts & { inputSchema: InputSchema; outbox: true },
191
+ ): RemoteFunction<
192
+ StandardSchemaV1.InferInput<InputSchema>,
193
+ SuccessBody<R>,
194
+ InferredErrors<R>,
195
+ true
196
+ >
197
+ <F extends RpcFn>(fn: F, opts: MutatingRpcOpts & { outbox: true }): RpcOf<F, true>
198
+ }
199
+
111
200
  /*
112
201
  The mutating helpers (POST/PUT/PATCH/DELETE). A durable (`outbox`) call is a normal
113
202
  RemoteFunction — it throws exactly like a non-durable one and only parks the request as a
114
203
  side-effect on an unreachable server — so there is no separate return shape; `outbox` rides
115
204
  MutatingRpcOpts and `rpc.outbox` exposes the queue. The distinct opts base is what makes
116
- `outbox` legal here and a compile error on the read helpers.
205
+ `outbox` legal here and a compile error on the read helpers; the durable overloads then set
206
+ the return type's `Durable` bit so `rpc.outbox` is present without an optional chain.
117
207
  */
118
- export type MutatingRpcHelper = RpcHelperOf<MutatingRpcOpts>
208
+ export type MutatingRpcHelper = DurableMutatingRpcHelper & RpcHelperOf<MutatingRpcOpts>
@@ -0,0 +1,18 @@
1
+ import type { ErrorSpec } from '../../../shared/types/ErrorSpec.ts'
2
+
3
+ /*
4
+ A `Response` branded with the typed error a handler RETURNED — the name it
5
+ travels as (`$abideError`) plus its spec entry (`{ status, data? }`). The rpc
6
+ helper reads this brand off the handler's inferred return union to build the
7
+ function's `Errors` surface, so `rpc.isError(e, 'name')` narrows `.kind` / `.data`
8
+ with no `errors:` option to declare.
9
+
10
+ Unlike `TypedResponse`'s `__body?` (optional, so a bare `new Response()` stays
11
+ assignable), the brand here is REQUIRED: it's the discriminant that tells the
12
+ helper an error branch from a success branch (the success body is extracted from
13
+ the non-error members, the error spec from these). The runtime value is a plain
14
+ serialized Response cast to this type — the brand is phantom, never assigned.
15
+ */
16
+ export type TypedError<Name extends string, Entry extends ErrorSpec[string]> = Response & {
17
+ readonly __abideError: { name: Name; entry: Entry }
18
+ }
@@ -1,17 +1,17 @@
1
1
  import type { StandardSchemaV1 } from '../../shared/types/StandardSchemaV1.ts'
2
2
  import type { ValidationErrorData } from '../../shared/types/ValidationErrorData.ts'
3
- import { error } from '../error.ts'
3
+ import { typedErrorResponse } from '../runtime/typedErrorResponse.ts'
4
4
  import { fieldErrorsFromIssues } from './fieldErrorsFromIssues.ts'
5
5
 
6
6
  /*
7
7
  The framework-reserved `validation` typed error a 422 carries: the raw Standard
8
8
  Schema `issues` plus the form-friendly field → first-message map. Routed through
9
- the single `error()` funnel (descriptor form) so it serializes as the same
9
+ the shared `typedErrorResponse` serializer so it emits the same
10
10
  `{ $abideError, data }` body every typed error uses, and so the 422's reason
11
11
  phrase reaches `HttpError.statusText`. The client parses it back onto
12
12
  `HttpError.kind = 'validation'` / `.data: ValidationErrorData`.
13
13
  */
14
14
  export function validationError(issues: readonly StandardSchemaV1.Issue[]): Response {
15
15
  const data: ValidationErrorData = { issues, fields: fieldErrorsFromIssues(issues) }
16
- return error({ $abideError: 'validation', status: 422, data })
16
+ return typedErrorResponse('validation', 422, data)
17
17
  }
@@ -0,0 +1,22 @@
1
+ /*
2
+ Standard reason phrases for the statuses abide sets explicitly. Bun's `Response`
3
+ does not populate `statusText` from the status code, so there's no platform table
4
+ to read. Unlisted codes fall back to `HTTP <status>` at the call site.
5
+ */
6
+ export const STATUS_TEXT: Record<number, string> = {
7
+ 400: 'Bad Request',
8
+ 401: 'Unauthorized',
9
+ 403: 'Forbidden',
10
+ 404: 'Not Found',
11
+ 405: 'Method Not Allowed',
12
+ 409: 'Conflict',
13
+ 410: 'Gone',
14
+ 413: 'Content Too Large',
15
+ 422: 'Unprocessable Content',
16
+ 429: 'Too Many Requests',
17
+ 500: 'Internal Server Error',
18
+ 501: 'Not Implemented',
19
+ 502: 'Bad Gateway',
20
+ 503: 'Service Unavailable',
21
+ 504: 'Gateway Timeout',
22
+ }
@@ -50,6 +50,7 @@ import { devHotModuleResponse } from './devHotModuleResponse.ts'
50
50
  import { devReloadResponse } from './devReloadResponse.ts'
51
51
  import { disableIdleTimeoutForStream } from './disableIdleTimeoutForStream.ts'
52
52
  import { gzipResponse } from './gzipResponse.ts'
53
+ import { installAmbientScopeStore } from './installAmbientScopeStore.ts'
53
54
  import { internalErrorResponse } from './internalErrorResponse.ts'
54
55
  import { listenOnOpenPort } from './listenOnOpenPort.ts'
55
56
  import { logExposedSurfaces } from './logExposedSurfaces.ts'
@@ -159,6 +160,12 @@ export async function createServer({
159
160
  HTTP test harness gets the same behaviour as a real boot. elapsedMs is
160
161
  computed at read time so every log line carries a current value.
161
162
  */
163
+ /*
164
+ Key the ambient lexical scope off the per-request ALS store, so concurrent
165
+ async SSR renders don't clobber one shared module global across the inline
166
+ `await`s they suspend on (see installAmbientScopeStore / CURRENT_SCOPE).
167
+ */
168
+ installAmbientScopeStore()
162
169
  setRequestScopeResolver(() => {
163
170
  const store = requestContext.getStore()
164
171
  if (!store) {
@@ -0,0 +1,33 @@
1
+ import { ambientScopeBacking } from '../../ui/runtime/ambientScopeBacking.ts'
2
+ import type { Scope } from '../../ui/types/Scope.ts'
3
+ import { requestContext } from './requestContext.ts'
4
+
5
+ /* Ambient scope when no request is in flight (server boot, a standalone render). */
6
+ let outsideRequest: Scope | undefined
7
+
8
+ /*
9
+ Swaps the ambient-scope backing to one keyed off the per-request AsyncLocalStorage
10
+ store, so `CURRENT_SCOPE.current` isolates per request instead of living in one
11
+ module global (see CURRENT_SCOPE). SSR is partly async — a render `await`s inline
12
+ between its `enterScope`/`exitScope` bracket — and the async context propagates the
13
+ right store across those awaits, so a resumed render reads ITS OWN scope even while
14
+ another request renders concurrently. Called once at server boot (createServer),
15
+ alongside the request-scope resolver install. Reads/writes outside any request fall
16
+ back to a module variable (unchanged behaviour for standalone renders).
17
+ */
18
+ export function installAmbientScopeStore(): void {
19
+ ambientScopeBacking.active = {
20
+ get: () => {
21
+ const store = requestContext.getStore()
22
+ return store ? store.currentScope : outsideRequest
23
+ },
24
+ set: (value) => {
25
+ const store = requestContext.getStore()
26
+ if (store) {
27
+ store.currentScope = value
28
+ } else {
29
+ outsideRequest = value
30
+ }
31
+ },
32
+ }
33
+ }
@@ -74,6 +74,8 @@ export async function snapshotEntryFromCache(
74
74
  statusText: response.statusText,
75
75
  headers: Array.from(response.headers.entries()),
76
76
  body,
77
+ /* Deferred by the SSR resume path → seed the shipped body lazily (no hydration decode). */
78
+ lazy: entry.deferred === true ? true : undefined,
77
79
  }
78
80
  }
79
81
 
@@ -0,0 +1,26 @@
1
+ import { NO_STORE } from '../../shared/CACHE_CONTROL_VALUES.ts'
2
+ import type { TypedResponse } from '../rpc/types/TypedResponse.ts'
3
+ import { STATUS_TEXT } from './STATUS_TEXT.ts'
4
+ import { withResponseDefaults } from './withResponseDefaults.ts'
5
+
6
+ /*
7
+ Serializes a typed error as a `{ $abideError, data }` JSON body at `status`, with
8
+ the status reason phrase as statusText so it reaches `HttpError.statusText` on the
9
+ client (which parses the body back onto `HttpError.kind` / `.data`). `data` of
10
+ `undefined` drops the key (nullary errors). The single serializer shared by the
11
+ `error.typed(...)` constructors and the framework-reserved `validation` error.
12
+ */
13
+ export function typedErrorResponse(
14
+ name: string,
15
+ status: number,
16
+ data: unknown,
17
+ ): TypedResponse<never> {
18
+ return new Response(
19
+ JSON.stringify({ $abideError: name, data }),
20
+ withResponseDefaults(
21
+ { statusText: STATUS_TEXT[status] ?? `HTTP ${status}` },
22
+ { 'Content-Type': 'application/json', 'Cache-Control': NO_STORE },
23
+ status,
24
+ ),
25
+ ) as TypedResponse<never>
26
+ }
@@ -1,5 +1,6 @@
1
1
  import type { CacheStore } from '../../../shared/types/CacheStore.ts'
2
2
  import type { TraceContext } from '../../../shared/types/TraceContext.ts'
3
+ import type { Scope } from '../../../ui/types/Scope.ts'
3
4
 
4
5
  /*
5
6
  Per-request state propagated through AsyncLocalStorage. Every field is
@@ -68,4 +69,12 @@ export type RequestStore = {
68
69
  back to classifying the response themselves.
69
70
  */
70
71
  responseStreaming?: boolean
72
+ /*
73
+ The request's ambient lexical scope during its SSR pass — the backing for
74
+ `CURRENT_SCOPE.current` under the server's ALS-backed holder
75
+ (installAmbientScopeStore). Keeping it per-request isolates the ambient across
76
+ the inline `await`s a render suspends on, so concurrent renders don't clobber
77
+ one shared module global. Undefined until the render enters its first scope.
78
+ */
79
+ currentScope?: Scope
71
80
  }
@@ -5,7 +5,7 @@ Server-Sent Events (text/event-stream) — each frame becomes one
5
5
  generator over plain HTTP so EventSource (or `tail(fn.stream(args))`
6
6
  on the client) can consume it frame-by-frame.
7
7
 
8
- export const orderFeed = GET<Args>((args) =>
8
+ export const orderFeed = GET((args: Args) =>
9
9
  sse(async function* () {
10
10
  for await (const order of db.watchOrders(args)) yield order
11
11
  }())
@@ -9,7 +9,7 @@ export class HttpError extends Error {
9
9
  readonly status: number
10
10
  readonly statusText: string
11
11
  readonly response: Response
12
- /* Set when the handler returned a typed error (`error(errors.x(...))`) or a
12
+ /* Set when the handler returned a typed error (`error.typed(...)` constructor) or a
13
13
  validation 422: `kind` is the declared error name (or 'validation'), `data` the
14
14
  payload it carried — parsed off the `{ $abideError, data }` body by decodeResponse.
15
15
  `data` is typed `unknown` (a throw can't carry the rpc's per-kind type to the