@rsc-kit/core 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/vite.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // carry the route composition and the worker's render contract, and supplies
13
13
  // the structural config (entries, output dirs, base). @vitejs/plugin-rsc is
14
14
  // included here so it always runs before any react() layer the app adds.
15
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { gzipSync } from 'node:zlib';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { createRequire } from 'node:module';
@@ -20,6 +20,9 @@ import { dirname, join, relative, resolve } from 'node:path';
20
20
  import { fileURLToPath, pathToFileURL } from 'node:url';
21
21
  import rsc from '@vitejs/plugin-rsc';
22
22
  import { loadEnv } from 'vite';
23
+ import { REPORT_FILE, buildReport } from './buildReport.js';
24
+ import { MANIFEST_PATH, manifestWarning, webManifest } from './webManifest.js';
25
+ import { ASSET_BASE, appAssets, headTags } from './appAssets.js';
23
26
  import { httpHostCalls } from './hostCalls.js';
24
27
  // Resolved once per rscKit() call. One build runs in one process, so these are
25
28
  // module state rather than threaded through every helper.
@@ -58,6 +61,8 @@ let isWatch = false;
58
61
  let viewTransitions = false;
59
62
  /** Whether a service worker is generated and registered — see options. */
60
63
  let offline = false;
64
+ let webManifestOptions = null;
65
+ let foundAssets = { favicon: null, icons: [], appleIcon: null, openGraph: null, twitter: null };
61
66
  /** Host functions to generate stubs for — see RscKitOptions.hostActions. */
62
67
  let hostActions;
63
68
  /**
@@ -201,6 +206,20 @@ function resolvePaths(options) {
201
206
  prerenderAfterBuild = options.prerender ?? process.env.RSC_PRERENDER !== '0';
202
207
  viewTransitions = options.viewTransitions === true;
203
208
  offline = options.offline === true;
209
+ // One place, and it is the file. A plugin option as well would be the same
210
+ // thing sayable in two places, which is the problem the file was moved to
211
+ // solve rather than a convenience to keep beside it.
212
+ webManifestOptions = declaredManifest(join(sourceDir, 'app'));
213
+ foundAssets = appAssets(join(sourceDir, 'app'));
214
+ // An app that put icons where they could be found has already listed them.
215
+ // Writing them again in the manifest is the same set in two places, and the
216
+ // one that goes stale is the one nobody looks at.
217
+ if (webManifestOptions && !webManifestOptions.icons?.length && foundAssets.icons.length) {
218
+ webManifestOptions = {
219
+ ...webManifestOptions,
220
+ icons: foundAssets.icons.map((icon) => icon.href),
221
+ };
222
+ }
204
223
  hostActions = options.hostActions ?? fileHostActions(projectRoot);
205
224
  }
206
225
  function log(...args) {
@@ -547,17 +566,63 @@ function reportAllDynamic() {
547
566
  * Nothing else is touched. An action is a POST and must never be answered from
548
567
  * a cache; anything cross-origin is somebody else's to cache.
549
568
  */
550
- export const SERVICE_WORKER = (version, precache) => `// GENERATED by rscKit() — do not edit.
569
+ export const SERVICE_WORKER = (version, precache, frozen = [], offlineUrl = null, swExtra = null) => `// GENERATED by rscKit() — do not edit.
551
570
  const VERSION = ${JSON.stringify(version)}
552
571
  const CACHE = 'rsc-kit-' + VERSION
553
572
  const PRECACHE = ${JSON.stringify(precache, null, 2)}
554
573
 
574
+ // The urls the build stored whole. Their answer cannot change until the next
575
+ // deploy, and a deploy changes VERSION and sweeps this cache — so for these,
576
+ // and only these, the cache is the better source and the network is the
577
+ // fallback rather than the other way round.
578
+ //
579
+ // Everything else stays network-first. A page that reads the request has a
580
+ // right answer that depends on the request, and serving yesterday's from a
581
+ // cache would be wrong in a way the visitor cannot see.
582
+ const FROZEN = new Set(${JSON.stringify(frozen)})
583
+
584
+ // The page to show when a navigation cannot be answered at all.
585
+ //
586
+ // An ordinary route at /offline, stored at build time like any other — nothing
587
+ // special about the file, only about when it is served. Null when the app has
588
+ // none, and then a navigation with nothing cached fails as it always did.
589
+ //
590
+ // Deliberately NOT the cached root. That was tried: the document IS the page
591
+ // here, so the visitor got the home page's markup under the address they asked
592
+ // for and it did not hydrate — a wrong page pretending to be the right one.
593
+ // This one is about being offline whatever url it appears under, so it is the
594
+ // only page that can honestly stand in for another.
595
+ const OFFLINE_URL = ${JSON.stringify(offlineUrl)}
596
+ ${swExtra
597
+ ? `
598
+ // The app's own worker code — push, notification clicks, background sync.
599
+ //
600
+ // importScripts rather than a bundled import, because this file is evaluated
601
+ // in a worker scope by the browser rather than built: whatever the app wrote is
602
+ // run as it was written, in the same global, so its listeners sit beside the
603
+ // ones below.
604
+ //
605
+ // First, so an app handler for an event this file does not handle is registered
606
+ // before anything here can call respondWith on it.
607
+ self.importScripts(${JSON.stringify(swExtra)})
608
+ `
609
+ : ''}
610
+
555
611
  self.addEventListener('install', (event) => {
556
612
  // The new worker takes over rather than waiting for every tab to close.
557
613
  // Safe here because assets are content-hashed: a page already open keeps
558
614
  // asking for the names it was built with, and those are still cached under
559
615
  // their own version until this activates and sweeps.
560
- event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting()))
616
+ event.waitUntil(
617
+ caches
618
+ .open(CACHE)
619
+ // The offline page is fetched rather than copied from the build output:
620
+ // it is served by the host out of the prerendered directory, which this
621
+ // worker cannot see. Added here so it is in the cache before it is
622
+ // needed, which is the only moment it cannot be fetched.
623
+ .then((cache) => cache.addAll(OFFLINE_URL ? [...PRECACHE, OFFLINE_URL] : PRECACHE))
624
+ .then(() => self.skipWaiting()),
625
+ )
561
626
  })
562
627
 
563
628
  self.addEventListener('activate', (event) => {
@@ -565,10 +630,24 @@ self.addEventListener('activate', (event) => {
565
630
  caches
566
631
  .keys()
567
632
  .then((keys) => Promise.all(keys.filter((k) => k.startsWith('rsc-kit-') && k !== CACHE).map((k) => caches.delete(k))))
568
- .then(() => self.clients.claim()),
633
+ .then(() => self.clients.claim())
634
+ .then(() => tellTheOpenPages()),
569
635
  )
570
636
  })
571
637
 
638
+ // A page open right now is running the previous build's javascript, and the
639
+ // chunks it has not loaded yet were just swept. It cannot fix that by itself —
640
+ // only a reload gets the new ones — so it is told, and the app decides what to
641
+ // say about it.
642
+ //
643
+ // After the sweep rather than before, so a page acting on this immediately
644
+ // reloads into the new version rather than racing the deletion.
645
+ async function tellTheOpenPages() {
646
+ const open = await self.clients.matchAll({ type: 'window' })
647
+
648
+ for (const page of open) page.postMessage({ type: 'rsc-kit:updated', version: VERSION })
649
+ }
650
+
572
651
  const immutable = (url) => url.pathname.startsWith('/assets/') || /-[A-Za-z0-9_-]{8,}\\.[a-z]+$/.test(url.pathname)
573
652
 
574
653
  // Whether a response may be kept at all.
@@ -622,6 +701,40 @@ self.addEventListener('fetch', (event) => {
622
701
  return
623
702
  }
624
703
 
704
+ // A stored page, asked for without a query string. Cache first, and refresh
705
+ // in the background so the next visit has the new one even if this worker
706
+ // never updates.
707
+ //
708
+ // The query matters for the same reason it matters to a stored api route:
709
+ // the build answered the bare url, and a page reading ?q= answers
710
+ // differently for every value of it.
711
+ if (FROZEN.has(url.pathname.replace(/\\/+$/, '') || '/') && !url.search) {
712
+ event.respondWith(
713
+ caches.match(keyFor(request)).then((hit) => {
714
+ const fresh = fetch(request)
715
+ .then((response) => {
716
+ if (mayStore(response)) {
717
+ const copy = response.clone()
718
+ caches.open(CACHE).then((cache) => cache.put(keyFor(request), copy))
719
+ }
720
+
721
+ return response
722
+ })
723
+ .catch((error) => {
724
+ if (hit) return hit
725
+
726
+ throw error
727
+ })
728
+
729
+ // Not awaited when there is a hit: the point is that the visitor does
730
+ // not wait for the network for a page that cannot have changed.
731
+ return hit ?? fresh
732
+ }),
733
+ )
734
+
735
+ return
736
+ }
737
+
625
738
  event.respondWith(
626
739
  fetch(request)
627
740
  .then((response) => {
@@ -678,12 +791,25 @@ self.addEventListener('fetch', (event) => {
678
791
 
679
792
  if (hit) return hit
680
793
 
681
- // Nothing cached for this url and no network to ask. Falling back to
682
- // the cached root was worse than failing: the document IS the page
683
- // here, so the visitor got the home page's markup under the address
684
- // they asked for, and it did not hydrate — a wrong page pretending to
685
- // be the right one. Letting it fail says what is true, and a page
686
- // already open is unaffected.
794
+ // Nothing cached for this url and no network to ask.
795
+ //
796
+ // Falling back to the cached ROOT was worse than failing: the document
797
+ // IS the page here, so the visitor got the home page's markup under the
798
+ // address they asked for, and it did not hydrate — a wrong page
799
+ // pretending to be the right one.
800
+ //
801
+ // An offline page is different, and is the one page that can honestly
802
+ // stand in for another: it is about being offline, not about the url it
803
+ // appears under. Navigations only — a payload request answered with a
804
+ // document would be decoded as one and throw.
805
+ if (OFFLINE_URL && request.mode === 'navigate') {
806
+ const page = await caches.match(OFFLINE_URL)
807
+
808
+ if (page) return page
809
+ }
810
+
811
+ // Letting it fail says what is true, and a page already open is
812
+ // unaffected.
687
813
  return Response.error()
688
814
  }),
689
815
  )
@@ -698,9 +824,150 @@ self.addEventListener('fetch', (event) => {
698
824
  * and a build that changed anything produces a different one — which is the
699
825
  * whole of cache invalidation, without asking anyone to set a variable.
700
826
  */
701
- function writeServiceWorker(clientDir) {
827
+ /**
828
+ * Write the web app manifest into the client output, beside the assets.
829
+ *
830
+ * Into the same directory the browser is served from, because that is where a
831
+ * relative icon path resolves — a manifest served from somewhere else resolves
832
+ * its icons somewhere else too, and the failure is a browser that quietly does
833
+ * not offer to install.
834
+ */
835
+ /**
836
+ * The manifest an app declared beside its routes.
837
+ *
838
+ * `src/app/manifest.ts`, default-exporting the object — the same shape every
839
+ * other thing about a route tree takes here, and the reason this is a file
840
+ * rather than a `vite.config.ts` key. A manifest is not build configuration
841
+ * any more than a page is; it is one more thing the app declares about itself,
842
+ * and it belongs where the app is.
843
+ *
844
+ * Evaluated with the TypeScript stripped rather than imported: this runs while
845
+ * the plugin is being constructed, long before there is a module graph to pull
846
+ * it through, and importing an app module here would drag its imports in with
847
+ * it. The file is a literal object by contract, which is all that has to parse.
848
+ */
849
+ export function declaredManifest(appDir) {
850
+ for (const extension of ['ts', 'tsx', 'js', 'mjs']) {
851
+ const file = join(appDir, `manifest.${extension}`);
852
+ if (!existsSync(file))
853
+ continue;
854
+ const source = readFileSync(file, 'utf-8');
855
+ // The object literal after `export default`, with a `satisfies` or `as`
856
+ // annotation tolerated after it — which is how anyone who wants the type
857
+ // checked will actually write the file, and getting that wrong would refuse
858
+ // the recommended spelling.
859
+ //
860
+ // A manifest that is not a literal — computed, imported from elsewhere — is
861
+ // refused loudly rather than silently ignored, because the failure would
862
+ // otherwise be an app that is simply not installable with nothing said.
863
+ const match = /export\s+default\s+(\{[\s\S]*\})(?:\s+(?:satisfies|as)\s+[\w.<>\[\]| ]+)?\s*;?\s*$/.exec(source.trim());
864
+ if (!match) {
865
+ throw new Error(`[rsc-kit] app/manifest.${extension} must default-export an object literal.\n` +
866
+ 'It is read at build time, before there is a module graph to evaluate it in, so it ' +
867
+ 'cannot be computed or imported from elsewhere.');
868
+ }
869
+ try {
870
+ // Function rather than JSON.parse: the file is TypeScript source with
871
+ // unquoted keys, trailing commas and comments in it, none of which JSON
872
+ // accepts and all of which are ordinary in a file a person edits.
873
+ return new Function(`return (${match[1]})`)();
874
+ }
875
+ catch (error) {
876
+ throw new Error(`[rsc-kit] Could not read app/manifest.${extension}: ${error.message}`);
877
+ }
878
+ }
879
+ return null;
880
+ }
881
+ /**
882
+ * Copy the icons and share images into the client output.
883
+ *
884
+ * They live in `app/` so the build can read them - an icon's filename decides
885
+ * what goes in the manifest - but a browser has to be able to fetch them, and
886
+ * nothing serves `app/`. Copied rather than symlinked: the output is what gets
887
+ * deployed, and a link into a source tree that is not deployed points nowhere.
888
+ */
889
+ function copyAppAssets(clientDir) {
890
+ if (!existsSync(clientDir))
891
+ return;
892
+ const all = [
893
+ ...(foundAssets.favicon ? [foundAssets.favicon] : []),
894
+ ...foundAssets.icons,
895
+ ...(foundAssets.appleIcon ? [foundAssets.appleIcon] : []),
896
+ ...(foundAssets.openGraph ? [foundAssets.openGraph] : []),
897
+ ...(foundAssets.twitter ? [foundAssets.twitter] : []),
898
+ ];
899
+ if (all.length === 0)
900
+ return;
901
+ for (const asset of all) {
902
+ const to = join(clientDir, asset.href.slice(1));
903
+ mkdirSync(dirname(to), { recursive: true });
904
+ copyFileSync(join(sourceDir, 'app', asset.file), to);
905
+ }
906
+ log(`icons: ${all.length} copied from app/`);
907
+ }
908
+ function writeWebManifest(clientDir, options) {
702
909
  if (!existsSync(clientDir))
703
910
  return;
911
+ writeFileSync(join(clientDir, MANIFEST_PATH.slice(1)), webManifest(options));
912
+ const warning = manifestWarning(options);
913
+ if (warning)
914
+ log(warning);
915
+ else
916
+ log(`manifest: ${options.name} is installable`);
917
+ }
918
+ /**
919
+ * The url a navigation falls back to when nothing else can answer it.
920
+ *
921
+ * An ordinary route at /offline, and it has to be one the build STORED — a
922
+ * page that renders per request cannot be served when there is no request to
923
+ * be made. So a dynamic /offline is refused as a fallback rather than
924
+ * precached and found wanting at the one moment it matters, and the build says
925
+ * which read did it.
926
+ */
927
+ function offlineFallback(frozen, results) {
928
+ const found = results.find((r) => r.url === '/offline');
929
+ if (!found)
930
+ return null;
931
+ if (!frozen.includes('/offline')) {
932
+ // The reason already reads "dynamic — called cookies()", and this sentence
933
+ // has said "not stored" by the time it gets there, so the prefix would say
934
+ // it twice with a dash in the middle of both.
935
+ const why = found.reason?.replace(/^dynamic — /, '') ?? null;
936
+ log('offline: /offline cannot be the fallback' +
937
+ (why ? `, because it ${why}` : '') +
938
+ '. A fallback has to be servable with no network at all.');
939
+ return null;
940
+ }
941
+ return '/offline';
942
+ }
943
+ /**
944
+ * The app's own service worker code, if it wrote any.
945
+ *
946
+ * `src/app/sw.js`, copied next to the generated worker and imported by it. Plain
947
+ * javascript rather than TypeScript, and that is not an oversight: it is
948
+ * evaluated by the browser in a worker scope with no build step in front of it,
949
+ * so what is written is what runs. Calling it .js says so.
950
+ *
951
+ * This is the only way to add an event this package does not handle — push,
952
+ * notificationclick, sync — without giving up everything the generated worker
953
+ * does. There is no option for it because there is nothing to configure: the
954
+ * file is there or it is not.
955
+ */
956
+ function copyServiceWorkerExtra(clientDir) {
957
+ const source = join(sourceDir, 'app', 'sw.js');
958
+ if (!existsSync(source))
959
+ return null;
960
+ copyFileSync(source, join(clientDir, 'sw-app.js'));
961
+ return '/sw-app.js';
962
+ }
963
+ function writeServiceWorker(clientDir, frozen = [], offlineUrl = null) {
964
+ if (!existsSync(clientDir))
965
+ return;
966
+ // Before the walk, so it lands in the precache with everything else. The
967
+ // worker importScripts it while evaluating, which is the one moment it cannot
968
+ // go to the network for it — a worker whose import fails does not start, and
969
+ // then nothing is cached at all.
970
+ const extra = copyServiceWorkerExtra(clientDir);
704
971
  const files = [];
705
972
  const walk = (dir, prefix) => {
706
973
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -718,8 +985,10 @@ function writeServiceWorker(clientDir) {
718
985
  walk(clientDir, '');
719
986
  const precache = ['/', ...files.map((file) => `/${file}`)].sort();
720
987
  const version = createHash('sha256').update(precache.join('\n')).digest('hex').slice(0, 12);
721
- writeFileSync(join(clientDir, 'sw.js'), SERVICE_WORKER(version, precache));
722
- log(`offline: ${precache.length} files precached as rsc-kit-${version}`);
988
+ writeFileSync(join(clientDir, 'sw.js'), SERVICE_WORKER(version, precache, frozen, offlineUrl, extra));
989
+ log(`offline: ${precache.length} files precached as rsc-kit-${version}` +
990
+ (offlineUrl ? `, falling back to ${offlineUrl}` : '') +
991
+ (extra ? ', with app/sw.js' : ''));
723
992
  }
724
993
  /**
725
994
  * The rsc bundle the build just wrote, whatever it decided to call it.
@@ -768,6 +1037,7 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
768
1037
  // Weighed from the page the prerenderer just wrote, so the column is what
769
1038
  // that page actually loads rather than a total every route is charged for.
770
1039
  const weigh = weighClientJs(assetsDir);
1040
+ const sized = new Map();
771
1041
  const pending = [];
772
1042
  const results = await prerender({
773
1043
  engine,
@@ -779,9 +1049,12 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
779
1049
  const file = [`${key}.html`, `${key}.ppr.html`]
780
1050
  .map((name) => join(staticDir, name))
781
1051
  .find((path) => existsSync(path));
1052
+ const bytes = file ? weigh(readFileSync(file, 'utf-8')) : null;
1053
+ if (bytes !== null)
1054
+ sized.set(r.url, bytes);
782
1055
  pending.push({
783
1056
  line: ` ${mark[r.type] ?? ' '} ${r.url}`,
784
- bytes: file ? weigh(readFileSync(file, 'utf-8')) : null,
1057
+ bytes,
785
1058
  extra: [
786
1059
  ...(r.reason ? [` ${r.reason}`] : []),
787
1060
  ...(r.warning ? [` ⚠ ${r.warning}`] : []),
@@ -793,7 +1066,8 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
793
1066
  // either answered or could not, which is the same question the table above
794
1067
  // is already answering — a second list under its own heading would be two
795
1068
  // places to look for one fact.
796
- const apis = await prerenderApiRoutes(engine, engine.manifest(), writeTo(staticDir));
1069
+ const manifest = engine.manifest();
1070
+ const apis = await prerenderApiRoutes(engine, manifest, writeTo(staticDir));
797
1071
  for (const api of apis) {
798
1072
  pending.push({
799
1073
  line: ` ${api.type === 'frozen' ? '○' : 'ƒ'} ${api.url}`,
@@ -811,6 +1085,16 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
811
1085
  console.log(line);
812
1086
  }
813
1087
  const count = (type) => results.filter((r) => r.type === type).length;
1088
+ // Written from the rows that were just printed rather than recomputed: the
1089
+ // report and the terminal must not be able to disagree about what happened.
1090
+ writeFileSync(join(outDir, REPORT_FILE), buildReport(results.map((r) => ({
1091
+ url: r.url,
1092
+ component: r.component,
1093
+ type: r.type,
1094
+ reason: r.reason,
1095
+ warning: r.warning ?? null,
1096
+ clientJs: sized.get(r.url) ?? null,
1097
+ })), apis.map((a) => ({ url: a.url, name: a.name, type: a.type, reason: a.reason }))));
814
1098
  const note = notes(results);
815
1099
  const counted = [...results, ...apis];
816
1100
  console.log(`
@@ -824,6 +1108,28 @@ ${legend(counted)}
824
1108
  }
825
1109
  if (output === 'export')
826
1110
  await exportAfterPrerender(results, staticDir, assetsDir);
1111
+ // The urls whose answer cannot change until the next build. The service
1112
+ // worker serves these from its cache first rather than asking the network
1113
+ // and falling back — see writeServiceWorker.
1114
+ //
1115
+ // Two narrowings beyond "frozen", and neither is redundant even though the
1116
+ // worker would survive without them. A guarded route IS frozen — the guard
1117
+ // is a serving decision, not a build one — but its response is per visitor
1118
+ // and must never be read from a cache that has no notion of who asked. A
1119
+ // redirect is stored as a destination rather than a page, so there is no
1120
+ // document to serve from a cache at all.
1121
+ //
1122
+ // Both are already refused downstream: a guarded response carries no-store
1123
+ // and a 3xx is not `ok`. Saying it here as well means the list means what it
1124
+ // says, rather than being a wider list that happens to be filtered later.
1125
+ const guarded = new Set(manifest.routes.filter((r) => r.middleware?.length).map((r) => r.component));
1126
+ return {
1127
+ frozen: results
1128
+ .filter((r) => r.type === 'frozen' && !guarded.has(r.component))
1129
+ .filter((r) => existsSync(join(staticDir, pathKeyOf(r.url) + '.html')))
1130
+ .map((r) => r.url),
1131
+ results,
1132
+ };
827
1133
  }
828
1134
  /**
829
1135
  * Weighs the javascript one stored page makes the browser download.
@@ -1357,6 +1663,7 @@ import { PathnameProvider } from ${JSON.stringify(join(packageDir, "js/PathnameP
1357
1663
  import { searchParams as requestSearchParams } from ${JSON.stringify(join(packageDir, "request"))}
1358
1664
  import { parseParams, parseSearchParams, parseBody, isSearchParamsError, isBodyError } from ${JSON.stringify(join(packageDir, "routeSchema"))}
1359
1665
  import { notFoundDigest, isNotFoundSignal } from ${JSON.stringify(join(packageDir, "notFound"))}
1666
+ import { noteRequestRead } from ${JSON.stringify(join(packageDir, "request"))}
1360
1667
  import { redirectDigest } from ${JSON.stringify(join(packageDir, "redirectDigest"))}
1361
1668
  import { createRscHandler } from ${JSON.stringify(join(packageDir, "host"))}
1362
1669
  import { httpHostCalls } from ${JSON.stringify(join(packageDir, 'hostCalls'))}
@@ -1381,6 +1688,20 @@ ${mapEntries.join('\n')}
1381
1688
  * Empty for a page that exported none, which is the common case — the lookup
1382
1689
  * below then hands the url through untouched and costs a property read.
1383
1690
  */
1691
+ /**
1692
+ * The manifest to link from every page, or null when the app declared none.
1693
+ *
1694
+ * A literal rather than a define: defines are configured per environment and
1695
+ * this is read while rendering, in the rsc one. Generated in, so an app with
1696
+ * no manifest carries the word null and no branch worth taking.
1697
+ */
1698
+ /** Head tags for the icons and share images found in app/. */
1699
+ const APP_HEAD: { tag: any; props: Record<string, string> }[] = ${JSON.stringify(headTags(foundAssets))}
1700
+
1701
+ const WEB_MANIFEST: { href: string; themeColor?: string } | null = ${JSON.stringify(webManifestOptions
1702
+ ? { href: MANIFEST_PATH, ...(webManifestOptions.themeColor ? { themeColor: webManifestOptions.themeColor } : {}) }
1703
+ : null)}
1704
+
1384
1705
  const urlSchemas: Record<string, { params?: any; searchParams?: any }> = {
1385
1706
  ${schemaEntries.join('\n')}
1386
1707
  }
@@ -1401,6 +1722,33 @@ ${apiEntries.join('\n')}
1401
1722
  * without a body. Answering 405 instead breaks link checkers and anything that
1402
1723
  * probes before it fetches.
1403
1724
  */
1725
+ /**
1726
+ * A promise that does not start until something awaits it.
1727
+ *
1728
+ * A thenable rather than a promise for the reason pageSearchParams is one:
1729
+ * every handler is handed all three of these whether it reads them or not, and
1730
+ * a real promise would run the work - and record the read - for every route on
1731
+ * every request.
1732
+ */
1733
+ function lazily<T>(start: () => Promise<T>): Promise<T> {
1734
+ let pending: Promise<T> | null = null
1735
+
1736
+ const begin = (): Promise<T> => {
1737
+ if (!pending) {
1738
+ pending = start()
1739
+ pending.catch(() => {})
1740
+ }
1741
+
1742
+ return pending
1743
+ }
1744
+
1745
+ return {
1746
+ then: (ok, fail) => begin().then(ok, fail),
1747
+ catch: (fail) => begin().catch(fail),
1748
+ finally: (done) => begin().finally(done),
1749
+ } as Promise<T>
1750
+ }
1751
+
1404
1752
  /** Whether a method may carry a body worth reading. */
1405
1753
  function hasBody(method: string): boolean {
1406
1754
  return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE'
@@ -1454,26 +1802,39 @@ export async function handleApiRoute(
1454
1802
  return new Response('Method not allowed', { status: 405, headers: { Allow: allow } })
1455
1803
  }
1456
1804
 
1457
- // Schemas are optional, per route and per kind. A route that exported none
1458
- // reaches the handler with exactly what it always did — the raw params, the
1459
- // URLSearchParams, and a body nobody has read — so nothing written before
1460
- // this existed changes behaviour.
1461
- let input
1805
+ // Awaited by the handler, not before it - the same shape a page's props have,
1806
+ // and for more than symmetry. A route that never awaits its query string
1807
+ // provably does not vary by it, so the build can store one answer and the
1808
+ // host can serve it for any query at all. Resolved eagerly here, that fact
1809
+ // is unknowable and every ?utm_source= misses the stored answer.
1810
+ //
1811
+ // Schemas stay optional per route and per kind. A route that exported none
1812
+ // gets the raw params, the URLSearchParams, and a body nobody has read.
1813
+ const input = {
1814
+ params: lazily(() => parseParams(mod.params, params)),
1815
+ searchParams: lazily(() => {
1816
+ // Declaring a schema is itself a statement that the query matters, so a
1817
+ // route with one is treated as varying by it whether or not the handler
1818
+ // reaches for the value.
1819
+ noteRequestRead('searchParams')
1820
+
1821
+ return parseSearchParams(mod.searchParams, new URL(request.url).searchParams)
1822
+ }),
1823
+ body: hasBody(method) ? lazily(() => parseBody(mod.body, request)) : undefined,
1824
+ }
1825
+
1826
+ if (mod.searchParams) noteRequestRead('searchParams')
1827
+
1828
+ let answer
1462
1829
  try {
1463
- input = {
1464
- params: await parseParams(mod.params, params),
1465
- searchParams: await parseSearchParams(
1466
- mod.searchParams,
1467
- new URL(request.url).searchParams,
1468
- ),
1469
- body: hasBody(method) ? await parseBody(mod.body, request) : undefined,
1470
- }
1830
+ answer = await handler(request, input)
1471
1831
  } catch (error) {
1832
+ // A refusal raised by one of the thenables above surfaces here, because the
1833
+ // handler awaited it and did not catch it. Anything else is the route's own
1834
+ // failure and is left to the caller.
1472
1835
  return refusedInput(error)
1473
1836
  }
1474
1837
 
1475
- const answer = await handler(request, input)
1476
-
1477
1838
  // A HEAD answered by GET must not carry a body.
1478
1839
  if (method === 'HEAD' && answer instanceof Response) {
1479
1840
  return new Response(null, { status: answer.status, headers: answer.headers })
@@ -1868,6 +2229,27 @@ async function renderTree(
1868
2229
  const md = await resolveMetadata(component, props, layouts)
1869
2230
  const head: unknown[] = []
1870
2231
 
2232
+ // Rendered into the tree rather than written into the app's layout: React
2233
+ // hoists a link and a meta into <head> from anywhere, so this works for an
2234
+ // app that already has a layout and never asks anyone to edit one. The
2235
+ // engine knows the manifest exists; the app should not have to.
2236
+ // Icons and share images the build found in app/. Rendered here so React
2237
+ // hoists them into <head>, the same way the manifest link is — an app never
2238
+ // edits its layout to get a favicon.
2239
+ for (const [i, tag] of APP_HEAD.entries()) {
2240
+ head.push(createElement(tag.tag, { key: '__a' + i, ...tag.props }))
2241
+ }
2242
+
2243
+ if (WEB_MANIFEST) {
2244
+ head.push(createElement('link', { key: '__mf', rel: 'manifest', href: WEB_MANIFEST.href }))
2245
+
2246
+ if (WEB_MANIFEST.themeColor) {
2247
+ head.push(
2248
+ createElement('meta', { key: '__tc', name: 'theme-color', content: WEB_MANIFEST.themeColor }),
2249
+ )
2250
+ }
2251
+ }
2252
+
1871
2253
  if (md) {
1872
2254
  if (md.title != null) {
1873
2255
  // The element is what a server render puts in <head>, and what a route
@@ -3517,9 +3899,17 @@ export function rscKit(options = {}) {
3517
3899
  const staticDir = clientOut
3518
3900
  ? join(dirname(clientOut), 'server', NITRO_STATIC_DIR)
3519
3901
  : join(outDir, NITRO_STATIC_DIR);
3520
- await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir);
3521
- if (offline)
3522
- writeServiceWorker(clientOut ?? publicAssetsDir);
3902
+ const { frozen, results } = await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir);
3903
+ // Manifest first. The service worker precaches whatever it finds in this
3904
+ // directory, so writing it afterwards leaves it out of the list — and an
3905
+ // installed app whose manifest is the one file that needs the network is
3906
+ // the wrong way round.
3907
+ copyAppAssets(clientOut ?? publicAssetsDir);
3908
+ if (webManifestOptions)
3909
+ writeWebManifest(clientOut ?? publicAssetsDir, webManifestOptions);
3910
+ if (offline) {
3911
+ writeServiceWorker(clientOut ?? publicAssetsDir, frozen, offlineFallback(frozen, results));
3912
+ }
3523
3913
  },
3524
3914
  configResolved(config) {
3525
3915
  isWatch = config.build?.watch != null;