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