@rsc-kit/core 0.11.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.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Whether a newer build is live while this page is still running the old one.
3
+ *
4
+ * Free of React for the same reason onlineStore is: the value arrives from a
5
+ * service worker message, which has nothing to do with rendering, and the hook
6
+ * that reads it lives next door.
7
+ *
8
+ * The situation it reports is real rather than theoretical. This package's
9
+ * worker activates immediately rather than waiting for every tab to close, and
10
+ * activating sweeps the previous build's cache — so a page that has been open
11
+ * across a deploy is running javascript whose remaining chunks are gone. It
12
+ * works until it navigates somewhere that needs one.
13
+ */
14
+ export declare function isUpdated(): boolean;
15
+ export declare function subscribeToUpdates(callback: () => void): () => void;
16
+ /** The server render's answer. A fresh document is by definition not stale. */
17
+ export declare function updatedOnServer(): boolean;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Whether a newer build is live while this page is still running the old one.
3
+ *
4
+ * Free of React for the same reason onlineStore is: the value arrives from a
5
+ * service worker message, which has nothing to do with rendering, and the hook
6
+ * that reads it lives next door.
7
+ *
8
+ * The situation it reports is real rather than theoretical. This package's
9
+ * worker activates immediately rather than waiting for every tab to close, and
10
+ * activating sweeps the previous build's cache — so a page that has been open
11
+ * across a deploy is running javascript whose remaining chunks are gone. It
12
+ * works until it navigates somewhere that needs one.
13
+ */
14
+ let updated = false;
15
+ const listeners = new Set();
16
+ /** A boolean, so React compares snapshots by value and does not loop. */
17
+ function announce() {
18
+ if (updated)
19
+ return;
20
+ updated = true;
21
+ listeners.forEach((fn) => fn());
22
+ }
23
+ if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
24
+ navigator.serviceWorker.addEventListener('message', (event) => {
25
+ if (event.data?.type === 'rsc-kit:updated')
26
+ announce();
27
+ });
28
+ // A worker that took control after this page loaded is the same news by
29
+ // another route — it fires when the page was open across a deploy and the
30
+ // message was posted before this listener existed.
31
+ navigator.serviceWorker.addEventListener('controllerchange', announce);
32
+ }
33
+ export function isUpdated() {
34
+ return updated;
35
+ }
36
+ export function subscribeToUpdates(callback) {
37
+ listeners.add(callback);
38
+ return () => listeners.delete(callback);
39
+ }
40
+ /** The server render's answer. A fresh document is by definition not stale. */
41
+ export function updatedOnServer() {
42
+ return false;
43
+ }
44
+ //# sourceMappingURL=updateStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"updateStore.js","sourceRoot":"","sources":["../../src/js/updateStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,IAAI,OAAO,GAAG,KAAK,CAAA;AACnB,MAAM,SAAS,GAAG,IAAI,GAAG,EAAc,CAAA;AAEvC,yEAAyE;AACzE,SAAS,QAAQ;IACf,IAAI,OAAO;QAAE,OAAM;IAEnB,OAAO,GAAG,IAAI,CAAA;IACd,SAAS,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAA;AACjC,CAAC;AAED,IAAI,OAAO,SAAS,KAAK,WAAW,IAAI,eAAe,IAAI,SAAS,EAAE,CAAC;IACrE,SAAS,CAAC,aAAa,CAAC,gBAAgB,CAAC,SAAS,EAAE,CAAC,KAAmB,EAAE,EAAE;QAC1E,IAAK,KAAK,CAAC,IAAiC,EAAE,IAAI,KAAK,iBAAiB;YAAE,QAAQ,EAAE,CAAA;IACtF,CAAC,CAAC,CAAA;IAEF,wEAAwE;IACxE,0EAA0E;IAC1E,mDAAmD;IACnD,SAAS,CAAC,aAAa,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAA;AACxE,CAAC;AAED,MAAM,UAAU,SAAS;IACvB,OAAO,OAAO,CAAA;AAChB,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,QAAoB;IACrD,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IAEvB,OAAO,GAAG,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;AACzC,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,eAAe;IAC7B,OAAO,KAAK,CAAA;AACd,CAAC","sourcesContent":["/**\n * Whether a newer build is live while this page is still running the old one.\n *\n * Free of React for the same reason onlineStore is: the value arrives from a\n * service worker message, which has nothing to do with rendering, and the hook\n * that reads it lives next door.\n *\n * The situation it reports is real rather than theoretical. This package's\n * worker activates immediately rather than waiting for every tab to close, and\n * activating sweeps the previous build's cache — so a page that has been open\n * across a deploy is running javascript whose remaining chunks are gone. It\n * works until it navigates somewhere that needs one.\n */\n\nlet updated = false\nconst listeners = new Set<() => void>()\n\n/** A boolean, so React compares snapshots by value and does not loop. */\nfunction announce(): void {\n if (updated) return\n\n updated = true\n listeners.forEach((fn) => fn())\n}\n\nif (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {\n navigator.serviceWorker.addEventListener('message', (event: MessageEvent) => {\n if ((event.data as { type?: string } | null)?.type === 'rsc-kit:updated') announce()\n })\n\n // A worker that took control after this page loaded is the same news by\n // another route — it fires when the page was open across a deploy and the\n // message was posted before this listener existed.\n navigator.serviceWorker.addEventListener('controllerchange', announce)\n}\n\nexport function isUpdated(): boolean {\n return updated\n}\n\nexport function subscribeToUpdates(callback: () => void): () => void {\n listeners.add(callback)\n\n return () => listeners.delete(callback)\n}\n\n/** The server render's answer. A fresh document is by definition not stale. */\nexport function updatedOnServer(): boolean {\n return false\n}\n"]}
@@ -0,0 +1,4 @@
1
+ export declare function useAppUpdate(): {
2
+ updated: boolean;
3
+ reload: () => void;
4
+ };
@@ -0,0 +1,29 @@
1
+ "use client";
2
+ /**
3
+ * Whether a newer build is live, and how to get it.
4
+ *
5
+ * const { updated, reload } = useAppUpdate()
6
+ *
7
+ * if (updated) return <button onClick={reload}>A new version is ready</button>
8
+ *
9
+ * Reported rather than acted on. Reloading out from under someone mid-form is
10
+ * worse than the staleness it fixes, so this package will not do it for you —
11
+ * what it will do is tell you, which nothing else can, because only the worker
12
+ * knows a new version activated.
13
+ */
14
+ import { useSyncExternalStore } from "react";
15
+ import { isUpdated, subscribeToUpdates, updatedOnServer } from "./updateStore";
16
+ export function useAppUpdate() {
17
+ const updated = useSyncExternalStore(subscribeToUpdates, isUpdated, updatedOnServer);
18
+ return {
19
+ updated,
20
+ // location.reload() rather than a router navigation: the point is to
21
+ // re-fetch the document and its javascript, and a client navigation
22
+ // deliberately does neither.
23
+ reload: () => {
24
+ if (typeof window !== "undefined")
25
+ window.location.reload();
26
+ },
27
+ };
28
+ }
29
+ //# sourceMappingURL=useAppUpdate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useAppUpdate.js","sourceRoot":"","sources":["../../src/js/useAppUpdate.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAC;AAC7C,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAE/E,MAAM,UAAU,YAAY;IAC1B,MAAM,OAAO,GAAG,oBAAoB,CAAC,kBAAkB,EAAE,SAAS,EAAE,eAAe,CAAC,CAAC;IAErF,OAAO;QACL,OAAO;QACP,qEAAqE;QACrE,oEAAoE;QACpE,6BAA6B;QAC7B,MAAM,EAAE,GAAG,EAAE;YACX,IAAI,OAAO,MAAM,KAAK,WAAW;gBAAE,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC;QAC9D,CAAC;KACF,CAAC;AACJ,CAAC","sourcesContent":["\"use client\";\n\n/**\n * Whether a newer build is live, and how to get it.\n *\n * const { updated, reload } = useAppUpdate()\n *\n * if (updated) return <button onClick={reload}>A new version is ready</button>\n *\n * Reported rather than acted on. Reloading out from under someone mid-form is\n * worse than the staleness it fixes, so this package will not do it for you —\n * what it will do is tell you, which nothing else can, because only the worker\n * knows a new version activated.\n */\n\nimport { useSyncExternalStore } from \"react\";\nimport { isUpdated, subscribeToUpdates, updatedOnServer } from \"./updateStore\";\n\nexport function useAppUpdate(): { updated: boolean; reload: () => void } {\n const updated = useSyncExternalStore(subscribeToUpdates, isUpdated, updatedOnServer);\n\n return {\n updated,\n // location.reload() rather than a router navigation: the point is to\n // re-fetch the document and its javascript, and a client navigation\n // deliberately does neither.\n reload: () => {\n if (typeof window !== \"undefined\") window.location.reload();\n },\n };\n}\n"]}
package/dist/vite.d.ts CHANGED
@@ -189,7 +189,7 @@ export interface RscKitOptions {
189
189
  * Nothing else is touched. An action is a POST and must never be answered from
190
190
  * a cache; anything cross-origin is somebody else's to cache.
191
191
  */
192
- export declare const SERVICE_WORKER: (version: string, precache: string[], frozen?: string[]) => string;
192
+ export declare const SERVICE_WORKER: (version: string, precache: string[], frozen?: string[], offlineUrl?: string | null, swExtra?: string | null) => string;
193
193
  /**
194
194
  * Write the worker beside the assets it caches.
195
195
  *
package/dist/vite.js CHANGED
@@ -566,7 +566,7 @@ function reportAllDynamic() {
566
566
  * Nothing else is touched. An action is a POST and must never be answered from
567
567
  * a cache; anything cross-origin is somebody else's to cache.
568
568
  */
569
- export const SERVICE_WORKER = (version, precache, frozen = []) => `// GENERATED by rscKit() — do not edit.
569
+ export const SERVICE_WORKER = (version, precache, frozen = [], offlineUrl = null, swExtra = null) => `// GENERATED by rscKit() — do not edit.
570
570
  const VERSION = ${JSON.stringify(version)}
571
571
  const CACHE = 'rsc-kit-' + VERSION
572
572
  const PRECACHE = ${JSON.stringify(precache, null, 2)}
@@ -581,12 +581,48 @@ const PRECACHE = ${JSON.stringify(precache, null, 2)}
581
581
  // cache would be wrong in a way the visitor cannot see.
582
582
  const FROZEN = new Set(${JSON.stringify(frozen)})
583
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
+
584
611
  self.addEventListener('install', (event) => {
585
612
  // The new worker takes over rather than waiting for every tab to close.
586
613
  // Safe here because assets are content-hashed: a page already open keeps
587
614
  // asking for the names it was built with, and those are still cached under
588
615
  // their own version until this activates and sweeps.
589
- 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
+ )
590
626
  })
591
627
 
592
628
  self.addEventListener('activate', (event) => {
@@ -594,10 +630,24 @@ self.addEventListener('activate', (event) => {
594
630
  caches
595
631
  .keys()
596
632
  .then((keys) => Promise.all(keys.filter((k) => k.startsWith('rsc-kit-') && k !== CACHE).map((k) => caches.delete(k))))
597
- .then(() => self.clients.claim()),
633
+ .then(() => self.clients.claim())
634
+ .then(() => tellTheOpenPages()),
598
635
  )
599
636
  })
600
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
+
601
651
  const immutable = (url) => url.pathname.startsWith('/assets/') || /-[A-Za-z0-9_-]{8,}\\.[a-z]+$/.test(url.pathname)
602
652
 
603
653
  // Whether a response may be kept at all.
@@ -741,12 +791,25 @@ self.addEventListener('fetch', (event) => {
741
791
 
742
792
  if (hit) return hit
743
793
 
744
- // Nothing cached for this url and no network to ask. Falling back to
745
- // the cached root was worse than failing: the document IS the page
746
- // here, so the visitor got the home page's markup under the address
747
- // they asked for, and it did not hydrate — a wrong page pretending to
748
- // be the right one. Letting it fail says what is true, and a page
749
- // 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.
750
813
  return Response.error()
751
814
  }),
752
815
  )
@@ -852,9 +915,59 @@ function writeWebManifest(clientDir, options) {
852
915
  else
853
916
  log(`manifest: ${options.name} is installable`);
854
917
  }
855
- function writeServiceWorker(clientDir, frozen = []) {
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) {
856
964
  if (!existsSync(clientDir))
857
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);
858
971
  const files = [];
859
972
  const walk = (dir, prefix) => {
860
973
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -872,8 +985,10 @@ function writeServiceWorker(clientDir, frozen = []) {
872
985
  walk(clientDir, '');
873
986
  const precache = ['/', ...files.map((file) => `/${file}`)].sort();
874
987
  const version = createHash('sha256').update(precache.join('\n')).digest('hex').slice(0, 12);
875
- writeFileSync(join(clientDir, 'sw.js'), SERVICE_WORKER(version, precache, frozen));
876
- 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' : ''));
877
992
  }
878
993
  /**
879
994
  * The rsc bundle the build just wrote, whatever it decided to call it.
@@ -1008,10 +1123,13 @@ ${legend(counted)}
1008
1123
  // and a 3xx is not `ok`. Saying it here as well means the list means what it
1009
1124
  // says, rather than being a wider list that happens to be filtered later.
1010
1125
  const guarded = new Set(manifest.routes.filter((r) => r.middleware?.length).map((r) => r.component));
1011
- return results
1012
- .filter((r) => r.type === 'frozen' && !guarded.has(r.component))
1013
- .filter((r) => existsSync(join(staticDir, pathKeyOf(r.url) + '.html')))
1014
- .map((r) => r.url);
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
+ };
1015
1133
  }
1016
1134
  /**
1017
1135
  * Weighs the javascript one stored page makes the browser download.
@@ -3781,7 +3899,7 @@ export function rscKit(options = {}) {
3781
3899
  const staticDir = clientOut
3782
3900
  ? join(dirname(clientOut), 'server', NITRO_STATIC_DIR)
3783
3901
  : join(outDir, NITRO_STATIC_DIR);
3784
- const frozen = await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir);
3902
+ const { frozen, results } = await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir);
3785
3903
  // Manifest first. The service worker precaches whatever it finds in this
3786
3904
  // directory, so writing it afterwards leaves it out of the list — and an
3787
3905
  // installed app whose manifest is the one file that needs the network is
@@ -3789,8 +3907,9 @@ export function rscKit(options = {}) {
3789
3907
  copyAppAssets(clientOut ?? publicAssetsDir);
3790
3908
  if (webManifestOptions)
3791
3909
  writeWebManifest(clientOut ?? publicAssetsDir, webManifestOptions);
3792
- if (offline)
3793
- writeServiceWorker(clientOut ?? publicAssetsDir, frozen);
3910
+ if (offline) {
3911
+ writeServiceWorker(clientOut ?? publicAssetsDir, frozen, offlineFallback(frozen, results));
3912
+ }
3794
3913
  },
3795
3914
  configResolved(config) {
3796
3915
  isWatch = config.build?.watch != null;