@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/apiPrerender.d.ts +8 -0
- package/dist/apiPrerender.js +6 -1
- package/dist/apiPrerender.js.map +1 -1
- package/dist/appAssets.d.ts +30 -0
- package/dist/appAssets.js +90 -0
- package/dist/appAssets.js.map +1 -0
- package/dist/buildReport.d.ts +43 -0
- package/dist/buildReport.js +40 -0
- package/dist/buildReport.js.map +1 -0
- package/dist/host.js +10 -2
- package/dist/host.js.map +1 -1
- package/dist/js/updateStore.d.ts +17 -0
- package/dist/js/updateStore.js +44 -0
- package/dist/js/updateStore.js.map +1 -0
- package/dist/js/useAppUpdate.d.ts +4 -0
- package/dist/js/useAppUpdate.js +29 -0
- package/dist/js/useAppUpdate.js.map +1 -0
- package/dist/request.d.ts +20 -0
- package/dist/request.js +19 -0
- package/dist/request.js.map +1 -1
- package/dist/vite.d.ts +34 -1
- package/dist/vite.js +423 -33
- package/dist/vite.js.map +1 -1
- package/dist/webManifest.d.ts +54 -0
- package/dist/webManifest.js +81 -0
- package/dist/webManifest.js.map +1 -0
- package/package.json +9 -1
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(
|
|
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.
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3522
|
-
|
|
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;
|