@rsc-kit/core 0.16.1 → 0.16.2

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.
@@ -20,6 +20,8 @@ export interface ApiPrerenderResult {
20
20
  name: string;
21
21
  type: 'frozen' | 'dynamic';
22
22
  reason: string | null;
23
+ /** Stored, and worth a second look - it froze a value that will not be the same tomorrow. */
24
+ warning?: string;
23
25
  }
24
26
  /**
25
27
  * Try to answer every api route once, at build time, and store what can be.
@@ -11,7 +11,8 @@
11
11
  // answer to a POST is a stored answer to something that was supposed to happen
12
12
  // once.
13
13
  import { pathKey } from './prerender.js';
14
- import { requestReadBy, withRequest } from './request.js';
14
+ import { requestReadBy, withRequest, withResponseDraft } from './request.js';
15
+ import { watchNondeterminism, whileRendering } from './nondeterminism.js';
15
16
  import { allowFor } from './routing.js';
16
17
  /**
17
18
  * How long a route gets to answer before it is called dynamic.
@@ -113,87 +114,116 @@ export async function prerenderApiRoutes(engine, manifest, write) {
113
114
  if (!engine.handleApiRoute || !manifest.apis?.length)
114
115
  return [];
115
116
  const results = [];
116
- for (const route of manifest.apis) {
117
- const said = (type, reason) => {
118
- results.push({ url: labelFor(route), name: route.name, type, reason });
119
- };
120
- if (!route.methods.includes('GET')) {
121
- said('dynamic', 'no GET to store');
122
- continue;
123
- }
124
- // A guarded route answers differently depending on who is asking, which is
125
- // the whole purpose of the guard. Storing one answer and serving it to
126
- // everyone is how a guard is silently removed.
127
- if (route.middleware.length > 0) {
128
- said('dynamic', 'guarded by middleware');
129
- continue;
130
- }
131
- const url = urlFor(route);
132
- if (!url) {
133
- said('dynamic', 'one url per param value, and none are listed');
134
- continue;
135
- }
136
- const touched = new Set();
137
- const request = probeRequest('https://prerender.invalid' + url, touched);
138
- // No request in scope, so headers(), cookies() and connection() suspend
139
- // forever rather than resolving to whatever the build machine had. The
140
- // budget below is what turns that into an answer.
141
- const answered = await withRequest(null, async () => {
142
- let readBy = [];
143
- const response = await Promise.race([
144
- engine
145
- .handleApiRoute(route.name, request, {}, allowFor(route))
146
- .then((value) => ({ value }))
147
- .catch((error) => ({ error })),
148
- new Promise((resolve) => setTimeout(() => {
149
- readBy = requestReadBy();
150
- resolve(null);
151
- }, BUDGET_MS)),
152
- ]);
153
- return { response, readBy: readBy.length ? readBy : requestReadBy() };
154
- });
155
- if (answered.response === null) {
156
- const why = answered.readBy.length
157
- ? 'dynamic — called ' + answered.readBy.join(', ')
158
- : 'did not answer within the build budget';
159
- said('dynamic', why);
160
- continue;
161
- }
162
- if ('error' in answered.response) {
163
- // Not a build failure. A route that throws with no request may be doing
164
- // exactly the right thing — refusing a caller it cannot identify — and
165
- // refusing the build over it would make that route unbuildable.
166
- said('dynamic', 'threw without a request');
167
- continue;
168
- }
169
- if (touched.size > 0) {
170
- said('dynamic', 'reads the request — ' + [...touched].sort().join(', '));
171
- continue;
172
- }
173
- // 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');
177
- const response = answered.response.value;
178
- const body = asText(new Uint8Array(await response.arrayBuffer()));
179
- if (body === null) {
180
- said('dynamic', 'answers with bytes rather than text');
181
- continue;
117
+ // Date.now() and friends are watched for the length of the loop, the way
118
+ // the page prerender watches them; whileRendering records what each route
119
+ // reached for while it answered.
120
+ const unwatch = watchNondeterminism();
121
+ try {
122
+ for (const route of manifest.apis) {
123
+ const said = (type, reason) => {
124
+ results.push({ url: labelFor(route), name: route.name, type, reason });
125
+ };
126
+ if (!route.methods.includes('GET')) {
127
+ said('dynamic', 'no GET to store');
128
+ continue;
129
+ }
130
+ // A guarded route answers differently depending on who is asking, which is
131
+ // the whole purpose of the guard. Storing one answer and serving it to
132
+ // everyone is how a guard is silently removed.
133
+ if (route.middleware.length > 0) {
134
+ said('dynamic', 'guarded by middleware');
135
+ continue;
136
+ }
137
+ const url = urlFor(route);
138
+ if (!url) {
139
+ said('dynamic', 'one url per param value, and none are listed');
140
+ continue;
141
+ }
142
+ const touched = new Set();
143
+ const request = probeRequest('https://prerender.invalid' + url, touched);
144
+ // No request in scope, so headers(), cookies() and connection() suspend
145
+ // forever rather than resolving to whatever the build machine had. The
146
+ // budget below is what turns that into an answer.
147
+ // Inside a response draft, because that is where the engine puts a cookie
148
+ // a route sets - a literal Set-Cookie on its Response is moved there too
149
+ // whenever a draft is open - and a probe with no draft reads a Response
150
+ // the cookie may already have left. Whether it went to the draft or stayed
151
+ // on the Response depends on what else is running in the process; the
152
+ // question here is only whether there was one.
153
+ const answered = await withResponseDraft(({ taken }) => withRequest(null, async () => {
154
+ let readBy = [];
155
+ const response = await Promise.race([
156
+ whileRendering(() => engine.handleApiRoute(route.name, request, {}, allowFor(route)))
157
+ .then(([value, reached]) => ({ value, reached }))
158
+ .catch((error) => ({ error })),
159
+ new Promise((resolve) => setTimeout(() => {
160
+ readBy = requestReadBy();
161
+ resolve(null);
162
+ }, BUDGET_MS)),
163
+ ]);
164
+ return { response, readBy: readBy.length ? readBy : requestReadBy(), drafted: taken() };
165
+ }));
166
+ if (answered.response === null) {
167
+ const why = answered.readBy.length
168
+ ? 'dynamic — called ' + answered.readBy.join(', ')
169
+ : 'did not answer within the build budget';
170
+ said('dynamic', why);
171
+ continue;
172
+ }
173
+ if ('error' in answered.response) {
174
+ // Not a build failure. A route that throws with no request may be doing
175
+ // exactly the right thing — refusing a caller it cannot identify — and
176
+ // refusing the build over it would make that route unbuildable.
177
+ said('dynamic', 'threw without a request');
178
+ continue;
179
+ }
180
+ if (touched.size > 0) {
181
+ said('dynamic', 'reads the request — ' + [...touched].sort().join(', '));
182
+ continue;
183
+ }
184
+ // Awaiting the query string is not a reason to give up on the route — the
185
+ // bare url still has one right answer. It only narrows which requests the
186
+ // stored answer is good for.
187
+ const varies = answered.readBy.includes('searchParams');
188
+ const response = answered.response.value;
189
+ // A cookie is an answer for one visitor, whatever the route read to
190
+ // decide on it. Stored, the build's cookie would be handed to everyone.
191
+ if (response.headers.has('set-cookie') || answered.drafted.has('set-cookie')) {
192
+ said('dynamic', 'sets a cookie — an answer for one visitor, not one to store');
193
+ continue;
194
+ }
195
+ const body = asText(new Uint8Array(await response.arrayBuffer()));
196
+ if (body === null) {
197
+ said('dynamic', 'answers with bytes rather than text');
198
+ continue;
199
+ }
200
+ await write(apiKey(url), JSON.stringify({
201
+ status: response.status,
202
+ // Lower-cased and sorted, so two builds of the same route produce the
203
+ // same bytes. Headers iteration does not promise a case or an order,
204
+ // and a file that differs between builds for no reason defeats
205
+ // content-addressed caching and makes a diff unreadable. Names are
206
+ // case-insensitive, so nothing is lost by picking one.
207
+ headers: [...response.headers]
208
+ .map(([name, value]) => [name.toLowerCase(), value])
209
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
210
+ body,
211
+ varies,
212
+ }));
213
+ said('frozen', varies ? 'stored for the bare url — it reads the query string' : null);
214
+ // The same warning a page gets: a stored answer keeps whatever Date.now()
215
+ // or Math.random() returned at build time, and a json body has no browser
216
+ // to move it to.
217
+ const reached = answered.response.reached;
218
+ if (reached.length > 0) {
219
+ results[results.length - 1].warning =
220
+ `froze ${reached.join(' and ')} — a stored answer keeps whatever that returned at build time. ` +
221
+ 'If it should differ per call, read the request (await connection()) so the route runs on demand.';
222
+ }
182
223
  }
183
- await write(apiKey(url), JSON.stringify({
184
- status: response.status,
185
- // Lower-cased and sorted, so two builds of the same route produce the
186
- // same bytes. Headers iteration does not promise a case or an order,
187
- // and a file that differs between builds for no reason defeats
188
- // content-addressed caching and makes a diff unreadable. Names are
189
- // case-insensitive, so nothing is lost by picking one.
190
- headers: [...response.headers]
191
- .map(([name, value]) => [name.toLowerCase(), value])
192
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
193
- body,
194
- varies,
195
- }));
196
- said('frozen', varies ? 'stored for the bare url — it reads the query string' : null);
224
+ }
225
+ finally {
226
+ unwatch();
197
227
  }
198
228
  return results;
199
229
  }
@@ -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 * 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"]}
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, withResponseDraft } from './request.js'\nimport { watchNondeterminism, whileRendering } from './nondeterminism.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 /** Stored, and worth a second look - it froze a value that will not be the same tomorrow. */\n warning?: string\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 // Date.now() and friends are watched for the length of the loop, the way\n // the page prerender watches them; whileRendering records what each route\n // reached for while it answered.\n const unwatch = watchNondeterminism()\n\n try {\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 // Inside a response draft, because that is where the engine puts a cookie\n // a route sets - a literal Set-Cookie on its Response is moved there too\n // whenever a draft is open - and a probe with no draft reads a Response\n // the cookie may already have left. Whether it went to the draft or stayed\n // on the Response depends on what else is running in the process; the\n // question here is only whether there was one.\n const answered = await withResponseDraft(({ taken }) =>\n withRequest(null as never, async () => {\n let readBy: string[] = []\n\n const response = await Promise.race([\n whileRendering(() => engine.handleApiRoute!(route.name, request, {}, allowFor(route)))\n .then(([value, reached]) => ({ value, reached }))\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(), drafted: taken() }\n }),\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\n // A cookie is an answer for one visitor, whatever the route read to\n // decide on it. Stored, the build's cookie would be handed to everyone.\n if (response.headers.has('set-cookie') || answered.drafted.has('set-cookie')) {\n said('dynamic', 'sets a cookie — an answer for one visitor, not one to store')\n continue\n }\n\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 // The same warning a page gets: a stored answer keeps whatever Date.now()\n // or Math.random() returned at build time, and a json body has no browser\n // to move it to.\n const reached = answered.response.reached\n\n if (reached.length > 0) {\n results[results.length - 1]!.warning =\n `froze ${reached.join(' and ')} — a stored answer keeps whatever that returned at build time. ` +\n 'If it should differ per call, read the request (await connection()) so the route runs on demand.'\n }\n }\n } finally {\n unwatch()\n }\n\n return results\n}\n"]}
@@ -28,6 +28,8 @@ export interface ReportedApiRoute {
28
28
  name: string;
29
29
  type: string;
30
30
  reason: string | null;
31
+ /** Stored, and froze a value that will not be the same tomorrow. Absent from older reports. */
32
+ warning?: string | null;
31
33
  }
32
34
  export interface BuildReport {
33
35
  version: 1;
@@ -1 +1 @@
1
- {"version":3,"file":"buildReport.js","sourceRoot":"","sources":["../src/buildReport.ts"],"names":[],"mappings":"AAAA,wCAAwC;AACxC,EAAE;AACF,2EAA2E;AAC3E,6EAA6E;AAC7E,yEAAyE;AACzE,iEAAiE;AACjE,EAAE;AACF,yEAAyE;AACzE,6EAA6E;AAC7E,+CAA+C;AAoD/C,8EAA8E;AAC9E,MAAM,CAAC,MAAM,WAAW,GAAG,mBAAmB,CAAA;AAE9C,MAAM,UAAU,WAAW,CACzB,MAAuB,EACvB,IAAwB,EACxB,OAAO,GAAqB,EAAE;IAE9B,MAAM,KAAK,GAAG,CAAC,GAAG,KAAe,EAAE,EAAE,CACnC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM;QACnD,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAA;IAEnD,MAAM,MAAM,GAAgB;QAC1B,OAAO,EAAE,CAAC;QACV,MAAM;QACN,IAAI;QACJ,OAAO;QACP,MAAM,EAAE;YACN,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC;YACvB,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC;YACvB,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC;YACzB,yEAAyE;YACzE,uEAAuE;YACvE,MAAM,EAAE,KAAK,CAAC,OAAO,EAAE,SAAS,CAAC;SAClC;KACF,CAAA;IAED,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAA;AAC/C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,MAAuB;IAChD,MAAM,IAAI,GAA2B,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAA;IAE9F,OAAO,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,CACrB,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC,CAClF,CAAA;AACH,CAAC","sourcesContent":["// What the build decided, written down.\n//\n// The classification exists already — it is printed as the build runs, and\n// then it is gone. That is fine for a person watching a terminal and useless\n// for anything that wants to ask afterwards: a CI step asserting nothing\n// regressed, an editor, an agent being asked why a page is slow.\n//\n// So the same facts go to a file. Not a new computation and not a second\n// source of truth — the report is written from the results the build already\n// produced, in the same pass that prints them.\n\n/** One route, as the build left it. */\nexport interface ReportedRoute {\n url: string\n component: string\n /** frozen | shell | blocked | dynamic | error — see PrerenderResult. blocked and error failed the build. */\n type: string\n /** Why it is not frozen, in the words the build printed. */\n reason: string | null\n /** Something true and worth knowing that is not a failure. */\n warning: string | null\n /** A fact about how it was stored - \"no client components, so ships no javascript\". Absent from older reports. */\n note?: string | null\n /** Gzipped bytes of javascript this url makes the browser download. */\n clientJs: number | null\n}\n\n/** A server action, and whether anything checks who calls it. */\nexport interface ReportedAction {\n id: string\n name: string\n file: string\n /** Built by createActionClient, so its middleware ran. */\n client: boolean\n /** A read (GET) rather than a mutation. */\n query: boolean\n}\n\nexport interface ReportedApiRoute {\n url: string\n name: string\n type: string\n reason: string | null\n}\n\nexport interface BuildReport {\n version: 1\n /** Routes that render, in the order the build reported them. */\n routes: ReportedRoute[]\n /** route.ts endpoints. */\n apis: ReportedApiRoute[]\n /** Every \"use server\" export the app registered. Absent from reports older than this field. */\n actions?: ReportedAction[]\n totals: {\n static: number\n partial: number\n dynamic: number\n failed: number\n }\n}\n\n/** The name the report is written under, inside the build's own directory. */\nexport const REPORT_FILE = 'build-report.json'\n\nexport function buildReport(\n routes: ReportedRoute[],\n apis: ReportedApiRoute[],\n actions: ReportedAction[] = [],\n): string {\n const count = (...types: string[]) =>\n routes.filter((r) => types.includes(r.type)).length +\n apis.filter((a) => types.includes(a.type)).length\n\n const report: BuildReport = {\n version: 1,\n routes,\n apis,\n actions,\n totals: {\n static: count('frozen'),\n partial: count('shell'),\n dynamic: count('dynamic'),\n // Blocked is refused, not dynamic: a page that painted nothing before it\n // read the request has no shell to store and the build did not finish.\n failed: count('error', 'blocked'),\n },\n }\n\n return JSON.stringify(report, null, 2) + '\\n'\n}\n\n/**\n * The routes worth asking about, most interesting first.\n *\n * \"Interesting\" is not a judgement about the app — it is the order someone\n * looking for a problem reads in. A failure first, then a page that could not\n * be stored at all, then one that ships a shell, then the ones that are fine.\n */\nexport function byInterest(routes: ReportedRoute[]): ReportedRoute[] {\n const rank: Record<string, number> = { error: 0, blocked: 1, shell: 2, dynamic: 3, frozen: 4 }\n\n return [...routes].sort(\n (a, b) => (rank[a.type] ?? 9) - (rank[b.type] ?? 9) || a.url.localeCompare(b.url),\n )\n}\n"]}
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;AAsD/C,8EAA8E;AAC9E,MAAM,CAAC,MAAM,WAAW,GAAG,mBAAmB,CAAA;AAE9C,MAAM,UAAU,WAAW,CACzB,MAAuB,EACvB,IAAwB,EACxB,OAAO,GAAqB,EAAE;IAE9B,MAAM,KAAK,GAAG,CAAC,GAAG,KAAe,EAAE,EAAE,CACnC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM;QACnD,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAA;IAEnD,MAAM,MAAM,GAAgB;QAC1B,OAAO,EAAE,CAAC;QACV,MAAM;QACN,IAAI;QACJ,OAAO;QACP,MAAM,EAAE;YACN,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC;YACvB,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC;YACvB,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC;YACzB,yEAAyE;YACzE,uEAAuE;YACvE,MAAM,EAAE,KAAK,CAAC,OAAO,EAAE,SAAS,CAAC;SAClC;KACF,CAAA;IAED,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAA;AAC/C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,MAAuB;IAChD,MAAM,IAAI,GAA2B,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAA;IAE9F,OAAO,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,CACrB,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC,CAClF,CAAA;AACH,CAAC","sourcesContent":["// What the build decided, written down.\n//\n// The classification exists already — it is printed as the build runs, and\n// then it is gone. That is fine for a person watching a terminal and useless\n// for anything that wants to ask afterwards: a CI step asserting nothing\n// regressed, an editor, an agent being asked why a page is slow.\n//\n// So the same facts go to a file. Not a new computation and not a second\n// source of truth — the report is written from the results the build already\n// produced, in the same pass that prints them.\n\n/** One route, as the build left it. */\nexport interface ReportedRoute {\n url: string\n component: string\n /** frozen | shell | blocked | dynamic | error — see PrerenderResult. blocked and error failed the build. */\n type: string\n /** Why it is not frozen, in the words the build printed. */\n reason: string | null\n /** Something true and worth knowing that is not a failure. */\n warning: string | null\n /** A fact about how it was stored - \"no client components, so ships no javascript\". Absent from older reports. */\n note?: string | null\n /** Gzipped bytes of javascript this url makes the browser download. */\n clientJs: number | null\n}\n\n/** A server action, and whether anything checks who calls it. */\nexport interface ReportedAction {\n id: string\n name: string\n file: string\n /** Built by createActionClient, so its middleware ran. */\n client: boolean\n /** A read (GET) rather than a mutation. */\n query: boolean\n}\n\nexport interface ReportedApiRoute {\n url: string\n name: string\n type: string\n reason: string | null\n /** Stored, and froze a value that will not be the same tomorrow. Absent from older reports. */\n warning?: string | null\n}\n\nexport interface BuildReport {\n version: 1\n /** Routes that render, in the order the build reported them. */\n routes: ReportedRoute[]\n /** route.ts endpoints. */\n apis: ReportedApiRoute[]\n /** Every \"use server\" export the app registered. Absent from reports older than this field. */\n actions?: ReportedAction[]\n totals: {\n static: number\n partial: number\n dynamic: number\n failed: number\n }\n}\n\n/** The name the report is written under, inside the build's own directory. */\nexport const REPORT_FILE = 'build-report.json'\n\nexport function buildReport(\n routes: ReportedRoute[],\n apis: ReportedApiRoute[],\n actions: ReportedAction[] = [],\n): string {\n const count = (...types: string[]) =>\n routes.filter((r) => types.includes(r.type)).length +\n apis.filter((a) => types.includes(a.type)).length\n\n const report: BuildReport = {\n version: 1,\n routes,\n apis,\n actions,\n totals: {\n static: count('frozen'),\n partial: count('shell'),\n dynamic: count('dynamic'),\n // Blocked is refused, not dynamic: a page that painted nothing before it\n // read the request has no shell to store and the build did not finish.\n failed: count('error', 'blocked'),\n },\n }\n\n return JSON.stringify(report, null, 2) + '\\n'\n}\n\n/**\n * The routes worth asking about, most interesting first.\n *\n * \"Interesting\" is not a judgement about the app — it is the order someone\n * looking for a problem reads in. A failure first, then a page that could not\n * be stored at all, then one that ships a shell, then the ones that are fine.\n */\nexport function byInterest(routes: ReportedRoute[]): ReportedRoute[] {\n const rank: Record<string, number> = { error: 0, blocked: 1, shell: 2, dynamic: 3, frozen: 4 }\n\n return [...routes].sort(\n (a, b) => (rank[a.type] ?? 9) - (rank[b.type] ?? 9) || a.url.localeCompare(b.url),\n )\n}\n"]}
package/dist/files.js CHANGED
@@ -26,12 +26,31 @@ export function prerenderedFrom(dir) {
26
26
  * caught two exceptions before finding anything. Measured at 3,000 req/s
27
27
  * against 28,000 for the same page read from memory.
28
28
  *
29
- * Only existence is cached, never contents: a file that is there is still
30
- * read on every request, so a redeploy that rewrites one is picked up. What
31
- * a running server will not notice is a page appearing that was not there at
32
- * boot — which is a build artefact, and the build has finished.
29
+ * Contents are cached for the life of the process. A build's output does
30
+ * not change under a running server - a deploy restarts it, and a server
31
+ * that outlived a rebuild would be serving a stale engine against new
32
+ * pages anyway - so the first read is the only one. Under load the same
33
+ * page used to be opened, read and closed thousands of times a second,
34
+ * which a profile showed as the one cost on a stored page that was ours
35
+ * rather than React's. Capped by bytes, so a site with more pages than
36
+ * memory holds the hot ones and re-reads the rest.
33
37
  */
34
38
  let present = null;
39
+ const CONTENT_CAP_BYTES = 64 * 1024 * 1024;
40
+ const contents = new Map();
41
+ let held = 0;
42
+ const remember = (name, text) => {
43
+ const size = text.length * 2;
44
+ if (size > CONTENT_CAP_BYTES / 4)
45
+ return;
46
+ while (held + size > CONTENT_CAP_BYTES && contents.size > 0) {
47
+ const oldest = contents.keys().next().value;
48
+ held -= contents.get(oldest).length * 2;
49
+ contents.delete(oldest);
50
+ }
51
+ contents.set(name, text);
52
+ held += size;
53
+ };
35
54
  const listing = async () => {
36
55
  present ??= readdir(dir, { recursive: true })
37
56
  .then((names) => new Set(names.map((n) => String(n).replace(/\\/g, '/'))))
@@ -45,8 +64,13 @@ export function prerenderedFrom(dir) {
45
64
  return null;
46
65
  if (!(await listing()).has(name))
47
66
  return null;
67
+ const cached = contents.get(name);
68
+ if (cached !== undefined)
69
+ return cached;
48
70
  try {
49
- return await readFile(join(dir, name), 'utf-8');
71
+ const text = await readFile(join(dir, name), 'utf-8');
72
+ remember(name, text);
73
+ return text;
50
74
  }
51
75
  catch {
52
76
  return null;
package/dist/files.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"files.js","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"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","sourcesContent":["// Reading built output off a disk.\n//\n// Kept apart from the host on purpose: it is the only part of serving an RSC\n// app that assumes a filesystem, and plenty of places to run one do not have\n// one. Nothing in `@rsc-kit/core/host` imports this module, so a bundle for a\n// host without a filesystem carries no reference to `node:fs` at all.\n//\n// Assets are not here any more — Nitro publishes and serves those. What is\n// left is the prerendered output: written by the build, read by the server it\n// generates, and copied by a static export.\n\nimport { cp, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\n\n/**\n * Read what the prerenderer wrote, from a directory.\n *\n * Missing is an answer, not an error: a partial prerender is a valid state,\n * and anything not found here is rendered on demand instead.\n */\nexport function prerenderedFrom(dir: string) {\n /**\n * What is on disk, listed once.\n *\n * A miss used to be a thrown ENOENT, and misses are the common case: the\n * host asks for `{url}.html`, then `{url}.ppr.html`, then the route's\n * pattern — so every request to a page served by a pattern shell threw and\n * caught two exceptions before finding anything. Measured at 3,000 req/s\n * against 28,000 for the same page read from memory.\n *\n * Only existence is cached, never contents: a file that is there is still\n * read on every request, so a redeploy that rewrites one is picked up. What\n * a running server will not notice is a page appearing that was not there at\n * boot — which is a build artefact, and the build has finished.\n */\n let present: Promise<Set<string>> | null = null\n\n const listing = async (): Promise<Set<string>> => {\n present ??= readdir(dir, { recursive: true })\n .then((names) => new Set(names.map((n) => String(n).replace(/\\\\/g, '/'))))\n // An absent directory is an empty one: a partial prerender is a valid\n // state, and everything falls through to being rendered.\n .catch(() => new Set<string>())\n\n return await present\n }\n\n return async (name: string): Promise<string | null> => {\n if (name.includes('..')) return null\n\n if (!(await listing()).has(name)) return null\n\n try {\n return await readFile(join(dir, name), 'utf-8')\n } catch {\n return null\n }\n }\n}\n\n/**\n * The same reader, for a directory whose absolute path is not known until the\n * server runs.\n *\n * Nitro bundles the generated entry to a different depth depending on the\n * preset — `.output/server/index.mjs` for one, `.output/server/_ssr/rsc.mjs`\n * for another — so neither a fixed relative path nor a path baked in at build\n * time survives: the first is wrong for half the presets, and the second stops\n * being true the moment `.output/` is copied to the machine that serves it.\n *\n * So the caller passes its own `import.meta.url` and the directory is found by\n * walking up from it. A few levels, bounded, and resolved once.\n *\n * prerendered: prerenderedBeside(import.meta.url, 'rsc-static')\n *\n * Not finding it is a valid state and not an error: nothing was frozen, or\n * this host has no filesystem to have frozen it on. Every page renders live,\n * which is what happens today everywhere.\n */\nexport function prerenderedBeside(moduleUrl: string, dirName: string, levels = 4) {\n let reader: Promise<(name: string) => Promise<string | null>> | null = null\n\n const resolveDir = async (): Promise<(name: string) => Promise<string | null>> => {\n let dir: string | null = null\n\n try {\n const { fileURLToPath } = await import('node:url')\n\n dir = dirname(fileURLToPath(moduleUrl))\n } catch {\n // No filesystem, or no file: url to speak of. The inline module below\n // is the only place the pages can be.\n }\n\n if (dir !== null) {\n for (let i = 0; i <= levels; i++) {\n const candidate = join(dir, ...Array(i).fill('..'), dirName)\n\n try {\n await readdir(candidate)\n\n return prerenderedFrom(candidate)\n } catch {\n // Not at this level. Keep walking.\n }\n }\n }\n\n const inline = await inlinePrerendered(moduleUrl, `${dirName}-inline.mjs`, levels)\n\n return inline ? async (name) => inline[name] ?? null : async () => null\n }\n\n return async (name: string): Promise<string | null> => {\n reader ??= resolveDir()\n\n return await (await reader)(name)\n }\n}\n\n/**\n * The frozen pages as one module, for a runtime with no filesystem.\n *\n * A Worker cannot readdir. Its modules are what wrangler uploaded, and a\n * sibling .mjs beside the bundle is one of them - so the build writes every\n * stored file into `rsc-static-inline.mjs` as strings, and this walks up from\n * the caller the way the disk reader does, importing rather than reading.\n * The specifier is computed, and marked for the bundler to leave alone: at\n * bundle time the file does not exist yet, because it is written by the\n * prerender that runs the bundle.\n *\n * Two forms of the specifier, because runtimes disagree. workerd resolves a\n * relative string against the importing module's name and refuses a url;\n * Node and Bun want the url, since a relative string would resolve against\n * this file rather than the caller's.\n */\nasync function inlinePrerendered(\n moduleUrl: string,\n fileName: string,\n levels: number,\n): Promise<Record<string, string> | null> {\n for (let i = 0; i <= levels; i++) {\n const relative = './' + '../'.repeat(i) + fileName\n\n for (const specifier of [relative, safeUrl(relative, moduleUrl)]) {\n if (!specifier) continue\n\n try {\n const loaded = (await import(/* @vite-ignore */ specifier)) as { default?: Record<string, string> }\n\n if (loaded.default && typeof loaded.default === 'object') return loaded.default\n } catch {\n // Not there, or not at this level.\n }\n }\n }\n\n return null\n}\n\nfunction safeUrl(relative: string, base: string): string | null {\n try {\n return new URL(relative, base).href\n } catch {\n return null\n }\n}\n\n/** The name the build writes the inline module under, beside the stored pages' directory. */\nexport function inlineModuleName(dirName: string): string {\n return `${dirName}-inline.mjs`\n}\n\n/**\n * Every stored file under `dir`, as the source of the inline module.\n * Strings only: a frozen page, its flight payload, its meta, an api answer.\n */\nexport async function inlineModuleSource(dir: string): Promise<string> {\n const { readdir: list, readFile: read } = await import('node:fs/promises')\n const entries: Record<string, string> = {}\n\n const walk = async (sub: string): Promise<void> => {\n for (const entry of await list(join(dir, sub), { withFileTypes: true })) {\n const rel = sub ? `${sub}/${entry.name}` : entry.name\n\n if (entry.isDirectory()) await walk(rel)\n else entries[rel] = await read(join(dir, rel), 'utf-8')\n }\n }\n\n await walk('')\n\n return (\n '// @generated by the RSC build: the stored pages, for a runtime with no filesystem.\\n' +\n 'export default ' +\n JSON.stringify(entries) +\n '\\n'\n )\n}\n\nfunction contentTypeOf(pathname: string): string {\n if (pathname.endsWith('.js')) return 'text/javascript; charset=utf-8'\n if (pathname.endsWith('.css')) return 'text/css; charset=utf-8'\n if (pathname.endsWith('.map')) return 'application/json; charset=utf-8'\n if (pathname.endsWith('.svg')) return 'image/svg+xml'\n if (pathname.endsWith('.woff2')) return 'font/woff2'\n\n return 'application/octet-stream'\n}\n\n/**\n * Write build output into a directory.\n *\n * The sink `prerender` and `exportSite` take. Names are relative and may\n * contain directories — `docs/index.html` — so each one's parent is created\n * as it goes.\n */\nexport function writeTo(dir: string): ((name: string, contents: string) => Promise<void>) & { dir: string } {\n const write = async (name: string, contents: string): Promise<void> => {\n const path = join(dir, name)\n\n await mkdir(dirname(path), { recursive: true })\n await writeFile(path, contents)\n }\n\n // Where it wrote, for a caller that has to read it back. The embeddable\n // module has to list these files, and being told the directory a second time\n // is a second place for it to be wrong.\n return Object.assign(write, { dir })\n}\n\n/**\n * Copy the built browser bundle into an exported site.\n *\n * Separate from the export itself because copying a tree of files is a\n * filesystem operation by nature — a deploy that uploads them to a bucket\n * has its own way to do that, and passes its own callback instead.\n */\nexport function copyAssets(from: string, to: string, url = '/assets/') {\n return async (): Promise<void> => {\n const at = url.replace(/^\\/+|\\/+$/g, '')\n\n if (at === '') return\n\n const target = join(to, at)\n\n await mkdir(dirname(target), { recursive: true })\n await cp(from, target, { recursive: true })\n }\n}\n"]}
1
+ {"version":3,"file":"files.js","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"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","sourcesContent":["// Reading built output off a disk.\n//\n// Kept apart from the host on purpose: it is the only part of serving an RSC\n// app that assumes a filesystem, and plenty of places to run one do not have\n// one. Nothing in `@rsc-kit/core/host` imports this module, so a bundle for a\n// host without a filesystem carries no reference to `node:fs` at all.\n//\n// Assets are not here any more — Nitro publishes and serves those. What is\n// left is the prerendered output: written by the build, read by the server it\n// generates, and copied by a static export.\n\nimport { cp, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\n\n/**\n * Read what the prerenderer wrote, from a directory.\n *\n * Missing is an answer, not an error: a partial prerender is a valid state,\n * and anything not found here is rendered on demand instead.\n */\nexport function prerenderedFrom(dir: string) {\n /**\n * What is on disk, listed once.\n *\n * A miss used to be a thrown ENOENT, and misses are the common case: the\n * host asks for `{url}.html`, then `{url}.ppr.html`, then the route's\n * pattern — so every request to a page served by a pattern shell threw and\n * caught two exceptions before finding anything. Measured at 3,000 req/s\n * against 28,000 for the same page read from memory.\n *\n * Contents are cached for the life of the process. A build's output does\n * not change under a running server - a deploy restarts it, and a server\n * that outlived a rebuild would be serving a stale engine against new\n * pages anyway - so the first read is the only one. Under load the same\n * page used to be opened, read and closed thousands of times a second,\n * which a profile showed as the one cost on a stored page that was ours\n * rather than React's. Capped by bytes, so a site with more pages than\n * memory holds the hot ones and re-reads the rest.\n */\n let present: Promise<Set<string>> | null = null\n\n const CONTENT_CAP_BYTES = 64 * 1024 * 1024\n const contents = new Map<string, string>()\n let held = 0\n\n const remember = (name: string, text: string): void => {\n const size = text.length * 2\n\n if (size > CONTENT_CAP_BYTES / 4) return\n\n while (held + size > CONTENT_CAP_BYTES && contents.size > 0) {\n const oldest = contents.keys().next().value as string\n held -= contents.get(oldest)!.length * 2\n contents.delete(oldest)\n }\n\n contents.set(name, text)\n held += size\n }\n\n const listing = async (): Promise<Set<string>> => {\n present ??= readdir(dir, { recursive: true })\n .then((names) => new Set(names.map((n) => String(n).replace(/\\\\/g, '/'))))\n // An absent directory is an empty one: a partial prerender is a valid\n // state, and everything falls through to being rendered.\n .catch(() => new Set<string>())\n\n return await present\n }\n\n return async (name: string): Promise<string | null> => {\n if (name.includes('..')) return null\n\n if (!(await listing()).has(name)) return null\n\n const cached = contents.get(name)\n\n if (cached !== undefined) return cached\n\n try {\n const text = await readFile(join(dir, name), 'utf-8')\n\n remember(name, text)\n\n return text\n } catch {\n return null\n }\n }\n}\n\n/**\n * The same reader, for a directory whose absolute path is not known until the\n * server runs.\n *\n * Nitro bundles the generated entry to a different depth depending on the\n * preset — `.output/server/index.mjs` for one, `.output/server/_ssr/rsc.mjs`\n * for another — so neither a fixed relative path nor a path baked in at build\n * time survives: the first is wrong for half the presets, and the second stops\n * being true the moment `.output/` is copied to the machine that serves it.\n *\n * So the caller passes its own `import.meta.url` and the directory is found by\n * walking up from it. A few levels, bounded, and resolved once.\n *\n * prerendered: prerenderedBeside(import.meta.url, 'rsc-static')\n *\n * Not finding it is a valid state and not an error: nothing was frozen, or\n * this host has no filesystem to have frozen it on. Every page renders live,\n * which is what happens today everywhere.\n */\nexport function prerenderedBeside(moduleUrl: string, dirName: string, levels = 4) {\n let reader: Promise<(name: string) => Promise<string | null>> | null = null\n\n const resolveDir = async (): Promise<(name: string) => Promise<string | null>> => {\n let dir: string | null = null\n\n try {\n const { fileURLToPath } = await import('node:url')\n\n dir = dirname(fileURLToPath(moduleUrl))\n } catch {\n // No filesystem, or no file: url to speak of. The inline module below\n // is the only place the pages can be.\n }\n\n if (dir !== null) {\n for (let i = 0; i <= levels; i++) {\n const candidate = join(dir, ...Array(i).fill('..'), dirName)\n\n try {\n await readdir(candidate)\n\n return prerenderedFrom(candidate)\n } catch {\n // Not at this level. Keep walking.\n }\n }\n }\n\n const inline = await inlinePrerendered(moduleUrl, `${dirName}-inline.mjs`, levels)\n\n return inline ? async (name) => inline[name] ?? null : async () => null\n }\n\n return async (name: string): Promise<string | null> => {\n reader ??= resolveDir()\n\n return await (await reader)(name)\n }\n}\n\n/**\n * The frozen pages as one module, for a runtime with no filesystem.\n *\n * A Worker cannot readdir. Its modules are what wrangler uploaded, and a\n * sibling .mjs beside the bundle is one of them - so the build writes every\n * stored file into `rsc-static-inline.mjs` as strings, and this walks up from\n * the caller the way the disk reader does, importing rather than reading.\n * The specifier is computed, and marked for the bundler to leave alone: at\n * bundle time the file does not exist yet, because it is written by the\n * prerender that runs the bundle.\n *\n * Two forms of the specifier, because runtimes disagree. workerd resolves a\n * relative string against the importing module's name and refuses a url;\n * Node and Bun want the url, since a relative string would resolve against\n * this file rather than the caller's.\n */\nasync function inlinePrerendered(\n moduleUrl: string,\n fileName: string,\n levels: number,\n): Promise<Record<string, string> | null> {\n for (let i = 0; i <= levels; i++) {\n const relative = './' + '../'.repeat(i) + fileName\n\n for (const specifier of [relative, safeUrl(relative, moduleUrl)]) {\n if (!specifier) continue\n\n try {\n const loaded = (await import(/* @vite-ignore */ specifier)) as { default?: Record<string, string> }\n\n if (loaded.default && typeof loaded.default === 'object') return loaded.default\n } catch {\n // Not there, or not at this level.\n }\n }\n }\n\n return null\n}\n\nfunction safeUrl(relative: string, base: string): string | null {\n try {\n return new URL(relative, base).href\n } catch {\n return null\n }\n}\n\n/** The name the build writes the inline module under, beside the stored pages' directory. */\nexport function inlineModuleName(dirName: string): string {\n return `${dirName}-inline.mjs`\n}\n\n/**\n * Every stored file under `dir`, as the source of the inline module.\n * Strings only: a frozen page, its flight payload, its meta, an api answer.\n */\nexport async function inlineModuleSource(dir: string): Promise<string> {\n const { readdir: list, readFile: read } = await import('node:fs/promises')\n const entries: Record<string, string> = {}\n\n const walk = async (sub: string): Promise<void> => {\n for (const entry of await list(join(dir, sub), { withFileTypes: true })) {\n const rel = sub ? `${sub}/${entry.name}` : entry.name\n\n if (entry.isDirectory()) await walk(rel)\n else entries[rel] = await read(join(dir, rel), 'utf-8')\n }\n }\n\n await walk('')\n\n return (\n '// @generated by the RSC build: the stored pages, for a runtime with no filesystem.\\n' +\n 'export default ' +\n JSON.stringify(entries) +\n '\\n'\n )\n}\n\nfunction contentTypeOf(pathname: string): string {\n if (pathname.endsWith('.js')) return 'text/javascript; charset=utf-8'\n if (pathname.endsWith('.css')) return 'text/css; charset=utf-8'\n if (pathname.endsWith('.map')) return 'application/json; charset=utf-8'\n if (pathname.endsWith('.svg')) return 'image/svg+xml'\n if (pathname.endsWith('.woff2')) return 'font/woff2'\n\n return 'application/octet-stream'\n}\n\n/**\n * Write build output into a directory.\n *\n * The sink `prerender` and `exportSite` take. Names are relative and may\n * contain directories — `docs/index.html` — so each one's parent is created\n * as it goes.\n */\nexport function writeTo(dir: string): ((name: string, contents: string) => Promise<void>) & { dir: string } {\n const write = async (name: string, contents: string): Promise<void> => {\n const path = join(dir, name)\n\n await mkdir(dirname(path), { recursive: true })\n await writeFile(path, contents)\n }\n\n // Where it wrote, for a caller that has to read it back. The embeddable\n // module has to list these files, and being told the directory a second time\n // is a second place for it to be wrong.\n return Object.assign(write, { dir })\n}\n\n/**\n * Copy the built browser bundle into an exported site.\n *\n * Separate from the export itself because copying a tree of files is a\n * filesystem operation by nature — a deploy that uploads them to a bucket\n * has its own way to do that, and passes its own callback instead.\n */\nexport function copyAssets(from: string, to: string, url = '/assets/') {\n return async (): Promise<void> => {\n const at = url.replace(/^\\/+|\\/+$/g, '')\n\n if (at === '') return\n\n const target = join(to, at)\n\n await mkdir(dirname(target), { recursive: true })\n await cp(from, target, { recursive: true })\n }\n}\n"]}
package/dist/host.d.ts CHANGED
@@ -127,6 +127,17 @@ export interface RscHostOptions {
127
127
  * a CDN, where the shell it holds may already be from an older build.
128
128
  */
129
129
  version?: string;
130
+ /**
131
+ * The most an action body may be, in bytes. 8 MB unless said otherwise.
132
+ *
133
+ * Everything an action receives arrives in one body - arguments, and the
134
+ * files a form uploads - and it is read whole before the action runs. With
135
+ * no ceiling a single request could ask this process to hold as much as a
136
+ * caller cares to send. Above the ceiling the answer is 413, before a byte
137
+ * of the body is kept. Raise it for an app that uploads larger files
138
+ * through actions; a proxy in front usually has its own limit too.
139
+ */
140
+ maxActionBody?: number;
130
141
  }
131
142
  /**
132
143
  * Whether a browser was told it could post this action.
package/dist/host.js CHANGED
@@ -180,6 +180,58 @@ export function actionOriginAllowed(request, url) {
180
180
  return false;
181
181
  }
182
182
  }
183
+ const DEFAULT_MAX_ACTION_BODY = 8 * 1024 * 1024;
184
+ /**
185
+ * The body, read whole, or null once it has passed the ceiling.
186
+ *
187
+ * Content-Length is checked first because it is free, and the stream is
188
+ * counted anyway because a body need not announce its size and a stated size
189
+ * need not be true. Reading stops at the first byte over: what was held is
190
+ * dropped, and the caller answers 413 rather than holding the rest.
191
+ */
192
+ async function readBodyUpTo(request, limit) {
193
+ const declared = Number(request.headers.get('content-length'));
194
+ if (Number.isFinite(declared) && declared > limit)
195
+ return null;
196
+ if (!request.body)
197
+ return new Uint8Array(0);
198
+ const reader = request.body.getReader();
199
+ const chunks = [];
200
+ let held = 0;
201
+ for (;;) {
202
+ const { done, value } = await reader.read();
203
+ if (done)
204
+ break;
205
+ held += value.byteLength;
206
+ if (held > limit) {
207
+ await reader.cancel().catch(() => { });
208
+ return null;
209
+ }
210
+ chunks.push(value);
211
+ }
212
+ const body = new Uint8Array(held);
213
+ let at = 0;
214
+ for (const chunk of chunks) {
215
+ body.set(chunk, at);
216
+ at += chunk.byteLength;
217
+ }
218
+ return body;
219
+ }
220
+ /**
221
+ * The page a client says it was on, as a path this router can match - or
222
+ * null. A header is a string anyone can send, and `new URL` throws on one
223
+ * that is not a url; that must not become a 500 on the action endpoint.
224
+ */
225
+ function refererPath(referer, origin) {
226
+ if (!referer)
227
+ return null;
228
+ try {
229
+ return new URL(referer, origin).pathname;
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ }
183
235
  /**
184
236
  * How large a query url may be before it is refused.
185
237
  *
@@ -194,6 +246,7 @@ export function actionOriginAllowed(request, url) {
194
246
  const MAX_QUERY = 8_000;
195
247
  export function createRscHandler(options) {
196
248
  const { engine, assets, version } = options;
249
+ const maxActionBody = options.maxActionBody ?? DEFAULT_MAX_ACTION_BODY;
197
250
  // Annotated rather than inferred: the narrowing below is lost inside the
198
251
  // closures that use it, and every one of them runs after the throw.
199
252
  const manifest = options.manifest ?? engine.manifest?.();
@@ -902,8 +955,8 @@ export function createRscHandler(options) {
902
955
  const refusal = await refuseUnlessAllowed(request, intercepted);
903
956
  if (refusal)
904
957
  return refusal;
905
- const referer = request.headers.get(HEADER.referer);
906
- const under = referer ? matchRoute(routes, new URL(referer, url.origin).pathname) : null;
958
+ const from = refererPath(request.headers.get(HEADER.referer), url.origin);
959
+ const under = from ? matchRoute(routes, from) : null;
907
960
  // Without a page to open over there is nothing to intercept: render the
908
961
  // interceptor on its own rather than answering with the wrong page.
909
962
  const component = under ? under.route.component : intercept.component;
@@ -1058,12 +1111,15 @@ export function createRscHandler(options) {
1058
1111
  return new Response('Missing X-RSC-Action', { status: 400 });
1059
1112
  // The body travels as application/octet-stream so a host that parses
1060
1113
  // multipart cannot consume it first; its real type rides in a header.
1061
- const body = new Uint8Array(await request.arrayBuffer());
1114
+ const body = await readBodyUpTo(request, maxActionBody);
1115
+ if (body === null) {
1116
+ return new Response(`Action body over ${maxActionBody} bytes`, { status: 413 });
1117
+ }
1062
1118
  const contentType = request.headers.get(HEADER.contentType) ?? 'text/plain;charset=UTF-8';
1063
1119
  // Where it was invoked from, so anything the action invalidates can be
1064
1120
  // re-rendered against the page that is actually on screen.
1065
- const referer = request.headers.get(HEADER.referer);
1066
- const match = referer ? matchRoute(routes, new URL(referer, url.origin).pathname) : null;
1121
+ const from = refererPath(request.headers.get(HEADER.referer), url.origin);
1122
+ const match = from ? matchRoute(routes, from) : null;
1067
1123
  const page = match ? pageContext(match, await propsFor(match, request)) : undefined;
1068
1124
  // Scoped to this action: revalidate() called anywhere inside it, at any
1069
1125
  // depth, marks here and nowhere else — two requests can be in flight and