@rsc-kit/core 0.7.2 → 0.9.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.d.ts CHANGED
@@ -4,6 +4,30 @@ export interface RscKitOptions {
4
4
  projectRoot?: string;
5
5
  /** Directory holding the app/ route tree. Defaults to `src`. */
6
6
  sourceDir?: string;
7
+ /**
8
+ * Animate navigations with React's `<ViewTransition>`.
9
+ *
10
+ * Off by default and a build-time constant rather than a runtime setting, so
11
+ * an app that does not ask for it does not carry the boundary at all.
12
+ *
13
+ * Needs react and react-dom at 19.3 or newer, where ViewTransition is
14
+ * stable. What it animates is the segment a navigation replaces; what a page
15
+ * does inside itself is the app's own business and needs no flag.
16
+ */
17
+ viewTransitions?: boolean;
18
+ /**
19
+ * Serve the app from a service worker, so it survives a reload with no
20
+ * network at all.
21
+ *
22
+ * Everything else this engine keeps — held pages, the prefetch cache — lives
23
+ * in one page's memory. Reload with no network and the browser shows its own
24
+ * error page: no script runs, so nothing cached in JavaScript is reachable.
25
+ * A worker is the only thing on the other side of that line.
26
+ *
27
+ * Off by default. It changes what a visitor sees when your deploy is broken,
28
+ * which is not a decision to make for someone.
29
+ */
30
+ offline?: boolean;
7
31
  /** Where the server bundles and generated entries go. Defaults to `.rsc`. */
8
32
  outDir?: string;
9
33
  /**
@@ -144,4 +168,25 @@ export interface RscKitOptions {
144
168
  */
145
169
  prerender?: boolean;
146
170
  }
171
+ /**
172
+ * The service worker, written into the client output at build time.
173
+ *
174
+ * Scoped to the site root because it is served from there, so it sees every
175
+ * navigation. Three kinds of request, three answers:
176
+ *
177
+ * Hashed assets are immutable by construction — the name changes when the
178
+ * bytes do — so they are answered from the cache and only fetched once ever.
179
+ *
180
+ * Documents and payloads go to the network first and fall back to the cache,
181
+ * because a page whose data moved on should say so while there is a network to
182
+ * ask. Only the fallback is what makes a reload work with none.
183
+ *
184
+ * A document and its payload share a url and differ by header. They do not
185
+ * collide, because the server sends `Vary: X-RSC, ...` and the Cache API
186
+ * honours it when a Response is stored whole and matched with its Request.
187
+ *
188
+ * Nothing else is touched. An action is a POST and must never be answered from
189
+ * a cache; anything cross-origin is somebody else's to cache.
190
+ */
191
+ export declare const SERVICE_WORKER: (version: string, precache: string[]) => string;
147
192
  export declare function rscKit(options?: RscKitOptions): PluginOption[];
package/dist/vite.js CHANGED
@@ -13,6 +13,7 @@
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
15
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
+ import { createHash } from 'node:crypto';
16
17
  import { createRequire } from 'node:module';
17
18
  import { dirname, join, relative, resolve } from 'node:path';
18
19
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -52,6 +53,10 @@ let routeConfig;
52
53
  let prerenderAfterBuild;
53
54
  /** True during `vite build --watch`, where re-rendering every route is noise. */
54
55
  let isWatch = false;
56
+ /** Whether navigations are wrapped in React's ViewTransition — see options. */
57
+ let viewTransitions = false;
58
+ /** Whether a service worker is generated and registered — see options. */
59
+ let offline = false;
55
60
  /** Host functions to generate stubs for — see RscKitOptions.hostActions. */
56
61
  let hostActions;
57
62
  /**
@@ -193,6 +198,8 @@ function resolvePaths(options) {
193
198
  // A host driving the build out of process cannot pass an option, and may
194
199
  // prerender itself afterwards with paths only it knows.
195
200
  prerenderAfterBuild = options.prerender ?? process.env.RSC_PRERENDER !== '0';
201
+ viewTransitions = options.viewTransitions === true;
202
+ offline = options.offline === true;
196
203
  hostActions = options.hostActions ?? fileHostActions(projectRoot);
197
204
  }
198
205
  function log(...args) {
@@ -406,10 +413,19 @@ function warnIfTypesUnreachable() {
406
413
  const include = JSON.parse(text).include;
407
414
  if (!Array.isArray(include))
408
415
  return;
409
- if (include.some((entry) => typeof entry === 'string' && entry.includes('.rsc-kit')))
410
- return;
411
- log(`tsconfig.json does not include .rsc-kit, where the generated types are written.\n` +
412
- ` Add ".rsc-kit/**/*" to "include", or typed routes and rpc() fall back to string.`);
416
+ const covers = (what) => include.some((entry) => typeof entry === 'string' && entry.includes(what));
417
+ if (!covers('.rsc-kit')) {
418
+ log(`tsconfig.json does not include .rsc-kit, where the generated types are written.\n` +
419
+ ` Add ".rsc-kit/**/*" to "include", or typed routes and rpc() fall back to string.`);
420
+ }
421
+ // Only when there is something there to check. An app with no api routes
422
+ // has nothing to be warned about, and a warning it cannot act on is one it
423
+ // learns to scroll past.
424
+ if (existsSync(join(projectRoot, 'server')) && !covers('server')) {
425
+ log(`tsconfig.json does not include server/, where your api handlers live.\n` +
426
+ ` Add "server/**/*" to "include", or they are not type-checked at all — ` +
427
+ `a handler returning the wrong shape builds and deploys without complaint.`);
428
+ }
413
429
  }
414
430
  catch {
415
431
  // An unparseable tsconfig is the project's own problem, not this one's.
@@ -444,10 +460,6 @@ function writeHostBindings(manifest) {
444
460
  // and a typecheck cannot see it. Written whether or not there are actions:
445
461
  // server components call it directly too.
446
462
  writeFileSync(join(typesDir, 'rsc-env.d.ts'), renderHostGlobalTypes());
447
- // The engine's own ambient types, copied where the app's typechecker will
448
- // see them. Deliberately a separate file from the one above: this one is
449
- // the engine's and identical everywhere, that one is generated from how
450
- // this host is configured.
451
463
  // The urls this build found, so a link to a page that does not exist fails
452
464
  // the typecheck instead of the browser.
453
465
  writeFileSync(join(typesDir, 'rsc-routes.d.ts'), renderRouteTypes(manifest));
@@ -456,10 +468,6 @@ function writeHostBindings(manifest) {
456
468
  // an app-authored one goes stale — the first version named only RscEngine,
457
469
  // which typechecks a server and fails a prerender script.
458
470
  writeFileSync(join(typesDir, 'rsc-engine.d.ts'), ENGINE_TYPES);
459
- const engineTypes = join(packageDir, 'types.d.ts');
460
- if (existsSync(engineTypes)) {
461
- writeFileSync(join(typesDir, 'rsc-types.d.ts'), readFileSync(engineTypes, 'utf-8'));
462
- }
463
471
  warnIfTypesUnreachable();
464
472
  const target = join(sourceDir, 'server-actions.generated.ts');
465
473
  // A host with no functions of its own leaves no file behind: kept, its
@@ -519,6 +527,200 @@ function reportAllDynamic() {
519
527
 
520
528
  ${routes.length} dynamic — prerendering is off`);
521
529
  }
530
+ /**
531
+ * The service worker, written into the client output at build time.
532
+ *
533
+ * Scoped to the site root because it is served from there, so it sees every
534
+ * navigation. Three kinds of request, three answers:
535
+ *
536
+ * Hashed assets are immutable by construction — the name changes when the
537
+ * bytes do — so they are answered from the cache and only fetched once ever.
538
+ *
539
+ * Documents and payloads go to the network first and fall back to the cache,
540
+ * because a page whose data moved on should say so while there is a network to
541
+ * ask. Only the fallback is what makes a reload work with none.
542
+ *
543
+ * A document and its payload share a url and differ by header. They do not
544
+ * collide, because the server sends `Vary: X-RSC, ...` and the Cache API
545
+ * honours it when a Response is stored whole and matched with its Request.
546
+ *
547
+ * Nothing else is touched. An action is a POST and must never be answered from
548
+ * a cache; anything cross-origin is somebody else's to cache.
549
+ */
550
+ export const SERVICE_WORKER = (version, precache) => `// GENERATED by rscKit() — do not edit.
551
+ const VERSION = ${JSON.stringify(version)}
552
+ const CACHE = 'rsc-kit-' + VERSION
553
+ const PRECACHE = ${JSON.stringify(precache, null, 2)}
554
+
555
+ self.addEventListener('install', (event) => {
556
+ // The new worker takes over rather than waiting for every tab to close.
557
+ // Safe here because assets are content-hashed: a page already open keeps
558
+ // asking for the names it was built with, and those are still cached under
559
+ // their own version until this activates and sweeps.
560
+ event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting()))
561
+ })
562
+
563
+ self.addEventListener('activate', (event) => {
564
+ event.waitUntil(
565
+ caches
566
+ .keys()
567
+ .then((keys) => Promise.all(keys.filter((k) => k.startsWith('rsc-kit-') && k !== CACHE).map((k) => caches.delete(k))))
568
+ .then(() => self.clients.claim()),
569
+ )
570
+ })
571
+
572
+ const immutable = (url) => url.pathname.startsWith('/assets/') || /-[A-Za-z0-9_-]{8,}\\.[a-z]+$/.test(url.pathname)
573
+
574
+ // Whether a response may be kept at all.
575
+ //
576
+ // \`no-store\` is not advice here, it is the answer. A query defaults to
577
+ // \`private, no-store\` precisely because it may read the session, and a worker
578
+ // that files it by url alone would serve one visitor's answer to whoever signs
579
+ // in next — the Cache API has no notion of who asked. A live response is worse
580
+ // again: the clone goes on streaming long after the page that opened it is
581
+ // gone.
582
+ //
583
+ // Checked before every put, rather than only for the paths that happen to reach
584
+ // a query today. A new cacheable route added later must not have to remember
585
+ // this.
586
+ const mayStore = (response) =>
587
+ response.ok && !(response.headers.get('Cache-Control') || '').includes('no-store')
588
+
589
+ // What a response is filed under. A payload request carries headers the url
590
+ // does not, so the url is where they have to go.
591
+ const keyFor = (request) => {
592
+ if (!request.headers.get('X-RSC')) return request
593
+
594
+ const url = new URL(request.url)
595
+ url.searchParams.set('__rsc', request.headers.get('X-RSC-Segments') || '')
596
+
597
+ return new Request(url, { headers: request.headers })
598
+ }
599
+
600
+ self.addEventListener('fetch', (event) => {
601
+ const request = event.request
602
+ const url = new URL(request.url)
603
+
604
+ if (request.method !== 'GET' || url.origin !== self.location.origin) return
605
+
606
+ if (immutable(url)) {
607
+ event.respondWith(
608
+ caches.match(request).then(
609
+ (hit) =>
610
+ hit ??
611
+ fetch(request).then((response) => {
612
+ if (mayStore(response)) {
613
+ const copy = response.clone()
614
+ caches.open(CACHE).then((cache) => cache.put(request, copy))
615
+ }
616
+
617
+ return response
618
+ }),
619
+ ),
620
+ )
621
+
622
+ return
623
+ }
624
+
625
+ event.respondWith(
626
+ fetch(request)
627
+ .then((response) => {
628
+ if (mayStore(response)) {
629
+ const copy = response.clone()
630
+ caches.open(CACHE).then((cache) => cache.put(keyFor(request), copy))
631
+
632
+ // A document is not enough to boot from. The client hydrates from a
633
+ // payload it fetches for itself, and on a first visit that request
634
+ // happens before this worker controls the page — so it is never
635
+ // cached, and a reload with no network has the markup and nothing to
636
+ // hydrate it with.
637
+ //
638
+ // \`X-RSC: true\` and no segments header is exactly what a fresh boot
639
+ // sends, which is what makes this entry the one it finds: the server
640
+ // varies on those, and the Cache API matches on the same.
641
+ if (request.mode === 'navigate') {
642
+ const warm = new Request(request.url, { headers: { 'X-RSC': 'true' } })
643
+
644
+ fetch(warm)
645
+ .then((payload) => {
646
+ if (mayStore(payload)) {
647
+ caches.open(CACHE).then((cache) => cache.put(keyFor(warm), payload))
648
+ }
649
+ })
650
+ .catch(() => {})
651
+ }
652
+
653
+ // And the other way round. Moving between pages fetches payloads and
654
+ // never documents, so a page reached only by a link had nothing to
655
+ // serve when someone reloaded its url — it navigated fine and then
656
+ // died on refresh, which is the half of offline nobody would trust.
657
+ //
658
+ // Once per url: the document is fetched only when the cache has none,
659
+ // so this costs one extra request the first time a page is visited
660
+ // rather than one on every navigation to it.
661
+ if (request.headers.get('X-RSC')) {
662
+ const document = new Request(request.url)
663
+
664
+ caches.open(CACHE).then(async (cache) => {
665
+ if (await cache.match(document)) return
666
+
667
+ const fresh = await fetch(document).catch(() => null)
668
+
669
+ if (fresh && mayStore(fresh)) await cache.put(document, fresh)
670
+ })
671
+ }
672
+ }
673
+
674
+ return response
675
+ })
676
+ .catch(async () => {
677
+ const hit = await caches.match(keyFor(request))
678
+
679
+ if (hit) return hit
680
+
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.
687
+ return Response.error()
688
+ }),
689
+ )
690
+ })
691
+ `;
692
+ /**
693
+ * Write the worker beside the assets it caches.
694
+ *
695
+ * The version is a hash of what is being precached rather than a build id from
696
+ * the environment. The names are content-hashed already, so a build that
697
+ * changed nothing produces the same list and leaves the visitor's cache alone,
698
+ * and a build that changed anything produces a different one — which is the
699
+ * whole of cache invalidation, without asking anyone to set a variable.
700
+ */
701
+ function writeServiceWorker(clientDir) {
702
+ if (!existsSync(clientDir))
703
+ return;
704
+ const files = [];
705
+ const walk = (dir, prefix) => {
706
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
707
+ // Not itself, and not a map: a debugger asks for those, a visitor does
708
+ // not, and precaching them doubles what an install costs.
709
+ if (entry.name === 'sw.js' || entry.name.endsWith('.map'))
710
+ continue;
711
+ const path = join(dir, entry.name);
712
+ if (entry.isDirectory())
713
+ walk(path, `${prefix}${entry.name}/`);
714
+ else
715
+ files.push(`${prefix}${entry.name}`);
716
+ }
717
+ };
718
+ walk(clientDir, '');
719
+ const precache = ['/', ...files.map((file) => `/${file}`)].sort();
720
+ 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}`);
723
+ }
522
724
  /**
523
725
  * The rsc bundle the build just wrote, whatever it decided to call it.
524
726
  *
@@ -990,6 +1192,7 @@ import { createRscHandler } from ${JSON.stringify(join(packageDir, "host"))}
990
1192
  import { httpHostCalls } from ${JSON.stringify(join(packageDir, 'hostCalls'))}
991
1193
  import { prerenderedBeside } from ${JSON.stringify(join(packageDir, 'files'))}
992
1194
  import { renderToReadableStream, decodeReply, loadServerAction } from '@vitejs/plugin-rsc/rsc'
1195
+ import { isQuery, queryCacheControl } from ${JSON.stringify(join(packageDir, 'query'))}
993
1196
  import { Suspense, createElement, Fragment } from 'react'
994
1197
  import { AsyncLocalStorage } from 'node:async_hooks'
995
1198
  ${imports.join('\n')}
@@ -1290,7 +1493,14 @@ async function renderTree(
1290
1493
  if (bootstrap) head.push(createElement(DocumentTitle, { key: '__ts', title: String(md.title) }))
1291
1494
  }
1292
1495
  if (md.description != null) head.push(createElement('meta', { key: '__d', name: 'description', content: String(md.description) }))
1293
- for (const [k, v] of Object.entries(md)) {
1496
+ // other is flattened in beside the named keys, because it is a place to put
1497
+ // meta tags rather than a meta tag by that name. A key at the top level
1498
+ // still renders — the type no longer invites one, but an app written
1499
+ // against the old shape must not silently lose its tags.
1500
+ const named = Object.entries(md).filter(([k]) => k !== 'other')
1501
+ const extra = Object.entries((md.other ?? {}) as Record<string, unknown>)
1502
+
1503
+ for (const [k, v] of [...named, ...extra]) {
1294
1504
  if (k === 'title' || k === 'description' || v == null) continue
1295
1505
  head.push(createElement('meta', { key: '__m_' + k, name: k, content: String(v) }))
1296
1506
  }
@@ -1677,6 +1887,44 @@ async function renderRevalidated(target: string, page: PageContext): Promise<unk
1677
1887
  })
1678
1888
  }
1679
1889
 
1890
+ /**
1891
+ * Answer one read.
1892
+ *
1893
+ * Only functions declared with query() are reachable. The id comes off the url,
1894
+ * and every registered server action has one — without the mark, this address
1895
+ * would invoke mutations over GET, for anyone who can fetch it.
1896
+ */
1897
+ export async function handleQuery(
1898
+ id: string,
1899
+ args: string,
1900
+ report?: (error: unknown) => string,
1901
+ ): Promise<{ stream: ReadableStream; cacheControl: string } | null> {
1902
+ applyHost()
1903
+
1904
+ let fn: unknown
1905
+
1906
+ try {
1907
+ fn = await loadServerAction(id)
1908
+ } catch {
1909
+ return null
1910
+ }
1911
+
1912
+ // Null for both, deliberately. Saying "that exists but is not a query" tells
1913
+ // whoever is probing this endpoint which ids are real actions, and the ids
1914
+ // are stable across a build.
1915
+ if (!isQuery(fn)) return null
1916
+
1917
+ const decoded = (await decodeReply(args)) as unknown[]
1918
+ const result = await (fn as (...a: unknown[]) => unknown)(...decoded)
1919
+
1920
+ return {
1921
+ stream: renderToReadableStream(result, {
1922
+ onError: (error: unknown) => (report ? report(error) : 'Query failed.'),
1923
+ }),
1924
+ cacheControl: queryCacheControl(fn),
1925
+ }
1926
+ }
1927
+
1680
1928
  export async function handleAction(
1681
1929
  actionId: string,
1682
1930
  body: string | FormData | Uint8Array,
@@ -1723,6 +1971,19 @@ export async function handleAction(
1723
1971
 
1724
1972
  const args = (await decodeReply(decodable)) as unknown[]
1725
1973
  const action = await loadServerAction(actionId)
1974
+
1975
+ // A query reached the ACTION endpoint, which means it was called directly
1976
+ // rather than through fetchQuery — so it went out as a POST and none of the
1977
+ // reasons it was marked a read apply to it. It still works, which is the
1978
+ // problem: nothing else would ever mention it. Only in development, and only
1979
+ // a warning, because the call is not wrong, just not what was asked for.
1980
+ if (isQuery(action) && import.meta.env?.DEV) {
1981
+ console.warn(
1982
+ '[rsc-kit] ' + actionId + ' is a query but was called directly, so it was sent as a POST. ' +
1983
+ 'Call it through fetchQuery() to send a GET.',
1984
+ )
1985
+ }
1986
+
1726
1987
  const result = await (action as (...a: unknown[]) => unknown)(...args)
1727
1988
 
1728
1989
  // Read after the action has run: what it invalidated is only known once its
@@ -1764,12 +2025,31 @@ export async function resolveMetadata(
1764
2025
  : {}
1765
2026
 
1766
2027
  // Non-title metadata: layout defaults (outer→inner), page overrides.
2028
+ //
2029
+ // other merges per key rather than being replaced, so a page adding one
2030
+ // custom tag keeps the ones its layout set. Assigning it like any other key
2031
+ // would mean a root layout's theme-color disappearing from every page that
2032
+ // happened to declare one of its own.
1767
2033
  const merged: Record<string, unknown> = {}
2034
+ const other: Record<string, unknown> = {}
2035
+
2036
+ const take = (from: Record<string, unknown>) => {
2037
+ for (const [k, v] of Object.entries(from)) {
2038
+ if (k === 'title') continue
2039
+ if (k === 'other') Object.assign(other, v as Record<string, unknown>)
2040
+ else merged[k] = v
2041
+ }
2042
+ }
2043
+
1768
2044
  for (const l of layouts) {
1769
2045
  const s = metadataMap[l.component]?.static
1770
- if (s) for (const [k, v] of Object.entries(s)) if (k !== 'title') merged[k] = v
2046
+
2047
+ if (s) take(s as Record<string, unknown>)
1771
2048
  }
1772
- for (const [k, v] of Object.entries(page)) if (k !== 'title') merged[k] = v
2049
+
2050
+ take(page)
2051
+
2052
+ if (Object.keys(other).length > 0) merged.other = other
1773
2053
 
1774
2054
  // Title: the page title with the NEAREST layout title.template applied; if the
1775
2055
  // page has no title, the nearest layout default/string title.
@@ -2128,6 +2408,7 @@ export default async function handler(request: Request): Promise<Response> {
2128
2408
  handleRscPprShell,
2129
2409
  handleRscResume,
2130
2410
  handleAction,
2411
+ handleQuery,
2131
2412
  resolveMetadata,
2132
2413
  runRouteMiddleware,
2133
2414
  } as never,
@@ -2302,7 +2583,25 @@ if (import.meta.hot) {
2302
2583
  void refresh('all')
2303
2584
  })
2304
2585
  }
2305
- `;
2586
+ ${offline
2587
+ ? `
2588
+ // Registered after load rather than during it, so fetching and installing the
2589
+ // worker competes with nothing the first visit actually needs. The second
2590
+ // visit is the one it is for.
2591
+ //
2592
+ // Not in development: the dev server is the thing you are editing, and a
2593
+ // worker answering from a cache in front of it turns every edit into a
2594
+ // question about which copy you are looking at.
2595
+ if ('serviceWorker' in navigator && import.meta.env.PROD) {
2596
+ window.addEventListener('load', () => {
2597
+ void navigator.serviceWorker.register('/sw.js').catch(() => {
2598
+ // A worker that will not register is not a reason for the page to fail.
2599
+ // The app works; it just will not survive being reloaded offline.
2600
+ })
2601
+ })
2602
+ }
2603
+ `
2604
+ : ''}`;
2306
2605
  }
2307
2606
  /**
2308
2607
  * Intercepted URL patterns, published by the host before the build.
@@ -2510,6 +2809,10 @@ export function rscKit(options = {}) {
2510
2809
  */
2511
2810
  define: {
2512
2811
  'process.env.NODE_ENV': JSON.stringify(env.mode === 'development' ? 'development' : 'production'),
2812
+ // A constant, so the boundary and its import fall out of the bundle
2813
+ // entirely when this is off rather than shipping a branch nobody takes.
2814
+ __RSC_VIEW_TRANSITIONS__: JSON.stringify(viewTransitions),
2815
+ __RSC_OFFLINE__: JSON.stringify(offline),
2513
2816
  },
2514
2817
  /*
2515
2818
  * This package's client modules are served as source, never
@@ -2730,6 +3033,8 @@ export function rscKit(options = {}) {
2730
3033
  ? join(dirname(clientOut), 'server', NITRO_STATIC_DIR)
2731
3034
  : join(outDir, NITRO_STATIC_DIR);
2732
3035
  await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir);
3036
+ if (offline)
3037
+ writeServiceWorker(clientOut ?? publicAssetsDir);
2733
3038
  },
2734
3039
  configResolved(config) {
2735
3040
  isWatch = config.build?.watch != null;