create-rsc-kit 0.1.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.
@@ -0,0 +1,405 @@
1
+ // The files a new app gets.
2
+ //
3
+ // Kept as strings rather than a copied directory on purpose: the choices cross
4
+ // each other — the compiler changes vite.config, Tailwind changes it and the
5
+ // layout — and a template directory would need one copy per combination.
6
+ const PORT = 3000;
7
+ export function packageJson(o) {
8
+ const deps = {
9
+ '@rsc-kit/core': o.core,
10
+ react: '^19.2.5',
11
+ 'react-dom': '^19.2.5',
12
+ };
13
+ if (o.host === 'hono')
14
+ deps.hono = '^4.13.5';
15
+ if (o.host === 'elysia')
16
+ deps.elysia = '^1.4.30';
17
+ const dev = {
18
+ '@types/react': '^19.2.18',
19
+ '@types/react-dom': '^19.2.7',
20
+ typescript: '^7.0.2',
21
+ vite: '^8.1.5',
22
+ // Not redundant, though it is also the engine's peer: the generated entry
23
+ // imports '@vitejs/plugin-rsc/rsc' by specifier, so it has to resolve from
24
+ // the app. bun hoists peers and makes that work by accident; npm does not,
25
+ // and the build fails on a specifier nothing in the app depends on.
26
+ '@vitejs/plugin-rsc': '^0.5.34',
27
+ };
28
+ if (o.host !== 'node')
29
+ dev['@types/bun'] = '^1.4.0';
30
+ else
31
+ dev['@types/node'] = '^24.0.0';
32
+ // Always, not only for the compiler: this is also what gives a client
33
+ // component Fast Refresh, so without it every edit to one is a full reload
34
+ // and any state it held is gone.
35
+ dev['@vitejs/plugin-react'] = '^6.0.0';
36
+ if (o.compiler === 'oxc')
37
+ dev['oxc-transform-react'] = 'latest';
38
+ if (o.compiler === 'babel') {
39
+ dev['@rolldown/plugin-babel'] = 'latest';
40
+ dev['babel-plugin-react-compiler'] = 'latest';
41
+ }
42
+ if (o.tailwind) {
43
+ dev['tailwindcss'] = '^4.0.0';
44
+ dev['@tailwindcss/vite'] = '^4.0.0';
45
+ }
46
+ if (o.lint)
47
+ dev['oxlint'] = '^1.81.0';
48
+ const run = o.host === 'node' ? 'node' : 'bun run';
49
+ return (JSON.stringify({
50
+ name: o.name,
51
+ type: 'module',
52
+ private: true,
53
+ 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',
61
+ typecheck: 'tsc --noEmit',
62
+ ...(o.lint
63
+ ? { lint: 'oxlint src --fix', 'lint:check': 'oxlint src --deny-warnings' }
64
+ : {}),
65
+ },
66
+ dependencies: sorted(deps),
67
+ devDependencies: sorted(dev),
68
+ }, null, 2) + '\n');
69
+ }
70
+ const sorted = (o) => Object.fromEntries(Object.entries(o).sort(([a], [b]) => a.localeCompare(b)));
71
+ /** One server per app, so it needs no qualifier. */
72
+ export const serverFile = (_host) => 'server.ts';
73
+ export function viteConfig(o) {
74
+ const imports = ["import { defineConfig } from 'vite'"];
75
+ const plugins = [];
76
+ imports.push(o.compiler === 'babel'
77
+ ? "import react, { reactCompilerPreset } from '@vitejs/plugin-react'"
78
+ : "import react from '@vitejs/plugin-react'");
79
+ if (o.compiler === 'babel')
80
+ imports.push("import babel from '@rolldown/plugin-babel'");
81
+ if (o.tailwind)
82
+ imports.push("import tailwindcss from '@tailwindcss/vite'");
83
+ imports.push("import { rscRoutes } from '@rsc-kit/core/vite'");
84
+ plugins.push(`rscRoutes({
85
+ sourceDir: 'src',
86
+ outDir: 'build',
87
+ assetsDir: 'build/public',
88
+ })`);
89
+ plugins.push(o.compiler === 'oxc' ? 'react({ compiler: true })' : 'react()');
90
+ if (o.compiler === 'babel')
91
+ plugins.push('babel({ presets: [reactCompilerPreset()] })');
92
+ if (o.tailwind)
93
+ plugins.push('tailwindcss()');
94
+ return `${imports.join('\n')}
95
+
96
+ export default defineConfig({
97
+ plugins: [
98
+ ${plugins.join(',\n ')},
99
+ ],
100
+ })
101
+ `;
102
+ }
103
+ export const tsconfig = (o) => JSON.stringify({
104
+ compilerOptions: {
105
+ target: 'ESNext',
106
+ module: 'ESNext',
107
+ moduleResolution: 'bundler',
108
+ jsx: 'react-jsx',
109
+ strict: true,
110
+ noEmit: true,
111
+ skipLibCheck: true,
112
+ resolveJsonModule: true,
113
+ types: o.host === 'node' ? ['node', 'vite/client'] : ['@types/bun', 'vite/client'],
114
+ },
115
+ include: [`${o.sourceDir}/**/*`, serverFile(o.host)],
116
+ }, null, 2) + '\n';
117
+ const HANDLER = `const rsc = createRscHandler({
118
+ engine,
119
+ assets: assetsFrom('./build/public'),
120
+ // Served from disk when a page was frozen at build time; rendered now when
121
+ // it was not.
122
+ prerendered: prerenderedFrom('./build/static'),
123
+ })`;
124
+ const IMPORTS = `import { createRscHandler } from '@rsc-kit/core/host'
125
+ import { assetsFrom, prerenderedFrom } from '@rsc-kit/core/files'
126
+
127
+ // Statically imported, not \`import(variable)\`: a bundler cannot see through a
128
+ // variable, so \`bun build --compile\` would leave the engine out of the binary.
129
+ //
130
+ // No NODE_ENV to set before it. The build bakes the mode it ran in into the
131
+ // bundle, so this server is production because it was built that way — not
132
+ // because whoever started it remembered to say so.
133
+ import * as engine from './build/dist/rsc/index.js'`;
134
+ export function server(host) {
135
+ if (host === 'bun') {
136
+ return `${IMPORTS}
137
+
138
+ ${HANDLER}
139
+
140
+ Bun.serve({
141
+ port: ${PORT},
142
+ // Anything the route manifest does not claim comes back null and is yours.
143
+ fetch: async (request) => (await rsc(request)) ?? new Response('Not found', { status: 404 }),
144
+ })
145
+
146
+ console.log('http://localhost:${PORT}')
147
+ `;
148
+ }
149
+ if (host === 'hono') {
150
+ return `import { Hono } from 'hono'
151
+ ${IMPORTS}
152
+
153
+ ${HANDLER}
154
+
155
+ const app = new Hono()
156
+
157
+ app.get('/health', (c) => c.json({ ok: true }))
158
+ // Last, so the app's own routes win; anything left falls through to the RSC
159
+ // handler, and anything it does not claim is a real 404.
160
+ app.all('*', async (c) => (await rsc(c.req.raw)) ?? c.notFound())
161
+
162
+ export default { port: ${PORT}, fetch: app.fetch }
163
+ `;
164
+ }
165
+ if (host === 'elysia') {
166
+ return `import { Elysia } from 'elysia'
167
+ ${IMPORTS}
168
+
169
+ ${HANDLER}
170
+
171
+ new Elysia()
172
+ .get('/health', () => ({ ok: true }))
173
+ .all('*', async ({ request, status }) => (await rsc(request)) ?? status(404, 'Not found'))
174
+ .listen(${PORT})
175
+
176
+ console.log('http://localhost:${PORT}')
177
+ `;
178
+ }
179
+ return `import { createServer } from 'node:http'
180
+ import { Readable } from 'node:stream'
181
+ ${IMPORTS}
182
+
183
+ ${HANDLER}
184
+
185
+ // Node exits on an unhandled rejection; Bun logs one and carries on. That
186
+ // difference is reachable from outside: a malformed body posted to
187
+ // /_rsc/action fails inside React's Flight decoder, in a promise nobody
188
+ // awaits, so no try/catch here can see it — and on Node the process dies.
189
+ process.on('unhandledRejection', (reason) => {
190
+ console.error('[unhandled rejection]', reason)
191
+ })
192
+
193
+ const server = createServer(async (req, res) => {
194
+ const url = \`http://\${req.headers.host ?? 'localhost'}\${req.url ?? '/'}\`
195
+ const hasBody = req.method !== 'GET' && req.method !== 'HEAD'
196
+
197
+ const request = new Request(url, {
198
+ method: req.method,
199
+ headers: req.headers as Record<string, string>,
200
+ // A server action posts binary. Streaming rather than buffering keeps an
201
+ // upload from being held twice; \`duplex\` is required for a stream body.
202
+ body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined,
203
+ ...(hasBody ? { duplex: 'half' } : {}),
204
+ } as RequestInit)
205
+
206
+ let response: Response
207
+
208
+ try {
209
+ response = (await rsc(request)) ?? new Response('Not found', { status: 404 })
210
+ } catch (error) {
211
+ console.error('[rsc]', error)
212
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
213
+ res.end('Internal Server Error')
214
+
215
+ return
216
+ }
217
+
218
+ res.writeHead(response.status, Object.fromEntries(response.headers))
219
+
220
+ if (!response.body) {
221
+ res.end()
222
+
223
+ return
224
+ }
225
+
226
+ // Piped, not buffered: reading it to a string first would hold the whole
227
+ // page before sending any of it, which is the streaming this exists to do
228
+ // thrown away in the last three lines.
229
+ Readable.fromWeb(response.body as never).pipe(res)
230
+ })
231
+
232
+ server.listen(${PORT}, () => console.log('http://localhost:${PORT}'))
233
+ `;
234
+ }
235
+ export function layout(o) {
236
+ return `${o.tailwind ? "import './styles.css'\n" : ''}import type { ReactNode } from 'react'
237
+
238
+ export const metadata = {
239
+ title: { template: '%s · ${o.name}', default: '${o.name}' },
240
+ }
241
+
242
+ // The root layout owns <html>. Everything below it is a segment the router can
243
+ // replace on its own without re-rendering this.
244
+ export default function RootLayout({ children }: { children: ReactNode }) {
245
+ return (
246
+ <html lang="en">
247
+ <head>
248
+ <meta charSet="utf-8" />
249
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
250
+ </head>
251
+ <body${o.tailwind ? ' className="min-h-screen bg-white text-slate-900"' : ''}>
252
+ <main${o.tailwind ? ' className="mx-auto max-w-2xl p-8"' : ''}>{children}</main>
253
+ </body>
254
+ </html>
255
+ )
256
+ }
257
+ `;
258
+ }
259
+ export function page(o) {
260
+ const h1 = o.tailwind ? ' className="text-3xl font-bold"' : '';
261
+ const p = o.tailwind ? ' className="mt-4 text-slate-600"' : '';
262
+ return `import { Counter } from '../components/Counter'
263
+
264
+ export const metadata = { title: 'Home' }
265
+
266
+ // A server component: async, runs only on the server, ships no JavaScript.
267
+ export default async function HomePage() {
268
+ const now = new Date().toISOString()
269
+
270
+ return (
271
+ <>
272
+ <h1${h1}>${o.name}</h1>
273
+ <p${p}>
274
+ Rendered on the server at {now}. The only JavaScript on this page is the
275
+ counter below.
276
+ </p>
277
+
278
+ <Counter />
279
+ </>
280
+ )
281
+ }
282
+ `;
283
+ }
284
+ export function counter(o) {
285
+ const button = o.tailwind
286
+ ? ' className="mt-6 rounded border px-3 py-1 hover:bg-slate-50"'
287
+ : '';
288
+ return `'use client'
289
+
290
+ import { useState } from 'react'
291
+
292
+ // "use client" is the boundary: this component and what it imports are the
293
+ // only things that reach the browser.
294
+ export function Counter() {
295
+ const [count, setCount] = useState(0)
296
+
297
+ return (
298
+ <button${button} onClick={() => setCount(count + 1)}>
299
+ Clicked {count} times
300
+ </button>
301
+ )
302
+ }
303
+ `;
304
+ }
305
+ /**
306
+ * Tailwind needs @source pointing at the RSC tree.
307
+ *
308
+ * Server components never enter the client module graph and Tailwind's
309
+ * detection roots at the Vite root, so without this the utilities layer holds
310
+ * only classes scraped from the generated entries. The build still succeeds
311
+ * and nothing warns — the page just arrives unstyled.
312
+ */
313
+ export const styles = `@import 'tailwindcss';
314
+
315
+ /* The whole source tree, not just this directory. A client component is found
316
+ automatically because it enters the browser bundle; a server component never
317
+ does, so anything it uses has to be declared here or it is silently absent
318
+ from the stylesheet. */
319
+ @source '../';
320
+ `;
321
+ export const gitignore = `node_modules
322
+ build
323
+ .rsc
324
+ dist
325
+ *.log
326
+ .DS_Store
327
+
328
+ # Written by the build into the source dir, every run.
329
+ src/rsc-env.d.ts
330
+ src/rsc-types.d.ts
331
+ src/rsc-routes.d.ts
332
+ src/rsc-engine.d.ts
333
+ `;
334
+ /**
335
+ * oxlint, with the React Compiler's own rules turned on.
336
+ *
337
+ * Those `react/*` rules are the interesting half here: purity, immutability,
338
+ * set-state-in-render. They describe what the compiler needs in order to
339
+ * memoise safely, and they are worth running whether or not the compiler is
340
+ * enabled — a component that breaks them is a component with a bug the
341
+ * compiler would have made louder.
342
+ *
343
+ * `correctness: error` rather than the enumerated list an eslint migration
344
+ * leaves behind: a new project has nothing to grandfather in.
345
+ */
346
+ export function oxlintConfig(o) {
347
+ return (JSON.stringify({
348
+ $schema: './node_modules/oxlint/configuration_schema.json',
349
+ plugins: ['typescript', 'react', 'unicorn'],
350
+ categories: { correctness: 'error' },
351
+ env: { builtin: true, browser: true, node: true },
352
+ rules: {
353
+ 'react/rules-of-hooks': 'error',
354
+ 'react/purity': 'error',
355
+ 'react/set-state-in-render': 'error',
356
+ 'react/set-state-in-effect': 'error',
357
+ 'react/immutability': 'error',
358
+ 'react/preserve-manual-memoization': 'error',
359
+ 'react/error-boundaries': 'error',
360
+ 'react/refs': 'error',
361
+ 'react/globals': 'error',
362
+ 'react/static-components': 'error',
363
+ // Off, not error: the compiler infers dependencies, and the rule
364
+ // reports on code it has already handled.
365
+ 'react/exhaustive-deps': o.compiler === 'none' ? 'error' : 'off',
366
+ 'typescript/no-explicit-any': 'error',
367
+ 'typescript/ban-ts-comment': 'error',
368
+ 'typescript/no-unsafe-function-type': 'error',
369
+ 'no-unused-vars': 'error',
370
+ 'prefer-const': 'error',
371
+ 'no-var': 'error',
372
+ },
373
+ // The build rewrites these on every run, and lints nobody can act on
374
+ // are lints people learn to ignore.
375
+ ignorePatterns: ['build', 'dist', `${o.sourceDir}/rsc-*.d.ts`],
376
+ }, null, 2) + '\n');
377
+ }
378
+ export function readme(o) {
379
+ const pm = o.host === 'node' ? 'npm run' : 'bun run';
380
+ return `# ${o.name}
381
+
382
+ React Server Components on ${o.host === 'node' ? 'node:http' : o.host === 'bun' ? 'Bun.serve' : o.host}.
383
+
384
+ \`\`\`sh
385
+ ${pm} dev # vite — serves from source, no build step
386
+ ${pm} build # bundles, then freezes every page it can
387
+ ${pm} start # serve on http://localhost:${PORT}
388
+ \`\`\`
389
+
390
+ \`${pm} prerender\` re-runs only the freezing part, for when you turned it off
391
+ in \`vite.config.ts\` or want to redo it without rebuilding.
392
+
393
+ ## Where things go
394
+
395
+ src/app/layout.tsx the root layout; owns <html>
396
+ src/app/page.tsx /
397
+ src/app/about/page.tsx /about
398
+ src/components/ client components ("use client")
399
+
400
+ A directory with a \`page.tsx\` is a route. \`[slug]\` is a parameter,
401
+ \`middleware.ts\` runs before anything at or below it renders.
402
+
403
+ Docs: https://github.com/ramonmalcolm/rsc-kit
404
+ `;
405
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "create-rsc-kit",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Scaffold an RSC app on Bun, Hono, Elysia or Node.",
6
+ "bin": {
7
+ "create-rsc-kit": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/index.js",
14
+ "typecheck": "tsc --noEmit"
15
+ },
16
+ "keywords": [
17
+ "react",
18
+ "rsc",
19
+ "server-components",
20
+ "bun",
21
+ "hono",
22
+ "elysia"
23
+ ],
24
+ "license": "MIT",
25
+ "devDependencies": {
26
+ "@types/node": "^24.0.0",
27
+ "typescript": "^7.0.2"
28
+ },
29
+ "exports": {
30
+ "./init": {
31
+ "types": "./dist/init.d.ts",
32
+ "default": "./dist/init.js"
33
+ },
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ }
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/rsc-kit/rsc-kit.git",
42
+ "directory": "packages/create"
43
+ },
44
+ "homepage": "https://rsc-kit.dev"
45
+ }