@rsc-kit/core 0.30.14 → 0.30.16

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
@@ -24,7 +24,7 @@ import { loadEnv, parseAst } from "vite";
24
24
  import { stampDisplayNames } from "./stableNames.js";
25
25
  import { REPORT_FILE, buildReport } from "./buildReport.js";
26
26
  import { MANIFEST_PATH, manifestWarning, webManifest } from "./webManifest.js";
27
- import { ASSET_BASE, allAppAssets, appAssets, headTags, typeOf, } from "./appAssets.js";
27
+ import { ASSET_BASE, allAppAssets, assetFile, appAssets, headTags, shareImageUrls, typeOf, } from "./appAssets.js";
28
28
  import { reactCacheImports } from "./reactCache.js";
29
29
  import { clientEntries, clientScanPlugin, engineClientEntries, } from "./clientEntries.js";
30
30
  import { serverImportsOfClientPackages } from "./clientImports.js";
@@ -33,6 +33,7 @@ import { ownHosts } from "./hostRouting.js";
33
33
  import { unrollBarrelImports } from "./barrelImports.js";
34
34
  import { hasUseSsr, serverRendererMessage, SERVER_RENDERER, ssrProxyModule, UseSsrError, withoutSsrDirective } from "./useSsr.js";
35
35
  import { httpHostCalls } from "./hostCalls.js";
36
+ import { COMMANDS_MODULE_ID, commandFiles, commandsEntry, commandsEntrySource, commandsModule } from "./commandFiles.js";
36
37
  import { clientPackages, importsServerRenderer, packageDir as installedPackageDir, packageEntryInGraph, polyfillsToTrace, traceableByName } from "./clientPackages.js";
37
38
  // Resolved once per rscKit() call. One build runs in one process, so these are
38
39
  // module state rather than threaded through every helper.
@@ -47,6 +48,46 @@ let clientLibraryImports = [];
47
48
  * decisions buildApp makes about where stored pages can live.
48
49
  */
49
50
  let nitroPreset = null;
51
+ /** `src/commands`, read once per build; null until asked. See buildCommands(). */
52
+ let commandsFound = null;
53
+ /**
54
+ * The presets whose output is a process that Node or Bun starts: where a
55
+ * command runs. Every other preset is a platform that starts the app itself -
56
+ * a function, a Worker - and a release step there is not the app's to run.
57
+ */
58
+ const COMMAND_PRESETS = /^(?:bun|node|node[-_]server|node[-_]cluster)$/;
59
+ /**
60
+ * The app's commands, for a build that has a process to run them in.
61
+ *
62
+ * Decided once, the first time the bundle asks, and every later step reads the
63
+ * same answer: the table in the bundle, `commands.mjs`, the compile entry and
64
+ * `.output/rsc-kit.json` cannot disagree about what is there.
65
+ *
66
+ * Not the dev server, which has no output to run one from - a command there is
67
+ * `bun src/commands/<name>.ts`. Not a Worker: its bundle targets workerd, where
68
+ * the packages a release step wants (a database driver, `node:child_process`)
69
+ * either resolve to a different build or not at all, so a command that ran there
70
+ * would run something other than what was written. And not a preset this has not
71
+ * been run on: it says so, rather than writing an entry for a layout it has not
72
+ * seen.
73
+ */
74
+ function buildCommands() {
75
+ if (resolvedConfig?.command !== "build")
76
+ return [];
77
+ if (commandsFound)
78
+ return commandsFound;
79
+ const { commands, ignored } = commandFiles(sourceDir);
80
+ for (const { path, reason } of ignored)
81
+ log(`${relative(projectRoot, path)} is not a command: ${reason}.`);
82
+ if (commands.length && !(nitroPreset && COMMAND_PRESETS.test(nitroPreset))) {
83
+ const names = commands.map((c) => c.name).join(", ");
84
+ log(nitroPreset?.startsWith("cloudflare")
85
+ ? `src/commands is not built for a Worker (${names}): the bundle runs on workerd, which is not where a release step runs. Run the file itself, or wrangler.`
86
+ : `src/commands is built for the bun and node presets, and this build is ${nitroPreset ? `for ${nitroPreset}` : "not using a Nitro preset"} (${names}). Run the file itself from CI.`);
87
+ return (commandsFound = []);
88
+ }
89
+ return (commandsFound = commands);
90
+ }
50
91
  /**
51
92
  * Modules a runtime provides and no bundle should try to carry.
52
93
  *
@@ -195,8 +236,7 @@ let foundAssets = {
195
236
  appleIcon: null,
196
237
  appleSplash: [],
197
238
  screenshots: [],
198
- openGraph: null,
199
- twitter: null,
239
+ shareImages: {},
200
240
  };
201
241
  /** Host functions to generate stubs for — see RscKitOptions.hostActions. */
202
242
  let hostActions;
@@ -1393,7 +1433,7 @@ function copyAppAssets(clientDir) {
1393
1433
  if (all.length === 0)
1394
1434
  return;
1395
1435
  for (const asset of all) {
1396
- const to = join(clientDir, asset.href.slice(1));
1436
+ const to = join(clientDir, assetFile(asset.href.slice(1)));
1397
1437
  mkdirSync(dirname(to), { recursive: true });
1398
1438
  copyFileSync(join(sourceDir, "app", asset.file), to);
1399
1439
  }
@@ -2424,6 +2464,8 @@ const API_METHODS = [
2424
2464
  ];
2425
2465
  /** `orders.section.tsx` — a region of a page that can be refreshed by name. */
2426
2466
  const SECTION_FILE = /\.section\.(tsx|jsx|ts|js)$/;
2467
+ /** A share image file: the pages beneath its folder change what they say about themselves. */
2468
+ const SHARE_IMAGE_FILE = /^(?:opengraph|twitter)-image[-\w]*\.(?:png|svg|jpe?g|webp|gif|avif)$/i;
2427
2469
  /**
2428
2470
  * Whether a page or a section declares refreshOn. Read from the source, as
2429
2471
  * section names are, because the manifest is written before any bundle exists.
@@ -2489,6 +2531,20 @@ function registerOpenApiRoutes() {
2489
2531
  }
2490
2532
  apiRoutes.set(name, { name, absPath: OPENAPI_ROUTE_ID, methods: ["GET"], generated: { kind: "openapi", file: "" } });
2491
2533
  }
2534
+ /** The table behind `runCommand` in the server's entry: src/commands, or nothing where they are not built. */
2535
+ function commandsPlugin() {
2536
+ return {
2537
+ name: "rsc-kit:commands",
2538
+ resolveId(id) {
2539
+ if (id === COMMANDS_MODULE_ID)
2540
+ return "\0" + id;
2541
+ },
2542
+ load(id) {
2543
+ if (id === "\0" + COMMANDS_MODULE_ID)
2544
+ return commandsModule(buildCommands());
2545
+ },
2546
+ };
2547
+ }
2492
2548
  /** The module behind the synthesised /openapi.json route. */
2493
2549
  function openApiPlugin() {
2494
2550
  return {
@@ -3069,7 +3125,7 @@ export function instrumentationImport(instrumentation) {
3069
3125
  return (`import { ${hooks.map((hook) => `${hook} as __instrumentation_${hook}`).join(", ")} } from ${from}\n` +
3070
3126
  `const __instrumentation: ${type} = { ${hooks.map((hook) => `${hook}: __instrumentation_${hook}`).join(", ")} }`);
3071
3127
  }
3072
- function generateEntryRsc(fallbackOrigin = "") {
3128
+ function generateEntryRsc(fallbackOrigin = "", withCommands = false) {
3073
3129
  const instrumentation = instrumentationFile();
3074
3130
  // Every 404 page the app has, and the layouts each renders inside. Computed
3075
3131
  // here rather than looked up at runtime: not-found is not a route, so the
@@ -3146,6 +3202,7 @@ ${
3146
3202
  import { SegmentBoundary } from ${JSON.stringify(join(packageDir, "js/SegmentBoundary"))}
3147
3203
  import { LoadingBoundary } from ${JSON.stringify(join(packageDir, "js/LoadingBoundary"))}
3148
3204
  import { DocumentTitle } from ${JSON.stringify(join(packageDir, "js/DocumentTitle"))}
3205
+ import { depthOf, fileWins, folderOf, nearestShareImage } from ${JSON.stringify(join(packageDir, "shareImages"))}
3149
3206
  import { SlotBoundary } from ${JSON.stringify(join(packageDir, "js/SlotBoundary"))}
3150
3207
  import { RouteErrorBoundary } from ${JSON.stringify(join(packageDir, "js/RouteErrorBoundary"))}
3151
3208
  import __rsc_assets from 'virtual:vite-rsc/assets-manifest'
@@ -3199,6 +3256,9 @@ ${mapEntries.join("\n")}
3199
3256
  /** Head tags for the icons and share images found in app/. */
3200
3257
  const APP_HEAD: { tag: any; props: Record<string, string> }[] = ${JSON.stringify(headTags(foundAssets))}
3201
3258
 
3259
+ /** The share images found under app/, by the folder each is in - see shareImages.ts. */
3260
+ const SHARE_IMAGES: Record<string, { openGraph?: string; twitter?: string }> = ${JSON.stringify(shareImageUrls(foundAssets))}
3261
+
3202
3262
  const WEB_MANIFEST: { href: string; themeColor?: string } | null = ${JSON.stringify(webManifestOptions
3203
3263
  ? {
3204
3264
  href: MANIFEST_PATH,
@@ -3598,7 +3658,9 @@ if (!isolateRuntime) {
3598
3658
  ;(globalThis as { __rscKitShutdown?: () => unknown }).__rscKitShutdown = () => __instrumentation.shutdown?.()
3599
3659
  }
3600
3660
 
3601
- if (__instrumentation.register && !isolateRuntime) {
3661
+ // Started to run a command (src/commands), not to serve: register() waits for
3662
+ // the command that asks for it, rather than running at import.
3663
+ if (__instrumentation.register && !isolateRuntime && !(globalThis as Record<symbol, unknown>)[Symbol.for('rsc-kit.command')]) {
3602
3664
  instrumented().catch((error) => {
3603
3665
  console.error('[rsc-kit] instrumentation.ts register() failed:', error)
3604
3666
 
@@ -4199,13 +4261,35 @@ async function renderTree(
4199
4261
  }
4200
4262
 
4201
4263
  for (const [i, found] of APP_HEAD.entries()) {
4202
- const props = { ...found.props }
4264
+ head.push(createElement(found.tag, { key: '__a' + i, ...found.props }))
4265
+ }
4203
4266
 
4204
- if (props.content && /^(og:image|twitter:image)$/.test(props.property ?? props.name ?? '')) {
4205
- props.content = absolute(props.content)
4206
- }
4267
+ // The share image files found in app/. The nearest to this page is its
4268
+ // card - unless its metadata exports images from a segment nearer still,
4269
+ // in which case the file is left out and the exported ones render, so a
4270
+ // page that names its own image replaces the site's instead of following
4271
+ // it. Scrapers read the first og:image they meet; a second after the
4272
+ // site's would be a card that never changes.
4273
+ const folder = folderOf(component)
4274
+ const sources = md ? imageSources.get(md) : undefined
4275
+ const shared = (kind: 'openGraph' | 'twitter') => {
4276
+ const file = nearestShareImage(SHARE_IMAGES, folder, kind)
4277
+ const source = sources?.[kind]
4278
+
4279
+ return fileWins(file, source?.exports ? source : null) ? file : null
4280
+ }
4281
+ const ogFile = shared('openGraph')
4282
+ const twitterFile = shared('twitter')
4283
+
4284
+ if (ogFile) head.push(createElement('meta', { key: '__og', property: 'og:image', content: absolute(ogFile.href) }))
4207
4285
 
4208
- head.push(createElement(found.tag, { key: '__a' + i, ...props }))
4286
+ if (twitterFile) {
4287
+ head.push(createElement('meta', { key: '__tw', name: 'twitter:image', content: absolute(twitterFile.href) }))
4288
+
4289
+ // The card a twitter image implies, unless the page named its own.
4290
+ if ((md?.twitter as { card?: unknown } | undefined)?.card == null) {
4291
+ head.push(createElement('meta', { key: '__twc', name: 'twitter:card', content: 'summary_large_image' }))
4292
+ }
4209
4293
  }
4210
4294
 
4211
4295
  if (md) {
@@ -4305,7 +4389,7 @@ async function renderTree(
4305
4389
  if (og.siteName != null) head.push(tag('og:site_name', og.siteName))
4306
4390
  if (og.type != null) head.push(tag('og:type', og.type))
4307
4391
  if (og.locale != null) head.push(tag('og:locale', og.locale))
4308
- if (og.images != null) images('og:image', og.images)
4392
+ if (og.images != null && !ogFile) images('og:image', og.images)
4309
4393
  }
4310
4394
 
4311
4395
  if (md.twitter) {
@@ -4316,7 +4400,7 @@ async function renderTree(
4316
4400
  if (tw.description != null) head.push(tag('twitter:description', tw.description))
4317
4401
  if (tw.site != null) head.push(tag('twitter:site', tw.site))
4318
4402
  if (tw.creator != null) head.push(tag('twitter:creator', tw.creator))
4319
- if (tw.images != null) images('twitter:image', tw.images)
4403
+ if (tw.images != null && !twitterFile) images('twitter:image', tw.images)
4320
4404
  }
4321
4405
 
4322
4406
  // icons is links, not meta. A string is one icon; an object names which
@@ -5209,6 +5293,17 @@ const METADATA_PROBE_GRACE_MS = 20
5209
5293
 
5210
5294
  const METADATA_POSTPONED = Symbol('metadata postponed')
5211
5295
 
5296
+ /**
5297
+ * For each of openGraph and twitter, how deep the segment that supplied the
5298
+ * merged object is, and whether it exports images - the other half of the
5299
+ * comparison a share image file is held to. Beside the metadata rather than
5300
+ * inside it, so the object a page's tags are rendered from stays only what
5301
+ * the app wrote.
5302
+ */
5303
+ type ImageSources = { openGraph?: { depth: number; exports: boolean }; twitter?: { depth: number; exports: boolean } }
5304
+
5305
+ const imageSources = new WeakMap<object, ImageSources>()
5306
+
5212
5307
  export async function resolveMetadata(
5213
5308
  component: string,
5214
5309
  props: Record<string, unknown> = {},
@@ -5273,18 +5368,27 @@ export async function resolveMetadata(
5273
5368
  // happened to declare one of its own.
5274
5369
  const merged: Record<string, unknown> = {}
5275
5370
  const other: Record<string, unknown> = {}
5371
+ const sources: ImageSources = {}
5276
5372
 
5277
- const take = (from: Record<string, unknown>) => {
5373
+ const take = (from: Record<string, unknown>, depth: number) => {
5278
5374
  for (const [k, v] of Object.entries(from)) {
5279
5375
  if (k === 'title') continue
5280
5376
  if (k === 'other') Object.assign(other, v as Record<string, unknown>)
5281
5377
  else merged[k] = v
5378
+
5379
+ // openGraph and twitter are replaced whole by a nearer segment's, so
5380
+ // whichever segment took them last is the one whose images - or lack of
5381
+ // them - the merged object carries. That segment's depth is what a file
5382
+ // in app/ is weighed against; see shareImages.ts.
5383
+ if (k === 'openGraph' || k === 'twitter') {
5384
+ sources[k] = { depth, exports: typeof v === 'object' && v !== null && (v as { images?: unknown }).images != null }
5385
+ }
5282
5386
  }
5283
5387
  }
5284
5388
 
5285
- for (const m of layoutMeta) take(m)
5389
+ layoutMeta.forEach((m, i) => take(m, depthOf(folderOf(layouts[i].component))))
5286
5390
 
5287
- take(page)
5391
+ take(page, depthOf(folderOf(component)))
5288
5392
 
5289
5393
  if (Object.keys(other).length > 0) merged.other = other
5290
5394
 
@@ -5309,7 +5413,11 @@ export async function resolveMetadata(
5309
5413
  }
5310
5414
  if (title != null) merged.title = title
5311
5415
 
5312
- return Object.keys(merged).length ? merged : null
5416
+ if (Object.keys(merged).length === 0) return null
5417
+
5418
+ imageSources.set(merged, sources)
5419
+
5420
+ return merged
5313
5421
  }
5314
5422
 
5315
5423
  // Buffered render (worker: rsc / rscWithoutCallbacks — used at prerender time).
@@ -5939,7 +6047,11 @@ async function renderNotFound(request: Request): Promise<Response> {
5939
6047
  : ""}
5940
6048
  return new Response('Not found', { status: 404 })
5941
6049
  }
5942
- `;
6050
+ ${
6051
+ // After everything the entry is for: running a command reads its
6052
+ // register() and shutdown(). Only a build for an app that has commands; what
6053
+ // is in the table is decided later, when the bundle is built.
6054
+ withCommands ? commandsEntry(join(packageDir, "commands")) : ""}`;
5943
6055
  }
5944
6056
  function generateEntrySsr() {
5945
6057
  const devUrls = join(packageDir, "devUrls");
@@ -7168,6 +7280,20 @@ export function rscKit(options = {}) {
7168
7280
  if (nitro.options.dev)
7169
7281
  return;
7170
7282
  nitroPreset = nitro.options.preset ?? null;
7283
+ // The entry a command runs from, beside the server it does not start.
7284
+ // Written once Nitro has assembled the output, because it names a
7285
+ // file Nitro places: if that moves, the build says so rather than a
7286
+ // release step finding out.
7287
+ nitro.hooks.hook("compiled", async () => {
7288
+ if (buildCommands().length === 0)
7289
+ return;
7290
+ const serverDir = nitro.options.output.serverDir;
7291
+ if (!existsSync(join(serverDir, "_ssr", "rsc.mjs"))) {
7292
+ throw new Error(`[rsc-kit] src/commands runs from ${join(serverDir, "_ssr", "rsc.mjs")}, and Nitro did not write it there. ` +
7293
+ "Nitro's output layout changed; the commands entry needs to follow it.");
7294
+ }
7295
+ writeFileSync(join(serverDir, "commands.mjs"), commandsEntrySource());
7296
+ });
7171
7297
  // On Workers the stored pages ride the static assets, not the bundle.
7172
7298
  // Copied once Nitro has listed its public files, so the copies are
7173
7299
  // not in that list: a path in it is served as an asset before the
@@ -7280,6 +7406,7 @@ export function rscKit(options = {}) {
7280
7406
  // Both maps, not one: the config hook runs again when the config
7281
7407
  // changes under a dev server, and a route.ts or a synthesised
7282
7408
  // robots.txt left over from the previous run read as a duplicate.
7409
+ commandsFound = null;
7283
7410
  components.clear();
7284
7411
  apiRoutes.clear();
7285
7412
  discover(appDir);
@@ -7362,7 +7489,7 @@ export function rscKit(options = {}) {
7362
7489
  : env.command === "build"
7363
7490
  ? (declaresBackend ? "" : false)
7364
7491
  : (detected || (declaresBackend ? "" : false));
7365
- writeGenerated(join(genDir, "entry.rsc.tsx"), generateEntryRsc(fallbackOrigin));
7492
+ writeGenerated(join(genDir, "entry.rsc.tsx"), generateEntryRsc(fallbackOrigin, env.command === "build" && existsSync(join(sourceDir, "commands"))));
7366
7493
  writeGenerated(join(genDir, "entry.ssr.tsx"), generateEntrySsr());
7367
7494
  writeGenerated(join(genDir, "entry.browser.tsx"), generateEntryBrowser());
7368
7495
  // Written beside the entries, for a host to read instead of walking the
@@ -7637,7 +7764,8 @@ export function rscKit(options = {}) {
7637
7764
  res.end(webManifest(webManifestOptions));
7638
7765
  return;
7639
7766
  }
7640
- const asset = allAppAssets(foundAssets).find((a) => a.href === url);
7767
+ // Either spelling of a bracket: the tag says %5B, a hand-typed url says [.
7768
+ const asset = allAppAssets(foundAssets).find((a) => assetFile(a.href) === assetFile(url));
7641
7769
  const file = asset ? join(sourceDir, "app", asset.file) : null;
7642
7770
  if (!asset || !file || !existsSync(file))
7643
7771
  return next();
@@ -7724,6 +7852,7 @@ export function rscKit(options = {}) {
7724
7852
  // published without any — generic-host.test.ts fails if it reappears.
7725
7853
  return ((base !== stem && shapes.has(stem)) ||
7726
7854
  SECTION_FILE.test(base) ||
7855
+ SHARE_IMAGE_FILE.test(base) ||
7727
7856
  (routeConfig !== null && base === routeConfig.file));
7728
7857
  };
7729
7858
  const restart = (file) => {
@@ -7840,7 +7969,7 @@ export function rscKit(options = {}) {
7840
7969
  // And the entry a binary is compiled from, so the pages above end up
7841
7970
  // inside it rather than rendered live: bun build --compile
7842
7971
  // .output/server/compile.mjs. The scaffold's script names it.
7843
- writeFileSync(join(dirname(staticDir), "compile.mjs"), compileEntrySource(NITRO_STATIC_DIR));
7972
+ writeFileSync(join(dirname(staticDir), "compile.mjs"), compileEntrySource(NITRO_STATIC_DIR, { commands: buildCommands().length > 0 }));
7844
7973
  }
7845
7974
  // Manifest first. The service worker precaches whatever it finds in this
7846
7975
  // directory, so writing it afterwards leaves it out of the list — and an
@@ -7864,7 +7993,12 @@ export function rscKit(options = {}) {
7864
7993
  if (clientOut) {
7865
7994
  writeFileSync(join(dirname(clientOut), "rsc-kit.json"), JSON.stringify(output === "export"
7866
7995
  ? { version: PACKAGE_VERSION, output: "export", dir: relative(projectRoot, exportPath) }
7867
- : { version: PACKAGE_VERSION, output: "server", ...compileStep(projectRoot) }, null, 2) + "\n");
7996
+ : {
7997
+ version: PACKAGE_VERSION,
7998
+ output: "server",
7999
+ ...compileStep(projectRoot),
8000
+ ...(buildCommands().length ? { commands: buildCommands().map((c) => c.name) } : {}),
8001
+ }, null, 2) + "\n");
7868
8002
  }
7869
8003
  },
7870
8004
  configResolved(config) {
@@ -7947,6 +8081,7 @@ export function rscKit(options = {}) {
7947
8081
  serverRendererInRsc(),
7948
8082
  metadataRoutesPlugin(),
7949
8083
  openApiPlugin(),
8084
+ commandsPlugin(),
7950
8085
  extendableClientReferences(),
7951
8086
  stableComponentNames(),
7952
8087
  typecheckPlugin(),