@rsc-kit/core 0.10.0 → 0.11.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.
@@ -4,6 +4,14 @@ export interface FrozenApiResponse {
4
4
  status: number;
5
5
  headers: [string, string][];
6
6
  body: string;
7
+ /**
8
+ * Whether the answer depends on the query string.
9
+ *
10
+ * False when the handler never awaited `searchParams` and declared no schema
11
+ * for it, which means the same answer is right for `?utm_source=anything`.
12
+ * True and the stored answer is only good for the bare url.
13
+ */
14
+ varies: boolean;
7
15
  }
8
16
  /** The file a frozen route is stored as. */
9
17
  export declare function apiKey(url: string): string;
@@ -170,6 +170,10 @@ export async function prerenderApiRoutes(engine, manifest, write) {
170
170
  said('dynamic', 'reads the request — ' + [...touched].sort().join(', '));
171
171
  continue;
172
172
  }
173
+ // Awaiting the query string is not a reason to give up on the route — the
174
+ // bare url still has one right answer. It only narrows which requests the
175
+ // stored answer is good for.
176
+ const varies = answered.readBy.includes('searchParams');
173
177
  const response = answered.response.value;
174
178
  const body = asText(new Uint8Array(await response.arrayBuffer()));
175
179
  if (body === null) {
@@ -187,8 +191,9 @@ export async function prerenderApiRoutes(engine, manifest, write) {
187
191
  .map(([name, value]) => [name.toLowerCase(), value])
188
192
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
189
193
  body,
194
+ varies,
190
195
  }));
191
- said('frozen', null);
196
+ said('frozen', varies ? 'stored for the bare url — it reads the query string' : null);
192
197
  }
193
198
  return results;
194
199
  }
@@ -1 +1 @@
1
- {"version":3,"file":"apiPrerender.js","sourceRoot":"","sources":["../src/apiPrerender.ts"],"names":[],"mappings":"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","sourcesContent":["// Freezing an api route the way a page is frozen.\n//\n// Not as an opt-in flag, deliberately. A page is stored by default and opts\n// OUT by touching the request — connection(), cookies(), headers() all suspend\n// at build time because there is no request there, and that is what marks the\n// page dynamic. A route works the same way and for the same reason: one model\n// to learn rather than two, and the honest default in both cases is \"the build\n// tried, and here is what it found\".\n//\n// GET only. Everything else is a method a caller may not repeat, and a stored\n// answer to a POST is a stored answer to something that was supposed to happen\n// once.\n\nimport { pathKey } from './prerender.js'\nimport { requestReadBy, withRequest } from './request.js'\nimport type { ManifestApiRoute, RouteManifest } from './manifest.js'\nimport { allowFor } from './routing.js'\n\n/**\n * How long a route gets to answer before it is called dynamic.\n *\n * A route that reads the request does not fail here — it never settles, because\n * the accessors suspend forever with no request to read. So the budget is what\n * turns \"waiting\" into an answer, and it only has to be long enough for a route\n * that was going to finish.\n */\nconst BUDGET_MS = 2_000\n\n/** What a stored answer holds. Enough to rebuild the Response exactly. */\nexport interface FrozenApiResponse {\n status: number\n headers: [string, string][]\n body: string\n}\n\n/** The file a frozen route is stored as. */\nexport function apiKey(url: string): string {\n return `${pathKey(url)}.api.json`\n}\n\n/**\n * Reading anything here means the answer depends on the caller.\n *\n * Deliberately not `url`: the url is the key the answer is stored under, so\n * reading it tells you the same thing on every request that would hit the\n * stored file. The query string is handled by refusing to serve a stored\n * answer to a request that has one, which needs no detection at all.\n */\nconst PER_CALLER = new Set([\n 'headers',\n 'body',\n 'bodyUsed',\n 'text',\n 'json',\n 'formData',\n 'arrayBuffer',\n 'blob',\n 'bytes',\n 'signal',\n 'referrer',\n 'credentials',\n])\n\n/**\n * A Request that records what was read out of it.\n *\n * A proxy rather than a subclass because the interesting properties are\n * getters on Request.prototype, and `this` has to stay the real Request or\n * every one of them throws about an illegal invocation.\n */\nfunction probeRequest(url: string, touched: Set<string>): Request {\n const real = new Request(url, { method: 'GET' })\n\n return new Proxy(real, {\n get(target, property) {\n if (typeof property === 'string' && PER_CALLER.has(property)) touched.add(property)\n\n const value = Reflect.get(target, property, target)\n\n return typeof value === 'function' ? value.bind(target) : value\n },\n })\n}\n\n/** The url a route with no parameters answers. */\nfunction urlFor(route: ManifestApiRoute): string | null {\n // A parameterised route has as many urls as there are values, and nothing\n // here knows them. Pages solve this with generateStaticParams; until a route\n // can say the same, one is answered per request.\n if (route.segments.some((segment) => segment.type !== 'static')) return null\n\n return '/' + route.segments.map((segment) => segment.value).join('/')\n}\n\n/**\n * What the build calls a route in its output.\n *\n * The pattern for a parameterised one, spelled the way pages already spell\n * theirs, rather than the module name — a line reading\n * \"/app/api/greet/[name]/route\" names a file on disk and the rest of the table\n * names urls.\n */\nfunction labelFor(route: ManifestApiRoute): string {\n return (\n '/' +\n route.segments\n .map((segment) => (segment.type === 'static' ? segment.value : `_${segment.value}_`))\n .join('/')\n )\n}\n\n/** Whether a body is text this can store and hand back unchanged. */\nfunction asText(bytes: Uint8Array): string | null {\n try {\n const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)\n\n return text\n } catch {\n // Binary. Storable in principle, as base64, at the cost of a third of its\n // size on disk and a decode per request — for a route that is far more\n // likely to be streaming a file it should be serving as a file.\n return null\n }\n}\n\nexport interface ApiPrerenderResult {\n url: string\n name: string\n type: 'frozen' | 'dynamic'\n reason: string | null\n}\n\n/**\n * Try to answer every api route once, at build time, and store what can be.\n *\n * Sequential rather than parallel: there are usually few of them, each is a\n * function call rather than a React render, and the ones that are going to be\n * dynamic spend the whole budget waiting — which is time, not work.\n */\nexport async function prerenderApiRoutes(\n engine: { handleApiRoute?: (n: string, r: Request, p: Record<string, string>, a: string) => Promise<Response> },\n manifest: RouteManifest,\n write: (name: string, contents: string) => Promise<void>,\n): Promise<ApiPrerenderResult[]> {\n if (!engine.handleApiRoute || !manifest.apis?.length) return []\n\n const results: ApiPrerenderResult[] = []\n\n for (const route of manifest.apis) {\n const said = (type: 'frozen' | 'dynamic', reason: string | null) => {\n results.push({ url: labelFor(route), name: route.name, type, reason })\n }\n\n if (!route.methods.includes('GET')) {\n said('dynamic', 'no GET to store')\n continue\n }\n\n // A guarded route answers differently depending on who is asking, which is\n // the whole purpose of the guard. Storing one answer and serving it to\n // everyone is how a guard is silently removed.\n if (route.middleware.length > 0) {\n said('dynamic', 'guarded by middleware')\n continue\n }\n\n const url = urlFor(route)\n\n if (!url) {\n said('dynamic', 'one url per param value, and none are listed')\n continue\n }\n\n const touched = new Set<string>()\n const request = probeRequest('https://prerender.invalid' + url, touched)\n\n // No request in scope, so headers(), cookies() and connection() suspend\n // forever rather than resolving to whatever the build machine had. The\n // budget below is what turns that into an answer.\n const answered = await withRequest(null as never, async () => {\n let readBy: string[] = []\n\n const response = await Promise.race([\n engine\n .handleApiRoute!(route.name, request, {}, allowFor(route))\n .then((value) => ({ value }))\n .catch((error) => ({ error })),\n new Promise<null>((resolve) =>\n setTimeout(() => {\n readBy = requestReadBy()\n resolve(null)\n }, BUDGET_MS),\n ),\n ])\n\n return { response, readBy: readBy.length ? readBy : requestReadBy() }\n })\n\n if (answered.response === null) {\n const why = answered.readBy.length\n ? 'dynamic — called ' + answered.readBy.join(', ')\n : 'did not answer within the build budget'\n\n said('dynamic', why)\n continue\n }\n\n if ('error' in answered.response) {\n // Not a build failure. A route that throws with no request may be doing\n // exactly the right thing — refusing a caller it cannot identify — and\n // refusing the build over it would make that route unbuildable.\n said('dynamic', 'threw without a request')\n continue\n }\n\n if (touched.size > 0) {\n said('dynamic', 'reads the request — ' + [...touched].sort().join(', '))\n continue\n }\n\n const response = answered.response.value\n const body = asText(new Uint8Array(await response.arrayBuffer()))\n\n if (body === null) {\n said('dynamic', 'answers with bytes rather than text')\n continue\n }\n\n await write(\n apiKey(url),\n JSON.stringify({\n status: response.status,\n // Lower-cased and sorted, so two builds of the same route produce the\n // same bytes. Headers iteration does not promise a case or an order,\n // and a file that differs between builds for no reason defeats\n // content-addressed caching and makes a diff unreadable. Names are\n // case-insensitive, so nothing is lost by picking one.\n headers: [...response.headers]\n .map(([name, value]) => [name.toLowerCase(), value] as [string, string])\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),\n body,\n } satisfies FrozenApiResponse),\n )\n\n said('frozen', null)\n }\n\n return results\n}\n"]}
1
+ {"version":3,"file":"apiPrerender.js","sourceRoot":"","sources":["../src/apiPrerender.ts"],"names":[],"mappings":"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","sourcesContent":["// Freezing an api route the way a page is frozen.\n//\n// Not as an opt-in flag, deliberately. A page is stored by default and opts\n// OUT by touching the request — connection(), cookies(), headers() all suspend\n// at build time because there is no request there, and that is what marks the\n// page dynamic. A route works the same way and for the same reason: one model\n// to learn rather than two, and the honest default in both cases is \"the build\n// tried, and here is what it found\".\n//\n// GET only. Everything else is a method a caller may not repeat, and a stored\n// answer to a POST is a stored answer to something that was supposed to happen\n// once.\n\nimport { pathKey } from './prerender.js'\nimport { requestReadBy, withRequest } from './request.js'\nimport type { ManifestApiRoute, RouteManifest } from './manifest.js'\nimport { allowFor } from './routing.js'\n\n/**\n * How long a route gets to answer before it is called dynamic.\n *\n * A route that reads the request does not fail here — it never settles, because\n * the accessors suspend forever with no request to read. So the budget is what\n * turns \"waiting\" into an answer, and it only has to be long enough for a route\n * that was going to finish.\n */\nconst BUDGET_MS = 2_000\n\n/** What a stored answer holds. Enough to rebuild the Response exactly. */\nexport interface FrozenApiResponse {\n status: number\n headers: [string, string][]\n body: string\n /**\n * Whether the answer depends on the query string.\n *\n * False when the handler never awaited `searchParams` and declared no schema\n * for it, which means the same answer is right for `?utm_source=anything`.\n * True and the stored answer is only good for the bare url.\n */\n varies: boolean\n}\n\n/** The file a frozen route is stored as. */\nexport function apiKey(url: string): string {\n return `${pathKey(url)}.api.json`\n}\n\n/**\n * Reading anything here means the answer depends on the caller.\n *\n * Deliberately not `url`: the url is the key the answer is stored under, so\n * reading it tells you the same thing on every request that would hit the\n * stored file. The query string is handled by refusing to serve a stored\n * answer to a request that has one, which needs no detection at all.\n */\nconst PER_CALLER = new Set([\n 'headers',\n 'body',\n 'bodyUsed',\n 'text',\n 'json',\n 'formData',\n 'arrayBuffer',\n 'blob',\n 'bytes',\n 'signal',\n 'referrer',\n 'credentials',\n])\n\n/**\n * A Request that records what was read out of it.\n *\n * A proxy rather than a subclass because the interesting properties are\n * getters on Request.prototype, and `this` has to stay the real Request or\n * every one of them throws about an illegal invocation.\n */\nfunction probeRequest(url: string, touched: Set<string>): Request {\n const real = new Request(url, { method: 'GET' })\n\n return new Proxy(real, {\n get(target, property) {\n if (typeof property === 'string' && PER_CALLER.has(property)) touched.add(property)\n\n const value = Reflect.get(target, property, target)\n\n return typeof value === 'function' ? value.bind(target) : value\n },\n })\n}\n\n/** The url a route with no parameters answers. */\nfunction urlFor(route: ManifestApiRoute): string | null {\n // A parameterised route has as many urls as there are values, and nothing\n // here knows them. Pages solve this with generateStaticParams; until a route\n // can say the same, one is answered per request.\n if (route.segments.some((segment) => segment.type !== 'static')) return null\n\n return '/' + route.segments.map((segment) => segment.value).join('/')\n}\n\n/**\n * What the build calls a route in its output.\n *\n * The pattern for a parameterised one, spelled the way pages already spell\n * theirs, rather than the module name — a line reading\n * \"/app/api/greet/[name]/route\" names a file on disk and the rest of the table\n * names urls.\n */\nfunction labelFor(route: ManifestApiRoute): string {\n return (\n '/' +\n route.segments\n .map((segment) => (segment.type === 'static' ? segment.value : `_${segment.value}_`))\n .join('/')\n )\n}\n\n/** Whether a body is text this can store and hand back unchanged. */\nfunction asText(bytes: Uint8Array): string | null {\n try {\n const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)\n\n return text\n } catch {\n // Binary. Storable in principle, as base64, at the cost of a third of its\n // size on disk and a decode per request — for a route that is far more\n // likely to be streaming a file it should be serving as a file.\n return null\n }\n}\n\nexport interface ApiPrerenderResult {\n url: string\n name: string\n type: 'frozen' | 'dynamic'\n reason: string | null\n}\n\n/**\n * Try to answer every api route once, at build time, and store what can be.\n *\n * Sequential rather than parallel: there are usually few of them, each is a\n * function call rather than a React render, and the ones that are going to be\n * dynamic spend the whole budget waiting — which is time, not work.\n */\nexport async function prerenderApiRoutes(\n engine: { handleApiRoute?: (n: string, r: Request, p: Record<string, string>, a: string) => Promise<Response> },\n manifest: RouteManifest,\n write: (name: string, contents: string) => Promise<void>,\n): Promise<ApiPrerenderResult[]> {\n if (!engine.handleApiRoute || !manifest.apis?.length) return []\n\n const results: ApiPrerenderResult[] = []\n\n for (const route of manifest.apis) {\n const said = (type: 'frozen' | 'dynamic', reason: string | null) => {\n results.push({ url: labelFor(route), name: route.name, type, reason })\n }\n\n if (!route.methods.includes('GET')) {\n said('dynamic', 'no GET to store')\n continue\n }\n\n // A guarded route answers differently depending on who is asking, which is\n // the whole purpose of the guard. Storing one answer and serving it to\n // everyone is how a guard is silently removed.\n if (route.middleware.length > 0) {\n said('dynamic', 'guarded by middleware')\n continue\n }\n\n const url = urlFor(route)\n\n if (!url) {\n said('dynamic', 'one url per param value, and none are listed')\n continue\n }\n\n const touched = new Set<string>()\n const request = probeRequest('https://prerender.invalid' + url, touched)\n\n // No request in scope, so headers(), cookies() and connection() suspend\n // forever rather than resolving to whatever the build machine had. The\n // budget below is what turns that into an answer.\n const answered = await withRequest(null as never, async () => {\n let readBy: string[] = []\n\n const response = await Promise.race([\n engine\n .handleApiRoute!(route.name, request, {}, allowFor(route))\n .then((value) => ({ value }))\n .catch((error) => ({ error })),\n new Promise<null>((resolve) =>\n setTimeout(() => {\n readBy = requestReadBy()\n resolve(null)\n }, BUDGET_MS),\n ),\n ])\n\n return { response, readBy: readBy.length ? readBy : requestReadBy() }\n })\n\n if (answered.response === null) {\n const why = answered.readBy.length\n ? 'dynamic — called ' + answered.readBy.join(', ')\n : 'did not answer within the build budget'\n\n said('dynamic', why)\n continue\n }\n\n if ('error' in answered.response) {\n // Not a build failure. A route that throws with no request may be doing\n // exactly the right thing — refusing a caller it cannot identify — and\n // refusing the build over it would make that route unbuildable.\n said('dynamic', 'threw without a request')\n continue\n }\n\n if (touched.size > 0) {\n said('dynamic', 'reads the request — ' + [...touched].sort().join(', '))\n continue\n }\n\n // Awaiting the query string is not a reason to give up on the route — the\n // bare url still has one right answer. It only narrows which requests the\n // stored answer is good for.\n const varies = answered.readBy.includes('searchParams')\n\n const response = answered.response.value\n const body = asText(new Uint8Array(await response.arrayBuffer()))\n\n if (body === null) {\n said('dynamic', 'answers with bytes rather than text')\n continue\n }\n\n await write(\n apiKey(url),\n JSON.stringify({\n status: response.status,\n // Lower-cased and sorted, so two builds of the same route produce the\n // same bytes. Headers iteration does not promise a case or an order,\n // and a file that differs between builds for no reason defeats\n // content-addressed caching and makes a diff unreadable. Names are\n // case-insensitive, so nothing is lost by picking one.\n headers: [...response.headers]\n .map(([name, value]) => [name.toLowerCase(), value] as [string, string])\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),\n body,\n varies,\n } satisfies FrozenApiResponse),\n )\n\n said('frozen', varies ? 'stored for the bare url — it reads the query string' : null)\n }\n\n return results\n}\n"]}
@@ -0,0 +1,30 @@
1
+ /** One found file, and where it will be served from. */
2
+ export interface AppAsset {
3
+ /** The file's name in the app directory. */
4
+ file: string;
5
+ /** The url it is served at. */
6
+ href: string;
7
+ }
8
+ export interface AppAssets {
9
+ favicon: AppAsset | null;
10
+ icons: AppAsset[];
11
+ appleIcon: AppAsset | null;
12
+ openGraph: AppAsset | null;
13
+ twitter: AppAsset | null;
14
+ }
15
+ /**
16
+ * Where these are served from.
17
+ *
18
+ * Their own directory rather than the output root, so an app that happens to
19
+ * have a `public/icon.png` as well is not silently overwritten by one of
20
+ * these, in either direction. The exception is the favicon, which browsers ask
21
+ * for at `/favicon.ico` whatever any markup says.
22
+ */
23
+ export declare const ASSET_BASE = "/_app";
24
+ /** What the app declared by putting a file where it could be found. */
25
+ export declare function appAssets(appDir: string): AppAssets;
26
+ /** The head tags these produce, as plain data the generated entry turns into elements. */
27
+ export declare function headTags(assets: AppAssets, origin?: string): {
28
+ tag: 'link' | 'meta';
29
+ props: Record<string, string>;
30
+ }[];
@@ -0,0 +1,90 @@
1
+ // Files that describe the app, found by name rather than configured.
2
+ //
3
+ // src/app/
4
+ // favicon.ico <link rel="icon">
5
+ // icon.png <link rel="icon">, and the manifest's icons
6
+ // icon-192.png …as many as you like; sizes read from the name
7
+ // apple-icon.png <link rel="apple-touch-icon">
8
+ // opengraph-image.png <meta property="og:image">
9
+ // twitter-image.png <meta name="twitter:image">
10
+ //
11
+ // The same bargain as every other file in this directory: put it where it
12
+ // belongs and it works, with nothing to register. Next established these names
13
+ // and there is nothing to gain by inventing different ones — an app moving
14
+ // between the two should not have to rename its favicon.
15
+ //
16
+ // They live in `app/` rather than `public/` on purpose. `public/` is "serve
17
+ // this verbatim"; these are read as well as served — an icon's size decides
18
+ // what goes in the manifest, and its presence decides what goes in the head.
19
+ import { existsSync, readdirSync } from 'node:fs';
20
+ const IMAGE = /\.(png|svg|jpg|jpeg|webp|gif|avif)$/i;
21
+ /**
22
+ * Where these are served from.
23
+ *
24
+ * Their own directory rather than the output root, so an app that happens to
25
+ * have a `public/icon.png` as well is not silently overwritten by one of
26
+ * these, in either direction. The exception is the favicon, which browsers ask
27
+ * for at `/favicon.ico` whatever any markup says.
28
+ */
29
+ export const ASSET_BASE = '/_app';
30
+ function assetsIn(dir, match) {
31
+ return readdirSync(dir)
32
+ .filter(match)
33
+ .sort()
34
+ .map((file) => ({ file, href: `${ASSET_BASE}/${file}` }));
35
+ }
36
+ /** What the app declared by putting a file where it could be found. */
37
+ export function appAssets(appDir) {
38
+ if (!existsSync(appDir)) {
39
+ return { favicon: null, icons: [], appleIcon: null, openGraph: null, twitter: null };
40
+ }
41
+ const names = readdirSync(appDir);
42
+ const has = (name) => names.includes(name);
43
+ return {
44
+ // Served where a browser looks for it regardless of markup: a request for
45
+ // /favicon.ico goes out whether or not a link tag exists, and answering it
46
+ // from somewhere else means answering a 404 to the request that matters.
47
+ favicon: has('favicon.ico') ? { file: 'favicon.ico', href: '/favicon.ico' } : null,
48
+ icons: assetsIn(appDir, (name) => /^icon[-\w]*/.test(name) && IMAGE.test(name)),
49
+ appleIcon: assetsIn(appDir, (name) => /^apple-icon[-\w]*/.test(name) && IMAGE.test(name))[0] ?? null,
50
+ openGraph: assetsIn(appDir, (name) => /^opengraph-image[-\w]*/.test(name) && IMAGE.test(name))[0] ?? null,
51
+ twitter: assetsIn(appDir, (name) => /^twitter-image[-\w]*/.test(name) && IMAGE.test(name))[0] ?? null,
52
+ };
53
+ }
54
+ function typeOf(file) {
55
+ const extension = file.slice(file.lastIndexOf('.') + 1).toLowerCase();
56
+ return extension === 'svg'
57
+ ? 'image/svg+xml'
58
+ : extension === 'jpg' || extension === 'jpeg'
59
+ ? 'image/jpeg'
60
+ : extension === 'ico'
61
+ ? 'image/x-icon'
62
+ : `image/${extension}`;
63
+ }
64
+ /** The head tags these produce, as plain data the generated entry turns into elements. */
65
+ export function headTags(assets, origin) {
66
+ const tags = [];
67
+ if (assets.favicon) {
68
+ tags.push({ tag: 'link', props: { rel: 'icon', href: assets.favicon.href, type: 'image/x-icon' } });
69
+ }
70
+ for (const icon of assets.icons) {
71
+ tags.push({ tag: 'link', props: { rel: 'icon', href: icon.href, type: typeOf(icon.file) } });
72
+ }
73
+ if (assets.appleIcon) {
74
+ tags.push({ tag: 'link', props: { rel: 'apple-touch-icon', href: assets.appleIcon.href } });
75
+ }
76
+ // Absolute when the app said where it lives. The og spec asks for an absolute
77
+ // url and several crawlers still mean it — a relative one is read by some and
78
+ // ignored by others, which is the worst of both. Relative is what is left
79
+ // when nothing said, and is better than omitting the tag.
80
+ const absolute = (href) => (origin ? new URL(href, origin).href : href);
81
+ if (assets.openGraph) {
82
+ tags.push({ tag: 'meta', props: { property: 'og:image', content: absolute(assets.openGraph.href) } });
83
+ }
84
+ if (assets.twitter) {
85
+ tags.push({ tag: 'meta', props: { name: 'twitter:image', content: absolute(assets.twitter.href) } });
86
+ tags.push({ tag: 'meta', props: { name: 'twitter:card', content: 'summary_large_image' } });
87
+ }
88
+ return tags;
89
+ }
90
+ //# sourceMappingURL=appAssets.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"appAssets.js","sourceRoot":"","sources":["../src/appAssets.ts"],"names":[],"mappings":"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","sourcesContent":["// Files that describe the app, found by name rather than configured.\n//\n// src/app/\n// favicon.ico <link rel=\"icon\">\n// icon.png <link rel=\"icon\">, and the manifest's icons\n// icon-192.png …as many as you like; sizes read from the name\n// apple-icon.png <link rel=\"apple-touch-icon\">\n// opengraph-image.png <meta property=\"og:image\">\n// twitter-image.png <meta name=\"twitter:image\">\n//\n// The same bargain as every other file in this directory: put it where it\n// belongs and it works, with nothing to register. Next established these names\n// and there is nothing to gain by inventing different ones — an app moving\n// between the two should not have to rename its favicon.\n//\n// They live in `app/` rather than `public/` on purpose. `public/` is \"serve\n// this verbatim\"; these are read as well as served — an icon's size decides\n// what goes in the manifest, and its presence decides what goes in the head.\n\nimport { existsSync, readdirSync } from 'node:fs'\n\n/** One found file, and where it will be served from. */\nexport interface AppAsset {\n /** The file's name in the app directory. */\n file: string\n /** The url it is served at. */\n href: string\n}\n\nexport interface AppAssets {\n favicon: AppAsset | null\n icons: AppAsset[]\n appleIcon: AppAsset | null\n openGraph: AppAsset | null\n twitter: AppAsset | null\n}\n\nconst IMAGE = /\\.(png|svg|jpg|jpeg|webp|gif|avif)$/i\n\n/**\n * Where these are served from.\n *\n * Their own directory rather than the output root, so an app that happens to\n * have a `public/icon.png` as well is not silently overwritten by one of\n * these, in either direction. The exception is the favicon, which browsers ask\n * for at `/favicon.ico` whatever any markup says.\n */\nexport const ASSET_BASE = '/_app'\n\nfunction assetsIn(dir: string, match: (name: string) => boolean): AppAsset[] {\n return readdirSync(dir)\n .filter(match)\n .sort()\n .map((file) => ({ file, href: `${ASSET_BASE}/${file}` }))\n}\n\n/** What the app declared by putting a file where it could be found. */\nexport function appAssets(appDir: string): AppAssets {\n if (!existsSync(appDir)) {\n return { favicon: null, icons: [], appleIcon: null, openGraph: null, twitter: null }\n }\n\n const names = readdirSync(appDir)\n const has = (name: string) => names.includes(name)\n\n return {\n // Served where a browser looks for it regardless of markup: a request for\n // /favicon.ico goes out whether or not a link tag exists, and answering it\n // from somewhere else means answering a 404 to the request that matters.\n favicon: has('favicon.ico') ? { file: 'favicon.ico', href: '/favicon.ico' } : null,\n icons: assetsIn(appDir, (name) => /^icon[-\\w]*/.test(name) && IMAGE.test(name)),\n appleIcon:\n assetsIn(appDir, (name) => /^apple-icon[-\\w]*/.test(name) && IMAGE.test(name))[0] ?? null,\n openGraph:\n assetsIn(appDir, (name) => /^opengraph-image[-\\w]*/.test(name) && IMAGE.test(name))[0] ?? null,\n twitter:\n assetsIn(appDir, (name) => /^twitter-image[-\\w]*/.test(name) && IMAGE.test(name))[0] ?? null,\n }\n}\n\nfunction typeOf(file: string): string {\n const extension = file.slice(file.lastIndexOf('.') + 1).toLowerCase()\n\n return extension === 'svg'\n ? 'image/svg+xml'\n : extension === 'jpg' || extension === 'jpeg'\n ? 'image/jpeg'\n : extension === 'ico'\n ? 'image/x-icon'\n : `image/${extension}`\n}\n\n/** The head tags these produce, as plain data the generated entry turns into elements. */\nexport function headTags(\n assets: AppAssets,\n origin?: string,\n): { tag: 'link' | 'meta'; props: Record<string, string> }[] {\n const tags: { tag: 'link' | 'meta'; props: Record<string, string> }[] = []\n\n if (assets.favicon) {\n tags.push({ tag: 'link', props: { rel: 'icon', href: assets.favicon.href, type: 'image/x-icon' } })\n }\n\n for (const icon of assets.icons) {\n tags.push({ tag: 'link', props: { rel: 'icon', href: icon.href, type: typeOf(icon.file) } })\n }\n\n if (assets.appleIcon) {\n tags.push({ tag: 'link', props: { rel: 'apple-touch-icon', href: assets.appleIcon.href } })\n }\n\n // Absolute when the app said where it lives. The og spec asks for an absolute\n // url and several crawlers still mean it — a relative one is read by some and\n // ignored by others, which is the worst of both. Relative is what is left\n // when nothing said, and is better than omitting the tag.\n const absolute = (href: string) => (origin ? new URL(href, origin).href : href)\n\n if (assets.openGraph) {\n tags.push({ tag: 'meta', props: { property: 'og:image', content: absolute(assets.openGraph.href) } })\n }\n\n if (assets.twitter) {\n tags.push({ tag: 'meta', props: { name: 'twitter:image', content: absolute(assets.twitter.href) } })\n tags.push({ tag: 'meta', props: { name: 'twitter:card', content: 'summary_large_image' } })\n }\n\n return tags\n}\n"]}
@@ -0,0 +1,43 @@
1
+ /** One route, as the build left it. */
2
+ export interface ReportedRoute {
3
+ url: string;
4
+ component: string;
5
+ /** frozen | shell | blocked | dynamic | error — see PrerenderResult. */
6
+ type: string;
7
+ /** Why it is not frozen, in the words the build printed. */
8
+ reason: string | null;
9
+ /** Something true and worth knowing that is not a failure. */
10
+ warning: string | null;
11
+ /** Gzipped bytes of javascript this url makes the browser download. */
12
+ clientJs: number | null;
13
+ }
14
+ export interface ReportedApiRoute {
15
+ url: string;
16
+ name: string;
17
+ type: string;
18
+ reason: string | null;
19
+ }
20
+ export interface BuildReport {
21
+ version: 1;
22
+ /** Routes that render, in the order the build reported them. */
23
+ routes: ReportedRoute[];
24
+ /** route.ts endpoints. */
25
+ apis: ReportedApiRoute[];
26
+ totals: {
27
+ static: number;
28
+ partial: number;
29
+ dynamic: number;
30
+ failed: number;
31
+ };
32
+ }
33
+ /** The name the report is written under, inside the build's own directory. */
34
+ export declare const REPORT_FILE = "build-report.json";
35
+ export declare function buildReport(routes: ReportedRoute[], apis: ReportedApiRoute[]): string;
36
+ /**
37
+ * The routes worth asking about, most interesting first.
38
+ *
39
+ * "Interesting" is not a judgement about the app — it is the order someone
40
+ * looking for a problem reads in. A failure first, then a page that could not
41
+ * be stored at all, then one that ships a shell, then the ones that are fine.
42
+ */
43
+ export declare function byInterest(routes: ReportedRoute[]): ReportedRoute[];
@@ -0,0 +1,40 @@
1
+ // What the build decided, written down.
2
+ //
3
+ // The classification exists already — it is printed as the build runs, and
4
+ // then it is gone. That is fine for a person watching a terminal and useless
5
+ // for anything that wants to ask afterwards: a CI step asserting nothing
6
+ // regressed, an editor, an agent being asked why a page is slow.
7
+ //
8
+ // So the same facts go to a file. Not a new computation and not a second
9
+ // source of truth — the report is written from the results the build already
10
+ // produced, in the same pass that prints them.
11
+ /** The name the report is written under, inside the build's own directory. */
12
+ export const REPORT_FILE = 'build-report.json';
13
+ export function buildReport(routes, apis) {
14
+ const count = (...types) => routes.filter((r) => types.includes(r.type)).length +
15
+ apis.filter((a) => types.includes(a.type)).length;
16
+ const report = {
17
+ version: 1,
18
+ routes,
19
+ apis,
20
+ totals: {
21
+ static: count('frozen'),
22
+ partial: count('shell'),
23
+ dynamic: count('blocked', 'dynamic'),
24
+ failed: count('error'),
25
+ },
26
+ };
27
+ return JSON.stringify(report, null, 2) + '\n';
28
+ }
29
+ /**
30
+ * The routes worth asking about, most interesting first.
31
+ *
32
+ * "Interesting" is not a judgement about the app — it is the order someone
33
+ * looking for a problem reads in. A failure first, then a page that could not
34
+ * be stored at all, then one that ships a shell, then the ones that are fine.
35
+ */
36
+ export function byInterest(routes) {
37
+ const rank = { error: 0, blocked: 1, shell: 2, dynamic: 3, frozen: 4 };
38
+ return [...routes].sort((a, b) => (rank[a.type] ?? 9) - (rank[b.type] ?? 9) || a.url.localeCompare(b.url));
39
+ }
40
+ //# sourceMappingURL=buildReport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buildReport.js","sourceRoot":"","sources":["../src/buildReport.ts"],"names":[],"mappings":"AAAA,wCAAwC;AACxC,EAAE;AACF,2EAA2E;AAC3E,6EAA6E;AAC7E,yEAAyE;AACzE,iEAAiE;AACjE,EAAE;AACF,yEAAyE;AACzE,6EAA6E;AAC7E,+CAA+C;AAqC/C,8EAA8E;AAC9E,MAAM,CAAC,MAAM,WAAW,GAAG,mBAAmB,CAAA;AAE9C,MAAM,UAAU,WAAW,CACzB,MAAuB,EACvB,IAAwB;IAExB,MAAM,KAAK,GAAG,CAAC,GAAG,KAAe,EAAE,EAAE,CACnC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM;QACnD,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAA;IAEnD,MAAM,MAAM,GAAgB;QAC1B,OAAO,EAAE,CAAC;QACV,MAAM;QACN,IAAI;QACJ,MAAM,EAAE;YACN,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC;YACvB,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC;YACvB,OAAO,EAAE,KAAK,CAAC,SAAS,EAAE,SAAS,CAAC;YACpC,MAAM,EAAE,KAAK,CAAC,OAAO,CAAC;SACvB;KACF,CAAA;IAED,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAA;AAC/C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,MAAuB;IAChD,MAAM,IAAI,GAA2B,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAA;IAE9F,OAAO,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,CACrB,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC,CAClF,CAAA;AACH,CAAC","sourcesContent":["// What the build decided, written down.\n//\n// The classification exists already — it is printed as the build runs, and\n// then it is gone. That is fine for a person watching a terminal and useless\n// for anything that wants to ask afterwards: a CI step asserting nothing\n// regressed, an editor, an agent being asked why a page is slow.\n//\n// So the same facts go to a file. Not a new computation and not a second\n// source of truth — the report is written from the results the build already\n// produced, in the same pass that prints them.\n\n/** One route, as the build left it. */\nexport interface ReportedRoute {\n url: string\n component: string\n /** frozen | shell | blocked | dynamic | error — see PrerenderResult. */\n type: string\n /** Why it is not frozen, in the words the build printed. */\n reason: string | null\n /** Something true and worth knowing that is not a failure. */\n warning: string | null\n /** Gzipped bytes of javascript this url makes the browser download. */\n clientJs: number | null\n}\n\nexport interface ReportedApiRoute {\n url: string\n name: string\n type: string\n reason: string | null\n}\n\nexport interface BuildReport {\n version: 1\n /** Routes that render, in the order the build reported them. */\n routes: ReportedRoute[]\n /** route.ts endpoints. */\n apis: ReportedApiRoute[]\n totals: {\n static: number\n partial: number\n dynamic: number\n failed: number\n }\n}\n\n/** The name the report is written under, inside the build's own directory. */\nexport const REPORT_FILE = 'build-report.json'\n\nexport function buildReport(\n routes: ReportedRoute[],\n apis: ReportedApiRoute[],\n): string {\n const count = (...types: string[]) =>\n routes.filter((r) => types.includes(r.type)).length +\n apis.filter((a) => types.includes(a.type)).length\n\n const report: BuildReport = {\n version: 1,\n routes,\n apis,\n totals: {\n static: count('frozen'),\n partial: count('shell'),\n dynamic: count('blocked', 'dynamic'),\n failed: count('error'),\n },\n }\n\n return JSON.stringify(report, null, 2) + '\\n'\n}\n\n/**\n * The routes worth asking about, most interesting first.\n *\n * \"Interesting\" is not a judgement about the app — it is the order someone\n * looking for a problem reads in. A failure first, then a page that could not\n * be stored at all, then one that ships a shell, then the ones that are fine.\n */\nexport function byInterest(routes: ReportedRoute[]): ReportedRoute[] {\n const rank: Record<string, number> = { error: 0, blocked: 1, shell: 2, dynamic: 3, frozen: 4 }\n\n return [...routes].sort(\n (a, b) => (rank[a.type] ?? 9) - (rank[b.type] ?? 9) || a.url.localeCompare(b.url),\n )\n}\n"]}
package/dist/host.js CHANGED
@@ -551,8 +551,6 @@ export function createRscHandler(options) {
551
551
  return null;
552
552
  if (request.method !== 'GET' && request.method !== 'HEAD')
553
553
  return null;
554
- if (url.search)
555
- return null;
556
554
  if (api.route.middleware.length > 0)
557
555
  return null;
558
556
  const stored = await options.prerendered(apiKey(url.pathname));
@@ -567,6 +565,16 @@ export function createRscHandler(options) {
567
565
  // to answer badly. Falling through runs the route, which is correct.
568
566
  return null;
569
567
  }
568
+ // The build said whether this route's answer depends on the query. A route
569
+ // that never awaited searchParams gives the same answer whatever is on the
570
+ // end of the url — which matters more than it sounds, because every
571
+ // ?utm_source= and ?fbclid= would otherwise miss the stored answer.
572
+ //
573
+ // Defaulting to varying when the field is absent: a file written by an
574
+ // older build did not record this, and serving it for every query would be
575
+ // guessing on the unsafe side.
576
+ if (url.search && frozen.varies !== false)
577
+ return null;
570
578
  return new Response(request.method === 'HEAD' ? null : frozen.body, {
571
579
  status: frozen.status,
572
580
  headers: withVersion(Object.fromEntries(frozen.headers)),