@rsc-kit/core 0.14.0 → 0.16.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 CHANGED
@@ -20,6 +20,8 @@ export interface ActionResult<Data> {
20
20
  export declare class ActionMisuse extends Error {
21
21
  constructor(message: string);
22
22
  }
23
+ /** Whether a server action was built by createActionClient, and so ran its chain. */
24
+ export declare function isClientBuilt(fn: unknown): boolean;
23
25
  export declare class ActionValidationError extends Error {
24
26
  readonly errors: Record<string, string[]>;
25
27
  constructor(errors: Record<string, string[]>);
@@ -45,7 +47,15 @@ export type FieldErrorsFor<Input> = Partial<Record<(Input extends object ? keyof
45
47
  export interface HandlerArgs<Input, Ctx> {
46
48
  input: Input;
47
49
  ctx: Ctx;
48
- /** Fail with errors on this input's fields. Throws; nothing after it runs. */
50
+ /**
51
+ * Fail with errors on this input's fields. Throws; nothing after it runs.
52
+ *
53
+ * Write `return fieldErrors(...)`. It throws either way, but TypeScript does
54
+ * not treat a never-return as terminating when the callee is a destructured
55
+ * binding — only a declaration or an explicitly annotated variable — so
56
+ * without the return, a value checked on the line above is still possibly
57
+ * undefined on the line below. The return is what lets the type narrow.
58
+ */
49
59
  fieldErrors: (errors: FieldErrorsFor<Input>) => never;
50
60
  }
51
61
  /**
package/dist/action.js CHANGED
@@ -49,6 +49,22 @@ export class ActionMisuse extends Error {
49
49
  * Symbol.for, so the two copies agree on the key as well as the value.
50
50
  */
51
51
  const VALIDATION_MARK = Symbol.for('@rsc-kit/core.action-validation');
52
+ /**
53
+ * Set on every function a client builds, so the build can tell them from a
54
+ * bare "use server" export. The difference is the middleware: a bare export
55
+ * runs with nothing checking who called it, and nothing else in the app
56
+ * would ever say so. Symbol.for, because the build reads the mark from the
57
+ * bundled copy of this module and the app's actions were built by another.
58
+ */
59
+ const CLIENT_MARK = Symbol.for('@rsc-kit/core.action-client');
60
+ /** Whether a server action was built by createActionClient, and so ran its chain. */
61
+ export function isClientBuilt(fn) {
62
+ return typeof fn === 'function' && fn[CLIENT_MARK] === true;
63
+ }
64
+ function markClientBuilt(fn) {
65
+ Object.defineProperty(fn, CLIENT_MARK, { value: true });
66
+ return fn;
67
+ }
52
68
  export class ActionValidationError extends Error {
53
69
  errors;
54
70
  constructor(errors) {
@@ -157,7 +173,7 @@ export function createActionClient(options = {}) {
157
173
  return build(middlewares, next);
158
174
  },
159
175
  handler(fn) {
160
- return async (raw) => {
176
+ return markClientBuilt(async (raw) => {
161
177
  try {
162
178
  return { data: (await pipeline(raw, fn)) };
163
179
  }
@@ -170,7 +186,7 @@ export function createActionClient(options = {}) {
170
186
  }
171
187
  return { serverError: report(error) };
172
188
  }
173
- };
189
+ });
174
190
  },
175
191
  query(fn, options) {
176
192
  const read = async (raw) => {
@@ -189,7 +205,7 @@ export function createActionClient(options = {}) {
189
205
  throw new Error(report(error));
190
206
  }
191
207
  };
192
- return markQuery(read, options);
208
+ return markClientBuilt(markQuery(read, options));
193
209
  },
194
210
  };
195
211
  }
@@ -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'\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 * The field errors a handler may report, keyed by its own input's fields.\n *\n * `''` is the whole submission — for a refusal that is about no field in\n * particular, which is where the form already looks for one.\n */\nexport type FieldErrorsFor<Input> = Partial<\n Record<(Input extends object ? keyof Input & string : string) | '', string | string[]>\n>\n\n/**\n * What a handler is given.\n *\n * `fieldErrors` is here as well as exported on its own, and the one here is\n * the one to use: it is typed to this handler's input, so a field the schema\n * does not have is a type error rather than an error the form never shows.\n * The bare export takes any string, for the rare check that runs outside a\n * handler.\n */\nexport interface HandlerArgs<Input, Ctx> {\n input: Input\n ctx: Ctx\n /** Fail with errors on this input's fields. Throws; nothing after it runs. */\n fieldErrors: (errors: FieldErrorsFor<Input>) => never\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 *\n * Untyped by field, because it has no handler to take the input from. Inside\n * one, use the `fieldErrors` the handler is given instead.\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: HandlerArgs<Input, 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: HandlerArgs<never, 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) {\n return await fn({\n input: parsed as never,\n ctx: ctx as never,\n // The same function as the export; the type on the way in is what\n // is different, and the type is the handler's input.\n fieldErrors: fieldErrors as never,\n })\n }\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"]}
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\n/**\n * Set on every function a client builds, so the build can tell them from a\n * bare \"use server\" export. The difference is the middleware: a bare export\n * runs with nothing checking who called it, and nothing else in the app\n * would ever say so. Symbol.for, because the build reads the mark from the\n * bundled copy of this module and the app's actions were built by another.\n */\nconst CLIENT_MARK = Symbol.for('@rsc-kit/core.action-client')\n\n/** Whether a server action was built by createActionClient, and so ran its chain. */\nexport function isClientBuilt(fn: unknown): boolean {\n return typeof fn === 'function' && (fn as unknown as Record<symbol, unknown>)[CLIENT_MARK] === true\n}\n\nfunction markClientBuilt<F extends Function>(fn: F): F {\n Object.defineProperty(fn, CLIENT_MARK, { value: true })\n\n return fn\n}\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 * The field errors a handler may report, keyed by its own input's fields.\n *\n * `''` is the whole submission — for a refusal that is about no field in\n * particular, which is where the form already looks for one.\n */\nexport type FieldErrorsFor<Input> = Partial<\n Record<(Input extends object ? keyof Input & string : string) | '', string | string[]>\n>\n\n/**\n * What a handler is given.\n *\n * `fieldErrors` is here as well as exported on its own, and the one here is\n * the one to use: it is typed to this handler's input, so a field the schema\n * does not have is a type error rather than an error the form never shows.\n * The bare export takes any string, for the rare check that runs outside a\n * handler.\n */\nexport interface HandlerArgs<Input, Ctx> {\n input: Input\n ctx: Ctx\n /**\n * Fail with errors on this input's fields. Throws; nothing after it runs.\n *\n * Write `return fieldErrors(...)`. It throws either way, but TypeScript does\n * not treat a never-return as terminating when the callee is a destructured\n * binding — only a declaration or an explicitly annotated variable — so\n * without the return, a value checked on the line above is still possibly\n * undefined on the line below. The return is what lets the type narrow.\n */\n fieldErrors: (errors: FieldErrorsFor<Input>) => never\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 *\n * Untyped by field, because it has no handler to take the input from. Inside\n * one, use the `fieldErrors` the handler is given instead.\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: HandlerArgs<Input, 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: HandlerArgs<never, 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) {\n return await fn({\n input: parsed as never,\n ctx: ctx as never,\n // The same function as the export; the type on the way in is what\n // is different, and the type is the handler's input.\n fieldErrors: fieldErrors as never,\n })\n }\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 markClientBuilt(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 markClientBuilt(markQuery(read, options)) as (input?: unknown) => Promise<never>\n },\n }\n }\n\n return build([], null)\n}\n"]}
@@ -2,15 +2,27 @@
2
2
  export interface ReportedRoute {
3
3
  url: string;
4
4
  component: string;
5
- /** frozen | shell | blocked | dynamic | error — see PrerenderResult. */
5
+ /** frozen | shell | blocked | dynamic | error — see PrerenderResult. blocked and error failed the build. */
6
6
  type: string;
7
7
  /** Why it is not frozen, in the words the build printed. */
8
8
  reason: string | null;
9
9
  /** Something true and worth knowing that is not a failure. */
10
10
  warning: string | null;
11
+ /** A fact about how it was stored - "no client components, so ships no javascript". Absent from older reports. */
12
+ note?: string | null;
11
13
  /** Gzipped bytes of javascript this url makes the browser download. */
12
14
  clientJs: number | null;
13
15
  }
16
+ /** A server action, and whether anything checks who calls it. */
17
+ export interface ReportedAction {
18
+ id: string;
19
+ name: string;
20
+ file: string;
21
+ /** Built by createActionClient, so its middleware ran. */
22
+ client: boolean;
23
+ /** A read (GET) rather than a mutation. */
24
+ query: boolean;
25
+ }
14
26
  export interface ReportedApiRoute {
15
27
  url: string;
16
28
  name: string;
@@ -23,6 +35,8 @@ export interface BuildReport {
23
35
  routes: ReportedRoute[];
24
36
  /** route.ts endpoints. */
25
37
  apis: ReportedApiRoute[];
38
+ /** Every "use server" export the app registered. Absent from reports older than this field. */
39
+ actions?: ReportedAction[];
26
40
  totals: {
27
41
  static: number;
28
42
  partial: number;
@@ -32,7 +46,7 @@ export interface BuildReport {
32
46
  }
33
47
  /** The name the report is written under, inside the build's own directory. */
34
48
  export declare const REPORT_FILE = "build-report.json";
35
- export declare function buildReport(routes: ReportedRoute[], apis: ReportedApiRoute[]): string;
49
+ export declare function buildReport(routes: ReportedRoute[], apis: ReportedApiRoute[], actions?: ReportedAction[]): string;
36
50
  /**
37
51
  * The routes worth asking about, most interesting first.
38
52
  *
@@ -10,18 +10,21 @@
10
10
  // produced, in the same pass that prints them.
11
11
  /** The name the report is written under, inside the build's own directory. */
12
12
  export const REPORT_FILE = 'build-report.json';
13
- export function buildReport(routes, apis) {
13
+ export function buildReport(routes, apis, actions = []) {
14
14
  const count = (...types) => routes.filter((r) => types.includes(r.type)).length +
15
15
  apis.filter((a) => types.includes(a.type)).length;
16
16
  const report = {
17
17
  version: 1,
18
18
  routes,
19
19
  apis,
20
+ actions,
20
21
  totals: {
21
22
  static: count('frozen'),
22
23
  partial: count('shell'),
23
- dynamic: count('blocked', 'dynamic'),
24
- failed: count('error'),
24
+ dynamic: count('dynamic'),
25
+ // Blocked is refused, not dynamic: a page that painted nothing before it
26
+ // read the request has no shell to store and the build did not finish.
27
+ failed: count('error', 'blocked'),
25
28
  },
26
29
  };
27
30
  return JSON.stringify(report, null, 2) + '\n';
@@ -1 +1 @@
1
- {"version":3,"file":"buildReport.js","sourceRoot":"","sources":["../src/buildReport.ts"],"names":[],"mappings":"AAAA,wCAAwC;AACxC,EAAE;AACF,2EAA2E;AAC3E,6EAA6E;AAC7E,yEAAyE;AACzE,iEAAiE;AACjE,EAAE;AACF,yEAAyE;AACzE,6EAA6E;AAC7E,+CAA+C;AAqC/C,8EAA8E;AAC9E,MAAM,CAAC,MAAM,WAAW,GAAG,mBAAmB,CAAA;AAE9C,MAAM,UAAU,WAAW,CACzB,MAAuB,EACvB,IAAwB;IAExB,MAAM,KAAK,GAAG,CAAC,GAAG,KAAe,EAAE,EAAE,CACnC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM;QACnD,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAA;IAEnD,MAAM,MAAM,GAAgB;QAC1B,OAAO,EAAE,CAAC;QACV,MAAM;QACN,IAAI;QACJ,MAAM,EAAE;YACN,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC;YACvB,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC;YACvB,OAAO,EAAE,KAAK,CAAC,SAAS,EAAE,SAAS,CAAC;YACpC,MAAM,EAAE,KAAK,CAAC,OAAO,CAAC;SACvB;KACF,CAAA;IAED,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAA;AAC/C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,MAAuB;IAChD,MAAM,IAAI,GAA2B,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAA;IAE9F,OAAO,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,CACrB,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC,CAClF,CAAA;AACH,CAAC","sourcesContent":["// What the build decided, written down.\n//\n// The classification exists already — it is printed as the build runs, and\n// then it is gone. That is fine for a person watching a terminal and useless\n// for anything that wants to ask afterwards: a CI step asserting nothing\n// regressed, an editor, an agent being asked why a page is slow.\n//\n// So the same facts go to a file. Not a new computation and not a second\n// source of truth — the report is written from the results the build already\n// produced, in the same pass that prints them.\n\n/** One route, as the build left it. */\nexport interface ReportedRoute {\n url: string\n component: string\n /** frozen | shell | blocked | dynamic | error — see PrerenderResult. */\n type: string\n /** Why it is not frozen, in the words the build printed. */\n reason: string | null\n /** Something true and worth knowing that is not a failure. */\n warning: string | null\n /** Gzipped bytes of javascript this url makes the browser download. */\n clientJs: number | null\n}\n\nexport interface ReportedApiRoute {\n url: string\n name: string\n type: string\n reason: string | null\n}\n\nexport interface BuildReport {\n version: 1\n /** Routes that render, in the order the build reported them. */\n routes: ReportedRoute[]\n /** route.ts endpoints. */\n apis: ReportedApiRoute[]\n totals: {\n static: number\n partial: number\n dynamic: number\n failed: number\n }\n}\n\n/** The name the report is written under, inside the build's own directory. */\nexport const REPORT_FILE = 'build-report.json'\n\nexport function buildReport(\n routes: ReportedRoute[],\n apis: ReportedApiRoute[],\n): string {\n const count = (...types: string[]) =>\n routes.filter((r) => types.includes(r.type)).length +\n apis.filter((a) => types.includes(a.type)).length\n\n const report: BuildReport = {\n version: 1,\n routes,\n apis,\n totals: {\n static: count('frozen'),\n partial: count('shell'),\n dynamic: count('blocked', 'dynamic'),\n failed: count('error'),\n },\n }\n\n return JSON.stringify(report, null, 2) + '\\n'\n}\n\n/**\n * The routes worth asking about, most interesting first.\n *\n * \"Interesting\" is not a judgement about the app — it is the order someone\n * looking for a problem reads in. A failure first, then a page that could not\n * be stored at all, then one that ships a shell, then the ones that are fine.\n */\nexport function byInterest(routes: ReportedRoute[]): ReportedRoute[] {\n const rank: Record<string, number> = { error: 0, blocked: 1, shell: 2, dynamic: 3, frozen: 4 }\n\n return [...routes].sort(\n (a, b) => (rank[a.type] ?? 9) - (rank[b.type] ?? 9) || a.url.localeCompare(b.url),\n )\n}\n"]}
1
+ {"version":3,"file":"buildReport.js","sourceRoot":"","sources":["../src/buildReport.ts"],"names":[],"mappings":"AAAA,wCAAwC;AACxC,EAAE;AACF,2EAA2E;AAC3E,6EAA6E;AAC7E,yEAAyE;AACzE,iEAAiE;AACjE,EAAE;AACF,yEAAyE;AACzE,6EAA6E;AAC7E,+CAA+C;AAoD/C,8EAA8E;AAC9E,MAAM,CAAC,MAAM,WAAW,GAAG,mBAAmB,CAAA;AAE9C,MAAM,UAAU,WAAW,CACzB,MAAuB,EACvB,IAAwB,EACxB,OAAO,GAAqB,EAAE;IAE9B,MAAM,KAAK,GAAG,CAAC,GAAG,KAAe,EAAE,EAAE,CACnC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM;QACnD,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAA;IAEnD,MAAM,MAAM,GAAgB;QAC1B,OAAO,EAAE,CAAC;QACV,MAAM;QACN,IAAI;QACJ,OAAO;QACP,MAAM,EAAE;YACN,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC;YACvB,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC;YACvB,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC;YACzB,yEAAyE;YACzE,uEAAuE;YACvE,MAAM,EAAE,KAAK,CAAC,OAAO,EAAE,SAAS,CAAC;SAClC;KACF,CAAA;IAED,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAA;AAC/C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,MAAuB;IAChD,MAAM,IAAI,GAA2B,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAA;IAE9F,OAAO,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,CACrB,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC,CAClF,CAAA;AACH,CAAC","sourcesContent":["// What the build decided, written down.\n//\n// The classification exists already — it is printed as the build runs, and\n// then it is gone. That is fine for a person watching a terminal and useless\n// for anything that wants to ask afterwards: a CI step asserting nothing\n// regressed, an editor, an agent being asked why a page is slow.\n//\n// So the same facts go to a file. Not a new computation and not a second\n// source of truth — the report is written from the results the build already\n// produced, in the same pass that prints them.\n\n/** One route, as the build left it. */\nexport interface ReportedRoute {\n url: string\n component: string\n /** frozen | shell | blocked | dynamic | error — see PrerenderResult. blocked and error failed the build. */\n type: string\n /** Why it is not frozen, in the words the build printed. */\n reason: string | null\n /** Something true and worth knowing that is not a failure. */\n warning: string | null\n /** A fact about how it was stored - \"no client components, so ships no javascript\". Absent from older reports. */\n note?: string | null\n /** Gzipped bytes of javascript this url makes the browser download. */\n clientJs: number | null\n}\n\n/** A server action, and whether anything checks who calls it. */\nexport interface ReportedAction {\n id: string\n name: string\n file: string\n /** Built by createActionClient, so its middleware ran. */\n client: boolean\n /** A read (GET) rather than a mutation. */\n query: boolean\n}\n\nexport interface ReportedApiRoute {\n url: string\n name: string\n type: string\n reason: string | null\n}\n\nexport interface BuildReport {\n version: 1\n /** Routes that render, in the order the build reported them. */\n routes: ReportedRoute[]\n /** route.ts endpoints. */\n apis: ReportedApiRoute[]\n /** Every \"use server\" export the app registered. Absent from reports older than this field. */\n actions?: ReportedAction[]\n totals: {\n static: number\n partial: number\n dynamic: number\n failed: number\n }\n}\n\n/** The name the report is written under, inside the build's own directory. */\nexport const REPORT_FILE = 'build-report.json'\n\nexport function buildReport(\n routes: ReportedRoute[],\n apis: ReportedApiRoute[],\n actions: ReportedAction[] = [],\n): string {\n const count = (...types: string[]) =>\n routes.filter((r) => types.includes(r.type)).length +\n apis.filter((a) => types.includes(a.type)).length\n\n const report: BuildReport = {\n version: 1,\n routes,\n apis,\n actions,\n totals: {\n static: count('frozen'),\n partial: count('shell'),\n dynamic: count('dynamic'),\n // Blocked is refused, not dynamic: a page that painted nothing before it\n // read the request has no shell to store and the build did not finish.\n failed: count('error', 'blocked'),\n },\n }\n\n return JSON.stringify(report, null, 2) + '\\n'\n}\n\n/**\n * The routes worth asking about, most interesting first.\n *\n * \"Interesting\" is not a judgement about the app — it is the order someone\n * looking for a problem reads in. A failure first, then a page that could not\n * be stored at all, then one that ships a shell, then the ones that are fine.\n */\nexport function byInterest(routes: ReportedRoute[]): ReportedRoute[] {\n const rank: Record<string, number> = { error: 0, blocked: 1, shell: 2, dynamic: 3, frozen: 4 }\n\n return [...routes].sort(\n (a, b) => (rank[a.type] ?? 9) - (rank[b.type] ?? 9) || a.url.localeCompare(b.url),\n )\n}\n"]}
package/dist/js/Link.d.ts CHANGED
@@ -1,12 +1,12 @@
1
- import type { Href } from "../routes.js";
1
+ import { type Href, type SearchProp } from "../routes.js";
2
2
  import { type AnchorHTMLAttributes, type Ref } from "react";
3
3
  type PrefetchStrategy = "hover" | "mount" | "click" | "none" | boolean;
4
- interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
4
+ interface LinkBaseProps<H extends Href> extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
5
5
  /**
6
6
  * Where this goes. Typed to the routes the build found, so a link to a page
7
7
  * that does not exist stops compiling; `path as Href` when it is computed.
8
8
  */
9
- href: Href;
9
+ href: H;
10
10
  /**
11
11
  * The underlying anchor.
12
12
  *
@@ -21,8 +21,15 @@ interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"
21
21
  replace?: boolean;
22
22
  preserveScroll?: boolean;
23
23
  }
24
+ /**
25
+ * `search` is typed to the page's own `searchParams` schema when it exports
26
+ * one — the same schema the page parses with, so a key it never reads or a
27
+ * number written as text does not compile, and a key it requires is required
28
+ * here. With no schema, any scalars. See SearchFor in routes.ts.
29
+ */
30
+ type LinkProps<H extends Href> = LinkBaseProps<H> & SearchProp<H>;
24
31
  export declare function useLinkStatus(): {
25
32
  pending: boolean;
26
33
  };
27
- export default function Link({ href, prefetch: prefetchProp, cacheFor, replace, preserveScroll, children, onClick, onMouseEnter, onMouseLeave, ...rest }: LinkProps): import("react").JSX.Element;
34
+ export default function Link<H extends Href>({ href: path, search, prefetch: prefetchProp, cacheFor, replace, preserveScroll, children, onClick, onMouseEnter, onMouseLeave, ...rest }: LinkProps<H>): import("react").JSX.Element;
28
35
  export {};
package/dist/js/Link.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { withSearch } from "../routes.js";
3
4
  import { createContext, useCallback, useContext, useEffect, useRef, useState, } from "react";
4
5
  const LinkStatusContext = createContext({ pending: false });
5
6
  export function useLinkStatus() {
@@ -31,7 +32,10 @@ function shouldInterceptClick(e) {
31
32
  * hover this short would not have had its payload back anyway.
32
33
  */
33
34
  const HOVER_PREFETCH_DELAY_MS = 100;
34
- export default function Link({ href, prefetch: prefetchProp = "hover", cacheFor, replace = false, preserveScroll = false, children, onClick, onMouseEnter, onMouseLeave, ...rest }) {
35
+ export default function Link({ href: path, search, prefetch: prefetchProp = "hover", cacheFor, replace = false, preserveScroll = false, children, onClick, onMouseEnter, onMouseLeave, ...rest }) {
36
+ // The string the anchor and the router both use: the path, with the typed
37
+ // search params serialised onto it.
38
+ const href = (search ? withSearch(path, search) : path);
35
39
  const [pending, setPending] = useState(false);
36
40
  const hoverTimer = useRef(null);
37
41
  const prefetchStrategy = prefetchProp === true
@@ -1 +1 @@
1
- {"version":3,"file":"Link.js","sourceRoot":"","sources":["../../src/js/Link.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport type { Href } from \"../routes.js\";\nimport {\n type AnchorHTMLAttributes,\n type MouseEvent,\n type Ref,\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\n\ntype PrefetchStrategy = \"hover\" | \"mount\" | \"click\" | \"none\" | boolean;\n\ninterface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"href\"> {\n /**\n * Where this goes. Typed to the routes the build found, so a link to a page\n * that does not exist stops compiling; `path as Href` when it is computed.\n */\n href: Href;\n /**\n * The underlying anchor.\n *\n * Spread onto it like any other prop — React 19 passes ref through a\n * function component without forwardRef — but declared here because it is\n * not part of AnchorHTMLAttributes. A link that cannot be focused is a link\n * a dialog cannot move focus to when it opens.\n */\n ref?: Ref<HTMLAnchorElement>;\n prefetch?: PrefetchStrategy;\n cacheFor?: number;\n replace?: boolean;\n preserveScroll?: boolean;\n}\n\nconst LinkStatusContext = createContext<{ pending: boolean }>({ pending: false });\n\nexport function useLinkStatus(): { pending: boolean } {\n return useContext(LinkStatusContext);\n}\n\nfunction isExternalUrl(url: string): boolean {\n try {\n return new URL(url, window.location.origin).origin !== window.location.origin;\n } catch {\n return false;\n }\n}\n\nfunction shouldInterceptClick(e: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n !e.defaultPrevented &&\n e.button === 0 &&\n !e.metaKey &&\n !e.ctrlKey &&\n !e.shiftKey &&\n !e.altKey\n );\n}\n\n/**\n * How long a pointer has to settle on a link before it prefetches.\n *\n * A pointer crossing a nav bar enters every link on the way, and each one used\n * to fire a request immediately — enough to fill the browser's per-origin\n * connection limit with pages the user never meant to visit. Waiting is close\n * to free: the prefetch only has to beat the click, and a click that follows a\n * hover this short would not have had its payload back anyway.\n */\nconst HOVER_PREFETCH_DELAY_MS = 100;\n\nexport default function Link({\n href,\n prefetch: prefetchProp = \"hover\",\n cacheFor,\n replace = false,\n preserveScroll = false,\n children,\n onClick,\n onMouseEnter,\n onMouseLeave,\n ...rest\n}: LinkProps) {\n const [pending, setPending] = useState(false);\n const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const prefetchStrategy = prefetchProp === true\n ? \"hover\"\n : prefetchProp === false\n ? \"none\"\n : prefetchProp;\n\n const doPrefetch = useCallback(() => {\n if (isExternalUrl(href)) return;\n const fn = (window as any).__rsc_prefetch;\n fn?.(href, cacheFor);\n }, [href, cacheFor]);\n\n // Only useEffect needed: prefetch on mount strategy\n useEffect(() => {\n if (prefetchStrategy === \"mount\") {\n doPrefetch();\n }\n }, [prefetchStrategy, doPrefetch]);\n\n const handleClick = useCallback(\n (e: MouseEvent<HTMLAnchorElement>) => {\n onClick?.(e);\n\n if (e.defaultPrevented) return;\n\n const target = (e.currentTarget as HTMLAnchorElement).target;\n if (target && target !== \"_self\") return;\n if (!shouldInterceptClick(e) || isExternalUrl(href)) return;\n\n // Hash-only links (#section) — let the browser scroll natively\n if (href.startsWith(\"#\")) return;\n\n e.preventDefault();\n setPending(true);\n\n // navigate() returns a Promise — clear pending when it resolves or rejects\n const nav = (window as any).__rsc_navigate;\n const promise = nav?.(href, { replace, preserveScroll });\n promise?.then(\n () => setPending(false),\n () => setPending(false),\n );\n },\n [href, replace, preserveScroll, onClick]\n );\n\n const handleMouseEnter = useCallback(\n (e: MouseEvent<HTMLAnchorElement>) => {\n onMouseEnter?.(e);\n\n if (prefetchStrategy !== \"hover\" && prefetchStrategy !== \"click\") return;\n\n if (hoverTimer.current !== null) clearTimeout(hoverTimer.current);\n\n hoverTimer.current = setTimeout(() => {\n hoverTimer.current = null;\n doPrefetch();\n }, HOVER_PREFETCH_DELAY_MS);\n },\n [prefetchStrategy, doPrefetch, onMouseEnter]\n );\n\n const handleMouseLeave = useCallback(\n (e: MouseEvent<HTMLAnchorElement>) => {\n onMouseLeave?.(e);\n\n // Never fired: the pointer passed over on its way somewhere else.\n if (hoverTimer.current !== null) {\n clearTimeout(hoverTimer.current);\n hoverTimer.current = null;\n }\n\n // Already in flight: give the connection back to whatever the pointer is\n // heading for. A prefetch that has landed is kept — see cancelPrefetch.\n if (isExternalUrl(href)) return;\n (window as any).__rsc_cancel_prefetch?.(href);\n },\n [href, onMouseLeave]\n );\n\n // Touch gets no delay. There is no hovering to disambiguate — a touch is\n // already the start of a tap — and touchstart leads the click by little\n // enough that spending any of it waiting would waste the head start.\n const handleTouchStart = useCallback(() => {\n if (prefetchStrategy === \"hover\" || prefetchStrategy === \"click\") {\n doPrefetch();\n }\n }, [prefetchStrategy, doPrefetch]);\n\n // A link unmounted mid-hover (navigating away) must not prefetch afterwards.\n useEffect(() => () => {\n if (hoverTimer.current !== null) clearTimeout(hoverTimer.current);\n }, []);\n\n return (\n <LinkStatusContext.Provider value={{ pending }}>\n <a\n href={href}\n onClick={handleClick}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n onTouchStart={handleTouchStart}\n data-pending={pending ? \"\" : undefined}\n {...rest}\n >\n {children}\n </a>\n </LinkStatusContext.Provider>\n );\n}\n"]}
1
+ {"version":3,"file":"Link.js","sourceRoot":"","sources":["../../src/js/Link.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport { type Href, type SearchProp, withSearch } from \"../routes.js\";\nimport {\n type AnchorHTMLAttributes,\n type MouseEvent,\n type Ref,\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\n\ntype PrefetchStrategy = \"hover\" | \"mount\" | \"click\" | \"none\" | boolean;\n\ninterface LinkBaseProps<H extends Href> extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"href\"> {\n /**\n * Where this goes. Typed to the routes the build found, so a link to a page\n * that does not exist stops compiling; `path as Href` when it is computed.\n */\n href: H;\n /**\n * The underlying anchor.\n *\n * Spread onto it like any other prop — React 19 passes ref through a\n * function component without forwardRef — but declared here because it is\n * not part of AnchorHTMLAttributes. A link that cannot be focused is a link\n * a dialog cannot move focus to when it opens.\n */\n ref?: Ref<HTMLAnchorElement>;\n prefetch?: PrefetchStrategy;\n cacheFor?: number;\n replace?: boolean;\n preserveScroll?: boolean;\n}\n\n/**\n * `search` is typed to the page's own `searchParams` schema when it exports\n * one — the same schema the page parses with, so a key it never reads or a\n * number written as text does not compile, and a key it requires is required\n * here. With no schema, any scalars. See SearchFor in routes.ts.\n */\ntype LinkProps<H extends Href> = LinkBaseProps<H> & SearchProp<H>;\n\nconst LinkStatusContext = createContext<{ pending: boolean }>({ pending: false });\n\nexport function useLinkStatus(): { pending: boolean } {\n return useContext(LinkStatusContext);\n}\n\nfunction isExternalUrl(url: string): boolean {\n try {\n return new URL(url, window.location.origin).origin !== window.location.origin;\n } catch {\n return false;\n }\n}\n\nfunction shouldInterceptClick(e: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n !e.defaultPrevented &&\n e.button === 0 &&\n !e.metaKey &&\n !e.ctrlKey &&\n !e.shiftKey &&\n !e.altKey\n );\n}\n\n/**\n * How long a pointer has to settle on a link before it prefetches.\n *\n * A pointer crossing a nav bar enters every link on the way, and each one used\n * to fire a request immediately — enough to fill the browser's per-origin\n * connection limit with pages the user never meant to visit. Waiting is close\n * to free: the prefetch only has to beat the click, and a click that follows a\n * hover this short would not have had its payload back anyway.\n */\nconst HOVER_PREFETCH_DELAY_MS = 100;\n\nexport default function Link<H extends Href>({\n href: path,\n search,\n prefetch: prefetchProp = \"hover\",\n cacheFor,\n replace = false,\n preserveScroll = false,\n children,\n onClick,\n onMouseEnter,\n onMouseLeave,\n ...rest\n}: LinkProps<H>) {\n // The string the anchor and the router both use: the path, with the typed\n // search params serialised onto it.\n const href = (search ? withSearch(path, search as object) : path) as Href;\n const [pending, setPending] = useState(false);\n const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const prefetchStrategy = prefetchProp === true\n ? \"hover\"\n : prefetchProp === false\n ? \"none\"\n : prefetchProp;\n\n const doPrefetch = useCallback(() => {\n if (isExternalUrl(href)) return;\n const fn = (window as any).__rsc_prefetch;\n fn?.(href, cacheFor);\n }, [href, cacheFor]);\n\n // Only useEffect needed: prefetch on mount strategy\n useEffect(() => {\n if (prefetchStrategy === \"mount\") {\n doPrefetch();\n }\n }, [prefetchStrategy, doPrefetch]);\n\n const handleClick = useCallback(\n (e: MouseEvent<HTMLAnchorElement>) => {\n onClick?.(e);\n\n if (e.defaultPrevented) return;\n\n const target = (e.currentTarget as HTMLAnchorElement).target;\n if (target && target !== \"_self\") return;\n if (!shouldInterceptClick(e) || isExternalUrl(href)) return;\n\n // Hash-only links (#section) — let the browser scroll natively\n if (href.startsWith(\"#\")) return;\n\n e.preventDefault();\n setPending(true);\n\n // navigate() returns a Promise — clear pending when it resolves or rejects\n const nav = (window as any).__rsc_navigate;\n const promise = nav?.(href, { replace, preserveScroll });\n promise?.then(\n () => setPending(false),\n () => setPending(false),\n );\n },\n [href, replace, preserveScroll, onClick]\n );\n\n const handleMouseEnter = useCallback(\n (e: MouseEvent<HTMLAnchorElement>) => {\n onMouseEnter?.(e);\n\n if (prefetchStrategy !== \"hover\" && prefetchStrategy !== \"click\") return;\n\n if (hoverTimer.current !== null) clearTimeout(hoverTimer.current);\n\n hoverTimer.current = setTimeout(() => {\n hoverTimer.current = null;\n doPrefetch();\n }, HOVER_PREFETCH_DELAY_MS);\n },\n [prefetchStrategy, doPrefetch, onMouseEnter]\n );\n\n const handleMouseLeave = useCallback(\n (e: MouseEvent<HTMLAnchorElement>) => {\n onMouseLeave?.(e);\n\n // Never fired: the pointer passed over on its way somewhere else.\n if (hoverTimer.current !== null) {\n clearTimeout(hoverTimer.current);\n hoverTimer.current = null;\n }\n\n // Already in flight: give the connection back to whatever the pointer is\n // heading for. A prefetch that has landed is kept — see cancelPrefetch.\n if (isExternalUrl(href)) return;\n (window as any).__rsc_cancel_prefetch?.(href);\n },\n [href, onMouseLeave]\n );\n\n // Touch gets no delay. There is no hovering to disambiguate — a touch is\n // already the start of a tap — and touchstart leads the click by little\n // enough that spending any of it waiting would waste the head start.\n const handleTouchStart = useCallback(() => {\n if (prefetchStrategy === \"hover\" || prefetchStrategy === \"click\") {\n doPrefetch();\n }\n }, [prefetchStrategy, doPrefetch]);\n\n // A link unmounted mid-hover (navigating away) must not prefetch afterwards.\n useEffect(() => () => {\n if (hoverTimer.current !== null) clearTimeout(hoverTimer.current);\n }, []);\n\n return (\n <LinkStatusContext.Provider value={{ pending }}>\n <a\n href={href}\n onClick={handleClick}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n onTouchStart={handleTouchStart}\n data-pending={pending ? \"\" : undefined}\n {...rest}\n >\n {children}\n </a>\n </LinkStatusContext.Provider>\n );\n}\n"]}
@@ -66,8 +66,13 @@ export interface ManifestRoute {
66
66
  * client, no router. A client component on such a route is inert markup — a
67
67
  * button that does nothing — so the build refuses the combination rather
68
68
  * than shipping it.
69
+ *
70
+ * 'auto' when the page declares nothing: a route that freezes whole with no
71
+ * client component and no server action in it is stored without the
72
+ * bootstrap, because there is nothing for a runtime to do. `true` forces
73
+ * the runtime onto such a page.
69
74
  */
70
- clientJs: boolean;
75
+ clientJs: boolean | 'auto';
71
76
  }
72
77
  export interface ManifestIntercept {
73
78
  component: string;
@@ -1 +1 @@
1
- {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,6EAA6E;AAC7E,6EAA6E;AAC7E,+EAA+E;AAC/E,gFAAgF;AAChF,uCAAuC;AACvC,EAAE;AACF,6EAA6E;AAC7E,+EAA+E;AAC/E,oBAAoB","sourcesContent":["// The shape of routes.json — what the build discovered, for a host to read.\n//\n// The build already walks app/ to generate its entries, and every host needs\n// the same facts: which url a component answers, what layouts wrap it, which\n// slots and sections belong to it. Laravel used to scan the tree a second time\n// to work that out; a JS host would have had to write a third walk. This is the\n// one answer, and these are its types.\n//\n// Urls are segments rather than a pattern string, because the pattern is the\n// host's dialect: Laravel writes {slug}, Hono writes :slug, and neither is the\n// build's business.\n\nexport interface RouteSegment {\n type: 'static' | 'param' | 'catchAll'\n value: string\n}\n\nexport interface ManifestRoute {\n component: string\n segments: RouteSegment[]\n layouts: string[]\n loadings: string[]\n /**\n * `error.tsx` files above this route, outermost first.\n *\n * The nearest one to a failure catches it, the same way the nearest\n * `loading.tsx` is the fallback. Optional: a manifest from a build before\n * error boundaries existed has none.\n */\n errors?: string[]\n /**\n * `middleware.ts` files above this route, outermost first.\n *\n * Run before anything at or below them renders, on every path. A check is\n * not UI, and making it a layout meant the client could decline it: layouts\n * are skipped on a partial navigation, and what gets skipped is named in a\n * header nothing can verify.\n */\n middleware: string[]\n slots: Record<string, string>\n sections: string[]\n /**\n * The host's route-config file beside this page, if it named one, and the\n * ancestor ones that also apply — outermost first, this page's excluded.\n *\n * Relative to the project root: an absolute path is true only on the machine\n * that produced it, and building in a container is ordinary.\n */\n config: string | null\n ancestorConfigs: string[]\n /**\n * Host middleware names for this route, outermost first.\n *\n * Declared in a route.ts beside or above the page. The engine does not know\n * what they mean — they are the host's own vocabulary — it only runs them\n * past the host before anything at or below this route renders.\n *\n * Empty on a route that named none, and on every route in an app that never\n * wrote a route.ts, which is why this needs no flag.\n *\n * Optional because registration boots from the PREVIOUS build's manifest: a\n * shape change takes two builds to settle, and a required field would make\n * the first of those a hard failure rather than a route with no guards.\n */\n hostMiddleware?: string[]\n /**\n * Whether the page exports generateStaticParams.\n *\n * Recorded here so a host can plan a build — which routes to ask for urls,\n * which to leave on demand — without loading the server bundle first. The\n * function itself is reached through the bundle's getStaticParams(), because\n * only the bundle can run it.\n */\n staticParams: boolean\n /**\n * Whether this route ships the client runtime.\n *\n * False renders to HTML and stops: no bootstrap, so no React, no Flight\n * client, no router. A client component on such a route is inert markup — a\n * button that does nothing — so the build refuses the combination rather\n * than shipping it.\n */\n clientJs: boolean\n}\n\nexport interface ManifestIntercept {\n component: string\n slot: string\n segments: RouteSegment[]\n /** (.) same level, (..) one up, (...) from the root. */\n marker: string\n}\n\n/**\n * A `route.ts` — an api endpoint rather than a page.\n *\n * Separate from `routes` because it is matched before them and answered\n * without rendering anything: no layouts, no payload, no client. A url cannot\n * be both, and the build refuses one that is.\n */\nexport interface ManifestApiRoute {\n /** The module name, as the engine's registry keys it. */\n name: string\n segments: RouteSegment[]\n /** Which methods the file exports, so a 405 can name the rest. */\n methods: string[]\n /**\n * `middleware.ts` files above this route, outermost first.\n *\n * The same chain a page in that directory runs. A route.ts sits among the\n * pages it belongs with, so a guard on the directory covers it too —\n * anything else would mean adding an endpoint under a guarded path silently\n * opened a hole in it.\n */\n middleware: string[]\n}\n\nexport interface RouteManifest {\n version: number\n build: { output: string; exportPath: string; payloadName: string }\n routes: ManifestRoute[]\n intercepts: ManifestIntercept[]\n /** Optional: a manifest from a build before api routes existed has none. */\n apis?: ManifestApiRoute[]\n}\n"]}
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,6EAA6E;AAC7E,6EAA6E;AAC7E,+EAA+E;AAC/E,gFAAgF;AAChF,uCAAuC;AACvC,EAAE;AACF,6EAA6E;AAC7E,+EAA+E;AAC/E,oBAAoB","sourcesContent":["// The shape of routes.json — what the build discovered, for a host to read.\n//\n// The build already walks app/ to generate its entries, and every host needs\n// the same facts: which url a component answers, what layouts wrap it, which\n// slots and sections belong to it. Laravel used to scan the tree a second time\n// to work that out; a JS host would have had to write a third walk. This is the\n// one answer, and these are its types.\n//\n// Urls are segments rather than a pattern string, because the pattern is the\n// host's dialect: Laravel writes {slug}, Hono writes :slug, and neither is the\n// build's business.\n\nexport interface RouteSegment {\n type: 'static' | 'param' | 'catchAll'\n value: string\n}\n\nexport interface ManifestRoute {\n component: string\n segments: RouteSegment[]\n layouts: string[]\n loadings: string[]\n /**\n * `error.tsx` files above this route, outermost first.\n *\n * The nearest one to a failure catches it, the same way the nearest\n * `loading.tsx` is the fallback. Optional: a manifest from a build before\n * error boundaries existed has none.\n */\n errors?: string[]\n /**\n * `middleware.ts` files above this route, outermost first.\n *\n * Run before anything at or below them renders, on every path. A check is\n * not UI, and making it a layout meant the client could decline it: layouts\n * are skipped on a partial navigation, and what gets skipped is named in a\n * header nothing can verify.\n */\n middleware: string[]\n slots: Record<string, string>\n sections: string[]\n /**\n * The host's route-config file beside this page, if it named one, and the\n * ancestor ones that also apply — outermost first, this page's excluded.\n *\n * Relative to the project root: an absolute path is true only on the machine\n * that produced it, and building in a container is ordinary.\n */\n config: string | null\n ancestorConfigs: string[]\n /**\n * Host middleware names for this route, outermost first.\n *\n * Declared in a route.ts beside or above the page. The engine does not know\n * what they mean — they are the host's own vocabulary — it only runs them\n * past the host before anything at or below this route renders.\n *\n * Empty on a route that named none, and on every route in an app that never\n * wrote a route.ts, which is why this needs no flag.\n *\n * Optional because registration boots from the PREVIOUS build's manifest: a\n * shape change takes two builds to settle, and a required field would make\n * the first of those a hard failure rather than a route with no guards.\n */\n hostMiddleware?: string[]\n /**\n * Whether the page exports generateStaticParams.\n *\n * Recorded here so a host can plan a build — which routes to ask for urls,\n * which to leave on demand — without loading the server bundle first. The\n * function itself is reached through the bundle's getStaticParams(), because\n * only the bundle can run it.\n */\n staticParams: boolean\n /**\n * Whether this route ships the client runtime.\n *\n * False renders to HTML and stops: no bootstrap, so no React, no Flight\n * client, no router. A client component on such a route is inert markup — a\n * button that does nothing — so the build refuses the combination rather\n * than shipping it.\n *\n * 'auto' when the page declares nothing: a route that freezes whole with no\n * client component and no server action in it is stored without the\n * bootstrap, because there is nothing for a runtime to do. `true` forces\n * the runtime onto such a page.\n */\n clientJs: boolean | 'auto'\n}\n\nexport interface ManifestIntercept {\n component: string\n slot: string\n segments: RouteSegment[]\n /** (.) same level, (..) one up, (...) from the root. */\n marker: string\n}\n\n/**\n * A `route.ts` — an api endpoint rather than a page.\n *\n * Separate from `routes` because it is matched before them and answered\n * without rendering anything: no layouts, no payload, no client. A url cannot\n * be both, and the build refuses one that is.\n */\nexport interface ManifestApiRoute {\n /** The module name, as the engine's registry keys it. */\n name: string\n segments: RouteSegment[]\n /** Which methods the file exports, so a 405 can name the rest. */\n methods: string[]\n /**\n * `middleware.ts` files above this route, outermost first.\n *\n * The same chain a page in that directory runs. A route.ts sits among the\n * pages it belongs with, so a guard on the directory covers it too —\n * anything else would mean adding an endpoint under a guarded path silently\n * opened a hole in it.\n */\n middleware: string[]\n}\n\nexport interface RouteManifest {\n version: number\n build: { output: string; exportPath: string; payloadName: string }\n routes: ManifestRoute[]\n intercepts: ManifestIntercept[]\n /** Optional: a manifest from a build before api routes existed has none. */\n apis?: ManifestApiRoute[]\n}\n"]}
@@ -1,4 +1,5 @@
1
1
  import type { ManifestRoute, RouteManifest } from './manifest.js';
2
+ export declare function withWorkerRegistration(html: string): string;
2
3
  export interface PrerenderEngine {
3
4
  manifest?(): RouteManifest;
4
5
  /**
@@ -60,6 +61,8 @@ export interface PrerenderEngine {
60
61
  body: string;
61
62
  rscPayload: string;
62
63
  clientComponents: string[];
64
+ /** A server action is in the tree - a form or a client prop - so a runtime is needed to submit it. */
65
+ serverReferences?: boolean;
63
66
  usedDynamicApis?: boolean;
64
67
  }>;
65
68
  handleRscPayload(component: string, props?: Record<string, unknown>, layouts?: {
@@ -71,6 +74,13 @@ export interface PrerenderEngine {
71
74
  }
72
75
  export interface PrerenderOptions {
73
76
  engine: PrerenderEngine;
77
+ /**
78
+ * The app has a service worker. A page stored without the bootstrap has
79
+ * nothing to register it, so one line is put in its place - the worker is
80
+ * for the second visit, and a visitor who lands on such a page first should
81
+ * still get one.
82
+ */
83
+ serviceWorker?: boolean;
74
84
  /**
75
85
  * Where the output goes, as a sink rather than a directory.
76
86
  *
@@ -141,6 +151,8 @@ export interface PrerenderResult {
141
151
  * page in the app shows the same thing while this one's data arrives.
142
152
  */
143
153
  warning?: string;
154
+ /** A fact about how it was stored that is neither a reason nor a warning. */
155
+ note?: string;
144
156
  }
145
157
  /**
146
158
  * The file name a route's shell is stored under, with params standing in for
@@ -200,6 +212,7 @@ export declare function clientJsSize(bytes: number): string;
200
212
  * forty times.
201
213
  */
202
214
  export declare function notes(results: {
215
+ type?: string;
203
216
  reason?: string | null;
204
217
  }[]): string;
205
218
  /**
package/dist/prerender.js CHANGED
@@ -41,6 +41,31 @@ const ROOT_FALLBACK_BUDGET_MS = 200;
41
41
  * fixtures, where six was enough to turn a two-second budget into a timeout.
42
42
  */
43
43
  const DEFAULT_PRERENDER_CONCURRENCY = 4;
44
+ /**
45
+ * The client components the engine itself puts around every page when the
46
+ * bootstrap is on - the segment and slot boundaries, the title, the pathname
47
+ * provider, the error boundary. They are in every payload, they are not the
48
+ * app's, and a render without the bootstrap does not insert them. Anything
49
+ * else in the list is the app's, and needs the runtime.
50
+ */
51
+ const RUNTIME_OWN = new Set([
52
+ 'DocumentTitle',
53
+ 'PathnameProvider',
54
+ 'RouteErrorBoundary',
55
+ 'SegmentBoundary',
56
+ 'SlotBoundary',
57
+ ]);
58
+ /**
59
+ * The one line the bootstrap would have run for the worker, for a document
60
+ * stored without a bootstrap. Same timing as the runtime's: after load, so
61
+ * it competes with nothing the first visit needs. A page whose document has
62
+ * no closing body tag is left alone.
63
+ */
64
+ const WORKER_REGISTRATION = "<script>'serviceWorker'in navigator&&addEventListener('load',function(){navigator.serviceWorker.register('/sw.js').catch(function(){})})</script>";
65
+ export function withWorkerRegistration(html) {
66
+ const at = html.lastIndexOf('</body>');
67
+ return at === -1 ? html : html.slice(0, at) + WORKER_REGISTRATION + html.slice(at);
68
+ }
44
69
  /**
45
70
  * The build's refusal to store a route it cannot answer ahead of time.
46
71
  *
@@ -78,6 +103,9 @@ export class NotPrerenderable extends Error {
78
103
  super('Some routes could not be prerendered.\n\n' + advice.join('\n\n') + '\n');
79
104
  this.name = 'NotPrerenderable';
80
105
  this.routes = routes;
106
+ // Not enumerable: Vite prints an error's own properties under its stack,
107
+ // and this list is the message again, as an object dump.
108
+ Object.defineProperty(this, 'routes', { value: routes, enumerable: false });
81
109
  }
82
110
  }
83
111
  /**
@@ -129,11 +157,11 @@ export function legend(results) {
129
157
  if (has('shell')) {
130
158
  lines.push(' \u25D0 (Partial Prerender) prerendered as static HTML with dynamic server-streamed content');
131
159
  }
132
- if (has('blocked') || has('dynamic')) {
160
+ if (has('dynamic')) {
133
161
  lines.push(' \u0192 (Dynamic) server-rendered on demand');
134
162
  }
135
- if (has('error')) {
136
- lines.push(' \u2717 (Failed) did not render');
163
+ if (has('error') || has('blocked')) {
164
+ lines.push(' \u2717 (Failed) did not render, or painted nothing — the build stops here');
137
165
  }
138
166
  return lines.join('\n');
139
167
  }
@@ -168,8 +196,25 @@ export function clientJsSize(bytes) {
168
196
  */
169
197
  export function notes(results) {
170
198
  const said = (text) => results.some((r) => r.reason?.includes(text));
171
- if (!said('rpc('))
172
- return '';
199
+ const parts = [];
200
+ // Every route not frozen, all for one reason, is the shape a read in the
201
+ // root layout leaves: one cookies() in a header component and nothing on
202
+ // the site can be stored. The build cannot see which component read it -
203
+ // only that every page did - so it says what that usually means.
204
+ const pages = results.filter((r) => r.type && r.type !== 'error' && r.type !== 'blocked');
205
+ const reasons = new Set(pages.map((r) => r.reason ?? ''));
206
+ if (pages.length > 1 && !pages.some((r) => r.type === 'frozen') && reasons.size === 1) {
207
+ const [reason] = reasons;
208
+ parts.push(` Every route ${reason}. When one read makes every page dynamic it is usually\n` +
209
+ ' in the root layout - a header reading the session, a locale from a cookie.\n' +
210
+ ' Move that read into the component that needs it, under a <Suspense>, and\n' +
211
+ ' the rest of the site can freeze around it.');
212
+ }
213
+ if (said('rpc('))
214
+ parts.push(rpcNote());
215
+ return parts.join('\n\n');
216
+ }
217
+ function rpcNote() {
173
218
  return (' A build has no backend to call. rpc() suspends instead of answering, so a page\n' +
174
219
  ' that reads through one ships a shell and finishes for whoever asks — which is\n' +
175
220
  ' almost always what you want, since that data is rarely the same for everyone.\n' +
@@ -188,11 +233,10 @@ export function summary(results) {
188
233
  parts.push(`${count('frozen')} static`);
189
234
  if (count('shell'))
190
235
  parts.push(`${count('shell')} partial prerender`);
191
- if (count('blocked') + count('dynamic')) {
192
- parts.push(`${count('blocked') + count('dynamic')} dynamic`);
193
- }
194
- if (count('error'))
195
- parts.push(`${count('error')} failed`);
236
+ if (count('dynamic'))
237
+ parts.push(`${count('dynamic')} dynamic`);
238
+ if (count('error') + count('blocked'))
239
+ parts.push(`${count('error') + count('blocked')} failed`);
196
240
  return parts.join(', ') || 'nothing to store';
197
241
  }
198
242
  export function pathKey(url) {
@@ -536,8 +580,26 @@ export async function prerender(options) {
536
580
  return said('error', `ships no client runtime, but the tree renders ${rendered.clientComponents.join(', ')}. ` +
537
581
  'These usually come from a shared layout rather than the page itself.');
538
582
  }
583
+ // Nothing for a runtime to do. No client component means nothing to
584
+ // hydrate, and no server reference means no form that would post through
585
+ // React - so the bootstrap is 70 kB of javascript that runs and changes
586
+ // nothing. Rendered once more without it and stored that way. Only when
587
+ // the page declared nothing: `clientJs = true` keeps the runtime, for a
588
+ // page that wants the service worker or the update prompt regardless.
589
+ let note;
590
+ let body = rendered.body;
591
+ if (route.clientJs === 'auto' &&
592
+ rendered.clientComponents.every((name) => RUNTIME_OWN.has(name)) &&
593
+ !rendered.serverReferences) {
594
+ // Only the document. The flight payload stays the bootstrap render's:
595
+ // a Link elsewhere navigating here fetches it and expects the segment
596
+ // and title wrappers that render puts in, which this one leaves out.
597
+ const bare = await engine.handleRsc(route.component, props, null, layouts, route.loadings, route.slots, 0, url, false, false);
598
+ body = options.serviceWorker ? withWorkerRegistration(bare.body) : bare.body;
599
+ note = 'no client components, so ships no javascript';
600
+ }
539
601
  const key = pathKey(url);
540
- await write(`${key}.html`, rendered.body);
602
+ await write(`${key}.html`, body);
541
603
  await write(`${key}.flight`, rendered.rscPayload);
542
604
  // One variant per depth the client might already hold. Without them every
543
605
  // navigation to a prerendered route is a whole document, which replaces the
@@ -549,7 +611,8 @@ export async function prerender(options) {
549
611
  await write(`${key}.seg${depth}.flight`, rscPayload);
550
612
  }
551
613
  await write(`${key}.meta.json`, JSON.stringify({ layouts: route.layouts, component: route.component, version: version ?? null }, null, 2));
552
- return noteNondeterminism(await withRootFallbackChecked(said('frozen', null)));
614
+ const frozen = noteNondeterminism(await withRootFallbackChecked(said('frozen', null)));
615
+ return note ? { ...frozen, note } : frozen;
553
616
  }
554
617
  /**
555
618
  * Freeze a shell, under the url when it is one and under the route's pattern