@rsc-kit/core 0.13.0 → 0.14.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 +26 -4
- package/dist/action.js +12 -2
- package/dist/action.js.map +1 -1
- package/dist/js/nuqs.d.ts +4 -0
- package/dist/js/nuqs.js +64 -0
- package/dist/js/nuqs.js.map +1 -0
- package/dist/js/useSearchParams.js +11 -4
- package/dist/js/useSearchParams.js.map +1 -1
- package/dist/metadata.d.ts +58 -0
- package/dist/metadata.js.map +1 -1
- package/dist/testing.d.ts +29 -0
- package/dist/testing.js +102 -0
- package/dist/testing.js.map +1 -0
- package/dist/vite.js +151 -7
- package/dist/vite.js.map +1 -1
- package/package.json +16 -3
package/dist/action.d.ts
CHANGED
|
@@ -26,12 +26,37 @@ export declare class ActionValidationError extends Error {
|
|
|
26
26
|
}
|
|
27
27
|
/** Whether this is a refusal, whichever copy of the class built it. */
|
|
28
28
|
export declare function isActionValidationError(error: unknown): error is ActionValidationError;
|
|
29
|
+
/**
|
|
30
|
+
* The field errors a handler may report, keyed by its own input's fields.
|
|
31
|
+
*
|
|
32
|
+
* `''` is the whole submission — for a refusal that is about no field in
|
|
33
|
+
* particular, which is where the form already looks for one.
|
|
34
|
+
*/
|
|
35
|
+
export type FieldErrorsFor<Input> = Partial<Record<(Input extends object ? keyof Input & string : string) | '', string | string[]>>;
|
|
36
|
+
/**
|
|
37
|
+
* What a handler is given.
|
|
38
|
+
*
|
|
39
|
+
* `fieldErrors` is here as well as exported on its own, and the one here is
|
|
40
|
+
* the one to use: it is typed to this handler's input, so a field the schema
|
|
41
|
+
* does not have is a type error rather than an error the form never shows.
|
|
42
|
+
* The bare export takes any string, for the rare check that runs outside a
|
|
43
|
+
* handler.
|
|
44
|
+
*/
|
|
45
|
+
export interface HandlerArgs<Input, Ctx> {
|
|
46
|
+
input: Input;
|
|
47
|
+
ctx: Ctx;
|
|
48
|
+
/** Fail with errors on this input's fields. Throws; nothing after it runs. */
|
|
49
|
+
fieldErrors: (errors: FieldErrorsFor<Input>) => never;
|
|
50
|
+
}
|
|
29
51
|
/**
|
|
30
52
|
* Fail with field errors the form can show.
|
|
31
53
|
*
|
|
32
54
|
* For what a schema cannot know — a name already taken, a balance too low.
|
|
33
55
|
* Throws, so the handler stops where it is; the action turns it into a
|
|
34
56
|
* returned result on the way out.
|
|
57
|
+
*
|
|
58
|
+
* Untyped by field, because it has no handler to take the input from. Inside
|
|
59
|
+
* one, use the `fieldErrors` the handler is given instead.
|
|
35
60
|
*/
|
|
36
61
|
export declare function fieldErrors(errors: Record<string, string[] | string>): never;
|
|
37
62
|
/**
|
|
@@ -71,10 +96,7 @@ export interface ActionBuilder<Ctx extends Record<string, unknown>, Input> {
|
|
|
71
96
|
/** Parse and check what the caller sent. The handler's `input` follows. */
|
|
72
97
|
input<S extends StandardSchemaV1>(schema: S): ActionBuilder<Ctx, Output<S>>;
|
|
73
98
|
/** The body. */
|
|
74
|
-
handler<Data>(fn: (args:
|
|
75
|
-
input: Input;
|
|
76
|
-
ctx: Ctx;
|
|
77
|
-
}) => Promise<Data> | Data): (input?: unknown) => Promise<ActionResult<Data>>;
|
|
99
|
+
handler<Data>(fn: (args: HandlerArgs<Input, Ctx>) => Promise<Data> | Data): (input?: unknown) => Promise<ActionResult<Data>>;
|
|
78
100
|
/**
|
|
79
101
|
* The body of a READ, sharing this client's middleware and schema.
|
|
80
102
|
*
|
package/dist/action.js
CHANGED
|
@@ -70,6 +70,9 @@ export function isActionValidationError(error) {
|
|
|
70
70
|
* For what a schema cannot know — a name already taken, a balance too low.
|
|
71
71
|
* Throws, so the handler stops where it is; the action turns it into a
|
|
72
72
|
* returned result on the way out.
|
|
73
|
+
*
|
|
74
|
+
* Untyped by field, because it has no handler to take the input from. Inside
|
|
75
|
+
* one, use the `fieldErrors` the handler is given instead.
|
|
73
76
|
*/
|
|
74
77
|
export function fieldErrors(errors) {
|
|
75
78
|
const normalised = {};
|
|
@@ -118,8 +121,15 @@ export function createActionClient(options = {}) {
|
|
|
118
121
|
let index = 0;
|
|
119
122
|
const run = async () => {
|
|
120
123
|
const middleware = middlewares[index++];
|
|
121
|
-
if (!middleware)
|
|
122
|
-
return await fn({
|
|
124
|
+
if (!middleware) {
|
|
125
|
+
return await fn({
|
|
126
|
+
input: parsed,
|
|
127
|
+
ctx: ctx,
|
|
128
|
+
// The same function as the export; the type on the way in is what
|
|
129
|
+
// is different, and the type is the handler's input.
|
|
130
|
+
fieldErrors: fieldErrors,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
123
133
|
let continued = false;
|
|
124
134
|
const result = await middleware({
|
|
125
135
|
ctx,
|
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'\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"]}
|
|
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"]}
|
package/dist/js/nuqs.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* nuqs, driving this router instead of `location.assign`.
|
|
4
|
+
*
|
|
5
|
+
* import { NuqsAdapter } from '@rsc-kit/core/nuqs'
|
|
6
|
+
*
|
|
7
|
+
* The stock `nuqs/adapters/react` works for a shallow update — it writes the
|
|
8
|
+
* url and nothing else. For `shallow: false`, where a server component should
|
|
9
|
+
* re-render with the new query, it has no router to call and falls back to a
|
|
10
|
+
* full page load. This hands that case to `navigate()`, which refetches the
|
|
11
|
+
* page's payload in place and keeps every bit of client state.
|
|
12
|
+
*
|
|
13
|
+
* THIS BELONGS IN NUQS, and is here until it can be. Every other adapter
|
|
14
|
+
* nuqs has lives in nuqs — `nuqs/adapters/tanstack-router` imports
|
|
15
|
+
* @tanstack/react-router and takes it as a peer — and the `custom` api this is
|
|
16
|
+
* built on is marked `unstable_` because it is the escape hatch for a
|
|
17
|
+
* framework that does not have one in-tree yet. A first-party adapter for a
|
|
18
|
+
* framework with no users is a hard ask of a maintainer; once there are
|
|
19
|
+
* users, this is a forty-line contribution that already works. When it lands
|
|
20
|
+
* as `nuqs/adapters/rsc-kit`, this becomes a re-export pointing there and then
|
|
21
|
+
* goes away.
|
|
22
|
+
*
|
|
23
|
+
* Shipped from here in the meantime rather than pasted into apps because it
|
|
24
|
+
* is the one piece of glue that is easy to get subtly wrong — our
|
|
25
|
+
* useSearchParams listens for an event rather than for history changes, and
|
|
26
|
+
* an adapter that forgets to fire it leaves the rest of the page reading a
|
|
27
|
+
* stale query.
|
|
28
|
+
*
|
|
29
|
+
* nuqs is an optional peer: this entry is the only thing that imports it.
|
|
30
|
+
*/
|
|
31
|
+
import { unstable_createAdapterProvider as createAdapterProvider, renderQueryString } from 'nuqs/adapters/custom';
|
|
32
|
+
import { visit } from './router';
|
|
33
|
+
import { useSearchParams } from './useSearchParams';
|
|
34
|
+
function useRscKitAdapter() {
|
|
35
|
+
const searchParams = useSearchParams();
|
|
36
|
+
return {
|
|
37
|
+
searchParams,
|
|
38
|
+
updateUrl(search, { history, scroll, shallow }) {
|
|
39
|
+
const url = location.pathname + renderQueryString(search) + location.hash;
|
|
40
|
+
if (shallow) {
|
|
41
|
+
// The url only. Nothing on the server needs to know, so nothing is
|
|
42
|
+
// fetched — but our own useSearchParams listens for this event rather
|
|
43
|
+
// than for history changes, so it is told.
|
|
44
|
+
window.history[history === 'push' ? 'pushState' : 'replaceState'](history === 'push' ? null : window.history.state, '', url);
|
|
45
|
+
window.dispatchEvent(new CustomEvent('rsc-navigate', { detail: url }));
|
|
46
|
+
if (scroll)
|
|
47
|
+
window.scrollTo(0, 0);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// A server component reads this query, so the page is refetched. The
|
|
51
|
+
// promise is what keeps nuqs's isPending true until the new payload
|
|
52
|
+
// has landed rather than until the url changed.
|
|
53
|
+
//
|
|
54
|
+
// Through router.ts rather than navigate.ts directly: client components
|
|
55
|
+
// are built apart from the bootstrap, and importing the router module
|
|
56
|
+
// here would put a second copy of it in this chunk.
|
|
57
|
+
return visit(url, { replace: history !== 'push' });
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// Annotated, because the inferred type names a file inside nuqs's dist that a
|
|
62
|
+
// declaration file cannot portably refer to. The shape is the same.
|
|
63
|
+
export const NuqsAdapter = createAdapterProvider(useRscKitAdapter);
|
|
64
|
+
//# sourceMappingURL=nuqs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nuqs.js","sourceRoot":"","sources":["../../src/js/nuqs.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;AAEZ;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,8BAA8B,IAAI,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAA;AAGjH,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAA;AAChC,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAGnD,SAAS,gBAAgB;IACvB,MAAM,YAAY,GAAG,eAAe,EAAE,CAAA;IAEtC,OAAO;QACL,YAAY;QACZ,SAAS,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE;YAC5C,MAAM,GAAG,GAAG,QAAQ,CAAC,QAAQ,GAAG,iBAAiB,CAAC,MAAM,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAA;YAEzE,IAAI,OAAO,EAAE,CAAC;gBACZ,mEAAmE;gBACnE,sEAAsE;gBACtE,2CAA2C;gBAC3C,MAAM,CAAC,OAAO,CAAC,OAAO,KAAK,MAAM,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,OAAO,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,EAAE,GAAG,CAAC,CAAA;gBAC5H,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,cAAc,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC,CAAA;gBAEtE,IAAI,MAAM;oBAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;gBAEjC,OAAM;YACR,CAAC;YAED,qEAAqE;YACrE,oEAAoE;YACpE,gDAAgD;YAChD,EAAE;YACF,wEAAwE;YACxE,sEAAsE;YACtE,oDAAoD;YACpD,OAAO,KAAK,CAAC,GAAW,EAAE,EAAE,OAAO,EAAE,OAAO,KAAK,MAAM,EAAE,CAAC,CAAA;QAC5D,CAAC;KACF,CAAA;AACH,CAAC;AAED,8EAA8E;AAC9E,oEAAoE;AACpE,MAAM,CAAC,MAAM,WAAW,GAA2C,qBAAqB,CAAC,gBAAgB,CAAC,CAAA","sourcesContent":["'use client'\n\n/**\n * nuqs, driving this router instead of `location.assign`.\n *\n * import { NuqsAdapter } from '@rsc-kit/core/nuqs'\n *\n * The stock `nuqs/adapters/react` works for a shallow update — it writes the\n * url and nothing else. For `shallow: false`, where a server component should\n * re-render with the new query, it has no router to call and falls back to a\n * full page load. This hands that case to `navigate()`, which refetches the\n * page's payload in place and keeps every bit of client state.\n *\n * THIS BELONGS IN NUQS, and is here until it can be. Every other adapter\n * nuqs has lives in nuqs — `nuqs/adapters/tanstack-router` imports\n * @tanstack/react-router and takes it as a peer — and the `custom` api this is\n * built on is marked `unstable_` because it is the escape hatch for a\n * framework that does not have one in-tree yet. A first-party adapter for a\n * framework with no users is a hard ask of a maintainer; once there are\n * users, this is a forty-line contribution that already works. When it lands\n * as `nuqs/adapters/rsc-kit`, this becomes a re-export pointing there and then\n * goes away.\n *\n * Shipped from here in the meantime rather than pasted into apps because it\n * is the one piece of glue that is easy to get subtly wrong — our\n * useSearchParams listens for an event rather than for history changes, and\n * an adapter that forgets to fire it leaves the rest of the page reading a\n * stale query.\n *\n * nuqs is an optional peer: this entry is the only thing that imports it.\n */\n\nimport { unstable_createAdapterProvider as createAdapterProvider, renderQueryString } from 'nuqs/adapters/custom'\nimport type { unstable_AdapterInterface as AdapterInterface } from 'nuqs/adapters/custom'\nimport type { ComponentType, ReactNode } from 'react'\nimport { visit } from './router'\nimport { useSearchParams } from './useSearchParams'\nimport type { Href } from '../routes'\n\nfunction useRscKitAdapter(): AdapterInterface {\n const searchParams = useSearchParams()\n\n return {\n searchParams,\n updateUrl(search, { history, scroll, shallow }) {\n const url = location.pathname + renderQueryString(search) + location.hash\n\n if (shallow) {\n // The url only. Nothing on the server needs to know, so nothing is\n // fetched — but our own useSearchParams listens for this event rather\n // than for history changes, so it is told.\n window.history[history === 'push' ? 'pushState' : 'replaceState'](history === 'push' ? null : window.history.state, '', url)\n window.dispatchEvent(new CustomEvent('rsc-navigate', { detail: url }))\n\n if (scroll) window.scrollTo(0, 0)\n\n return\n }\n\n // A server component reads this query, so the page is refetched. The\n // promise is what keeps nuqs's isPending true until the new payload\n // has landed rather than until the url changed.\n //\n // Through router.ts rather than navigate.ts directly: client components\n // are built apart from the bootstrap, and importing the router module\n // here would put a second copy of it in this chunk.\n return visit(url as Href, { replace: history !== 'push' })\n },\n }\n}\n\n// Annotated, because the inferred type names a file inside nuqs's dist that a\n// declaration file cannot portably refer to. The shape is the same.\nexport const NuqsAdapter: ComponentType<{ children: ReactNode }> = createAdapterProvider(useRscKitAdapter)\n"]}
|
|
@@ -34,11 +34,18 @@ function currentSearch() {
|
|
|
34
34
|
function notify() {
|
|
35
35
|
listeners.forEach((fn) => fn());
|
|
36
36
|
}
|
|
37
|
-
|
|
38
|
-
window.addEventListener("rsc-navigate", notify);
|
|
39
|
-
window.addEventListener("popstate", notify);
|
|
40
|
-
}
|
|
37
|
+
let listening = false;
|
|
41
38
|
function subscribe(callback) {
|
|
39
|
+
// On the first subscriber rather than at module load. A listener attached
|
|
40
|
+
// when the module is evaluated is attached to whatever `window` exists at
|
|
41
|
+
// that moment — which in a test that installs a DOM after its imports is
|
|
42
|
+
// none, and the hook silently never updates. Attaching here is the shape
|
|
43
|
+
// useSyncExternalStore expects and is correct wherever the module loads.
|
|
44
|
+
if (!listening && typeof window !== "undefined") {
|
|
45
|
+
window.addEventListener("rsc-navigate", notify);
|
|
46
|
+
window.addEventListener("popstate", notify);
|
|
47
|
+
listening = true;
|
|
48
|
+
}
|
|
42
49
|
listeners.add(callback);
|
|
43
50
|
return () => listeners.delete(callback);
|
|
44
51
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSearchParams.js","sourceRoot":"","sources":["../../src/js/useSearchParams.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAC;AAE7C,MAAM,SAAS,GAAG,IAAI,GAAG,EAAc,CAAC;AAExC;;;;;;GAMG;AACH,IAAI,MAAM,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,eAAe,EAAE,EAAE,CAAC;AAE3D,SAAS,aAAa;IACpB,OAAO,OAAO,MAAM,KAAK,WAAW,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC;AACrE,CAAC;AAED,SAAS,MAAM;IACb,SAAS,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;AAClC,CAAC;AAED,IAAI,OAAO,MAAM,KAAK,WAAW,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"useSearchParams.js","sourceRoot":"","sources":["../../src/js/useSearchParams.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAC;AAE7C,MAAM,SAAS,GAAG,IAAI,GAAG,EAAc,CAAC;AAExC;;;;;;GAMG;AACH,IAAI,MAAM,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,eAAe,EAAE,EAAE,CAAC;AAE3D,SAAS,aAAa;IACpB,OAAO,OAAO,MAAM,KAAK,WAAW,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC;AACrE,CAAC;AAED,SAAS,MAAM;IACb,SAAS,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;AAClC,CAAC;AAED,IAAI,SAAS,GAAG,KAAK,CAAC;AAEtB,SAAS,SAAS,CAAC,QAAoB;IACrC,0EAA0E;IAC1E,0EAA0E;IAC1E,yEAAyE;IACzE,yEAAyE;IACzE,yEAAyE;IACzE,IAAI,CAAC,SAAS,IAAI,OAAO,MAAM,KAAK,WAAW,EAAE,CAAC;QAChD,MAAM,CAAC,gBAAgB,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;QAChD,MAAM,CAAC,gBAAgB,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;QAC5C,SAAS,GAAG,IAAI,CAAC;IACnB,CAAC;IAED,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAExB,OAAO,GAAG,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;AAC1C,CAAC;AAED,SAAS,WAAW;IAClB,MAAM,MAAM,GAAG,aAAa,EAAE,CAAC;IAE/B,IAAI,MAAM,KAAK,MAAM,CAAC,MAAM,EAAE,CAAC;QAC7B,MAAM,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,eAAe,CAAC,MAAM,CAAC,EAAE,CAAC;IAC3D,CAAC;IAED,OAAO,MAAM,CAAC,MAAM,CAAC;AACvB,CAAC;AAED,SAAS,iBAAiB;IACxB,MAAM,IAAI,KAAK,CACb,4FAA4F;QAC1F,4FAA4F;QAC5F,sDAAsD,CACzD,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,eAAe;IAC7B,OAAO,oBAAoB,CAAC,SAAS,EAAE,WAAW,EAAE,iBAAiB,CAAC,CAAC;AACzE,CAAC","sourcesContent":["\"use client\";\n\n/**\n * The query string, live across navigations.\n *\n * Deliberately unlike usePathname, which answers with the url being rendered.\n * A pathname is fixed for a given stored page; a query string is not — the\n * same route is asked for with `?q=shoes` and `?q=hats`, and a page stored\n * holding one of them would serve it to everyone.\n *\n * So there is no server snapshot to give, and this throws rather than\n * inventing one. React treats an error thrown during SSR as recoverable at the\n * nearest Suspense boundary: the fallback is what gets stored, and the client\n * renders the real thing on hydration. Without a boundary the error reaches the\n * root, nothing paints, and the build refuses the route and says why — which\n * is the same answer it gives for reading the request too early on the server.\n *\n * Returning an empty URLSearchParams instead would be worse than either: the\n * page would be stored showing results for no query at all, and nothing would\n * say so.\n */\n\nimport { useSyncExternalStore } from \"react\";\n\nconst listeners = new Set<() => void>();\n\n/**\n * Cached by the string it was parsed from.\n *\n * useSyncExternalStore compares snapshots by identity, so handing back a fresh\n * URLSearchParams on every read reads as \"changed every time\" and loops until\n * React gives up.\n */\nlet cached = { search: \"\", params: new URLSearchParams() };\n\nfunction currentSearch(): string {\n return typeof window === \"undefined\" ? \"\" : window.location.search;\n}\n\nfunction notify(): void {\n listeners.forEach((fn) => fn());\n}\n\nlet listening = false;\n\nfunction subscribe(callback: () => void): () => void {\n // On the first subscriber rather than at module load. A listener attached\n // when the module is evaluated is attached to whatever `window` exists at\n // that moment — which in a test that installs a DOM after its imports is\n // none, and the hook silently never updates. Attaching here is the shape\n // useSyncExternalStore expects and is correct wherever the module loads.\n if (!listening && typeof window !== \"undefined\") {\n window.addEventListener(\"rsc-navigate\", notify);\n window.addEventListener(\"popstate\", notify);\n listening = true;\n }\n\n listeners.add(callback);\n\n return () => listeners.delete(callback);\n}\n\nfunction getSnapshot(): URLSearchParams {\n const search = currentSearch();\n\n if (search !== cached.search) {\n cached = { search, params: new URLSearchParams(search) };\n }\n\n return cached.params;\n}\n\nfunction getServerSnapshot(): URLSearchParams {\n throw new Error(\n \"useSearchParams() was read while rendering on the server, where there is no query string. \" +\n \"Wrap the component in <Suspense> — or add a loading.tsx beside the page — so the fallback \" +\n \"is stored and the real value arrives in the browser.\",\n );\n}\n\nexport function useSearchParams(): URLSearchParams {\n return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n}\n"]}
|
package/dist/metadata.d.ts
CHANGED
|
@@ -21,6 +21,40 @@ export interface TitleTemplate {
|
|
|
21
21
|
/** Used by a page that exports no title of its own. */
|
|
22
22
|
default?: string;
|
|
23
23
|
}
|
|
24
|
+
/** One image a share card may show. A string is its url. */
|
|
25
|
+
export interface OpenGraphImage {
|
|
26
|
+
url: string | URL;
|
|
27
|
+
width?: number;
|
|
28
|
+
height?: number;
|
|
29
|
+
alt?: string;
|
|
30
|
+
type?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The card a link to this page unfurls into on Facebook, Slack, LinkedIn and
|
|
34
|
+
* most of the rest. Rendered with `property=`, which is what those scrapers
|
|
35
|
+
* read — a `name=` attribute is ignored by every one of them.
|
|
36
|
+
*/
|
|
37
|
+
export interface OpenGraph {
|
|
38
|
+
title?: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
/** Absolute, or relative to `metadataBase`. */
|
|
41
|
+
url?: string | URL;
|
|
42
|
+
siteName?: string;
|
|
43
|
+
type?: 'website' | 'article' | 'profile' | 'book' | (string & {});
|
|
44
|
+
locale?: string;
|
|
45
|
+
images?: string | URL | OpenGraphImage | (string | URL | OpenGraphImage)[];
|
|
46
|
+
}
|
|
47
|
+
/** The same card for X, which reads `name=` rather than `property=`. */
|
|
48
|
+
export interface Twitter {
|
|
49
|
+
card?: 'summary' | 'summary_large_image' | 'app' | 'player';
|
|
50
|
+
title?: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
/** The site's account, `@handle`. */
|
|
53
|
+
site?: string;
|
|
54
|
+
/** The author's account, `@handle`. */
|
|
55
|
+
creator?: string;
|
|
56
|
+
images?: string | URL | OpenGraphImage | (string | URL | OpenGraphImage)[];
|
|
57
|
+
}
|
|
24
58
|
export interface Metadata {
|
|
25
59
|
/** A string on a page; a template on a layout, applied to the pages below it. */
|
|
26
60
|
title?: string | TitleTemplate;
|
|
@@ -28,17 +62,41 @@ export interface Metadata {
|
|
|
28
62
|
keywords?: string | string[];
|
|
29
63
|
author?: string;
|
|
30
64
|
robots?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Where the site lives, so a relative image or url can be made absolute.
|
|
67
|
+
*
|
|
68
|
+
* metadataBase: new URL('https://example.com')
|
|
69
|
+
*
|
|
70
|
+
* On the root layout, once. A share-card scraper needs an absolute url and
|
|
71
|
+
* several refuse a relative one; without this, `og:image` for an image in
|
|
72
|
+
* `app/` is emitted relative and works in some places and not others. The
|
|
73
|
+
* same name as Next, so a port carries it across unchanged.
|
|
74
|
+
*/
|
|
75
|
+
metadataBase?: string | URL;
|
|
31
76
|
icons?: IconURL | (IconURL | IconDescriptor)[] | Icons | null;
|
|
77
|
+
openGraph?: OpenGraph;
|
|
78
|
+
twitter?: Twitter;
|
|
79
|
+
/** @deprecated Use `openGraph.title`. Still rendered, correctly, as `property=`. */
|
|
32
80
|
'og:title'?: string;
|
|
81
|
+
/** @deprecated Use `openGraph.description`. */
|
|
33
82
|
'og:description'?: string;
|
|
83
|
+
/** @deprecated Use `openGraph.images`. */
|
|
34
84
|
'og:image'?: string;
|
|
85
|
+
/** @deprecated Use `openGraph.url`. */
|
|
35
86
|
'og:url'?: string;
|
|
87
|
+
/** @deprecated Use `openGraph.type`. */
|
|
36
88
|
'og:type'?: string;
|
|
89
|
+
/** @deprecated Use `openGraph.siteName`. */
|
|
37
90
|
'og:site_name'?: string;
|
|
91
|
+
/** @deprecated Use `twitter.card`. */
|
|
38
92
|
'twitter:card'?: string;
|
|
93
|
+
/** @deprecated Use `twitter.title`. */
|
|
39
94
|
'twitter:title'?: string;
|
|
95
|
+
/** @deprecated Use `twitter.description`. */
|
|
40
96
|
'twitter:description'?: string;
|
|
97
|
+
/** @deprecated Use `twitter.images`. */
|
|
41
98
|
'twitter:image'?: string;
|
|
99
|
+
/** @deprecated Use `twitter.site`. */
|
|
42
100
|
'twitter:site'?: string;
|
|
43
101
|
/**
|
|
44
102
|
* Any other meta tag, by name.
|
package/dist/metadata.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"metadata.js","sourceRoot":"","sources":["../src/metadata.ts"],"names":[],"mappings":"AAAA,sDAAsD;AACtD,EAAE;AACF,6DAA6D;AAC7D,EAAE;AACF,4DAA4D;AAC5D,EAAE;AACF,4EAA4E;AAC5E,+EAA+E;AAC/E,+EAA+E;AAC/E,4EAA4E;AAC5E,2EAA2E;AAC3E,EAAE;AACF,4EAA4E;AAC5E,+EAA+E;AAC/E,MAAM","sourcesContent":["// What a page says about itself, as importable types.\n//\n// import type { Metadata } from '@rsc-kit/core/metadata'\n//\n// export const metadata: Metadata = { title: 'Orders' }\n//\n// Imported rather than ambient, and that is the whole point of the move. An\n// ambient declaration has to be COPIED into the project, which means it is not\n// there until the build has run once — so a freshly cloned app reports \"Cannot\n// find name 'Metadata'\" on every page until someone runs the dev server. An\n// import resolves from node_modules the moment dependencies are installed.\n//\n// The ambient names still work. `.rsc-kit/rsc-types.d.ts` now aliases these\n// rather than restating them, so there is one definition and two ways to reach\n// it.\n\nexport interface IconDescriptor {\n url: string | URL\n type?: string\n sizes?: string\n color?: string\n rel?: string\n media?: string\n fetchPriority?: 'high' | 'low' | 'auto'\n}\n\nexport type IconURL = string | URL\n\nexport interface Icons {\n icon?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[]\n apple?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[]\n shortcut?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[]\n other?: IconDescriptor | IconDescriptor[]\n}\n\n/** A layout's title, wrapping the titles of the pages beneath it. */\nexport interface TitleTemplate {\n /** `%s` stands in for the page's own title. */\n template?: string\n /** Used by a page that exports no title of its own. */\n default?: string\n}\n\nexport interface Metadata {\n /** A string on a page; a template on a layout, applied to the pages below it. */\n title?: string | TitleTemplate\n description?: string\n keywords?: string | string[]\n author?: string\n robots?: string\n icons?: IconURL | (IconURL | IconDescriptor)[] | Icons | null\n 'og:title'?: string\n 'og:description'?: string\n 'og:image'?: string\n 'og:url'?: string\n 'og:type'?: string\n 'og:site_name'?: string\n 'twitter:card'?: string\n 'twitter:title'?: string\n 'twitter:description'?: string\n 'twitter:image'?: string\n 'twitter:site'?: string\n\n /**\n * Any other meta tag, by name.\n *\n * other: { 'fb:app_id': '123', 'theme-color': '#000' }\n *\n * Here rather than alongside the named keys, and that is what makes the rest\n * of this interface worth annotating. An index signature on the interface\n * itself made every key legal — so `titel` was accepted in silence, and an\n * editor offered no completions at all, because with any identifier valid\n * TypeScript reads an unfinished key as a shorthand property and goes looking\n * for a variable by that name.\n */\n other?: Record<string, string | string[] | null | undefined>\n}\n\n/**\n * Metadata that depends on the request.\n *\n * Receives the same awaitables a page does, so one shape is learned rather\n * than two:\n *\n * export const generateMetadata: GenerateMetadata<{ slug: string }> =\n * async ({ params }) => ({ title: (await params).slug })\n */\nexport type GenerateMetadata<P = Record<string, string>> = (args: {\n params: Promise<P>\n searchParams: Promise<URLSearchParams>\n}) => Metadata | Promise<Metadata>\n"]}
|
|
1
|
+
{"version":3,"file":"metadata.js","sourceRoot":"","sources":["../src/metadata.ts"],"names":[],"mappings":"AAAA,sDAAsD;AACtD,EAAE;AACF,6DAA6D;AAC7D,EAAE;AACF,4DAA4D;AAC5D,EAAE;AACF,4EAA4E;AAC5E,+EAA+E;AAC/E,+EAA+E;AAC/E,4EAA4E;AAC5E,2EAA2E;AAC3E,EAAE;AACF,4EAA4E;AAC5E,+EAA+E;AAC/E,MAAM","sourcesContent":["// What a page says about itself, as importable types.\n//\n// import type { Metadata } from '@rsc-kit/core/metadata'\n//\n// export const metadata: Metadata = { title: 'Orders' }\n//\n// Imported rather than ambient, and that is the whole point of the move. An\n// ambient declaration has to be COPIED into the project, which means it is not\n// there until the build has run once — so a freshly cloned app reports \"Cannot\n// find name 'Metadata'\" on every page until someone runs the dev server. An\n// import resolves from node_modules the moment dependencies are installed.\n//\n// The ambient names still work. `.rsc-kit/rsc-types.d.ts` now aliases these\n// rather than restating them, so there is one definition and two ways to reach\n// it.\n\nexport interface IconDescriptor {\n url: string | URL\n type?: string\n sizes?: string\n color?: string\n rel?: string\n media?: string\n fetchPriority?: 'high' | 'low' | 'auto'\n}\n\nexport type IconURL = string | URL\n\nexport interface Icons {\n icon?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[]\n apple?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[]\n shortcut?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[]\n other?: IconDescriptor | IconDescriptor[]\n}\n\n/** A layout's title, wrapping the titles of the pages beneath it. */\nexport interface TitleTemplate {\n /** `%s` stands in for the page's own title. */\n template?: string\n /** Used by a page that exports no title of its own. */\n default?: string\n}\n\n/** One image a share card may show. A string is its url. */\nexport interface OpenGraphImage {\n url: string | URL\n width?: number\n height?: number\n alt?: string\n type?: string\n}\n\n/**\n * The card a link to this page unfurls into on Facebook, Slack, LinkedIn and\n * most of the rest. Rendered with `property=`, which is what those scrapers\n * read — a `name=` attribute is ignored by every one of them.\n */\nexport interface OpenGraph {\n title?: string\n description?: string\n /** Absolute, or relative to `metadataBase`. */\n url?: string | URL\n siteName?: string\n type?: 'website' | 'article' | 'profile' | 'book' | (string & {})\n locale?: string\n images?: string | URL | OpenGraphImage | (string | URL | OpenGraphImage)[]\n}\n\n/** The same card for X, which reads `name=` rather than `property=`. */\nexport interface Twitter {\n card?: 'summary' | 'summary_large_image' | 'app' | 'player'\n title?: string\n description?: string\n /** The site's account, `@handle`. */\n site?: string\n /** The author's account, `@handle`. */\n creator?: string\n images?: string | URL | OpenGraphImage | (string | URL | OpenGraphImage)[]\n}\n\nexport interface Metadata {\n /** A string on a page; a template on a layout, applied to the pages below it. */\n title?: string | TitleTemplate\n description?: string\n keywords?: string | string[]\n author?: string\n robots?: string\n /**\n * Where the site lives, so a relative image or url can be made absolute.\n *\n * metadataBase: new URL('https://example.com')\n *\n * On the root layout, once. A share-card scraper needs an absolute url and\n * several refuse a relative one; without this, `og:image` for an image in\n * `app/` is emitted relative and works in some places and not others. The\n * same name as Next, so a port carries it across unchanged.\n */\n metadataBase?: string | URL\n icons?: IconURL | (IconURL | IconDescriptor)[] | Icons | null\n openGraph?: OpenGraph\n twitter?: Twitter\n /** @deprecated Use `openGraph.title`. Still rendered, correctly, as `property=`. */\n 'og:title'?: string\n /** @deprecated Use `openGraph.description`. */\n 'og:description'?: string\n /** @deprecated Use `openGraph.images`. */\n 'og:image'?: string\n /** @deprecated Use `openGraph.url`. */\n 'og:url'?: string\n /** @deprecated Use `openGraph.type`. */\n 'og:type'?: string\n /** @deprecated Use `openGraph.siteName`. */\n 'og:site_name'?: string\n /** @deprecated Use `twitter.card`. */\n 'twitter:card'?: string\n /** @deprecated Use `twitter.title`. */\n 'twitter:title'?: string\n /** @deprecated Use `twitter.description`. */\n 'twitter:description'?: string\n /** @deprecated Use `twitter.images`. */\n 'twitter:image'?: string\n /** @deprecated Use `twitter.site`. */\n 'twitter:site'?: string\n\n /**\n * Any other meta tag, by name.\n *\n * other: { 'fb:app_id': '123', 'theme-color': '#000' }\n *\n * Here rather than alongside the named keys, and that is what makes the rest\n * of this interface worth annotating. An index signature on the interface\n * itself made every key legal — so `titel` was accepted in silence, and an\n * editor offered no completions at all, because with any identifier valid\n * TypeScript reads an unfinished key as a shorthand property and goes looking\n * for a variable by that name.\n */\n other?: Record<string, string | string[] | null | undefined>\n}\n\n/**\n * Metadata that depends on the request.\n *\n * Receives the same awaitables a page does, so one shape is learned rather\n * than two:\n *\n * export const generateMetadata: GenerateMetadata<{ slug: string }> =\n * async ({ params }) => ({ title: (await params).slug })\n */\nexport type GenerateMetadata<P = Record<string, string>> = (args: {\n params: Promise<P>\n searchParams: Promise<URLSearchParams>\n}) => Metadata | Promise<Metadata>\n"]}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface TestApp {
|
|
2
|
+
/** A path, not a url. The origin is whatever the app was told it is. */
|
|
3
|
+
fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
4
|
+
/** Where the build was read from, for a test that wants to look. */
|
|
5
|
+
bundle: string;
|
|
6
|
+
}
|
|
7
|
+
export interface TestAppOptions {
|
|
8
|
+
/** The project. Defaults to the working directory. */
|
|
9
|
+
root?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Whether to build first.
|
|
12
|
+
*
|
|
13
|
+
* `true` builds when the source is newer than the last build, which is what
|
|
14
|
+
* a test run wants: the first run pays for it, the rest do not, and an edit
|
|
15
|
+
* is picked up. `false` never builds and fails loudly if there is nothing to
|
|
16
|
+
* read — for a ci step that already built.
|
|
17
|
+
*/
|
|
18
|
+
build?: boolean;
|
|
19
|
+
/** The origin requests are made against. Nothing reads it; it is a url. */
|
|
20
|
+
origin?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Build the app if it needs it, load it, and hand back something to fetch from.
|
|
24
|
+
*
|
|
25
|
+
* Memoised per root: every test file in a run shares one build and one loaded
|
|
26
|
+
* module, which is both the fast path and the correct one — two copies of the
|
|
27
|
+
* server bundle in one process would be two client-reference registries.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createTestApp(options?: TestAppOptions): Promise<TestApp>;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The whole app as a function, for tests.
|
|
2
|
+
//
|
|
3
|
+
// import { createTestApp } from '@rsc-kit/core/testing'
|
|
4
|
+
//
|
|
5
|
+
// const app = await createTestApp()
|
|
6
|
+
// const res = await app.fetch('/api/orders', { headers: { Cookie: 'user=ada' } })
|
|
7
|
+
//
|
|
8
|
+
// No port, no browser, no server process. The built entry already exports the
|
|
9
|
+
// same Request → Response handler the dev server and the production server
|
|
10
|
+
// both call, so a test can hand it a Request and read the Response — through
|
|
11
|
+
// the real router, the real middleware, the real api routes, and the pages the
|
|
12
|
+
// build stored.
|
|
13
|
+
//
|
|
14
|
+
// That is the tier between a unit test and a browser. An action or a route is
|
|
15
|
+
// a function and can be imported and called; a browser test proves the page
|
|
16
|
+
// works; this proves the app answers a url the way it is deployed, which is
|
|
17
|
+
// where a route that renders fine and is served wrong shows up.
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
import { pathToFileURL } from 'node:url';
|
|
22
|
+
/**
|
|
23
|
+
* Where the build put the server bundle.
|
|
24
|
+
*
|
|
25
|
+
* Two layouts, because two ways of building. Under Nitro — every scaffolded
|
|
26
|
+
* app — it is inside Nitro's own directory. On its own the plugin writes
|
|
27
|
+
* <outDir>/dist/rsc. The first that exists wins.
|
|
28
|
+
*/
|
|
29
|
+
function findBundle(root) {
|
|
30
|
+
const candidates = [
|
|
31
|
+
join(root, 'node_modules/.nitro/vite/services/rsc/index.js'),
|
|
32
|
+
join(root, '.rsc/dist/rsc/index.js'),
|
|
33
|
+
join(root, 'build/dist/rsc/index.js'),
|
|
34
|
+
];
|
|
35
|
+
return candidates.find((path) => existsSync(path)) ?? null;
|
|
36
|
+
}
|
|
37
|
+
/** The newest mtime under a directory, for deciding whether a build is stale. */
|
|
38
|
+
function newest(dir) {
|
|
39
|
+
if (!existsSync(dir))
|
|
40
|
+
return 0;
|
|
41
|
+
let latest = 0;
|
|
42
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
43
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.'))
|
|
44
|
+
continue;
|
|
45
|
+
const path = join(dir, entry.name);
|
|
46
|
+
const time = entry.isDirectory() ? newest(path) : statSync(path).mtimeMs;
|
|
47
|
+
if (time > latest)
|
|
48
|
+
latest = time;
|
|
49
|
+
}
|
|
50
|
+
return latest;
|
|
51
|
+
}
|
|
52
|
+
/** One build per process per root, however many test files ask. */
|
|
53
|
+
const built = new Map();
|
|
54
|
+
async function ensureBuilt(root, build) {
|
|
55
|
+
const existing = findBundle(root);
|
|
56
|
+
if (!build) {
|
|
57
|
+
if (!existing) {
|
|
58
|
+
throw new Error(`[rsc-kit] No build to test against under ${root}. Run the build first, or let createTestApp() do it by leaving \`build\` on.`);
|
|
59
|
+
}
|
|
60
|
+
return existing;
|
|
61
|
+
}
|
|
62
|
+
const fresh = existing && statSync(existing).mtimeMs > newest(join(root, 'src'));
|
|
63
|
+
if (fresh)
|
|
64
|
+
return existing;
|
|
65
|
+
// The user's own build command, so what is tested is what ships. A test
|
|
66
|
+
// that built some other way would pass against a bundle nobody deploys.
|
|
67
|
+
const run = spawnSync('npx', ['vite', 'build'], {
|
|
68
|
+
cwd: root,
|
|
69
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
70
|
+
env: { ...process.env, NODE_ENV: 'production' },
|
|
71
|
+
});
|
|
72
|
+
if (run.status !== 0) {
|
|
73
|
+
throw new Error(`[rsc-kit] The build failed, so there is nothing to test:\n${run.stderr}`);
|
|
74
|
+
}
|
|
75
|
+
const bundle = findBundle(root);
|
|
76
|
+
if (!bundle) {
|
|
77
|
+
throw new Error(`[rsc-kit] The build finished but no server bundle was found under ${root}. Is rscKit() in vite.config?`);
|
|
78
|
+
}
|
|
79
|
+
return bundle;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Build the app if it needs it, load it, and hand back something to fetch from.
|
|
83
|
+
*
|
|
84
|
+
* Memoised per root: every test file in a run shares one build and one loaded
|
|
85
|
+
* module, which is both the fast path and the correct one — two copies of the
|
|
86
|
+
* server bundle in one process would be two client-reference registries.
|
|
87
|
+
*/
|
|
88
|
+
export async function createTestApp(options = {}) {
|
|
89
|
+
const root = resolve(options.root ?? process.cwd());
|
|
90
|
+
const origin = options.origin ?? 'https://app.test';
|
|
91
|
+
built.set(root, built.get(root) ?? ensureBuilt(root, options.build ?? true));
|
|
92
|
+
const bundle = await built.get(root);
|
|
93
|
+
const entry = (await import(pathToFileURL(bundle).href));
|
|
94
|
+
if (typeof entry.default !== 'function') {
|
|
95
|
+
throw new Error(`[rsc-kit] ${bundle} does not export a request handler.`);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
bundle,
|
|
99
|
+
fetch: (path, init) => entry.default(new Request(new URL(path, origin), init)),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=testing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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","sourcesContent":["// The whole app as a function, for tests.\n//\n// import { createTestApp } from '@rsc-kit/core/testing'\n//\n// const app = await createTestApp()\n// const res = await app.fetch('/api/orders', { headers: { Cookie: 'user=ada' } })\n//\n// No port, no browser, no server process. The built entry already exports the\n// same Request → Response handler the dev server and the production server\n// both call, so a test can hand it a Request and read the Response — through\n// the real router, the real middleware, the real api routes, and the pages the\n// build stored.\n//\n// That is the tier between a unit test and a browser. An action or a route is\n// a function and can be imported and called; a browser test proves the page\n// works; this proves the app answers a url the way it is deployed, which is\n// where a route that renders fine and is served wrong shows up.\n\nimport { spawnSync } from 'node:child_process'\nimport { existsSync, readdirSync, statSync } from 'node:fs'\nimport { join, resolve } from 'node:path'\nimport { pathToFileURL } from 'node:url'\n\nexport interface TestApp {\n /** A path, not a url. The origin is whatever the app was told it is. */\n fetch(path: string, init?: RequestInit): Promise<Response>\n /** Where the build was read from, for a test that wants to look. */\n bundle: string\n}\n\nexport interface TestAppOptions {\n /** The project. Defaults to the working directory. */\n root?: string\n /**\n * Whether to build first.\n *\n * `true` builds when the source is newer than the last build, which is what\n * a test run wants: the first run pays for it, the rest do not, and an edit\n * is picked up. `false` never builds and fails loudly if there is nothing to\n * read — for a ci step that already built.\n */\n build?: boolean\n /** The origin requests are made against. Nothing reads it; it is a url. */\n origin?: string\n}\n\n/**\n * Where the build put the server bundle.\n *\n * Two layouts, because two ways of building. Under Nitro — every scaffolded\n * app — it is inside Nitro's own directory. On its own the plugin writes\n * <outDir>/dist/rsc. The first that exists wins.\n */\nfunction findBundle(root: string): string | null {\n const candidates = [\n join(root, 'node_modules/.nitro/vite/services/rsc/index.js'),\n join(root, '.rsc/dist/rsc/index.js'),\n join(root, 'build/dist/rsc/index.js'),\n ]\n\n return candidates.find((path) => existsSync(path)) ?? null\n}\n\n/** The newest mtime under a directory, for deciding whether a build is stale. */\nfunction newest(dir: string): number {\n if (!existsSync(dir)) return 0\n\n let latest = 0\n\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue\n\n const path = join(dir, entry.name)\n const time = entry.isDirectory() ? newest(path) : statSync(path).mtimeMs\n\n if (time > latest) latest = time\n }\n\n return latest\n}\n\n/** One build per process per root, however many test files ask. */\nconst built = new Map<string, Promise<string>>()\n\nasync function ensureBuilt(root: string, build: boolean): Promise<string> {\n const existing = findBundle(root)\n\n if (!build) {\n if (!existing) {\n throw new Error(\n `[rsc-kit] No build to test against under ${root}. Run the build first, or let createTestApp() do it by leaving \\`build\\` on.`,\n )\n }\n\n return existing\n }\n\n const fresh = existing && statSync(existing).mtimeMs > newest(join(root, 'src'))\n\n if (fresh) return existing\n\n // The user's own build command, so what is tested is what ships. A test\n // that built some other way would pass against a bundle nobody deploys.\n const run = spawnSync('npx', ['vite', 'build'], {\n cwd: root,\n stdio: ['ignore', 'pipe', 'pipe'],\n env: { ...process.env, NODE_ENV: 'production' },\n })\n\n if (run.status !== 0) {\n throw new Error(`[rsc-kit] The build failed, so there is nothing to test:\\n${run.stderr}`)\n }\n\n const bundle = findBundle(root)\n\n if (!bundle) {\n throw new Error(\n `[rsc-kit] The build finished but no server bundle was found under ${root}. Is rscKit() in vite.config?`,\n )\n }\n\n return bundle\n}\n\n/**\n * Build the app if it needs it, load it, and hand back something to fetch from.\n *\n * Memoised per root: every test file in a run shares one build and one loaded\n * module, which is both the fast path and the correct one — two copies of the\n * server bundle in one process would be two client-reference registries.\n */\nexport async function createTestApp(options: TestAppOptions = {}): Promise<TestApp> {\n const root = resolve(options.root ?? process.cwd())\n const origin = options.origin ?? 'https://app.test'\n\n built.set(root, built.get(root) ?? ensureBuilt(root, options.build ?? true))\n\n const bundle = await built.get(root)!\n const entry = (await import(pathToFileURL(bundle).href)) as {\n default: (request: Request) => Promise<Response>\n }\n\n if (typeof entry.default !== 'function') {\n throw new Error(`[rsc-kit] ${bundle} does not export a request handler.`)\n }\n\n return {\n bundle,\n fetch: (path, init) => entry.default(new Request(new URL(path, origin), init)),\n }\n}\n"]}
|