@pajh/buldng 0.0.4 → 0.0.6

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.
@@ -0,0 +1,574 @@
1
+ // serve-lib.js
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { exec, spawn } from "node:child_process";
6
+
7
+ export const VALID_FLAGS = {
8
+ "log": { type: "boolean" },
9
+ "dev-errors": { type: "boolean" },
10
+ "no-hot": { type: "boolean" },
11
+ "map": { type: "value" },
12
+ "root": { type: "value" },
13
+ "port": { type: "value" },
14
+ "browser": { type: "value" }
15
+ };
16
+
17
+ // --------------------------------------------------
18
+ // STATE CREATION
19
+ // --------------------------------------------------
20
+
21
+ export function createState({ ROOT, PORT, MAPPINGS, MIME, LOG_ALL, DEV_ERRORS, NO_HOT }) {
22
+ return {
23
+ ROOT,
24
+ PORT,
25
+ MAPPINGS,
26
+ MIME,
27
+ LOG_ALL,
28
+ DEV_ERRORS,
29
+ NO_HOT,
30
+ hotEnabled: false,
31
+ injections: { html: [] },
32
+ running: false,
33
+ sseClients: [],
34
+ watchers: []
35
+ };
36
+ }
37
+
38
+ // backing store for parsed arguments
39
+ export const ARGS = new Map();
40
+
41
+ // --------------------------------------------------
42
+ // LOCAL PATH HELPER
43
+ // --------------------------------------------------
44
+
45
+ export const local = (strings, ...values) => {
46
+ const raw = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "");
47
+ const cleaned = raw.replace(/^\.?\//, "");
48
+ return `${process.cwd()}/${cleaned}`;
49
+ };
50
+
51
+ export function processArgs() {
52
+ const args = process.argv.slice(2);
53
+
54
+ for (let i = 0; i < args.length; i++) {
55
+ const a = args[i];
56
+
57
+ // must start with --
58
+ if (!a.startsWith("--")) {
59
+ console.error(`Unexpected argument: ${a}`);
60
+ process.exit(1);
61
+ }
62
+
63
+ const key = a.slice(2); // trim "--"
64
+ const next = (i < args.length -1) ? args[i+1] : "--dummy";
65
+
66
+ if (next.startsWith("--")) { // flag
67
+ validated_set(key, true);
68
+ } else {
69
+ validated_set(key, next);
70
+ i++; // skip next
71
+ }
72
+ }
73
+ }
74
+
75
+
76
+ // returns the value or undefined
77
+ export function arg(key) {
78
+ return ARGS.get(key);
79
+ }
80
+ function validated_set(key, value) {
81
+
82
+ // --------------------------------------------------
83
+ // internal sanity checks
84
+ // --------------------------------------------------
85
+ if (typeof key !== "string" || key.length === 0) {
86
+ console.error(`internal error: bad argmap key "${key}"`);
87
+ process.exit(1);
88
+ }
89
+
90
+ const isStringValue = (typeof value === "string" && value.length > 0);
91
+ const isTrueValue = (value === true);
92
+
93
+ if (!isStringValue && !isTrueValue) {
94
+ console.error(`internal error: bad argmap value for "${key}": ${value}`);
95
+ process.exit(1);
96
+ }
97
+
98
+ const spec = VALID_FLAGS[key];
99
+ if (!spec) {
100
+ console.error(`[args] Unknown argument: --${key}`);
101
+ process.exit(1);
102
+ }
103
+
104
+ if (spec.type === "boolean") {
105
+ if (!isTrueValue) {
106
+ console.error(`[args] Cannot set a value for boolean flag --${key}`);
107
+ process.exit(1);
108
+ }
109
+ ARGS.set(key, true);
110
+ return;
111
+ }
112
+
113
+ if (spec.type === "value") {
114
+ if (!isStringValue) {
115
+ console.error(`[args] Usage: --${key} <value>`);
116
+ process.exit(1);
117
+ }
118
+ ARGS.set(key, value);
119
+ return;
120
+ }
121
+
122
+ console.error(`internal error: bad VALID_FLAGS entry for "${key}"`);
123
+ process.exit(1);
124
+ }
125
+
126
+ // --------------------------------------------------
127
+ // INJECTION SYSTEM
128
+ // --------------------------------------------------
129
+
130
+ export function registerInject(state, type, marker, content, mode = "pre") {
131
+ if (!["pre", "post", "replace"].includes(mode)) {
132
+ throw new Error(`Unknown injection mode: ${mode}`);
133
+ }
134
+ state.injections[type] ??= [];
135
+ state.injections[type].push({ marker, content, mode });
136
+ }
137
+
138
+ function inject(block, injection) {
139
+ const { marker, content, mode } = injection;
140
+ switch (mode) {
141
+ case "replace": return block.replace(marker, content);
142
+ case "pre": return block.replace(marker, content + marker);
143
+ case "post": return block.replace(marker, marker + content);
144
+ default: throw new Error(`Unknown injection mode: ${mode}`);
145
+ }
146
+ }
147
+
148
+ function applyInjections(data, type, state) {
149
+ const list = state.injections[type];
150
+ if (!list || list.length === 0) return data;
151
+
152
+ let text = data.toString("utf8");
153
+ for (const inj of list) text = inject(text, inj);
154
+ return Buffer.from(text, "utf8");
155
+ }
156
+
157
+ export function sanitisePort(port) {
158
+ const n = Number(port);
159
+
160
+ if (!Number.isInteger(n)) {
161
+ console.error(`[args] port must be an integer: ${port}`);
162
+ process.exit(1);
163
+ }
164
+
165
+ if (n < 1 || n > 65535) {
166
+ console.error(`[args] port must be between 1 and 65535: ${port}`);
167
+ process.exit(1);
168
+ }
169
+
170
+ return n;
171
+ }
172
+
173
+ export function sanitiseRoot(root) {
174
+ // must be a non-empty string
175
+ if (typeof root !== "string" || root.trim() === "") {
176
+ console.error(`[args] root must be a non-empty string`);
177
+ process.exit(1);
178
+ }
179
+
180
+ // resolve relative paths from where the server was launched
181
+ const resolved = path.resolve(root);
182
+
183
+ // check existence
184
+ let stat;
185
+ try {
186
+ stat = fs.statSync(resolved);
187
+ } catch {
188
+ console.error(`[args] root directory does not exist: ${resolved}`);
189
+ process.exit(1);
190
+ }
191
+
192
+ // check it's a directory
193
+ if (!stat.isDirectory()) {
194
+ console.error(`[args] root is not a directory: ${resolved}`);
195
+ process.exit(1);
196
+ }
197
+
198
+ // check user can read it
199
+ try {
200
+ fs.accessSync(resolved, fs.constants.R_OK);
201
+ } catch {
202
+ console.error(`[args] root directory is not readable by current user: ${resolved}`);
203
+ process.exit(1);
204
+ }
205
+
206
+ return resolved;
207
+ }
208
+
209
+
210
+ // --------------------------------------------------
211
+ // MAPPING SYSTEM — unified, minimal, correct
212
+ // --------------------------------------------------
213
+
214
+ function resolveMappedPath(urlPath, MAPPINGS) {
215
+
216
+ if (!MAPPINGS || MAPPINGS.length === 0) return null;
217
+
218
+ let to = null;
219
+
220
+ // 1. FILE MAPPING
221
+ const exact = MAPPINGS.find(m => m.type === "file" && urlPath === m.from);
222
+ if (exact) {
223
+ to = exact.to;
224
+ }
225
+
226
+ // 2. DIRECTORY MAPPING
227
+ if (!to) {
228
+ const dir = MAPPINGS.find(
229
+ m => m.type === "directory" &&
230
+ (urlPath === m.from || urlPath.startsWith(m.from + "/"))
231
+ );
232
+
233
+ if (dir) {
234
+ const relative = urlPath.slice(dir.from.length).replace(/^\/+/, "");
235
+ to = path.join(dir.to, relative);
236
+ }
237
+ }
238
+
239
+ // 3. NOT MAPPED
240
+ if (!to) return null;
241
+
242
+ // 4. SINGLE buldng:// interception point
243
+ if (to.startsWith("buldng://")) {
244
+ const filename = to.slice("buldng://".length);
245
+
246
+ const __filename = fileURLToPath(import.meta.url);
247
+ const __dirname = path.dirname(__filename);
248
+
249
+ return path.join(__dirname, filename);
250
+ }
251
+
252
+ // 5. Normal project-relative mapping
253
+ return path.resolve(process.cwd(), to);
254
+ }
255
+
256
+ // --------------------------------------------------
257
+ // New file resolving system.
258
+ // --------------------------------------------------
259
+ function stripSuffix(url) {
260
+ const match = url.match(/^[^?#]*/);
261
+ const base = match[0];
262
+ const suffix = url.slice(base.length); // includes ? or #
263
+ return { base, suffix };
264
+ }
265
+
266
+ function rawResolve(base, ROOT, MAPPINGS) {
267
+ const mapped = resolveMappedPath(base, MAPPINGS);
268
+
269
+ const abs = mapped
270
+ ? mapped
271
+ : path.join(ROOT, base);
272
+
273
+ if (!fs.existsSync(abs)) {
274
+ return { type: "missing", absPath: abs };
275
+ }
276
+
277
+ const stat = fs.statSync(abs);
278
+
279
+ if (stat.isFile()) return { type: "file", absPath: abs };
280
+ if (stat.isDirectory()) return { type: "dir", absPath: abs };
281
+
282
+ return { type: "missing", absPath: abs };
283
+ }
284
+
285
+ function resolve(url, ROOT, MAPPINGS) {
286
+ const { base, suffix } = stripSuffix(url);
287
+ const raw = rawResolve(base, ROOT, MAPPINGS);
288
+
289
+ // If URL explicitly asks for a directory, but rawResolve found a file,
290
+ // treat it as missing (forced directory → 404).
291
+ if (base.endsWith("/") && raw.type === "file") {
292
+ return {
293
+ type: "missing",
294
+ absPath: raw.absPath,
295
+ resolvedUrl: base + suffix
296
+ };
297
+ }
298
+
299
+ // 1. Exact file always wins
300
+ if (raw.type === "file") {
301
+ return {
302
+ type: "file",
303
+ absPath: raw.absPath,
304
+ resolvedUrl: base + suffix
305
+ };
306
+ }
307
+
308
+ // 2. Missing → 404 immediately
309
+ if (raw.type === "missing") {
310
+ return {
311
+ type: "missing",
312
+ absPath: raw.absPath,
313
+ resolvedUrl: base + suffix
314
+ };
315
+ }
316
+
317
+ // 3. Directory → try index.html
318
+ const indexPath = path.join(raw.absPath, "index.html");
319
+
320
+ if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
321
+ return {
322
+ type: "file",
323
+ absPath: indexPath,
324
+ resolvedUrl: base + "/index.html" + suffix
325
+ };
326
+ }
327
+
328
+ // 4. Directory but no index.html → 404
329
+ return {
330
+ type: "missing",
331
+ absPath: indexPath,
332
+ resolvedUrl: base + suffix
333
+ };
334
+ }
335
+
336
+ // --------------------------------------------------
337
+ // HOT RELOAD SYSTEM
338
+ // --------------------------------------------------
339
+
340
+ function loadManifest(state) {
341
+ const manifestPath = path.join(state.ROOT, "build-inputs.json");
342
+ if (!fs.existsSync(manifestPath)) return null;
343
+
344
+ try {
345
+ const list = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
346
+ return Array.isArray(list) ? list : null;
347
+ } catch {
348
+ return null;
349
+ }
350
+ }
351
+
352
+ function stopWatching(state) {
353
+ for (const w of state.watchers) w.close();
354
+ state.watchers = [];
355
+ }
356
+
357
+ export function stopHotReload(state) {
358
+ state.hotEnabled = false;
359
+ stopWatching(state);
360
+
361
+ for (const response of state.sseClients) {
362
+ if (!response.writableEnded) response.end();
363
+ }
364
+ state.sseClients = [];
365
+ }
366
+
367
+ function startWatching(files, state) {
368
+ stopWatching(state);
369
+
370
+ state.watchers = files.map(f => {
371
+ try {
372
+ return fs.watch(f, () => {
373
+ console.log(`[hot] change detected in ${f}`);
374
+ stopWatching(state);
375
+ rebuild(state);
376
+ });
377
+ } catch {
378
+ return null;
379
+ }
380
+ }).filter(Boolean);
381
+
382
+ return state.watchers.length;
383
+ }
384
+
385
+ function disableHot(state, reason) {
386
+ state.hotEnabled = false;
387
+ stopWatching(state);
388
+ console.warn(`[hot] disabled: ${reason}`);
389
+ }
390
+
391
+ function rebuild(state) {
392
+ if (!state.hotEnabled) return;
393
+
394
+ console.log("[hot] rebuilding…");
395
+ const child = spawn("npm", ["run", "build:dev"], { stdio: "inherit" });
396
+
397
+ child.on("exit", (code) => {
398
+ if (code !== 0) {
399
+ disableHot(state, `build exited with code ${code}`);
400
+ return;
401
+ }
402
+
403
+ console.log("[hot] rebuild complete");
404
+ sendSSE(state, "reload");
405
+
406
+ const newFiles = loadManifest(state);
407
+ if (!newFiles) {
408
+ disableHot(state, "build-inputs.json missing after rebuild");
409
+ return;
410
+ }
411
+
412
+ const watched = startWatching(newFiles, state);
413
+ if (watched === 0) {
414
+ disableHot(state, "no files watchable");
415
+ return;
416
+ }
417
+
418
+ console.log(`[hot] hot reloading enabled — ${watched} files being monitored`);
419
+ });
420
+ }
421
+
422
+ function sendSSE(state, msg) {
423
+ for (const res of state.sseClients) {
424
+ res.write(`data: ${msg}\n\n`);
425
+ }
426
+ }
427
+
428
+ export function initHotReload(state) {
429
+ if (state.NO_HOT) {
430
+ console.log("[hot] disabled by --no-hot");
431
+ return;
432
+ }
433
+
434
+ const files = loadManifest(state);
435
+ if (!files) {
436
+ console.log("[hot] no manifest found — hot reloading disabled");
437
+ return;
438
+ }
439
+
440
+ const watched = startWatching(files, state);
441
+ if (watched === 0) {
442
+ console.log("[hot] manifest has no watchable files — hot reloading disabled");
443
+ return;
444
+ }
445
+
446
+ state.hotEnabled = true;
447
+ console.log(`[hot] hot reloading enabled — ${watched} files being monitored`);
448
+ }
449
+
450
+ // --------------------------------------------------
451
+ // DEV ERRORS
452
+ // --------------------------------------------------
453
+
454
+ export function initDevErrors(state) {
455
+ if (!state.DEV_ERRORS) {
456
+ console.log("[dev-errors] off");
457
+ return;
458
+ }
459
+ const __filename = fileURLToPath(import.meta.url);
460
+ const __dirname = path.dirname(__filename);
461
+
462
+ const devErrorsFile = path.join(__dirname, "dev-errors.js");
463
+ if (fs.existsSync(devErrorsFile)) {
464
+ console.log("[dev-errors] active");
465
+ } else {
466
+ state.DEV_ERRORS = false;
467
+ console.warn("[dev-errors] disabled (requested but missing:", devErrorsFile, ")");
468
+ }
469
+ }
470
+
471
+ // --------------------------------------------------
472
+ // REQUEST HANDLER (called from serve.js)
473
+ // --------------------------------------------------
474
+
475
+ export function handleHttpRequest(urlPath, req, state) {
476
+ // --------------------------------------------------
477
+ // HOT RELOAD (unchanged)
478
+ // --------------------------------------------------
479
+ if (state.hotEnabled && req.url === "/__hot") {
480
+ return {
481
+ status: 200,
482
+ headers: {
483
+ "Content-Type": "text/event-stream",
484
+ "Cache-Control": "no-cache",
485
+ "Connection": "keep-alive"
486
+ },
487
+ body: Buffer.from("\n"),
488
+ send(res) {
489
+ res.writeHead(this.status, this.headers);
490
+ res.write(this.body);
491
+ state.sseClients.push(res);
492
+ req.on("close", () => {
493
+ state.sseClients = state.sseClients.filter(r => r !== res);
494
+ });
495
+ }
496
+ };
497
+ }
498
+
499
+ const { absPath, type, resolvedUrl } = resolve(urlPath, state.ROOT, state.MAPPINGS);
500
+
501
+ // --------------------------------------------------
502
+ // 404 BEFORE FILE READ
503
+ // --------------------------------------------------
504
+ if (type === "missing") {
505
+ if (state.LOG_ALL) {
506
+ console.log(`[serve](404) ${urlPath} -> ${absPath}`);
507
+ }
508
+
509
+ return {
510
+ status: 404,
511
+ headers: {},
512
+ body: Buffer.from("Not found"),
513
+ send(res) {
514
+ res.writeHead(this.status);
515
+ res.end(this.body);
516
+ }
517
+ };
518
+ }
519
+
520
+ // --------------------------------------------------
521
+ // INTERNAL ERROR: resolve() MUST NOT RETURN DIR HERE
522
+ // --------------------------------------------------
523
+ if (type !== "file") {
524
+ console.error("INTERNAL ERROR: resolve() returned non-file type");
525
+ return {
526
+ status: 500,
527
+ headers: {},
528
+ body: Buffer.from("Internal server error"),
529
+ send(res) {
530
+ res.writeHead(this.status);
531
+ res.end(this.body);
532
+ }
533
+ };
534
+ }
535
+
536
+ // --------------------------------------------------
537
+ // FILE READ (ONLY FOR type === file)
538
+ // --------------------------------------------------
539
+ try {
540
+ let data = fs.readFileSync(absPath);
541
+ const ext = path.extname(absPath);
542
+ const mime = state.MIME[ext] || "application/octet-stream";
543
+
544
+ data = applyInjections(data, ext.slice(1), state);
545
+
546
+ if (state.LOG_ALL) {
547
+ console.log(`[serve](200) ${urlPath} -> ${absPath}`);
548
+ }
549
+
550
+ return {
551
+ status: 200,
552
+ headers: { "Content-Type": mime },
553
+ body: data,
554
+ send(res) {
555
+ res.writeHead(this.status, this.headers);
556
+ res.end(this.body);
557
+ }
558
+ };
559
+
560
+ } catch (err) {
561
+ console.error("FILE READ ERROR:", err);
562
+
563
+ return {
564
+ status: 500,
565
+ headers: {},
566
+ body: Buffer.from("Internal server error"),
567
+ send(res) {
568
+ res.writeHead(this.status);
569
+ res.end(this.body);
570
+ }
571
+ };
572
+ }
573
+
574
+ }