@rsc-kit/core 0.7.2 → 0.8.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/headers.d.ts +20 -0
- package/dist/headers.js +20 -0
- package/dist/headers.js.map +1 -1
- package/dist/host.d.ts +11 -0
- package/dist/host.js +60 -0
- package/dist/host.js.map +1 -1
- package/dist/js/SegmentBoundary.js +14 -7
- package/dist/js/SegmentBoundary.js.map +1 -1
- package/dist/js/createViteRscApp.js +21 -1
- package/dist/js/createViteRscApp.js.map +1 -1
- package/dist/js/navigate.d.ts +2 -1
- package/dist/js/navigate.js +40 -7
- package/dist/js/navigate.js.map +1 -1
- package/dist/js/queryClient.d.ts +26 -0
- package/dist/js/queryClient.js +173 -0
- package/dist/js/queryClient.js.map +1 -0
- package/dist/js/segmentStore.d.ts +41 -1
- package/dist/js/segmentStore.js +49 -3
- package/dist/js/segmentStore.js.map +1 -1
- package/dist/query.d.ts +36 -0
- package/dist/query.js +74 -0
- package/dist/query.js.map +1 -0
- package/dist/vite.d.ts +45 -0
- package/dist/vite.js +279 -1
- package/dist/vite.js.map +1 -1
- package/package.json +14 -5
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) {
|
|
@@ -519,6 +526,200 @@ function reportAllDynamic() {
|
|
|
519
526
|
|
|
520
527
|
${routes.length} dynamic — prerendering is off`);
|
|
521
528
|
}
|
|
529
|
+
/**
|
|
530
|
+
* The service worker, written into the client output at build time.
|
|
531
|
+
*
|
|
532
|
+
* Scoped to the site root because it is served from there, so it sees every
|
|
533
|
+
* navigation. Three kinds of request, three answers:
|
|
534
|
+
*
|
|
535
|
+
* Hashed assets are immutable by construction — the name changes when the
|
|
536
|
+
* bytes do — so they are answered from the cache and only fetched once ever.
|
|
537
|
+
*
|
|
538
|
+
* Documents and payloads go to the network first and fall back to the cache,
|
|
539
|
+
* because a page whose data moved on should say so while there is a network to
|
|
540
|
+
* ask. Only the fallback is what makes a reload work with none.
|
|
541
|
+
*
|
|
542
|
+
* A document and its payload share a url and differ by header. They do not
|
|
543
|
+
* collide, because the server sends `Vary: X-RSC, ...` and the Cache API
|
|
544
|
+
* honours it when a Response is stored whole and matched with its Request.
|
|
545
|
+
*
|
|
546
|
+
* Nothing else is touched. An action is a POST and must never be answered from
|
|
547
|
+
* a cache; anything cross-origin is somebody else's to cache.
|
|
548
|
+
*/
|
|
549
|
+
export const SERVICE_WORKER = (version, precache) => `// GENERATED by rscKit() — do not edit.
|
|
550
|
+
const VERSION = ${JSON.stringify(version)}
|
|
551
|
+
const CACHE = 'rsc-kit-' + VERSION
|
|
552
|
+
const PRECACHE = ${JSON.stringify(precache, null, 2)}
|
|
553
|
+
|
|
554
|
+
self.addEventListener('install', (event) => {
|
|
555
|
+
// The new worker takes over rather than waiting for every tab to close.
|
|
556
|
+
// Safe here because assets are content-hashed: a page already open keeps
|
|
557
|
+
// asking for the names it was built with, and those are still cached under
|
|
558
|
+
// their own version until this activates and sweeps.
|
|
559
|
+
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting()))
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
self.addEventListener('activate', (event) => {
|
|
563
|
+
event.waitUntil(
|
|
564
|
+
caches
|
|
565
|
+
.keys()
|
|
566
|
+
.then((keys) => Promise.all(keys.filter((k) => k.startsWith('rsc-kit-') && k !== CACHE).map((k) => caches.delete(k))))
|
|
567
|
+
.then(() => self.clients.claim()),
|
|
568
|
+
)
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
const immutable = (url) => url.pathname.startsWith('/assets/') || /-[A-Za-z0-9_-]{8,}\\.[a-z]+$/.test(url.pathname)
|
|
572
|
+
|
|
573
|
+
// Whether a response may be kept at all.
|
|
574
|
+
//
|
|
575
|
+
// \`no-store\` is not advice here, it is the answer. A query defaults to
|
|
576
|
+
// \`private, no-store\` precisely because it may read the session, and a worker
|
|
577
|
+
// that files it by url alone would serve one visitor's answer to whoever signs
|
|
578
|
+
// in next — the Cache API has no notion of who asked. A live response is worse
|
|
579
|
+
// again: the clone goes on streaming long after the page that opened it is
|
|
580
|
+
// gone.
|
|
581
|
+
//
|
|
582
|
+
// Checked before every put, rather than only for the paths that happen to reach
|
|
583
|
+
// a query today. A new cacheable route added later must not have to remember
|
|
584
|
+
// this.
|
|
585
|
+
const mayStore = (response) =>
|
|
586
|
+
response.ok && !(response.headers.get('Cache-Control') || '').includes('no-store')
|
|
587
|
+
|
|
588
|
+
// What a response is filed under. A payload request carries headers the url
|
|
589
|
+
// does not, so the url is where they have to go.
|
|
590
|
+
const keyFor = (request) => {
|
|
591
|
+
if (!request.headers.get('X-RSC')) return request
|
|
592
|
+
|
|
593
|
+
const url = new URL(request.url)
|
|
594
|
+
url.searchParams.set('__rsc', request.headers.get('X-RSC-Segments') || '')
|
|
595
|
+
|
|
596
|
+
return new Request(url, { headers: request.headers })
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
self.addEventListener('fetch', (event) => {
|
|
600
|
+
const request = event.request
|
|
601
|
+
const url = new URL(request.url)
|
|
602
|
+
|
|
603
|
+
if (request.method !== 'GET' || url.origin !== self.location.origin) return
|
|
604
|
+
|
|
605
|
+
if (immutable(url)) {
|
|
606
|
+
event.respondWith(
|
|
607
|
+
caches.match(request).then(
|
|
608
|
+
(hit) =>
|
|
609
|
+
hit ??
|
|
610
|
+
fetch(request).then((response) => {
|
|
611
|
+
if (mayStore(response)) {
|
|
612
|
+
const copy = response.clone()
|
|
613
|
+
caches.open(CACHE).then((cache) => cache.put(request, copy))
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return response
|
|
617
|
+
}),
|
|
618
|
+
),
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
event.respondWith(
|
|
625
|
+
fetch(request)
|
|
626
|
+
.then((response) => {
|
|
627
|
+
if (mayStore(response)) {
|
|
628
|
+
const copy = response.clone()
|
|
629
|
+
caches.open(CACHE).then((cache) => cache.put(keyFor(request), copy))
|
|
630
|
+
|
|
631
|
+
// A document is not enough to boot from. The client hydrates from a
|
|
632
|
+
// payload it fetches for itself, and on a first visit that request
|
|
633
|
+
// happens before this worker controls the page — so it is never
|
|
634
|
+
// cached, and a reload with no network has the markup and nothing to
|
|
635
|
+
// hydrate it with.
|
|
636
|
+
//
|
|
637
|
+
// \`X-RSC: true\` and no segments header is exactly what a fresh boot
|
|
638
|
+
// sends, which is what makes this entry the one it finds: the server
|
|
639
|
+
// varies on those, and the Cache API matches on the same.
|
|
640
|
+
if (request.mode === 'navigate') {
|
|
641
|
+
const warm = new Request(request.url, { headers: { 'X-RSC': 'true' } })
|
|
642
|
+
|
|
643
|
+
fetch(warm)
|
|
644
|
+
.then((payload) => {
|
|
645
|
+
if (mayStore(payload)) {
|
|
646
|
+
caches.open(CACHE).then((cache) => cache.put(keyFor(warm), payload))
|
|
647
|
+
}
|
|
648
|
+
})
|
|
649
|
+
.catch(() => {})
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// And the other way round. Moving between pages fetches payloads and
|
|
653
|
+
// never documents, so a page reached only by a link had nothing to
|
|
654
|
+
// serve when someone reloaded its url — it navigated fine and then
|
|
655
|
+
// died on refresh, which is the half of offline nobody would trust.
|
|
656
|
+
//
|
|
657
|
+
// Once per url: the document is fetched only when the cache has none,
|
|
658
|
+
// so this costs one extra request the first time a page is visited
|
|
659
|
+
// rather than one on every navigation to it.
|
|
660
|
+
if (request.headers.get('X-RSC')) {
|
|
661
|
+
const document = new Request(request.url)
|
|
662
|
+
|
|
663
|
+
caches.open(CACHE).then(async (cache) => {
|
|
664
|
+
if (await cache.match(document)) return
|
|
665
|
+
|
|
666
|
+
const fresh = await fetch(document).catch(() => null)
|
|
667
|
+
|
|
668
|
+
if (fresh && mayStore(fresh)) await cache.put(document, fresh)
|
|
669
|
+
})
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return response
|
|
674
|
+
})
|
|
675
|
+
.catch(async () => {
|
|
676
|
+
const hit = await caches.match(keyFor(request))
|
|
677
|
+
|
|
678
|
+
if (hit) return hit
|
|
679
|
+
|
|
680
|
+
// Nothing cached for this url and no network to ask. Falling back to
|
|
681
|
+
// the cached root was worse than failing: the document IS the page
|
|
682
|
+
// here, so the visitor got the home page's markup under the address
|
|
683
|
+
// they asked for, and it did not hydrate — a wrong page pretending to
|
|
684
|
+
// be the right one. Letting it fail says what is true, and a page
|
|
685
|
+
// already open is unaffected.
|
|
686
|
+
return Response.error()
|
|
687
|
+
}),
|
|
688
|
+
)
|
|
689
|
+
})
|
|
690
|
+
`;
|
|
691
|
+
/**
|
|
692
|
+
* Write the worker beside the assets it caches.
|
|
693
|
+
*
|
|
694
|
+
* The version is a hash of what is being precached rather than a build id from
|
|
695
|
+
* the environment. The names are content-hashed already, so a build that
|
|
696
|
+
* changed nothing produces the same list and leaves the visitor's cache alone,
|
|
697
|
+
* and a build that changed anything produces a different one — which is the
|
|
698
|
+
* whole of cache invalidation, without asking anyone to set a variable.
|
|
699
|
+
*/
|
|
700
|
+
function writeServiceWorker(clientDir) {
|
|
701
|
+
if (!existsSync(clientDir))
|
|
702
|
+
return;
|
|
703
|
+
const files = [];
|
|
704
|
+
const walk = (dir, prefix) => {
|
|
705
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
706
|
+
// Not itself, and not a map: a debugger asks for those, a visitor does
|
|
707
|
+
// not, and precaching them doubles what an install costs.
|
|
708
|
+
if (entry.name === 'sw.js' || entry.name.endsWith('.map'))
|
|
709
|
+
continue;
|
|
710
|
+
const path = join(dir, entry.name);
|
|
711
|
+
if (entry.isDirectory())
|
|
712
|
+
walk(path, `${prefix}${entry.name}/`);
|
|
713
|
+
else
|
|
714
|
+
files.push(`${prefix}${entry.name}`);
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
walk(clientDir, '');
|
|
718
|
+
const precache = ['/', ...files.map((file) => `/${file}`)].sort();
|
|
719
|
+
const version = createHash('sha256').update(precache.join('\n')).digest('hex').slice(0, 12);
|
|
720
|
+
writeFileSync(join(clientDir, 'sw.js'), SERVICE_WORKER(version, precache));
|
|
721
|
+
log(`offline: ${precache.length} files precached as rsc-kit-${version}`);
|
|
722
|
+
}
|
|
522
723
|
/**
|
|
523
724
|
* The rsc bundle the build just wrote, whatever it decided to call it.
|
|
524
725
|
*
|
|
@@ -990,6 +1191,7 @@ import { createRscHandler } from ${JSON.stringify(join(packageDir, "host"))}
|
|
|
990
1191
|
import { httpHostCalls } from ${JSON.stringify(join(packageDir, 'hostCalls'))}
|
|
991
1192
|
import { prerenderedBeside } from ${JSON.stringify(join(packageDir, 'files'))}
|
|
992
1193
|
import { renderToReadableStream, decodeReply, loadServerAction } from '@vitejs/plugin-rsc/rsc'
|
|
1194
|
+
import { isQuery, queryCacheControl } from ${JSON.stringify(join(packageDir, 'query'))}
|
|
993
1195
|
import { Suspense, createElement, Fragment } from 'react'
|
|
994
1196
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
995
1197
|
${imports.join('\n')}
|
|
@@ -1677,6 +1879,44 @@ async function renderRevalidated(target: string, page: PageContext): Promise<unk
|
|
|
1677
1879
|
})
|
|
1678
1880
|
}
|
|
1679
1881
|
|
|
1882
|
+
/**
|
|
1883
|
+
* Answer one read.
|
|
1884
|
+
*
|
|
1885
|
+
* Only functions declared with query() are reachable. The id comes off the url,
|
|
1886
|
+
* and every registered server action has one — without the mark, this address
|
|
1887
|
+
* would invoke mutations over GET, for anyone who can fetch it.
|
|
1888
|
+
*/
|
|
1889
|
+
export async function handleQuery(
|
|
1890
|
+
id: string,
|
|
1891
|
+
args: string,
|
|
1892
|
+
report?: (error: unknown) => string,
|
|
1893
|
+
): Promise<{ stream: ReadableStream; cacheControl: string } | null> {
|
|
1894
|
+
applyHost()
|
|
1895
|
+
|
|
1896
|
+
let fn: unknown
|
|
1897
|
+
|
|
1898
|
+
try {
|
|
1899
|
+
fn = await loadServerAction(id)
|
|
1900
|
+
} catch {
|
|
1901
|
+
return null
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// Null for both, deliberately. Saying "that exists but is not a query" tells
|
|
1905
|
+
// whoever is probing this endpoint which ids are real actions, and the ids
|
|
1906
|
+
// are stable across a build.
|
|
1907
|
+
if (!isQuery(fn)) return null
|
|
1908
|
+
|
|
1909
|
+
const decoded = (await decodeReply(args)) as unknown[]
|
|
1910
|
+
const result = await (fn as (...a: unknown[]) => unknown)(...decoded)
|
|
1911
|
+
|
|
1912
|
+
return {
|
|
1913
|
+
stream: renderToReadableStream(result, {
|
|
1914
|
+
onError: (error: unknown) => (report ? report(error) : 'Query failed.'),
|
|
1915
|
+
}),
|
|
1916
|
+
cacheControl: queryCacheControl(fn),
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1680
1920
|
export async function handleAction(
|
|
1681
1921
|
actionId: string,
|
|
1682
1922
|
body: string | FormData | Uint8Array,
|
|
@@ -1723,6 +1963,19 @@ export async function handleAction(
|
|
|
1723
1963
|
|
|
1724
1964
|
const args = (await decodeReply(decodable)) as unknown[]
|
|
1725
1965
|
const action = await loadServerAction(actionId)
|
|
1966
|
+
|
|
1967
|
+
// A query reached the ACTION endpoint, which means it was called directly
|
|
1968
|
+
// rather than through fetchQuery — so it went out as a POST and none of the
|
|
1969
|
+
// reasons it was marked a read apply to it. It still works, which is the
|
|
1970
|
+
// problem: nothing else would ever mention it. Only in development, and only
|
|
1971
|
+
// a warning, because the call is not wrong, just not what was asked for.
|
|
1972
|
+
if (isQuery(action) && import.meta.env?.DEV) {
|
|
1973
|
+
console.warn(
|
|
1974
|
+
'[rsc-kit] ' + actionId + ' is a query but was called directly, so it was sent as a POST. ' +
|
|
1975
|
+
'Call it through fetchQuery() to send a GET.',
|
|
1976
|
+
)
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1726
1979
|
const result = await (action as (...a: unknown[]) => unknown)(...args)
|
|
1727
1980
|
|
|
1728
1981
|
// Read after the action has run: what it invalidated is only known once its
|
|
@@ -2128,6 +2381,7 @@ export default async function handler(request: Request): Promise<Response> {
|
|
|
2128
2381
|
handleRscPprShell,
|
|
2129
2382
|
handleRscResume,
|
|
2130
2383
|
handleAction,
|
|
2384
|
+
handleQuery,
|
|
2131
2385
|
resolveMetadata,
|
|
2132
2386
|
runRouteMiddleware,
|
|
2133
2387
|
} as never,
|
|
@@ -2302,7 +2556,25 @@ if (import.meta.hot) {
|
|
|
2302
2556
|
void refresh('all')
|
|
2303
2557
|
})
|
|
2304
2558
|
}
|
|
2305
|
-
|
|
2559
|
+
${offline
|
|
2560
|
+
? `
|
|
2561
|
+
// Registered after load rather than during it, so fetching and installing the
|
|
2562
|
+
// worker competes with nothing the first visit actually needs. The second
|
|
2563
|
+
// visit is the one it is for.
|
|
2564
|
+
//
|
|
2565
|
+
// Not in development: the dev server is the thing you are editing, and a
|
|
2566
|
+
// worker answering from a cache in front of it turns every edit into a
|
|
2567
|
+
// question about which copy you are looking at.
|
|
2568
|
+
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
|
2569
|
+
window.addEventListener('load', () => {
|
|
2570
|
+
void navigator.serviceWorker.register('/sw.js').catch(() => {
|
|
2571
|
+
// A worker that will not register is not a reason for the page to fail.
|
|
2572
|
+
// The app works; it just will not survive being reloaded offline.
|
|
2573
|
+
})
|
|
2574
|
+
})
|
|
2575
|
+
}
|
|
2576
|
+
`
|
|
2577
|
+
: ''}`;
|
|
2306
2578
|
}
|
|
2307
2579
|
/**
|
|
2308
2580
|
* Intercepted URL patterns, published by the host before the build.
|
|
@@ -2510,6 +2782,10 @@ export function rscKit(options = {}) {
|
|
|
2510
2782
|
*/
|
|
2511
2783
|
define: {
|
|
2512
2784
|
'process.env.NODE_ENV': JSON.stringify(env.mode === 'development' ? 'development' : 'production'),
|
|
2785
|
+
// A constant, so the boundary and its import fall out of the bundle
|
|
2786
|
+
// entirely when this is off rather than shipping a branch nobody takes.
|
|
2787
|
+
__RSC_VIEW_TRANSITIONS__: JSON.stringify(viewTransitions),
|
|
2788
|
+
__RSC_OFFLINE__: JSON.stringify(offline),
|
|
2513
2789
|
},
|
|
2514
2790
|
/*
|
|
2515
2791
|
* This package's client modules are served as source, never
|
|
@@ -2730,6 +3006,8 @@ export function rscKit(options = {}) {
|
|
|
2730
3006
|
? join(dirname(clientOut), 'server', NITRO_STATIC_DIR)
|
|
2731
3007
|
: join(outDir, NITRO_STATIC_DIR);
|
|
2732
3008
|
await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir);
|
|
3009
|
+
if (offline)
|
|
3010
|
+
writeServiceWorker(clientOut ?? publicAssetsDir);
|
|
2733
3011
|
},
|
|
2734
3012
|
configResolved(config) {
|
|
2735
3013
|
isWatch = config.build?.watch != null;
|