@rsc-kit/core 0.8.0 → 0.10.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/dist/action.d.ts +21 -0
- package/dist/action.js +67 -36
- package/dist/action.js.map +1 -1
- package/dist/apiPrerender.d.ts +25 -0
- package/dist/apiPrerender.js +195 -0
- package/dist/apiPrerender.js.map +1 -0
- package/dist/host.d.ts +12 -0
- package/dist/host.js +140 -2
- package/dist/host.js.map +1 -1
- package/dist/js/RouteErrorBoundary.d.ts +36 -0
- package/dist/js/RouteErrorBoundary.js +43 -0
- package/dist/js/RouteErrorBoundary.js.map +1 -0
- package/dist/js/queryClient.js +21 -1
- package/dist/js/queryClient.js.map +1 -1
- package/dist/manifest.d.ts +33 -0
- package/dist/manifest.js.map +1 -1
- package/dist/metadata.d.ts +69 -0
- package/dist/metadata.js +17 -0
- package/dist/metadata.js.map +1 -0
- package/dist/notFound.d.ts +24 -0
- package/dist/notFound.js +107 -0
- package/dist/notFound.js.map +1 -0
- package/dist/prerender.d.ts +48 -4
- package/dist/prerender.js +79 -9
- package/dist/prerender.js.map +1 -1
- package/dist/query.d.ts +23 -0
- package/dist/query.js +45 -3
- package/dist/query.js.map +1 -1
- package/dist/redirect.d.ts +8 -0
- package/dist/redirect.js +11 -1
- package/dist/redirect.js.map +1 -1
- package/dist/request.d.ts +7 -0
- package/dist/request.js +31 -6
- package/dist/request.js.map +1 -1
- package/dist/routeSchema.d.ts +115 -0
- package/dist/routeSchema.js +182 -0
- package/dist/routeSchema.js.map +1 -0
- package/dist/routing.d.ts +20 -1
- package/dist/routing.js +30 -7
- package/dist/routing.js.map +1 -1
- package/dist/vite.js +594 -34
- package/dist/vite.js.map +1 -1
- package/package.json +18 -6
- package/dist/types.d.ts +0 -82
package/dist/action.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type StandardSchemaV1 } from './js/standardSchema.js';
|
|
2
|
+
import { type QueryOptions } from './query.js';
|
|
2
3
|
/** What an action answers with. Exactly one of the three is set. */
|
|
3
4
|
export interface ActionResult<Data> {
|
|
4
5
|
/** What the handler returned. */
|
|
@@ -74,6 +75,26 @@ export interface ActionBuilder<Ctx extends Record<string, unknown>, Input> {
|
|
|
74
75
|
input: Input;
|
|
75
76
|
ctx: Ctx;
|
|
76
77
|
}) => Promise<Data> | Data): (input?: unknown) => Promise<ActionResult<Data>>;
|
|
78
|
+
/**
|
|
79
|
+
* The body of a READ, sharing this client's middleware and schema.
|
|
80
|
+
*
|
|
81
|
+
* export const getPosts = client.input(filter).query(async ({ input, ctx }) =>
|
|
82
|
+
* db.posts(ctx.user.id, input))
|
|
83
|
+
*
|
|
84
|
+
* The same builder as `handler`, and deliberately so: an app configures its
|
|
85
|
+
* auth check and its error reporting once, and both a mutation and a read go
|
|
86
|
+
* through them.
|
|
87
|
+
*
|
|
88
|
+
* It fails differently, though, and that is not an oversight. An action
|
|
89
|
+
* RETURNS its failures because React serialises a rejection opaquely. A query
|
|
90
|
+
* is handed to a cache library as a fetcher, and every one of them reports
|
|
91
|
+
* failure by rejection — so this returns the data directly and throws, and
|
|
92
|
+
* the endpoint carries the message across for it.
|
|
93
|
+
*/
|
|
94
|
+
query<Data>(fn: (args: {
|
|
95
|
+
input: Input;
|
|
96
|
+
ctx: Ctx;
|
|
97
|
+
}) => Promise<Data> | Data, options?: QueryOptions): (input?: unknown) => Promise<Data>;
|
|
77
98
|
}
|
|
78
99
|
export interface ActionClientOptions {
|
|
79
100
|
/**
|
package/dist/action.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// page may render. This wraps one action. They are different questions: an
|
|
21
21
|
// action is reachable without any page, which is why it defends itself.
|
|
22
22
|
import { validateWith } from './js/standardSchema.js';
|
|
23
|
+
import { markQuery, QueryValidationError } from './query.js';
|
|
23
24
|
/**
|
|
24
25
|
* A middleware that neither continued nor refused.
|
|
25
26
|
*
|
|
@@ -92,6 +93,52 @@ function fromFormData(body) {
|
|
|
92
93
|
export function createActionClient(options = {}) {
|
|
93
94
|
const report = options.onError ?? (() => GENERIC);
|
|
94
95
|
function build(middlewares, schema) {
|
|
96
|
+
/**
|
|
97
|
+
* Validate, run the chain, call the body.
|
|
98
|
+
*
|
|
99
|
+
* Shared by both terminals, which is the point of putting a read on this
|
|
100
|
+
* builder at all: one set of middleware, one schema, one place the auth
|
|
101
|
+
* check lives. Refusals leave by throwing, and each terminal decides what
|
|
102
|
+
* that should look like from the outside.
|
|
103
|
+
*/
|
|
104
|
+
const pipeline = async (raw, fn) => {
|
|
105
|
+
const value = raw instanceof FormData ? fromFormData(raw) : raw;
|
|
106
|
+
if (schema) {
|
|
107
|
+
const invalid = await validateWith(schema, value);
|
|
108
|
+
if (invalid)
|
|
109
|
+
throw new ActionValidationError(invalid);
|
|
110
|
+
}
|
|
111
|
+
const parsed = schema
|
|
112
|
+
? (await schema['~standard'].validate(value)).value
|
|
113
|
+
: value;
|
|
114
|
+
// Composed inside-out so the first `use` is the outermost — it sees the
|
|
115
|
+
// others run, which is what makes timing and cleanup possible rather
|
|
116
|
+
// than only checks.
|
|
117
|
+
let ctx = {};
|
|
118
|
+
let index = 0;
|
|
119
|
+
const run = async () => {
|
|
120
|
+
const middleware = middlewares[index++];
|
|
121
|
+
if (!middleware)
|
|
122
|
+
return await fn({ input: parsed, ctx: ctx });
|
|
123
|
+
let continued = false;
|
|
124
|
+
const result = await middleware({
|
|
125
|
+
ctx,
|
|
126
|
+
next: (async (opts) => {
|
|
127
|
+
continued = true;
|
|
128
|
+
ctx = { ...ctx, ...(opts?.ctx ?? {}) };
|
|
129
|
+
return { ctx, value: await run() };
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
// Silence is not refusal. A middleware that neither called next() nor
|
|
133
|
+
// threw has done nothing, and guessing which it meant turns a
|
|
134
|
+
// forgotten `return` into a check that quietly passes.
|
|
135
|
+
if (!continued) {
|
|
136
|
+
throw new ActionMisuse('A middleware returned without calling next(). Call it to continue, or throw to refuse.');
|
|
137
|
+
}
|
|
138
|
+
return result?.value;
|
|
139
|
+
};
|
|
140
|
+
return await run();
|
|
141
|
+
};
|
|
95
142
|
return {
|
|
96
143
|
use(middleware) {
|
|
97
144
|
return build([...middlewares, middleware], schema);
|
|
@@ -102,42 +149,7 @@ export function createActionClient(options = {}) {
|
|
|
102
149
|
handler(fn) {
|
|
103
150
|
return async (raw) => {
|
|
104
151
|
try {
|
|
105
|
-
|
|
106
|
-
if (schema) {
|
|
107
|
-
const invalid = await validateWith(schema, value);
|
|
108
|
-
if (invalid)
|
|
109
|
-
return { validationErrors: invalid };
|
|
110
|
-
}
|
|
111
|
-
const parsed = schema
|
|
112
|
-
? (await schema['~standard'].validate(value)).value
|
|
113
|
-
: value;
|
|
114
|
-
// Composed inside-out so the first `use` is the outermost — it
|
|
115
|
-
// sees the others run, which is what makes timing and cleanup
|
|
116
|
-
// possible rather than only checks.
|
|
117
|
-
let ctx = {};
|
|
118
|
-
let index = 0;
|
|
119
|
-
const run = async () => {
|
|
120
|
-
const middleware = middlewares[index++];
|
|
121
|
-
if (!middleware)
|
|
122
|
-
return await fn({ input: parsed, ctx });
|
|
123
|
-
let continued = false;
|
|
124
|
-
const result = await middleware({
|
|
125
|
-
ctx,
|
|
126
|
-
next: (async (opts) => {
|
|
127
|
-
continued = true;
|
|
128
|
-
ctx = { ...ctx, ...(opts?.ctx ?? {}) };
|
|
129
|
-
return { ctx, value: await run() };
|
|
130
|
-
}),
|
|
131
|
-
});
|
|
132
|
-
// Silence is not refusal. A middleware that neither called
|
|
133
|
-
// next() nor threw has done nothing, and guessing which it meant
|
|
134
|
-
// turns a forgotten `return` into a check that quietly passes.
|
|
135
|
-
if (!continued) {
|
|
136
|
-
throw new ActionMisuse('A middleware returned without calling next(). Call it to continue, or throw to refuse.');
|
|
137
|
-
}
|
|
138
|
-
return result?.value;
|
|
139
|
-
};
|
|
140
|
-
return { data: (await run()) };
|
|
152
|
+
return { data: (await pipeline(raw, fn)) };
|
|
141
153
|
}
|
|
142
154
|
catch (error) {
|
|
143
155
|
// Past onError deliberately — see ActionMisuse.
|
|
@@ -150,6 +162,25 @@ export function createActionClient(options = {}) {
|
|
|
150
162
|
}
|
|
151
163
|
};
|
|
152
164
|
},
|
|
165
|
+
query(fn, options) {
|
|
166
|
+
const read = async (raw) => {
|
|
167
|
+
try {
|
|
168
|
+
return await pipeline(raw, fn);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error instanceof ActionMisuse)
|
|
172
|
+
throw error;
|
|
173
|
+
// Thrown, not returned. A cache library reports failure by
|
|
174
|
+
// rejection, so a query that answered with an error-shaped object
|
|
175
|
+
// would look like a successful read of something odd.
|
|
176
|
+
if (isActionValidationError(error)) {
|
|
177
|
+
throw new QueryValidationError(error.errors);
|
|
178
|
+
}
|
|
179
|
+
throw new Error(report(error));
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
return markQuery(read, options);
|
|
183
|
+
},
|
|
153
184
|
};
|
|
154
185
|
}
|
|
155
186
|
return build([], null);
|
package/dist/action.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action.js","sourceRoot":"","sources":["../src/action.ts"],"names":[],"mappings":"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","sourcesContent":["// Server actions with a schema, middleware, and types that follow from both.\n//\n// export const action = createActionClient({ onError: report })\n//\n// export const createPost = action\n// .use(async ({ next }) => next({ ctx: { user: await currentUser() } }))\n// .input(z.object({ title: z.string().min(3) }))\n// .handler(async ({ input, ctx }) => savePost(ctx.user.id, input.title))\n//\n// `input` is typed from the schema and `ctx` from every middleware that ran, so\n// the handler is checked against both without either being written down twice.\n//\n// The built action RETURNS its failures rather than throwing them, and that is\n// not a style choice. React serialises a rejected server action opaquely —\n// production strips the message and leaves a digest — so a thrown validation\n// error reaches the browser as \"an error occurred\" and the fields it named are\n// gone. A returned object crosses the boundary intact.\n//\n// Distinct from `middleware.ts` in a route directory, which decides whether a\n// page may render. This wraps one action. They are different questions: an\n// action is reachable without any page, which is why it defends itself.\n\nimport { validateWith, type StandardSchemaV1 } from './js/standardSchema.js'\n\n/** What an action answers with. Exactly one of the three is set. */\nexport interface ActionResult<Data> {\n /** What the handler returned. */\n data?: Data\n /** Field name to messages, in the shape a form already renders. */\n validationErrors?: Record<string, string[]>\n /** Something else went wrong, reduced to a message the browser may see. */\n serverError?: string\n}\n\n/**\n * A middleware that neither continued nor refused.\n *\n * Never reported through `onError`: that reduces an error to a message for the\n * browser, and this one is for whoever wrote the middleware. A check that\n * forgot to call `next()` would otherwise look exactly like a check that\n * passed.\n */\nexport class ActionMisuse extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'ActionMisuse'\n }\n}\n\n/** Refuse from inside a handler, naming the fields. */\n/**\n * Marks a refusal so it survives a bundle seam.\n *\n * A property rather than `instanceof`, for the reason this project keeps\n * running into: an app's actions are bundled separately from the engine, so\n * each gets its own copy of this module and its own copy of the class.\n * `instanceof` compares identity across that seam and is simply false — and\n * the refusal is then reported as a server error, so the form shows \"Something\n * went wrong\" instead of naming the fields. Everything works; nothing logs.\n *\n * Symbol.for, so the two copies agree on the key as well as the value.\n */\nconst VALIDATION_MARK = Symbol.for('@rsc-kit/core.action-validation')\n\nexport class ActionValidationError extends Error {\n public readonly errors: Record<string, string[]>\n\n constructor(errors: Record<string, string[]>) {\n super('Validation failed')\n this.name = 'ActionValidationError'\n this.errors = errors\n ;(this as unknown as Record<symbol, boolean>)[VALIDATION_MARK] = true\n }\n}\n\n/** Whether this is a refusal, whichever copy of the class built it. */\nexport function isActionValidationError(error: unknown): error is ActionValidationError {\n return (\n typeof error === 'object' &&\n error !== null &&\n (error as Record<symbol, unknown>)[VALIDATION_MARK] === true\n )\n}\n\n/**\n * Fail with field errors the form can show.\n *\n * For what a schema cannot know — a name already taken, a balance too low.\n * Throws, so the handler stops where it is; the action turns it into a\n * returned result on the way out.\n */\nexport function fieldErrors(errors: Record<string, string[] | string>): never {\n const normalised: Record<string, string[]> = {}\n\n for (const [field, message] of Object.entries(errors)) {\n normalised[field] = Array.isArray(message) ? message : [message]\n }\n\n throw new ActionValidationError(normalised)\n}\n\n/**\n * What `next()` hands back, carrying what the step added.\n *\n * The context a middleware contributes cannot be inferred from the arguments\n * it passes to `next` — TypeScript infers from a function's return, not from a\n * call inside it. So `next` returns this, the middleware returns it, and the\n * addition is read off the middleware's own return type.\n */\nexport interface MiddlewareResult<Extra> {\n readonly ctx: Extra\n readonly value: unknown\n}\n\n/**\n * A step that runs before the handler.\n *\n * It calls `next` to continue, optionally adding to the context, and what it\n * adds shows up in the handler's types. Returning without calling `next` — or\n * throwing — stops the action, which is how a check refuses.\n */\nexport type ActionMiddleware<Ctx, Extra extends Record<string, unknown>> = (args: {\n ctx: Ctx\n next: <E extends Record<string, unknown> = Record<string, never>>(\n opts?: { ctx?: E },\n ) => Promise<MiddlewareResult<E>>\n}) => Promise<MiddlewareResult<Extra>>\n\ntype Output<S> = S extends StandardSchemaV1<unknown, infer O> ? O : never\n\nexport interface ActionBuilder<Ctx extends Record<string, unknown>, Input> {\n /** Add a step, and whatever context it contributes. */\n use<Extra extends Record<string, unknown> = Record<string, never>>(\n middleware: (args: {\n ctx: Ctx\n next: <E extends Record<string, unknown> = Record<string, never>>(\n opts?: { ctx?: E },\n ) => Promise<MiddlewareResult<E>>\n }) => Promise<MiddlewareResult<Extra>>,\n ): ActionBuilder<Ctx & Extra, Input>\n /** Parse and check what the caller sent. The handler's `input` follows. */\n input<S extends StandardSchemaV1>(schema: S): ActionBuilder<Ctx, Output<S>>\n /** The body. */\n handler<Data>(\n fn: (args: { input: Input; ctx: Ctx }) => Promise<Data> | Data,\n ): (input?: unknown) => Promise<ActionResult<Data>>\n}\n\nexport interface ActionClientOptions {\n /**\n * What the browser is told when something unexpected throws.\n *\n * Everything reaching here is a bug or an outage, and its message may say\n * more than a stranger should see — a query, a path, a host. Returning a\n * fixed string is the safe default; return the message only for errors you\n * raised deliberately.\n */\n onError?: (error: unknown) => string\n}\n\nconst GENERIC = 'Something went wrong.'\n\n/** FormData in, a plain object out — what a schema expects to be handed. */\nfunction fromFormData(body: FormData): Record<string, unknown> {\n const out: Record<string, unknown> = {}\n\n for (const key of new Set(body.keys())) {\n const values = body.getAll(key)\n\n // One value stays a value. Several stay several — a multi-select that\n // collapsed to its last entry would be a silent data loss.\n out[key] = values.length > 1 ? values : values[0]\n }\n\n return out\n}\n\nexport function createActionClient(\n options: ActionClientOptions = {},\n): ActionBuilder<Record<never, never>, undefined> {\n const report = options.onError ?? (() => GENERIC)\n\n function build<Ctx extends Record<string, unknown>, Input>(\n middlewares: ActionMiddleware<never, never>[],\n schema: StandardSchemaV1 | null,\n ): ActionBuilder<Ctx, Input> {\n return {\n use(middleware) {\n return build([...middlewares, middleware as never], schema) as never\n },\n input(next) {\n return build(middlewares, next) as never\n },\n handler(fn) {\n return async (raw?: unknown) => {\n try {\n const value = raw instanceof FormData ? fromFormData(raw) : raw\n\n if (schema) {\n const invalid = await validateWith(schema, value)\n\n if (invalid) return { validationErrors: invalid }\n }\n\n const parsed = schema\n ? ((await schema['~standard'].validate(value)).value as Input)\n : (value as Input)\n\n // Composed inside-out so the first `use` is the outermost — it\n // sees the others run, which is what makes timing and cleanup\n // possible rather than only checks.\n let ctx = {} as Ctx\n let index = 0\n\n const run = async (): Promise<unknown> => {\n const middleware = middlewares[index++]\n\n if (!middleware) return await fn({ input: parsed, ctx })\n\n let continued = false\n\n const result = await (middleware as unknown as ActionMiddleware<Ctx, Record<string, unknown>>)({\n ctx,\n next: (async (opts?: { ctx?: Record<string, unknown> }) => {\n continued = true\n ctx = { ...ctx, ...(opts?.ctx ?? {}) } as Ctx\n\n return { ctx, value: await run() }\n }) as never,\n })\n\n // Silence is not refusal. A middleware that neither called\n // next() nor threw has done nothing, and guessing which it meant\n // turns a forgotten `return` into a check that quietly passes.\n if (!continued) {\n throw new ActionMisuse(\n 'A middleware returned without calling next(). Call it to continue, or throw to refuse.',\n )\n }\n\n return (result as { value?: unknown })?.value\n }\n\n return { data: (await run()) as Awaited<ReturnType<typeof fn>> }\n } catch (error) {\n // Past onError deliberately — see ActionMisuse.\n if (error instanceof ActionMisuse) throw error\n\n if (isActionValidationError(error)) {\n return { validationErrors: error.errors }\n }\n\n return { serverError: report(error) }\n }\n }\n },\n }\n }\n\n return build([], null)\n}\n"]}
|
|
1
|
+
{"version":3,"file":"action.js","sourceRoot":"","sources":["../src/action.ts"],"names":[],"mappings":"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","sourcesContent":["// Server actions with a schema, middleware, and types that follow from both.\n//\n// export const action = createActionClient({ onError: report })\n//\n// export const createPost = action\n// .use(async ({ next }) => next({ ctx: { user: await currentUser() } }))\n// .input(z.object({ title: z.string().min(3) }))\n// .handler(async ({ input, ctx }) => savePost(ctx.user.id, input.title))\n//\n// `input` is typed from the schema and `ctx` from every middleware that ran, so\n// the handler is checked against both without either being written down twice.\n//\n// The built action RETURNS its failures rather than throwing them, and that is\n// not a style choice. React serialises a rejected server action opaquely —\n// production strips the message and leaves a digest — so a thrown validation\n// error reaches the browser as \"an error occurred\" and the fields it named are\n// gone. A returned object crosses the boundary intact.\n//\n// Distinct from `middleware.ts` in a route directory, which decides whether a\n// page may render. This wraps one action. They are different questions: an\n// action is reachable without any page, which is why it defends itself.\n\nimport { validateWith, type StandardSchemaV1 } from './js/standardSchema.js'\nimport { markQuery, QueryValidationError, type QueryOptions } from './query.js'\n\n/** What an action answers with. Exactly one of the three is set. */\nexport interface ActionResult<Data> {\n /** What the handler returned. */\n data?: Data\n /** Field name to messages, in the shape a form already renders. */\n validationErrors?: Record<string, string[]>\n /** Something else went wrong, reduced to a message the browser may see. */\n serverError?: string\n}\n\n/**\n * A middleware that neither continued nor refused.\n *\n * Never reported through `onError`: that reduces an error to a message for the\n * browser, and this one is for whoever wrote the middleware. A check that\n * forgot to call `next()` would otherwise look exactly like a check that\n * passed.\n */\nexport class ActionMisuse extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'ActionMisuse'\n }\n}\n\n/** Refuse from inside a handler, naming the fields. */\n/**\n * Marks a refusal so it survives a bundle seam.\n *\n * A property rather than `instanceof`, for the reason this project keeps\n * running into: an app's actions are bundled separately from the engine, so\n * each gets its own copy of this module and its own copy of the class.\n * `instanceof` compares identity across that seam and is simply false — and\n * the refusal is then reported as a server error, so the form shows \"Something\n * went wrong\" instead of naming the fields. Everything works; nothing logs.\n *\n * Symbol.for, so the two copies agree on the key as well as the value.\n */\nconst VALIDATION_MARK = Symbol.for('@rsc-kit/core.action-validation')\n\nexport class ActionValidationError extends Error {\n public readonly errors: Record<string, string[]>\n\n constructor(errors: Record<string, string[]>) {\n super('Validation failed')\n this.name = 'ActionValidationError'\n this.errors = errors\n ;(this as unknown as Record<symbol, boolean>)[VALIDATION_MARK] = true\n }\n}\n\n/** Whether this is a refusal, whichever copy of the class built it. */\nexport function isActionValidationError(error: unknown): error is ActionValidationError {\n return (\n typeof error === 'object' &&\n error !== null &&\n (error as Record<symbol, unknown>)[VALIDATION_MARK] === true\n )\n}\n\n/**\n * Fail with field errors the form can show.\n *\n * For what a schema cannot know — a name already taken, a balance too low.\n * Throws, so the handler stops where it is; the action turns it into a\n * returned result on the way out.\n */\nexport function fieldErrors(errors: Record<string, string[] | string>): never {\n const normalised: Record<string, string[]> = {}\n\n for (const [field, message] of Object.entries(errors)) {\n normalised[field] = Array.isArray(message) ? message : [message]\n }\n\n throw new ActionValidationError(normalised)\n}\n\n/**\n * What `next()` hands back, carrying what the step added.\n *\n * The context a middleware contributes cannot be inferred from the arguments\n * it passes to `next` — TypeScript infers from a function's return, not from a\n * call inside it. So `next` returns this, the middleware returns it, and the\n * addition is read off the middleware's own return type.\n */\nexport interface MiddlewareResult<Extra> {\n readonly ctx: Extra\n readonly value: unknown\n}\n\n/**\n * A step that runs before the handler.\n *\n * It calls `next` to continue, optionally adding to the context, and what it\n * adds shows up in the handler's types. Returning without calling `next` — or\n * throwing — stops the action, which is how a check refuses.\n */\nexport type ActionMiddleware<Ctx, Extra extends Record<string, unknown>> = (args: {\n ctx: Ctx\n next: <E extends Record<string, unknown> = Record<string, never>>(\n opts?: { ctx?: E },\n ) => Promise<MiddlewareResult<E>>\n}) => Promise<MiddlewareResult<Extra>>\n\ntype Output<S> = S extends StandardSchemaV1<unknown, infer O> ? O : never\n\nexport interface ActionBuilder<Ctx extends Record<string, unknown>, Input> {\n /** Add a step, and whatever context it contributes. */\n use<Extra extends Record<string, unknown> = Record<string, never>>(\n middleware: (args: {\n ctx: Ctx\n next: <E extends Record<string, unknown> = Record<string, never>>(\n opts?: { ctx?: E },\n ) => Promise<MiddlewareResult<E>>\n }) => Promise<MiddlewareResult<Extra>>,\n ): ActionBuilder<Ctx & Extra, Input>\n /** Parse and check what the caller sent. The handler's `input` follows. */\n input<S extends StandardSchemaV1>(schema: S): ActionBuilder<Ctx, Output<S>>\n /** The body. */\n handler<Data>(\n fn: (args: { input: Input; ctx: Ctx }) => Promise<Data> | Data,\n ): (input?: unknown) => Promise<ActionResult<Data>>\n /**\n * The body of a READ, sharing this client's middleware and schema.\n *\n * export const getPosts = client.input(filter).query(async ({ input, ctx }) =>\n * db.posts(ctx.user.id, input))\n *\n * The same builder as `handler`, and deliberately so: an app configures its\n * auth check and its error reporting once, and both a mutation and a read go\n * through them.\n *\n * It fails differently, though, and that is not an oversight. An action\n * RETURNS its failures because React serialises a rejection opaquely. A query\n * is handed to a cache library as a fetcher, and every one of them reports\n * failure by rejection — so this returns the data directly and throws, and\n * the endpoint carries the message across for it.\n */\n query<Data>(\n fn: (args: { input: Input; ctx: Ctx }) => Promise<Data> | Data,\n options?: QueryOptions,\n ): (input?: unknown) => Promise<Data>\n}\n\nexport interface ActionClientOptions {\n /**\n * What the browser is told when something unexpected throws.\n *\n * Everything reaching here is a bug or an outage, and its message may say\n * more than a stranger should see — a query, a path, a host. Returning a\n * fixed string is the safe default; return the message only for errors you\n * raised deliberately.\n */\n onError?: (error: unknown) => string\n}\n\nconst GENERIC = 'Something went wrong.'\n\n/** FormData in, a plain object out — what a schema expects to be handed. */\nfunction fromFormData(body: FormData): Record<string, unknown> {\n const out: Record<string, unknown> = {}\n\n for (const key of new Set(body.keys())) {\n const values = body.getAll(key)\n\n // One value stays a value. Several stay several — a multi-select that\n // collapsed to its last entry would be a silent data loss.\n out[key] = values.length > 1 ? values : values[0]\n }\n\n return out\n}\n\nexport function createActionClient(\n options: ActionClientOptions = {},\n): ActionBuilder<Record<never, never>, undefined> {\n const report = options.onError ?? (() => GENERIC)\n\n function build<Ctx extends Record<string, unknown>, Input>(\n middlewares: ActionMiddleware<never, never>[],\n schema: StandardSchemaV1 | null,\n ): ActionBuilder<Ctx, Input> {\n /**\n * Validate, run the chain, call the body.\n *\n * Shared by both terminals, which is the point of putting a read on this\n * builder at all: one set of middleware, one schema, one place the auth\n * check lives. Refusals leave by throwing, and each terminal decides what\n * that should look like from the outside.\n */\n const pipeline = async (\n raw: unknown,\n fn: (args: { input: never; ctx: never }) => unknown,\n ): Promise<unknown> => {\n const value = raw instanceof FormData ? fromFormData(raw) : raw\n\n if (schema) {\n const invalid = await validateWith(schema, value)\n\n if (invalid) throw new ActionValidationError(invalid)\n }\n\n const parsed = schema\n ? ((await schema['~standard'].validate(value)).value as Input)\n : (value as Input)\n\n // Composed inside-out so the first `use` is the outermost — it sees the\n // others run, which is what makes timing and cleanup possible rather\n // than only checks.\n let ctx = {} as Ctx\n let index = 0\n\n const run = async (): Promise<unknown> => {\n const middleware = middlewares[index++]\n\n if (!middleware) return await fn({ input: parsed as never, ctx: ctx as never })\n\n let continued = false\n\n const result = await (middleware as unknown as ActionMiddleware<Ctx, Record<string, unknown>>)({\n ctx,\n next: (async (opts?: { ctx?: Record<string, unknown> }) => {\n continued = true\n ctx = { ...ctx, ...(opts?.ctx ?? {}) } as Ctx\n\n return { ctx, value: await run() }\n }) as never,\n })\n\n // Silence is not refusal. A middleware that neither called next() nor\n // threw has done nothing, and guessing which it meant turns a\n // forgotten `return` into a check that quietly passes.\n if (!continued) {\n throw new ActionMisuse(\n 'A middleware returned without calling next(). Call it to continue, or throw to refuse.',\n )\n }\n\n return (result as { value?: unknown })?.value\n }\n\n return await run()\n }\n\n return {\n use(middleware) {\n return build([...middlewares, middleware as never], schema) as never\n },\n input(next) {\n return build(middlewares, next) as never\n },\n handler(fn) {\n return async (raw?: unknown) => {\n try {\n return { data: (await pipeline(raw, fn)) as Awaited<ReturnType<typeof fn>> }\n } catch (error) {\n // Past onError deliberately — see ActionMisuse.\n if (error instanceof ActionMisuse) throw error\n\n if (isActionValidationError(error)) {\n return { validationErrors: error.errors }\n }\n\n return { serverError: report(error) }\n }\n }\n },\n query(fn, options) {\n const read = async (raw?: unknown) => {\n try {\n return await pipeline(raw, fn)\n } catch (error) {\n if (error instanceof ActionMisuse) throw error\n\n // Thrown, not returned. A cache library reports failure by\n // rejection, so a query that answered with an error-shaped object\n // would look like a successful read of something odd.\n if (isActionValidationError(error)) {\n throw new QueryValidationError(error.errors)\n }\n\n throw new Error(report(error))\n }\n }\n\n return markQuery(read, options) as (input?: unknown) => Promise<never>\n },\n }\n }\n\n return build([], null)\n}\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RouteManifest } from './manifest.js';
|
|
2
|
+
/** What a stored answer holds. Enough to rebuild the Response exactly. */
|
|
3
|
+
export interface FrozenApiResponse {
|
|
4
|
+
status: number;
|
|
5
|
+
headers: [string, string][];
|
|
6
|
+
body: string;
|
|
7
|
+
}
|
|
8
|
+
/** The file a frozen route is stored as. */
|
|
9
|
+
export declare function apiKey(url: string): string;
|
|
10
|
+
export interface ApiPrerenderResult {
|
|
11
|
+
url: string;
|
|
12
|
+
name: string;
|
|
13
|
+
type: 'frozen' | 'dynamic';
|
|
14
|
+
reason: string | null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Try to answer every api route once, at build time, and store what can be.
|
|
18
|
+
*
|
|
19
|
+
* Sequential rather than parallel: there are usually few of them, each is a
|
|
20
|
+
* function call rather than a React render, and the ones that are going to be
|
|
21
|
+
* dynamic spend the whole budget waiting — which is time, not work.
|
|
22
|
+
*/
|
|
23
|
+
export declare function prerenderApiRoutes(engine: {
|
|
24
|
+
handleApiRoute?: (n: string, r: Request, p: Record<string, string>, a: string) => Promise<Response>;
|
|
25
|
+
}, manifest: RouteManifest, write: (name: string, contents: string) => Promise<void>): Promise<ApiPrerenderResult[]>;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Freezing an api route the way a page is frozen.
|
|
2
|
+
//
|
|
3
|
+
// Not as an opt-in flag, deliberately. A page is stored by default and opts
|
|
4
|
+
// OUT by touching the request — connection(), cookies(), headers() all suspend
|
|
5
|
+
// at build time because there is no request there, and that is what marks the
|
|
6
|
+
// page dynamic. A route works the same way and for the same reason: one model
|
|
7
|
+
// to learn rather than two, and the honest default in both cases is "the build
|
|
8
|
+
// tried, and here is what it found".
|
|
9
|
+
//
|
|
10
|
+
// GET only. Everything else is a method a caller may not repeat, and a stored
|
|
11
|
+
// answer to a POST is a stored answer to something that was supposed to happen
|
|
12
|
+
// once.
|
|
13
|
+
import { pathKey } from './prerender.js';
|
|
14
|
+
import { requestReadBy, withRequest } from './request.js';
|
|
15
|
+
import { allowFor } from './routing.js';
|
|
16
|
+
/**
|
|
17
|
+
* How long a route gets to answer before it is called dynamic.
|
|
18
|
+
*
|
|
19
|
+
* A route that reads the request does not fail here — it never settles, because
|
|
20
|
+
* the accessors suspend forever with no request to read. So the budget is what
|
|
21
|
+
* turns "waiting" into an answer, and it only has to be long enough for a route
|
|
22
|
+
* that was going to finish.
|
|
23
|
+
*/
|
|
24
|
+
const BUDGET_MS = 2_000;
|
|
25
|
+
/** The file a frozen route is stored as. */
|
|
26
|
+
export function apiKey(url) {
|
|
27
|
+
return `${pathKey(url)}.api.json`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Reading anything here means the answer depends on the caller.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately not `url`: the url is the key the answer is stored under, so
|
|
33
|
+
* reading it tells you the same thing on every request that would hit the
|
|
34
|
+
* stored file. The query string is handled by refusing to serve a stored
|
|
35
|
+
* answer to a request that has one, which needs no detection at all.
|
|
36
|
+
*/
|
|
37
|
+
const PER_CALLER = new Set([
|
|
38
|
+
'headers',
|
|
39
|
+
'body',
|
|
40
|
+
'bodyUsed',
|
|
41
|
+
'text',
|
|
42
|
+
'json',
|
|
43
|
+
'formData',
|
|
44
|
+
'arrayBuffer',
|
|
45
|
+
'blob',
|
|
46
|
+
'bytes',
|
|
47
|
+
'signal',
|
|
48
|
+
'referrer',
|
|
49
|
+
'credentials',
|
|
50
|
+
]);
|
|
51
|
+
/**
|
|
52
|
+
* A Request that records what was read out of it.
|
|
53
|
+
*
|
|
54
|
+
* A proxy rather than a subclass because the interesting properties are
|
|
55
|
+
* getters on Request.prototype, and `this` has to stay the real Request or
|
|
56
|
+
* every one of them throws about an illegal invocation.
|
|
57
|
+
*/
|
|
58
|
+
function probeRequest(url, touched) {
|
|
59
|
+
const real = new Request(url, { method: 'GET' });
|
|
60
|
+
return new Proxy(real, {
|
|
61
|
+
get(target, property) {
|
|
62
|
+
if (typeof property === 'string' && PER_CALLER.has(property))
|
|
63
|
+
touched.add(property);
|
|
64
|
+
const value = Reflect.get(target, property, target);
|
|
65
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** The url a route with no parameters answers. */
|
|
70
|
+
function urlFor(route) {
|
|
71
|
+
// A parameterised route has as many urls as there are values, and nothing
|
|
72
|
+
// here knows them. Pages solve this with generateStaticParams; until a route
|
|
73
|
+
// can say the same, one is answered per request.
|
|
74
|
+
if (route.segments.some((segment) => segment.type !== 'static'))
|
|
75
|
+
return null;
|
|
76
|
+
return '/' + route.segments.map((segment) => segment.value).join('/');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* What the build calls a route in its output.
|
|
80
|
+
*
|
|
81
|
+
* The pattern for a parameterised one, spelled the way pages already spell
|
|
82
|
+
* theirs, rather than the module name — a line reading
|
|
83
|
+
* "/app/api/greet/[name]/route" names a file on disk and the rest of the table
|
|
84
|
+
* names urls.
|
|
85
|
+
*/
|
|
86
|
+
function labelFor(route) {
|
|
87
|
+
return ('/' +
|
|
88
|
+
route.segments
|
|
89
|
+
.map((segment) => (segment.type === 'static' ? segment.value : `_${segment.value}_`))
|
|
90
|
+
.join('/'));
|
|
91
|
+
}
|
|
92
|
+
/** Whether a body is text this can store and hand back unchanged. */
|
|
93
|
+
function asText(bytes) {
|
|
94
|
+
try {
|
|
95
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
96
|
+
return text;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Binary. Storable in principle, as base64, at the cost of a third of its
|
|
100
|
+
// size on disk and a decode per request — for a route that is far more
|
|
101
|
+
// likely to be streaming a file it should be serving as a file.
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Try to answer every api route once, at build time, and store what can be.
|
|
107
|
+
*
|
|
108
|
+
* Sequential rather than parallel: there are usually few of them, each is a
|
|
109
|
+
* function call rather than a React render, and the ones that are going to be
|
|
110
|
+
* dynamic spend the whole budget waiting — which is time, not work.
|
|
111
|
+
*/
|
|
112
|
+
export async function prerenderApiRoutes(engine, manifest, write) {
|
|
113
|
+
if (!engine.handleApiRoute || !manifest.apis?.length)
|
|
114
|
+
return [];
|
|
115
|
+
const results = [];
|
|
116
|
+
for (const route of manifest.apis) {
|
|
117
|
+
const said = (type, reason) => {
|
|
118
|
+
results.push({ url: labelFor(route), name: route.name, type, reason });
|
|
119
|
+
};
|
|
120
|
+
if (!route.methods.includes('GET')) {
|
|
121
|
+
said('dynamic', 'no GET to store');
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// A guarded route answers differently depending on who is asking, which is
|
|
125
|
+
// the whole purpose of the guard. Storing one answer and serving it to
|
|
126
|
+
// everyone is how a guard is silently removed.
|
|
127
|
+
if (route.middleware.length > 0) {
|
|
128
|
+
said('dynamic', 'guarded by middleware');
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const url = urlFor(route);
|
|
132
|
+
if (!url) {
|
|
133
|
+
said('dynamic', 'one url per param value, and none are listed');
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const touched = new Set();
|
|
137
|
+
const request = probeRequest('https://prerender.invalid' + url, touched);
|
|
138
|
+
// No request in scope, so headers(), cookies() and connection() suspend
|
|
139
|
+
// forever rather than resolving to whatever the build machine had. The
|
|
140
|
+
// budget below is what turns that into an answer.
|
|
141
|
+
const answered = await withRequest(null, async () => {
|
|
142
|
+
let readBy = [];
|
|
143
|
+
const response = await Promise.race([
|
|
144
|
+
engine
|
|
145
|
+
.handleApiRoute(route.name, request, {}, allowFor(route))
|
|
146
|
+
.then((value) => ({ value }))
|
|
147
|
+
.catch((error) => ({ error })),
|
|
148
|
+
new Promise((resolve) => setTimeout(() => {
|
|
149
|
+
readBy = requestReadBy();
|
|
150
|
+
resolve(null);
|
|
151
|
+
}, BUDGET_MS)),
|
|
152
|
+
]);
|
|
153
|
+
return { response, readBy: readBy.length ? readBy : requestReadBy() };
|
|
154
|
+
});
|
|
155
|
+
if (answered.response === null) {
|
|
156
|
+
const why = answered.readBy.length
|
|
157
|
+
? 'dynamic — called ' + answered.readBy.join(', ')
|
|
158
|
+
: 'did not answer within the build budget';
|
|
159
|
+
said('dynamic', why);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if ('error' in answered.response) {
|
|
163
|
+
// Not a build failure. A route that throws with no request may be doing
|
|
164
|
+
// exactly the right thing — refusing a caller it cannot identify — and
|
|
165
|
+
// refusing the build over it would make that route unbuildable.
|
|
166
|
+
said('dynamic', 'threw without a request');
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (touched.size > 0) {
|
|
170
|
+
said('dynamic', 'reads the request — ' + [...touched].sort().join(', '));
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const response = answered.response.value;
|
|
174
|
+
const body = asText(new Uint8Array(await response.arrayBuffer()));
|
|
175
|
+
if (body === null) {
|
|
176
|
+
said('dynamic', 'answers with bytes rather than text');
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
await write(apiKey(url), JSON.stringify({
|
|
180
|
+
status: response.status,
|
|
181
|
+
// Lower-cased and sorted, so two builds of the same route produce the
|
|
182
|
+
// same bytes. Headers iteration does not promise a case or an order,
|
|
183
|
+
// and a file that differs between builds for no reason defeats
|
|
184
|
+
// content-addressed caching and makes a diff unreadable. Names are
|
|
185
|
+
// case-insensitive, so nothing is lost by picking one.
|
|
186
|
+
headers: [...response.headers]
|
|
187
|
+
.map(([name, value]) => [name.toLowerCase(), value])
|
|
188
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
189
|
+
body,
|
|
190
|
+
}));
|
|
191
|
+
said('frozen', null);
|
|
192
|
+
}
|
|
193
|
+
return results;
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=apiPrerender.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apiPrerender.js","sourceRoot":"","sources":["../src/apiPrerender.ts"],"names":[],"mappings":"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","sourcesContent":["// Freezing an api route the way a page is frozen.\n//\n// Not as an opt-in flag, deliberately. A page is stored by default and opts\n// OUT by touching the request — connection(), cookies(), headers() all suspend\n// at build time because there is no request there, and that is what marks the\n// page dynamic. A route works the same way and for the same reason: one model\n// to learn rather than two, and the honest default in both cases is \"the build\n// tried, and here is what it found\".\n//\n// GET only. Everything else is a method a caller may not repeat, and a stored\n// answer to a POST is a stored answer to something that was supposed to happen\n// once.\n\nimport { pathKey } from './prerender.js'\nimport { requestReadBy, withRequest } from './request.js'\nimport type { ManifestApiRoute, RouteManifest } from './manifest.js'\nimport { allowFor } from './routing.js'\n\n/**\n * How long a route gets to answer before it is called dynamic.\n *\n * A route that reads the request does not fail here — it never settles, because\n * the accessors suspend forever with no request to read. So the budget is what\n * turns \"waiting\" into an answer, and it only has to be long enough for a route\n * that was going to finish.\n */\nconst BUDGET_MS = 2_000\n\n/** What a stored answer holds. Enough to rebuild the Response exactly. */\nexport interface FrozenApiResponse {\n status: number\n headers: [string, string][]\n body: string\n}\n\n/** The file a frozen route is stored as. */\nexport function apiKey(url: string): string {\n return `${pathKey(url)}.api.json`\n}\n\n/**\n * Reading anything here means the answer depends on the caller.\n *\n * Deliberately not `url`: the url is the key the answer is stored under, so\n * reading it tells you the same thing on every request that would hit the\n * stored file. The query string is handled by refusing to serve a stored\n * answer to a request that has one, which needs no detection at all.\n */\nconst PER_CALLER = new Set([\n 'headers',\n 'body',\n 'bodyUsed',\n 'text',\n 'json',\n 'formData',\n 'arrayBuffer',\n 'blob',\n 'bytes',\n 'signal',\n 'referrer',\n 'credentials',\n])\n\n/**\n * A Request that records what was read out of it.\n *\n * A proxy rather than a subclass because the interesting properties are\n * getters on Request.prototype, and `this` has to stay the real Request or\n * every one of them throws about an illegal invocation.\n */\nfunction probeRequest(url: string, touched: Set<string>): Request {\n const real = new Request(url, { method: 'GET' })\n\n return new Proxy(real, {\n get(target, property) {\n if (typeof property === 'string' && PER_CALLER.has(property)) touched.add(property)\n\n const value = Reflect.get(target, property, target)\n\n return typeof value === 'function' ? value.bind(target) : value\n },\n })\n}\n\n/** The url a route with no parameters answers. */\nfunction urlFor(route: ManifestApiRoute): string | null {\n // A parameterised route has as many urls as there are values, and nothing\n // here knows them. Pages solve this with generateStaticParams; until a route\n // can say the same, one is answered per request.\n if (route.segments.some((segment) => segment.type !== 'static')) return null\n\n return '/' + route.segments.map((segment) => segment.value).join('/')\n}\n\n/**\n * What the build calls a route in its output.\n *\n * The pattern for a parameterised one, spelled the way pages already spell\n * theirs, rather than the module name — a line reading\n * \"/app/api/greet/[name]/route\" names a file on disk and the rest of the table\n * names urls.\n */\nfunction labelFor(route: ManifestApiRoute): string {\n return (\n '/' +\n route.segments\n .map((segment) => (segment.type === 'static' ? segment.value : `_${segment.value}_`))\n .join('/')\n )\n}\n\n/** Whether a body is text this can store and hand back unchanged. */\nfunction asText(bytes: Uint8Array): string | null {\n try {\n const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)\n\n return text\n } catch {\n // Binary. Storable in principle, as base64, at the cost of a third of its\n // size on disk and a decode per request — for a route that is far more\n // likely to be streaming a file it should be serving as a file.\n return null\n }\n}\n\nexport interface ApiPrerenderResult {\n url: string\n name: string\n type: 'frozen' | 'dynamic'\n reason: string | null\n}\n\n/**\n * Try to answer every api route once, at build time, and store what can be.\n *\n * Sequential rather than parallel: there are usually few of them, each is a\n * function call rather than a React render, and the ones that are going to be\n * dynamic spend the whole budget waiting — which is time, not work.\n */\nexport async function prerenderApiRoutes(\n engine: { handleApiRoute?: (n: string, r: Request, p: Record<string, string>, a: string) => Promise<Response> },\n manifest: RouteManifest,\n write: (name: string, contents: string) => Promise<void>,\n): Promise<ApiPrerenderResult[]> {\n if (!engine.handleApiRoute || !manifest.apis?.length) return []\n\n const results: ApiPrerenderResult[] = []\n\n for (const route of manifest.apis) {\n const said = (type: 'frozen' | 'dynamic', reason: string | null) => {\n results.push({ url: labelFor(route), name: route.name, type, reason })\n }\n\n if (!route.methods.includes('GET')) {\n said('dynamic', 'no GET to store')\n continue\n }\n\n // A guarded route answers differently depending on who is asking, which is\n // the whole purpose of the guard. Storing one answer and serving it to\n // everyone is how a guard is silently removed.\n if (route.middleware.length > 0) {\n said('dynamic', 'guarded by middleware')\n continue\n }\n\n const url = urlFor(route)\n\n if (!url) {\n said('dynamic', 'one url per param value, and none are listed')\n continue\n }\n\n const touched = new Set<string>()\n const request = probeRequest('https://prerender.invalid' + url, touched)\n\n // No request in scope, so headers(), cookies() and connection() suspend\n // forever rather than resolving to whatever the build machine had. The\n // budget below is what turns that into an answer.\n const answered = await withRequest(null as never, async () => {\n let readBy: string[] = []\n\n const response = await Promise.race([\n engine\n .handleApiRoute!(route.name, request, {}, allowFor(route))\n .then((value) => ({ value }))\n .catch((error) => ({ error })),\n new Promise<null>((resolve) =>\n setTimeout(() => {\n readBy = requestReadBy()\n resolve(null)\n }, BUDGET_MS),\n ),\n ])\n\n return { response, readBy: readBy.length ? readBy : requestReadBy() }\n })\n\n if (answered.response === null) {\n const why = answered.readBy.length\n ? 'dynamic — called ' + answered.readBy.join(', ')\n : 'did not answer within the build budget'\n\n said('dynamic', why)\n continue\n }\n\n if ('error' in answered.response) {\n // Not a build failure. A route that throws with no request may be doing\n // exactly the right thing — refusing a caller it cannot identify — and\n // refusing the build over it would make that route unbuildable.\n said('dynamic', 'threw without a request')\n continue\n }\n\n if (touched.size > 0) {\n said('dynamic', 'reads the request — ' + [...touched].sort().join(', '))\n continue\n }\n\n const response = answered.response.value\n const body = asText(new Uint8Array(await response.arrayBuffer()))\n\n if (body === null) {\n said('dynamic', 'answers with bytes rather than text')\n continue\n }\n\n await write(\n apiKey(url),\n JSON.stringify({\n status: response.status,\n // Lower-cased and sorted, so two builds of the same route produce the\n // same bytes. Headers iteration does not promise a case or an order,\n // and a file that differs between builds for no reason defeats\n // content-addressed caching and makes a diff unreadable. Names are\n // case-insensitive, so nothing is lost by picking one.\n headers: [...response.headers]\n .map(([name, value]) => [name.toLowerCase(), value] as [string, string])\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),\n body,\n } satisfies FrozenApiResponse),\n )\n\n said('frozen', null)\n }\n\n return results\n}\n"]}
|
package/dist/host.d.ts
CHANGED
|
@@ -59,7 +59,19 @@ export interface RscEngine {
|
|
|
59
59
|
handleQuery?(id: string, args: string, report?: (error: unknown) => string): Promise<{
|
|
60
60
|
stream: ReadableStream;
|
|
61
61
|
cacheControl: string;
|
|
62
|
+
} | {
|
|
63
|
+
status: number;
|
|
64
|
+
message: string;
|
|
65
|
+
errors?: Record<string, string[]>;
|
|
62
66
|
} | null>;
|
|
67
|
+
/**
|
|
68
|
+
* Answer a `route.ts` — an api endpoint rather than a page.
|
|
69
|
+
*
|
|
70
|
+
* Optional so a host can be pointed at a bundle built before these existed;
|
|
71
|
+
* without it the url falls through to page routing, which is what that
|
|
72
|
+
* bundle would have done anyway.
|
|
73
|
+
*/
|
|
74
|
+
handleApiRoute?(name: string, request: Request, params: Record<string, string>, allow: string): Promise<Response>;
|
|
63
75
|
}
|
|
64
76
|
export interface RscHostOptions {
|
|
65
77
|
/** The built server bundle — `import * as engine from './build/rsc/index.js'`. */
|