@pracht/cli 0.0.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/bin/pracht.js +465 -0
- package/package.json +11 -0
package/bin/pracht.js
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createServer as createHttpServer } from "node:http";
|
|
4
|
+
import { resolve, join, dirname, extname } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
statSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
createReadStream,
|
|
11
|
+
readFileSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
cpSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { createServer, build as viteBuild } from "vite";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_SECURITY_HEADERS = {
|
|
18
|
+
"permissions-policy":
|
|
19
|
+
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()",
|
|
20
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
21
|
+
"x-content-type-options": "nosniff",
|
|
22
|
+
"x-frame-options": "SAMEORIGIN",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const VERSION = "0.0.0";
|
|
26
|
+
const command = process.argv[2];
|
|
27
|
+
|
|
28
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
29
|
+
printHelp();
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (command === "--version" || command === "-v") {
|
|
34
|
+
console.log(VERSION);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const handlers = { dev, build, preview };
|
|
39
|
+
|
|
40
|
+
if (!(command in handlers)) {
|
|
41
|
+
console.error(`Unknown pracht command: ${command}`);
|
|
42
|
+
printHelp();
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
handlers[command]().catch((err) => {
|
|
47
|
+
console.error(err);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Commands
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
async function dev() {
|
|
56
|
+
const port = parseInt(process.env.PORT || process.argv[3] || "3000", 10);
|
|
57
|
+
|
|
58
|
+
const server = await createServer({
|
|
59
|
+
root: process.cwd(),
|
|
60
|
+
server: { port },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await server.listen();
|
|
64
|
+
server.printUrls();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function build() {
|
|
68
|
+
// 1. Client build
|
|
69
|
+
console.log("\n Building client...\n");
|
|
70
|
+
await viteBuild({
|
|
71
|
+
root: process.cwd(),
|
|
72
|
+
build: {
|
|
73
|
+
outDir: "dist/client",
|
|
74
|
+
manifest: true,
|
|
75
|
+
rollupOptions: {
|
|
76
|
+
input: "virtual:pracht/client",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// 2. SSR build
|
|
82
|
+
console.log("\n Building server...\n");
|
|
83
|
+
await viteBuild({
|
|
84
|
+
root: process.cwd(),
|
|
85
|
+
build: {
|
|
86
|
+
ssr: "virtual:pracht/server",
|
|
87
|
+
outDir: "dist/server",
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// 3. SSG prerendering
|
|
92
|
+
const root = process.cwd();
|
|
93
|
+
const serverEntry = resolve(root, "dist/server/server.js");
|
|
94
|
+
const clientDir = resolve(root, "dist/client");
|
|
95
|
+
|
|
96
|
+
if (existsSync(serverEntry)) {
|
|
97
|
+
const serverMod = await import(serverEntry);
|
|
98
|
+
const { prerenderApp } = await import("@pracht/core");
|
|
99
|
+
|
|
100
|
+
// Read the Vite manifest for asset URLs
|
|
101
|
+
const manifestPath = resolve(clientDir, ".vite/manifest.json");
|
|
102
|
+
const viteManifest = existsSync(manifestPath)
|
|
103
|
+
? JSON.parse(readFileSync(manifestPath, "utf-8"))
|
|
104
|
+
: {};
|
|
105
|
+
|
|
106
|
+
const clientEntry = viteManifest["virtual:pracht/client"];
|
|
107
|
+
const clientEntryUrl = clientEntry ? `/${clientEntry.file}` : undefined;
|
|
108
|
+
|
|
109
|
+
// Build per-source-file CSS manifest by walking static imports transitively.
|
|
110
|
+
// This ensures each page gets only the CSS it actually needs.
|
|
111
|
+
function collectTransitiveCss(key) {
|
|
112
|
+
const css = new Set();
|
|
113
|
+
const visited = new Set();
|
|
114
|
+
function collect(k) {
|
|
115
|
+
if (visited.has(k)) return;
|
|
116
|
+
visited.add(k);
|
|
117
|
+
const entry = viteManifest[k];
|
|
118
|
+
if (!entry) return;
|
|
119
|
+
for (const c of entry.css ?? []) css.add(c);
|
|
120
|
+
for (const imp of entry.imports ?? []) collect(imp);
|
|
121
|
+
}
|
|
122
|
+
collect(key);
|
|
123
|
+
return [...css];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const cssManifest = {};
|
|
127
|
+
for (const [key, entry] of Object.entries(viteManifest)) {
|
|
128
|
+
if (!entry.src) continue;
|
|
129
|
+
const css = collectTransitiveCss(key);
|
|
130
|
+
if (css.length > 0) {
|
|
131
|
+
cssManifest[key] = css.map((f) => `/${f}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { pages, isgManifest } = await prerenderApp({
|
|
136
|
+
app: serverMod.resolvedApp,
|
|
137
|
+
registry: serverMod.registry,
|
|
138
|
+
clientEntryUrl,
|
|
139
|
+
cssManifest,
|
|
140
|
+
withISGManifest: true,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (pages.length > 0) {
|
|
144
|
+
console.log(`\n Prerendering ${pages.length} SSG/ISG route(s)...\n`);
|
|
145
|
+
for (const page of pages) {
|
|
146
|
+
const filePath =
|
|
147
|
+
page.path === "/"
|
|
148
|
+
? join(clientDir, "index.html")
|
|
149
|
+
: join(clientDir, page.path, "index.html");
|
|
150
|
+
|
|
151
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
152
|
+
writeFileSync(filePath, page.html, "utf-8");
|
|
153
|
+
console.log(` ${page.path} → ${filePath.replace(root + "/", "")}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Write ISG manifest for the preview/production server
|
|
158
|
+
if (Object.keys(isgManifest).length > 0) {
|
|
159
|
+
const isgManifestPath = resolve(root, "dist/server/isg-manifest.json");
|
|
160
|
+
writeFileSync(isgManifestPath, JSON.stringify(isgManifest, null, 2), "utf-8");
|
|
161
|
+
console.log(
|
|
162
|
+
`\n ISG manifest → dist/server/isg-manifest.json (${Object.keys(isgManifest).length} route(s))\n`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (serverMod.buildTarget === "cloudflare") {
|
|
167
|
+
console.log(`\n Cloudflare worker → dist/server/server.js\n`);
|
|
168
|
+
console.log(` Deploy with: wrangler deploy\n`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (serverMod.buildTarget === "vercel") {
|
|
172
|
+
const outputPath = writeVercelBuildOutput({
|
|
173
|
+
functionName: serverMod.vercelFunctionName,
|
|
174
|
+
regions: serverMod.vercelRegions,
|
|
175
|
+
root,
|
|
176
|
+
staticRoutes: pages.map((page) => page.path).filter((path) => !(path in isgManifest)),
|
|
177
|
+
isgRoutes: Object.keys(isgManifest),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
console.log(`\n Vercel build output → ${outputPath}\n`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log("\n Build complete.\n");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function preview() {
|
|
188
|
+
const root = process.cwd();
|
|
189
|
+
const clientDir = resolve(root, "dist/client");
|
|
190
|
+
const serverEntry = resolve(root, "dist/server/server.js");
|
|
191
|
+
|
|
192
|
+
if (!existsSync(serverEntry)) {
|
|
193
|
+
console.error("Server build not found at dist/server/. Run `pracht build` first.");
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const serverMod = await import(serverEntry);
|
|
198
|
+
const { handlePrachtRequest } = await import("@pracht/core");
|
|
199
|
+
|
|
200
|
+
// Load ISG manifest if it exists
|
|
201
|
+
const isgManifestPath = resolve(root, "dist/server/isg-manifest.json");
|
|
202
|
+
const isgManifest = existsSync(isgManifestPath)
|
|
203
|
+
? JSON.parse(readFileSync(isgManifestPath, "utf-8"))
|
|
204
|
+
: {};
|
|
205
|
+
|
|
206
|
+
// Read the Vite manifest for asset URLs
|
|
207
|
+
const manifestPath = resolve(clientDir, ".vite/manifest.json");
|
|
208
|
+
const viteManifest = existsSync(manifestPath)
|
|
209
|
+
? JSON.parse(readFileSync(manifestPath, "utf-8"))
|
|
210
|
+
: {};
|
|
211
|
+
|
|
212
|
+
// Find the client entry asset from the manifest
|
|
213
|
+
const clientEntry = viteManifest["virtual:pracht/client"];
|
|
214
|
+
const clientEntryUrl = clientEntry ? `/${clientEntry.file}` : undefined;
|
|
215
|
+
const cssUrls = (clientEntry?.css ?? []).map((f) => `/${f}`);
|
|
216
|
+
|
|
217
|
+
const MIME_TYPES = {
|
|
218
|
+
".html": "text/html",
|
|
219
|
+
".js": "application/javascript",
|
|
220
|
+
".css": "text/css",
|
|
221
|
+
".json": "application/json",
|
|
222
|
+
".png": "image/png",
|
|
223
|
+
".jpg": "image/jpeg",
|
|
224
|
+
".svg": "image/svg+xml",
|
|
225
|
+
".woff": "font/woff",
|
|
226
|
+
".woff2": "font/woff2",
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const port = parseInt(process.argv[3] || "3000", 10);
|
|
230
|
+
|
|
231
|
+
const server = createHttpServer(async (req, res) => {
|
|
232
|
+
const url = req.url ?? "/";
|
|
233
|
+
|
|
234
|
+
const parsedUrl = new URL(url, "http://localhost");
|
|
235
|
+
|
|
236
|
+
// ISG stale-while-revalidate check
|
|
237
|
+
if (req.method === "GET" && parsedUrl.pathname in isgManifest) {
|
|
238
|
+
const entry = isgManifest[parsedUrl.pathname];
|
|
239
|
+
const htmlPath =
|
|
240
|
+
parsedUrl.pathname === "/"
|
|
241
|
+
? join(clientDir, "index.html")
|
|
242
|
+
: join(clientDir, parsedUrl.pathname, "index.html");
|
|
243
|
+
|
|
244
|
+
if (existsSync(htmlPath) && statSync(htmlPath).isFile()) {
|
|
245
|
+
const stat = statSync(htmlPath);
|
|
246
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
247
|
+
const isStale = entry.revalidate.kind === "time" && ageMs > entry.revalidate.seconds * 1000;
|
|
248
|
+
|
|
249
|
+
setDefaultSecurityHeaders(res, {
|
|
250
|
+
"content-type": "text/html; charset=utf-8",
|
|
251
|
+
"x-pracht-isg": isStale ? "stale" : "fresh",
|
|
252
|
+
});
|
|
253
|
+
createReadStream(htmlPath).pipe(res);
|
|
254
|
+
|
|
255
|
+
if (isStale) {
|
|
256
|
+
// Background regeneration
|
|
257
|
+
const regenRequest = new Request(new URL(parsedUrl.pathname, "http://localhost"), {
|
|
258
|
+
method: "GET",
|
|
259
|
+
});
|
|
260
|
+
handlePrachtRequest({
|
|
261
|
+
app: serverMod.resolvedApp,
|
|
262
|
+
registry: serverMod.registry,
|
|
263
|
+
request: regenRequest,
|
|
264
|
+
clientEntryUrl,
|
|
265
|
+
cssUrls,
|
|
266
|
+
})
|
|
267
|
+
.then(async (response) => {
|
|
268
|
+
if (response.status === 200) {
|
|
269
|
+
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
270
|
+
mkdirSync(dirname(htmlPath), { recursive: true });
|
|
271
|
+
writeFileSync(htmlPath, await response.text(), "utf-8");
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
.catch((err) => {
|
|
275
|
+
console.error(`ISG regeneration failed for ${parsedUrl.pathname}:`, err);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Try static file first
|
|
283
|
+
const filePath = resolve(clientDir, "." + url);
|
|
284
|
+
if (!filePath.startsWith(clientDir + "/") && filePath !== clientDir) {
|
|
285
|
+
res.statusCode = 403;
|
|
286
|
+
res.end("Forbidden");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (existsSync(filePath) && statSync(filePath).isFile()) {
|
|
290
|
+
const ext = extname(filePath);
|
|
291
|
+
const headers = {
|
|
292
|
+
"content-type": MIME_TYPES[ext] || "application/octet-stream",
|
|
293
|
+
};
|
|
294
|
+
if (ext === ".html") {
|
|
295
|
+
setDefaultSecurityHeaders(res, headers);
|
|
296
|
+
} else {
|
|
297
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
298
|
+
res.setHeader(key, value);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
createReadStream(filePath).pipe(res);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// SSR fallback
|
|
306
|
+
try {
|
|
307
|
+
const headers = new Headers();
|
|
308
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
309
|
+
if (value === undefined) continue;
|
|
310
|
+
if (Array.isArray(value)) {
|
|
311
|
+
for (const v of value) headers.append(key, v);
|
|
312
|
+
} else {
|
|
313
|
+
headers.set(key, value);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const protocol = req.headers["x-forwarded-proto"] || "http";
|
|
318
|
+
const host = req.headers.host || "localhost";
|
|
319
|
+
const webRequest = new Request(new URL(url, `${protocol}://${host}`), {
|
|
320
|
+
method: req.method,
|
|
321
|
+
headers,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const response = await handlePrachtRequest({
|
|
325
|
+
app: serverMod.resolvedApp,
|
|
326
|
+
registry: serverMod.registry,
|
|
327
|
+
request: webRequest,
|
|
328
|
+
clientEntryUrl,
|
|
329
|
+
cssUrls,
|
|
330
|
+
apiRoutes: serverMod.apiRoutes,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
res.statusCode = response.status;
|
|
334
|
+
response.headers.forEach((value, key) => {
|
|
335
|
+
res.setHeader(key, value);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
if (!response.body) {
|
|
339
|
+
res.end();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
344
|
+
res.end(body);
|
|
345
|
+
} catch (err) {
|
|
346
|
+
console.error("SSR error:", err);
|
|
347
|
+
res.statusCode = 500;
|
|
348
|
+
res.end("Internal Server Error");
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
server.listen(port, () => {
|
|
353
|
+
console.log(`\n pracht preview server running at http://localhost:${port}\n`);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function printHelp() {
|
|
358
|
+
console.log(`pracht ${VERSION}
|
|
359
|
+
|
|
360
|
+
Usage:
|
|
361
|
+
pracht dev Start development server with HMR
|
|
362
|
+
pracht build Production build (client + server)
|
|
363
|
+
pracht preview Preview the production build
|
|
364
|
+
`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function setDefaultSecurityHeaders(res, headers = {}) {
|
|
368
|
+
for (const [key, value] of Object.entries({
|
|
369
|
+
...DEFAULT_SECURITY_HEADERS,
|
|
370
|
+
...headers,
|
|
371
|
+
})) {
|
|
372
|
+
res.setHeader(key, value);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function writeVercelBuildOutput({ functionName, regions, root, staticRoutes, isgRoutes }) {
|
|
377
|
+
const outputDir = join(root, ".vercel/output");
|
|
378
|
+
const staticDir = join(outputDir, "static");
|
|
379
|
+
const functionDir = join(outputDir, "functions", `${functionName || "render"}.func`);
|
|
380
|
+
|
|
381
|
+
rmSync(outputDir, { force: true, recursive: true });
|
|
382
|
+
mkdirSync(outputDir, { recursive: true });
|
|
383
|
+
cpSync(join(root, "dist/client"), staticDir, { recursive: true });
|
|
384
|
+
cpSync(join(root, "dist/server"), functionDir, { recursive: true });
|
|
385
|
+
|
|
386
|
+
writeFileSync(
|
|
387
|
+
join(outputDir, "config.json"),
|
|
388
|
+
`${JSON.stringify(createVercelOutputConfig({ functionName, staticRoutes, isgRoutes }), null, 2)}\n`,
|
|
389
|
+
"utf-8",
|
|
390
|
+
);
|
|
391
|
+
writeFileSync(
|
|
392
|
+
join(functionDir, ".vc-config.json"),
|
|
393
|
+
`${JSON.stringify(createVercelFunctionConfig({ regions }), null, 2)}\n`,
|
|
394
|
+
"utf-8",
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
return ".vercel/output";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function createVercelOutputConfig({ functionName, staticRoutes, isgRoutes }) {
|
|
401
|
+
const target = `/${functionName || "render"}`;
|
|
402
|
+
const routes = [];
|
|
403
|
+
|
|
404
|
+
for (const route of sortStaticRoutes(staticRoutes)) {
|
|
405
|
+
routes.push({
|
|
406
|
+
src: routeToRouteExpression(route),
|
|
407
|
+
dest: routeToStaticHtmlPath(route),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
for (const route of isgRoutes) {
|
|
412
|
+
routes.push({
|
|
413
|
+
src: routeToRouteExpression(route),
|
|
414
|
+
dest: target,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
routes.push({ handle: "filesystem" });
|
|
419
|
+
routes.push({ src: "/(.*)", dest: target });
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
version: 3,
|
|
423
|
+
routes,
|
|
424
|
+
framework: {
|
|
425
|
+
version: VERSION,
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function createVercelFunctionConfig({ regions }) {
|
|
431
|
+
const config = {
|
|
432
|
+
runtime: "edge",
|
|
433
|
+
entrypoint: "server.js",
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
if (regions) {
|
|
437
|
+
config.regions = regions;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return config;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function sortStaticRoutes(routes) {
|
|
444
|
+
return [...new Set(routes)].sort((left, right) => right.length - left.length);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function routeToRouteExpression(route) {
|
|
448
|
+
if (route === "/") {
|
|
449
|
+
return "^/$";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return `^${escapeRegex(route)}/?$`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function routeToStaticHtmlPath(route) {
|
|
456
|
+
if (route === "/") {
|
|
457
|
+
return "/index.html";
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return `${route}/index.html`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function escapeRegex(value) {
|
|
464
|
+
return value.replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&");
|
|
465
|
+
}
|