create-rsc-kit 0.16.3 → 0.18.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
@@ -9,8 +9,9 @@ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
9
9
  import { dirname, join, resolve } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { spawnSync } from 'node:child_process';
12
+ import { randomBytes } from 'node:crypto';
12
13
  import { argv, exit, stdout } from 'node:process';
13
- import { DEFAULT_COMPILER, HELP, HOSTS, publishedCore, assertInsideCwd, assertUsableName, defaultCore, parseArgs, } from './options.js';
14
+ import { DEFAULT_COMPILER, HELP, HOSTS, publishedCore, assertInsideCwd, assertUsableName, defaultCore, parseArgs, VALIDATIONS } from './options.js';
14
15
  import { Prompter, bold, cyan, dim } from './prompt.js';
15
16
  import { checkForNewer, notifyIfStale, selfVersion } from './stale.js';
16
17
  import * as t from './templates.js';
@@ -77,10 +78,13 @@ async function collect() {
77
78
  compiler: flags.compiler ?? 'none',
78
79
  tailwind: flags.tailwind ?? true,
79
80
  lint: flags.lint ?? true,
81
+ validation: flags.validation ?? 'zod',
82
+ env: flags.env ?? (flags.validation ?? 'zod') !== 'none',
80
83
  sourceDir: flags.sourceDir ?? 'src',
81
84
  install: flags.install ?? true,
82
85
  git: flags.git ?? true,
83
86
  core,
87
+ backend: flags.backend,
84
88
  };
85
89
  }
86
90
  // Nothing is attached to answer. readline would simply never resolve, and a
@@ -101,6 +105,11 @@ async function collect() {
101
105
  const compiler = flags.compiler ?? ((await p.confirm('React Compiler', true)) ? DEFAULT_COMPILER : 'none');
102
106
  const tailwind = flags.tailwind ?? (await p.confirm('Tailwind CSS', true));
103
107
  const lint = flags.lint ?? (await p.confirm('oxlint', true));
108
+ const validation = flags.validation ?? (await p.select('Validation library', VALIDATIONS));
109
+ // Only worth asking once there is a library to validate with.
110
+ const env = validation === 'none'
111
+ ? false
112
+ : (flags.env ?? (await p.confirm('Typed environment variables (@t3-oss/env-core)', true)));
104
113
  return {
105
114
  dir: resolve(dir),
106
115
  name: basename(dir),
@@ -108,10 +117,13 @@ async function collect() {
108
117
  compiler,
109
118
  tailwind,
110
119
  lint,
120
+ validation,
121
+ env,
111
122
  sourceDir: flags.sourceDir ?? 'src',
112
123
  install: flags.install ?? (await p.confirm('Install dependencies now', true)),
113
124
  git: flags.git ?? true,
114
125
  core,
126
+ backend: flags.backend,
115
127
  };
116
128
  }
117
129
  finally {
@@ -148,8 +160,21 @@ function write(o) {
148
160
  ];
149
161
  if (o.tailwind)
150
162
  files.push(['src/app/styles.css', t.styles]);
163
+ if (o.env) {
164
+ files.push([`${o.sourceDir}/env.ts`, t.env(o)]);
165
+ files.push([`${o.sourceDir}/instrumentation.ts`, t.instrumentation(o)]);
166
+ files.push(['.env.example', t.envExample]);
167
+ }
151
168
  if (o.lint)
152
169
  files.push(['.oxlintrc.json', t.oxlintConfig(o)]);
170
+ // A backend answering host calls: the two lines that wire it, and a
171
+ // secret the backend is given once. The scaffold's .gitignore already
172
+ // keeps .env out of git.
173
+ if (o.backend) {
174
+ files.push(['.env', t.backendEnv(o.backend, randomBytes(32).toString('base64url'))]);
175
+ if (!o.env)
176
+ files.push(['.env.example', t.backendEnvExample(o.backend)]);
177
+ }
153
178
  const replaced = [];
154
179
  for (const [path, contents] of files) {
155
180
  const full = join(o.dir, path);
@@ -193,6 +218,9 @@ function report(o) {
193
218
  for (const step of steps)
194
219
  stdout.write(` ${cyan(step)}\n`);
195
220
  stdout.write(`\n${dim('Pages live in src/app. Where it deploys is the Nitro preset in vite.config.ts.')}\n\n`);
221
+ if (o.backend) {
222
+ stdout.write(`${bold('Then:')} ${t.backendStep(false)}\n\n`);
223
+ }
196
224
  }
197
225
  /** A path the user can paste, when it is under where they are. */
198
226
  function relativeish(dir) {
package/dist/init.d.ts CHANGED
@@ -4,6 +4,8 @@ export interface Detected {
4
4
  deps: Record<string, string>;
5
5
  /** A Laravel application: artisan and a composer manifest, both. */
6
6
  laravel: boolean;
7
+ /** A Go module: the backend is Go, and answers host calls from its own server. */
8
+ go: boolean;
7
9
  host: Host | null;
8
10
  sourceDir: string | null;
9
11
  viteConfig: string | null;
@@ -25,7 +27,6 @@ export interface Step {
25
27
  * about things the project has already decided.
26
28
  */
27
29
  export declare function detect(dir: string): Detected;
28
- /** Everything, in the order a reader would want to hear about it. */
29
30
  export declare function initialise(o: Options, found: Detected, dir: string): Step[];
30
31
  /**
31
32
  * Add RSC to a project that already exists.
package/dist/init.js CHANGED
@@ -8,7 +8,8 @@
8
8
  // written, missing dependencies are added, and for anything already present
9
9
  // the exact edit is printed for the reader to make. A tool that silently
10
10
  // reformats a working server has to be right about more than it can know.
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
12
+ import { randomBytes } from 'node:crypto';
12
13
  import { dirname, join } from 'node:path';
13
14
  import { cwd, exit, stdout } from 'node:process';
14
15
  import { DEFAULT_COMPILER, parseArgs, publishedCore } from './options.js';
@@ -47,12 +48,18 @@ export function detect(dir) {
47
48
  return {
48
49
  deps,
49
50
  laravel,
51
+ go: existsSync(join(dir, 'go.mod')),
50
52
  host,
51
- // Its own directory under resources/js rather than resources/js itself: a
52
- // Laravel app already keeps its asset entry points there, and a route tree
53
- // rooted at that directory would make app.js a page.
53
+ // resources/js, the directory a Laravel app already keeps its JavaScript
54
+ // in, so the route tree is resources/js/app the way it is src/app
55
+ // everywhere else. The stock app.js and bootstrap.js beside it are files,
56
+ // not the app/ directory, and the route tree never looks at them. An app
57
+ // that init set up before this default - a tree at resources/js/rsc/app
58
+ // - keeps it: a second run must never move a tree.
54
59
  sourceDir: laravel
55
- ? 'resources/js/rsc'
60
+ ? existsSync(join(dir, 'resources/js/rsc/app'))
61
+ ? 'resources/js/rsc'
62
+ : 'resources/js'
56
63
  : ['src', 'app', 'resources/js'].find((d) => existsSync(join(dir, d))) ?? null,
57
64
  viteConfig: ['vite.config.ts', 'vite.config.js', 'vite.config.mts'].find((f) => existsSync(join(dir, f))) ?? null,
58
65
  hasReact: Boolean(deps.react),
@@ -86,6 +93,17 @@ function major(range) {
86
93
  * asset pipeline too.
87
94
  */
88
95
  function mergeDependencies(o, found) {
96
+ // laravel-vite-plugin served the Blade asset pipeline, which the renderer
97
+ // replaces; left installed it would still be resolvable from the moved-aside
98
+ // config, and nothing else. Removed from the manifest so `npm install` does
99
+ // not keep fetching it; the moved-aside config names it in its own steps.
100
+ if (o.host === 'laravel') {
101
+ for (const field of ['dependencies', 'devDependencies']) {
102
+ const bucket = found.packageJson[field];
103
+ if (bucket && 'laravel-vite-plugin' in bucket)
104
+ delete bucket['laravel-vite-plugin'];
105
+ }
106
+ }
89
107
  const pkg = found.packageJson;
90
108
  const wanted = JSON.parse(t.packageJson(o));
91
109
  const steps = [];
@@ -182,6 +200,15 @@ function mergeScripts(o, found) {
182
200
  if (existing === command)
183
201
  continue;
184
202
  if (STOCK[name]?.includes(existing.trim())) {
203
+ // A stock script. On Laravel it was the Blade asset pipeline's, and
204
+ // the renderer owns the frontend now - one config, one pipeline - so
205
+ // it is replaced rather than run beside. Elsewhere the two are
206
+ // combined, so the command keeps doing what it did as well.
207
+ if (o.host === 'laravel') {
208
+ scripts[name] = command;
209
+ combined.push(name);
210
+ continue;
211
+ }
185
212
  scripts[name] = combine(name, existing.trim(), command);
186
213
  combined.push(name);
187
214
  needsConcurrently ||= name === 'dev';
@@ -200,7 +227,9 @@ function mergeScripts(o, found) {
200
227
  steps.push({
201
228
  kind: 'merged',
202
229
  what: combined.join(' and '),
203
- detail: 'now runs the asset pipeline AND the renderer',
230
+ detail: o.host === 'laravel'
231
+ ? 'the renderer, in place of the Blade pipeline they ran'
232
+ : 'now runs the asset pipeline AND the renderer',
204
233
  });
205
234
  }
206
235
  // Declared here rather than in the template, because whether it is needed
@@ -223,32 +252,64 @@ function mergeScripts(o, found) {
223
252
  /** The plugin entry, written if there is no config and printed if there is. */
224
253
  function viteConfig(o, found, dir) {
225
254
  const file = t.configFile(o);
226
- // Laravel is asked about a different file than the one it already has. Its
227
- // vite.config carries laravel-vite-plugin, and the two cannot share a config
228
- // — so the question is whether the RSC config exists, not whether any does.
229
- const existing = o.host === 'laravel' ? (existsSync(join(dir, file)) ? file : null) : found.viteConfig;
255
+ const existing = found.viteConfig;
230
256
  if (existing === null) {
231
257
  writeFileSync(join(dir, file), t.viteConfig(o));
232
258
  return [{ kind: 'wrote', what: file }];
233
259
  }
260
+ // A Laravel app's vite.config carries laravel-vite-plugin, which owns
261
+ // base, publicDir, outDir, the input list and the server origin - the same
262
+ // things this build owns - so the two cannot share a file. They do not
263
+ // need to: once the renderer owns the frontend there is no @vite
264
+ // directive and no Blade asset pipeline for that plugin to serve. The old
265
+ // config is kept beside the new one, for a Blade page or two that still
266
+ // needs it, rather than lost.
267
+ if (o.host === 'laravel') {
268
+ const source = readFileSync(join(dir, existing), 'utf-8');
269
+ // Already the renderer's: a second run, or a hand-written one.
270
+ if (source.includes('rscKit(')) {
271
+ return [{ kind: 'skipped', what: existing, detail: 'already has rscKit()' }];
272
+ }
273
+ // Some other config the build cannot own - not Blade's - so the edit is
274
+ // printed rather than the file replaced.
275
+ if (!source.includes('laravel-vite-plugin')) {
276
+ return [manualPluginStep(o, existing)];
277
+ }
278
+ const aside = existing.replace(/vite\.config/, 'vite.config.blade');
279
+ if (!existsSync(join(dir, aside)))
280
+ renameSync(join(dir, existing), join(dir, aside));
281
+ writeFileSync(join(dir, file), t.viteConfig(o));
282
+ return [
283
+ { kind: 'wrote', what: file, detail: `the renderer owns the frontend now` },
284
+ {
285
+ kind: 'manual',
286
+ what: aside,
287
+ detail: `your previous config, moved aside. It served the Blade asset pipeline ` +
288
+ `(laravel-vite-plugin, @vite in a layout). Delete it once nothing uses that; ` +
289
+ `to keep a Blade page, build it with \`vite build --config ${aside}\`.`,
290
+ },
291
+ ];
292
+ }
293
+ return [manualPluginStep(o, existing)];
294
+ }
295
+ /** The edit to make by hand when a config the build cannot own is already there. */
296
+ function manualPluginStep(o, existing) {
234
297
  const p = t.paths(o);
235
298
  const shown = [
236
299
  `sourceDir: '${p.sourceDir}'`,
237
300
  `outDir: '${p.outDir}'`,
238
301
  ...(p.hotFile ? [`hotFile: '${p.hotFile}'`] : []),
239
302
  ].join(', ');
240
- return [
241
- {
242
- kind: 'manual',
243
- what: existing,
244
- detail: `add the plugin — it must come before any react() layer:\n` +
245
- ` import { rscKit } from '@rsc-kit/core/vite'\n\n` +
246
- ` plugins: [\n` +
247
- ` rscKit({ ${shown} }),\n` +
248
- ` …whatever you already have\n` +
249
- ` ]`,
250
- },
251
- ];
303
+ return {
304
+ kind: 'manual',
305
+ what: existing,
306
+ detail: `add the plugin — it must come before any react() layer:\n` +
307
+ ` import { rscKit } from '@rsc-kit/core/vite'\n\n` +
308
+ ` plugins: [\n` +
309
+ ` rscKit({ ${shown} }),\n` +
310
+ ` …whatever you already have\n` +
311
+ ` ]`,
312
+ };
252
313
  }
253
314
  /** The route tree, only where there is not one already. */
254
315
  function routes(o, dir) {
@@ -302,6 +363,38 @@ function routes(o, dir) {
302
363
  * takes `string` again instead of the route union, so a link to a page that
303
364
  * does not exist compiles and 404s in the browser.
304
365
  */
366
+ /** JSON with comments, made JSON: comments outside strings removed, strings kept whole. */
367
+ function withoutComments(source) {
368
+ let out = '';
369
+ let i = 0;
370
+ while (i < source.length) {
371
+ const c = source[i];
372
+ if (c === '"') {
373
+ const end = source.indexOf('"', i + 1);
374
+ let j = end;
375
+ // A quote escaped inside the string is not its end.
376
+ while (j !== -1 && source[j - 1] === '\\')
377
+ j = source.indexOf('"', j + 1);
378
+ const close = j === -1 ? source.length : j + 1;
379
+ out += source.slice(i, close);
380
+ i = close;
381
+ continue;
382
+ }
383
+ if (c === '/' && source[i + 1] === '/') {
384
+ const nl = source.indexOf('\n', i);
385
+ i = nl === -1 ? source.length : nl;
386
+ continue;
387
+ }
388
+ if (c === '/' && source[i + 1] === '*') {
389
+ const end = source.indexOf('*/', i + 2);
390
+ i = end === -1 ? source.length : end + 2;
391
+ continue;
392
+ }
393
+ out += c;
394
+ i++;
395
+ }
396
+ return out;
397
+ }
305
398
  function tsconfig(o, found, dir) {
306
399
  const path = join(dir, 'tsconfig.json');
307
400
  if (!found.hasTypeScript) {
@@ -313,10 +406,14 @@ function tsconfig(o, found, dir) {
313
406
  // formatting someone chose — a worse trade than one line of output, for a
314
407
  // file this does not own.
315
408
  const current = readFileSync(path, 'utf-8');
316
- // Comments are legal here and JSON.parse does not take them.
409
+ // Comments are legal here and JSON.parse does not take them. Stripped
410
+ // outside strings only: a glob like ".rsc-kit/**/*" holds "/*", and a
411
+ // regex that did not know about strings read it as a comment opening,
412
+ // ate the rest of the file, and reported the tsconfig this very tool
413
+ // wrote as missing the entry it wrote.
317
414
  const include = (() => {
318
415
  try {
319
- const parsed = JSON.parse(current.replace(/\/\*[\s\S]*?\*\/|(^|\s)\/\/.*$/gm, '$1'));
416
+ const parsed = JSON.parse(withoutComments(current));
320
417
  return Array.isArray(parsed.include) ? parsed.include : null;
321
418
  }
322
419
  catch {
@@ -356,8 +453,12 @@ function gitignore(o, dir) {
356
453
  // machine at a dev server that is not running there.
357
454
  const all = ['.output', p.outDir, ...(p.hotFile ? [p.hotFile] : [])];
358
455
  const outputs = all.filter((path) => !all.some((other) => other !== path && path.startsWith(other + '/')));
456
+ // A backend's secret lives in .env, which is the file whose commit is
457
+ // noticed late. Only when there is one: a project without a backend has
458
+ // its own policy and this leaves it alone.
459
+ const secrets = o.backend && o.host !== 'laravel' ? ['.env', '.env.*', '!.env.example'] : [];
359
460
  const current = existsSync(path) ? readFileSync(path, 'utf-8') : '';
360
- const missing = [...generated, ...outputs].filter((line) => !current.split('\n').some((existing) => existing.trim() === line));
461
+ const missing = [...generated, ...outputs, ...secrets].filter((line) => !current.split('\n').some((existing) => existing.trim() === line));
361
462
  if (missing.length === 0)
362
463
  return [{ kind: 'skipped', what: '.gitignore', detail: 'already covers the generated files' }];
363
464
  // The blank line separates this block from whatever was above it, so there
@@ -367,18 +468,50 @@ function gitignore(o, dir) {
367
468
  return [{ kind: 'merged', what: '.gitignore', detail: `added ${missing.length} generated paths` }];
368
469
  }
369
470
  /** Everything, in the order a reader would want to hear about it. */
471
+ /**
472
+ * The backend's two lines, for a project that has one and is not Laravel.
473
+ *
474
+ * .env is written once and never rewritten: the secret in it is the one the
475
+ * backend was given, and regenerating it on a second run would answer every
476
+ * host call with 403 - which reads as the application refusing its own data.
477
+ * The wiring on the other side is printed, never written: a package main in
478
+ * someone's module is not a file to add blind.
479
+ */
480
+ function backend(o, found, dir) {
481
+ if (!o.backend || o.host === 'laravel')
482
+ return [];
483
+ const steps = [];
484
+ const env = join(dir, '.env');
485
+ if (existsSync(env)) {
486
+ steps.push({ kind: 'skipped', what: '.env', detail: 'already exists; RSC_BACKEND and RSC_HOST_CALL_SECRET must be in it' });
487
+ }
488
+ else {
489
+ writeFileSync(env, t.backendEnv(o.backend, randomBytes(32).toString('base64url')));
490
+ steps.push({ kind: 'wrote', what: '.env', detail: 'RSC_BACKEND and a generated RSC_HOST_CALL_SECRET' });
491
+ }
492
+ const example = join(dir, '.env.example');
493
+ if (!existsSync(example)) {
494
+ writeFileSync(example, t.backendEnvExample(o.backend));
495
+ steps.push({ kind: 'wrote', what: '.env.example' });
496
+ }
497
+ steps.push({ kind: 'manual', what: 'backend', detail: t.backendStep(found.go) });
498
+ return steps;
499
+ }
370
500
  export function initialise(o, found, dir) {
371
501
  const steps = [
372
502
  ...routes(o, dir),
373
503
  ...viteConfig(o, found, dir),
374
504
  ...tsconfig(o, found, dir),
375
505
  ...gitignore(o, dir),
506
+ ...backend(o, found, dir),
376
507
  ...mergeDependencies(o, found),
377
508
  ...mergeScripts(o, found),
378
509
  ];
379
510
  writeFileSync(join(dir, 'package.json'), JSON.stringify(found.packageJson, null, 2) + '\n');
380
511
  return steps;
381
512
  }
513
+ /** Where a Go server listens, unless told otherwise. */
514
+ const DEFAULT_BACKEND = 'http://127.0.0.1:8080';
382
515
  const INIT_HELP = `
383
516
  rsc-kit init — add RSC to the project in this directory
384
517
 
@@ -390,7 +523,9 @@ const INIT_HELP = `
390
523
  --source-dir <dir> where app/ should live (detected, usually src)
391
524
  --host=… bun | hono | elysia | node (detected from your deps)
392
525
  laravel is detected from artisan, never asked
393
- --backend=<url> for laravel: where host calls go, e.g. http://app.test
526
+ --backend=<url> a backend answering host calls - a Go server, say, at
527
+ http://127.0.0.1:8080 (assumed when go.mod is here);
528
+ laravel reads APP_URL instead
394
529
  --compiler=… none | oxc | babel
395
530
  --tailwind add Tailwind as well
396
531
  -y, --yes accept what was detected, ask nothing
@@ -423,9 +558,13 @@ export async function runInit(args) {
423
558
  stdout.write(` ${dim('server')} ${found.host}${flags.host ? '' : dim(' (detected)')}\n`);
424
559
  stdout.write(` ${dim('source')} ${flags.sourceDir ?? found.sourceDir ?? 'src'}\n`);
425
560
  stdout.write(` ${dim('react')} ${found.hasReact ? 'already here' : 'will be added'}\n`);
561
+ const backendUrl = flags.backend ?? (found.go ? DEFAULT_BACKEND : undefined);
426
562
  if (found.host === 'laravel') {
427
563
  stdout.write(` ${dim('backend')} ${flags.backend ?? 'http://localhost'}\n`);
428
564
  }
565
+ else if (backendUrl) {
566
+ stdout.write(` ${dim('backend')} ${backendUrl}${flags.backend ? '' : dim(' (go.mod is here)')}\n`);
567
+ }
429
568
  stdout.write('\n');
430
569
  let compiler = flags.compiler ?? 'none';
431
570
  let tailwind = flags.tailwind ?? found.hasTailwind;
@@ -448,11 +587,14 @@ export async function runInit(args) {
448
587
  compiler,
449
588
  tailwind,
450
589
  lint: false,
590
+ // An existing project has its own schema library and env handling; init adds neither.
591
+ validation: 'none',
592
+ env: false,
451
593
  sourceDir: flags.sourceDir ?? found.sourceDir ?? 'src',
452
594
  install: false,
453
595
  git: false,
454
596
  core: flags.core ?? publishedCore(),
455
- backend: flags.backend,
597
+ backend: backendUrl,
456
598
  };
457
599
  const steps = initialise(options, found, dir);
458
600
  const mark = { wrote: cyan('+'), merged: cyan('~'), manual: bold('!'), skipped: dim('·') };
@@ -462,7 +604,7 @@ export async function runInit(args) {
462
604
  }
463
605
  const manual = steps.filter((s) => s.kind === 'manual');
464
606
  if (manual.length > 0) {
465
- stdout.write(`\n${bold('Then, by hand:')} the edits marked ! above are in files you already had.\n\n`);
607
+ stdout.write(`\n${bold('Then, by hand:')} the steps marked ! above.\n\n`);
466
608
  return;
467
609
  }
468
610
  stdout.write(options.host === 'laravel'
package/dist/options.d.ts CHANGED
@@ -8,6 +8,13 @@
8
8
  */
9
9
  export type Host = 'bun' | 'node' | 'worker' | 'laravel';
10
10
  export type Compiler = 'none' | 'oxc' | 'babel';
11
+ /**
12
+ * The validation library, asked once because everything schema-shaped hangs
13
+ * off it: typed environment variables, `action.input(schema)`, a form's
14
+ * schema, a page's `searchParams`. All three speak Standard Schema, which is
15
+ * what the engine reads, so none is special-cased anywhere else.
16
+ */
17
+ export type Validation = 'none' | 'zod' | 'valibot' | 'arktype';
11
18
  export interface Options {
12
19
  dir: string;
13
20
  name: string;
@@ -22,6 +29,13 @@ export interface Options {
22
29
  * component's rpc() reaches the application it is rendering for.
23
30
  */
24
31
  backend?: string;
32
+ validation: Validation;
33
+ /**
34
+ * Typed environment variables through @t3-oss/env-core, read once at
35
+ * startup and refused with the variable named when one is missing or wrong.
36
+ * Needs a validation library; asked only when one was chosen.
37
+ */
38
+ env: boolean;
25
39
  install: boolean;
26
40
  git: boolean;
27
41
  /** What to depend on for the engine. A path makes a local checkout testable. */
@@ -35,6 +49,12 @@ export interface Options {
35
49
  * question whose only right answer is "the framework I am already in" is a
36
50
  * question with a wrong answer available.
37
51
  */
52
+ export declare const VALIDATIONS: {
53
+ value: Validation;
54
+ label: string;
55
+ hint: string;
56
+ }[];
57
+ export declare function assertValidation(value: string): Validation;
38
58
  export declare const HOSTS: {
39
59
  value: Host;
40
60
  label: string;
@@ -93,4 +113,4 @@ export declare function assertUsableName(name: string): void;
93
113
  * generator nobody can run without reading it first.
94
114
  */
95
115
  export declare function assertInsideCwd(dir: string, cwd: string): void;
96
- export declare const HELP = "\n create-rsc-kit \u2014 scaffold an RSC app\n\n Usage\n create-rsc-kit <dir> [options]\n bun create rsc-kit@latest <dir> [options]\n\n Options\n --host=bun|node|worker where it runs \u2014 picks the Nitro preset\n --compiler=none|oxc|babel React Compiler (prompt offers oxc; babel by flag)\n --tailwind / --no-tailwind include Tailwind\n --lint / --no-lint include oxlint\n --source-dir <dir> where app/ lives (default: src)\n --init add to the project here, rather than scaffold\n --core=<spec> engine dependency, e.g. file:../rsc-kit/packages/core\n --no-install skip installing dependencies\n --no-git skip git init\n -y, --yes accept every default, ask nothing\n -h, --help this\n";
116
+ export declare const HELP = "\n create-rsc-kit \u2014 scaffold an RSC app\n\n Usage\n create-rsc-kit <dir> [options]\n bun create rsc-kit@latest <dir> [options]\n\n Options\n --host=bun|node|worker where it runs \u2014 picks the Nitro preset\n --compiler=none|oxc|babel React Compiler (prompt offers oxc; babel by flag)\n --tailwind / --no-tailwind include Tailwind\n --lint / --no-lint include oxlint\n --validation=zod|valibot|arktype|none\n the schema library; forms, actions and env use it\n --env / --no-env typed environment variables (@t3-oss/env-core)\n --source-dir <dir> where app/ lives (default: src)\n --backend=<url> a backend answering host calls \u2014 a Go server, say,\n at http://127.0.0.1:8080; writes .env with a secret\n --init add to the project here, rather than scaffold\n --core=<spec> engine dependency, e.g. file:../rsc-kit/packages/core\n --no-install skip installing dependencies\n --no-git skip git init\n -y, --yes accept every default, ask nothing\n -h, --help this\n";
package/dist/options.js CHANGED
@@ -9,6 +9,17 @@ import { fileURLToPath } from 'node:url';
9
9
  * question whose only right answer is "the framework I am already in" is a
10
10
  * question with a wrong answer available.
11
11
  */
12
+ export const VALIDATIONS = [
13
+ { value: 'zod', label: 'Zod', hint: 'the one most examples are written in' },
14
+ { value: 'valibot', label: 'Valibot', hint: 'the same idea, a tenth of the bytes in the browser' },
15
+ { value: 'arktype', label: 'ArkType', hint: 'TypeScript syntax as a string, fastest at runtime' },
16
+ { value: 'none', label: 'None', hint: 'add one later; forms and actions take any Standard Schema' },
17
+ ];
18
+ export function assertValidation(value) {
19
+ if (value === 'none' || value === 'zod' || value === 'valibot' || value === 'arktype')
20
+ return value;
21
+ throw new Error(`--validation must be one of zod, valibot, arktype or none; got ${JSON.stringify(value)}`);
22
+ }
12
23
  export const HOSTS = [
13
24
  { value: 'bun', label: 'Bun', hint: 'and compiles to a single binary' },
14
25
  { value: 'node', label: 'Node', hint: 'the default everywhere else' },
@@ -132,6 +143,12 @@ export function parseArgs(argv) {
132
143
  out.host = assertHost(arg.slice(7));
133
144
  else if (arg.startsWith('--compiler='))
134
145
  out.compiler = arg.slice(11);
146
+ else if (arg.startsWith('--validation='))
147
+ out.validation = assertValidation(arg.slice(13));
148
+ else if (arg === '--env')
149
+ out.env = true;
150
+ else if (arg === '--no-env')
151
+ out.env = false;
135
152
  else if (arg.startsWith('--core='))
136
153
  out.core = arg.slice(7);
137
154
  else if (arg.startsWith('--backend='))
@@ -183,7 +200,12 @@ export const HELP = `
183
200
  --compiler=none|oxc|babel React Compiler (prompt offers oxc; babel by flag)
184
201
  --tailwind / --no-tailwind include Tailwind
185
202
  --lint / --no-lint include oxlint
203
+ --validation=zod|valibot|arktype|none
204
+ the schema library; forms, actions and env use it
205
+ --env / --no-env typed environment variables (@t3-oss/env-core)
186
206
  --source-dir <dir> where app/ lives (default: src)
207
+ --backend=<url> a backend answering host calls — a Go server, say,
208
+ at http://127.0.0.1:8080; writes .env with a secret
187
209
  --init add to the project here, rather than scaffold
188
210
  --core=<spec> engine dependency, e.g. file:../rsc-kit/packages/core
189
211
  --no-install skip installing dependencies
@@ -23,15 +23,17 @@ export interface Paths {
23
23
  */
24
24
  export declare function paths(o: Options): Paths;
25
25
  /**
26
- * The config the RSC build runs, which for Laravel is not the app's own.
26
+ * The config the RSC build runs: the app's own, on every host.
27
27
  *
28
- * A Laravel application already has a vite.config with laravel-vite-plugin in
29
- * it, and the two cannot share one: both set an input list, an outDir and a
30
- * hot file, and whichever plugin runs second wins. So the RSC build gets its
31
- * own file and the scripts name it, rather than an install that quietly breaks
32
- * the asset pipeline the app was already using.
28
+ * Laravel used to get a second file, vite.rsc.config.ts, on the reasoning
29
+ * that laravel-vite-plugin and rscKit() cannot share one - which is true:
30
+ * laravel-vite-plugin sets base, publicDir, outDir, the input list and the
31
+ * server origin, and so does this build. But once the renderer owns the
32
+ * frontend there is nothing left for laravel-vite-plugin to do: no @vite
33
+ * directive, no public/hot, no Blade asset pipeline. The right answer is
34
+ * one config with rscKit() in it, which is what the Laravel docs app runs.
33
35
  */
34
- export declare const configFile: (o: Options) => string;
36
+ export declare const configFile: (_o: Options) => string;
35
37
  /**
36
38
  * The commands, under the names someone would guess.
37
39
  *
@@ -76,7 +78,7 @@ export declare function counter(o: Options): string;
76
78
  * and nothing warns — the page just arrives unstyled.
77
79
  */
78
80
  export declare const styles = "@import 'tailwindcss';\n\n/* The whole source tree, not just this directory. A client component is found\n automatically because it enters the browser bundle; a server component never\n does, so anything it uses has to be declared here or it is silently absent\n from the stylesheet. */\n@source '../';\n";
79
- export declare const gitignore = "node_modules\nbuild\n.output\n.rsc\ndist\n*.log\n.DS_Store\n\n# Rewritten by the build every run: the ambient declarations, and the stub\n# module the app imports its server actions from.\n.rsc-kit/\nsrc/server-actions.generated.ts\n";
81
+ export declare const gitignore = "node_modules\nbuild\n.output\n.rsc\ndist\n*.log\n.DS_Store\n\n# Rewritten by the build every run: the ambient declarations, and the stub\n# module the app imports its server actions from.\n.rsc-kit/\nsrc/server-actions.generated.ts\n\n# Secrets. The example is the one to commit.\n.env\n.env.*\n!.env.example\n";
80
82
  /**
81
83
  * oxlint, with the React Compiler's own rules turned on.
82
84
  *
@@ -112,3 +114,39 @@ export declare function agents(o: Options): string;
112
114
  */
113
115
  export declare function mcp(): string;
114
116
  export declare function readme(o: Options): string;
117
+ /**
118
+ * Typed environment variables, read once at startup.
119
+ *
120
+ * A missing or malformed variable is refused here, with its name, before
121
+ * anything runs - not as an `undefined` three calls later. Server variables
122
+ * never reach the browser; a client one has to carry the prefix, and is read
123
+ * from import.meta.env, which is what Vite exposes there.
124
+ */
125
+ export declare function env(o: Options): string;
126
+ /**
127
+ * The process bootstrap, written when the app validates its environment.
128
+ *
129
+ * env.ts refuses at import - and where that import happens decides who sees
130
+ * the refusal. Imported by the first page that needs a variable, a missing
131
+ * one is reported by that page, to that visitor, after the server said it
132
+ * was up. Imported here, the generated entry evaluates this file before any
133
+ * page module and awaits register() before the first request, so a server
134
+ * with a bad environment does not start. Next names the file the same.
135
+ */
136
+ export declare function instrumentation(_o: Options): string;
137
+ /**
138
+ * The two lines that wire a backend: where host calls go, and the secret it
139
+ * checks. Written when the project has a backend answering them - a Go
140
+ * server, or anything speaking the contract - so the renderer needs no
141
+ * configuring in development or in production. A Laravel app has both in
142
+ * its .env already, under APP_URL.
143
+ */
144
+ export declare function backendEnv(backend: string, secret: string): string;
145
+ export declare function backendEnvExample(backend: string): string;
146
+ /**
147
+ * What the backend has to do, said once at the end. Go gets its own, because
148
+ * the project said it was Go; anything else gets the contract.
149
+ */
150
+ export declare function backendStep(go: boolean): string;
151
+ /** The example beside it - the one file that is committed. */
152
+ export declare const envExample = "# Copy to .env and fill in. Server variables never reach the browser;\n# a browser-readable one starts with PUBLIC_. The schema is src/env.ts.\n#\n# Not NODE_ENV. Vite sets it - development under `vite`, production under\n# `vite build` - and a value written here overrides that: NODE_ENV=development\n# in .env makes a production build emit React's development JSX runtime,\n# which the production server does not have, and every page fails to render.\n# DATABASE_URL=\n# SESSION_SECRET=\n# PUBLIC_SITE_URL=\n";
package/dist/templates.js CHANGED
@@ -29,15 +29,17 @@ export function paths(o) {
29
29
  };
30
30
  }
31
31
  /**
32
- * The config the RSC build runs, which for Laravel is not the app's own.
32
+ * The config the RSC build runs: the app's own, on every host.
33
33
  *
34
- * A Laravel application already has a vite.config with laravel-vite-plugin in
35
- * it, and the two cannot share one: both set an input list, an outDir and a
36
- * hot file, and whichever plugin runs second wins. So the RSC build gets its
37
- * own file and the scripts name it, rather than an install that quietly breaks
38
- * the asset pipeline the app was already using.
34
+ * Laravel used to get a second file, vite.rsc.config.ts, on the reasoning
35
+ * that laravel-vite-plugin and rscKit() cannot share one - which is true:
36
+ * laravel-vite-plugin sets base, publicDir, outDir, the input list and the
37
+ * server origin, and so does this build. But once the renderer owns the
38
+ * frontend there is nothing left for laravel-vite-plugin to do: no @vite
39
+ * directive, no public/hot, no Blade asset pipeline. The right answer is
40
+ * one config with rscKit() in it, which is what the Laravel docs app runs.
39
41
  */
40
- export const configFile = (o) => o.host === 'laravel' ? 'vite.rsc.config.ts' : 'vite.config.ts';
42
+ export const configFile = (_o) => 'vite.config.ts';
41
43
  /**
42
44
  * The commands, under the names someone would guess.
43
45
  *
@@ -48,14 +50,13 @@ export const configFile = (o) => o.host === 'laravel' ? 'vite.rsc.config.ts' : '
48
50
  */
49
51
  export function scripts(o) {
50
52
  if (o.host === 'laravel') {
51
- const config = `--config ${configFile(o)}`;
52
53
  const actions = 'php artisan rsc:action-manifest';
53
54
  return {
54
55
  // The ordinary names. A Laravel application already has `dev` and
55
56
  // `build`, and init combines rather than replaces — the stock ones run
56
57
  // the asset pipeline, and both pipelines belong to `npm run dev`.
57
- dev: `${actions} && vite ${config}`,
58
- build: `${actions} && vite build ${config}`,
58
+ dev: `${actions} && vite`,
59
+ build: `${actions} && vite build`,
59
60
  // What the build wrote. There is no server file to start any more.
60
61
  start: 'bun .output/server/index.mjs',
61
62
  };
@@ -154,6 +155,14 @@ export function packageJson(o) {
154
155
  }
155
156
  if (o.lint)
156
157
  dev['oxlint'] = '^1.81.0';
158
+ if (o.validation === 'zod')
159
+ deps['zod'] = '^4.0.0';
160
+ if (o.validation === 'valibot')
161
+ deps['valibot'] = '^1.0.0';
162
+ if (o.validation === 'arktype')
163
+ deps['arktype'] = '^2.1.0';
164
+ if (o.env)
165
+ deps['@t3-oss/env-core'] = '^0.13.0';
157
166
  return (JSON.stringify({
158
167
  name: o.name,
159
168
  type: 'module',
@@ -442,6 +451,11 @@ dist
442
451
  # module the app imports its server actions from.
443
452
  .rsc-kit/
444
453
  src/server-actions.generated.ts
454
+
455
+ # Secrets. The example is the one to commit.
456
+ .env
457
+ .env.*
458
+ !.env.example
445
459
  `;
446
460
  /**
447
461
  * oxlint, with the React Compiler's own rules turned on.
@@ -543,7 +557,7 @@ stored, which render per request, and why:
543
557
  \`\`\`
544
558
  ○ /account 85 kB
545
559
  ◐ /locale 85 kB
546
- dynamic — called cookies(), headers()
560
+ cookies(), headers() stream per request; the rest is stored
547
561
  \`\`\`
548
562
 
549
563
  If a page you expected to be static is not, the reason is on that line. Do not
@@ -582,6 +596,16 @@ at build time. That is usually correct — just know that it is the trade.
582
596
  \`await connection()\` says "render this per visitor" deliberately, when
583
597
  nothing else in the page happens to say it.
584
598
 
599
+ ## Startup
600
+
601
+ \`${o.sourceDir}/instrumentation.ts\` runs once before anything else — at
602
+ startup on a server, at the first request on a Worker — and the entry imports
603
+ it before any page. ${o.env
604
+ ? "It imports \`./env\`, so a missing or malformed variable stops the server from starting rather than reaching a visitor. "
605
+ : ''}Put once-per-process setup there (\`register()\` may be async, and the first
606
+ render waits for it). Do not import a bootstrap module from pages to get the
607
+ same effect; it depends on nobody forgetting.
608
+
585
609
  ## Data
586
610
 
587
611
  Fetch in a server component and await it. There is no loader and no
@@ -740,3 +764,144 @@ the ambient declarations. Rewritten every build, and gitignored.
740
764
  Docs: https://docs.rsc-kit.dev
741
765
  `;
742
766
  }
767
+ /**
768
+ * Typed environment variables, read once at startup.
769
+ *
770
+ * A missing or malformed variable is refused here, with its name, before
771
+ * anything runs - not as an `undefined` three calls later. Server variables
772
+ * never reach the browser; a client one has to carry the prefix, and is read
773
+ * from import.meta.env, which is what Vite exposes there.
774
+ */
775
+ export function env(o) {
776
+ const lib = {
777
+ zod: {
778
+ imp: "import * as z from 'zod'",
779
+ url: 'z.url()',
780
+ str: 'z.string().min(1)',
781
+ opt: "z.enum(['development', 'production', 'test']).default('development')",
782
+ },
783
+ valibot: {
784
+ imp: "import * as v from 'valibot'",
785
+ url: 'v.pipe(v.string(), v.url())',
786
+ str: 'v.pipe(v.string(), v.minLength(1))',
787
+ opt: "v.optional(v.picklist(['development', 'production', 'test']), 'development')",
788
+ },
789
+ arktype: {
790
+ imp: "import { type } from 'arktype'",
791
+ url: "type('string.url')",
792
+ str: "type('string > 0')",
793
+ opt: "type(\"'development' | 'production' | 'test' | undefined\")",
794
+ },
795
+ }[o.validation === 'none' ? 'zod' : o.validation];
796
+ return `${lib.imp}
797
+ import { createEnv } from '@t3-oss/env-core'
798
+
799
+ // Read once, here, and refused with the variable named when one is missing
800
+ // or wrong - before anything runs, not as an undefined three calls later.
801
+ //
802
+ // Server variables stay on the server. A variable the browser may read has
803
+ // to start with PUBLIC_, and is read from import.meta.env, which is what Vite
804
+ // exposes there. Add a variable: one line in the schema, and every reader is
805
+ // typed.
806
+ export const env = createEnv({
807
+ server: {
808
+ NODE_ENV: ${lib.opt},
809
+ // DATABASE_URL: ${lib.url},
810
+ // SESSION_SECRET: ${lib.str},
811
+ },
812
+ clientPrefix: 'PUBLIC_',
813
+ client: {
814
+ // PUBLIC_SITE_URL: ${lib.url},
815
+ },
816
+ runtimeEnv: { ...process.env, ...import.meta.env },
817
+ emptyStringAsUndefined: true,
818
+ // A build machine without the production variables: SKIP_ENV_VALIDATION=1
819
+ // builds anyway, and the server that runs the build validates at startup.
820
+ skipValidation: !!process.env.SKIP_ENV_VALIDATION,
821
+ })
822
+ `;
823
+ }
824
+ /**
825
+ * The process bootstrap, written when the app validates its environment.
826
+ *
827
+ * env.ts refuses at import - and where that import happens decides who sees
828
+ * the refusal. Imported by the first page that needs a variable, a missing
829
+ * one is reported by that page, to that visitor, after the server said it
830
+ * was up. Imported here, the generated entry evaluates this file before any
831
+ * page module and awaits register() before the first request, so a server
832
+ * with a bad environment does not start. Next names the file the same.
833
+ */
834
+ export function instrumentation(_o) {
835
+ return `// Runs once, before anything else: on a server at startup, on a Worker
836
+ // when its first request arrives. The entry imports this file first, so a
837
+ // package configured here is configured before any page module evaluates.
838
+ //
839
+ // env.ts validates on import. Importing it here means a missing variable
840
+ // stops the server from starting rather than reaching a visitor as a page
841
+ // that fails three calls later.
842
+ import './env'
843
+
844
+ // Anything asynchronous the app needs before its first request - warming a
845
+ // connection, checking a migration - goes here. The first render waits for
846
+ // it. Read environment inside this function, not at the top of the module,
847
+ // if the app deploys to a Worker: a binding is only readable once a request
848
+ // has arrived.
849
+ export async function register() {}
850
+ `;
851
+ }
852
+ /**
853
+ * The two lines that wire a backend: where host calls go, and the secret it
854
+ * checks. Written when the project has a backend answering them - a Go
855
+ * server, or anything speaking the contract - so the renderer needs no
856
+ * configuring in development or in production. A Laravel app has both in
857
+ * its .env already, under APP_URL.
858
+ */
859
+ export function backendEnv(backend, secret) {
860
+ return `# Where rpc() goes: a backend answering POST /__rsc/host-call. The renderer
861
+ # reads both of these, in development (vite) and in production (the built
862
+ # server). The backend checks the secret on every call; keep it out of git.
863
+ RSC_BACKEND=${backend}
864
+ RSC_HOST_CALL_SECRET=${secret}
865
+ `;
866
+ }
867
+ export function backendEnvExample(backend) {
868
+ return `# Copy to .env. The backend answering rpc(), and the secret it checks -
869
+ # generate one: openssl rand -base64 32
870
+ RSC_BACKEND=${backend}
871
+ RSC_HOST_CALL_SECRET=
872
+ `;
873
+ }
874
+ /**
875
+ * What the backend has to do, said once at the end. Go gets its own, because
876
+ * the project said it was Go; anything else gets the contract.
877
+ */
878
+ export function backendStep(go) {
879
+ if (go) {
880
+ return [
881
+ 'answer host calls from your Go server - go get github.com/rsc-kit/go, then:',
882
+ '',
883
+ ' reg := rsckit.NewRegistry()',
884
+ ' reg.Register("Orders.recent", func(ctx context.Context, args rsckit.Args) (any, error) { … })',
885
+ ' callback, _ := rsckit.NewCallbackHandler(reg, os.Getenv("RSC_HOST_CALL_SECRET"))',
886
+ ' mux.Handle("POST /__rsc/host-call", callback)',
887
+ '',
888
+ ' A page reads it with await rpc(\'Orders.recent\'). https://rsc-kit.dev/hosts/go',
889
+ ].join('\n');
890
+ }
891
+ return [
892
+ 'answer POST /__rsc/host-call from your backend, checking X-Rsc-Host-Secret',
893
+ ' against RSC_HOST_CALL_SECRET in .env. The contract: https://rsc-kit.dev/hosts/your-own-backend',
894
+ ].join('\n');
895
+ }
896
+ /** The example beside it - the one file that is committed. */
897
+ export const envExample = `# Copy to .env and fill in. Server variables never reach the browser;
898
+ # a browser-readable one starts with PUBLIC_. The schema is src/env.ts.
899
+ #
900
+ # Not NODE_ENV. Vite sets it - development under \`vite\`, production under
901
+ # \`vite build\` - and a value written here overrides that: NODE_ENV=development
902
+ # in .env makes a production build emit React's development JSX runtime,
903
+ # which the production server does not have, and every page fails to render.
904
+ # DATABASE_URL=
905
+ # SESSION_SECRET=
906
+ # PUBLIC_SITE_URL=
907
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-rsc-kit",
3
- "version": "0.16.3",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "description": "Scaffold an RSC app on Bun, Hono, Elysia or Node.",
6
6
  "bin": {