create-rsc-kit 0.3.0 → 0.4.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
@@ -18,7 +18,7 @@ if (flags.help) {
18
18
  stdout.write(HELP);
19
19
  exit(0);
20
20
  }
21
- const unattended = flags.host !== undefined;
21
+ const unattended = flags.yes === true || flags.host !== undefined;
22
22
  // Resolved once: it walks up from this file looking for the sibling package.
23
23
  const core = flags.core ?? defaultCore(dirname(fileURLToPath(import.meta.url)));
24
24
  const options = await collect();
@@ -58,7 +58,7 @@ async function collect() {
58
58
  return {
59
59
  dir: resolve(dir),
60
60
  name: basename(dir),
61
- host: flags.host,
61
+ host: flags.host ?? 'bun',
62
62
  compiler: flags.compiler ?? 'none',
63
63
  tailwind: flags.tailwind ?? true,
64
64
  lint: flags.lint ?? true,
@@ -122,7 +122,7 @@ 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.host)],
125
+ [t.serverFile(o.host), t.server(o)],
126
126
  ['.gitignore', t.gitignore],
127
127
  ['README.md', t.readme(o)],
128
128
  ['src/app/layout.tsx', t.layout(o)],
package/dist/init.d.ts CHANGED
@@ -2,6 +2,8 @@ import type { Host, Options } from './options.js';
2
2
  export interface Detected {
3
3
  /** What the project's package.json says it already depends on. */
4
4
  deps: Record<string, string>;
5
+ /** A Laravel application: artisan and a composer manifest, both. */
6
+ laravel: boolean;
5
7
  host: Host | null;
6
8
  sourceDir: string | null;
7
9
  viteConfig: string | null;
package/dist/init.js CHANGED
@@ -28,9 +28,13 @@ export function detect(dir) {
28
28
  ...(packageJson.dependencies ?? {}),
29
29
  ...(packageJson.devDependencies ?? {}),
30
30
  };
31
- let host = null;
31
+ // Both files, not either: `artisan` alone is a name anything could use, and
32
+ // a composer.json alone is any PHP project. Together they are Laravel, and
33
+ // the check costs nothing to be sure about.
34
+ const laravel = existsSync(join(dir, 'artisan')) && existsSync(join(dir, 'composer.json'));
35
+ let host = laravel ? 'laravel' : null;
32
36
  for (const [pkg, value] of Object.entries(HOST_PACKAGES)) {
33
- if (deps[pkg])
37
+ if (!laravel && deps[pkg])
34
38
  host = value;
35
39
  }
36
40
  // No framework named, so it comes down to which runtime's types are here.
@@ -40,8 +44,14 @@ export function detect(dir) {
40
44
  host = deps['@types/node'] && !deps['@types/bun'] ? 'node' : 'bun';
41
45
  return {
42
46
  deps,
47
+ laravel,
43
48
  host,
44
- sourceDir: ['src', 'app', 'resources/js'].find((d) => existsSync(join(dir, d))) ?? null,
49
+ // Its own directory under resources/js rather than resources/js itself: a
50
+ // Laravel app already keeps its asset entry points there, and a route tree
51
+ // rooted at that directory would make app.js a page.
52
+ sourceDir: laravel
53
+ ? 'resources/js/rsc'
54
+ : ['src', 'app', 'resources/js'].find((d) => existsSync(join(dir, d))) ?? null,
45
55
  viteConfig: ['vite.config.ts', 'vite.config.js', 'vite.config.mts'].find((f) => existsSync(join(dir, f))) ?? null,
46
56
  hasReact: Boolean(deps.react),
47
57
  hasTailwind: Boolean(deps.tailwindcss),
@@ -49,15 +59,36 @@ export function detect(dir) {
49
59
  packageJson,
50
60
  };
51
61
  }
62
+ /**
63
+ * The major a range starts at, or null when it is not one.
64
+ *
65
+ * Deliberately crude — `file:` specs, `latest`, `workspace:*` and git urls all
66
+ * come back null and are left alone. It only has to answer one question, and
67
+ * only where the answer is unambiguous.
68
+ */
69
+ function major(range) {
70
+ const match = /^\D*(\d+)\./.exec(range);
71
+ return match ? Number(match[1]) : null;
72
+ }
52
73
  /**
53
74
  * Add the dependencies the engine needs, without touching versions already
54
75
  * chosen. A project on React 19.3 does not want to be pinned back to ours.
76
+ *
77
+ * Except when what is already there is too OLD, which is not the same thing
78
+ * and is the common case here: every Laravel application ships a Vite, and at
79
+ * the time of writing none of them ship Vite 8. Leaving it silently is how an
80
+ * install ends in a build failing on a plugin API that does not exist yet —
81
+ * an error naming neither Vite nor this package. So a major below what the
82
+ * engine needs is reported as an edit to make, and nothing is upgraded on
83
+ * someone's behalf: a Vite major is their decision, and it moves their own
84
+ * asset pipeline too.
55
85
  */
56
86
  function mergeDependencies(o, found) {
57
87
  const pkg = found.packageJson;
58
88
  const wanted = JSON.parse(t.packageJson(o));
59
89
  const steps = [];
60
90
  const added = [];
91
+ const tooOld = [];
61
92
  for (const [field, incoming] of [
62
93
  ['dependencies', wanted.dependencies],
63
94
  ['devDependencies', wanted.devDependencies],
@@ -65,8 +96,14 @@ function mergeDependencies(o, found) {
65
96
  const current = pkg[field] ?? {};
66
97
  for (const [name, range] of Object.entries(incoming)) {
67
98
  // Already declared anywhere: leave it exactly as it is.
68
- if (found.deps[name])
99
+ if (found.deps[name]) {
100
+ const have = major(found.deps[name]);
101
+ const want = major(range);
102
+ if (have !== null && want !== null && have < want) {
103
+ tooOld.push(`${name} ${found.deps[name]} → ${range}`);
104
+ }
69
105
  continue;
106
+ }
70
107
  current[name] = range;
71
108
  added.push(name);
72
109
  }
@@ -77,56 +114,137 @@ function mergeDependencies(o, found) {
77
114
  steps.push(added.length > 0
78
115
  ? { kind: 'merged', what: 'package.json', detail: `added ${added.join(', ')}` }
79
116
  : { kind: 'skipped', what: 'package.json dependencies', detail: 'everything needed is already here' });
117
+ if (tooOld.length > 0) {
118
+ steps.push({
119
+ kind: 'manual',
120
+ what: 'versions already here that the engine cannot build with',
121
+ detail: `left alone — upgrade them yourself, the build fails on the old ones:\n ` +
122
+ tooOld.join('\n '),
123
+ });
124
+ }
80
125
  return steps;
81
126
  }
82
127
  /**
83
- * Scripts, but never over one that exists.
128
+ * Laravel's own scripts, exactly as the framework ships them.
129
+ *
130
+ * Matched by value, not by name. `dev` meaning `vite` is a script nobody
131
+ * chose; `dev` meaning anything else is somebody's work and is not ours to
132
+ * reason about.
133
+ */
134
+ const STOCK = {
135
+ dev: ['vite'],
136
+ build: ['vite build'],
137
+ };
138
+ /**
139
+ * The two pipelines, under one name.
84
140
  *
85
- * `dev` and `build` are the two most likely to already mean something, and
86
- * quietly replacing either is how a tool loses someone's trust permanently.
141
+ * `npm run dev` already means the asset pipeline, and after this it has to
142
+ * mean the renderer too — so it means both. Anything else asks a Laravel
143
+ * developer to learn a second command for the thing they already have a
144
+ * command for, and the one they know would silently stop being enough.
145
+ *
146
+ * Sequential for a build because it has to be — the outputs are independent
147
+ * but a failure in either should stop the deploy. Parallel for dev because
148
+ * both are long-lived servers.
149
+ */
150
+ function combine(name, theirs, ours) {
151
+ return name === 'dev'
152
+ ? `concurrently -k -n assets,rsc -c blue,magenta "${theirs}" "${ours}"`
153
+ : `${theirs} && ${ours}`;
154
+ }
155
+ /**
156
+ * Scripts, never over one that somebody wrote.
157
+ *
158
+ * Three outcomes. A name that is free is taken. A name holding the framework's
159
+ * own script is combined, so the command keeps doing what it did and starts
160
+ * doing this as well. A name holding anything else is left completely alone,
161
+ * and the RSC command goes to `rsc:<name>` with the reason reported — a tool
162
+ * that quietly replaces a working build script loses someone's trust
163
+ * permanently, and it only has to be wrong once.
87
164
  */
88
165
  function mergeScripts(o, found) {
89
166
  const pkg = found.packageJson;
90
167
  const scripts = pkg.scripts ?? {};
91
- const wanted = JSON.parse(t.packageJson(o)).scripts;
168
+ const wanted = t.scripts(o);
92
169
  const added = [];
93
- const conflicts = [];
170
+ const combined = [];
171
+ const renamed = [];
172
+ let needsConcurrently = false;
94
173
  for (const [name, command] of Object.entries(wanted)) {
95
- if (scripts[name] === undefined) {
174
+ const existing = scripts[name];
175
+ if (existing === undefined) {
96
176
  scripts[name] = command;
97
177
  added.push(name);
178
+ continue;
98
179
  }
99
- else if (scripts[name] !== command) {
100
- conflicts.push(`${name}: ${command}`);
180
+ if (existing === command)
181
+ continue;
182
+ if (STOCK[name]?.includes(existing.trim())) {
183
+ scripts[name] = combine(name, existing.trim(), command);
184
+ combined.push(name);
185
+ needsConcurrently ||= name === 'dev';
186
+ continue;
101
187
  }
188
+ const fallback = `rsc:${name}`;
189
+ if (scripts[fallback] === undefined)
190
+ scripts[fallback] = command;
191
+ renamed.push(`${name} is yours, so this one is ${fallback}`);
102
192
  }
103
193
  pkg.scripts = scripts;
104
194
  const steps = [];
105
195
  if (added.length > 0)
106
196
  steps.push({ kind: 'merged', what: 'scripts', detail: added.join(', ') });
107
- if (conflicts.length > 0) {
197
+ if (combined.length > 0) {
198
+ steps.push({
199
+ kind: 'merged',
200
+ what: combined.join(' and '),
201
+ detail: 'now runs the asset pipeline AND the renderer',
202
+ });
203
+ }
204
+ // Declared here rather than in the template, because whether it is needed
205
+ // depends on what was already in package.json. Laravel ships it for its own
206
+ // `composer run dev`, so this is usually a no-op.
207
+ if (needsConcurrently && !found.deps.concurrently) {
208
+ const dev = pkg.devDependencies ?? {};
209
+ dev.concurrently = '^9.0.0';
210
+ pkg.devDependencies = Object.fromEntries(Object.entries(dev).sort(([a], [b]) => a.localeCompare(b)));
211
+ }
212
+ if (renamed.length > 0) {
108
213
  steps.push({
109
214
  kind: 'manual',
110
- what: 'scripts you already have',
111
- detail: `left alone — add these yourself if you want them:\n ${conflicts.join('\n ')}`,
215
+ what: 'scripts you wrote yourself',
216
+ detail: `left alone:\n ${renamed.join('\n ')}`,
112
217
  });
113
218
  }
114
219
  return steps;
115
220
  }
116
221
  /** The plugin entry, written if there is no config and printed if there is. */
117
222
  function viteConfig(o, found, dir) {
118
- if (found.viteConfig === null) {
119
- writeFileSync(join(dir, 'vite.config.ts'), t.viteConfig(o));
120
- return [{ kind: 'wrote', what: 'vite.config.ts' }];
223
+ const file = t.configFile(o);
224
+ // Laravel is asked about a different file than the one it already has. Its
225
+ // vite.config carries laravel-vite-plugin, and the two cannot share a config
226
+ // — so the question is whether the RSC config exists, not whether any does.
227
+ const existing = o.host === 'laravel' ? (existsSync(join(dir, file)) ? file : null) : found.viteConfig;
228
+ if (existing === null) {
229
+ writeFileSync(join(dir, file), t.viteConfig(o));
230
+ return [{ kind: 'wrote', what: file }];
121
231
  }
232
+ const p = t.paths(o);
233
+ const shown = [
234
+ `sourceDir: '${p.sourceDir}'`,
235
+ `outDir: '${p.outDir}'`,
236
+ `assetsDir: '${p.assetsDir}'`,
237
+ ...(p.assetsUrl ? [`assetsUrl: '${p.assetsUrl}'`] : []),
238
+ ...(p.hotFile ? [`hotFile: '${p.hotFile}'`] : []),
239
+ ].join(', ');
122
240
  return [
123
241
  {
124
242
  kind: 'manual',
125
- what: found.viteConfig,
243
+ what: existing,
126
244
  detail: `add the plugin — it must come before any react() layer:\n` +
127
245
  ` import { rscRoutes } from '@rsc-kit/core/vite'\n\n` +
128
246
  ` plugins: [\n` +
129
- ` rscRoutes({ sourceDir: '${o.sourceDir}', outDir: 'build', assetsDir: 'build/public' }),\n` +
247
+ ` rscRoutes({ ${shown} }),\n` +
130
248
  ` …whatever you already have\n` +
131
249
  ` ]`,
132
250
  },
@@ -143,14 +261,14 @@ function server(o, dir) {
143
261
  detail: 'left alone. Mount the handler in it — anything the route table does not\n' +
144
262
  ' claim comes back null, so your own routes still win:\n\n' +
145
263
  t
146
- .server(o.host)
264
+ .server(o)
147
265
  .split('\n')
148
266
  .map((line) => ' ' + line)
149
267
  .join('\n'),
150
268
  },
151
269
  ];
152
270
  }
153
- writeFileSync(join(dir, file), t.server(o.host));
271
+ writeFileSync(join(dir, file), t.server(o));
154
272
  return [{ kind: 'wrote', what: file }];
155
273
  }
156
274
  /** The route tree, only where there is not one already. */
@@ -179,17 +297,39 @@ function routes(o, dir) {
179
297
  }
180
298
  return steps;
181
299
  }
300
+ /**
301
+ * A tsconfig, for a project that has none.
302
+ *
303
+ * Laravel ships without one, and the route tree is .tsx — so with nothing here
304
+ * an editor reports an error on every generated file and the `typecheck`
305
+ * script has no configuration to read. Written only when absent, like
306
+ * everything else.
307
+ */
308
+ function tsconfig(o, found, dir) {
309
+ if (found.hasTypeScript) {
310
+ return [{ kind: 'skipped', what: 'tsconfig.json', detail: 'already here' }];
311
+ }
312
+ writeFileSync(join(dir, 'tsconfig.json'), t.tsconfig(o));
313
+ return [{ kind: 'wrote', what: 'tsconfig.json' }];
314
+ }
182
315
  /** Ignore the files the build rewrites into the source dir on every run. */
183
316
  function gitignore(o, dir) {
184
317
  const path = join(dir, '.gitignore');
185
318
  const generated = ['rsc-env.d.ts', 'rsc-types.d.ts', 'rsc-routes.d.ts', 'rsc-engine.d.ts'].map((f) => `${o.sourceDir}/${f}`);
319
+ 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] : [])];
325
+ const outputs = all.filter((path) => !all.some((other) => other !== path && path.startsWith(other + '/')));
186
326
  const current = existsSync(path) ? readFileSync(path, 'utf-8') : '';
187
- const missing = generated.filter((line) => !current.includes(line));
327
+ const missing = [...generated, ...outputs].filter((line) => !current.split('\n').some((existing) => existing.trim() === line));
188
328
  if (missing.length === 0)
189
329
  return [{ kind: 'skipped', what: '.gitignore', detail: 'already covers the generated files' }];
190
330
  writeFileSync(path, current + (current.endsWith('\n') || current === '' ? '' : '\n') +
191
- '\n# Written into the source dir by the RSC build, every run.\n' +
192
- missing.join('\n') + '\nbuild\n');
331
+ '\n# The RSC build: rewritten into the source dir every run, and written out.\n' +
332
+ missing.join('\n') + '\n');
193
333
  return [{ kind: 'merged', what: '.gitignore', detail: `added ${missing.length} generated paths` }];
194
334
  }
195
335
  /** Everything, in the order a reader would want to hear about it. */
@@ -198,6 +338,7 @@ export function initialise(o, found, dir) {
198
338
  ...routes(o, dir),
199
339
  ...viteConfig(o, found, dir),
200
340
  ...server(o, dir),
341
+ ...tsconfig(o, found, dir),
201
342
  ...gitignore(o, dir),
202
343
  ...mergeDependencies(o, found),
203
344
  ...mergeScripts(o, found),
@@ -215,6 +356,8 @@ const INIT_HELP = `
215
356
  Options
216
357
  --source-dir <dir> where app/ should live (detected, usually src)
217
358
  --host=… bun | hono | elysia | node (detected from your deps)
359
+ laravel is detected from artisan, never asked
360
+ --backend=<url> for laravel: where host calls go, e.g. http://app.test
218
361
  --compiler=… none | oxc | babel
219
362
  --tailwind add Tailwind as well
220
363
  -y, --yes accept what was detected, ask nothing
@@ -242,11 +385,15 @@ export async function runInit(args) {
242
385
  }
243
386
  const flags = parseArgs(args);
244
387
  const found = detect(dir);
245
- const unattended = args.includes('-y') || args.includes('--yes') || flags.host !== undefined;
388
+ const unattended = flags.yes === true || flags.host !== undefined;
246
389
  stdout.write(`\n${bold('Adding rsc-kit')} ${dim(dir)}\n\n`);
247
390
  stdout.write(` ${dim('server')} ${found.host}${flags.host ? '' : dim(' (detected)')}\n`);
248
391
  stdout.write(` ${dim('source')} ${flags.sourceDir ?? found.sourceDir ?? 'src'}\n`);
249
- stdout.write(` ${dim('react')} ${found.hasReact ? 'already here' : 'will be added'}\n\n`);
392
+ stdout.write(` ${dim('react')} ${found.hasReact ? 'already here' : 'will be added'}\n`);
393
+ if (found.host === 'laravel') {
394
+ stdout.write(` ${dim('backend')} ${flags.backend ?? 'http://localhost'}\n`);
395
+ }
396
+ stdout.write('\n');
250
397
  let compiler = flags.compiler ?? 'none';
251
398
  let tailwind = flags.tailwind ?? found.hasTailwind;
252
399
  if (!unattended) {
@@ -272,6 +419,7 @@ export async function runInit(args) {
272
419
  install: false,
273
420
  git: false,
274
421
  core: flags.core ?? publishedCore(),
422
+ backend: flags.backend,
275
423
  };
276
424
  const steps = initialise(options, found, dir);
277
425
  const mark = { wrote: cyan('+'), merged: cyan('~'), manual: bold('!'), skipped: dim('·') };
@@ -280,7 +428,11 @@ export async function runInit(args) {
280
428
  stdout.write(` ${mark[step.kind]} ${step.what}${step.detail ? dim(' — ' + step.detail) : ''}\n`);
281
429
  }
282
430
  const manual = steps.filter((s) => s.kind === 'manual');
283
- stdout.write(manual.length > 0
284
- ? `\n${bold('Then, by hand:')} the edits marked ! above are in files you already had.\n\n`
431
+ if (manual.length > 0) {
432
+ stdout.write(`\n${bold('Then, by hand:')} the edits marked ! above are in files you already had.\n\n`);
433
+ return;
434
+ }
435
+ 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`
285
437
  : `\n ${cyan('bun install')} and you are ready.\n\n`);
286
438
  }
package/dist/options.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type Host = 'bun' | 'hono' | 'elysia' | 'node';
1
+ export type Host = 'bun' | 'hono' | 'elysia' | 'node' | 'laravel';
2
2
  export type Compiler = 'none' | 'oxc' | 'babel';
3
3
  export interface Options {
4
4
  dir: string;
@@ -9,11 +9,24 @@ export interface Options {
9
9
  lint: boolean;
10
10
  /** Where the app/ route tree lives, relative to the project. */
11
11
  sourceDir: string;
12
+ /**
13
+ * Where host calls go, for a backed host. Laravel's own url, so a server
14
+ * component's rpc() reaches the application it is rendering for.
15
+ */
16
+ backend?: string;
12
17
  install: boolean;
13
18
  git: boolean;
14
19
  /** What to depend on for the engine. A path makes a local checkout testable. */
15
20
  core: string;
16
21
  }
22
+ /**
23
+ * What the prompt offers, which is not every Host.
24
+ *
25
+ * `laravel` is missing on purpose: it is never scaffolded, only added to an
26
+ * application that already exists — so init detects it and nothing asks. A
27
+ * question whose only right answer is "the framework I am already in" is a
28
+ * question with a wrong answer available.
29
+ */
17
30
  export declare const HOSTS: {
18
31
  value: Host;
19
32
  label: string;
@@ -51,9 +64,18 @@ export declare function publishedCore(fromDir?: string): string;
51
64
  * sibling and the range is right.
52
65
  */
53
66
  export declare function defaultCore(fromDir: string): string;
67
+ /**
68
+ * The flags, with --yes as its own answer rather than a host.
69
+ *
70
+ * It used to set `host = 'bun'`, because both entry points read "a host was
71
+ * named" as "do not prompt". That silently overrode detection: `rsc-kit init
72
+ * -y` in a Hono project — or a Laravel one — generated a Bun server for it,
73
+ * having detected the right answer and then thrown it away.
74
+ */
54
75
  export declare function parseArgs(argv: string[]): Partial<Options> & {
55
76
  help?: boolean;
56
77
  init?: boolean;
78
+ yes?: boolean;
57
79
  };
58
80
  export declare function assertUsableName(name: string): void;
59
81
  /**
package/dist/options.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ /**
5
+ * What the prompt offers, which is not every Host.
6
+ *
7
+ * `laravel` is missing on purpose: it is never scaffolded, only added to an
8
+ * application that already exists — so init detects it and nothing asks. A
9
+ * question whose only right answer is "the framework I am already in" is a
10
+ * question with a wrong answer available.
11
+ */
4
12
  export const HOSTS = [
5
13
  { value: 'bun', label: 'Bun.serve', hint: 'no framework, fastest to start' },
6
14
  { value: 'hono', label: 'Hono', hint: 'also what a Worker or Deno would use' },
@@ -78,13 +86,21 @@ export function defaultCore(fromDir) {
78
86
  }
79
87
  return publishedCore();
80
88
  }
89
+ /**
90
+ * The flags, with --yes as its own answer rather than a host.
91
+ *
92
+ * It used to set `host = 'bun'`, because both entry points read "a host was
93
+ * named" as "do not prompt". That silently overrode detection: `rsc-kit init
94
+ * -y` in a Hono project — or a Laravel one — generated a Bun server for it,
95
+ * having detected the right answer and then thrown it away.
96
+ */
81
97
  export function parseArgs(argv) {
82
98
  const out = {};
83
99
  for (const arg of argv) {
84
100
  if (arg === '--help' || arg === '-h')
85
101
  out.help = true;
86
102
  else if (arg === '--yes' || arg === '-y')
87
- out.host ??= 'bun';
103
+ out.yes = true;
88
104
  else if (arg === '--no-install')
89
105
  out.install = false;
90
106
  else if (arg === '--no-git')
@@ -107,6 +123,8 @@ export function parseArgs(argv) {
107
123
  out.compiler = arg.slice(11);
108
124
  else if (arg.startsWith('--core='))
109
125
  out.core = arg.slice(7);
126
+ else if (arg.startsWith('--backend='))
127
+ out.backend = arg.slice(10);
110
128
  else if (!arg.startsWith('-'))
111
129
  out.dir ??= arg;
112
130
  }
@@ -1,10 +1,51 @@
1
1
  import type { Host, Options } from './options.js';
2
+ export interface Paths {
3
+ /** Where the app/ route tree lives. */
4
+ sourceDir: string;
5
+ /** Where the bundles land. The server imports the rsc one from here. */
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
+ /** Written while the dev server runs, for a backend that has to find it. */
12
+ hotFile?: string;
13
+ }
14
+ /**
15
+ * Where this host's build writes, and where its server reads.
16
+ *
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.
22
+ */
23
+ export declare function paths(o: Options): Paths;
24
+ /**
25
+ * The config the RSC build runs, which for Laravel is not the app's own.
26
+ *
27
+ * A Laravel application already has a vite.config with laravel-vite-plugin in
28
+ * it, and the two cannot share one: both set an input list, an outDir and a
29
+ * hot file, and whichever plugin runs second wins. So the RSC build gets its
30
+ * own file and the scripts name it, rather than an install that quietly breaks
31
+ * the asset pipeline the app was already using.
32
+ */
33
+ export declare const configFile: (o: Options) => string;
34
+ /**
35
+ * The commands, under the names someone would guess.
36
+ *
37
+ * `dev` and `build`, even on Laravel where both are already taken. What to do
38
+ * about that is init's decision and not this one — see mergeScripts, which
39
+ * combines with the stock scripts and only steps aside for a script somebody
40
+ * wrote themselves.
41
+ */
42
+ export declare function scripts(o: Options): Record<string, string>;
2
43
  export declare function packageJson(o: Options): string;
3
44
  /** One server per app, so it needs no qualifier. */
4
45
  export declare const serverFile: (_host: Host) => string;
5
46
  export declare function viteConfig(o: Options): string;
6
47
  export declare const tsconfig: (o: Options) => string;
7
- export declare function server(host: Host): string;
48
+ export declare function server(o: Options): string;
8
49
  export declare function layout(o: Options): string;
9
50
  export declare function page(o: Options): string;
10
51
  export declare function counter(o: Options): string;
package/dist/templates.js CHANGED
@@ -4,6 +4,78 @@
4
4
  // each other — the compiler changes vite.config, Tailwind changes it and the
5
5
  // layout — and a template directory would need one copy per combination.
6
6
  const PORT = 3000;
7
+ /**
8
+ * Where this host's build writes, and where its server reads.
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.
15
+ */
16
+ export function paths(o) {
17
+ if (o.host !== 'laravel') {
18
+ return { sourceDir: o.sourceDir, outDir: 'build', assetsDir: 'build/public' };
19
+ }
20
+ return {
21
+ sourceDir: o.sourceDir,
22
+ outDir: 'bootstrap/rsc/vite',
23
+ assetsDir: 'public/build/rsc-vite',
24
+ assetsUrl: '/build/rsc-vite/',
25
+ hotFile: 'public/rsc-hot',
26
+ };
27
+ }
28
+ /**
29
+ * The config the RSC build runs, which for Laravel is not the app's own.
30
+ *
31
+ * A Laravel application already has a vite.config with laravel-vite-plugin in
32
+ * it, and the two cannot share one: both set an input list, an outDir and a
33
+ * hot file, and whichever plugin runs second wins. So the RSC build gets its
34
+ * own file and the scripts name it, rather than an install that quietly breaks
35
+ * the asset pipeline the app was already using.
36
+ */
37
+ export const configFile = (o) => o.host === 'laravel' ? 'vite.rsc.config.ts' : 'vite.config.ts';
38
+ /**
39
+ * The commands, under the names someone would guess.
40
+ *
41
+ * `dev` and `build`, even on Laravel where both are already taken. What to do
42
+ * about that is init's decision and not this one — see mergeScripts, which
43
+ * combines with the stock scripts and only steps aside for a script somebody
44
+ * wrote themselves.
45
+ */
46
+ export function scripts(o) {
47
+ const run = o.host === 'node' ? 'node' : 'bun run';
48
+ const p = paths(o);
49
+ if (o.host === 'laravel') {
50
+ 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
+ const actions = 'php artisan rsc:action-manifest';
58
+ return {
59
+ // The ordinary names. A Laravel application already has `dev` and
60
+ // `build`, and init combines rather than replaces — the stock ones run
61
+ // 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
+ dev: `${actions} && vite ${config}`,
65
+ build: `${actions} && vite build ${config}`,
66
+ start: `${run} ${serverFile(o.host)}`,
67
+ };
68
+ }
69
+ 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
+ dev: 'vite',
74
+ build: 'vite build',
75
+ start: `${run} ${serverFile(o.host)}`,
76
+ prerender: `rsc-kit prerender --out ${p.outDir}`,
77
+ };
78
+ }
7
79
  export function packageJson(o) {
8
80
  const deps = {
9
81
  '@rsc-kit/core': o.core,
@@ -45,19 +117,12 @@ export function packageJson(o) {
45
117
  }
46
118
  if (o.lint)
47
119
  dev['oxlint'] = '^1.81.0';
48
- const run = o.host === 'node' ? 'node' : 'bun run';
49
120
  return (JSON.stringify({
50
121
  name: o.name,
51
122
  type: 'module',
52
123
  private: true,
53
124
  scripts: {
54
- // Vite serves it: modules are re-evaluated on edit, and adding a
55
- // page restarts to pick up the new route table. Nothing is prebuilt,
56
- // so there is no NODE_ENV to keep in step with a build.
57
- dev: 'vite',
58
- build: 'vite build',
59
- start: `${run} ${serverFile(o.host)}`,
60
- prerender: 'rsc-kit prerender --out build',
125
+ ...scripts(o),
61
126
  typecheck: 'tsc --noEmit',
62
127
  ...(o.lint
63
128
  ? { lint: 'oxlint src --fix', 'lint:check': 'oxlint src --deny-warnings' }
@@ -81,10 +146,20 @@ export function viteConfig(o) {
81
146
  if (o.tailwind)
82
147
  imports.push("import tailwindcss from '@tailwindcss/vite'");
83
148
  imports.push("import { rscRoutes } from '@rsc-kit/core/vite'");
149
+ 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.
154
+ const options = [
155
+ `sourceDir: '${p.sourceDir}'`,
156
+ `outDir: '${p.outDir}'`,
157
+ `assetsDir: '${p.assetsDir}'`,
158
+ ...(p.assetsUrl ? [`assetsUrl: '${p.assetsUrl}'`] : []),
159
+ ...(p.hotFile ? [`hotFile: '${p.hotFile}'`] : []),
160
+ ];
84
161
  plugins.push(`rscRoutes({
85
- sourceDir: 'src',
86
- outDir: 'build',
87
- assetsDir: 'build/public',
162
+ ${options.join(',\n ')},
88
163
  })`);
89
164
  plugins.push(o.compiler === 'oxc' ? 'react({ compiler: true })' : 'react()');
90
165
  if (o.compiler === 'babel')
@@ -131,7 +206,95 @@ import { assetsFrom, prerenderedFrom } from '@rsc-kit/core/files'
131
206
  // bundle, so this server is production because it was built that way — not
132
207
  // because whoever started it remembered to say so.
133
208
  import * as engine from './build/dist/rsc/index.js'`;
134
- export function server(host) {
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);
135
298
  if (host === 'bun') {
136
299
  return `${IMPORTS}
137
300
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-rsc-kit",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Scaffold an RSC app on Bun, Hono, Elysia or Node.",
6
6
  "bin": {