@rsc-kit/core 0.29.7 → 0.29.9

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,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/host.d.ts CHANGED
@@ -83,15 +83,6 @@ export interface RscEngine {
83
83
  share?: string): Promise<{
84
84
  rscPayload: string;
85
85
  }>;
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
86
  /**
96
87
  * Run a route's middleware without rendering anything.
97
88
  *
package/dist/host.js CHANGED
@@ -47,12 +47,12 @@ 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";
50
51
  import { currentNotFound, withRedirect } from "./redirect.js";
51
52
  import { compressed } from "./compress.js";
52
53
  import { withCache } from "./cache.js";
53
54
  import { takeAfterWork, withRequest, withResponseDraft } from "./request.js";
54
55
  import { criticalAssetsOf, linkHeader, mergeAssets } from "./earlyHints.js";
55
- import { withHead } from "./shellHead.js";
56
56
  /**
57
57
  * @internal For a host adapter that embeds the engine. An app imports this
58
58
  * from `@rsc-kit/core/redirect` - the one the guides teach, and the one an
@@ -123,6 +123,16 @@ function redirectResponse(to, isPayloadRequest) {
123
123
  headers: { Location: to.location, Vary: VARY_ON_RSC },
124
124
  });
125
125
  }
126
+ /**
127
+ * What a page that said notFound() after its status line had gone out ends
128
+ * with: 200 is spent, so the document asks not to be indexed instead - a
129
+ * not-found page that a search engine keeps as a real one is a soft 404.
130
+ * The tag, then a script that moves it into <head>, where a crawler that
131
+ * runs the page reads it; a crawler that does not run it is answered with
132
+ * the whole document and a real 404 - see `forCrawler`.
133
+ */
134
+ const LATE_NOINDEX = '<meta name="robots" content="noindex">' +
135
+ "<script>document.head.appendChild(document.currentScript.previousSibling)</script>";
126
136
  /**
127
137
  * Append the redirect a render asked for after its shell had already gone out.
128
138
  *
@@ -134,7 +144,7 @@ function redirectResponse(to, isPayloadRequest) {
134
144
  * Read on flush, because that is when the render has finished and a redirect
135
145
  * from inside a Suspense boundary is finally known.
136
146
  */
137
- function appendLateRedirect(stream, taken) {
147
+ function appendLateRedirect(stream, taken, missing = () => false) {
138
148
  // An engine that answered with a finished body rather than a stream has no
139
149
  // late window at all: the render was over before this was called, so the
140
150
  // caller's own check already saw everything there was to see.
@@ -158,6 +168,9 @@ function appendLateRedirect(stream, taken) {
158
168
  if (to) {
159
169
  controller.enqueue(encoder.encode(`<script>location.replace(${inScript(to.location)})</script>`));
160
170
  }
171
+ else if (missing()) {
172
+ controller.enqueue(encoder.encode(LATE_NOINDEX));
173
+ }
161
174
  controller.close();
162
175
  },
163
176
  cancel(reason) {
@@ -846,7 +859,7 @@ export function createRscHandler(options) {
846
859
  if (request.headers.get(HEADER.rsc) === null) {
847
860
  // Scoped to this render, so two requests redirecting at once cannot read
848
861
  // each other's destination.
849
- return await withRedirect(async (taken) => {
862
+ return await withRedirect(async (taken, missing) => {
850
863
  // Awaited, and that await is the whole design: React resolves this
851
864
  // when the SHELL is ready, so a redirect thrown above every Suspense
852
865
  // boundary rejects here — before a byte is written, with a status line
@@ -901,7 +914,7 @@ export function createRscHandler(options) {
901
914
  // stays 200 because the status line has gone.
902
915
  if (currentNotFound())
903
916
  return saidNotFound(request);
904
- return new Response(appendLateRedirect(htmlStream, taken), {
917
+ return new Response(appendLateRedirect(htmlStream, taken, missing), {
905
918
  headers: withVersion({
906
919
  "Content-Type": HTML_TYPE,
907
920
  [HEADER.layouts]: chain.join(","),
@@ -1276,6 +1289,11 @@ export function createRscHandler(options) {
1276
1289
  "whole instead. Rebuild the prerendered output with the server bundle - a cached .output is the usual cause.");
1277
1290
  return null;
1278
1291
  }
1292
+ // A crawler is answered with the finished page, rendered now: a shell
1293
+ // has gone out with its 200 before its holes know whether the page
1294
+ // exists. See crawler.ts.
1295
+ if (isCrawler(request.headers.get("user-agent")))
1296
+ return null;
1279
1297
  // A shell with no state cannot be finished by anyone. It was frozen with
1280
1298
  // its fallbacks showing and there is no record of what came next, so
1281
1299
  // serving it would be serving a page that stays on its loading state for
@@ -1294,33 +1312,37 @@ export function createRscHandler(options) {
1294
1312
  const forShape = shellKey === key
1295
1313
  ? route.params
1296
1314
  : 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));
1315
+ // In a render scope, so a notFound() in a hole is recorded for the
1316
+ // stream's end - see LATE_NOINDEX.
1317
+ let missing = () => false;
1318
+ const resume = engine.handleRscResume;
1319
+ const { htmlStream, replayed } = await withRedirect((_, said) => {
1320
+ missing = said;
1321
+ return resume(route.route.component, forShape,
1322
+ // Empty props, because that is what the build passed. Resuming replays
1323
+ // the tree against the slots the shell left, and React matches those by
1324
+ // key — so an argument that differs from the frozen render at all is a
1325
+ // tree that "doesn't match", and every boundary falls back to the
1326
+ // client instead of being filled here.
1327
+ route.route.layouts.map((component) => ({ component, props: {} })), route.route.loadings, route.route.slots, {}, JSON.parse(state), undefined,
1328
+ // A shell found under the route's pattern was frozen for no particular
1329
+ // url, so it was rendered with no page key. Handing one over now would
1330
+ // key the tree differently from the one being resumed.
1331
+ shellKey === key ? url.pathname : "",
1332
+ // But the url itself, for the hooks: the holes are rendered for it.
1333
+ url.pathname, route.params);
1334
+ });
1335
+ // A shell stored for the pattern has no title of the page's: its
1336
+ // generateMetadata streamed under its own boundary, the probe left that
1337
+ // a hole, and the resume fills it with the real one. Nothing is looked
1338
+ // up before the shell leaves.
1339
+ //
1318
1340
  // The shell first, then whatever the resume writes. React's own script
1319
1341
  // travels with the resumed segments and moves them into place, so this is
1320
1342
  // a plain concatenation and the holes land without hydration.
1321
1343
  const body = new ReadableStream({
1322
1344
  async start(controller) {
1323
- controller.enqueue(new TextEncoder().encode(served));
1345
+ controller.enqueue(new TextEncoder().encode(shell));
1324
1346
  const reader = htmlStream.getReader();
1325
1347
  try {
1326
1348
  while (true) {
@@ -1329,6 +1351,8 @@ export function createRscHandler(options) {
1329
1351
  break;
1330
1352
  controller.enqueue(value);
1331
1353
  }
1354
+ if (missing())
1355
+ controller.enqueue(new TextEncoder().encode(LATE_NOINDEX));
1332
1356
  }
1333
1357
  finally {
1334
1358
  controller.close();