statikapi 0.1.4 → 0.2.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/package.json +4 -2
- package/src/commands/dev.js +192 -13
- package/src/commands/preview.js +4 -323
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "statikapi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/zonayedpca/statikapi",
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"keywords": ["static", "json", "api", "cli", "ssg"],
|
|
18
18
|
"publishConfig": { "access": "public", "provenance": true },
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"chokidar": "^3.6.0"
|
|
20
|
+
"chokidar": "^3.6.0",
|
|
21
|
+
"sirv": "^2.0.4",
|
|
22
|
+
"polka": "^0.5.2"
|
|
21
23
|
},
|
|
22
24
|
"scripts": {
|
|
23
25
|
"build": "node ./scripts/build.js || true",
|
package/src/commands/dev.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import chokidar from 'chokidar';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
|
+
import fss from 'node:fs'; // NEW: for createReadStream
|
|
4
5
|
import crypto from 'node:crypto';
|
|
6
|
+
import http from 'node:http'; // NEW: tiny HTTP server
|
|
7
|
+
import { fileURLToPath } from 'node:url'; // NEW: resolve UI dist
|
|
5
8
|
|
|
6
9
|
import { loadConfig } from '../config/loadConfig.js';
|
|
7
10
|
import { loadModuleValue } from '../loader/loadModuleValue.js';
|
|
@@ -11,6 +14,12 @@ import { readFlags } from '../util/readFlags.js';
|
|
|
11
14
|
import { writeFileEnsured } from '../util/fsx.js';
|
|
12
15
|
import { routeToOutPath } from '../build/routeOutPath.js';
|
|
13
16
|
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
function hasIndex(dir) {
|
|
20
|
+
return fss.existsSync(path.join(dir, 'index.html'));
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
function clearScreen() {
|
|
15
24
|
process.stdout.write('\x1Bc'); // ANSI "clear screen"
|
|
16
25
|
}
|
|
@@ -57,20 +66,28 @@ export default async function devCmd(argv) {
|
|
|
57
66
|
const { config } = await loadConfig({ flags });
|
|
58
67
|
|
|
59
68
|
// Where to notify preview
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
// NEW: dev server + UI defaults
|
|
70
|
+
const host = String(flags.host ?? '127.0.0.1');
|
|
71
|
+
const port = Number.isFinite(flags.port) ? Number(flags.port) : 8788;
|
|
72
|
+
const noUi = !!(flags['no-ui'] || flags.noUi);
|
|
73
|
+
const noOpen = !!(flags['no-open'] || flags.noOpen);
|
|
74
|
+
|
|
75
|
+
// NEW: live SSE clients
|
|
76
|
+
const sseClients = new Set(); // each entry: { id, res }
|
|
77
|
+
function sseBroadcast(msg) {
|
|
78
|
+
const line = `data: ${msg}\n\n`;
|
|
79
|
+
for (const c of sseClients) {
|
|
80
|
+
try {
|
|
81
|
+
c.res.write(line);
|
|
82
|
+
} catch {
|
|
83
|
+
/* ignore */
|
|
84
|
+
}
|
|
72
85
|
}
|
|
73
86
|
}
|
|
87
|
+
async function notifyChanged(route) {
|
|
88
|
+
// Push to connected UIs
|
|
89
|
+
sseBroadcast(`changed:${route}`);
|
|
90
|
+
}
|
|
74
91
|
|
|
75
92
|
// Cache of outputs per source file (for deletions on subsequent rebuilds)
|
|
76
93
|
const lastEmitted = new Map(); // fileAbs -> Set<concreteRoute>
|
|
@@ -252,6 +269,116 @@ export default async function devCmd(argv) {
|
|
|
252
269
|
await writeManifest();
|
|
253
270
|
console.log(`[statikapi] ready. Watching ${path.relative(process.cwd(), config.paths.srcAbs)}/`);
|
|
254
271
|
|
|
272
|
+
// NEW: start HTTP server (UI + JSON helpers + SSE)
|
|
273
|
+
const server = http.createServer(async (req, res) => {
|
|
274
|
+
try {
|
|
275
|
+
let url;
|
|
276
|
+
try {
|
|
277
|
+
url = new URL(req.url || '/', `http://${host}:${port}`);
|
|
278
|
+
} catch {
|
|
279
|
+
// Extremely defensive fallback
|
|
280
|
+
url = new URL('/', `http://${host}:${port}`);
|
|
281
|
+
}
|
|
282
|
+
const pathname = url.pathname;
|
|
283
|
+
|
|
284
|
+
// 1) SSE: /_ui/events
|
|
285
|
+
if (pathname === '/_ui/events') {
|
|
286
|
+
res.writeHead(200, {
|
|
287
|
+
'Content-Type': 'text/event-stream',
|
|
288
|
+
'Cache-Control': 'no-cache',
|
|
289
|
+
Connection: 'keep-alive',
|
|
290
|
+
'X-Accel-Buffering': 'no', // for proxies
|
|
291
|
+
});
|
|
292
|
+
res.write('\n');
|
|
293
|
+
const client = { id: Date.now() + Math.random(), res };
|
|
294
|
+
sseClients.add(client);
|
|
295
|
+
req.on('close', () => sseClients.delete(client));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 2) Manifest JSON for UI: /ui/index
|
|
300
|
+
if (pathname === '/ui/index' && req.method === 'GET') {
|
|
301
|
+
const list = Array.from(manifestByRoute.values()).sort((a, b) =>
|
|
302
|
+
a.route.localeCompare(b.route)
|
|
303
|
+
);
|
|
304
|
+
const body = JSON.stringify(list);
|
|
305
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
306
|
+
res.end(body);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 3) Serve built file content: /_ui/file?route=/path
|
|
311
|
+
if (pathname === '/_ui/file' && req.method === 'GET') {
|
|
312
|
+
const route = url.searchParams.get('route') || '';
|
|
313
|
+
const outFile = routeToOutPath({ outAbs: config.paths.outAbs, route });
|
|
314
|
+
// best-effort headers
|
|
315
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
316
|
+
try {
|
|
317
|
+
const rs = fss.createReadStream(outFile);
|
|
318
|
+
rs.on('error', () => {
|
|
319
|
+
res.statusCode = 404;
|
|
320
|
+
res.end(`Not found: ${route}`);
|
|
321
|
+
});
|
|
322
|
+
rs.pipe(res);
|
|
323
|
+
} catch {
|
|
324
|
+
res.statusCode = 404;
|
|
325
|
+
res.end(`Not found: ${route}`);
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 4) Static React UI at /_ui/* (unless --no-ui)
|
|
331
|
+
if (!noUi && pathname.startsWith('/_ui/')) {
|
|
332
|
+
const uiRoot = resolveUiDist();
|
|
333
|
+
const rel = pathname.replace(/^\/_ui\//, '') || 'index.html';
|
|
334
|
+
const file = path.join(uiRoot, rel);
|
|
335
|
+
if (!file.startsWith(uiRoot)) {
|
|
336
|
+
res.statusCode = 403;
|
|
337
|
+
res.end('Forbidden');
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const stat = await fs.stat(file);
|
|
342
|
+
if (stat.isDirectory()) {
|
|
343
|
+
// try index.html inside subdir
|
|
344
|
+
const idx = path.join(file, 'index.html');
|
|
345
|
+
await fs.access(idx);
|
|
346
|
+
streamFile(idx, res);
|
|
347
|
+
} else {
|
|
348
|
+
streamFile(file, res);
|
|
349
|
+
}
|
|
350
|
+
} catch {
|
|
351
|
+
// Fallback to index.html for SPA routes
|
|
352
|
+
const fallback = path.join(uiRoot, 'index.html');
|
|
353
|
+
streamFile(fallback, res);
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 5) Root → redirect to UI (unless --no-ui)
|
|
359
|
+
if (!noUi && pathname === '/') {
|
|
360
|
+
res.writeHead(302, { Location: '/_ui/' });
|
|
361
|
+
res.end();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Otherwise: 404
|
|
366
|
+
res.statusCode = 404;
|
|
367
|
+
res.end('Not Found');
|
|
368
|
+
} catch (e) {
|
|
369
|
+
console.log(e);
|
|
370
|
+
res.statusCode = 500;
|
|
371
|
+
res.end('Internal Server Error');
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
server.listen(port, host, () => {
|
|
376
|
+
console.log(`statikapi dev → serving on http://${host}:${port}${noUi ? '' : '/_ui/'}`);
|
|
377
|
+
if (!noUi && !noOpen) {
|
|
378
|
+
openInBrowser(`http://${host}:${port}/_ui/`).catch(() => {});
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
255
382
|
const watcher = chokidar.watch(config.paths.srcAbs, {
|
|
256
383
|
ignoreInitial: true,
|
|
257
384
|
ignored: (p) => path.basename(p).startsWith('_'),
|
|
@@ -263,10 +390,62 @@ export default async function devCmd(argv) {
|
|
|
263
390
|
|
|
264
391
|
// Keep process alive until SIGINT
|
|
265
392
|
await new Promise((resolve) => {
|
|
266
|
-
const stop = () =>
|
|
393
|
+
const stop = () =>
|
|
394
|
+
Promise.allSettled([watcher.close(), new Promise((r) => server.close(() => r()))]).then(() =>
|
|
395
|
+
resolve()
|
|
396
|
+
);
|
|
267
397
|
process.on('SIGINT', stop);
|
|
268
398
|
process.on('SIGTERM', stop);
|
|
269
399
|
});
|
|
270
400
|
|
|
271
401
|
return 0;
|
|
272
402
|
}
|
|
403
|
+
|
|
404
|
+
// NEW: helpers (static file & UI dist resolver & opener)
|
|
405
|
+
function streamFile(file, res) {
|
|
406
|
+
const ext = path.extname(file).toLowerCase();
|
|
407
|
+
const ctype =
|
|
408
|
+
ext === '.html'
|
|
409
|
+
? 'text/html; charset=utf-8'
|
|
410
|
+
: ext === '.js'
|
|
411
|
+
? 'text/javascript; charset=utf-8'
|
|
412
|
+
: ext === '.css'
|
|
413
|
+
? 'text/css; charset=utf-8'
|
|
414
|
+
: ext === '.json'
|
|
415
|
+
? 'application/json; charset=utf-8'
|
|
416
|
+
: ext === '.svg'
|
|
417
|
+
? 'image/svg+xml'
|
|
418
|
+
: ext === '.map'
|
|
419
|
+
? 'application/json; charset=utf-8'
|
|
420
|
+
: 'application/octet-stream';
|
|
421
|
+
res.setHeader('Content-Type', ctype);
|
|
422
|
+
fss.createReadStream(file).pipe(res);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function resolveUiDist() {
|
|
426
|
+
// 0) Optional override for power users
|
|
427
|
+
const fromEnv = process.env.STATIKAPI_UI_DIR;
|
|
428
|
+
if (fromEnv && hasIndex(fromEnv)) return fromEnv;
|
|
429
|
+
|
|
430
|
+
// 1) Bundled with the CLI: packages/cli/ui/ (your screenshot)
|
|
431
|
+
const bundled = path.resolve(__dirname, '..', '..', 'ui');
|
|
432
|
+
if (hasIndex(bundled)) return bundled;
|
|
433
|
+
|
|
434
|
+
// 2) Monorepo dev fallback: packages/ui/dist
|
|
435
|
+
const monorepoDist = path.resolve(__dirname, '..', '..', '..', 'ui', 'dist');
|
|
436
|
+
if (hasIndex(monorepoDist)) return monorepoDist;
|
|
437
|
+
|
|
438
|
+
// 3) Last resort: throw with a helpful hint
|
|
439
|
+
throw new Error(
|
|
440
|
+
'StatikAPI UI build not found. ' +
|
|
441
|
+
'Either keep a built UI at packages/cli/ui/ (index.html present), ' +
|
|
442
|
+
'or run: pnpm -w --filter @statikapi/ui build'
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function openInBrowser(url) {
|
|
447
|
+
const { exec } = await import('node:child_process');
|
|
448
|
+
const cmd =
|
|
449
|
+
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
450
|
+
exec(`${cmd} "${url}"`);
|
|
451
|
+
}
|
package/src/commands/preview.js
CHANGED
|
@@ -1,326 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import fs from 'node:fs/promises';
|
|
3
|
-
import fss from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import crypto from 'node:crypto';
|
|
6
|
-
import { URL, fileURLToPath } from 'node:url';
|
|
7
|
-
|
|
8
|
-
import { loadConfig } from '../config/loadConfig.js';
|
|
9
|
-
import { readFlags } from '../util/readFlags.js';
|
|
10
|
-
import { routeToOutPath } from '../build/routeOutPath.js';
|
|
1
|
+
import devCmd from './dev.js';
|
|
11
2
|
|
|
12
3
|
export default async function previewCmd(argv) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return 0;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const flags = readFlags(argv || []);
|
|
21
|
-
const host = String(flags.host ?? '127.0.0.1');
|
|
22
|
-
const port = Number.isFinite(flags.port) ? Number(flags.port) : 8788;
|
|
23
|
-
const autoOpen = flags.open === true;
|
|
24
|
-
|
|
25
|
-
const { config } = await loadConfig({ flags });
|
|
26
|
-
|
|
27
|
-
// --- React UI defaults ---
|
|
28
|
-
// Prefer --uiDir; else use embedded UI inside this package; else proxy to Vite dev.
|
|
29
|
-
const here = path.dirname(fileURLToPath(import.meta.url)); // .../packages/cli/src/commands
|
|
30
|
-
const embeddedUi = path.resolve(here, '../../ui'); // .../packages/cli/ui
|
|
31
|
-
const uiDir = flags.uiDir ? path.resolve(String(flags.uiDir)) : embeddedUi;
|
|
32
|
-
const hasUi = uiDir && fss.existsSync(uiDir);
|
|
33
|
-
|
|
34
|
-
const uiDevHost = String(flags.uiDevHost ?? '127.0.0.1');
|
|
35
|
-
const uiDevPort = Number.isFinite(flags.uiDevPort) ? Number(flags.uiDevPort) : 5173;
|
|
36
|
-
|
|
37
|
-
const MIME = {
|
|
38
|
-
'.html': 'text/html; charset=utf-8',
|
|
39
|
-
'.js': 'application/javascript; charset=utf-8',
|
|
40
|
-
'.css': 'text/css; charset=utf-8',
|
|
41
|
-
'.json': 'application/json; charset=utf-8',
|
|
42
|
-
'.svg': 'image/svg+xml',
|
|
43
|
-
'.png': 'image/png',
|
|
44
|
-
'.jpg': 'image/jpeg',
|
|
45
|
-
'.jpeg': 'image/jpeg',
|
|
46
|
-
'.ico': 'image/x-icon',
|
|
47
|
-
'.map': 'application/json',
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const outDir = config.paths.outAbs;
|
|
51
|
-
const manifestPath = path.join(outDir, '.statikapi', 'manifest.json');
|
|
52
|
-
|
|
53
|
-
const send = (res, code, body, headers = {}) => {
|
|
54
|
-
const h = {
|
|
55
|
-
'Cache-Control': 'no-store',
|
|
56
|
-
...headers,
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
res.writeHead(code, h);
|
|
60
|
-
|
|
61
|
-
if (body && (typeof body === 'string' || Buffer.isBuffer(body))) res.end(body);
|
|
62
|
-
else res.end();
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const notFound = (res, msg = 'Not found') =>
|
|
66
|
-
send(res, 404, JSON.stringify({ error: msg }) + '\n', {
|
|
67
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
const badReq = (res, msg) =>
|
|
71
|
-
send(res, 400, JSON.stringify({ error: msg }) + '\n', {
|
|
72
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
const etag = (buf) => `"sha1-${crypto.createHash('sha1').update(buf).digest('hex')}"`;
|
|
76
|
-
|
|
77
|
-
async function readManifest() {
|
|
78
|
-
try {
|
|
79
|
-
const raw = await fs.readFile(manifestPath);
|
|
80
|
-
return raw;
|
|
81
|
-
} catch {
|
|
82
|
-
return Buffer.from('[]', 'utf8');
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// --- SSE: subscribers/broadcast ---
|
|
87
|
-
const clients = new Set(); // Set<http.ServerResponse>
|
|
88
|
-
|
|
89
|
-
function sseSend(res, data) {
|
|
90
|
-
// default "message" event with one data line
|
|
91
|
-
res.write(`data: ${data}\n\n`);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function broadcast(data) {
|
|
95
|
-
for (const res of clients) {
|
|
96
|
-
try {
|
|
97
|
-
sseSend(res, data);
|
|
98
|
-
} catch {
|
|
99
|
-
/* ignore */
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Simple proxy to Vite dev server (only used if no built UI is found)
|
|
105
|
-
async function proxyUi(req, res, uiPathname) {
|
|
106
|
-
const httpMod = uiDevHost.startsWith('https')
|
|
107
|
-
? await import('node:https')
|
|
108
|
-
: await import('node:http');
|
|
109
|
-
const client = uiDevHost.startsWith('https') ? httpMod.default : httpMod.default;
|
|
110
|
-
const targetPath =
|
|
111
|
-
uiPathname + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
|
|
112
|
-
const opts = {
|
|
113
|
-
hostname: uiDevHost,
|
|
114
|
-
port: uiDevPort,
|
|
115
|
-
method: req.method || 'GET',
|
|
116
|
-
path: targetPath,
|
|
117
|
-
headers: req.headers,
|
|
118
|
-
};
|
|
119
|
-
const p = client.request(opts, (up) => {
|
|
120
|
-
const headers = { ...up.headers };
|
|
121
|
-
// Always no-store for UI assets
|
|
122
|
-
headers['cache-control'] = 'no-store';
|
|
123
|
-
res.writeHead(up.statusCode || 502, headers);
|
|
124
|
-
up.pipe(res);
|
|
125
|
-
});
|
|
126
|
-
p.on('error', () => {
|
|
127
|
-
const msg = `StatikAPI UI dev server not found at http://${uiDevHost}:${uiDevPort}. Start it with: pnpm -w --filter packages/ui dev`;
|
|
128
|
-
res.writeHead(502, {
|
|
129
|
-
'Content-Type': 'text/plain; charset=utf-8',
|
|
130
|
-
'Cache-Control': 'no-store',
|
|
131
|
-
});
|
|
132
|
-
res.end(msg);
|
|
133
|
-
});
|
|
134
|
-
if (req.readable) req.pipe(p);
|
|
135
|
-
else p.end();
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async function tryServeFrom(rootDir, reqPath, { spaFallback = null } = {}) {
|
|
139
|
-
const target = path.normalize(path.join(rootDir, reqPath.replace(/^\/+/, '')));
|
|
140
|
-
if (!target.startsWith(rootDir)) return null; // path traversal guard
|
|
141
|
-
try {
|
|
142
|
-
const st = await fs.stat(target);
|
|
143
|
-
if (st.isDirectory()) {
|
|
144
|
-
const idx = path.join(target, 'index.html');
|
|
145
|
-
const buf = await fs.readFile(idx);
|
|
146
|
-
return { buf, ctype: MIME['.html'] };
|
|
147
|
-
}
|
|
148
|
-
const buf = await fs.readFile(target);
|
|
149
|
-
const ext = path.extname(target).toLowerCase();
|
|
150
|
-
return { buf, ctype: MIME[ext] || 'application/octet-stream' };
|
|
151
|
-
} catch {
|
|
152
|
-
if (spaFallback) {
|
|
153
|
-
try {
|
|
154
|
-
const fallback = path.join(rootDir, spaFallback);
|
|
155
|
-
const buf = await fs.readFile(fallback);
|
|
156
|
-
return { buf, ctype: MIME['.html'] };
|
|
157
|
-
} catch {
|
|
158
|
-
/* ignore */
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const server = http.createServer(async (req, res) => {
|
|
166
|
-
const base = `http://${host}:${port}`;
|
|
167
|
-
let url;
|
|
168
|
-
try {
|
|
169
|
-
url = new URL(req.url || '/', base);
|
|
170
|
-
} catch {
|
|
171
|
-
return notFound(res, 'Invalid URL');
|
|
172
|
-
}
|
|
173
|
-
const pathname = url.pathname;
|
|
174
|
-
|
|
175
|
-
// --- SSE subscription ---
|
|
176
|
-
if (pathname === '/_ui/events') {
|
|
177
|
-
res.writeHead(200, {
|
|
178
|
-
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
179
|
-
'Cache-Control': 'no-store',
|
|
180
|
-
Connection: 'keep-alive',
|
|
181
|
-
});
|
|
182
|
-
res.write(': connected\n\n'); // comment line
|
|
183
|
-
clients.add(res);
|
|
184
|
-
|
|
185
|
-
// keepalive pings
|
|
186
|
-
const ping = setInterval(() => {
|
|
187
|
-
try {
|
|
188
|
-
res.write(': ping\n\n');
|
|
189
|
-
} catch {
|
|
190
|
-
// ignore write errors
|
|
191
|
-
}
|
|
192
|
-
}, 30000);
|
|
193
|
-
|
|
194
|
-
req.on('close', () => {
|
|
195
|
-
clearInterval(ping);
|
|
196
|
-
clients.delete(res);
|
|
197
|
-
});
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Internal notify hook (used by dev watcher)
|
|
202
|
-
if (pathname === '/_ui/changed') {
|
|
203
|
-
const route = url.searchParams.get('route') || '';
|
|
204
|
-
broadcast(`changed:${route}`);
|
|
205
|
-
return send(res, 204, '');
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// UI root always React: serve built dist if present; otherwise proxy to Vite dev
|
|
209
|
-
if (pathname === '/_ui' || pathname === '/ui' || pathname === '/ui/') {
|
|
210
|
-
if (hasUi) {
|
|
211
|
-
const served = await tryServeFrom(uiDir, 'index.html', { spaFallback: null });
|
|
212
|
-
if (served) {
|
|
213
|
-
return send(res, 200, served.buf, {
|
|
214
|
-
'Content-Type': served.ctype,
|
|
215
|
-
'Cache-Control': 'no-store',
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
return proxyUi(req, res, '/_ui/');
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Helper: manifest passthrough
|
|
223
|
-
if (pathname === '/_ui/index' || pathname === '/ui/index') {
|
|
224
|
-
const raw = await readManifest();
|
|
225
|
-
const tag = etag(raw);
|
|
226
|
-
if (req.headers['if-none-match'] === tag) {
|
|
227
|
-
res.writeHead(304, { ETag: tag, 'Cache-Control': 'no-store' });
|
|
228
|
-
return res.end();
|
|
229
|
-
}
|
|
230
|
-
return send(res, 200, raw, {
|
|
231
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
232
|
-
ETag: tag,
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Helper: stream a built JSON by route
|
|
237
|
-
if (pathname === '/_ui/file' || pathname === '/ui/file') {
|
|
238
|
-
const route = url.searchParams.get('route');
|
|
239
|
-
if (!route || !route.startsWith('/')) {
|
|
240
|
-
return badReq(res, 'query parameter "route" is required and must start with "/"');
|
|
241
|
-
}
|
|
242
|
-
const fileAbs = routeToOutPath({ outAbs: outDir, route });
|
|
243
|
-
if (!fss.existsSync(fileAbs)) return notFound(res, `No file for route: ${route}`);
|
|
244
|
-
res.writeHead(200, {
|
|
245
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
246
|
-
'X-StatikAPI-Route': route,
|
|
247
|
-
'X-StatikAPI-File': path.relative(process.cwd(), fileAbs).replaceAll(path.sep, '/'),
|
|
248
|
-
'Cache-Control': 'no-store',
|
|
249
|
-
});
|
|
250
|
-
fss.createReadStream(fileAbs).pipe(res);
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Serve UI assets: built if present, else proxy to Vite dev
|
|
255
|
-
if (pathname.startsWith('/_ui') || pathname.startsWith('/ui')) {
|
|
256
|
-
if (hasUi) {
|
|
257
|
-
const rel = pathname.replace(/^\/_?ui\/?/, '');
|
|
258
|
-
const reqPath = rel === '' ? 'index.html' : rel;
|
|
259
|
-
const served = await tryServeFrom(uiDir, reqPath, { spaFallback: 'index.html' });
|
|
260
|
-
if (served) {
|
|
261
|
-
return send(res, 200, served.buf, {
|
|
262
|
-
'Content-Type': served.ctype,
|
|
263
|
-
'Cache-Control': 'no-store',
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
return notFound(res);
|
|
267
|
-
}
|
|
268
|
-
return proxyUi(req, res, pathname);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Static serve from api-out (best-effort)
|
|
272
|
-
const safe = path.normalize(path.join(outDir, pathname));
|
|
273
|
-
if (!safe.startsWith(outDir)) return notFound(res);
|
|
274
|
-
try {
|
|
275
|
-
const stat = await fs.stat(safe);
|
|
276
|
-
if (stat.isDirectory()) {
|
|
277
|
-
const idx = path.join(safe, 'index.json');
|
|
278
|
-
const s2 = await fs.readFile(idx);
|
|
279
|
-
return send(res, 200, s2, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
280
|
-
} else {
|
|
281
|
-
const buf = await fs.readFile(safe);
|
|
282
|
-
const ctype = safe.endsWith('.json')
|
|
283
|
-
? 'application/json; charset=utf-8'
|
|
284
|
-
: 'text/plain; charset=utf-8';
|
|
285
|
-
return send(res, 200, buf, { 'Content-Type': ctype });
|
|
286
|
-
}
|
|
287
|
-
} catch {
|
|
288
|
-
return notFound(res);
|
|
289
|
-
}
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
await new Promise((resolve, reject) => {
|
|
293
|
-
server.once('error', reject);
|
|
294
|
-
server.listen(port, host, resolve);
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
const url = `http://${host}:${port}/_ui`;
|
|
298
|
-
console.log(`statikapi preview → serving ${path.relative(process.cwd(), outDir) || outDir}`);
|
|
299
|
-
console.log(`open ${url}`);
|
|
300
|
-
|
|
301
|
-
if (autoOpen) {
|
|
302
|
-
openBrowser(url).catch(() => {});
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Graceful shutdown
|
|
306
|
-
await new Promise((resolve) => {
|
|
307
|
-
const stop = () => server.close(() => resolve());
|
|
308
|
-
process.on('SIGINT', stop);
|
|
309
|
-
process.on('SIGTERM', stop);
|
|
310
|
-
});
|
|
311
|
-
return 0;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
async function openBrowser(url) {
|
|
315
|
-
const { exec } = await import('node:child_process');
|
|
316
|
-
const plat = process.platform;
|
|
317
|
-
return new Promise((resolve) => {
|
|
318
|
-
const cmd =
|
|
319
|
-
plat === 'darwin'
|
|
320
|
-
? `open "${url}"`
|
|
321
|
-
: plat === 'win32'
|
|
322
|
-
? `start "" "${url}"`
|
|
323
|
-
: `xdg-open "${url}"`;
|
|
324
|
-
exec(cmd, () => resolve());
|
|
325
|
-
});
|
|
4
|
+
console.warn('`statikapi preview` is deprecated. Use `statikapi dev`.');
|
|
5
|
+
// forward to dev in UI mode
|
|
6
|
+
return devCmd(argv.filter((a) => a !== '--open')); // or just dev(argv)
|
|
326
7
|
}
|