@rsc-kit/core 0.20.13 → 0.21.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.
Files changed (56) hide show
  1. package/dist/apiPrerender.js +1 -1
  2. package/dist/apiPrerender.js.map +1 -1
  3. package/dist/appAssets.d.ts +6 -0
  4. package/dist/appAssets.js +34 -0
  5. package/dist/appAssets.js.map +1 -1
  6. package/dist/appleSplash.d.ts +10 -0
  7. package/dist/appleSplash.js +74 -0
  8. package/dist/appleSplash.js.map +1 -0
  9. package/dist/earlyHints.js +7 -2
  10. package/dist/earlyHints.js.map +1 -1
  11. package/dist/export.js +3 -1
  12. package/dist/export.js.map +1 -1
  13. package/dist/files.d.ts +0 -8
  14. package/dist/files.js +6 -0
  15. package/dist/files.js.map +1 -1
  16. package/dist/headers.d.ts +8 -0
  17. package/dist/headers.js +8 -0
  18. package/dist/headers.js.map +1 -1
  19. package/dist/host.d.ts +9 -0
  20. package/dist/host.js +109 -29
  21. package/dist/host.js.map +1 -1
  22. package/dist/js/Form.d.ts +47 -54
  23. package/dist/js/Form.js +41 -48
  24. package/dist/js/Form.js.map +1 -1
  25. package/dist/js/chunkPreload.d.ts +26 -0
  26. package/dist/js/chunkPreload.js +66 -0
  27. package/dist/js/chunkPreload.js.map +1 -0
  28. package/dist/js/installCapture.d.ts +15 -0
  29. package/dist/js/installCapture.js +17 -0
  30. package/dist/js/installCapture.js.map +1 -0
  31. package/dist/js/navigate.js +4 -0
  32. package/dist/js/navigate.js.map +1 -1
  33. package/dist/js/useInstall.d.ts +15 -0
  34. package/dist/js/useInstall.js +102 -0
  35. package/dist/js/useInstall.js.map +1 -0
  36. package/dist/manifest.d.ts +2 -0
  37. package/dist/manifest.js.map +1 -1
  38. package/dist/metadata.d.ts +31 -0
  39. package/dist/metadata.js.map +1 -1
  40. package/dist/prerender.d.ts +24 -5
  41. package/dist/prerender.js +107 -19
  42. package/dist/prerender.js.map +1 -1
  43. package/dist/request.js +3 -2
  44. package/dist/request.js.map +1 -1
  45. package/dist/shellHead.js +4 -2
  46. package/dist/shellHead.js.map +1 -1
  47. package/dist/stableNames.d.ts +52 -0
  48. package/dist/stableNames.js +91 -0
  49. package/dist/stableNames.js.map +1 -0
  50. package/dist/vite.d.ts +5 -3
  51. package/dist/vite.js +164 -10
  52. package/dist/vite.js.map +1 -1
  53. package/dist/webManifest.d.ts +37 -0
  54. package/dist/webManifest.js +51 -6
  55. package/dist/webManifest.js.map +1 -1
  56. package/package.json +8 -4
package/dist/vite.js CHANGED
@@ -20,7 +20,8 @@ import { spawnSync } from "node:child_process";
20
20
  import { basename, dirname, join, relative, resolve } from "node:path";
21
21
  import { fileURLToPath, pathToFileURL } from "node:url";
22
22
  import rsc, { getPluginApi } from "@vitejs/plugin-rsc";
23
- import { loadEnv } from "vite";
23
+ import { loadEnv, parseAst } from "vite";
24
+ import { stampDisplayNames } from "./stableNames.js";
24
25
  import { REPORT_FILE, buildReport } from "./buildReport.js";
25
26
  import { MANIFEST_PATH, manifestWarning, webManifest } from "./webManifest.js";
26
27
  import { ASSET_BASE, allAppAssets, appAssets, headTags, typeOf, } from "./appAssets.js";
@@ -192,6 +193,8 @@ let foundAssets = {
192
193
  favicon: null,
193
194
  icons: [],
194
195
  appleIcon: null,
196
+ appleSplash: [],
197
+ screenshots: [],
195
198
  openGraph: null,
196
199
  twitter: null,
197
200
  };
@@ -390,6 +393,16 @@ function resolvePaths(options) {
390
393
  icons: foundAssets.icons.map((icon) => icon.href),
391
394
  };
392
395
  }
396
+ // Screenshots the same way: found by name, their size and form factor read
397
+ // from it when the manifest is written.
398
+ if (webManifestOptions &&
399
+ !webManifestOptions.screenshots?.length &&
400
+ foundAssets.screenshots.length) {
401
+ webManifestOptions = {
402
+ ...webManifestOptions,
403
+ screenshots: foundAssets.screenshots.map((shot) => shot.href),
404
+ };
405
+ }
393
406
  hostActions = options.hostActions ?? fileHostActions(projectRoot);
394
407
  }
395
408
  function log(...args) {
@@ -620,6 +633,10 @@ function routeManifest() {
620
633
  // A synthesised robots.txt or sitemap.xml runs no guard: it exists to be
621
634
  // read by anyone, and a guard on the root would 401 the crawler.
622
635
  middleware: generated ? [] : ancestors(name, "middleware"),
636
+ // And the host's, for the same reason: `export const middleware =
637
+ // ['auth']` on a directory guards the route.ts files in it, or adding
638
+ // an endpoint under a guarded path opened a hole in it.
639
+ hostMiddleware: generated ? [] : hostMiddleware(dirOf(name)),
623
640
  })),
624
641
  };
625
642
  }
@@ -1222,6 +1239,15 @@ function copyAppAssets(clientDir) {
1222
1239
  copyFileSync(join(sourceDir, "app", asset.file), to);
1223
1240
  }
1224
1241
  log(`icons: ${all.length} copied from app/`);
1242
+ // A launch screen whose size no device has would be a media query that
1243
+ // matches nothing - served, linked from no page, shown to no one. Named
1244
+ // here, with the sizes that do exist a click away in the guide.
1245
+ const unmatched = foundAssets.appleSplash.filter((splash) => splash.media === null).map((splash) => splash.file);
1246
+ if (unmatched.length) {
1247
+ log(`apple-splash: ${unmatched.join(", ")} ${unmatched.length === 1 ? "matches" : "match"} no iPhone or iPad screen, ` +
1248
+ "so no page links to it. Name each file by the device's size in pixels, portrait or landscape - " +
1249
+ "apple-splash-1179x2556.png is an iPhone 15. The sizes are in the PWA guide.");
1250
+ }
1225
1251
  }
1226
1252
  function writeWebManifest(clientDir, options) {
1227
1253
  if (!existsSync(clientDir))
@@ -1230,6 +1256,9 @@ function writeWebManifest(clientDir, options) {
1230
1256
  const warning = manifestWarning(options);
1231
1257
  if (warning)
1232
1258
  log(warning);
1259
+ else if (!options.screenshots?.length)
1260
+ log(`manifest: ${options.name} is installable. Chrome shows its basic install sheet; ` +
1261
+ "a screenshot-wide-*.png and screenshot-narrow-*.png in app/ get the richer one.");
1233
1262
  else
1234
1263
  log(`manifest: ${options.name} is installable`);
1235
1264
  }
@@ -2265,6 +2294,18 @@ const FALLBACK_BODY = ` const answer = await devHandler(request)
2265
2294
  headers.set('x-forwarded-host', here.host)
2266
2295
  headers.set('x-forwarded-proto', here.protocol.replace(':', ''))
2267
2296
 
2297
+ // And never the visitor's own. The backend trusts this proxy, so an
2298
+ // X-Forwarded-For the browser wrote would be read as the client address -
2299
+ // a visitor choosing their own IP for a throttle on /login. Nothing here
2300
+ // knows the peer's address to put in its place, so the chain is dropped
2301
+ // unless this server sits behind a proxy of its own that wrote it, which
2302
+ // RSC_TRUST_FORWARDED=1 says.
2303
+ if (!(typeof process !== 'undefined' && process.env.RSC_TRUST_FORWARDED === '1')) {
2304
+ for (const name of ['x-forwarded-for', 'forwarded', 'x-real-ip', 'x-client-ip', 'x-forwarded-port', 'x-forwarded-prefix']) {
2305
+ headers.delete(name)
2306
+ }
2307
+ }
2308
+
2268
2309
  const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
2269
2310
 
2270
2311
  try {
@@ -2333,10 +2374,7 @@ const NITRO_PRERENDERED = ` prerendered: import.meta.env.PROD
2333
2374
  ? prerenderedBeside(import.meta.url, ${JSON.stringify(NITRO_STATIC_DIR)})
2334
2375
  : undefined,
2335
2376
  `;
2336
- const NITRO_HANDLER_OPTIONS = ` props: (match, request) => ({
2337
- ...match.params,
2338
- ...Object.fromEntries(new URL(request.url).searchParams),
2339
- }),
2377
+ const NITRO_HANDLER_OPTIONS = ` props: queryAndParams,
2340
2378
  // A built server gzips what it answers, where the runtime can; the dev
2341
2379
  // server answers raw, which is what a person reading a response wants.
2342
2380
  compress: import.meta.env.PROD,
@@ -2475,7 +2513,7 @@ import { parseParams, parseSearchParams, parseBody, isSearchParamsError, isBodyE
2475
2513
  import { notFoundDigest, isNotFoundSignal } from ${JSON.stringify(join(packageDir, "notFound"))}
2476
2514
  import { noteRequestRead, urlOf } from ${JSON.stringify(join(packageDir, "request"))}
2477
2515
  import { redirectDigest } from ${JSON.stringify(join(packageDir, "redirectDigest"))}
2478
- import { createRscHandler } from ${JSON.stringify(join(packageDir, "host"))}
2516
+ import { createRscHandler, queryAndParams } from ${JSON.stringify(join(packageDir, "host"))}
2479
2517
  import { httpHostCalls } from ${JSON.stringify(join(packageDir, "hostCalls"))}
2480
2518
  import { prerenderedBeside } from ${JSON.stringify(join(packageDir, "files"))}
2481
2519
  import { renderToReadableStream, decodeReply, decodeAction, decodeFormState, loadServerAction } from '@vitejs/plugin-rsc/rsc'
@@ -3512,11 +3550,47 @@ async function renderTree(
3512
3550
  }
3513
3551
  }
3514
3552
 
3553
+ // How the app behaves added to an iPhone's home screen - Next's shape.
3554
+ // Both capable names: Safari reads the apple- one, Chrome warns about it
3555
+ // and reads the plain one, and Next switched between them in a minor.
3556
+ if (md.appleWebApp != null && md.appleWebApp !== false) {
3557
+ const web = (md.appleWebApp === true ? { capable: true } : md.appleWebApp) as Record<string, unknown>
3558
+
3559
+ if (web.capable) {
3560
+ head.push(tag('mobile-web-app-capable', 'yes'))
3561
+ head.push(tag('apple-mobile-web-app-capable', 'yes'))
3562
+ }
3563
+
3564
+ if (web.title != null) head.push(tag('apple-mobile-web-app-title', web.title))
3565
+ if (web.statusBarStyle != null) head.push(tag('apple-mobile-web-app-status-bar-style', web.statusBarStyle))
3566
+
3567
+ if (web.startupImage != null) {
3568
+ const startup = Array.isArray(web.startupImage) ? web.startupImage : [web.startupImage]
3569
+
3570
+ for (const item of startup) {
3571
+ if (item == null) continue
3572
+
3573
+ const image = typeof item === 'object' && !(item instanceof URL)
3574
+ ? (item as Record<string, unknown>)
3575
+ : { url: item }
3576
+
3577
+ head.push(
3578
+ createElement('link', {
3579
+ key: '__s_' + String(image.url).slice(0, 60) + '_' + String(image.media ?? ''),
3580
+ rel: 'apple-touch-startup-image',
3581
+ href: absolute(image.url),
3582
+ ...(image.media ? { media: image.media } : {}),
3583
+ }),
3584
+ )
3585
+ }
3586
+ }
3587
+ }
3588
+
3515
3589
  // other is flattened in beside the named keys, because it is a place to put
3516
3590
  // meta tags rather than a meta tag by that name. A key at the top level
3517
3591
  // still renders - the type no longer invites one, but an app written
3518
3592
  // against the old shape must not silently lose its tags.
3519
- const structured = new Set(['title', 'description', 'robots', 'metadataBase', 'openGraph', 'twitter', 'icons', 'other'])
3593
+ const structured = new Set(['title', 'description', 'robots', 'metadataBase', 'openGraph', 'twitter', 'icons', 'appleWebApp', 'other'])
3520
3594
  const named = Object.entries(md).filter(([k]) => !structured.has(k))
3521
3595
  const extra = Object.entries((md.other ?? {}) as Record<string, unknown>)
3522
3596
 
@@ -3606,6 +3680,11 @@ async function runHostMiddleware(component) {
3606
3680
  for (const route of manifest().routes) {
3607
3681
  if (route.hostMiddleware?.length) hostChains[route.component] = route.hostMiddleware
3608
3682
  }
3683
+
3684
+ // Api routes, keyed by module name - the key runRouteMiddleware is asked with.
3685
+ for (const api of manifest().apis ?? []) {
3686
+ if (api.hostMiddleware?.length) hostChains[api.name] = api.hostMiddleware
3687
+ }
3609
3688
  }
3610
3689
 
3611
3690
  const names = hostChains[component] ?? []
@@ -4471,6 +4550,8 @@ export async function handleRscPayload(
4471
4550
  // data, so only the shell is safe to cache
4472
4551
  // A page that sets neither is genuinely static and can be prerendered fully.
4473
4552
  const PPR_SHELL_TIMEOUT_MS = Number(process.env.RSC_PPR_TIMEOUT_MS || 2000)
4553
+ // How long a render goes without a Flight row before it counts as waiting.
4554
+ const PPR_QUIET_MS = 50
4474
4555
 
4475
4556
  export async function handleRscPprShell(
4476
4557
  component: string,
@@ -4507,6 +4588,11 @@ export async function handleRscPprShell(
4507
4588
  // installed, because those are different statements: a test that installed
4508
4589
  // one is not a build that can reach the host.
4509
4590
  canReachHost = false,
4591
+ // Told once, when the render has stopped producing rows and is only
4592
+ // waiting. The budget still runs - a slow query can still land and be
4593
+ // stored - but the caller no longer has to hold a slot for a render that
4594
+ // is doing nothing but watching a clock.
4595
+ onQuiet?: () => void,
4510
4596
  ): Promise<{ shellHtml: string; clientChunks: unknown; timedOut: boolean; usedDynamicApis: boolean; error?: string }> {
4511
4597
  // Deliberately no middleware here. The probe is asking whether the content is
4512
4598
  // the same for everyone, which is a question about the page. Whether a
@@ -4535,6 +4621,35 @@ export async function handleRscPprShell(
4535
4621
  const controller = new AbortController()
4536
4622
  const budget = setTimeout(() => controller.abort(), budgetMs)
4537
4623
 
4624
+ // Quiet is a stretch with no Flight row: what is left is awaiting something,
4625
+ // and whether that something ever answers is what the budget decides. A
4626
+ // guess in one direction only - a render mistaken for quiet just overlaps
4627
+ // with the next, and is still given its whole budget.
4628
+ let quietTimer: ReturnType<typeof setTimeout> | undefined
4629
+ const stir = () => {
4630
+ if (!onQuiet) return
4631
+ clearTimeout(quietTimer)
4632
+ quietTimer = setTimeout(() => {
4633
+ const told = onQuiet
4634
+ onQuiet = undefined
4635
+ told?.()
4636
+ }, PPR_QUIET_MS)
4637
+ }
4638
+ const watchForQuiet = (stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> => {
4639
+ if (!onQuiet) return stream
4640
+
4641
+ stir()
4642
+
4643
+ return stream.pipeThrough(
4644
+ new TransformStream<Uint8Array, Uint8Array>({
4645
+ transform(chunk, out) {
4646
+ stir()
4647
+ out.enqueue(chunk)
4648
+ },
4649
+ }),
4650
+ )
4651
+ }
4652
+
4538
4653
  // Anything that failed while producing this shell.
4539
4654
  //
4540
4655
  // A rejection inside a Suspense boundary does NOT reach the caller: React
@@ -4616,7 +4731,7 @@ export async function handleRscPprShell(
4616
4731
  )
4617
4732
  // Quiet about a redirect: during the probe it is a classification, not
4618
4733
  // a failure, and React would otherwise print a stack for every one.
4619
- const flight = renderToReadableStream(tree, { onError: noteFailure })
4734
+ const flight = watchForQuiet(renderToReadableStream(tree, { onError: noteFailure }))
4620
4735
  const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
4621
4736
  // The flight stream stays open across this. A Flight stream that has
4622
4737
  // closed tells the decoder the connection ended, so the boundary waiting
@@ -4647,6 +4762,7 @@ export async function handleRscPprShell(
4647
4762
 
4648
4763
  await produce
4649
4764
  clearTimeout(budget)
4765
+ clearTimeout(quietTimer)
4650
4766
 
4651
4767
  // timedOut used to mean the stopwatch ran out. It now means React has
4652
4768
  // boundaries it could not finish — the thing the caller was always asking
@@ -4815,6 +4931,7 @@ function generateEntrySsr() {
4815
4931
  const redirectDigestModule = join(packageDir, "redirectDigest");
4816
4932
  const notFoundModule = join(packageDir, "notFound");
4817
4933
  const earlyClicks = join(packageDir, "js/earlyClicks");
4934
+ const installCapture = join(packageDir, "js/installCapture");
4818
4935
  return `// GENERATED by rscKit() — do not edit.
4819
4936
  import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
4820
4937
  import { renderToReadableStream, resume } from 'react-dom/server.edge'
@@ -4825,6 +4942,11 @@ import { cancelledByConsumer, caughtByLoading } from ${JSON.stringify(fallbackRe
4825
4942
  import { parseRedirectDigest } from ${JSON.stringify(redirectDigestModule)}
4826
4943
  import { isNotFoundDigest } from ${JSON.stringify(notFoundModule)}
4827
4944
  import { EARLY_CLICKS } from ${JSON.stringify(earlyClicks)}
4945
+ ${webManifestOptions ? `import { INSTALL_CAPTURE } from ${JSON.stringify(installCapture)}` : ""}
4946
+
4947
+ // What runs ahead of the runtime: held taps, and - in an app that can be
4948
+ // installed at all - the browser's offer to install it. See installCapture.ts.
4949
+ const EARLY = EARLY_CLICKS${webManifestOptions ? " + INSTALL_CAPTURE" : ""}
4828
4950
 
4829
4951
  // Set only by the dev server. @vitejs/plugin-rsc emits its bootstrap and CSS
4830
4952
  // links root-relative in dev, which would send the browser to the host for
@@ -4847,7 +4969,7 @@ export async function handleSsr(
4847
4969
  // seconds before the runtime hydrates is held for it rather than given
4848
4970
  // to the browser as a document load. See earlyClicks.ts.
4849
4971
  const bootstrapScriptContent = bootstrap
4850
- ? EARLY_CLICKS + (await (import.meta as any).viteRsc.loadBootstrapScriptContent('index'))
4972
+ ? EARLY + (await (import.meta as any).viteRsc.loadBootstrapScriptContent('index'))
4851
4973
  : undefined
4852
4974
 
4853
4975
  // Without an onError handler React rejects each abortable task on its own,
@@ -4889,7 +5011,7 @@ export async function handleSsrPrerender(
4889
5011
  const bootstrapScriptContent =
4890
5012
  options.bootstrap === false
4891
5013
  ? undefined
4892
- : EARLY_CLICKS + (await (import.meta as any).viteRsc.loadBootstrapScriptContent('index'))
5014
+ : EARLY + (await (import.meta as any).viteRsc.loadBootstrapScriptContent('index'))
4893
5015
 
4894
5016
  const { prelude, postponed } = await prerender(root as any, {
4895
5017
  bootstrapScriptContent,
@@ -5052,9 +5174,21 @@ function generateEntryBrowser() {
5052
5174
  }))
5053
5175
  : null;
5054
5176
  const refreshModule = join(packageDir, "js/navigate");
5177
+ const chunkPreload = join(packageDir, "js/chunkPreload");
5055
5178
  return `// GENERATED by rscKit() — do not edit.
5056
5179
  import { createViteRscApp } from ${JSON.stringify(clientBootstrap)}
5057
5180
  import { refresh } from ${JSON.stringify(refreshModule)}
5181
+ import { setChunkLoader } from ${JSON.stringify(chunkPreload)}
5182
+ import * as clientReferences from 'virtual:vite-rsc/client-references'
5183
+
5184
+ // How a client component's id becomes its chunk, for loading the ones a
5185
+ // prefetched page uses before the tap - see chunkPreload.ts. The same map the
5186
+ // runtime loads them through, so a chunk loaded here is the one it finds.
5187
+ // A production build only: in development the runtime imports by path.
5188
+ if (import.meta.env.PROD) {
5189
+ const references = (clientReferences as { default: Record<string, () => Promise<unknown>> }).default
5190
+ setChunkLoader((id) => references[id]?.())
5191
+ }
5058
5192
 
5059
5193
  createViteRscApp(document, ${JSON.stringify(interceptManifest())}, ${JSON.stringify({
5060
5194
  staticPayloads: staticPayloads || null,
@@ -5268,6 +5402,25 @@ const JSX_PLUGIN_PATTERN = /react|babel|oxc/i;
5268
5402
  * class is ever constructed on the server.
5269
5403
  */
5270
5404
  const CLIENT_STUB = /registerClientReference\(\s*\(\) => \{ throw new Error\("Unexpectedly client reference export '"/g;
5405
+ /**
5406
+ * The server's render of client components, with names a second bundler
5407
+ * cannot change. See stableNames.ts: a replay matches slots by component
5408
+ * name, and `bun build --compile` renames whatever collides when it merges
5409
+ * module scopes. Stamped on the ssr output as written, so the names are
5410
+ * the ones the prerender recorded - it renders from this same output - and
5411
+ * the ones anything serving it reads, compiled or not.
5412
+ */
5413
+ function stableComponentNames() {
5414
+ return {
5415
+ name: "rsc-kit:stable-component-names",
5416
+ apply: "build",
5417
+ applyToEnvironment: (environment) => environment.name === "ssr",
5418
+ renderChunk(code) {
5419
+ const stamped = stampDisplayNames(code, (source) => parseAst(source));
5420
+ return stamped === code ? null : { code: stamped, map: null };
5421
+ },
5422
+ };
5423
+ }
5271
5424
  function extendableClientReferences() {
5272
5425
  return {
5273
5426
  name: "rsc-kit:extendable-client-references",
@@ -6417,6 +6570,7 @@ export function rscKit(options = {}) {
6417
6570
  metadataRoutesPlugin(),
6418
6571
  openApiPlugin(),
6419
6572
  extendableClientReferences(),
6573
+ stableComponentNames(),
6420
6574
  typecheckPlugin(),
6421
6575
  clientImportsAudit(),
6422
6576
  routesPlugin,