@rsc-kit/core 0.30.13 → 0.30.15

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";
@@ -157,6 +157,7 @@ let typesDir;
157
157
  let hotFile;
158
158
  let hostCallOptions;
159
159
  let packageDir;
160
+ let backendPaths = [];
160
161
  let hostGlobal;
161
162
  let interceptManifestFile;
162
163
  let packageAlias;
@@ -194,8 +195,7 @@ let foundAssets = {
194
195
  appleIcon: null,
195
196
  appleSplash: [],
196
197
  screenshots: [],
197
- openGraph: null,
198
- twitter: null,
198
+ shareImages: {},
199
199
  };
200
200
  /** Host functions to generate stubs for — see RscKitOptions.hostActions. */
201
201
  let hostActions;
@@ -348,6 +348,34 @@ function aliasEntries() {
348
348
  });
349
349
  return entries;
350
350
  }
351
+ /**
352
+ * The prefixes a backend owns, as the matcher reads them: `/auth`, no trailing
353
+ * slash. Refused rather than corrected where a mistake would silently send the
354
+ * whole app, or its own endpoints, to the backend.
355
+ */
356
+ export function normaliseBackendPaths(paths) {
357
+ const out = [];
358
+ for (const raw of paths ?? []) {
359
+ const given = typeof raw === "string" ? raw.trim() : "";
360
+ if (!given.startsWith("/")) {
361
+ throw new Error(`[rsc-kit] backendPaths: ${JSON.stringify(raw)} is not a path. Each starts with a slash, as in '/auth'.`);
362
+ }
363
+ // After the slash check, so "/" is the root and not "not a path".
364
+ const path = given.replace(/\/+$/, "");
365
+ if (path === "") {
366
+ throw new Error("[rsc-kit] backendPaths: '/' is every url, and nothing would be left for this app. Name the prefixes the backend owns.");
367
+ }
368
+ if (path === "/_rsc" || path.startsWith("/_rsc/")) {
369
+ throw new Error(`[rsc-kit] backendPaths: ${JSON.stringify(raw)} is this package's own endpoint, which the renderer answers.`);
370
+ }
371
+ if (/[?#*]/.test(path)) {
372
+ throw new Error(`[rsc-kit] backendPaths: ${JSON.stringify(raw)} is a prefix, not a pattern: no query, fragment or wildcard.`);
373
+ }
374
+ if (!out.includes(path))
375
+ out.push(path);
376
+ }
377
+ return out;
378
+ }
351
379
  function resolvePaths(options) {
352
380
  projectRoot = resolve(options.projectRoot || process.env.RSC_PROJECT_ROOT || process.cwd());
353
381
  sourceDir = resolve(options.sourceDir || process.env.RSC_SOURCE_DIR || join(projectRoot, "src"));
@@ -385,6 +413,7 @@ function resolvePaths(options) {
385
413
  hotFile = options.hotFile || process.env.RSC_HOT_FILE || "";
386
414
  hostCallOptions = options.hostCall;
387
415
  packageDir = resolve(options.packageDir || process.env.RSC_PACKAGE_DIR || thisDir());
416
+ backendPaths = normaliseBackendPaths(options.backendPaths);
388
417
  hostGlobal = options.hostGlobal || process.env.RSC_HOST_GLOBAL || "rpc";
389
418
  interceptManifestFile = resolve(options.interceptManifestFile ||
390
419
  process.env.RSC_INTERCEPT_MANIFEST ||
@@ -1363,7 +1392,7 @@ function copyAppAssets(clientDir) {
1363
1392
  if (all.length === 0)
1364
1393
  return;
1365
1394
  for (const asset of all) {
1366
- const to = join(clientDir, asset.href.slice(1));
1395
+ const to = join(clientDir, assetFile(asset.href.slice(1)));
1367
1396
  mkdirSync(dirname(to), { recursive: true });
1368
1397
  copyFileSync(join(sourceDir, "app", asset.file), to);
1369
1398
  }
@@ -2394,6 +2423,8 @@ const API_METHODS = [
2394
2423
  ];
2395
2424
  /** `orders.section.tsx` — a region of a page that can be refreshed by name. */
2396
2425
  const SECTION_FILE = /\.section\.(tsx|jsx|ts|js)$/;
2426
+ /** A share image file: the pages beneath its folder change what they say about themselves. */
2427
+ const SHARE_IMAGE_FILE = /^(?:opengraph|twitter)-image[-\w]*\.(?:png|svg|jpe?g|webp|gif|avif)$/i;
2397
2428
  /**
2398
2429
  * Whether a page or a section declares refreshOn. Read from the source, as
2399
2430
  * section names are, because the manifest is written before any bundle exists.
@@ -2769,17 +2800,40 @@ function fallbackOrigin(): string {
2769
2800
  const FALLBACK_MARKER = 'x-rsc-renderer-fallback'
2770
2801
  const PROXIED_MARKER = 'x-rsc-proxied-by-backend'
2771
2802
  `;
2803
+ /**
2804
+ * The urls the app said belong to the backend, tested before any page is
2805
+ * matched. Emitted in both variants: a test's stand-in backend is reached by
2806
+ * the same prefixes as the real one.
2807
+ */
2808
+ function backendPathConsts() {
2809
+ return `const BACKEND_PATHS: string[] = ${JSON.stringify(backendPaths)}
2810
+
2811
+ // A prefix at a segment boundary: '/auth' is '/auth' and what is under it, not '/authors'.
2812
+ function onBackendPath(pathname: string): boolean {
2813
+ return BACKEND_PATHS.some((prefix) => pathname === prefix || pathname.startsWith(prefix + '/'))
2814
+ }
2815
+ `;
2816
+ }
2772
2817
  /** With no backend configured: this app's not-found page, or a test's backend. */
2773
- const NO_FALLBACK_BODY = ` const answer = await devHandler(request)
2818
+ const NO_FALLBACK_BODY = ` // The app named this prefix as the backend's: no page is asked about it, so one
2819
+ // that matches (a dynamic root route) cannot answer for it.
2820
+ const toBackend = onBackendPath(new URL(request.url).pathname)
2821
+ const answer = toBackend ? null : await devHandler(request)
2774
2822
 
2775
2823
  if (answer) return answer
2776
2824
 
2777
- if (backendForward && !pageSaidNotFound(request)) return await backendForward(request)
2825
+ if (backendForward && (toBackend || !pageSaidNotFound(request))) return await backendForward(request)
2778
2826
 
2779
2827
  return await notFound(request)
2780
2828
  `;
2781
2829
  const FALLBACK_BODY = ` const FALLBACK_ORIGIN = fallbackOrigin()
2782
- const answer = await devHandler(request)
2830
+
2831
+ // The app named this prefix as the backend's (rscKit({ backendPaths })): no
2832
+ // page is asked about it. A dynamic root route such as [team]/[app] matches
2833
+ // /gitlab/connect, renders, says notFound() - and a page that said that is not
2834
+ // forwarded, so the backend's own route was the not-found page.
2835
+ const toBackend = onBackendPath(new URL(request.url).pathname)
2836
+ const answer = toBackend ? null : await devHandler(request)
2783
2837
 
2784
2838
  if (answer) return answer
2785
2839
 
@@ -2787,7 +2841,7 @@ const FALLBACK_BODY = ` const FALLBACK_ORIGIN = fallbackOrigin()
2787
2841
  // its guard, or a backend's 404. That is this app's not-found.tsx, not a
2788
2842
  // url for the backend: forwarded, the backend answered with its own 404
2789
2843
  // and the app's never showed.
2790
- if (pageSaidNotFound(request)) return await notFound(request)
2844
+ if (!toBackend && pageSaidNotFound(request)) return await notFound(request)
2791
2845
 
2792
2846
  // Nothing here owns this url. In development the backend usually does — a
2793
2847
  // Blade page, /login, a webhook, an uploaded file under /storage — so the
@@ -3093,6 +3147,7 @@ ${
3093
3147
  import { SegmentBoundary } from ${JSON.stringify(join(packageDir, "js/SegmentBoundary"))}
3094
3148
  import { LoadingBoundary } from ${JSON.stringify(join(packageDir, "js/LoadingBoundary"))}
3095
3149
  import { DocumentTitle } from ${JSON.stringify(join(packageDir, "js/DocumentTitle"))}
3150
+ import { depthOf, fileWins, folderOf, nearestShareImage } from ${JSON.stringify(join(packageDir, "shareImages"))}
3096
3151
  import { SlotBoundary } from ${JSON.stringify(join(packageDir, "js/SlotBoundary"))}
3097
3152
  import { RouteErrorBoundary } from ${JSON.stringify(join(packageDir, "js/RouteErrorBoundary"))}
3098
3153
  import __rsc_assets from 'virtual:vite-rsc/assets-manifest'
@@ -3146,6 +3201,9 @@ ${mapEntries.join("\n")}
3146
3201
  /** Head tags for the icons and share images found in app/. */
3147
3202
  const APP_HEAD: { tag: any; props: Record<string, string> }[] = ${JSON.stringify(headTags(foundAssets))}
3148
3203
 
3204
+ /** The share images found under app/, by the folder each is in - see shareImages.ts. */
3205
+ const SHARE_IMAGES: Record<string, { openGraph?: string; twitter?: string }> = ${JSON.stringify(shareImageUrls(foundAssets))}
3206
+
3149
3207
  const WEB_MANIFEST: { href: string; themeColor?: string } | null = ${JSON.stringify(webManifestOptions
3150
3208
  ? {
3151
3209
  href: MANIFEST_PATH,
@@ -4146,13 +4204,35 @@ async function renderTree(
4146
4204
  }
4147
4205
 
4148
4206
  for (const [i, found] of APP_HEAD.entries()) {
4149
- const props = { ...found.props }
4207
+ head.push(createElement(found.tag, { key: '__a' + i, ...found.props }))
4208
+ }
4150
4209
 
4151
- if (props.content && /^(og:image|twitter:image)$/.test(props.property ?? props.name ?? '')) {
4152
- props.content = absolute(props.content)
4153
- }
4210
+ // The share image files found in app/. The nearest to this page is its
4211
+ // card - unless its metadata exports images from a segment nearer still,
4212
+ // in which case the file is left out and the exported ones render, so a
4213
+ // page that names its own image replaces the site's instead of following
4214
+ // it. Scrapers read the first og:image they meet; a second after the
4215
+ // site's would be a card that never changes.
4216
+ const folder = folderOf(component)
4217
+ const sources = md ? imageSources.get(md) : undefined
4218
+ const shared = (kind: 'openGraph' | 'twitter') => {
4219
+ const file = nearestShareImage(SHARE_IMAGES, folder, kind)
4220
+ const source = sources?.[kind]
4221
+
4222
+ return fileWins(file, source?.exports ? source : null) ? file : null
4223
+ }
4224
+ const ogFile = shared('openGraph')
4225
+ const twitterFile = shared('twitter')
4154
4226
 
4155
- head.push(createElement(found.tag, { key: '__a' + i, ...props }))
4227
+ if (ogFile) head.push(createElement('meta', { key: '__og', property: 'og:image', content: absolute(ogFile.href) }))
4228
+
4229
+ if (twitterFile) {
4230
+ head.push(createElement('meta', { key: '__tw', name: 'twitter:image', content: absolute(twitterFile.href) }))
4231
+
4232
+ // The card a twitter image implies, unless the page named its own.
4233
+ if ((md?.twitter as { card?: unknown } | undefined)?.card == null) {
4234
+ head.push(createElement('meta', { key: '__twc', name: 'twitter:card', content: 'summary_large_image' }))
4235
+ }
4156
4236
  }
4157
4237
 
4158
4238
  if (md) {
@@ -4252,7 +4332,7 @@ async function renderTree(
4252
4332
  if (og.siteName != null) head.push(tag('og:site_name', og.siteName))
4253
4333
  if (og.type != null) head.push(tag('og:type', og.type))
4254
4334
  if (og.locale != null) head.push(tag('og:locale', og.locale))
4255
- if (og.images != null) images('og:image', og.images)
4335
+ if (og.images != null && !ogFile) images('og:image', og.images)
4256
4336
  }
4257
4337
 
4258
4338
  if (md.twitter) {
@@ -4263,7 +4343,7 @@ async function renderTree(
4263
4343
  if (tw.description != null) head.push(tag('twitter:description', tw.description))
4264
4344
  if (tw.site != null) head.push(tag('twitter:site', tw.site))
4265
4345
  if (tw.creator != null) head.push(tag('twitter:creator', tw.creator))
4266
- if (tw.images != null) images('twitter:image', tw.images)
4346
+ if (tw.images != null && !twitterFile) images('twitter:image', tw.images)
4267
4347
  }
4268
4348
 
4269
4349
  // icons is links, not meta. A string is one icon; an object names which
@@ -5156,6 +5236,17 @@ const METADATA_PROBE_GRACE_MS = 20
5156
5236
 
5157
5237
  const METADATA_POSTPONED = Symbol('metadata postponed')
5158
5238
 
5239
+ /**
5240
+ * For each of openGraph and twitter, how deep the segment that supplied the
5241
+ * merged object is, and whether it exports images - the other half of the
5242
+ * comparison a share image file is held to. Beside the metadata rather than
5243
+ * inside it, so the object a page's tags are rendered from stays only what
5244
+ * the app wrote.
5245
+ */
5246
+ type ImageSources = { openGraph?: { depth: number; exports: boolean }; twitter?: { depth: number; exports: boolean } }
5247
+
5248
+ const imageSources = new WeakMap<object, ImageSources>()
5249
+
5159
5250
  export async function resolveMetadata(
5160
5251
  component: string,
5161
5252
  props: Record<string, unknown> = {},
@@ -5220,18 +5311,27 @@ export async function resolveMetadata(
5220
5311
  // happened to declare one of its own.
5221
5312
  const merged: Record<string, unknown> = {}
5222
5313
  const other: Record<string, unknown> = {}
5314
+ const sources: ImageSources = {}
5223
5315
 
5224
- const take = (from: Record<string, unknown>) => {
5316
+ const take = (from: Record<string, unknown>, depth: number) => {
5225
5317
  for (const [k, v] of Object.entries(from)) {
5226
5318
  if (k === 'title') continue
5227
5319
  if (k === 'other') Object.assign(other, v as Record<string, unknown>)
5228
5320
  else merged[k] = v
5321
+
5322
+ // openGraph and twitter are replaced whole by a nearer segment's, so
5323
+ // whichever segment took them last is the one whose images - or lack of
5324
+ // them - the merged object carries. That segment's depth is what a file
5325
+ // in app/ is weighed against; see shareImages.ts.
5326
+ if (k === 'openGraph' || k === 'twitter') {
5327
+ sources[k] = { depth, exports: typeof v === 'object' && v !== null && (v as { images?: unknown }).images != null }
5328
+ }
5229
5329
  }
5230
5330
  }
5231
5331
 
5232
- for (const m of layoutMeta) take(m)
5332
+ layoutMeta.forEach((m, i) => take(m, depthOf(folderOf(layouts[i].component))))
5233
5333
 
5234
- take(page)
5334
+ take(page, depthOf(folderOf(component)))
5235
5335
 
5236
5336
  if (Object.keys(other).length > 0) merged.other = other
5237
5337
 
@@ -5256,7 +5356,11 @@ export async function resolveMetadata(
5256
5356
  }
5257
5357
  if (title != null) merged.title = title
5258
5358
 
5259
- return Object.keys(merged).length ? merged : null
5359
+ if (Object.keys(merged).length === 0) return null
5360
+
5361
+ imageSources.set(merged, sources)
5362
+
5363
+ return merged
5260
5364
  }
5261
5365
 
5262
5366
  // Buffered render (worker: rsc / rscWithoutCallbacks — used at prerender time).
@@ -5707,7 +5811,7 @@ export async function handleRscPprShell(
5707
5811
  * server rendering live pages it had already frozen. The gate is the mode, not
5708
5812
  * the file.
5709
5813
  */
5710
- ${fallbackOrigin === false ? "" : FALLBACK_CONSTS.replace("__ORIGIN__", JSON.stringify(fallbackOrigin))}${NITRO_HOST_CALLS}
5814
+ ${backendPathConsts()}${fallbackOrigin === false ? "" : FALLBACK_CONSTS.replace("__ORIGIN__", JSON.stringify(fallbackOrigin))}${NITRO_HOST_CALLS}
5711
5815
  let devHandler: ((request: Request) => Promise<Response | null>) | null = null
5712
5816
 
5713
5817
  export default async function handler(request: Request): Promise<Response> {
@@ -7584,7 +7688,8 @@ export function rscKit(options = {}) {
7584
7688
  res.end(webManifest(webManifestOptions));
7585
7689
  return;
7586
7690
  }
7587
- const asset = allAppAssets(foundAssets).find((a) => a.href === url);
7691
+ // Either spelling of a bracket: the tag says %5B, a hand-typed url says [.
7692
+ const asset = allAppAssets(foundAssets).find((a) => assetFile(a.href) === assetFile(url));
7588
7693
  const file = asset ? join(sourceDir, "app", asset.file) : null;
7589
7694
  if (!asset || !file || !existsSync(file))
7590
7695
  return next();
@@ -7671,6 +7776,7 @@ export function rscKit(options = {}) {
7671
7776
  // published without any — generic-host.test.ts fails if it reappears.
7672
7777
  return ((base !== stem && shapes.has(stem)) ||
7673
7778
  SECTION_FILE.test(base) ||
7779
+ SHARE_IMAGE_FILE.test(base) ||
7674
7780
  (routeConfig !== null && base === routeConfig.file));
7675
7781
  };
7676
7782
  const restart = (file) => {