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