statikapi 0.1.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "statikapi",
3
- "version": "0.1.3",
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",
@@ -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
- const previewHost = String(flags.previewHost ?? '127.0.0.1');
61
- const previewPort = Number.isFinite(flags.previewPort) ? Number(flags.previewPort) : 8788;
62
- const notifyOrigin = `http://${previewHost}:${previewPort}`;
63
-
64
- async function notifyChanged(route) {
65
- try {
66
- // Node 18+ has global fetch
67
- const u = `${notifyOrigin}/_ui/changed?route=${encodeURIComponent(route)}`;
68
-
69
- await fetch(u, { method: 'POST' }).catch(() => {});
70
- } catch {
71
- // Ignore if preview isn't running
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 = () => watcher.close().then(resolve).catch(resolve);
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
+ }
@@ -1,326 +1,7 @@
1
- import http from 'node:http';
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
- // Keep old tests green: in non-TTY (node --test), behave like stub and exit.
14
- if (!process.stdout.isTTY) {
15
- console.log('statikapi preview previewing built JSON (stub)');
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
  }