@rsc-kit/core 0.18.0 → 0.18.1

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.d.ts CHANGED
@@ -101,6 +101,17 @@ export interface RscKitOptions {
101
101
  * reads this file follows the server instead of guessing at it.
102
102
  */
103
103
  hotFile?: string;
104
+ /**
105
+ * Packages the server bundles import at runtime rather than inline.
106
+ *
107
+ * For a dependency with a native binary, one that spawns a process, or one
108
+ * that reads files beside itself - anything a bundle breaks. A default list
109
+ * covers the usual ones (sharp, bcrypt, better-sqlite3, prisma, puppeteer,
110
+ * ...); this adds to it. Nitro traces an external package into
111
+ * .output/server/node_modules with its binaries, so the deployment is still
112
+ * one directory.
113
+ */
114
+ serverExternalPackages?: string[];
104
115
  /**
105
116
  * Where `rpc()` goes while `vite dev` is serving.
106
117
  *
package/dist/vite.js CHANGED
@@ -42,6 +42,47 @@ let resolvedConfig = null;
42
42
  let clientLibraryImports = [];
43
43
  /** Modules a runtime provides and no bundle should try to carry. */
44
44
  const RUNTIME_BUILTINS = ["bun", /^bun:/];
45
+ /**
46
+ * Packages the server bundles import rather than inline, by default.
47
+ *
48
+ * Each of these ships a native binary, spawns one, or reads files relative to
49
+ * its own location - none of which survives being rolled into a bundle. Left
50
+ * external, Nitro traces them into .output/server/node_modules with their
51
+ * binaries, and the built server imports them the way the package expects.
52
+ * Next keeps the same list under serverExternalPackages, for the same reason.
53
+ * `rscKit({ serverExternalPackages })` adds to it.
54
+ */
55
+ const DEFAULT_SERVER_EXTERNALS = [
56
+ "sharp",
57
+ "canvas",
58
+ "bcrypt",
59
+ "argon2",
60
+ "@node-rs/argon2",
61
+ "@node-rs/bcrypt",
62
+ "better-sqlite3",
63
+ "sqlite3",
64
+ "libsql",
65
+ "@libsql/client",
66
+ "@prisma/client",
67
+ "prisma",
68
+ "puppeteer",
69
+ "puppeteer-core",
70
+ "playwright",
71
+ "playwright-core",
72
+ "jsdom",
73
+ "node-pty",
74
+ "onnxruntime-node",
75
+ "@sentry/profiling-node",
76
+ "pdfkit",
77
+ "mongodb",
78
+ "oslo",
79
+ "@resvg/resvg-js",
80
+ "@napi-rs/canvas",
81
+ ];
82
+ /** A package name as a rollup external: the package and every subpath of it. */
83
+ function externalPackage(name) {
84
+ return new RegExp("^" + name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?:/|$)");
85
+ }
45
86
  function arrayOf(value) {
46
87
  return value == null ? [] : Array.isArray(value) ? value : [value];
47
88
  }
@@ -2068,8 +2109,15 @@ function installHostCallsOnce(): void {
2068
2109
  function instrumentationFile() {
2069
2110
  for (const ext of ["ts", "tsx", "mts", "js", "mjs"]) {
2070
2111
  const file = join(sourceDir, `instrumentation.${ext}`);
2071
- if (existsSync(file))
2072
- return file;
2112
+ if (!existsSync(file))
2113
+ continue;
2114
+ // Both halves are optional: a file of imports is a bootstrap, a file
2115
+ // with register() is a hook. The entry must only name `register` when it
2116
+ // exists - a namespace import's missing member is a bundler warning on
2117
+ // every build (IMPORT_IS_UNDEFINED), which is the app being told off for
2118
+ // a file written exactly as documented.
2119
+ const hasRegister = /export\s+(?:async\s+)?(?:function\s+register\b|const\s+register\b|let\s+register\b|\{[^}]*\bregister\b[^}]*\})/.test(readFileSync(file, "utf-8"));
2120
+ return { file, hasRegister };
2073
2121
  }
2074
2122
  return null;
2075
2123
  }
@@ -2135,10 +2183,13 @@ ${
2135
2183
  // First, before any page: an import's side effects run in import order,
2136
2184
  // and a package configured here has to be configured before a page module
2137
2185
  // that reads it at evaluation time.
2138
- instrumentation
2139
- ? `import * as __instrumentation from ${JSON.stringify(instrumentation)}`
2140
- : "const __instrumentation: { register?: () => unknown } = {}"}
2186
+ instrumentation?.hasRegister
2187
+ ? `import * as __instrumentation from ${JSON.stringify(instrumentation.file)}`
2188
+ : instrumentation
2189
+ ? `import ${JSON.stringify(instrumentation.file)}\nconst __instrumentation: { register?: () => unknown } = {}`
2190
+ : "const __instrumentation: { register?: () => unknown } = {}"}
2141
2191
  import { SegmentBoundary } from ${JSON.stringify(join(packageDir, "js/SegmentBoundary"))}
2192
+ import { LoadingBoundary } from ${JSON.stringify(join(packageDir, "js/LoadingBoundary"))}
2142
2193
  import { DocumentTitle } from ${JSON.stringify(join(packageDir, "js/DocumentTitle"))}
2143
2194
  import { SlotBoundary } from ${JSON.stringify(join(packageDir, "js/SlotBoundary"))}
2144
2195
  import { RouteErrorBoundary } from ${JSON.stringify(join(packageDir, "js/RouteErrorBoundary"))}
@@ -2675,9 +2726,17 @@ function buildElement(
2675
2726
  searchParams: checkedSearchParams(schemas, pageSearchParams()),
2676
2727
  })
2677
2728
 
2729
+ // Through LoadingBoundary when the runtime is shipped, so a server render
2730
+ // can tell the engine's boundary from one the developer wrote by name -
2731
+ // see that file. A page shipping no runtime gets a plain Suspense: a
2732
+ // client component would drag React in for a wrapper nothing can use.
2678
2733
  for (let i = loadings.length - 1; i >= 0; i--) {
2679
2734
  const Loading = components[loadings[i]]
2680
- element = createElement(Suspense, { fallback: Loading ? createElement(Loading) : null }, element)
2735
+ const fallback = Loading ? createElement(Loading) : null
2736
+
2737
+ element = bootstrap
2738
+ ? createElement(LoadingBoundary, { fallback }, element)
2739
+ : createElement(Suspense, { fallback }, element)
2681
2740
  }
2682
2741
 
2683
2742
  // Outside the Suspense boundary, innermost first — the nearest error.tsx to
@@ -4156,35 +4215,7 @@ export async function handleSsr(
4156
4215
  const html = await renderToReadableStream(root as any, {
4157
4216
  bootstrapScriptContent,
4158
4217
  nonce,
4159
- // The query-string fallback is the designed path for a stored page, and
4160
- // its digest is what lets the client tell it from a fault on hydration;
4161
- // returned here so React writes it into the document.
4162
- onError: onError ?? ((error: unknown, info?: { componentStack?: string }) => {
4163
- // The consumer cancelled - a browser that left mid-stream, a prefetch
4164
- // abandoned. React reports it as an error; the page had none.
4165
- if (cancelledByConsumer(error)) return
4166
- const digest = (error as { digest?: string } | null)?.digest
4167
- if (digest === 'rsc-kit:search-params-fallback') {
4168
- // The component is the first frame of React's stack. Noted on the
4169
- // request so the build attaches it to the route; printed as one line,
4170
- // not a stack, so the dev server says which boundary the build wants.
4171
- const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4172
- noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
4173
- // Under a boundary the developer wrote, nothing to say. With nothing
4174
- // closer than a loading.tsx, one line: the whole segment is the
4175
- // fallback until the query arrives.
4176
- if (caughtByLoading(info?.componentStack)) {
4177
- console.error(
4178
- '[rsc-kit] ' + (where ? where + ': ' : '') +
4179
- 'useSearchParams() was read on the server with nothing closer than a loading.tsx, so the whole ' +
4180
- 'segment shows that fallback until the query arrives. A <Suspense> around the component that reads ' +
4181
- 'keeps the rest of the page painted.',
4182
- )
4183
- }
4184
- return digest
4185
- }
4186
- console.error('[rsc-kit:ssr]', error)
4187
- }),
4218
+ onError: onError ?? reportRenderError('ssr'),
4188
4219
  })
4189
4220
 
4190
4221
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
@@ -4236,6 +4267,46 @@ export async function handleSsrPrerender(
4236
4267
  }
4237
4268
  }
4238
4269
 
4270
+ /**
4271
+ * What a server render says about an error, first render and resume alike.
4272
+ *
4273
+ * The query-string fallback is the designed path for a stored page, and its
4274
+ * digest is what lets the client tell it from a fault on hydration; returned
4275
+ * so React writes it into the document. Under a boundary the developer wrote
4276
+ * there is nothing to say; with nothing closer than a loading.tsx, one line.
4277
+ */
4278
+ function reportRenderError(phase: 'ssr' | 'resume') {
4279
+ return (error: unknown, info?: { componentStack?: string }) => {
4280
+ // The consumer cancelled - a browser that left mid-stream, a prefetch
4281
+ // abandoned. React reports it as an error; the page had none.
4282
+ if (cancelledByConsumer(error)) return
4283
+
4284
+ const digest = (error as { digest?: string } | null)?.digest
4285
+
4286
+ if (digest === 'rsc-kit:search-params-fallback') {
4287
+ // The component is the first frame of React's stack. Noted on the
4288
+ // request so the build attaches it to the route; printed as one line,
4289
+ // not a stack, so the server says which boundary it wants.
4290
+ const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4291
+
4292
+ noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
4293
+
4294
+ if (caughtByLoading(info?.componentStack)) {
4295
+ console.error(
4296
+ '[rsc-kit] ' + (where ? where + ': ' : '') +
4297
+ 'useSearchParams() was read on the server with nothing closer than a loading.tsx, so the whole ' +
4298
+ 'segment shows that fallback until the query arrives. A <Suspense> around the component that reads ' +
4299
+ 'keeps the rest of the page painted.',
4300
+ )
4301
+ }
4302
+
4303
+ return digest
4304
+ }
4305
+
4306
+ console.error('[rsc-kit:' + phase + ']', error)
4307
+ }
4308
+ }
4309
+
4239
4310
  /**
4240
4311
  * Pick a build-time render back up, against data that exists now.
4241
4312
  *
@@ -4255,7 +4326,12 @@ export async function handleSsrResume(
4255
4326
 
4256
4327
  const html = await resume(root as any, postponed as any, {
4257
4328
  nonce,
4258
- onError: (error: unknown) => { console.error('[rsc-kit:resume]', error) },
4329
+ // The same reading of an error the first render has. This used to log
4330
+ // every error raw, so a query read the developer's own boundary caught -
4331
+ // the designed path, on every resume of a shell whose hole holds one -
4332
+ // printed as "[rsc-kit:resume] Error: useSearchParams() was read..." on
4333
+ // every request, with advice the app had already followed.
4334
+ onError: reportRenderError('resume'),
4259
4335
  })
4260
4336
 
4261
4337
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
@@ -4790,8 +4866,51 @@ export default function rscKitStartup() {
4790
4866
  })
4791
4867
  }
4792
4868
  `;
4869
+ /**
4870
+ * Where NODE_ENV=development came from, and what to do about it.
4871
+ *
4872
+ * Vite reads NODE_ENV from its env files in this order, later winning, and
4873
+ * only when the process did not already set it - so the last file that
4874
+ * names it is the one that decided. No file naming it means the shell or
4875
+ * `--mode` did.
4876
+ */
4877
+ function nodeEnvSetIn(mode, envDir) {
4878
+ const files = [".env", ".env.local", `.env.${mode}`, `.env.${mode}.local`];
4879
+ let culprit = null;
4880
+ // The project's root, not Vite's: this plugin points Vite's root at the
4881
+ // build directory, and the app's .env is read from the project root - by
4882
+ // Nitro's dotenv loading into process.env, and by Vite where envDir says
4883
+ // so. Both places are looked at; the project's is where the file is.
4884
+ const dirs = [...new Set([projectRoot, envDir || projectRoot])];
4885
+ for (const dir of dirs)
4886
+ for (const name of files) {
4887
+ const path = join(dir, name);
4888
+ if (!existsSync(path))
4889
+ continue;
4890
+ const lines = readFileSync(path, "utf-8").split("\n");
4891
+ const at = lines.findIndex((line) => /^\s*(?:export\s+)?NODE_ENV\s*=/.test(line));
4892
+ if (at !== -1)
4893
+ culprit = `${path}:${at + 1}`;
4894
+ }
4895
+ return culprit;
4896
+ }
4897
+ function refuseDevelopmentBuild(config) {
4898
+ const culprit = nodeEnvSetIn(config.mode, config.envDir);
4899
+ return ("[rsc-kit] vite build is running as a development build, and the output cannot work: " +
4900
+ "the pages are compiled against React's development JSX runtime (jsxDEV), the server " +
4901
+ "bundles carry React's production build, and every route fails to render.\n\n" +
4902
+ (culprit
4903
+ ? `NODE_ENV=development is set in ${culprit}. Remove that line - Vite sets NODE_ENV ` +
4904
+ "itself: development under `vite`, production under `vite build`."
4905
+ : `NODE_ENV is "${process.env.NODE_ENV ?? ""}" from the environment or --mode ` +
4906
+ "(mode: " + JSON.stringify(config.mode) + "). Build with NODE_ENV=production, or unset it."));
4907
+ }
4793
4908
  export function rscKit(options = {}) {
4794
4909
  resolvePaths(options);
4910
+ const serverExternals = [
4911
+ ...RUNTIME_BUILTINS,
4912
+ ...[...DEFAULT_SERVER_EXTERNALS, ...(options.serverExternalPackages ?? [])].map(externalPackage),
4913
+ ];
4795
4914
  const routesPlugin = {
4796
4915
  name: "rsc-kit",
4797
4916
  // A Nitro module, which Nitro's Vite plugin collects from any plugin
@@ -5007,7 +5126,7 @@ export function rscKit(options = {}) {
5007
5126
  build: {
5008
5127
  rollupOptions: {
5009
5128
  input: { index: join(genDir, "entry.rsc.tsx") },
5010
- external: RUNTIME_BUILTINS,
5129
+ external: serverExternals,
5011
5130
  },
5012
5131
  },
5013
5132
  },
@@ -5015,7 +5134,7 @@ export function rscKit(options = {}) {
5015
5134
  build: {
5016
5135
  rollupOptions: {
5017
5136
  input: { index: join(genDir, "entry.ssr.tsx") },
5018
- external: RUNTIME_BUILTINS,
5137
+ external: serverExternals,
5019
5138
  },
5020
5139
  },
5021
5140
  },
@@ -5254,6 +5373,24 @@ export function rscKit(options = {}) {
5254
5373
  }
5255
5374
  },
5256
5375
  configResolved(config) {
5376
+ // A build that is not a production build cannot work here, and the
5377
+ // way it fails is opaque: plugin-react emits the development JSX
5378
+ // runtime (jsxDEV), the server bundles resolve React's production
5379
+ // build, and every route fails to render with React's "message
5380
+ // omitted in production builds". The usual cause is NODE_ENV=development
5381
+ // in a .env file, which Vite honours - a line the scaffold itself once
5382
+ // wrote. Named, with the file and line, before any of that happens.
5383
+ //
5384
+ // Refused rather than overridden, because a plugin cannot override it:
5385
+ // Vite notes whether NODE_ENV was set before it loads the config file
5386
+ // and applies the .env value after the config hooks have run, so an
5387
+ // assignment here is overwritten - and patching the resolved config
5388
+ // afterwards leaves the client bundle's process.env.NODE_ENV and
5389
+ // import.meta.env.DEV already decided, which is a production server
5390
+ // serving a development client.
5391
+ if (config.command === "build" && !config.isProduction) {
5392
+ throw new Error(refuseDevelopmentBuild(config));
5393
+ }
5257
5394
  isWatch = config.build?.watch != null;
5258
5395
  resolvedConfig = config;
5259
5396
  const nitroMain = config.plugins.find((p) => p.name === "nitro:main");