create-rsc-kit 0.5.0 → 0.6.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.
package/dist/index.js CHANGED
@@ -122,7 +122,6 @@ function write(o) {
122
122
  ['package.json', t.packageJson(o)],
123
123
  ['tsconfig.json', t.tsconfig(o)],
124
124
  ['vite.config.ts', t.viteConfig(o)],
125
- [t.serverFile(o.host), t.server(o)],
126
125
  ['.gitignore', t.gitignore],
127
126
  ['README.md', t.readme(o)],
128
127
  ['src/app/layout.tsx', t.layout(o)],
@@ -167,7 +166,8 @@ function report(o) {
167
166
  `cd ${relativeish(o.dir)}`,
168
167
  ...(o.install ? [] : [o.host === 'node' ? 'npm install' : 'bun install']),
169
168
  `${pm} build`,
170
- `${pm} start`,
169
+ // A Worker has no start: wrangler runs it on workerd, or deploys it.
170
+ ...(o.host === 'worker' ? [`${pm} preview`, `${pm} deploy`] : [`${pm} start`]),
171
171
  ];
172
172
  stdout.write(`\n${bold('Done.')} ${dim(o.dir)}\n\n`);
173
173
  for (const step of steps)
package/dist/init.js CHANGED
@@ -14,7 +14,9 @@ import { cwd, exit, stdout } from 'node:process';
14
14
  import { DEFAULT_COMPILER, parseArgs, publishedCore } from './options.js';
15
15
  import { Prompter, bold, cyan, dim } from './prompt.js';
16
16
  import * as t from './templates.js';
17
- const HOST_PACKAGES = { hono: 'hono', elysia: 'elysia' };
17
+ // Nothing here detects a host any more: a host is a Nitro preset, and Nitro
18
+ // is added to whatever project this runs in.
19
+ const HOST_PACKAGES = {};
18
20
  /**
19
21
  * What is already here.
20
22
  *
@@ -233,8 +235,6 @@ function viteConfig(o, found, dir) {
233
235
  const shown = [
234
236
  `sourceDir: '${p.sourceDir}'`,
235
237
  `outDir: '${p.outDir}'`,
236
- `assetsDir: '${p.assetsDir}'`,
237
- ...(p.assetsUrl ? [`assetsUrl: '${p.assetsUrl}'`] : []),
238
238
  ...(p.hotFile ? [`hotFile: '${p.hotFile}'`] : []),
239
239
  ].join(', ');
240
240
  return [
@@ -242,35 +242,14 @@ function viteConfig(o, found, dir) {
242
242
  kind: 'manual',
243
243
  what: existing,
244
244
  detail: `add the plugin — it must come before any react() layer:\n` +
245
- ` import { rscRoutes } from '@rsc-kit/core/vite'\n\n` +
245
+ ` import { rscKit } from '@rsc-kit/core/vite'\n\n` +
246
246
  ` plugins: [\n` +
247
- ` rscRoutes({ ${shown} }),\n` +
247
+ ` rscKit({ ${shown} }),\n` +
248
248
  ` …whatever you already have\n` +
249
249
  ` ]`,
250
250
  },
251
251
  ];
252
252
  }
253
- /** The server: written only when there is nothing there to break. */
254
- function server(o, dir) {
255
- const file = t.serverFile(o.host);
256
- if (existsSync(join(dir, file))) {
257
- return [
258
- {
259
- kind: 'manual',
260
- what: file,
261
- detail: 'left alone. Mount the handler in it — anything the route table does not\n' +
262
- ' claim comes back null, so your own routes still win:\n\n' +
263
- t
264
- .server(o)
265
- .split('\n')
266
- .map((line) => ' ' + line)
267
- .join('\n'),
268
- },
269
- ];
270
- }
271
- writeFileSync(join(dir, file), t.server(o));
272
- return [{ kind: 'wrote', what: file }];
273
- }
274
253
  /** The route tree, only where there is not one already. */
275
254
  function routes(o, dir) {
276
255
  const appDir = join(dir, o.sourceDir, 'app');
@@ -306,22 +285,22 @@ function routes(o, dir) {
306
285
  * everything else.
307
286
  */
308
287
  function tsconfig(o, found, dir) {
288
+ const steps = [];
309
289
  if (found.hasTypeScript) {
310
- return [{ kind: 'skipped', what: 'tsconfig.json', detail: 'already here' }];
290
+ return [...steps, { kind: 'skipped', what: 'tsconfig.json', detail: 'already here' }];
311
291
  }
312
292
  writeFileSync(join(dir, 'tsconfig.json'), t.tsconfig(o));
313
- return [{ kind: 'wrote', what: 'tsconfig.json' }];
293
+ return [...steps, { kind: 'wrote', what: 'tsconfig.json' }];
314
294
  }
315
295
  /** Ignore the files the build rewrites into the source dir on every run. */
316
296
  function gitignore(o, dir) {
317
297
  const path = join(dir, '.gitignore');
318
298
  const generated = ['rsc-env.d.ts', 'rsc-types.d.ts', 'rsc-routes.d.ts', 'rsc-engine.d.ts'].map((f) => `${o.sourceDir}/${f}`);
319
299
  const p = t.paths(o);
320
- // Everything the build writes. On Laravel that is three separate places —
321
- // the bundles, the browser assets under public/, and the hot file — and a
322
- // committed hot file is the worst of them: it points every other machine at
323
- // a dev server that is not running there.
324
- const all = [p.outDir, p.assetsDir, ...(p.hotFile ? [p.hotFile] : [])];
300
+ // Everything the build writes: the bundles, Nitro's output, and the hot
301
+ // file. The hot file is the worst of them to commit — it points every other
302
+ // machine at a dev server that is not running there.
303
+ const all = ['.output', p.outDir, ...(p.hotFile ? [p.hotFile] : [])];
325
304
  const outputs = all.filter((path) => !all.some((other) => other !== path && path.startsWith(other + '/')));
326
305
  const current = existsSync(path) ? readFileSync(path, 'utf-8') : '';
327
306
  const missing = [...generated, ...outputs].filter((line) => !current.split('\n').some((existing) => existing.trim() === line));
@@ -337,7 +316,6 @@ export function initialise(o, found, dir) {
337
316
  const steps = [
338
317
  ...routes(o, dir),
339
318
  ...viteConfig(o, found, dir),
340
- ...server(o, dir),
341
319
  ...tsconfig(o, found, dir),
342
320
  ...gitignore(o, dir),
343
321
  ...mergeDependencies(o, found),
@@ -433,6 +411,6 @@ export async function runInit(args) {
433
411
  return;
434
412
  }
435
413
  stdout.write(options.host === 'laravel'
436
- ? `\n ${cyan('npm install')}, then ${cyan('npm run rsc:dev')} — and open the app at its own domain.\n\n`
414
+ ? `\n ${cyan('npm install')}, then ${cyan('npm run dev')} — and open the app at its own domain.\n\n`
437
415
  : `\n ${cyan('bun install')} and you are ready.\n\n`);
438
416
  }
package/dist/options.d.ts CHANGED
@@ -1,4 +1,12 @@
1
- export type Host = 'bun' | 'hono' | 'elysia' | 'node' | 'laravel';
1
+ /**
2
+ * Where the app runs, which is now a Nitro preset rather than a server we write.
3
+ *
4
+ * hono and elysia are gone. They were never deployment targets — they were
5
+ * "mount the handler inside my framework", and Nitro owns the server now.
6
+ * createRscHandler is still exported, so mounting it by hand is still a thing
7
+ * anyone can do; there is simply no template for it.
8
+ */
9
+ export type Host = 'bun' | 'node' | 'worker' | 'laravel';
2
10
  export type Compiler = 'none' | 'oxc' | 'babel';
3
11
  export interface Options {
4
12
  dir: string;
package/dist/options.js CHANGED
@@ -10,10 +10,9 @@ import { fileURLToPath } from 'node:url';
10
10
  * question with a wrong answer available.
11
11
  */
12
12
  export const HOSTS = [
13
- { value: 'bun', label: 'Bun.serve', hint: 'no framework, fastest to start' },
14
- { value: 'hono', label: 'Hono', hint: 'also what a Worker or Deno would use' },
15
- { value: 'elysia', label: 'Elysia', hint: 'Bun-first, typed routes of its own' },
16
- { value: 'node', label: 'node:http', hint: 'no Bun, no framework' },
13
+ { value: 'bun', label: 'Bun', hint: 'and compiles to a single binary' },
14
+ { value: 'node', label: 'Node', hint: 'the default everywhere else' },
15
+ { value: 'worker', label: 'Cloudflare Workers', hint: 'no filesystem — assets from a binding' },
17
16
  ];
18
17
  /**
19
18
  * Not asked as a three-way.
@@ -4,21 +4,22 @@ export interface Paths {
4
4
  sourceDir: string;
5
5
  /** Where the bundles land. The server imports the rsc one from here. */
6
6
  outDir: string;
7
- /** Where browser assets are written. */
8
- assetsDir: string;
9
- /** The url they are served under, when it is not Vite's default. */
10
- assetsUrl?: string;
11
7
  /** Written while the dev server runs, for a backend that has to find it. */
12
8
  hotFile?: string;
13
9
  }
14
10
  /**
15
- * Where this host's build writes, and where its server reads.
11
+ * Where this host's build writes.
16
12
  *
17
- * One function because the two have to agree and nothing checks that they do:
18
- * an assetsDir the server does not serve 404s every asset while every page
19
- * still renders, so the page looks right and nothing hydrates. Laravel's
20
- * differ from the rest because public/ is already the browser's root and
21
- * bootstrap/ is already where a Laravel app keeps generated code.
13
+ * There is no assetsDir or assetsUrl any more. Nitro publishes the browser
14
+ * assets to .output/public and serves them from its own root, so naming a
15
+ * directory or a prefix here would be read and then ignored — the markup asks
16
+ * for the app's path, Nitro answers at its own, and every asset 404s while
17
+ * every page still renders. rscKit() refuses the pair outright rather than
18
+ * letting that happen quietly.
19
+ *
20
+ * Laravel keeps its own outDir, because bootstrap/ is already where a Laravel
21
+ * application puts generated code, and its own hotFile, because the framework
22
+ * has to find a running dev server.
22
23
  */
23
24
  export declare function paths(o: Options): Paths;
24
25
  /**
@@ -42,10 +43,19 @@ export declare const configFile: (o: Options) => string;
42
43
  export declare function scripts(o: Options): Record<string, string>;
43
44
  export declare function packageJson(o: Options): string;
44
45
  /** One server per app, so it needs no qualifier. */
46
+ /**
47
+ * The Nitro preset for a host.
48
+ *
49
+ * This is the whole of what a host is now. Nitro carries presets for Vercel,
50
+ * Netlify, Azure, Deno and the rest, and none of them need anything here — a
51
+ * preset is a string in a config file, not a server we write and keep working.
52
+ */
53
+ export declare const preset: (host: Host) => string;
54
+ /** @deprecated Nothing generates a server file. Kept until callers are gone. */
45
55
  export declare const serverFile: (_host: Host) => string;
46
56
  export declare function viteConfig(o: Options): string;
57
+ export declare const WRANGLER_FILE = "wrangler.toml";
47
58
  export declare const tsconfig: (o: Options) => string;
48
- export declare function server(o: Options): string;
49
59
  export declare function layout(o: Options): string;
50
60
  export declare function page(o: Options): string;
51
61
  export declare function counter(o: Options): string;
package/dist/templates.js CHANGED
@@ -5,23 +5,26 @@
5
5
  // layout — and a template directory would need one copy per combination.
6
6
  const PORT = 3000;
7
7
  /**
8
- * Where this host's build writes, and where its server reads.
8
+ * Where this host's build writes.
9
9
  *
10
- * One function because the two have to agree and nothing checks that they do:
11
- * an assetsDir the server does not serve 404s every asset while every page
12
- * still renders, so the page looks right and nothing hydrates. Laravel's
13
- * differ from the rest because public/ is already the browser's root and
14
- * bootstrap/ is already where a Laravel app keeps generated code.
10
+ * There is no assetsDir or assetsUrl any more. Nitro publishes the browser
11
+ * assets to .output/public and serves them from its own root, so naming a
12
+ * directory or a prefix here would be read and then ignored — the markup asks
13
+ * for the app's path, Nitro answers at its own, and every asset 404s while
14
+ * every page still renders. rscKit() refuses the pair outright rather than
15
+ * letting that happen quietly.
16
+ *
17
+ * Laravel keeps its own outDir, because bootstrap/ is already where a Laravel
18
+ * application puts generated code, and its own hotFile, because the framework
19
+ * has to find a running dev server.
15
20
  */
16
21
  export function paths(o) {
17
22
  if (o.host !== 'laravel') {
18
- return { sourceDir: o.sourceDir, outDir: 'build', assetsDir: 'build/public' };
23
+ return { sourceDir: o.sourceDir, outDir: 'build' };
19
24
  }
20
25
  return {
21
26
  sourceDir: o.sourceDir,
22
27
  outDir: 'bootstrap/rsc/vite',
23
- assetsDir: 'public/build/rsc-vite',
24
- assetsUrl: '/build/rsc-vite/',
25
28
  hotFile: 'public/rsc-hot',
26
29
  };
27
30
  }
@@ -44,36 +47,36 @@ export const configFile = (o) => o.host === 'laravel' ? 'vite.rsc.config.ts' : '
44
47
  * wrote themselves.
45
48
  */
46
49
  export function scripts(o) {
47
- const run = o.host === 'node' ? 'node' : 'bun run';
48
- const p = paths(o);
49
50
  if (o.host === 'laravel') {
50
51
  const config = `--config ${configFile(o)}`;
51
- // The build cannot discover the app's server actions — reflection through
52
- // Composer's autoloader is the only thing that sees what a class inherits
53
- // from its parents and traits — so PHP writes them out first and the
54
- // plugin reads the file. Part of the command rather than a step to
55
- // remember: a stale map names a method that has since been renamed, and
56
- // nothing fails until the browser calls it.
57
52
  const actions = 'php artisan rsc:action-manifest';
58
53
  return {
59
54
  // The ordinary names. A Laravel application already has `dev` and
60
55
  // `build`, and init combines rather than replaces — the stock ones run
61
56
  // the asset pipeline, and both pipelines belong to `npm run dev`.
62
- // Only a script somebody actually wrote gets left alone, and then the
63
- // RSC one takes an `rsc:` name and says so.
64
57
  dev: `${actions} && vite ${config}`,
65
58
  build: `${actions} && vite build ${config}`,
66
- start: `${run} ${serverFile(o.host)}`,
59
+ // What the build wrote. There is no server file to start any more.
60
+ start: 'bun .output/server/index.mjs',
67
61
  };
68
62
  }
69
63
  return {
70
- // Vite serves it: modules are re-evaluated on edit, and adding a
71
- // page restarts to pick up the new route table. Nothing is prebuilt,
72
- // so there is no NODE_ENV to keep in step with a build.
73
64
  dev: 'vite',
74
65
  build: 'vite build',
75
- start: `${run} ${serverFile(o.host)}`,
76
- prerender: `rsc-kit prerender --out ${p.outDir}`,
66
+ ...(o.host === 'worker'
67
+ ? { preview: 'wrangler dev .output/server/index.mjs', deploy: 'nitro deploy --prebuilt' }
68
+ : {
69
+ start: `${o.host === 'node' ? 'node' : 'bun'} .output/server/index.mjs`,
70
+ // Bun only, and only because serveStatic: 'inline' is set in the vite
71
+ // config. Without that the binary compiles, serves pages, and 404s
72
+ // every asset — the static path resolves into Bun's virtual
73
+ // filesystem, where the files on disk are not.
74
+ ...(o.host === 'bun'
75
+ ? { compile: `bun build --compile .output/server/index.mjs --outfile ${o.name}` }
76
+ : {}),
77
+ }),
78
+ typecheck: 'tsc --noEmit',
79
+ ...(o.lint ? { lint: 'oxlint src --fix', 'lint:check': 'oxlint src --deny-warnings' } : {}),
77
80
  };
78
81
  }
79
82
  export function packageJson(o) {
@@ -82,15 +85,19 @@ export function packageJson(o) {
82
85
  react: '^19.2.5',
83
86
  'react-dom': '^19.2.5',
84
87
  };
85
- if (o.host === 'hono')
86
- deps.hono = '^4.13.5';
87
- if (o.host === 'elysia')
88
- deps.elysia = '^1.4.30';
89
88
  const dev = {
90
89
  '@types/react': '^19.2.18',
91
90
  '@types/react-dom': '^19.2.7',
92
91
  typescript: '^7.0.2',
93
92
  vite: '^8.1.5',
93
+ // Pinned, and to a beta on purpose. Nitro's own `latest` tag is a dated
94
+ // prerelease that sorts ABOVE the plain 3.0.0 on npm, so `^3.0.0` quietly
95
+ // resolves to the older one — which builds without complaint and then 404s
96
+ // every route. TanStack Start pins a dated beta for the same reason.
97
+ nitro: '3.0.260903-beta',
98
+ ...(o.host === 'worker'
99
+ ? { wrangler: '^4.0.0', '@cloudflare/workers-types': '^4.0.0' }
100
+ : {}),
94
101
  // Not redundant, though it is also the engine's peer: the generated entry
95
102
  // imports '@vitejs/plugin-rsc/rsc' by specifier, so it has to resolve from
96
103
  // the app. bun hoists peers and makes that work by accident; npm does not,
@@ -134,6 +141,15 @@ export function packageJson(o) {
134
141
  }
135
142
  const sorted = (o) => Object.fromEntries(Object.entries(o).sort(([a], [b]) => a.localeCompare(b)));
136
143
  /** One server per app, so it needs no qualifier. */
144
+ /**
145
+ * The Nitro preset for a host.
146
+ *
147
+ * This is the whole of what a host is now. Nitro carries presets for Vercel,
148
+ * Netlify, Azure, Deno and the rest, and none of them need anything here — a
149
+ * preset is a string in a config file, not a server we write and keep working.
150
+ */
151
+ export const preset = (host) => host === 'worker' ? 'cloudflare_module' : host === 'node' ? 'node' : 'bun';
152
+ /** @deprecated Nothing generates a server file. Kept until callers are gone. */
137
153
  export const serverFile = (_host) => 'server.ts';
138
154
  export function viteConfig(o) {
139
155
  const imports = ["import { defineConfig } from 'vite'"];
@@ -145,20 +161,23 @@ export function viteConfig(o) {
145
161
  imports.push("import babel from '@rolldown/plugin-babel'");
146
162
  if (o.tailwind)
147
163
  imports.push("import tailwindcss from '@tailwindcss/vite'");
148
- imports.push("import { rscRoutes } from '@rsc-kit/core/vite'");
164
+ imports.push("import { rscKit } from '@rsc-kit/core/vite'");
165
+ imports.push("import { nitro } from 'nitro/vite'");
149
166
  const p = paths(o);
150
- // Every path the build writes to, and the two the server has to agree with.
151
- // Written out rather than defaulted so they are editable in one place — and
152
- // so the pair that has no error case, assetsDir and assetsUrl, is visible
153
- // together.
167
+ // Where the build writes. Assets are not here: Nitro publishes those to
168
+ // .output/public and serves them itself.
154
169
  const options = [
155
170
  `sourceDir: '${p.sourceDir}'`,
156
171
  `outDir: '${p.outDir}'`,
157
- `assetsDir: '${p.assetsDir}'`,
158
- ...(p.assetsUrl ? [`assetsUrl: '${p.assetsUrl}'`] : []),
159
172
  ...(p.hotFile ? [`hotFile: '${p.hotFile}'`] : []),
160
173
  ];
161
- plugins.push(`rscRoutes({
174
+ // Nitro leads: it builds the server around the rsc entry's default export,
175
+ // and rscKit tells plugin-rsc not to install a handler competing for that
176
+ // role. serveStatic: 'inline' embeds the built assets, without which a
177
+ // compiled binary serves pages and 404s every asset.
178
+ plugins.push(`nitro({ preset: ${JSON.stringify(preset(o.host))}, serveStatic: 'inline' })`);
179
+ plugins.push(`rscKit({
180
+ nitro: true,
162
181
  ${options.join(',\n ')},
163
182
  })`);
164
183
  plugins.push(o.compiler === 'oxc' ? 'react({ compiler: true })' : 'react()');
@@ -175,6 +194,7 @@ export default defineConfig({
175
194
  })
176
195
  `;
177
196
  }
197
+ export const WRANGLER_FILE = 'wrangler.toml';
178
198
  export const tsconfig = (o) => JSON.stringify({
179
199
  compilerOptions: {
180
200
  target: 'ESNext',
@@ -183,218 +203,31 @@ export const tsconfig = (o) => JSON.stringify({
183
203
  jsx: 'react-jsx',
184
204
  strict: true,
185
205
  noEmit: true,
206
+ // Vite hands each file to esbuild alone, with no view of any other, so
207
+ // the type system should only allow what a single-file transpiler can
208
+ // actually carry out — re-exporting a type without `export type`, a
209
+ // const enum, a file that is a script rather than a module.
210
+ isolatedModules: true,
211
+ moduleDetection: 'force',
212
+ // An import means what it says. Without this, `import { Thing }` where
213
+ // Thing is only a type is erased silently — and in an RSC app the
214
+ // difference between an erased import and a real one is the difference
215
+ // between a type reference and dragging a server module into the client
216
+ // bundle. Requiring `import type` makes the graph split something you
217
+ // can see in the source rather than infer from the output.
218
+ verbatimModuleSyntax: true,
186
219
  skipLibCheck: true,
187
220
  resolveJsonModule: true,
188
- types: o.host === 'node' ? ['node', 'vite/client'] : ['@types/bun', 'vite/client'],
221
+ types: o.host === 'worker'
222
+ ? // A Worker has neither node globals nor Bun's. Its own types, and
223
+ // vite/client for import.meta.env.
224
+ ['@cloudflare/workers-types', 'vite/client']
225
+ : o.host === 'node'
226
+ ? ['node', 'vite/client']
227
+ : ['@types/bun', 'vite/client'],
189
228
  },
190
- include: [`${o.sourceDir}/**/*`, serverFile(o.host)],
229
+ include: [`${o.sourceDir}/**/*`],
191
230
  }, null, 2) + '\n';
192
- const HANDLER = `const rsc = createRscHandler({
193
- engine,
194
- assets: assetsFrom('./build/public'),
195
- // Served from disk when a page was frozen at build time; rendered now when
196
- // it was not.
197
- prerendered: prerenderedFrom('./build/static'),
198
- })`;
199
- const IMPORTS = `import { createRscHandler } from '@rsc-kit/core/host'
200
- import { assetsFrom, prerenderedFrom } from '@rsc-kit/core/files'
201
-
202
- // Statically imported, not \`import(variable)\`: a bundler cannot see through a
203
- // variable, so \`bun build --compile\` would leave the engine out of the binary.
204
- //
205
- // No NODE_ENV to set before it. The build bakes the mode it ran in into the
206
- // bundle, so this server is production because it was built that way — not
207
- // because whoever started it remembered to say so.
208
- import * as engine from './build/dist/rsc/index.js'`;
209
- /**
210
- * The renderer for an app whose data lives in PHP.
211
- *
212
- * Different in kind from the others, not just in wiring: those servers ARE the
213
- * application, and this one renders for an application it talks to. Every
214
- * rpc() a server component makes leaves this process as a POST carrying the
215
- * visitor's own cookie, so the session, the user and the authorization are
216
- * Laravel's — this side holds no database connection and no session.
217
- *
218
- * Only production runs it. In development `vite` is the renderer, and Laravel
219
- * finds it through the hot file.
220
- */
221
- function backedServer(o) {
222
- const p = paths(o);
223
- const backend = o.backend ?? 'http://localhost';
224
- return `// The renderer for this app.
225
- //
226
- // It owns routing, rendering, prerendered pages and assets. Laravel owns the
227
- // data: every rpc() a server component makes arrives there as a POST, with
228
- // this visitor's cookie, and comes back as JSON.
229
- //
230
- // Run it beside Laravel:
231
- // bun server.ts
232
- //
233
- // Both processes need the same RSC_HOST_CALL_SECRET. Nothing else is shared.
234
-
235
- import { createBackedHandler } from '@rsc-kit/core/serve'
236
- import * as engine from './${p.outDir}/dist/rsc/index.js'
237
-
238
- const secret = process.env.RSC_HOST_CALL_SECRET
239
- // Where host calls go: the application this is rendering for.
240
- const backend = process.env.RSC_BACKEND ?? '${backend}'
241
- const port = Number(process.env.RSC_RENDERER_PORT ?? 5173)
242
-
243
- // Refused rather than defaulted. An empty secret is a host-call endpoint that
244
- // answers to anyone who can reach it, and it would fail nowhere until then.
245
- if (!secret) {
246
- console.error('RSC_HOST_CALL_SECRET must match the one Laravel is configured with.')
247
- process.exit(1)
248
- }
249
-
250
- const handle = createBackedHandler({
251
- engine,
252
- // The browser's root, and the prefix the build serves assets under. Passing
253
- // the asset folder itself 404s every asset while every page still renders —
254
- // so the page looks right and nothing hydrates.
255
- assetsDir: 'public',
256
- assetsPrefix: '${p.assetsUrl}',
257
- // Where the build's prerender writes.
258
- prerenderedDir: '${p.outDir}/static',
259
- hostCall: {
260
- endpoint: \`\${backend}/__rsc/host-call\`,
261
- secret,
262
- },
263
- // Compared by the client on every navigation, which falls back to a full
264
- // load when it changes. Without one a browser keeps talking to a deployment
265
- // that no longer exists.
266
- version: process.env.RSC_BUILD_VERSION,
267
-
268
- // A page reading \`params\` gets its url params from the engine; the query
269
- // string is merged in here, because a page asking for \`params.q\` should get
270
- // it whether it arrived in the path or after the ?.
271
- //
272
- // Read from the url rather than fetched: a page needing more than the
273
- // request carries — a loaded record, a tenant — asks for it with a host
274
- // call, because this process has no database.
275
- props: (match, request) => ({
276
- ...match.params,
277
- ...Object.fromEntries(new URL(request.url).searchParams),
278
- }),
279
- })
280
-
281
- Bun.serve({
282
- port,
283
- // Named explicitly. The default binds IPv6 only on some machines, so the
284
- // renderer answers on localhost and ::1 but not on 127.0.0.1 — which reads
285
- // as the process being down.
286
- hostname: process.env.RSC_RENDERER_HOST ?? '127.0.0.1',
287
- idleTimeout: 60,
288
- fetch: async (request) => (await handle(request)) ?? new Response('Not found', { status: 404 }),
289
- })
290
-
291
- console.log(\`renderer on http://127.0.0.1:\${port}, calling \${backend}\`)
292
- `;
293
- }
294
- export function server(o) {
295
- const host = o.host;
296
- if (host === 'laravel')
297
- return backedServer(o);
298
- if (host === 'bun') {
299
- return `${IMPORTS}
300
-
301
- ${HANDLER}
302
-
303
- Bun.serve({
304
- port: ${PORT},
305
- // Anything the route manifest does not claim comes back null and is yours.
306
- fetch: async (request) => (await rsc(request)) ?? new Response('Not found', { status: 404 }),
307
- })
308
-
309
- console.log('http://localhost:${PORT}')
310
- `;
311
- }
312
- if (host === 'hono') {
313
- return `import { Hono } from 'hono'
314
- ${IMPORTS}
315
-
316
- ${HANDLER}
317
-
318
- const app = new Hono()
319
-
320
- app.get('/health', (c) => c.json({ ok: true }))
321
- // Last, so the app's own routes win; anything left falls through to the RSC
322
- // handler, and anything it does not claim is a real 404.
323
- app.all('*', async (c) => (await rsc(c.req.raw)) ?? c.notFound())
324
-
325
- export default { port: ${PORT}, fetch: app.fetch }
326
- `;
327
- }
328
- if (host === 'elysia') {
329
- return `import { Elysia } from 'elysia'
330
- ${IMPORTS}
331
-
332
- ${HANDLER}
333
-
334
- new Elysia()
335
- .get('/health', () => ({ ok: true }))
336
- .all('*', async ({ request, status }) => (await rsc(request)) ?? status(404, 'Not found'))
337
- .listen(${PORT})
338
-
339
- console.log('http://localhost:${PORT}')
340
- `;
341
- }
342
- return `import { createServer } from 'node:http'
343
- import { Readable } from 'node:stream'
344
- ${IMPORTS}
345
-
346
- ${HANDLER}
347
-
348
- // Node exits on an unhandled rejection; Bun logs one and carries on. That
349
- // difference is reachable from outside: a malformed body posted to
350
- // /_rsc/action fails inside React's Flight decoder, in a promise nobody
351
- // awaits, so no try/catch here can see it — and on Node the process dies.
352
- process.on('unhandledRejection', (reason) => {
353
- console.error('[unhandled rejection]', reason)
354
- })
355
-
356
- const server = createServer(async (req, res) => {
357
- const url = \`http://\${req.headers.host ?? 'localhost'}\${req.url ?? '/'}\`
358
- const hasBody = req.method !== 'GET' && req.method !== 'HEAD'
359
-
360
- const request = new Request(url, {
361
- method: req.method,
362
- headers: req.headers as Record<string, string>,
363
- // A server action posts binary. Streaming rather than buffering keeps an
364
- // upload from being held twice; \`duplex\` is required for a stream body.
365
- body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined,
366
- ...(hasBody ? { duplex: 'half' } : {}),
367
- } as RequestInit)
368
-
369
- let response: Response
370
-
371
- try {
372
- response = (await rsc(request)) ?? new Response('Not found', { status: 404 })
373
- } catch (error) {
374
- console.error('[rsc]', error)
375
- res.writeHead(500, { 'Content-Type': 'text/plain' })
376
- res.end('Internal Server Error')
377
-
378
- return
379
- }
380
-
381
- res.writeHead(response.status, Object.fromEntries(response.headers))
382
-
383
- if (!response.body) {
384
- res.end()
385
-
386
- return
387
- }
388
-
389
- // Piped, not buffered: reading it to a string first would hold the whole
390
- // page before sending any of it, which is the streaming this exists to do
391
- // thrown away in the last three lines.
392
- Readable.fromWeb(response.body as never).pipe(res)
393
- })
394
-
395
- server.listen(${PORT}, () => console.log('http://localhost:${PORT}'))
396
- `;
397
- }
398
231
  export function layout(o) {
399
232
  return `${o.tailwind ? "import './styles.css'\n" : ''}import type { ReactNode } from 'react'
400
233
 
@@ -550,8 +383,10 @@ ${pm} build # bundles, then freezes every page it can
550
383
  ${pm} start # serve on http://localhost:${PORT}
551
384
  \`\`\`
552
385
 
553
- \`${pm} prerender\` re-runs only the freezing part, for when you turned it off
554
- in \`vite.config.ts\` or want to redo it without rebuilding.
386
+ Freezing is part of \`build\`. To redo it against fresh data without
387
+ rebuilding — or after turning it off in \`vite.config.ts\` — run
388
+ \`bunx rsc-kit prerender --out ${paths(o).outDir}\`, keeping that package's version in
389
+ step with \`@rsc-kit/core\`.
555
390
 
556
391
  ## Where things go
557
392
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-rsc-kit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Scaffold an RSC app on Bun, Hono, Elysia or Node.",
6
6
  "bin": {