@rsc-kit/core 0.13.1 → 0.15.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 +36 -4
- package/dist/action.js +31 -5
- package/dist/action.js.map +1 -1
- package/dist/buildReport.d.ts +14 -2
- package/dist/buildReport.js +6 -3
- package/dist/buildReport.js.map +1 -1
- package/dist/js/Link.d.ts +11 -4
- package/dist/js/Link.js +5 -1
- package/dist/js/Link.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/prerender.d.ts +1 -0
- package/dist/prerender.js +29 -10
- package/dist/prerender.js.map +1 -1
- package/dist/routes.d.ts +79 -0
- package/dist/routes.js +41 -0
- package/dist/routes.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.d.ts +6 -0
- package/dist/vite.js +296 -36
- package/dist/vite.js.map +1 -1
- package/package.json +16 -3
package/dist/routes.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,kEAAkE;AAClE,8EAA8E;AAC9E,4EAA4E;AAC5E,6EAA6E;AAC7E,mCAAmC;AACnC,EAAE;AACF,wEAAwE;AACxE,kEAAkE;AAClE,6EAA6E;AAC7E,8EAA8E;AAC9E,8CAA8C;AAC9C,EAAE;AACF,8EAA8E;AAC9E,sCAAsC;AACtC,EAAE;AACF,4CAA4C;AAC5C,2DAA2D;AAC3D,MAAM;AACN,EAAE;AACF,0EAA0E;AAC1E,6EAA6E;AAC7E,8EAA8E;AAC9E,4EAA4E;AAC5E,kBAAkB;AAwElB;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,MAAM,CAAC,IAAa;IAClC,OAAO,IAAI,CAAA;AACb,CAAC","sourcesContent":["// Typed routes: the urls this app can actually answer, as a type.\n//\n// Laravel needs route() because the url lives in PHP and can move\n// independently of the name it is called by. Here the url *is* the file path,\n// so a name would be indirection that buys nothing. What is worth having is\n// the other half — a link to a page that does not exist should fail at build\n// time rather than in the browser.\n//\n// There is no route() builder to go with this, deliberately. A template\n// literal is checked the same way — `/posts/${slug}` compiles and\n// `/postz/${slug}` does not — so a builder would only wrap what the language\n// already does. Encoding a value that is not url-safe is `encodeURIComponent`\n// in the template, the same as anywhere else.\n//\n// The build already walks app/ and knows every route's segments, so it writes\n// one line into the app's source dir:\n//\n// declare module '@rsc-kit/core/routes' {\n// interface Register { routes: '/' | '/posts/[slug]' }\n// }\n//\n// Everything below is derived from that union. An app that never runs the\n// generator — a generic host, a Laravel app that has not rebuilt — registers\n// nothing, `RoutePattern` stays `string`, and every url-taking API is exactly\n// as permissive as it was before. That fallback is the reason this can ship\n// without a flag.\n\n/**\n * Augmented by the generated `rsc-routes.d.ts`. Empty here on purpose.\n *\n * Declaration merging rather than a generic parameter, because the routes are\n * a property of the project, not of each call site — threading them through\n * every component that renders a Link is not a thing anyone would do twice.\n */\nexport interface Register {}\n\n/** The route patterns this app declared: `'/posts/[slug]'`. */\nexport type RoutePattern = Register extends { routes: infer R extends string } ? R : string\n\n/** Whether anything was registered. `string` means the generator never ran. */\ntype Unregistered = string extends RoutePattern ? true : false\n\n/**\n * A pattern with its dynamic segments opened up: `/posts/[slug]` accepts\n * `/posts/anything`.\n *\n * Catch-all and single params both become `${string}`, which for a catch-all\n * also swallows the slashes — `/docs/[...path]` accepts `/docs/a/b/c`.\n */\ntype Filled<P extends string> = P extends `${infer A}[...${string}]${infer B}`\n ? `${A}${string}${Filled<B>}`\n : P extends `${infer A}[${string}]${infer B}`\n ? `${A}${string}${Filled<B>}`\n : P\n\n/**\n * Not a route, but a legitimate href: another site, a mail client, a phone\n * number, an anchor on this page, a bare query string.\n */\ntype OffRoute = `${string}://${string}` | `mailto:${string}` | `tel:${string}` | `#${string}` | `?${string}`\n\n/**\n * A url this app can answer, or one that deliberately leaves it.\n *\n * Cast when the destination is computed rather than written:\n * `href={path as Href}`.\n */\nexport type Href = Unregistered extends true\n ? string\n : Filled<RoutePattern> | `${Filled<RoutePattern>}?${string}` | `${Filled<RoutePattern>}#${string}` | OffRoute\n\n// ── Api routes ───────────────────────────────────────────────────────────────\n//\n// Their own union rather than part of Href, because they are not pages and a\n// link to one is almost always a mistake — an <a href=\"/api/orders\"> navigates\n// the browser away to a json document. Keeping them apart means `Link` refuses\n// an api url and `apiUrl()` refuses a page, which is the pair of mistakes worth\n// catching.\n\n/** Augmented by the generated `rsc-routes.d.ts`, like `Register`. */\nexport interface RegisterApi {}\n\n/** The api route patterns this app declared: `'/api/orders/[id]'`. */\nexport type ApiPattern = RegisterApi extends { apis: infer R extends string } ? R : string\n\ntype NoApis = string extends ApiPattern ? true : false\n\n/**\n * A url an api route in this app answers.\n *\n * `/api/orders/[id]` accepts `/api/orders/42`, and a query string is allowed\n * because that is how a GET is parameterised.\n */\nexport type ApiHref = NoApis extends true\n ? string\n : Filled<ApiPattern> | `${Filled<ApiPattern>}?${string}`\n\n/**\n * An api url, checked against the routes the build found.\n *\n * await fetch(apiUrl(`/api/orders/${id}`))\n *\n * A function rather than a bare type so it can be used inline at a call site\n * that is typed `string` — `fetch` takes any string, so nothing would check the\n * argument without somewhere to put the type. It returns what it was given.\n *\n * Wrong path, and it stops compiling. Renamed the directory, and every call\n * site says so rather than one of them 404ing in production.\n */\nexport function apiUrl(href: ApiHref): string {\n return href\n}\n"]}
|
|
1
|
+
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"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","sourcesContent":["// Typed routes: the urls this app can actually answer, as a type.\n//\n// Laravel needs route() because the url lives in PHP and can move\n// independently of the name it is called by. Here the url *is* the file path,\n// so a name would be indirection that buys nothing. What is worth having is\n// the other half — a link to a page that does not exist should fail at build\n// time rather than in the browser.\n//\n// There is no route() builder to go with this, deliberately. A template\n// literal is checked the same way — `/posts/${slug}` compiles and\n// `/postz/${slug}` does not — so a builder would only wrap what the language\n// already does. Encoding a value that is not url-safe is `encodeURIComponent`\n// in the template, the same as anywhere else.\n//\n// The build already walks app/ and knows every route's segments, so it writes\n// one line into the app's source dir:\n//\n// declare module '@rsc-kit/core/routes' {\n// interface Register { routes: '/' | '/posts/[slug]' }\n// }\n//\n// Everything below is derived from that union. An app that never runs the\n// generator — a generic host, a Laravel app that has not rebuilt — registers\n// nothing, `RoutePattern` stays `string`, and every url-taking API is exactly\n// as permissive as it was before. That fallback is the reason this can ship\n// without a flag.\n\n/**\n * Augmented by the generated `rsc-routes.d.ts`. Empty here on purpose.\n *\n * Declaration merging rather than a generic parameter, because the routes are\n * a property of the project, not of each call site — threading them through\n * every component that renders a Link is not a thing anyone would do twice.\n */\nexport interface Register {}\n\n/** The route patterns this app declared: `'/posts/[slug]'`. */\nexport type RoutePattern = Register extends { routes: infer R extends string } ? R : string\n\n/** Whether anything was registered. `string` means the generator never ran. */\ntype Unregistered = string extends RoutePattern ? true : false\n\n/**\n * A pattern with its dynamic segments opened up: `/posts/[slug]` accepts\n * `/posts/anything`.\n *\n * Catch-all and single params both become `${string}`, which for a catch-all\n * also swallows the slashes — `/docs/[...path]` accepts `/docs/a/b/c`.\n */\ntype Filled<P extends string> = P extends `${infer A}[...${string}]${infer B}`\n ? `${A}${string}${Filled<B>}`\n : P extends `${infer A}[${string}]${infer B}`\n ? `${A}${string}${Filled<B>}`\n : P\n\n/**\n * Not a route, but a legitimate href: another site, a mail client, a phone\n * number, an anchor on this page, a bare query string.\n */\ntype OffRoute = `${string}://${string}` | `mailto:${string}` | `tel:${string}` | `#${string}` | `?${string}`\n\n/**\n * A url this app can answer, or one that deliberately leaves it.\n *\n * Cast when the destination is computed rather than written:\n * `href={path as Href}`.\n */\nexport type Href = Unregistered extends true\n ? string\n : Filled<RoutePattern> | `${Filled<RoutePattern>}?${string}` | `${Filled<RoutePattern>}#${string}` | OffRoute\n\n// ── Api routes ───────────────────────────────────────────────────────────────\n//\n// Their own union rather than part of Href, because they are not pages and a\n// link to one is almost always a mistake — an <a href=\"/api/orders\"> navigates\n// the browser away to a json document. Keeping them apart means `Link` refuses\n// an api url and `apiUrl()` refuses a page, which is the pair of mistakes worth\n// catching.\n\n/** Augmented by the generated `rsc-routes.d.ts`, like `Register`. */\nexport interface RegisterApi {}\n\n/** The api route patterns this app declared: `'/api/orders/[id]'`. */\nexport type ApiPattern = RegisterApi extends { apis: infer R extends string } ? R : string\n\ntype NoApis = string extends ApiPattern ? true : false\n\n/**\n * A url an api route in this app answers.\n *\n * `/api/orders/[id]` accepts `/api/orders/42`, and a query string is allowed\n * because that is how a GET is parameterised.\n */\nexport type ApiHref = NoApis extends true\n ? string\n : Filled<ApiPattern> | `${Filled<ApiPattern>}?${string}`\n\n/**\n * An api url, checked against the routes the build found.\n *\n * await fetch(apiUrl(`/api/orders/${id}`))\n *\n * A function rather than a bare type so it can be used inline at a call site\n * that is typed `string` — `fetch` takes any string, so nothing would check the\n * argument without somewhere to put the type. It returns what it was given.\n *\n * Wrong path, and it stops compiling. Renamed the directory, and every call\n * site says so rather than one of them 404ing in production.\n */\nexport function apiUrl(href: ApiHref): string {\n return href\n}\n\n// ── Search params, typed per route ───────────────────────────────────────────\n//\n// A page that exports a `searchParams` schema has said what its query string\n// means. The generated file records that schema per pattern:\n//\n// interface Register {\n// search: { '/search': SearchExportOf<typeof import('../src/app/search/page')> }\n// }\n//\n// and from there a link to `/search` is checked against the same schema the\n// page parses with — a `page` that must be a number is a number on the link,\n// a `q` the page requires is required to write the link, and a key the page\n// never reads does not compile. One schema, both ends. A route that exports\n// none takes anything; an href that is not a single route (computed, cast, or\n// off-site) takes anything too, because there is nothing to check it against.\n\n/** What a page module contributes: its `searchParams` export, or nothing. For the generated file. */\nexport type SearchExportOf<M> = M extends { searchParams: infer S } ? S : undefined\n\ntype SearchMap = Register extends { search: infer M } ? M : {}\n\ntype StripQuery<H extends string> = H extends `${infer P}?${string}` ? P : H extends `${infer P}#${string}` ? P : H\n\n/** The pattern a written href belongs to: `/posts/hello` is `/posts/[slug]`. */\ntype PatternOf<H extends string> = RoutePattern extends infer P\n ? P extends string\n ? StripQuery<H> extends Filled<P>\n ? P\n : never\n : never\n : never\n\ntype IsUnion<T, U = T> = T extends unknown ? ([U] extends [T] ? false : true) : never\n\ntype SchemaFor<P> = P extends keyof SearchMap ? SearchMap[P] : undefined\n\ntype InputOf<S> = S extends { '~standard': { types?: { input: infer I } } } ? I : never\ntype OutputOf<S> = S extends { '~standard': { types?: { output: infer O } } } ? O : never\n\ntype OptionalKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? K : never }[keyof T]\ntype RequiredKeys<T> = Exclude<keyof T, OptionalKeys<T>>\ntype Simplify<T> = { [K in keyof T]: T[K] } & {}\n\n/**\n * What a link may write for a schema: the keys the schema's input requires\n * are required, the rest optional, and every value is the schema's OUTPUT\n * type. Output rather than input because `z.coerce.number()` takes `unknown`\n * in - that is what coercion means - and a link typed by it would accept\n * `page: 'two'`. The output is the number the page will actually see.\n */\ntype LinkInputOf<S> = Simplify<\n { [K in RequiredKeys<InputOf<S>> & keyof OutputOf<S>]: OutputOf<S>[K] } & {\n [K in OptionalKeys<InputOf<S>> & keyof OutputOf<S>]?: OutputOf<S>[K]\n }\n>\n\ntype Scalar = string | number | boolean | null | undefined\n\n/** What a link may carry when nothing declares otherwise. */\nexport type LooseSearch = Record<string, Scalar | readonly (string | number)[]>\n\n/**\n * The search params a link to `H` may carry.\n *\n * The page's schema input when `H` is one declared route with a schema;\n * otherwise anything. \"One route\" matters: `path as Href` is every route at\n * once, and a link that could go anywhere cannot be held to one page's schema.\n */\nexport type SearchFor<H extends string> = Unregistered extends true\n ? LooseSearch\n : IsUnion<H> extends true\n ? LooseSearch\n : [PatternOf<H>] extends [never]\n ? LooseSearch\n : SchemaFor<PatternOf<H>> extends undefined\n ? LooseSearch\n : LinkInputOf<SchemaFor<PatternOf<H>>>\n\n/**\n * The `search` prop, required exactly when the page's schema has a required\n * key. A page that needs `q` is not reachable without one, so the link that\n * omits it is the bug — caught here rather than on the page's error boundary.\n */\nexport type SearchProp<H extends string> = {} extends SearchFor<H>\n ? { search?: SearchFor<H> }\n : { search: SearchFor<H> }\n\n/** A query string from an object: scalars stringified, arrays repeated, null and undefined dropped. */\nexport function searchString(search: object): string {\n const params = new URLSearchParams()\n\n for (const [key, value] of Object.entries(search)) {\n if (value === null || value === undefined) continue\n\n if (Array.isArray(value)) {\n for (const item of value) params.append(key, String(item))\n } else {\n params.set(key, String(value))\n }\n }\n\n return params.toString()\n}\n\n/** `path` with `search` appended, keeping any query and hash already on it. */\nexport function withSearch(path: string, search: object | undefined): string {\n if (!search) return path\n\n const hashAt = path.indexOf('#')\n const hash = hashAt === -1 ? '' : path.slice(hashAt)\n const before = hashAt === -1 ? path : path.slice(0, hashAt)\n const queryAt = before.indexOf('?')\n const base = queryAt === -1 ? before : before.slice(0, queryAt)\n const existing = queryAt === -1 ? '' : before.slice(queryAt + 1)\n const added = searchString(search)\n const query = [existing, added].filter(Boolean).join('&')\n\n return query ? `${base}?${query}${hash}` : `${base}${hash}`\n}\n\n/**\n * A typed url with its search params, for the places that take a string.\n *\n * visit(href('/search', { q: 'shoes', page: 2 }))\n *\n * `Link` has the same check on its own `search` prop. This is for `visit`,\n * `prefetch`, `redirect` and anything else that wants the finished string.\n */\nexport function href<H extends Href>(\n path: H,\n ...rest: {} extends SearchFor<H> ? [search?: SearchFor<H>] : [search: SearchFor<H>]\n): Href {\n return withSearch(path, rest[0] as object | undefined) as Href\n}\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":"AAAA,0CAA0C;AAC1C,EAAE;AACF,0DAA0D;AAC1D,EAAE;AACF,sCAAsC;AACtC,oFAAoF;AACpF,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,6EAA6E;AAC7E,+EAA+E;AAC/E,gBAAgB;AAChB,EAAE;AACF,8EAA8E;AAC9E,4EAA4E;AAC5E,4EAA4E;AAC5E,gEAAgE;AAEhE,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAC9C,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAC3D,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACzC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAyBxC;;;;;;GAMG;AACH,SAAS,UAAU,CAAC,IAAY;IAC9B,MAAM,UAAU,GAAG;QACjB,IAAI,CAAC,IAAI,EAAE,gDAAgD,CAAC;QAC5D,IAAI,CAAC,IAAI,EAAE,wBAAwB,CAAC;QACpC,IAAI,CAAC,IAAI,EAAE,yBAAyB,CAAC;KACtC,CAAA;IAED,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,IAAI,IAAI,CAAA;AAC5D,CAAC;AAED,iFAAiF;AACjF,SAAS,MAAM,CAAC,GAAW;IACzB,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,CAAC,CAAA;IAE9B,IAAI,MAAM,GAAG,CAAC,CAAA;IAEd,KAAK,MAAM,KAAK,IAAI,WAAW,CAAC,GAAG,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;QAC9D,IAAI,KAAK,CAAC,IAAI,KAAK,cAAc,IAAI,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAQ;QAEzE,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,CAAC,CAAA;QAClC,MAAM,IAAI,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,CAAA;QAExE,IAAI,IAAI,GAAG,MAAM;YAAE,MAAM,GAAG,IAAI,CAAA;IAClC,CAAC;IAED,OAAO,MAAM,CAAA;AACf,CAAC;AAED,mEAAmE;AACnE,MAAM,KAAK,GAAG,IAAI,GAAG,EAA2B,CAAA;AAEhD,KAAK,UAAU,WAAW,CAAC,IAAY,EAAE,KAAc;IACrD,MAAM,QAAQ,GAAG,UAAU,CAAC,IAAI,CAAC,CAAA;IAEjC,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,MAAM,IAAI,KAAK,CACb,4CAA4C,IAAI,8EAA8E,CAC/H,CAAA;QACH,CAAC;QAED,OAAO,QAAQ,CAAA;IACjB,CAAC;IAED,MAAM,KAAK,GAAG,QAAQ,IAAI,QAAQ,CAAC,QAAQ,CAAC,CAAC,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,CAAA;IAEhF,IAAI,KAAK;QAAE,OAAO,QAAQ,CAAA;IAE1B,wEAAwE;IACxE,wEAAwE;IACxE,MAAM,GAAG,GAAG,SAAS,CAAC,KAAK,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE;QAC9C,GAAG,EAAE,IAAI;QACT,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC;QACjC,GAAG,EAAE,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,QAAQ,EAAE,YAAY,EAAE;KAChD,CAAC,CAAA;IAEF,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACrB,MAAM,IAAI,KAAK,CAAC,6DAA6D,GAAG,CAAC,MAAM,EAAE,CAAC,CAAA;IAC5F,CAAC;IAED,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,CAAC,CAAA;IAE/B,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CACb,qEAAqE,IAAI,+BAA+B,CACzG,CAAA;IACH,CAAC;IAED,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAAO,GAAmB,EAAE;IAC9D,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC,CAAA;IACnD,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,kBAAkB,CAAA;IAEnD,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,WAAW,CAAC,IAAI,EAAE,OAAO,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,CAAA;IAE5E,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,GAAG,CAAC,IAAI,CAAE,CAAA;IACrC,MAAM,KAAK,GAAG,CAAC,MAAM,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAEtD,CAAA;IAED,IAAI,OAAO,KAAK,CAAC,OAAO,KAAK,UAAU,EAAE,CAAC;QACxC,MAAM,IAAI,KAAK,CAAC,aAAa,MAAM,qCAAqC,CAAC,CAAA;IAC3E,CAAC;IAED,OAAO;QACL,MAAM;QACN,KAAK,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,OAAO,CAAC,IAAI,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC;KAC/E,CAAA;AACH,CAAC","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"]}
|
package/dist/vite.d.ts
CHANGED
|
@@ -222,4 +222,10 @@ export declare const SERVICE_WORKER: (version: string, precache: string[], froze
|
|
|
222
222
|
* it. The file is a literal object by contract, which is all that has to parse.
|
|
223
223
|
*/
|
|
224
224
|
export declare function declaredManifest(appDir: string): WebManifestOptions | null;
|
|
225
|
+
/** A server action the bundle registered: where it is and what it is called. */
|
|
226
|
+
export interface KnownAction {
|
|
227
|
+
id: string;
|
|
228
|
+
name: string;
|
|
229
|
+
file: string;
|
|
230
|
+
}
|
|
225
231
|
export declare function rscKit(options?: RscKitOptions): PluginOption[];
|