create-rsc-kit 0.18.0 → 0.19.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
@@ -152,12 +152,19 @@ function write(o) {
152
152
  ['.gitignore', t.gitignore],
153
153
  ['README.md', t.readme(o)],
154
154
  ['AGENTS.md', t.agents(o)],
155
- ['.mcp.json', t.mcp()],
155
+ ['.mcp.json', t.mcp(o)],
156
156
  ['src/app/layout.tsx', t.layout(o)],
157
157
  ['src/app/page.tsx', t.page(o)],
158
158
  ['src/components/Counter.tsx', t.counter(o)],
159
159
  ['tests/app.test.ts', t.smokeTest(o)],
160
160
  ];
161
+ // Bun's test runner reads the real server-only package, which throws on
162
+ // import; a preload stubs it so an action file that carries the line is
163
+ // still a function a test can call.
164
+ if (o.host !== 'node') {
165
+ files.push(['bunfig.toml', t.bunfig]);
166
+ files.push(['tests/preload.ts', t.testPreload]);
167
+ }
161
168
  if (o.tailwind)
162
169
  files.push(['src/app/styles.css', t.styles]);
163
170
  if (o.env) {
package/dist/init.js CHANGED
@@ -182,6 +182,59 @@ function combine(name, theirs, ours) {
182
182
  * that quietly replaces a working build script loses someone's trust
183
183
  * permanently, and it only has to be wrong once.
184
184
  */
185
+ /** Where this tool's section of an AGENTS.md begins and ends, so a second run finds it. */
186
+ const AGENTS_START = '<!-- rsc-kit:start -->';
187
+ const AGENTS_END = '<!-- rsc-kit:end -->';
188
+ /**
189
+ * This tool's instructions, added to an existing AGENTS.md or written as one.
190
+ *
191
+ * Someone else's instructions are not a file to rewrite, but a delimited
192
+ * section at the end is not a rewrite: theirs stay exactly as written above
193
+ * it, the markers say what is ours, and a second run finds the markers and
194
+ * leaves it be. A project that already has an AGENTS.md is the project whose
195
+ * agents most need to hear how this works.
196
+ */
197
+ function mergeAgents(dir, o) {
198
+ const path = join(dir, 'AGENTS.md');
199
+ if (!existsSync(path)) {
200
+ writeFileSync(path, t.agents(o));
201
+ return { kind: 'wrote', what: 'AGENTS.md' };
202
+ }
203
+ const existing = readFileSync(path, 'utf8');
204
+ if (existing.includes(AGENTS_START) || /rsc-kit/i.test(existing)) {
205
+ return { kind: 'skipped', what: 'AGENTS.md', detail: 'already covers rsc-kit' };
206
+ }
207
+ writeFileSync(path, existing.replace(/\s*$/, '\n\n') + `${AGENTS_START}\n${t.agents(o).trim()}\n${AGENTS_END}\n`);
208
+ return { kind: 'merged', what: 'AGENTS.md', detail: 'added a section at the end; yours is untouched above it' };
209
+ }
210
+ /**
211
+ * The rsc-kit server, added to an existing .mcp.json or written as a new one.
212
+ *
213
+ * The file is a map of servers under `mcpServers`; a project with other
214
+ * servers keeps them, and one that already lists rsc-kit is left alone. A
215
+ * file that does not parse is not one to rewrite.
216
+ */
217
+ function mergeMcp(dir, o) {
218
+ const path = join(dir, '.mcp.json');
219
+ if (!existsSync(path)) {
220
+ writeFileSync(path, t.mcp(o));
221
+ return { kind: 'wrote', what: '.mcp.json' };
222
+ }
223
+ let existing;
224
+ try {
225
+ existing = JSON.parse(readFileSync(path, 'utf8'));
226
+ }
227
+ catch {
228
+ return { kind: 'skipped', what: '.mcp.json', detail: 'already exists and is not JSON; add the rsc-kit server by hand' };
229
+ }
230
+ if (existing.mcpServers?.['rsc-kit']) {
231
+ return { kind: 'skipped', what: '.mcp.json', detail: 'already lists rsc-kit' };
232
+ }
233
+ const ours = JSON.parse(t.mcp(o)).mcpServers['rsc-kit'];
234
+ existing.mcpServers = { ...(existing.mcpServers ?? {}), 'rsc-kit': ours };
235
+ writeFileSync(path, JSON.stringify(existing, null, 2) + '\n');
236
+ return { kind: 'merged', what: '.mcp.json', detail: 'added the rsc-kit server beside the others' };
237
+ }
185
238
  function mergeScripts(o, found) {
186
239
  const pkg = found.packageJson;
187
240
  const scripts = pkg.scripts ?? {};
@@ -315,18 +368,17 @@ function manualPluginStep(o, existing) {
315
368
  function routes(o, dir) {
316
369
  const appDir = join(dir, o.sourceDir, 'app');
317
370
  const steps = [];
371
+ // The two files beside the tree are looked at whether or not the tree is
372
+ // here: a project that already has pages is the one whose agents and
373
+ // editor most need to know about this.
374
+ steps.push(mergeAgents(dir, o), mergeMcp(dir, o));
318
375
  if (existsSync(join(appDir, 'layout.tsx')) || existsSync(join(appDir, 'page.tsx'))) {
319
- return [{ kind: 'skipped', what: `${o.sourceDir}/app`, detail: 'a route tree is already here' }];
376
+ return [...steps, { kind: 'skipped', what: `${o.sourceDir}/app`, detail: 'a route tree is already here' }];
320
377
  }
321
378
  const files = [
322
379
  [join(o.sourceDir, 'app/layout.tsx'), t.layout(o)],
323
380
  [join(o.sourceDir, 'app/page.tsx'), t.page(o)],
324
381
  [join(o.sourceDir, 'components/Counter.tsx'), t.counter(o)],
325
- // Beside the route tree rather than merged into an existing AGENTS.md: a
326
- // file of someone else's instructions is not one to append to blind, and
327
- // the loop below skips it if it is already there.
328
- ['AGENTS.md', t.agents(o)],
329
- ['.mcp.json', t.mcp()],
330
382
  ];
331
383
  if (o.tailwind)
332
384
  files.push([join(o.sourceDir, 'app/styles.css'), t.styles]);
@@ -401,10 +453,10 @@ function tsconfig(o, found, dir) {
401
453
  writeFileSync(path, t.tsconfig(o));
402
454
  return [{ kind: 'wrote', what: 'tsconfig.json' }];
403
455
  }
404
- // Reported, never rewritten. Adding the entry means parsing and reprinting
405
- // the file, which loses the comments a tsconfig is allowed to have and the
406
- // formatting someone chose — a worse trade than one line of output, for a
407
- // file this does not own.
456
+ // Edited in place, never reprinted: parsing and printing the file would
457
+ // lose the comments a tsconfig is allowed to have and the formatting
458
+ // someone chose. The entry goes in as text, at the front of the include
459
+ // list that is there, or as a list of its own after the opening brace.
408
460
  const current = readFileSync(path, 'utf-8');
409
461
  // Comments are legal here and JSON.parse does not take them. Stripped
410
462
  // outside strings only: a glob like ".rsc-kit/**/*" holds "/*", and a
@@ -428,13 +480,25 @@ function tsconfig(o, found, dir) {
428
480
  // with a dot is outside it. So both cases need the entry, and a project with
429
481
  // no `include` needs `**\/*` written alongside it or it loses everything
430
482
  // else. Measured both ways.
483
+ const opened = /"include"\s*:\s*\[/.exec(current);
484
+ if (include && opened) {
485
+ const at = opened.index + opened[0].length;
486
+ const rest = current.slice(at);
487
+ // The list's own style: one entry per line, or all on one.
488
+ const separator = /^\s*\n/.test(rest) ? rest.match(/^\s*\n(\s*)/)[0] : ' ';
489
+ writeFileSync(path, current.slice(0, at) + `${separator}"${TYPES_GLOB}",` + (separator === ' ' ? ' ' : '') + rest.replace(/^\s*\n/, ''));
490
+ return [{ kind: 'merged', what: 'tsconfig.json', detail: `added "${TYPES_GLOB}" to "include"` }];
491
+ }
492
+ const brace = current.indexOf('{');
493
+ if (!include && brace !== -1) {
494
+ writeFileSync(path, current.slice(0, brace + 1) + `\n "include": ["**/*", "${TYPES_GLOB}"],` + current.slice(brace + 1));
495
+ return [{ kind: 'merged', what: 'tsconfig.json', detail: `added "include": ["**/*", "${TYPES_GLOB}"]` }];
496
+ }
431
497
  return [
432
498
  {
433
499
  kind: 'manual',
434
500
  what: 'tsconfig.json',
435
- detail: include
436
- ? `add "${TYPES_GLOB}" to "include", or typed routes fall back to string`
437
- : `add "include": ["**/*", "${TYPES_GLOB}"], or typed routes fall back to string`,
501
+ detail: `add "${TYPES_GLOB}" to "include", or typed routes fall back to string`,
438
502
  },
439
503
  ];
440
504
  }
@@ -483,7 +547,23 @@ function backend(o, found, dir) {
483
547
  const steps = [];
484
548
  const env = join(dir, '.env');
485
549
  if (existsSync(env)) {
486
- steps.push({ kind: 'skipped', what: '.env', detail: 'already exists; RSC_BACKEND and RSC_HOST_CALL_SECRET must be in it' });
550
+ // The two lines, added to what is there. A secret already present is
551
+ // the one the backend was given and is never regenerated; a backend
552
+ // already named is left as named.
553
+ const current = readFileSync(env, 'utf8');
554
+ const missing = [];
555
+ if (!/^\s*RSC_BACKEND=/m.test(current))
556
+ missing.push(`RSC_BACKEND=${o.backend}`);
557
+ if (!/^\s*RSC_HOST_CALL_SECRET=/m.test(current)) {
558
+ missing.push(`RSC_HOST_CALL_SECRET=${randomBytes(32).toString('base64url')}`);
559
+ }
560
+ if (missing.length === 0) {
561
+ steps.push({ kind: 'skipped', what: '.env', detail: 'already has RSC_BACKEND and RSC_HOST_CALL_SECRET' });
562
+ }
563
+ else {
564
+ writeFileSync(env, current.replace(/\s*$/, '\n\n') + missing.join('\n') + '\n');
565
+ steps.push({ kind: 'merged', what: '.env', detail: `added ${missing.map((line) => line.split('=')[0]).join(' and ')}` });
566
+ }
487
567
  }
488
568
  else {
489
569
  writeFileSync(env, t.backendEnv(o.backend, randomBytes(32).toString('base64url')));
@@ -515,9 +595,10 @@ const DEFAULT_BACKEND = 'http://127.0.0.1:8080';
515
595
  const INIT_HELP = `
516
596
  rsc-kit init — add RSC to the project in this directory
517
597
 
518
- Nothing existing is ever rewritten. New files are written, missing
519
- dependencies are added, and for anything already there the exact edit is
520
- printed for you to make.
598
+ Nothing existing is rewritten. New files are written; a file that is a list
599
+ gets our entry added to it - .mcp.json, AGENTS.md, tsconfig.json's include,
600
+ .env - with yours left as written; and for anything else already there the
601
+ exact edit is printed for you to make.
521
602
 
522
603
  Options
523
604
  --source-dir <dir> where app/ should live (detected, usually src)
@@ -66,6 +66,18 @@ export declare function layout(o: Options): string;
66
66
  * the app, and what gets chosen is a port, a spawned server, and a sleep. This
67
67
  * is the shape instead: the deployed handler, a Request in, a Response out.
68
68
  */
69
+ /** Bun's test settings: the preload below, before every test file. */
70
+ export declare const bunfig = "[test]\npreload = [\"./tests/preload.ts\"]\n";
71
+ /**
72
+ * What every test file sees first.
73
+ *
74
+ * `import 'server-only'` is honoured by the build - a client file importing
75
+ * the module fails to build rather than shipping a secret - and resolves to
76
+ * nothing on the server. Under bun test it is the real package, which throws
77
+ * on import, so an action or a route that carries the line would not be a
78
+ * function a test can call. Stubbed here, once.
79
+ */
80
+ export declare const testPreload = "import { mock } from 'bun:test'\n\n// The build honours this import and resolves it to nothing on the server;\n// the real package throws when imported, which is what a test would hit.\nmock.module('server-only', () => ({}))\nmock.module('client-only', () => ({}))\n";
69
81
  export declare function smokeTest(o: Options): string;
70
82
  export declare function page(o: Options): string;
71
83
  export declare function counter(o: Options): string;
@@ -112,7 +124,7 @@ export declare function agents(o: Options): string;
112
124
  * the server the first time an agent starts it. Other clients want the same
113
125
  * four lines in their own file.
114
126
  */
115
- export declare function mcp(): string;
127
+ export declare function mcp(o?: Options): string;
116
128
  export declare function readme(o: Options): string;
117
129
  /**
118
130
  * Typed environment variables, read once at startup.
package/dist/templates.js CHANGED
@@ -61,9 +61,16 @@ export function scripts(o) {
61
61
  start: 'bun .output/server/index.mjs',
62
62
  };
63
63
  }
64
+ // On Bun, Vite itself runs on Bun. `vite` is a bin with a node shebang, so
65
+ // `bun run dev` alone still started it under Node - and a project that
66
+ // imports `bun` or `bun:sqlite` failed at the first render with "Cannot
67
+ // find package 'bun'", in a scaffold that had just said it was a Bun app.
68
+ // `bun --bun` runs the bin on Bun's runtime; the dev server, the build and
69
+ // the prerender then see the same runtime the server will.
70
+ const vite = o.host === 'bun' ? 'bun --bun vite' : 'vite';
64
71
  return {
65
- dev: 'vite',
66
- build: 'vite build',
72
+ dev: vite,
73
+ build: `${vite} build`,
67
74
  // The two that produce something you ship build first, rather than reading
68
75
  // whatever .output happens to hold. Run on a project that has never been
69
76
  // built, they failed with `ENOENT opening root directory ".output/server"`
@@ -85,12 +92,17 @@ export function scripts(o) {
85
92
  // every asset — the static path resolves into Bun's virtual
86
93
  // filesystem, where the files on disk are not.
87
94
  //
95
+ // compile.mjs rather than index.mjs: the build writes it beside the
96
+ // server, and it is what puts the frozen pages inside the binary -
97
+ // imported by name, which a compile embeds, where the server's own
98
+ // computed import is invisible to it.
99
+ //
88
100
  // Into dist/, which is already ignored. Named after the project it
89
101
  // landed a 63MB binary in the root of, next to the source, with
90
102
  // nothing in .gitignore covering it.
91
103
  ...(o.host === 'bun'
92
104
  ? {
93
- compile: 'vite build && bun build --compile .output/server/index.mjs --outfile dist/app',
105
+ compile: `${vite} build && bun build --compile .output/server/compile.mjs --outfile dist/app`,
94
106
  }
95
107
  : {}),
96
108
  }),
@@ -319,6 +331,26 @@ export default function RootLayout({ children }: { children: ReactNode }) {
319
331
  * the app, and what gets chosen is a port, a spawned server, and a sleep. This
320
332
  * is the shape instead: the deployed handler, a Request in, a Response out.
321
333
  */
334
+ /** Bun's test settings: the preload below, before every test file. */
335
+ export const bunfig = `[test]
336
+ preload = ["./tests/preload.ts"]
337
+ `;
338
+ /**
339
+ * What every test file sees first.
340
+ *
341
+ * `import 'server-only'` is honoured by the build - a client file importing
342
+ * the module fails to build rather than shipping a secret - and resolves to
343
+ * nothing on the server. Under bun test it is the real package, which throws
344
+ * on import, so an action or a route that carries the line would not be a
345
+ * function a test can call. Stubbed here, once.
346
+ */
347
+ export const testPreload = `import { mock } from 'bun:test'
348
+
349
+ // The build honours this import and resolves it to nothing on the server;
350
+ // the real package throws when imported, which is what a test would hit.
351
+ mock.module('server-only', () => ({}))
352
+ mock.module('client-only', () => ({}))
353
+ `;
322
354
  export function smokeTest(o) {
323
355
  const runner = o.host === 'node'
324
356
  ? `import { before, describe, test } from 'node:test'
@@ -584,6 +616,11 @@ export async function createPost(input) { … } // callable from a client comp
584
616
  Do not put \`"use server"\` at the top of a page to make it a server component.
585
617
  It already is one.
586
618
 
619
+ A callback that a timer, a subscription or a listener calls and that must see
620
+ the latest props is \`useEffectEvent\` from React, not a ref you assign every
621
+ render. An Effect Event is never a dependency: leave it out of the array. The
622
+ engine's own hooks are written this way.
623
+
587
624
  ## Reading the request
588
625
 
589
626
  \`cookies()\`, \`headers()\` and \`searchParams()\` come from
@@ -606,6 +643,13 @@ it before any page. ${o.env
606
643
  render waits for it). Do not import a bootstrap module from pages to get the
607
644
  same effect; it depends on nobody forgetting.
608
645
 
646
+ ## Forms
647
+
648
+ Uncontrolled. Inputs keep their value in the DOM, an initial value is
649
+ \`defaultValue\`, and the action reads \`FormData\`. Do not write \`useState\` +
650
+ \`value\`/\`onChange\` per input. Control one field only when the UI must react
651
+ as the user types, and bind that one with \`useField\`.
652
+
609
653
  ## Data
610
654
 
611
655
  Fetch in a server component and await it. There is no loader and no
@@ -679,7 +723,8 @@ not hand-parse \`Number(searchParams.get('page'))\`.
679
723
 
680
724
  \`${o.sourceDir}/app/**/route.ts\`, exporting \`GET\`, \`POST\` and so on.
681
725
  A real \`Request\` in, a real \`Response\` out. Await \`params\`,
682
- \`searchParams\` and \`body\` from the second argument.
726
+ \`searchParams\` and \`body\` from the second argument - never
727
+ \`new URL(request.url).searchParams\`, which the build cannot see.
683
728
 
684
729
  They run their directory's \`middleware.ts\`, and a \`GET\` that reads nothing
685
730
  from the request is answered from disk.
@@ -700,10 +745,15 @@ from the request is answered from disk.
700
745
  * the server the first time an agent starts it. Other clients want the same
701
746
  * four lines in their own file.
702
747
  */
703
- export function mcp() {
748
+ export function mcp(o) {
749
+ // bunx on a Bun project, npx elsewhere: the same server either way, but an
750
+ // editor launching it should not need npm on a machine that has Bun.
751
+ const launcher = o && o.host !== 'node'
752
+ ? { command: 'bunx', args: ['@rsc-kit/mcp'] }
753
+ : { command: 'npx', args: ['-y', '@rsc-kit/mcp'] };
704
754
  return (JSON.stringify({
705
755
  mcpServers: {
706
- 'rsc-kit': { command: 'npx', args: ['-y', '@rsc-kit/mcp'] },
756
+ 'rsc-kit': launcher,
707
757
  },
708
758
  }, null, 2) + '\n');
709
759
  }
@@ -803,6 +853,8 @@ import { createEnv } from '@t3-oss/env-core'
803
853
  // to start with PUBLIC_, and is read from import.meta.env, which is what Vite
804
854
  // exposes there. Add a variable: one line in the schema, and every reader is
805
855
  // typed.
856
+ const processEnv: Record<string, string | undefined> = typeof process === 'undefined' ? {} : process.env
857
+
806
858
  export const env = createEnv({
807
859
  server: {
808
860
  NODE_ENV: ${lib.opt},
@@ -813,11 +865,13 @@ export const env = createEnv({
813
865
  client: {
814
866
  // PUBLIC_SITE_URL: ${lib.url},
815
867
  },
816
- runtimeEnv: { ...process.env, ...import.meta.env },
868
+ // process is the server's; a "use client" file importing this for a
869
+ // PUBLIC_ value has only import.meta.env, and Vite fills the PUBLIC_ ones.
870
+ runtimeEnv: { ...processEnv, ...import.meta.env },
817
871
  emptyStringAsUndefined: true,
818
872
  // A build machine without the production variables: SKIP_ENV_VALIDATION=1
819
873
  // builds anyway, and the server that runs the build validates at startup.
820
- skipValidation: !!process.env.SKIP_ENV_VALIDATION,
874
+ skipValidation: !!processEnv.SKIP_ENV_VALIDATION,
821
875
  })
822
876
  `;
823
877
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-rsc-kit",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "description": "Scaffold an RSC app on Bun, Hono, Elysia or Node.",
6
6
  "bin": {