@rsc-kit/core 0.20.1 → 0.20.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.js CHANGED
@@ -32,7 +32,8 @@ import { ownHosts } from "./hostRouting.js";
32
32
  import { unrollBarrelImports } from "./barrelImports.js";
33
33
  import { serverRendererMessage, SERVER_RENDERER, ssrProxyModule, UseSsrError, withoutSsrDirective } from "./useSsr.js";
34
34
  import { httpHostCalls } from "./hostCalls.js";
35
- import { clientPackages, importsServerRenderer, packageDir as installedPackageDir, packageEntryInGraph } from "./clientPackages.js";
35
+ import { bytecodeNote } from "./bytecodeCheck.js";
36
+ import { clientPackages, importsServerRenderer, packageDir as installedPackageDir, packageEntryInGraph, polyfillsToTrace, traceableByName } from "./clientPackages.js";
36
37
  // Resolved once per rscKit() call. One build runs in one process, so these are
37
38
  // module state rather than threaded through every helper.
38
39
  let projectRoot;
@@ -2092,6 +2093,11 @@ function metadataExports(absPath) {
2092
2093
  return {
2093
2094
  static: /export\s+const\s+metadata\b/.test(src),
2094
2095
  generate: /export\s+(async\s+)?function\s+generateMetadata\b/.test(src),
2096
+ viewport: /export\s+const\s+viewport\b/.test(src),
2097
+ writes: {
2098
+ charset: /\bcharSet\s*=/.test(src),
2099
+ viewport: /name\s*=\s*["']viewport["']/.test(src),
2100
+ },
2095
2101
  };
2096
2102
  }
2097
2103
  /**
@@ -2258,7 +2264,6 @@ const NITRO_HANDLER_OPTIONS = ` props: (match, request) => ({
2258
2264
  ...match.params,
2259
2265
  ...Object.fromEntries(new URL(request.url).searchParams),
2260
2266
  }),
2261
- version: process.env.RSC_BUILD_VERSION,
2262
2267
  // A built server gzips what it answers, where the runtime can; the dev
2263
2268
  // server answers raw, which is what a person reading a response wants.
2264
2269
  compress: import.meta.env.PROD,
@@ -2340,11 +2345,15 @@ function generateEntryRsc(fallbackOrigin = "") {
2340
2345
  imports.push(`import ${c.alias} from ${JSON.stringify(c.absPath)}`);
2341
2346
  mapEntries.push(` ${JSON.stringify(c.name)}: ${c.alias},`);
2342
2347
  const meta = metadataExports(c.absPath);
2343
- if (meta.static || meta.generate) {
2344
- imports.push(`import * as ${c.alias}_meta from ${JSON.stringify(c.absPath)}`);
2348
+ if (meta.static || meta.generate || meta.viewport || meta.writes.charset || meta.writes.viewport) {
2349
+ if (meta.static || meta.generate || meta.viewport) {
2350
+ imports.push(`import * as ${c.alias}_meta from ${JSON.stringify(c.absPath)}`);
2351
+ }
2345
2352
  const fields = [
2346
2353
  meta.static ? `static: ${c.alias}_meta.metadata` : null,
2347
2354
  meta.generate ? `generate: ${c.alias}_meta.generateMetadata` : null,
2355
+ meta.viewport ? `viewport: ${c.alias}_meta.viewport` : null,
2356
+ meta.writes.charset || meta.writes.viewport ? `writes: ${JSON.stringify(meta.writes)}` : null,
2348
2357
  ].filter(Boolean);
2349
2358
  metaEntries.push(` ${JSON.stringify(c.name)}: { ${fields.join(", ")} },`);
2350
2359
  }
@@ -2583,10 +2592,57 @@ export async function handleApiRoute(
2583
2592
  return answer
2584
2593
  }
2585
2594
 
2586
- const metadataMap: Record<string, { static?: any; generate?: (p: any) => any }> = {
2595
+ const metadataMap: Record<string, { static?: any; generate?: (p: any) => any; viewport?: any; writes?: { charset: boolean; viewport: boolean } }> = {
2587
2596
  ${metaEntries.join("\n")}
2588
2597
  }
2589
2598
 
2599
+ /**
2600
+ * The viewport, in Next's shape: export const viewport on a layout or page,
2601
+ * layouts outer to inner then the page, merged per key. What is not set is
2602
+ * Next's default - width=device-width, initial-scale=1 - because a layout
2603
+ * ported from Next never wrote the tag: Next put it there, and a page with
2604
+ * no viewport meta is the desktop layout on a phone, which is how a port
2605
+ * found out.
2606
+ */
2607
+ function resolveViewport(component: string, layouts: LayoutEntry[]): Record<string, unknown> {
2608
+ const merged: Record<string, unknown> = { width: 'device-width', initialScale: 1 }
2609
+
2610
+ for (const l of layouts) Object.assign(merged, metadataMap[l.component]?.viewport ?? {})
2611
+ Object.assign(merged, metadataMap[component]?.viewport ?? {})
2612
+
2613
+ return merged
2614
+ }
2615
+
2616
+ /** The content attribute for a viewport object, spelled the way browsers read it. */
2617
+ function viewportContent(viewport: Record<string, unknown>): string {
2618
+ const names: Record<string, string> = {
2619
+ width: 'width',
2620
+ height: 'height',
2621
+ initialScale: 'initial-scale',
2622
+ minimumScale: 'minimum-scale',
2623
+ maximumScale: 'maximum-scale',
2624
+ userScalable: 'user-scalable',
2625
+ viewportFit: 'viewport-fit',
2626
+ interactiveWidget: 'interactive-widget',
2627
+ }
2628
+ const parts: string[] = []
2629
+
2630
+ for (const [key, name] of Object.entries(names)) {
2631
+ const value = viewport[key]
2632
+
2633
+ if (value === undefined || value === null) continue
2634
+
2635
+ parts.push(name + '=' + (typeof value === 'boolean' ? (value ? 'yes' : 'no') : String(value)))
2636
+ }
2637
+
2638
+ return parts.join(', ')
2639
+ }
2640
+
2641
+ /** Whether any file on the route renders the tag itself. */
2642
+ function routeWrites(component: string, layouts: LayoutEntry[], tag: 'charset' | 'viewport'): boolean {
2643
+ return [...layouts.map((l) => l.component), component].some((name) => metadataMap[name]?.writes?.[tag] === true)
2644
+ }
2645
+
2590
2646
  const staticParamsMap: Record<string, () => any> = {
2591
2647
  ${paramEntries.join("\n")}
2592
2648
  }
@@ -2603,6 +2659,35 @@ export function manifest(): any {
2603
2659
  return ${JSON.stringify(routeManifest())}
2604
2660
  }
2605
2661
 
2662
+ let cachedBuildId: string | null = null
2663
+
2664
+ /**
2665
+ * What this build's client is, as a short id.
2666
+ *
2667
+ * Derived from the bootstrap script the document ships - the hashed name of
2668
+ * the browser entry, which changes with the client references and chunks
2669
+ * it maps - so two builds whose browsers would disagree never share one.
2670
+ * The host puts it on every answer and compares it against what a client
2671
+ * says it is running; a mismatch on a payload request is a 409, and the
2672
+ * client loads the document instead. Read once, on demand, because the
2673
+ * bootstrap content is a build product this bundle only reaches at runtime.
2674
+ */
2675
+ export async function buildId(): Promise<string> {
2676
+ if (cachedBuildId !== null) return cachedBuildId
2677
+
2678
+ const content: string = await (import.meta as any).viteRsc.loadBootstrapScriptContent('index')
2679
+ let hash = 2166136261
2680
+
2681
+ for (let i = 0; i < content.length; i++) {
2682
+ hash ^= content.charCodeAt(i)
2683
+ hash = Math.imul(hash, 16777619) >>> 0
2684
+ }
2685
+
2686
+ cachedBuildId = hash.toString(16).padStart(8, '0')
2687
+
2688
+ return cachedBuildId
2689
+ }
2690
+
2606
2691
  /**
2607
2692
  * For each server action id, whether a client built it. The build asks after
2608
2693
  * the bundle exists, because the answer is a mark on the loaded function and
@@ -3088,6 +3173,33 @@ async function renderTree(
3088
3173
  return new URL(text, base).href
3089
3174
  }
3090
3175
 
3176
+ // What Next wrote into every document without being asked, and a layout
3177
+ // copied from a Next app therefore never writes: the charset, and the
3178
+ // viewport - without which a phone lays the page out at desktop width.
3179
+ // Rendered unless a file on the route renders the tag itself.
3180
+ if (!routeWrites(component, layouts, 'charset')) head.push(createElement('meta', { key: '__cs', charSet: 'utf-8' }))
3181
+
3182
+ const viewport = resolveViewport(component, layouts)
3183
+
3184
+ if (!routeWrites(component, layouts, 'viewport')) {
3185
+ head.push(createElement('meta', { key: '__vp', name: 'viewport', content: viewportContent(viewport) }))
3186
+ }
3187
+
3188
+ // themeColor and colorScheme live on the viewport export in Next, and one
3189
+ // set there wins over the web manifest's colour below.
3190
+ const themeColors = viewport.themeColor as string | { media?: string; color: string }[] | undefined
3191
+
3192
+ if (typeof themeColors === 'string') {
3193
+ head.push(createElement('meta', { key: '__vtc', name: 'theme-color', content: themeColors }))
3194
+ } else if (Array.isArray(themeColors)) {
3195
+ for (const [i, entry] of themeColors.entries()) {
3196
+ head.push(createElement('meta', { key: '__vtc' + i, name: 'theme-color', content: entry.color, ...(entry.media ? { media: entry.media } : {}) }))
3197
+ }
3198
+ }
3199
+ if (typeof viewport.colorScheme === 'string') {
3200
+ head.push(createElement('meta', { key: '__vcs', name: 'color-scheme', content: viewport.colorScheme }))
3201
+ }
3202
+
3091
3203
  for (const [i, found] of APP_HEAD.entries()) {
3092
3204
  const props = { ...found.props }
3093
3205
 
@@ -3101,7 +3213,7 @@ async function renderTree(
3101
3213
  if (WEB_MANIFEST) {
3102
3214
  head.push(createElement('link', { key: '__mf', rel: 'manifest', href: WEB_MANIFEST.href }))
3103
3215
 
3104
- if (WEB_MANIFEST.themeColor) {
3216
+ if (WEB_MANIFEST.themeColor && themeColors === undefined) {
3105
3217
  head.push(
3106
3218
  createElement('meta', { key: '__tc', name: 'theme-color', content: WEB_MANIFEST.themeColor }),
3107
3219
  )
@@ -3122,6 +3234,15 @@ async function renderTree(
3122
3234
  if (md.description != null) head.push(createElement('meta', { key: '__d', name: 'description', content: String(md.description) }))
3123
3235
  // The name only, never the version - see the identify option.
3124
3236
  if (manifest().build?.identify) head.push(createElement('meta', { key: '__g', name: 'generator', content: 'rsc-kit' }))
3237
+ }
3238
+
3239
+ // Which build this document is, for the client to say on every navigation
3240
+ // - read from the document rather than learned from the first answer,
3241
+ // because a document a service worker served from its cache is the last
3242
+ // build's while the answers are this one's. An id, not a version number.
3243
+ if (bootstrap) head.push(createElement('meta', { key: '__v', name: 'rsc-kit:build', content: await buildId() }))
3244
+
3245
+ if (md) {
3125
3246
 
3126
3247
  // robots is a string, or the object Next takes: index and follow as
3127
3248
  // their no- forms, the flags by name, the limits as name:value. googleBot
@@ -4353,6 +4474,7 @@ async function serve(request: Request): Promise<Response> {
4353
4474
  handleApiRoute,
4354
4475
  resolveMetadata,
4355
4476
  runRouteMiddleware,
4477
+ buildId,
4356
4478
  } as never,
4357
4479
  ${NITRO_HANDLER_OPTIONS}${NITRO_PRERENDERED} maxActionBody: ${maxActionBody === undefined ? "undefined" : String(maxActionBody)},
4358
4480
  })
@@ -5297,18 +5419,35 @@ export function rscKit(options = {}) {
5297
5419
  setup(nitro) {
5298
5420
  if (nitro.options.dev)
5299
5421
  return;
5422
+ // Said at the end of a bun build, only when true: which module keeps
5423
+ // `bun build --compile --bytecode` from applying, and where. Bun
5424
+ // itself names no file and exits 0. See bytecodeCheck.ts.
5425
+ if (nitro.options.preset === "bun") {
5426
+ nitro.hooks.hook("compiled", () => {
5427
+ const note = bytecodeNote(nitro.options.output.serverDir);
5428
+ if (note)
5429
+ log(note);
5430
+ });
5431
+ }
5300
5432
  // External at Nitro's layer too. The Vite build leaves these as
5301
5433
  // imports, and Nitro would then bundle them into its own chunks -
5302
5434
  // which for a native package fails at load, and for a polyfill like
5303
5435
  // reflect-metadata runs it after the chunk that checks for it.
5304
5436
  // Traced, each is copied into .output/server/node_modules and
5305
5437
  // imported by the built server the way its author expected.
5306
- // Only the ones the project has. Nitro's tracer says so, once per
5307
- // name, for every entry it cannot find - and a list of every native
5308
- // package anyone might install is mostly ones this app does not.
5438
+ // Only the ones the tracer can find by name. It says so, once per
5439
+ // name, for every entry it cannot - a list of every native package
5440
+ // anyone might install is mostly ones this app does not have, and
5441
+ // one the app has only through a dependency, under an isolated
5442
+ // store, is reached through that dependency and not by name.
5443
+ // And the polyfills, by pattern: bundled, one merged into the chunk
5444
+ // of the dependency that checks for it, after that chunk's imports -
5445
+ // and the server did not boot. External, the entry's import of it
5446
+ // comes first. See polyfillsToTrace.
5309
5447
  nitro.options.traceDeps = [
5310
5448
  ...(nitro.options.traceDeps ?? []),
5311
- ...[...DEFAULT_SERVER_EXTERNALS, ...(options.serverExternalPackages ?? [])].filter((name) => installedPackageDir(name, projectRoot) !== null || packageEntryInGraph(projectRoot, name) !== null),
5449
+ ...traceableByName([...DEFAULT_SERVER_EXTERNALS, ...(options.serverExternalPackages ?? [])], projectRoot),
5450
+ ...polyfillsToTrace(FIRST_POLYFILLS, projectRoot),
5312
5451
  ];
5313
5452
  // The assets, precompressed at build and served with their encoding
5314
5453
  // by Nitro's own static handler; the host gzips the rest as it
@@ -5319,6 +5458,21 @@ export function rscKit(options = {}) {
5319
5458
  if (typeof nitro.options.compressPublicAssets !== "object")
5320
5459
  nitro.options.compressPublicAssets = true;
5321
5460
  nitro.options.virtual ??= {};
5461
+ // The names of functions survive Nitro's bundle. The pages were
5462
+ // prerendered against Vite's own server bundles, where a dependency
5463
+ // is loaded from node_modules under the names it was written with;
5464
+ // Nitro then bundles that dependency into the server, and renames a
5465
+ // function whose name collides with another's - J becomes J2 - and
5466
+ // `name` follows the identifier. React's resume of a stored shell
5467
+ // compares the component names it recorded with the ones it meets,
5468
+ // and a renamed one is "Expected the resume to render <J> in this
5469
+ // slot but instead it rendered <J2>", on every request, with the
5470
+ // hole rendered in the browser instead. keepNames pins `name` to
5471
+ // what the source said, whatever the identifier became.
5472
+ nitro.options.rollupConfig = {
5473
+ ...(nitro.options.rollupConfig ?? {}),
5474
+ output: { keepNames: true, ...(nitro.options.rollupConfig?.output ?? {}) },
5475
+ };
5322
5476
  // A polyfill a dependency checks for at module evaluation, loaded
5323
5477
  // before any service. The bundler places an external import after
5324
5478
  // the chunk imports of the module that had it, whatever the source
@@ -5532,7 +5686,15 @@ export function rscKit(options = {}) {
5532
5686
  // node_modules. Installed from npm the name resolves on its own.
5533
5687
  alias: aliasEntries(),
5534
5688
  },
5535
- build: { emptyOutDir: true },
5689
+ build: {
5690
+ emptyOutDir: true,
5691
+ // Rolldown reports, after every build, which plugin hooks took
5692
+ // the time. Here the answer is always the same: the typecheck hook
5693
+ // - tsc, run before the bundle on purpose, as `next build` does -
5694
+ // and the two resolvers that look at every import. A paragraph
5695
+ // saying so at the end of each docker build is not a finding.
5696
+ rollupOptions: { checks: { pluginTimings: false } },
5697
+ },
5536
5698
  // What reaches the browser. Vite's own VITE_ prefix, and PUBLIC_ - the
5537
5699
  // scaffold's spelling, and Next's minus its brand - so a variable
5538
5700
  // named for a port reads through import.meta.env without a config