@studiometa/trafic-agent 0.1.37 → 0.1.39

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.
@@ -294,42 +294,31 @@ function watchProjectList(projectListPath, onChange) {
294
294
  if (eventType === "change") onChange();
295
295
  });
296
296
  }
297
- /**
298
- * Run a ddev command without blocking the event loop.
299
- *
300
- * The agent is single-threaded and serves forward auth for every project, so
301
- * an `execSync` here stops the whole server for the duration of the command.
302
- * `ddev start` takes over a minute, which froze auth for every unrelated
303
- * project and produced request timeouts on a live server. Nothing about
304
- * "background" work with setTimeout helps: the timer callback still runs on
305
- * the same thread.
306
- */
307
- function ddev(args, timeoutMs) {
308
- return new Promise((resolve) => {
309
- execFile("ddev", args, {
310
- encoding: "utf-8",
311
- timeout: timeoutMs
312
- }, (error, stdout) => {
313
- if (error) {
314
- console.error(`ddev ${args.join(" ")} failed:`, error.message);
315
- resolve({
316
- ok: false,
317
- stdout: stdout ?? ""
318
- });
319
- return;
320
- }
297
+ /** The real runner, used unless a caller passes its own. */
298
+ var nodeDdevRunner = (args, timeoutMs) => new Promise((resolve) => {
299
+ execFile("ddev", args, {
300
+ encoding: "utf-8",
301
+ timeout: timeoutMs
302
+ }, (error, stdout) => {
303
+ if (error) {
304
+ console.error(`ddev ${args.join(" ")} failed:`, error.message);
321
305
  resolve({
322
- ok: true,
306
+ ok: false,
323
307
  stdout: stdout ?? ""
324
308
  });
309
+ return;
310
+ }
311
+ resolve({
312
+ ok: true,
313
+ stdout: stdout ?? ""
325
314
  });
326
315
  });
327
- }
316
+ });
328
317
  /**
329
318
  * Get detailed project info using ddev describe
330
319
  */
331
- async function getProjectInfo(name) {
332
- const { ok, stdout } = await ddev([
320
+ async function getProjectInfo(name, run = nodeDdevRunner) {
321
+ const { ok, stdout } = await run([
333
322
  "describe",
334
323
  name,
335
324
  "-j"
@@ -359,15 +348,15 @@ async function getProjectInfo(name) {
359
348
  * Resolves when the project is up. Callers in the request path must not await
360
349
  * it — respond first, then let this settle and record the outcome.
361
350
  */
362
- async function startProject(name) {
363
- const { ok } = await ddev(["start", name], 12e4);
351
+ async function startProject(name, run = nodeDdevRunner) {
352
+ const { ok } = await run(["start", name], 12e4);
364
353
  return ok;
365
354
  }
366
355
  /**
367
356
  * Stop a DDEV project
368
357
  */
369
- async function stopProject(name) {
370
- const { ok } = await ddev(["stop", name], 6e4);
358
+ async function stopProject(name, run = nodeDdevRunner) {
359
+ const { ok } = await run(["stop", name], 6e4);
371
360
  return ok;
372
361
  }
373
362
  /**
@@ -394,7 +383,134 @@ function listProjects() {
394
383
  }
395
384
  }
396
385
  //#endregion
386
+ //#region src/setup/steps.ts
387
+ var stepNumber = 0;
388
+ var dryRun = false;
389
+ /**
390
+ * Set dry-run mode
391
+ */
392
+ function setDryRun(value) {
393
+ dryRun = value;
394
+ }
395
+ /**
396
+ * Reset step counter
397
+ */
398
+ function resetSteps() {
399
+ stepNumber = 0;
400
+ }
401
+ /**
402
+ * Log a step
403
+ */
404
+ function step(message) {
405
+ stepNumber++;
406
+ console.log(`\n\x1b[36m[${stepNumber}]\x1b[0m ${message}`);
407
+ }
408
+ /**
409
+ * Log success
410
+ */
411
+ function success(message) {
412
+ console.log(` \x1b[32m✓\x1b[0m ${message}`);
413
+ }
414
+ /**
415
+ * Log warning
416
+ */
417
+ function warn(message) {
418
+ console.log(` \x1b[33m⚠\x1b[0m ${message}`);
419
+ }
420
+ /**
421
+ * Log error
422
+ */
423
+ function error(message) {
424
+ console.log(` \x1b[31m✗\x1b[0m ${message}`);
425
+ }
426
+ /**
427
+ * Log info
428
+ */
429
+ function info(message) {
430
+ console.log(` ${message}`);
431
+ }
432
+ /**
433
+ * Execute a command (or show in dry-run mode)
434
+ */
435
+ function exec(command, options) {
436
+ if (dryRun) {
437
+ if (!options?.silent) console.log(` \x1b[90m$ ${command}\x1b[0m`);
438
+ return "";
439
+ }
440
+ try {
441
+ return execSync(command, {
442
+ encoding: "utf-8",
443
+ stdio: options?.silent ? "pipe" : "inherit"
444
+ });
445
+ } catch (err) {
446
+ throw new Error(`Command failed: ${command}\n${err.stderr ?? ""}`);
447
+ }
448
+ }
449
+ /**
450
+ * Execute a command and return output (silent)
451
+ */
452
+ function execSilent(command) {
453
+ if (dryRun) return "";
454
+ try {
455
+ return execSync(command, {
456
+ encoding: "utf-8",
457
+ stdio: "pipe"
458
+ }).trim();
459
+ } catch {
460
+ return "";
461
+ }
462
+ }
463
+ /**
464
+ * Check if a command exists
465
+ */
466
+ function commandExists(command) {
467
+ try {
468
+ execSync(`which ${command}`, { stdio: "pipe" });
469
+ return true;
470
+ } catch {
471
+ return false;
472
+ }
473
+ }
474
+ /**
475
+ * Check if running as root
476
+ */
477
+ function isRoot() {
478
+ return process.getuid?.() === 0;
479
+ }
480
+ /**
481
+ * Write a file (or show in dry-run mode)
482
+ */
483
+ function writeFile(path, content) {
484
+ if (dryRun) {
485
+ console.log(` \x1b[90mWrite ${path}:\x1b[0m`);
486
+ const lines = content.split("\n").slice(0, 5);
487
+ for (const line of lines) console.log(` \x1b[90m ${line}\x1b[0m`);
488
+ if (content.split("\n").length > 5) console.log(` \x1b[90m ...\x1b[0m`);
489
+ return;
490
+ }
491
+ writeFileSync(path, content);
492
+ }
493
+ //#endregion
494
+ //#region src/setup/io.ts
495
+ /**
496
+ * The real implementation, used when no io is injected.
497
+ *
498
+ * `writeFile` comes from steps.js rather than `node:fs` so writes are
499
+ * skipped in dry-run mode. Reads stay direct: they change nothing.
500
+ */
501
+ var nodeIo = {
502
+ exec,
503
+ execSilent,
504
+ commandExists,
505
+ writeFile,
506
+ fileExists: existsSync,
507
+ readFile: (path) => readFileSync(path, "utf-8"),
508
+ env: process.env
509
+ };
510
+ //#endregion
397
511
  //#region src/utils/db.ts
512
+ /** The real clock, used unless a caller passes its own. */
513
+ var systemClock = () => Date.now();
398
514
  var db = null;
399
515
  /**
400
516
  * Initialize the database connection and schema
@@ -442,12 +558,12 @@ function closeDb() {
442
558
  /**
443
559
  * Update project last access time
444
560
  */
445
- function updateProjectAccess(name) {
561
+ function updateProjectAccess(name, now = systemClock) {
446
562
  getDb().prepare(`
447
563
  INSERT INTO projects (name, last_access, status)
448
564
  VALUES (?, ?, 'running')
449
565
  ON CONFLICT(name) DO UPDATE SET last_access = excluded.last_access
450
- `).run(name, Date.now());
566
+ `).run(name, now());
451
567
  }
452
568
  /**
453
569
  * Get project record
@@ -458,19 +574,19 @@ function getProject(name) {
458
574
  /**
459
575
  * Set project status
460
576
  */
461
- function setProjectStatus(name, status) {
577
+ function setProjectStatus(name, status, now = systemClock) {
462
578
  getDb().prepare(`
463
579
  INSERT INTO projects (name, last_access, status)
464
580
  VALUES (?, ?, ?)
465
581
  ON CONFLICT(name) DO UPDATE SET status = excluded.status
466
- `).run(name, Date.now(), status);
582
+ `).run(name, now(), status);
467
583
  }
468
584
  /**
469
585
  * Get idle projects (last access older than threshold)
470
586
  */
471
- function getIdleProjects(thresholdMs) {
587
+ function getIdleProjects(thresholdMs, now = systemClock) {
472
588
  const database = getDb();
473
- const cutoff = Date.now() - thresholdMs;
589
+ const cutoff = now() - thresholdMs;
474
590
  return database.prepare(`
475
591
  SELECT name, last_access as lastAccess, status
476
592
  FROM projects
@@ -501,9 +617,9 @@ function getAccessLogs(project, limit = 100) {
501
617
  /**
502
618
  * Clean old access logs (older than given days)
503
619
  */
504
- function cleanOldLogs(days) {
620
+ function cleanOldLogs(days, now = systemClock) {
505
621
  const database = getDb();
506
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1e3;
622
+ const cutoff = now() - days * 24 * 60 * 60 * 1e3;
507
623
  const result = database.prepare("DELETE FROM access_logs WHERE timestamp < ?").run(cutoff);
508
624
  return Number(result.changes);
509
625
  }
@@ -574,131 +690,6 @@ function getIdleTimeoutMs(config) {
574
690
  return ms > 0 ? ms : void 0;
575
691
  }
576
692
  //#endregion
577
- //#region src/setup/steps.ts
578
- var stepNumber = 0;
579
- var dryRun = false;
580
- /**
581
- * Set dry-run mode
582
- */
583
- function setDryRun(value) {
584
- dryRun = value;
585
- }
586
- /**
587
- * Reset step counter
588
- */
589
- function resetSteps() {
590
- stepNumber = 0;
591
- }
592
- /**
593
- * Log a step
594
- */
595
- function step(message) {
596
- stepNumber++;
597
- console.log(`\n\x1b[36m[${stepNumber}]\x1b[0m ${message}`);
598
- }
599
- /**
600
- * Log success
601
- */
602
- function success(message) {
603
- console.log(` \x1b[32m✓\x1b[0m ${message}`);
604
- }
605
- /**
606
- * Log warning
607
- */
608
- function warn(message) {
609
- console.log(` \x1b[33m⚠\x1b[0m ${message}`);
610
- }
611
- /**
612
- * Log error
613
- */
614
- function error(message) {
615
- console.log(` \x1b[31m✗\x1b[0m ${message}`);
616
- }
617
- /**
618
- * Log info
619
- */
620
- function info(message) {
621
- console.log(` ${message}`);
622
- }
623
- /**
624
- * Execute a command (or show in dry-run mode)
625
- */
626
- function exec(command, options) {
627
- if (dryRun) {
628
- if (!options?.silent) console.log(` \x1b[90m$ ${command}\x1b[0m`);
629
- return "";
630
- }
631
- try {
632
- return execSync(command, {
633
- encoding: "utf-8",
634
- stdio: options?.silent ? "pipe" : "inherit"
635
- });
636
- } catch (err) {
637
- throw new Error(`Command failed: ${command}\n${err.stderr ?? ""}`);
638
- }
639
- }
640
- /**
641
- * Execute a command and return output (silent)
642
- */
643
- function execSilent(command) {
644
- if (dryRun) return "";
645
- try {
646
- return execSync(command, {
647
- encoding: "utf-8",
648
- stdio: "pipe"
649
- }).trim();
650
- } catch {
651
- return "";
652
- }
653
- }
654
- /**
655
- * Check if a command exists
656
- */
657
- function commandExists(command) {
658
- try {
659
- execSync(`which ${command}`, { stdio: "pipe" });
660
- return true;
661
- } catch {
662
- return false;
663
- }
664
- }
665
- /**
666
- * Check if running as root
667
- */
668
- function isRoot() {
669
- return process.getuid?.() === 0;
670
- }
671
- /**
672
- * Write a file (or show in dry-run mode)
673
- */
674
- function writeFile(path, content) {
675
- if (dryRun) {
676
- console.log(` \x1b[90mWrite ${path}:\x1b[0m`);
677
- const lines = content.split("\n").slice(0, 5);
678
- for (const line of lines) console.log(` \x1b[90m ${line}\x1b[0m`);
679
- if (content.split("\n").length > 5) console.log(` \x1b[90m ...\x1b[0m`);
680
- return;
681
- }
682
- writeFileSync(path, content);
683
- }
684
- //#endregion
685
- //#region src/setup/io.ts
686
- /**
687
- * The real implementation, used when no io is injected.
688
- *
689
- * `writeFile` comes from steps.js rather than `node:fs` so writes are
690
- * skipped in dry-run mode. Reads stay direct: they change nothing.
691
- */
692
- var nodeIo = {
693
- exec,
694
- execSilent,
695
- commandExists,
696
- writeFile,
697
- fileExists: existsSync,
698
- readFile: (path) => readFileSync(path, "utf-8"),
699
- env: process.env
700
- };
701
- //#endregion
702
693
  //#region src/setup/ddev.ts
703
694
  /**
704
695
  * Install system dependencies required by Trafic
@@ -1028,12 +1019,12 @@ function loadTemplate(name) {
1028
1019
  /**
1029
1020
  * Get effective auth config for a project (merges global + per-project)
1030
1021
  */
1031
- function getEffectiveAuthConfig(projectName) {
1032
- if (!projectName) return config.auth;
1033
- const projectConfig = projectConfigs.get(projectName);
1034
- if (!projectConfig?.auth_policy) return config.auth;
1022
+ function getEffectiveAuthConfig(projectName, deps) {
1023
+ if (!projectName) return deps.auth;
1024
+ const projectConfig = deps.projectConfigs.get(projectName);
1025
+ if (!projectConfig?.auth_policy) return deps.auth;
1035
1026
  return {
1036
- ...config.auth,
1027
+ ...deps.auth,
1037
1028
  defaultPolicy: projectConfig.auth_policy
1038
1029
  };
1039
1030
  }
@@ -1041,14 +1032,14 @@ function getEffectiveAuthConfig(projectName) {
1041
1032
  * Handle forward auth requests from Traefik
1042
1033
  * Traefik sends the original request headers, we return 200 (allow) or 401 (deny)
1043
1034
  */
1044
- function handleAuth(req, res) {
1035
+ function handleAuth(req, res, deps) {
1045
1036
  const hostname = req.headers["x-forwarded-host"] ?? "";
1046
1037
  const socketIp = req.socket.remoteAddress ?? "";
1047
1038
  const forwardedFor = req.headers["x-forwarded-for"];
1048
1039
  const authorization = req.headers["authorization"];
1049
1040
  const path = req.headers["x-forwarded-uri"] ?? "/";
1050
- const projectName = hostnameIndex.get(hostname);
1051
- const authConfig = getEffectiveAuthConfig(projectName);
1041
+ const projectName = deps.hostnameIndex.get(hostname);
1042
+ const authConfig = getEffectiveAuthConfig(projectName, deps);
1052
1043
  const clientIp = resolveClientIp(socketIp, forwardedFor, authConfig.trustedProxyHops);
1053
1044
  if (checkAuth({
1054
1045
  hostname,
@@ -1057,8 +1048,8 @@ function handleAuth(req, res) {
1057
1048
  forwardedFor
1058
1049
  }, authConfig).allowed) {
1059
1050
  if (projectName) {
1060
- updateProjectAccess(projectName);
1061
- logAccess({
1051
+ deps.updateProjectAccess(projectName);
1052
+ deps.logAccess({
1062
1053
  project: projectName,
1063
1054
  timestamp: Date.now(),
1064
1055
  ip: clientIp,
@@ -1077,17 +1068,17 @@ function handleAuth(req, res) {
1077
1068
  * Handle errors middleware requests (502 from Traefik)
1078
1069
  * When a project is stopped, Traefik returns 502. We show a waiting page and start the project.
1079
1070
  */
1080
- async function handleErrors(req, res) {
1071
+ async function handleErrors(req, res, deps) {
1081
1072
  const hostname = req.headers["x-forwarded-host"] ?? req.headers.host ?? "";
1082
- const projectName = hostnameIndex.get(hostname);
1073
+ const projectName = deps.hostnameIndex.get(hostname);
1083
1074
  if (!projectName) {
1084
- const template = loadTemplate("error");
1075
+ const template = deps.loadTemplate("error");
1085
1076
  res.writeHead(404, { "Content-Type": "text/html" });
1086
1077
  res.end(template.replace("{{message}}", "Project not found"));
1087
1078
  return;
1088
1079
  }
1089
- if (getProject(projectName)?.status === "starting") {
1090
- const template = loadTemplate("wait");
1080
+ if (deps.getProject(projectName)?.status === "starting") {
1081
+ const template = deps.loadTemplate("wait");
1091
1082
  res.writeHead(503, {
1092
1083
  "Content-Type": "text/html",
1093
1084
  "Retry-After": "5"
@@ -1095,32 +1086,32 @@ async function handleErrors(req, res) {
1095
1086
  res.end(template.replace(/\{\{project\}\}/g, projectName).replace(/\{\{hostname\}\}/g, hostname));
1096
1087
  return;
1097
1088
  }
1098
- setProjectStatus(projectName, "starting");
1099
- const template = loadTemplate("wait");
1089
+ deps.setProjectStatus(projectName, "starting");
1090
+ const template = deps.loadTemplate("wait");
1100
1091
  res.writeHead(503, {
1101
1092
  "Content-Type": "text/html",
1102
1093
  "Retry-After": "5"
1103
1094
  });
1104
1095
  res.end(template.replace(/\{\{project\}\}/g, projectName).replace(/\{\{hostname\}\}/g, hostname));
1105
- startProject(projectName).then((success) => {
1106
- setProjectStatus(projectName, success ? "running" : "stopped");
1096
+ deps.startProject(projectName).then((success) => {
1097
+ deps.setProjectStatus(projectName, success ? "running" : "stopped");
1107
1098
  }).catch((error) => {
1108
- setProjectStatus(projectName, "stopped");
1099
+ deps.setProjectStatus(projectName, "stopped");
1109
1100
  console.error(`Could not start ${projectName}:`, error);
1110
1101
  });
1111
1102
  }
1112
1103
  /**
1113
1104
  * Handle status polling requests
1114
1105
  */
1115
- async function handleStatus(req, res) {
1106
+ async function handleStatus(req, res, deps) {
1116
1107
  const projectName = new URL(req.url ?? "/", `http://${req.headers.host}`).searchParams.get("project");
1117
1108
  if (!projectName) {
1118
1109
  res.writeHead(400, { "Content-Type": "application/json" });
1119
1110
  res.end(JSON.stringify({ error: "Missing project parameter" }));
1120
1111
  return;
1121
1112
  }
1122
- const info = await getProjectInfo(projectName);
1123
- const record = getProject(projectName);
1113
+ const info = await deps.getProjectInfo(projectName);
1114
+ const record = deps.getProject(projectName);
1124
1115
  res.writeHead(200, { "Content-Type": "application/json" });
1125
1116
  res.end(JSON.stringify({
1126
1117
  name: projectName,
@@ -1148,18 +1139,18 @@ function routePath(target) {
1148
1139
  /**
1149
1140
  * Request handler
1150
1141
  */
1151
- async function handleRequest(req, res) {
1142
+ async function handleRequest(req, res, deps) {
1152
1143
  const path = routePath(req.url);
1153
1144
  try {
1154
- if (path === "/__auth__" || path.startsWith("/__auth__/")) handleAuth(req, res);
1155
- else if (path === "/__status__" || path.startsWith("/__status__/")) await handleStatus(req, res);
1145
+ if (path === "/__auth__" || path.startsWith("/__auth__/")) handleAuth(req, res, deps);
1146
+ else if (path === "/__status__" || path.startsWith("/__status__/")) await handleStatus(req, res, deps);
1156
1147
  else if (path === "/__health__") {
1157
1148
  res.writeHead(200, { "Content-Type": "application/json" });
1158
1149
  res.end(JSON.stringify({
1159
1150
  status: "ok",
1160
1151
  version: "__VERSION__"
1161
1152
  }));
1162
- } else await handleErrors(req, res);
1153
+ } else await handleErrors(req, res, deps);
1163
1154
  } catch (error) {
1164
1155
  console.error("Request error:", error);
1165
1156
  res.writeHead(500, { "Content-Type": "text/plain" });
@@ -1194,8 +1185,20 @@ function startServer(agentConfig) {
1194
1185
  console.log("Project list changed, reloading...");
1195
1186
  reloadProjects();
1196
1187
  });
1188
+ const deps = () => ({
1189
+ auth: config.auth,
1190
+ hostnameIndex,
1191
+ projectConfigs,
1192
+ loadTemplate,
1193
+ updateProjectAccess,
1194
+ logAccess,
1195
+ getProject,
1196
+ setProjectStatus,
1197
+ startProject,
1198
+ getProjectInfo
1199
+ });
1197
1200
  createServer((req, res) => {
1198
- handleRequest(req, res).catch((error) => {
1201
+ handleRequest(req, res, deps()).catch((error) => {
1199
1202
  console.error("Unhandled error:", error);
1200
1203
  if (!res.headersSent) {
1201
1204
  res.writeHead(500);
@@ -1209,6 +1212,6 @@ function startServer(agentConfig) {
1209
1212
  });
1210
1213
  }
1211
1214
  //#endregion
1212
- export { initDb as A, stopProject as B, shouldNeverStop as C, getDb as D, getAccessLogs as E, getProjectFromHostname as F, parseBasicAuth as G, checkAuth as H, getProjectInfo as I, parseBearerToken as K, listProjects as L, setProjectStatus as M, updateProjectAccess as N, getIdleProjects as O, buildHostnameIndex as P, loadProjectList as R, loadProjectConfig as S, closeDb as T, matchHostname as U, watchProjectList as V, matchIp as W, setDryRun as _, configureTraefik as a, warn as b, installSystemDeps as c, error as d, exec as f, resetSteps as g, isRoot as h, configureDdev as i, logAccess as j, getProject as k, purgeTraefikAcmeStorage as l, info as m, startServer as n, getDockerGatewayIp as o, execSilent as p, addDdevAptRepo as r, installDdev as s, routePath as t, nodeIo as u, step as v, cleanOldLogs as w, getIdleTimeoutMs as x, success as y, startProject as z };
1215
+ export { error as A, buildHostnameIndex as B, getIdleProjects as C, setProjectStatus as D, logAccess as E, resetSteps as F, startProject as G, getProjectInfo as H, setDryRun as I, checkAuth as J, stopProject as K, step as L, execSilent as M, info as N, updateProjectAccess as O, isRoot as P, parseBearerToken as Q, success as R, getDb as S, initDb as T, listProjects as U, getProjectFromHostname as V, loadProjectList as W, matchIp as X, matchHostname as Y, parseBasicAuth as Z, loadProjectConfig as _, handleStatus as a, closeDb as b, addDdevAptRepo as c, getDockerGatewayIp as d, installDdev as f, getIdleTimeoutMs as g, readToolPorts as h, handleRequest as i, exec as j, nodeIo as k, configureDdev as l, purgeTraefikAcmeStorage as m, handleAuth as n, routePath as o, installSystemDeps as p, watchProjectList as q, handleErrors as r, startServer as s, getEffectiveAuthConfig as t, configureTraefik as u, shouldNeverStop as v, getProject as w, getAccessLogs as x, cleanOldLogs as y, warn as z };
1213
1216
 
1214
- //# sourceMappingURL=server-CLrB1zpR.js.map
1217
+ //# sourceMappingURL=server-BAjHxgqh.js.map