@abide/abide 0.44.1 → 0.46.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.
- package/AGENTS.md +8 -6
- package/CHANGELOG.md +56 -0
- package/README.md +171 -181
- package/package.json +2 -1
- package/src/lib/server/error.ts +48 -53
- package/src/lib/server/json.ts +4 -3
- package/src/lib/server/jsonl.ts +1 -1
- package/src/lib/server/rpc/defineRpc.ts +1 -6
- package/src/lib/server/rpc/types/RemoteHandler.ts +5 -7
- package/src/lib/server/rpc/types/RpcHelper.ts +125 -35
- package/src/lib/server/rpc/types/TypedError.ts +18 -0
- package/src/lib/server/rpc/validationError.ts +3 -3
- package/src/lib/server/runtime/STATUS_TEXT.ts +22 -0
- package/src/lib/server/runtime/createServer.ts +7 -0
- package/src/lib/server/runtime/installAmbientScopeStore.ts +33 -0
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -0
- package/src/lib/server/runtime/typedErrorResponse.ts +26 -0
- package/src/lib/server/runtime/types/RequestStore.ts +9 -0
- package/src/lib/server/sse.ts +1 -1
- package/src/lib/shared/DEFER_MIN_ARRAY_LENGTH.ts +14 -0
- package/src/lib/shared/HttpError.ts +1 -1
- package/src/lib/shared/cache.ts +26 -4
- package/src/lib/shared/cacheEntryFromSnapshot.ts +21 -1
- package/src/lib/shared/cacheKeyOf.ts +7 -0
- package/src/lib/shared/cacheKeyStore.ts +8 -0
- package/src/lib/shared/httpErrorFor.ts +1 -1
- package/src/lib/shared/recordCacheKey.ts +6 -0
- package/src/lib/shared/toTagSet.ts +3 -3
- package/src/lib/shared/types/CacheEntry.ts +15 -0
- package/src/lib/shared/types/CacheOptions.ts +5 -5
- package/src/lib/shared/types/CacheSnapshotEntry.ts +5 -0
- package/src/lib/shared/types/ErrorSpec.ts +6 -6
- package/src/lib/shared/types/RemoteFunction.ts +8 -4
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/generateBuild.ts +5 -1
- package/src/lib/ui/compile/generateSSR.ts +10 -3
- package/src/lib/ui/compile/parseTemplate.ts +14 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +5 -0
- package/src/lib/ui/createScope.ts +11 -4
- package/src/lib/ui/deferResume.ts +36 -0
- package/src/lib/ui/dom/appendText.ts +19 -4
- package/src/lib/ui/dom/awaitBlock.ts +99 -26
- package/src/lib/ui/dom/eachAsync.ts +11 -18
- package/src/lib/ui/dom/firstElementBetween.ts +14 -0
- package/src/lib/ui/dom/mountChild.ts +5 -2
- package/src/lib/ui/dom/mountRange.ts +75 -0
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/matchRoute.ts +18 -1
- package/src/lib/ui/remoteProxy.ts +11 -1
- package/src/lib/ui/renderToStream.ts +13 -8
- package/src/lib/ui/resumeSeedScript.ts +2 -2
- package/src/lib/ui/runtime/CURRENT_SCOPE.ts +14 -1
- package/src/lib/ui/runtime/RESUME.ts +6 -0
- package/src/lib/ui/runtime/ambientScopeBacking.ts +26 -0
- package/src/lib/ui/runtime/createEffectNode.ts +7 -1
- package/src/lib/ui/runtime/flushEffects.ts +18 -0
- package/src/lib/ui/runtime/generationGuard.ts +40 -0
- package/src/lib/ui/runtime/runNode.ts +9 -1
- package/src/lib/ui/runtime/scheduleWake.ts +28 -0
- package/src/lib/ui/runtime/types/SsrRender.ts +4 -3
- package/src/lib/ui/runtime/whenIdle.ts +21 -0
- package/src/lib/ui/runtime/whenVisible.ts +105 -0
- package/src/lib/ui/scope.ts +6 -8
- package/src/lib/ui/tryEncodeResume.ts +5 -2
- package/src/lib/ui/types/Scope.ts +3 -4
- package/src/lib/server/rpc/buildErrorConstructors.ts +0 -19
- package/src/lib/shared/types/ErrorConstructors.ts +0 -17
- package/src/lib/shared/types/ErrorDescriptor.ts +0 -10
package/src/lib/server/error.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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 })
|
package/src/lib/server/json.ts
CHANGED
|
@@ -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
|
|
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 —
|
|
18
|
-
|
|
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.
|
package/src/lib/server/jsonl.ts
CHANGED
|
@@ -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
|
|
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
|
|
121
|
+
() => handler(args as Args) as unknown as Response,
|
|
127
122
|
)
|
|
128
123
|
}
|
|
129
124
|
|
|
@@ -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
|
|
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 {
|
|
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 / …).
|
|
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>`.
|
|
42
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
`
|
|
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
|
-
|
|
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:
|
|
81
|
-
StandardSchemaV1.InferOutput<InputSchema> &
|
|
82
|
-
|
|
83
|
-
|
|
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>,
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
>(
|
|
96
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
}
|
package/src/lib/server/sse.ts
CHANGED
|
@@ -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
|
|
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
|
}())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The array-length above which a blocking `{#await cache()}` value is worth deferring (shipped
|
|
3
|
+
inert, materialized on idle) rather than inlined and hydrated eagerly at boot. Deferral trades
|
|
4
|
+
a live boot for a cheaper one; below this many rows the boot decode is cheap enough that the
|
|
5
|
+
trade doesn't pay, so the block stays eager — fully interactive from the first frame, no wake
|
|
6
|
+
needed. Only genuinely large grids cross it and take the inert-then-idle-wake path.
|
|
7
|
+
|
|
8
|
+
An O(1) `.length` heuristic (measured on the resolved value, before serialization): array-shaped
|
|
9
|
+
cache reads — lists, grids, tables — are the payloads big enough to matter, and the ones the
|
|
10
|
+
inert path was built for. Non-array values never defer. A deliberately generous default: with
|
|
11
|
+
idle-wake, a deferred block is interactive anyway (live before a human acts), so this is a
|
|
12
|
+
boot-performance knob, not a correctness one — set high so eager-and-simple is the common case.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFER_MIN_ARRAY_LENGTH = 500
|
|
@@ -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
|
|
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
|