@rsc-kit/core 0.29.8 → 0.29.10

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.
@@ -0,0 +1,18 @@
1
+ /** How long a payload waits for its browser. */
2
+ export declare const BOOT_HOLD_MS = 10000;
3
+ export interface HeldPayload {
4
+ payload: ReadableStream;
5
+ /** The headers the boot answer carries beside the payload: the layout chain, the depth. */
6
+ headers: Record<string, string>;
7
+ at: number;
8
+ }
9
+ /** A token no one can guess: 128 bits, url-safe. */
10
+ export declare function bootToken(): string;
11
+ /** Keep a render's payload for the boot fetch that follows its document. */
12
+ export declare function holdBootPayload(token: string, payload: ReadableStream, headers: Record<string, string>, now?: number): void;
13
+ /** The payload for a token, once. Null for a token not held, redeemed or expired. */
14
+ export declare function takeBootPayload(token: string, now?: number): HeldPayload | null;
15
+ /** How many are held, for tests. */
16
+ export declare function heldBootPayloads(): number;
17
+ /** Forget everything, for tests. */
18
+ export declare function clearBootPayloads(): void;
@@ -0,0 +1,81 @@
1
+ // One render per visit.
2
+ //
3
+ // Hydrating server components needs the serialised tree, not the HTML, and a
4
+ // document does not carry its own payload - inlined, decode and hydration
5
+ // become one long task (#231). So the client fetches it, and that fetch used
6
+ // to render the whole page a second time: the same reads, the same queries,
7
+ // a moment after the document's render had made exactly that payload and
8
+ // thrown it away.
9
+ //
10
+ // Now the document's render keeps its payload here for the few seconds it
11
+ // takes the browser to come back for it, under a token written into the
12
+ // document that only that browser holds. The boot fetch redeems the token
13
+ // and is answered with the payload the document was made from - which is
14
+ // also the one it should hydrate against: two renders a moment apart could
15
+ // disagree, and the one that made the HTML cannot.
16
+ //
17
+ // A token is redeemed once. A payload nobody comes back for - a route with
18
+ // no runtime, a browser that left, a Worker isolate other than the one that
19
+ // rendered - is cancelled when it expires, and the boot fetch that misses
20
+ // renders as it always did. Missing costs a render; it never costs a wrong
21
+ // answer.
22
+ /** How long a payload waits for its browser. */
23
+ export const BOOT_HOLD_MS = 10_000;
24
+ /** At most this many held at once; the oldest is dropped first. */
25
+ const MAX_HELD = 500;
26
+ const held = new Map();
27
+ /** A token no one can guess: 128 bits, url-safe. */
28
+ export function bootToken() {
29
+ const bytes = new Uint8Array(16);
30
+ crypto.getRandomValues(bytes);
31
+ let out = "";
32
+ for (const b of bytes)
33
+ out += b.toString(16).padStart(2, "0");
34
+ return out;
35
+ }
36
+ /** Keep a render's payload for the boot fetch that follows its document. */
37
+ export function holdBootPayload(token, payload, headers, now = Date.now()) {
38
+ sweep(now);
39
+ if (held.size >= MAX_HELD) {
40
+ const oldest = held.keys().next().value;
41
+ if (oldest !== undefined)
42
+ drop(oldest);
43
+ }
44
+ held.set(token, { payload, headers, at: now });
45
+ }
46
+ /** The payload for a token, once. Null for a token not held, redeemed or expired. */
47
+ export function takeBootPayload(token, now = Date.now()) {
48
+ const found = held.get(token);
49
+ if (!found)
50
+ return null;
51
+ held.delete(token);
52
+ if (now - found.at >= BOOT_HOLD_MS) {
53
+ void found.payload.cancel().catch(() => { });
54
+ return null;
55
+ }
56
+ return found;
57
+ }
58
+ /** Let go of what nobody came back for. */
59
+ function sweep(now) {
60
+ for (const [token, entry] of held) {
61
+ if (now - entry.at < BOOT_HOLD_MS)
62
+ break;
63
+ drop(token);
64
+ }
65
+ }
66
+ function drop(token) {
67
+ const entry = held.get(token);
68
+ held.delete(token);
69
+ if (entry)
70
+ void entry.payload.cancel().catch(() => { });
71
+ }
72
+ /** How many are held, for tests. */
73
+ export function heldBootPayloads() {
74
+ return held.size;
75
+ }
76
+ /** Forget everything, for tests. */
77
+ export function clearBootPayloads() {
78
+ for (const token of [...held.keys()])
79
+ drop(token);
80
+ }
81
+ //# sourceMappingURL=bootPayloads.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bootPayloads.js","sourceRoot":"","sources":["../src/bootPayloads.ts"],"names":[],"mappings":"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","sourcesContent":["// One render per visit.\n//\n// Hydrating server components needs the serialised tree, not the HTML, and a\n// document does not carry its own payload - inlined, decode and hydration\n// become one long task (#231). So the client fetches it, and that fetch used\n// to render the whole page a second time: the same reads, the same queries,\n// a moment after the document's render had made exactly that payload and\n// thrown it away.\n//\n// Now the document's render keeps its payload here for the few seconds it\n// takes the browser to come back for it, under a token written into the\n// document that only that browser holds. The boot fetch redeems the token\n// and is answered with the payload the document was made from - which is\n// also the one it should hydrate against: two renders a moment apart could\n// disagree, and the one that made the HTML cannot.\n//\n// A token is redeemed once. A payload nobody comes back for - a route with\n// no runtime, a browser that left, a Worker isolate other than the one that\n// rendered - is cancelled when it expires, and the boot fetch that misses\n// renders as it always did. Missing costs a render; it never costs a wrong\n// answer.\n\n/** How long a payload waits for its browser. */\nexport const BOOT_HOLD_MS = 10_000;\n\n/** At most this many held at once; the oldest is dropped first. */\nconst MAX_HELD = 500;\n\nexport interface HeldPayload {\n payload: ReadableStream;\n /** The headers the boot answer carries beside the payload: the layout chain, the depth. */\n headers: Record<string, string>;\n at: number;\n}\n\nconst held = new Map<string, HeldPayload>();\n\n/** A token no one can guess: 128 bits, url-safe. */\nexport function bootToken(): string {\n const bytes = new Uint8Array(16);\n\n crypto.getRandomValues(bytes);\n\n let out = \"\";\n\n for (const b of bytes) out += b.toString(16).padStart(2, \"0\");\n\n return out;\n}\n\n/** Keep a render's payload for the boot fetch that follows its document. */\nexport function holdBootPayload(\n token: string,\n payload: ReadableStream,\n headers: Record<string, string>,\n now = Date.now(),\n): void {\n sweep(now);\n\n if (held.size >= MAX_HELD) {\n const oldest = held.keys().next().value;\n\n if (oldest !== undefined) drop(oldest);\n }\n\n held.set(token, { payload, headers, at: now });\n}\n\n/** The payload for a token, once. Null for a token not held, redeemed or expired. */\nexport function takeBootPayload(token: string, now = Date.now()): HeldPayload | null {\n const found = held.get(token);\n\n if (!found) return null;\n\n held.delete(token);\n\n if (now - found.at >= BOOT_HOLD_MS) {\n void found.payload.cancel().catch(() => {});\n\n return null;\n }\n\n return found;\n}\n\n/** Let go of what nobody came back for. */\nfunction sweep(now: number): void {\n for (const [token, entry] of held) {\n if (now - entry.at < BOOT_HOLD_MS) break;\n\n drop(token);\n }\n}\n\nfunction drop(token: string): void {\n const entry = held.get(token);\n\n held.delete(token);\n\n if (entry) void entry.payload.cancel().catch(() => {});\n}\n\n/** How many are held, for tests. */\nexport function heldBootPayloads(): number {\n return held.size;\n}\n\n/** Forget everything, for tests. */\nexport function clearBootPayloads(): void {\n for (const token of [...held.keys()]) drop(token);\n}\n"]}
@@ -0,0 +1,2 @@
1
+ /** Whether this user agent is answered with the finished document. */
2
+ export declare function isCrawler(userAgent: string | null | undefined): boolean;
@@ -0,0 +1,62 @@
1
+ // Who is answered with the finished document.
2
+ //
3
+ // A person is sent the shell the moment it exists and everything else as it
4
+ // streams: the page paints at once, the title and the slow regions arrive
5
+ // when they are ready. The status line leaves with the shell, so a notFound()
6
+ // or a redirect() decided later than that is delivered in the body - which a
7
+ // person's browser acts on and a search engine's indexer does not. It keeps
8
+ // a 200, a soft 404.
9
+ //
10
+ // A crawler is not waiting to see anything. It is answered once the whole
11
+ // render is done: a notFound() anywhere is a real 404, a redirect anywhere a
12
+ // real 3xx, and the generated metadata is in <head> where a link preview's
13
+ // scraper reads it and nowhere else. The cost - the slowest read on the page
14
+ // before the first byte - is paid only by something that is not a person.
15
+ //
16
+ // Matched by name, from the user agents the engines and preview scrapers
17
+ // publish. A crawler missing from the list is served as a person is, which
18
+ // is correct HTML with a noindex tag where the page was missing; one that
19
+ // should be here is a line to add.
20
+ const CRAWLERS = new RegExp([
21
+ // Search engines.
22
+ 'Googlebot',
23
+ 'Google-InspectionTool',
24
+ 'Storebot-Google',
25
+ 'AdsBot-Google',
26
+ 'Mediapartners-Google',
27
+ 'bingbot',
28
+ 'BingPreview',
29
+ 'Slurp',
30
+ 'DuckDuckBot',
31
+ 'Baiduspider',
32
+ 'YandexBot',
33
+ 'YandexMobileBot',
34
+ 'Sogou',
35
+ 'Applebot',
36
+ 'PetalBot',
37
+ 'SeznamBot',
38
+ 'Yeti',
39
+ 'ia_archiver',
40
+ // Link previews: a share card is the page's metadata, read once.
41
+ 'facebookexternalhit',
42
+ 'facebookcatalog',
43
+ 'meta-externalagent',
44
+ 'Twitterbot',
45
+ 'LinkedInBot',
46
+ 'Slackbot',
47
+ 'Discordbot',
48
+ 'TelegramBot',
49
+ 'WhatsApp',
50
+ 'redditbot',
51
+ 'Pinterestbot',
52
+ 'SkypeUriPreview',
53
+ 'vkShare',
54
+ 'Embedly',
55
+ 'bitlybot',
56
+ 'Iframely',
57
+ ].join('|'), 'i');
58
+ /** Whether this user agent is answered with the finished document. */
59
+ export function isCrawler(userAgent) {
60
+ return typeof userAgent === 'string' && CRAWLERS.test(userAgent);
61
+ }
62
+ //# sourceMappingURL=crawler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crawler.js","sourceRoot":"","sources":["../src/crawler.ts"],"names":[],"mappings":"AAAA,8CAA8C;AAC9C,EAAE;AACF,4EAA4E;AAC5E,0EAA0E;AAC1E,8EAA8E;AAC9E,6EAA6E;AAC7E,4EAA4E;AAC5E,qBAAqB;AACrB,EAAE;AACF,0EAA0E;AAC1E,6EAA6E;AAC7E,2EAA2E;AAC3E,6EAA6E;AAC7E,0EAA0E;AAC1E,EAAE;AACF,yEAAyE;AACzE,2EAA2E;AAC3E,0EAA0E;AAC1E,mCAAmC;AAEnC,MAAM,QAAQ,GAAG,IAAI,MAAM,CACzB;IACE,kBAAkB;IAClB,WAAW;IACX,uBAAuB;IACvB,iBAAiB;IACjB,eAAe;IACf,sBAAsB;IACtB,SAAS;IACT,aAAa;IACb,OAAO;IACP,aAAa;IACb,aAAa;IACb,WAAW;IACX,iBAAiB;IACjB,OAAO;IACP,UAAU;IACV,UAAU;IACV,WAAW;IACX,MAAM;IACN,aAAa;IACb,iEAAiE;IACjE,qBAAqB;IACrB,iBAAiB;IACjB,oBAAoB;IACpB,YAAY;IACZ,aAAa;IACb,UAAU;IACV,YAAY;IACZ,aAAa;IACb,UAAU;IACV,WAAW;IACX,cAAc;IACd,iBAAiB;IACjB,SAAS;IACT,SAAS;IACT,UAAU;IACV,UAAU;CACX,CAAC,IAAI,CAAC,GAAG,CAAC,EACX,GAAG,CACJ,CAAA;AAED,sEAAsE;AACtE,MAAM,UAAU,SAAS,CAAC,SAAoC;IAC5D,OAAO,OAAO,SAAS,KAAK,QAAQ,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;AAClE,CAAC","sourcesContent":["// Who is answered with the finished document.\n//\n// A person is sent the shell the moment it exists and everything else as it\n// streams: the page paints at once, the title and the slow regions arrive\n// when they are ready. The status line leaves with the shell, so a notFound()\n// or a redirect() decided later than that is delivered in the body - which a\n// person's browser acts on and a search engine's indexer does not. It keeps\n// a 200, a soft 404.\n//\n// A crawler is not waiting to see anything. It is answered once the whole\n// render is done: a notFound() anywhere is a real 404, a redirect anywhere a\n// real 3xx, and the generated metadata is in <head> where a link preview's\n// scraper reads it and nowhere else. The cost - the slowest read on the page\n// before the first byte - is paid only by something that is not a person.\n//\n// Matched by name, from the user agents the engines and preview scrapers\n// publish. A crawler missing from the list is served as a person is, which\n// is correct HTML with a noindex tag where the page was missing; one that\n// should be here is a line to add.\n\nconst CRAWLERS = new RegExp(\n [\n // Search engines.\n 'Googlebot',\n 'Google-InspectionTool',\n 'Storebot-Google',\n 'AdsBot-Google',\n 'Mediapartners-Google',\n 'bingbot',\n 'BingPreview',\n 'Slurp',\n 'DuckDuckBot',\n 'Baiduspider',\n 'YandexBot',\n 'YandexMobileBot',\n 'Sogou',\n 'Applebot',\n 'PetalBot',\n 'SeznamBot',\n 'Yeti',\n 'ia_archiver',\n // Link previews: a share card is the page's metadata, read once.\n 'facebookexternalhit',\n 'facebookcatalog',\n 'meta-externalagent',\n 'Twitterbot',\n 'LinkedInBot',\n 'Slackbot',\n 'Discordbot',\n 'TelegramBot',\n 'WhatsApp',\n 'redditbot',\n 'Pinterestbot',\n 'SkypeUriPreview',\n 'vkShare',\n 'Embedly',\n 'bitlybot',\n 'Iframely',\n ].join('|'),\n 'i',\n)\n\n/** Whether this user agent is answered with the finished document. */\nexport function isCrawler(userAgent: string | null | undefined): boolean {\n return typeof userAgent === 'string' && CRAWLERS.test(userAgent)\n}\n"]}
package/dist/headers.d.ts CHANGED
@@ -10,6 +10,11 @@ export declare const HEADER: {
10
10
  readonly segmentDepth: 'X-RSC-Segment-Depth';
11
11
  /** The chain to send back next time. */
12
12
  readonly layouts: 'X-RSC-Layouts';
13
+ /**
14
+ * On the boot fetch: the token the document carried, redeeming the payload
15
+ * its own render kept. See bootPayloads.ts.
16
+ */
17
+ readonly boot: 'X-RSC-Boot';
13
18
  /** Identifies the build, so a client can notice it is talking to an old one. */
14
19
  readonly version: 'X-RSC-Version';
15
20
  /** The slot an intercepted navigation is targeting. */
package/dist/headers.js CHANGED
@@ -19,6 +19,11 @@ export const HEADER = {
19
19
  segmentDepth: 'X-RSC-Segment-Depth',
20
20
  /** The chain to send back next time. */
21
21
  layouts: 'X-RSC-Layouts',
22
+ /**
23
+ * On the boot fetch: the token the document carried, redeeming the payload
24
+ * its own render kept. See bootPayloads.ts.
25
+ */
26
+ boot: 'X-RSC-Boot',
22
27
  /** Identifies the build, so a client can notice it is talking to an old one. */
23
28
  version: 'X-RSC-Version',
24
29
  /** The slot an intercepted navigation is targeting. */
@@ -1 +1 @@
1
- {"version":3,"file":"headers.js","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA,2DAA2D;AAC3D,EAAE;AACF,0EAA0E;AAC1E,sEAAsE;AACtE,wEAAwE;AACxE,2EAA2E;AAC3E,gCAAgC;AAChC,EAAE;AACF,gFAAgF;AAEhF,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB;;;OAGG;IACH,GAAG,EAAE,OAAO;IAEZ,iFAAiF;IACjF,QAAQ,EAAE,gBAAgB;IAE1B,kEAAkE;IAClE,YAAY,EAAE,qBAAqB;IAEnC,wCAAwC;IACxC,OAAO,EAAE,eAAe;IAExB,gFAAgF;IAChF,OAAO,EAAE,eAAe;IAExB,uDAAuD;IACvD,SAAS,EAAE,iBAAiB;IAE5B,gDAAgD;IAChD,OAAO,EAAE,eAAe;IAExB,uEAAuE;IACvE,UAAU,EAAE,kBAAkB;IAE9B,0CAA0C;IAC1C,MAAM,EAAE,cAAc;IAEtB;;;;;;;OAOG;IACH,QAAQ,EAAE,gBAAgB;IAE1B;;;;;;OAMG;IACH,WAAW,EAAE,oBAAoB;IAEjC;;;;;OAKG;IACH,UAAU,EAAE,cAAc;IAC1B;;;;;;;;;OASG;IACH;;;;;;;OAOG;IACH,KAAK,EAAE,aAAa;IAEpB,SAAS,EAAE,aAAa;IACxB,mEAAmE;IACnE,YAAY,EAAE,iBAAiB;IAC/B,uDAAuD;IACvD,aAAa,EAAE,kBAAkB;IACjC;;;;OAIG;IACH,SAAS,EAAE,eAAe;IAC1B,+EAA+E;IAC/E,WAAW,EAAE,eAAe;IAC5B,8FAA8F;IAC9F,UAAU,EAAE,cAAc;CAClB,CAAA;AAEV,sBAAsB;AACtB,MAAM,CAAC,MAAM,WAAW,GAAG,iCAAiC,CAAA;AAE5D,2BAA2B;AAC3B,MAAM,CAAC,MAAM,SAAS,GAAG,0BAA0B,CAAA;AAEnD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,MAAM,CAAC,GAAG;IACV,MAAM,CAAC,QAAQ;IACf,MAAM,CAAC,UAAU;IACjB,MAAM,CAAC,SAAS;IAChB,MAAM,CAAC,OAAO;CACf,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AAEZ;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,mBAAmB,CAAA;AAE7C;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,oCAAoC,CAAA;AAE9D;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,qCAAqC,CAAA","sourcesContent":["// The wire protocol, as names rather than string literals.\n//\n// A host writing these out by hand is how they drift. Two of them are not\n// negotiable at all — the client sends them or asks for them at fixed\n// addresses, and a host that spells one differently fails silently: the\n// request falls through, the decoder never runs, and a button does nothing\n// with no error on either side.\n//\n// PROTOCOL.md describes what each one means. This is the same list, importable.\n\nexport const HEADER = {\n /**\n * Present on a request for a Flight payload rather than the document. One\n * url serves both, which is why every answer must Vary on it.\n */\n rsc: 'X-RSC',\n\n /** The layout chain the client has mounted, outermost first, comma-separated. */\n segments: 'X-RSC-Segments',\n\n /** The boundary the answer replaces. 0 means a whole document. */\n segmentDepth: 'X-RSC-Segment-Depth',\n\n /** The chain to send back next time. */\n layouts: 'X-RSC-Layouts',\n\n /** Identifies the build, so a client can notice it is talking to an old one. */\n version: 'X-RSC-Version',\n\n /** The slot an intercepted navigation is targeting. */\n intercept: 'X-RSC-Intercept',\n\n /** Where the navigation or action came from. */\n referer: 'X-RSC-Referer',\n\n /** One named region of the current page to render and return alone. */\n revalidate: 'X-RSC-Revalidate',\n\n /** The server reference being invoked. */\n action: 'X-RSC-Action',\n\n /**\n * Go here instead.\n *\n * Used for a redirect the client has to perform itself: a failed action, and\n * a navigation whose render redirected. Never a 3xx for those — `fetch`\n * follows one transparently, so the client would get the destination's HTML\n * where it expected a Flight payload and decode it as one.\n */\n redirect: 'X-RSC-Redirect',\n\n /**\n * The body's real type.\n *\n * The body itself goes out as application/octet-stream so a host that parses\n * multipart cannot consume it before the action does — reading Content-Type\n * instead leaves an upload undecodable.\n */\n contentType: 'X-RSC-Content-Type',\n\n /**\n * Where the browser posts server actions.\n *\n * Not a convention this can choose: the client runtime posts here\n * unconditionally.\n */\n actionPath: '/_rsc/action',\n /**\n * Where the browser reads queries.\n *\n * A GET, and that is the whole point of it being a separate address: a POST\n * is cacheable by nothing, so a read routed through the action endpoint can\n * never be kept by an HTTP cache, a CDN or a service worker. Only functions\n * declared with `query()` are reachable here — the endpoint checks the mark\n * before invoking anything, because otherwise this address would run any\n * registered action over GET.\n */\n /**\n * Sent on every read, and required by the query endpoint.\n *\n * A GET with no unusual header is a simple request, so any page could\n * trigger one with an <img> and the visitor's cookies would ride along.\n * This header is not CORS-safelisted, so the browser preflights it — the\n * same thing that makes a POST carrying X-RSC-Action safe.\n */\n query: 'X-RSC-Query',\n\n queryPath: '/_rsc/query',\n /** Build-time shell for a PPR route, for an edge cache to hold. */\n pprShellPath: '/_rsc/ppr-shell',\n /** Finish a shell an edge cache is already serving. */\n pprResumePath: '/_rsc/ppr-resume',\n /**\n * On a refresh a change triggered: which names moved, to which versions\n * (`stock@5,orders@2`). A shared section renders once per such change, and\n * every tab asking with the same one gets that render.\n */\n changedBy: 'X-RSC-Changed',\n /** The stream a tab hears on when a name its page refreshes on has changed. */\n changesPath: '/_rsc/changes',\n /** Whether this renderer can serve: up, and its backend answering. GET only, never cached. */\n healthPath: '/_rsc/health',\n} as const\n\n/** Flight payload. */\nexport const FLIGHT_TYPE = 'text/x-component; charset=utf-8'\n\n/** A rendered document. */\nexport const HTML_TYPE = 'text/html; charset=utf-8'\n\n/**\n * Every request header that changes what a response body contains.\n *\n * One url answers with a whole document, a partial payload, a named region or\n * an interceptor depending on these — so a cache keyed on the url alone, or on\n * `X-RSC` alone, will hand one client another's answer. They are materially\n * different bodies, not variations in presentation.\n */\nexport const VARY_ON_RSC = [\n HEADER.rsc,\n HEADER.segments,\n HEADER.revalidate,\n HEADER.intercept,\n HEADER.referer,\n].join(', ')\n\n/**\n * What a response says about being stored, when it must not be.\n *\n * `Vary` alone is not enough on the platform this targets. Cloudflare ignores\n * `Vary` for everything except `Accept-Encoding`, and this package ships a\n * Workers story as a headline feature — so one url answering with a document,\n * a Flight payload, a segment and an interceptor, keyed only on the url, hands\n * one visitor another's body. Anything narrowed by a request header, and\n * anything behind a guard, says outright that it is not to be stored.\n */\nexport const PER_CLIENT = 'private, no-store'\n\n/**\n * And what a frozen, unguarded page says.\n *\n * Explicit rather than absent: with no directive at all, whether an\n * intermediary stores it comes down to that intermediary's heuristics, which\n * is not a decision this package should be leaving to chance in either\n * direction.\n */\nexport const REVALIDATE = 'public, max-age=0, must-revalidate'\n\n/**\n * And what a page rendered for one request says.\n *\n * It may have read the visitor's cookies, and nothing in the headers - which\n * leave before the render has finished - can know whether it did. So a shared\n * cache may not keep it; the visitor's own browser may, and must ask again.\n */\nexport const PER_REQUEST = 'private, max-age=0, must-revalidate'\n"]}
1
+ {"version":3,"file":"headers.js","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA,2DAA2D;AAC3D,EAAE;AACF,0EAA0E;AAC1E,sEAAsE;AACtE,wEAAwE;AACxE,2EAA2E;AAC3E,gCAAgC;AAChC,EAAE;AACF,gFAAgF;AAEhF,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB;;;OAGG;IACH,GAAG,EAAE,OAAO;IAEZ,iFAAiF;IACjF,QAAQ,EAAE,gBAAgB;IAE1B,kEAAkE;IAClE,YAAY,EAAE,qBAAqB;IAEnC,wCAAwC;IACxC,OAAO,EAAE,eAAe;IAExB;;;OAGG;IACH,IAAI,EAAE,YAAY;IAElB,gFAAgF;IAChF,OAAO,EAAE,eAAe;IAExB,uDAAuD;IACvD,SAAS,EAAE,iBAAiB;IAE5B,gDAAgD;IAChD,OAAO,EAAE,eAAe;IAExB,uEAAuE;IACvE,UAAU,EAAE,kBAAkB;IAE9B,0CAA0C;IAC1C,MAAM,EAAE,cAAc;IAEtB;;;;;;;OAOG;IACH,QAAQ,EAAE,gBAAgB;IAE1B;;;;;;OAMG;IACH,WAAW,EAAE,oBAAoB;IAEjC;;;;;OAKG;IACH,UAAU,EAAE,cAAc;IAC1B;;;;;;;;;OASG;IACH;;;;;;;OAOG;IACH,KAAK,EAAE,aAAa;IAEpB,SAAS,EAAE,aAAa;IACxB,mEAAmE;IACnE,YAAY,EAAE,iBAAiB;IAC/B,uDAAuD;IACvD,aAAa,EAAE,kBAAkB;IACjC;;;;OAIG;IACH,SAAS,EAAE,eAAe;IAC1B,+EAA+E;IAC/E,WAAW,EAAE,eAAe;IAC5B,8FAA8F;IAC9F,UAAU,EAAE,cAAc;CAClB,CAAA;AAEV,sBAAsB;AACtB,MAAM,CAAC,MAAM,WAAW,GAAG,iCAAiC,CAAA;AAE5D,2BAA2B;AAC3B,MAAM,CAAC,MAAM,SAAS,GAAG,0BAA0B,CAAA;AAEnD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,MAAM,CAAC,GAAG;IACV,MAAM,CAAC,QAAQ;IACf,MAAM,CAAC,UAAU;IACjB,MAAM,CAAC,SAAS;IAChB,MAAM,CAAC,OAAO;CACf,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AAEZ;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,mBAAmB,CAAA;AAE7C;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,oCAAoC,CAAA;AAE9D;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,qCAAqC,CAAA","sourcesContent":["// The wire protocol, as names rather than string literals.\n//\n// A host writing these out by hand is how they drift. Two of them are not\n// negotiable at all — the client sends them or asks for them at fixed\n// addresses, and a host that spells one differently fails silently: the\n// request falls through, the decoder never runs, and a button does nothing\n// with no error on either side.\n//\n// PROTOCOL.md describes what each one means. This is the same list, importable.\n\nexport const HEADER = {\n /**\n * Present on a request for a Flight payload rather than the document. One\n * url serves both, which is why every answer must Vary on it.\n */\n rsc: 'X-RSC',\n\n /** The layout chain the client has mounted, outermost first, comma-separated. */\n segments: 'X-RSC-Segments',\n\n /** The boundary the answer replaces. 0 means a whole document. */\n segmentDepth: 'X-RSC-Segment-Depth',\n\n /** The chain to send back next time. */\n layouts: 'X-RSC-Layouts',\n\n /**\n * On the boot fetch: the token the document carried, redeeming the payload\n * its own render kept. See bootPayloads.ts.\n */\n boot: 'X-RSC-Boot',\n\n /** Identifies the build, so a client can notice it is talking to an old one. */\n version: 'X-RSC-Version',\n\n /** The slot an intercepted navigation is targeting. */\n intercept: 'X-RSC-Intercept',\n\n /** Where the navigation or action came from. */\n referer: 'X-RSC-Referer',\n\n /** One named region of the current page to render and return alone. */\n revalidate: 'X-RSC-Revalidate',\n\n /** The server reference being invoked. */\n action: 'X-RSC-Action',\n\n /**\n * Go here instead.\n *\n * Used for a redirect the client has to perform itself: a failed action, and\n * a navigation whose render redirected. Never a 3xx for those — `fetch`\n * follows one transparently, so the client would get the destination's HTML\n * where it expected a Flight payload and decode it as one.\n */\n redirect: 'X-RSC-Redirect',\n\n /**\n * The body's real type.\n *\n * The body itself goes out as application/octet-stream so a host that parses\n * multipart cannot consume it before the action does — reading Content-Type\n * instead leaves an upload undecodable.\n */\n contentType: 'X-RSC-Content-Type',\n\n /**\n * Where the browser posts server actions.\n *\n * Not a convention this can choose: the client runtime posts here\n * unconditionally.\n */\n actionPath: '/_rsc/action',\n /**\n * Where the browser reads queries.\n *\n * A GET, and that is the whole point of it being a separate address: a POST\n * is cacheable by nothing, so a read routed through the action endpoint can\n * never be kept by an HTTP cache, a CDN or a service worker. Only functions\n * declared with `query()` are reachable here — the endpoint checks the mark\n * before invoking anything, because otherwise this address would run any\n * registered action over GET.\n */\n /**\n * Sent on every read, and required by the query endpoint.\n *\n * A GET with no unusual header is a simple request, so any page could\n * trigger one with an <img> and the visitor's cookies would ride along.\n * This header is not CORS-safelisted, so the browser preflights it — the\n * same thing that makes a POST carrying X-RSC-Action safe.\n */\n query: 'X-RSC-Query',\n\n queryPath: '/_rsc/query',\n /** Build-time shell for a PPR route, for an edge cache to hold. */\n pprShellPath: '/_rsc/ppr-shell',\n /** Finish a shell an edge cache is already serving. */\n pprResumePath: '/_rsc/ppr-resume',\n /**\n * On a refresh a change triggered: which names moved, to which versions\n * (`stock@5,orders@2`). A shared section renders once per such change, and\n * every tab asking with the same one gets that render.\n */\n changedBy: 'X-RSC-Changed',\n /** The stream a tab hears on when a name its page refreshes on has changed. */\n changesPath: '/_rsc/changes',\n /** Whether this renderer can serve: up, and its backend answering. GET only, never cached. */\n healthPath: '/_rsc/health',\n} as const\n\n/** Flight payload. */\nexport const FLIGHT_TYPE = 'text/x-component; charset=utf-8'\n\n/** A rendered document. */\nexport const HTML_TYPE = 'text/html; charset=utf-8'\n\n/**\n * Every request header that changes what a response body contains.\n *\n * One url answers with a whole document, a partial payload, a named region or\n * an interceptor depending on these — so a cache keyed on the url alone, or on\n * `X-RSC` alone, will hand one client another's answer. They are materially\n * different bodies, not variations in presentation.\n */\nexport const VARY_ON_RSC = [\n HEADER.rsc,\n HEADER.segments,\n HEADER.revalidate,\n HEADER.intercept,\n HEADER.referer,\n].join(', ')\n\n/**\n * What a response says about being stored, when it must not be.\n *\n * `Vary` alone is not enough on the platform this targets. Cloudflare ignores\n * `Vary` for everything except `Accept-Encoding`, and this package ships a\n * Workers story as a headline feature — so one url answering with a document,\n * a Flight payload, a segment and an interceptor, keyed only on the url, hands\n * one visitor another's body. Anything narrowed by a request header, and\n * anything behind a guard, says outright that it is not to be stored.\n */\nexport const PER_CLIENT = 'private, no-store'\n\n/**\n * And what a frozen, unguarded page says.\n *\n * Explicit rather than absent: with no directive at all, whether an\n * intermediary stores it comes down to that intermediary's heuristics, which\n * is not a decision this package should be leaving to chance in either\n * direction.\n */\nexport const REVALIDATE = 'public, max-age=0, must-revalidate'\n\n/**\n * And what a page rendered for one request says.\n *\n * It may have read the visitor's cookies, and nothing in the headers - which\n * leave before the render has finished - can know whether it did. So a shared\n * cache may not keep it; the visitor's own browser may, and must ask again.\n */\nexport const PER_REQUEST = 'private, max-age=0, must-revalidate'\n"]}
package/dist/host.d.ts CHANGED
@@ -36,6 +36,14 @@ export interface RscEngine {
36
36
  props: Record<string, unknown>;
37
37
  }[], loadings?: string[], parallelSlots?: Record<string, string>, slotOverrides?: Record<string, unknown>, nonce?: string, pageKey?: string, bootstrap?: boolean): Promise<{
38
38
  htmlStream: ReadableStream;
39
+ /**
40
+ * The same render as a Flight payload, and the token the document
41
+ * carries for it. The host holds the one under the other for the boot
42
+ * fetch - see bootPayloads.ts. An engine built before this answers
43
+ * without them, and every boot renders, as it used to.
44
+ */
45
+ rscPayload?: ReadableStream;
46
+ bootToken?: string;
39
47
  }>;
40
48
  /**
41
49
  * A form posted to the page's own url before the page had a runtime to
@@ -77,21 +85,14 @@ export interface RscEngine {
77
85
  * filled here - see `unresumable` below.
78
86
  */
79
87
  replayed?: () => boolean;
88
+ /** The render as a payload, for the boot fetch; see handleRscHtmlStream. */
89
+ rscPayload?: ReadableStream;
80
90
  }>;
81
91
  handleRscRevalidate?(target: string, page: unknown,
82
92
  /** Set on a refresh a change triggered: a shared section renders once per key. */
83
93
  share?: string): Promise<{
84
94
  rscPayload: string;
85
95
  }>;
86
- /**
87
- * The page's metadata for these params, merged with its layouts'. Used to
88
- * put the real title into a shell stored for a whole pattern, whose build
89
- * could not know the url.
90
- */
91
- resolveMetadata?(component: string, props?: Record<string, unknown>, layouts?: {
92
- component: string;
93
- props: Record<string, unknown>;
94
- }[]): Promise<Record<string, unknown> | null>;
95
96
  /**
96
97
  * Run a route's middleware without rendering anything.
97
98
  *
package/dist/host.js CHANGED
@@ -47,12 +47,13 @@ export function pageSaidNotFound(request) {
47
47
  return notFoundRequests.has(request);
48
48
  }
49
49
  export { revalidate } from "./revalidate.js";
50
+ import { isCrawler } from "./crawler.js";
51
+ import { bootToken, holdBootPayload, takeBootPayload } from "./bootPayloads.js";
50
52
  import { currentNotFound, withRedirect } from "./redirect.js";
51
53
  import { compressed } from "./compress.js";
52
54
  import { withCache } from "./cache.js";
53
55
  import { takeAfterWork, withRequest, withResponseDraft } from "./request.js";
54
56
  import { criticalAssetsOf, linkHeader, mergeAssets } from "./earlyHints.js";
55
- import { withHead } from "./shellHead.js";
56
57
  /**
57
58
  * @internal For a host adapter that embeds the engine. An app imports this
58
59
  * from `@rsc-kit/core/redirect` - the one the guides teach, and the one an
@@ -123,6 +124,16 @@ function redirectResponse(to, isPayloadRequest) {
123
124
  headers: { Location: to.location, Vary: VARY_ON_RSC },
124
125
  });
125
126
  }
127
+ /**
128
+ * What a page that said notFound() after its status line had gone out ends
129
+ * with: 200 is spent, so the document asks not to be indexed instead - a
130
+ * not-found page that a search engine keeps as a real one is a soft 404.
131
+ * The tag, then a script that moves it into <head>, where a crawler that
132
+ * runs the page reads it; a crawler that does not run it is answered with
133
+ * the whole document and a real 404 - see `forCrawler`.
134
+ */
135
+ const LATE_NOINDEX = '<meta name="robots" content="noindex">' +
136
+ "<script>document.head.appendChild(document.currentScript.previousSibling)</script>";
126
137
  /**
127
138
  * Append the redirect a render asked for after its shell had already gone out.
128
139
  *
@@ -134,7 +145,7 @@ function redirectResponse(to, isPayloadRequest) {
134
145
  * Read on flush, because that is when the render has finished and a redirect
135
146
  * from inside a Suspense boundary is finally known.
136
147
  */
137
- function appendLateRedirect(stream, taken) {
148
+ function appendLateRedirect(stream, taken, missing = () => false) {
138
149
  // An engine that answered with a finished body rather than a stream has no
139
150
  // late window at all: the render was over before this was called, so the
140
151
  // caller's own check already saw everything there was to see.
@@ -158,6 +169,9 @@ function appendLateRedirect(stream, taken) {
158
169
  if (to) {
159
170
  controller.enqueue(encoder.encode(`<script>location.replace(${inScript(to.location)})</script>`));
160
171
  }
172
+ else if (missing()) {
173
+ controller.enqueue(encoder.encode(LATE_NOINDEX));
174
+ }
161
175
  controller.close();
162
176
  },
163
177
  cancel(reason) {
@@ -474,6 +488,16 @@ export function createRscHandler(options) {
474
488
  const identify = Boolean(manifest.build?.identify);
475
489
  /** How a response was answered, for X-RSC-Kit: a file the build wrote, or a shell of one. */
476
490
  const servedFrom = new WeakMap();
491
+ /** Whether a stored shell carries a script - a runtime that will boot. Read once per shell. */
492
+ const bootsByKey = new Map();
493
+ function shellBoots(key, shell) {
494
+ let boots = bootsByKey.get(key);
495
+ if (boots === undefined) {
496
+ boots = shell.includes("<script");
497
+ bootsByKey.set(key, boots);
498
+ }
499
+ return boots;
500
+ }
477
501
  /** What a stored document's head names, read once per file, for the Link header. */
478
502
  const hinted = new WeakMap();
479
503
  /**
@@ -846,20 +870,31 @@ export function createRscHandler(options) {
846
870
  if (request.headers.get(HEADER.rsc) === null) {
847
871
  // Scoped to this render, so two requests redirecting at once cannot read
848
872
  // each other's destination.
849
- return await withRedirect(async (taken) => {
873
+ return await withRedirect(async (taken, missing) => {
850
874
  // Awaited, and that await is the whole design: React resolves this
851
875
  // when the SHELL is ready, so a redirect thrown above every Suspense
852
876
  // boundary rejects here — before a byte is written, with a status line
853
877
  // still available. Nothing is buffered to make that true.
854
878
  let htmlStream;
879
+ // The render's payload and the token the document carries for it.
880
+ // Held for the boot fetch when the document goes out as a 200;
881
+ // let go on every other exit, or the tee buffers it for nothing.
882
+ let rscPayload;
883
+ let token;
884
+ const letGo = () => void rscPayload?.cancel().catch(() => { });
855
885
  try {
856
- ({ htmlStream } = formPost
886
+ const rendered = formPost
857
887
  ? await engine.handleRscFormPost(match.route.component, props, layouts, match.route.loadings, match.route.slots, {}, undefined, url.pathname, true, formPost)
858
888
  : await engine.handleRscHtmlStream(match.route.component, props, layouts, match.route.loadings, match.route.slots, {}, undefined, url.pathname,
859
889
  // A route that ships no runtime gets no bootstrap and no segment
860
890
  // boundary — the boundary is itself a client component, so leaving
861
891
  // it in means no page could ever be JS-free.
862
- true));
892
+ true);
893
+ htmlStream = rendered.htmlStream;
894
+ // Not after a post: the answer to one is the result of something
895
+ // that happened once, and a payload of it is not a page to keep.
896
+ if (!formPost)
897
+ ({ rscPayload, bootToken: token } = rendered);
863
898
  }
864
899
  catch (error) {
865
900
  // A rejected shell is how a redirect above every boundary arrives:
@@ -893,15 +928,28 @@ export function createRscHandler(options) {
893
928
  throw error;
894
929
  }
895
930
  const early = taken();
896
- if (early)
931
+ if (early) {
932
+ letGo();
897
933
  return redirectResponse(early, false);
934
+ }
898
935
  // Above every boundary, so the shell resolving means the page did not
899
936
  // refuse itself. Deeper than that and the shell is already on the wire
900
937
  // — the digest carries it to the boundary instead, and the status
901
938
  // stays 200 because the status line has gone.
902
- if (currentNotFound())
939
+ if (currentNotFound()) {
940
+ letGo();
903
941
  return saidNotFound(request);
904
- return new Response(appendLateRedirect(htmlStream, taken), {
942
+ }
943
+ if (rscPayload && token) {
944
+ holdBootPayload(token, rscPayload, {
945
+ [HEADER.segmentDepth]: "0",
946
+ [HEADER.layouts]: chain.join(","),
947
+ });
948
+ }
949
+ else {
950
+ letGo();
951
+ }
952
+ return new Response(appendLateRedirect(htmlStream, taken, missing), {
905
953
  headers: withVersion({
906
954
  "Content-Type": HTML_TYPE,
907
955
  [HEADER.layouts]: chain.join(","),
@@ -919,6 +967,27 @@ export function createRscHandler(options) {
919
967
  });
920
968
  });
921
969
  }
970
+ // The boot fetch, redeeming the payload its own document's render kept:
971
+ // answered from it, nothing rendered, the guard already passed by the
972
+ // render that made it a moment ago for this same browser. A token not
973
+ // held - expired, another isolate, an older document - falls through to
974
+ // the render every boot used to be. See bootPayloads.ts.
975
+ const redeeming = request.headers.get(HEADER.boot);
976
+ if (redeeming !== null && request.headers.get(HEADER.segments) === null) {
977
+ const kept = takeBootPayload(redeeming);
978
+ if (kept) {
979
+ const answer = new Response(kept.payload, {
980
+ headers: withVersion({
981
+ "Content-Type": FLIGHT_TYPE,
982
+ ...kept.headers,
983
+ Vary: VARY_ON_RSC,
984
+ "Cache-Control": PER_CLIENT,
985
+ }),
986
+ });
987
+ servedFrom.set(answer, "held");
988
+ return answer;
989
+ }
990
+ }
922
991
  const from = sharedDepth(request.headers.get(HEADER.segments), chain);
923
992
  // A boot - X-RSC and no segments - of a document served from a pattern
924
993
  // shell: the shell was rendered for no url, and the client hydrates
@@ -1276,6 +1345,11 @@ export function createRscHandler(options) {
1276
1345
  "whole instead. Rebuild the prerendered output with the server bundle - a cached .output is the usual cause.");
1277
1346
  return null;
1278
1347
  }
1348
+ // A crawler is answered with the finished page, rendered now: a shell
1349
+ // has gone out with its 200 before its holes know whether the page
1350
+ // exists. See crawler.ts.
1351
+ if (isCrawler(request.headers.get("user-agent")))
1352
+ return null;
1279
1353
  // A shell with no state cannot be finished by anyone. It was frozen with
1280
1354
  // its fallbacks showing and there is no record of what came next, so
1281
1355
  // serving it would be serving a page that stays on its loading state for
@@ -1294,33 +1368,57 @@ export function createRscHandler(options) {
1294
1368
  const forShape = shellKey === key
1295
1369
  ? route.params
1296
1370
  : Object.fromEntries(Object.keys(route.params).map((name) => [name, PARAM_PLACEHOLDER]));
1297
- const { htmlStream, replayed } = await engine.handleRscResume(route.route.component, forShape,
1298
- // Empty props, because that is what the build passed. Resuming replays
1299
- // the tree against the slots the shell left, and React matches those by
1300
- // key — so an argument that differs from the frozen render at all is a
1301
- // tree that "doesn't match", and every boundary falls back to the
1302
- // client instead of being filled here.
1303
- route.route.layouts.map((component) => ({ component, props: {} })), route.route.loadings, route.route.slots, {}, JSON.parse(state), undefined,
1304
- // A shell found under the route's pattern was frozen for no particular
1305
- // url, so it was rendered with no page key. Handing one over now would
1306
- // key the tree differently from the one being resumed.
1307
- shellKey === key ? url.pathname : "",
1308
- // But the url itself, for the hooks: the holes are rendered for it.
1309
- url.pathname, route.params);
1310
- // A shell stored for the pattern was built without a url, so its title
1311
- // is the layouts' - the page's generateMetadata reads the params and
1312
- // was left out. This request has the params.
1313
- const served = shellKey === key || !engine.resolveMetadata
1314
- ? shell
1315
- : withHead(shell, await engine
1316
- .resolveMetadata(route.route.component, route.params, route.route.layouts.map((component) => ({ component, props: {} })))
1317
- .catch(() => null));
1371
+ // In a render scope, so a notFound() in a hole is recorded for the
1372
+ // stream's end - see LATE_NOINDEX.
1373
+ let missing = () => false;
1374
+ const resume = engine.handleRscResume;
1375
+ const { htmlStream, replayed, rscPayload } = await withRedirect((_, said) => {
1376
+ missing = said;
1377
+ return resume(route.route.component, forShape,
1378
+ // Empty props, because that is what the build passed. Resuming replays
1379
+ // the tree against the slots the shell left, and React matches those by
1380
+ // key — so an argument that differs from the frozen render at all is a
1381
+ // tree that "doesn't match", and every boundary falls back to the
1382
+ // client instead of being filled here.
1383
+ route.route.layouts.map((component) => ({ component, props: {} })), route.route.loadings, route.route.slots, {}, JSON.parse(state), undefined,
1384
+ // A shell found under the route's pattern was frozen for no particular
1385
+ // url, so it was rendered with no page key. Handing one over now would
1386
+ // key the tree differently from the one being resumed.
1387
+ shellKey === key ? url.pathname : "",
1388
+ // But the url itself, for the hooks: the holes are rendered for it.
1389
+ url.pathname, route.params);
1390
+ });
1391
+ // A shell stored for the pattern has no title of the page's: its
1392
+ // generateMetadata streamed under its own boundary, the probe left that
1393
+ // a hole, and the resume fills it with the real one. Nothing is looked
1394
+ // up before the shell leaves.
1395
+ //
1396
+ // The render's payload, kept for the boot fetch under a token written
1397
+ // right after the shell - the shell is a build artifact, so the token
1398
+ // cannot be in it. Only a shell that carries a script boots a runtime
1399
+ // that will come back for one. A pattern shell's resume renders with
1400
+ // no page key, so React can line its slots up; the client files the
1401
+ // page under the url it is at when the payload carries none - see
1402
+ // SegmentBoundary. See bootPayloads.ts.
1403
+ const token = rscPayload && shellBoots(shellKey, shell) ? bootToken() : null;
1404
+ if (token) {
1405
+ holdBootPayload(token, rscPayload, {
1406
+ [HEADER.segmentDepth]: "0",
1407
+ [HEADER.layouts]: layoutChain(route.route.layouts, route.params).join(","),
1408
+ });
1409
+ }
1410
+ else {
1411
+ void rscPayload?.cancel().catch(() => { });
1412
+ }
1318
1413
  // The shell first, then whatever the resume writes. React's own script
1319
1414
  // travels with the resumed segments and moves them into place, so this is
1320
1415
  // a plain concatenation and the holes land without hydration.
1321
1416
  const body = new ReadableStream({
1322
1417
  async start(controller) {
1323
- controller.enqueue(new TextEncoder().encode(served));
1418
+ controller.enqueue(new TextEncoder().encode(shell));
1419
+ if (token) {
1420
+ controller.enqueue(new TextEncoder().encode("<script>self.__rsc_boot=" + JSON.stringify(token) + "</script>"));
1421
+ }
1324
1422
  const reader = htmlStream.getReader();
1325
1423
  try {
1326
1424
  while (true) {
@@ -1329,6 +1427,8 @@ export function createRscHandler(options) {
1329
1427
  break;
1330
1428
  controller.enqueue(value);
1331
1429
  }
1430
+ if (missing())
1431
+ controller.enqueue(new TextEncoder().encode(LATE_NOINDEX));
1332
1432
  }
1333
1433
  finally {
1334
1434
  controller.close();