@rsc-kit/core 0.21.2 → 0.21.3

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.d.ts CHANGED
@@ -29,8 +29,10 @@ export interface RscKitOptions {
29
29
  * tags, responses }`; the document says its own with `info`, `servers`,
30
30
  * `security`. An api route the build stores, without middleware.
31
31
  *
32
- * The page that reads it is Scalar's: `export const GET = ApiReference({
33
- * url: '/openapi.json' })` from @scalar/nextjs-api-reference in a route.ts.
32
+ * The page that reads it is Scalar's: `renderApiReference({ config: { url:
33
+ * '/openapi.json' } })` from @scalar/client-side-rendering, returned as an
34
+ * HTML Response from a route.ts. Not @scalar/nextjs-api-reference, whose
35
+ * peer dependency installs all of Next.js.
34
36
  */
35
37
  openapi?: boolean | {
36
38
  path?: string;
@@ -347,6 +349,20 @@ export declare function offlineFallback(frozen: string[], results: PrerenderResu
347
349
  * browser ever renders.
348
350
  */
349
351
  export declare function bootsTheApp(file: string): boolean;
352
+ /**
353
+ * A service worker's version: a hash of everything it serves cache-first.
354
+ *
355
+ * It used to be the precache list alone - the hashed file names - on the
356
+ * reasoning that a deploy changes them. A deploy that changes a stored page
357
+ * and no client script does not: the list was the same, so sw.js was byte
358
+ * for byte the same, the browser saw no update, and the worker went on
359
+ * serving the previous build's page from its cache, cache-first, because "a
360
+ * deploy changes VERSION and sweeps this cache". Reproduced: deploy build B
361
+ * over A, reload, and the page is A; only the load after the background
362
+ * refresh shows B. So the stored pages' bytes are in it too, and a deploy
363
+ * that changes what the worker holds is a new worker.
364
+ */
365
+ export declare function workerVersion(precache: string[], storedDir: string | null): string;
350
366
  export declare function smallStylesheetReader(assetsDir: string, setting: "auto" | true | number): (href: string) => string | null;
351
367
  /** A server action the bundle registered: where it is and what it is called. */
352
368
  export interface KnownAction {
package/dist/vite.js CHANGED
@@ -849,6 +849,43 @@ const INSTALL_FETCH_MS = 30000
849
849
  const within = (ms, promise) =>
850
850
  Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error('no answer in ' + ms + ' ms')), ms))])
851
851
 
852
+ // The same limit, and the download stopped when it is reached. A race only
853
+ // stops the waiting: the fetch it gave up on went on downloading beside
854
+ // everything after it.
855
+ const abortable = (ms, start) => {
856
+ const controller = new AbortController()
857
+ const timer = setTimeout(() => controller.abort(), ms)
858
+
859
+ return within(ms, start(controller.signal)).finally(() => clearTimeout(timer))
860
+ }
861
+
862
+ // How many precache downloads run together. The page a visitor is looking at
863
+ // comes first; the precache is for the next visit.
864
+ const PRECACHE_AT_ONCE = 4
865
+
866
+ // Run a task over every item, at most limit of them at a time. Resolves once
867
+ // all have settled; a task that fails is its own business.
868
+ const pool = (items, limit, task) =>
869
+ new Promise((resolve) => {
870
+ let next = 0
871
+ let running = 0
872
+ const launch = () => {
873
+ if (next >= items.length && running === 0) return resolve()
874
+
875
+ while (running < limit && next < items.length) {
876
+ running++
877
+ Promise.resolve(task(items[next++]))
878
+ .catch(() => {})
879
+ .finally(() => {
880
+ running--
881
+ launch()
882
+ })
883
+ }
884
+ }
885
+
886
+ launch()
887
+ })
888
+
852
889
  self.addEventListener('install', (event) => {
853
890
  // The new worker takes over rather than waiting for every tab to close.
854
891
  // Safe here because assets are content-hashed: a page already open keeps
@@ -872,13 +909,23 @@ self.addEventListener('install', (event) => {
872
909
  // lands. The browser checks /sw.js for itself, without the page's
873
910
  // javascript, so an install that cannot fail is also what rescues a
874
911
  // visitor stuck on a worker far older than this one.
912
+ //
913
+ // A few at a time and at low priority, never all at once. An install
914
+ // runs beside the page that triggered it - the first visit after a
915
+ // deploy - and a hundred and seventy files requested together queued
916
+ // that page's own requests behind them: on a phone, the page sat
917
+ // unhydrated for about forty seconds, until the precache had finished.
875
918
  .then((cache) =>
876
- Promise.all(
877
- (OFFLINE_URL ? [...PRECACHE, OFFLINE_URL] : PRECACHE).map((url) =>
878
- within(INSTALL_FETCH_MS, cache.add(url)).catch((error) => {
879
- console.warn('[rsc-kit] offline: ' + url + ' was not precached (' + (error && error.message || error) + '); it will be cached the first time it is fetched')
919
+ pool(OFFLINE_URL ? [...PRECACHE, OFFLINE_URL] : PRECACHE, PRECACHE_AT_ONCE, (url) =>
920
+ abortable(INSTALL_FETCH_MS, (signal) =>
921
+ fetch(url, { priority: 'low', signal }).then((response) => {
922
+ if (!response.ok) throw new Error('status ' + response.status)
923
+
924
+ return cache.put(url, response)
880
925
  }),
881
- ),
926
+ ).catch((error) => {
927
+ console.warn('[rsc-kit] offline: ' + url + ' was not precached (' + (error && error.message || error) + '); it will be cached the first time it is fetched')
928
+ }),
882
929
  ).then(() => cache),
883
930
  )
884
931
  // And the payload each precached page boots from. A document alone is
@@ -1032,6 +1079,10 @@ const standIn = async (request) => {
1032
1079
  // the ?__rsc=<segments> above is the one header the answer differs on.
1033
1080
  const MATCH = { ignoreVary: true }
1034
1081
 
1082
+ // The documents being fetched to go with a payload, by url - so the payloads
1083
+ // of one page, asked for at several depths, fetch its document once.
1084
+ const documentsInFlight = new Set()
1085
+
1035
1086
  self.addEventListener('fetch', (event) => {
1036
1087
  const request = event.request
1037
1088
  const url = new URL(request.url)
@@ -1109,26 +1160,14 @@ self.addEventListener('fetch', (event) => {
1109
1160
  const copy = response.clone()
1110
1161
  caches.open(CACHE).then((cache) => cache.put(keyFor(request), copy))
1111
1162
 
1112
- // A document is not enough to boot from. The client hydrates from a
1113
- // payload it fetches for itself, and on a first visit that request
1114
- // happens before this worker controls the page — so it is never
1115
- // cached, and a reload with no network has the markup and nothing to
1116
- // hydrate it with.
1117
- //
1118
- // \`X-RSC: 1\` and no segments header is exactly what a fresh boot
1119
- // sends, which is what makes this entry the one it finds: the server
1120
- // varies on those, and the Cache API matches on the same.
1121
- if (request.mode === 'navigate') {
1122
- const warm = new Request(request.url, { headers: { 'X-RSC': '1' } })
1123
-
1124
- fetch(warm)
1125
- .then((payload) => {
1126
- if (mayStore(payload)) {
1127
- caches.open(CACHE).then((cache) => cache.put(keyFor(warm), payload))
1128
- }
1129
- })
1130
- .catch(() => {})
1131
- }
1163
+ // No second fetch of the page's payload here. There used to be one,
1164
+ // for offline: a document alone does not boot. But whenever this
1165
+ // worker handles a page load, the page's own boot request - the
1166
+ // same url, X-RSC: 1, no segments - comes through this worker too
1167
+ // and is cached just above. The one time it does not is a first
1168
+ // visit, before the worker controls the page, and then it did not
1169
+ // handle the page load either. So it was a duplicate request on
1170
+ // every page load, at the moment the visitor was waiting.
1132
1171
 
1133
1172
  // And the other way round. Moving between pages fetches payloads and
1134
1173
  // never documents, so a page reached only by a link had nothing to
@@ -1138,16 +1177,20 @@ self.addEventListener('fetch', (event) => {
1138
1177
  // Once per url: the document is fetched only when the cache has none,
1139
1178
  // so this costs one extra request the first time a page is visited
1140
1179
  // rather than one on every navigation to it.
1141
- if (request.headers.get('X-RSC')) {
1180
+ if (request.headers.get('X-RSC') && !documentsInFlight.has(request.url)) {
1142
1181
  const document = new Request(request.url)
1143
1182
 
1144
- caches.open(CACHE).then(async (cache) => {
1145
- if (await cache.match(document)) return
1183
+ documentsInFlight.add(request.url)
1184
+ caches
1185
+ .open(CACHE)
1186
+ .then(async (cache) => {
1187
+ if (await cache.match(document)) return
1146
1188
 
1147
- const fresh = await fetch(document).catch(() => null)
1189
+ const fresh = await fetch(document).catch(() => null)
1148
1190
 
1149
- if (fresh && mayStore(fresh)) await cache.put(document, fresh)
1150
- })
1191
+ if (fresh && mayStore(fresh)) await cache.put(document, fresh)
1192
+ })
1193
+ .finally(() => documentsInFlight.delete(request.url))
1151
1194
  }
1152
1195
  }
1153
1196
 
@@ -1359,7 +1402,45 @@ export function bootsTheApp(file) {
1359
1402
  return true;
1360
1403
  return false;
1361
1404
  }
1362
- function writeServiceWorker(clientDir, frozen = [], offlineUrl = null) {
1405
+ /**
1406
+ * A service worker's version: a hash of everything it serves cache-first.
1407
+ *
1408
+ * It used to be the precache list alone - the hashed file names - on the
1409
+ * reasoning that a deploy changes them. A deploy that changes a stored page
1410
+ * and no client script does not: the list was the same, so sw.js was byte
1411
+ * for byte the same, the browser saw no update, and the worker went on
1412
+ * serving the previous build's page from its cache, cache-first, because "a
1413
+ * deploy changes VERSION and sweeps this cache". Reproduced: deploy build B
1414
+ * over A, reload, and the page is A; only the load after the background
1415
+ * refresh shows B. So the stored pages' bytes are in it too, and a deploy
1416
+ * that changes what the worker holds is a new worker.
1417
+ */
1418
+ export function workerVersion(precache, storedDir) {
1419
+ const version = createHash("sha256");
1420
+ version.update(precache.join("\n"));
1421
+ if (storedDir && existsSync(storedDir)) {
1422
+ const stored = [];
1423
+ const walkStored = (dir) => {
1424
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1425
+ const path = join(dir, entry.name);
1426
+ if (entry.isDirectory())
1427
+ walkStored(path);
1428
+ else
1429
+ stored.push(path);
1430
+ }
1431
+ };
1432
+ walkStored(storedDir);
1433
+ for (const path of stored.sort()) {
1434
+ version.update("\n" + relative(storedDir, path) + "\n");
1435
+ version.update(readFileSync(path));
1436
+ }
1437
+ }
1438
+ return version.digest("hex").slice(0, 12);
1439
+ }
1440
+ function writeServiceWorker(clientDir, frozen = [], offlineUrl = null,
1441
+ // The pages the build stored, which the worker serves cache-first. Their
1442
+ // bytes are part of what a version is - see below.
1443
+ storedDir = null) {
1363
1444
  if (!existsSync(clientDir))
1364
1445
  return;
1365
1446
  // Before the walk, so it lands in the precache with everything else. The
@@ -1383,12 +1464,9 @@ function writeServiceWorker(clientDir, frozen = [], offlineUrl = null) {
1383
1464
  };
1384
1465
  walk(clientDir, "");
1385
1466
  const precache = ["/", ...files.map((file) => `/${file}`)].sort();
1386
- const version = createHash("sha256")
1387
- .update(precache.join("\n"))
1388
- .digest("hex")
1389
- .slice(0, 12);
1390
- writeFileSync(join(clientDir, "sw.js"), SERVICE_WORKER(version, precache, frozen, offlineUrl, extra));
1391
- log(`offline: ${precache.length} files precached as rsc-kit-${version}` +
1467
+ const versionHash = workerVersion(precache, storedDir);
1468
+ writeFileSync(join(clientDir, "sw.js"), SERVICE_WORKER(versionHash, precache, frozen, offlineUrl, extra));
1469
+ log(`offline: ${precache.length} files precached as rsc-kit-${versionHash}` +
1392
1470
  (offlineUrl ? `, falling back to ${offlineUrl}` : "") +
1393
1471
  (extra ? ", with app/sw.js" : ""));
1394
1472
  }
@@ -6526,7 +6604,7 @@ export function rscKit(options = {}) {
6526
6604
  if (webManifestOptions)
6527
6605
  writeWebManifest(clientOut ?? publicAssetsDir, webManifestOptions);
6528
6606
  if (offline) {
6529
- writeServiceWorker(clientOut ?? publicAssetsDir, frozen, offlineFallback(frozen, results));
6607
+ writeServiceWorker(clientOut ?? publicAssetsDir, frozen, offlineFallback(frozen, results), staticDir);
6530
6608
  }
6531
6609
  },
6532
6610
  configResolved(config) {