@takodotid/circuit 0.34.3 → 0.36.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.
Files changed (240) hide show
  1. package/README.md +14 -15
  2. package/dist/chunks/index-20kcjxjg.js +174 -0
  3. package/dist/chunks/index-2r4bbhac.js +977 -0
  4. package/dist/chunks/index-n7w3tg5y.js +149 -0
  5. package/dist/chunks/new-w3zys5y4.js +172 -0
  6. package/dist/chunks/server-gzsf5me5.js +259 -0
  7. package/dist/cli/index.js +11345 -9024
  8. package/dist/helpers/index.js +594 -0
  9. package/dist/index.js +9 -28
  10. package/dist/types/core/{addresses.d.ts → address-space.d.ts} +23 -7
  11. package/dist/types/core/address.d.ts +44 -0
  12. package/dist/types/core/cabling.d.ts +41 -0
  13. package/dist/types/core/cross-checks.d.ts +28 -0
  14. package/dist/types/core/define.d.ts +25 -3
  15. package/dist/types/core/project-files.d.ts +12 -0
  16. package/dist/types/core/schema-docs.d.ts +13 -4
  17. package/dist/types/core/secrets.d.ts +62 -21
  18. package/dist/types/core/{graph.d.ts → topology.d.ts} +32 -12
  19. package/dist/types/core/utils.d.ts +8 -0
  20. package/dist/types/core/validator.d.ts +19 -0
  21. package/dist/types/helpers/bgp.d.ts +97 -0
  22. package/dist/types/helpers/checks.d.ts +32 -0
  23. package/dist/types/helpers/exchange.d.ts +49 -0
  24. package/dist/types/helpers/filters.d.ts +51 -0
  25. package/dist/types/helpers/index.d.ts +10 -0
  26. package/dist/types/helpers/route-server.d.ts +52 -0
  27. package/dist/types/index.d.ts +5 -3
  28. package/dist/types/integrations/librenms.d.ts +60 -0
  29. package/dist/types/integrations/netbox.d.ts +89 -0
  30. package/dist/types/integrations/peeringdb.d.ts +24 -0
  31. package/dist/types/integrations/prometheus.d.ts +24 -0
  32. package/dist/types/integrations/registries.d.ts +8 -0
  33. package/dist/types/platforms/bgp-wait.d.ts +18 -0
  34. package/dist/types/platforms/catalog.d.ts +109 -0
  35. package/dist/types/platforms/derive.d.ts +17 -0
  36. package/dist/types/platforms/fortios/firewall.d.ts +45 -0
  37. package/dist/types/platforms/fortios/index.d.ts +134 -0
  38. package/dist/types/platforms/fortios/names.d.ts +52 -0
  39. package/dist/types/platforms/fortios/plan.d.ts +104 -0
  40. package/dist/types/platforms/fortios/render.d.ts +75 -0
  41. package/dist/types/platforms/fortios/routing.d.ts +56 -0
  42. package/dist/types/platforms/fortios/tree.d.ts +37 -0
  43. package/dist/types/platforms/index.d.ts +13 -0
  44. package/dist/types/platforms/junos/bgp.d.ts +44 -0
  45. package/dist/types/platforms/junos/index.d.ts +114 -0
  46. package/dist/types/platforms/junos/render.d.ts +154 -0
  47. package/dist/types/platforms/line-diff.d.ts +67 -0
  48. package/dist/types/platforms/platform.d.ts +120 -0
  49. package/dist/types/platforms/raisecom-ros/index.d.ts +71 -0
  50. package/dist/types/platforms/raisecom-ros/render.d.ts +41 -0
  51. package/dist/types/platforms/routeros/export.d.ts +59 -0
  52. package/dist/types/platforms/routeros/index.d.ts +96 -0
  53. package/dist/types/{adapters/devices → platforms}/routeros/models.d.ts +4 -0
  54. package/dist/types/platforms/routeros/plan.d.ts +54 -0
  55. package/dist/types/platforms/routeros/render.d.ts +240 -0
  56. package/dist/types/platforms/vrp/index.d.ts +83 -0
  57. package/dist/types/platforms/vrp/render.d.ts +96 -0
  58. package/dist/types/schema/common.d.ts +12 -6
  59. package/dist/types/schema/index.d.ts +1 -0
  60. package/dist/types/schema/interfaces.d.ts +14 -6
  61. package/dist/types/schema/policy.d.ts +18 -10
  62. package/dist/types/schema/routing.d.ts +14 -1
  63. package/dist/types/schema/system.d.ts +16 -6
  64. package/dist/types/schema/time-zones.d.ts +2 -0
  65. package/dist/types/transport/console.d.ts +79 -23
  66. package/dist/types/transport/path-check.d.ts +32 -0
  67. package/dist/types/transport/ssh.d.ts +17 -15
  68. package/package.json +18 -12
  69. package/src/cli/graph-format.ts +91 -0
  70. package/src/cli/index.ts +1074 -484
  71. package/src/cli/new.ts +208 -175
  72. package/src/cli/rename.ts +97 -91
  73. package/src/core/address-space.ts +320 -0
  74. package/src/core/address.ts +202 -0
  75. package/src/core/cabling.ts +154 -0
  76. package/src/core/cross-checks.ts +364 -0
  77. package/src/core/define.ts +26 -3
  78. package/src/core/project-files.ts +35 -0
  79. package/src/core/schema-docs.ts +138 -130
  80. package/src/core/secrets.ts +170 -76
  81. package/src/core/topology.ts +656 -0
  82. package/src/core/utils.ts +37 -0
  83. package/src/core/validator.ts +407 -0
  84. package/src/helpers/bgp.ts +301 -0
  85. package/src/helpers/checks.ts +178 -0
  86. package/src/helpers/exchange.ts +117 -0
  87. package/src/helpers/filters.ts +107 -0
  88. package/src/helpers/index.ts +12 -0
  89. package/src/helpers/route-server.ts +212 -0
  90. package/src/index.ts +6 -3
  91. package/src/integrations/librenms.ts +291 -0
  92. package/src/integrations/netbox.ts +620 -0
  93. package/src/integrations/peeringdb.ts +173 -0
  94. package/src/integrations/prometheus.ts +90 -0
  95. package/src/integrations/registries.ts +25 -0
  96. package/src/platforms/bgp-wait.ts +41 -0
  97. package/src/platforms/catalog.ts +69 -0
  98. package/src/platforms/derive.ts +117 -0
  99. package/src/{adapters/devices → platforms}/fortios/TRAPS.md +6 -0
  100. package/src/platforms/fortios/firewall.ts +377 -0
  101. package/src/platforms/fortios/index.ts +1060 -0
  102. package/src/platforms/fortios/names.ts +130 -0
  103. package/src/platforms/fortios/plan.ts +929 -0
  104. package/src/platforms/fortios/render.ts +779 -0
  105. package/src/platforms/fortios/routing.ts +476 -0
  106. package/src/platforms/fortios/tree.ts +129 -0
  107. package/src/platforms/index.ts +36 -0
  108. package/src/{adapters/devices → platforms}/junos/TRAPS.md +8 -0
  109. package/src/platforms/junos/bgp.ts +293 -0
  110. package/src/platforms/junos/index.ts +1006 -0
  111. package/src/platforms/junos/render.ts +967 -0
  112. package/src/platforms/line-diff.ts +244 -0
  113. package/src/platforms/platform.ts +122 -0
  114. package/src/platforms/raisecom-ros/TRAPS.md +40 -0
  115. package/src/platforms/raisecom-ros/index.ts +731 -0
  116. package/src/platforms/raisecom-ros/render.ts +328 -0
  117. package/src/{adapters/devices → platforms}/routeros/TRAPS.md +16 -2
  118. package/src/platforms/routeros/export.ts +342 -0
  119. package/src/platforms/routeros/index.ts +636 -0
  120. package/src/{adapters/devices → platforms}/routeros/models.ts +2 -0
  121. package/src/platforms/routeros/plan.ts +444 -0
  122. package/src/platforms/routeros/render.ts +1483 -0
  123. package/src/platforms/vrp/TRAPS.md +45 -0
  124. package/src/platforms/vrp/index.ts +652 -0
  125. package/src/platforms/vrp/render.ts +615 -0
  126. package/src/schema/common.ts +15 -6
  127. package/src/schema/index.ts +1 -0
  128. package/src/schema/interfaces.ts +15 -5
  129. package/src/schema/policy.ts +22 -10
  130. package/src/schema/routing.ts +16 -1
  131. package/src/schema/system.ts +16 -6
  132. package/src/schema/time-zones.ts +482 -0
  133. package/src/studio/data.ts +172 -168
  134. package/src/studio/git.ts +95 -90
  135. package/src/studio/public/app.js +161 -150
  136. package/src/studio/public/compare.js +219 -212
  137. package/src/studio/public/diff.js +77 -75
  138. package/src/studio/public/dom.js +118 -108
  139. package/src/studio/public/evaluate.js +584 -545
  140. package/src/studio/public/export.js +120 -118
  141. package/src/studio/public/graph.js +1343 -1266
  142. package/src/studio/public/net.js +115 -109
  143. package/src/studio/public/tables.js +1135 -1083
  144. package/src/studio/public/tools.js +561 -529
  145. package/src/studio/server.ts +202 -196
  146. package/src/transport/console.ts +363 -271
  147. package/src/transport/path-check.ts +91 -0
  148. package/src/transport/ssh.ts +98 -94
  149. package/templates/colocation/checks.ts +11 -6
  150. package/templates/colocation/edge-01.ts +9 -9
  151. package/templates/colocation/routing.ts +2 -2
  152. package/templates/colocation/tenancy.ts +3 -3
  153. package/templates/colocation/tor-01.ts +3 -3
  154. package/templates/edge-router/checks.ts +4 -4
  155. package/templates/edge-router/edge-01.ts +5 -5
  156. package/templates/edge-router/routing.ts +2 -2
  157. package/templates/project/AGENTS.md +5 -1
  158. package/templates/single-site/checks.ts +2 -2
  159. package/dist/chunks/index-47debt16.js +0 -956
  160. package/dist/chunks/index-v8zgr7wm.js +0 -128
  161. package/dist/chunks/new-ny89s7y7.js +0 -166
  162. package/dist/chunks/server-tjbb3rqc.js +0 -251
  163. package/dist/presets/index.js +0 -394
  164. package/dist/types/adapters/devices/catalog.d.ts +0 -86
  165. package/dist/types/adapters/devices/derive.d.ts +0 -7
  166. package/dist/types/adapters/devices/fortios/firewall.d.ts +0 -23
  167. package/dist/types/adapters/devices/fortios/index.d.ts +0 -58
  168. package/dist/types/adapters/devices/fortios/names.d.ts +0 -48
  169. package/dist/types/adapters/devices/fortios/plan.d.ts +0 -28
  170. package/dist/types/adapters/devices/fortios/render.d.ts +0 -38
  171. package/dist/types/adapters/devices/fortios/routing.d.ts +0 -32
  172. package/dist/types/adapters/devices/fortios/tree.d.ts +0 -33
  173. package/dist/types/adapters/devices/index.d.ts +0 -3
  174. package/dist/types/adapters/devices/junos/bgp.d.ts +0 -11
  175. package/dist/types/adapters/devices/junos/index.d.ts +0 -42
  176. package/dist/types/adapters/devices/junos/render.d.ts +0 -52
  177. package/dist/types/adapters/devices/lines.d.ts +0 -56
  178. package/dist/types/adapters/devices/raisecom-ros/index.d.ts +0 -28
  179. package/dist/types/adapters/devices/routeros/export.d.ts +0 -39
  180. package/dist/types/adapters/devices/routeros/index.d.ts +0 -30
  181. package/dist/types/adapters/devices/routeros/plan.d.ts +0 -4
  182. package/dist/types/adapters/devices/routeros/render.d.ts +0 -100
  183. package/dist/types/adapters/devices/types.d.ts +0 -40
  184. package/dist/types/adapters/devices/vrp/index.d.ts +0 -27
  185. package/dist/types/adapters/registries/index.d.ts +0 -3
  186. package/dist/types/adapters/registries/librenms.d.ts +0 -15
  187. package/dist/types/adapters/registries/peeringdb.d.ts +0 -16
  188. package/dist/types/adapters/registries/prometheus.d.ts +0 -16
  189. package/dist/types/core/across.d.ts +0 -4
  190. package/dist/types/core/addr.d.ts +0 -34
  191. package/dist/types/core/files.d.ts +0 -6
  192. package/dist/types/core/merge.d.ts +0 -4
  193. package/dist/types/core/time.d.ts +0 -2
  194. package/dist/types/core/validate.d.ts +0 -2
  195. package/dist/types/presets/index.d.ts +0 -183
  196. package/src/adapters/devices/catalog.ts +0 -41
  197. package/src/adapters/devices/derive.ts +0 -59
  198. package/src/adapters/devices/fortios/firewall.ts +0 -362
  199. package/src/adapters/devices/fortios/index.ts +0 -923
  200. package/src/adapters/devices/fortios/names.ts +0 -115
  201. package/src/adapters/devices/fortios/plan.ts +0 -888
  202. package/src/adapters/devices/fortios/render.ts +0 -768
  203. package/src/adapters/devices/fortios/routing.ts +0 -444
  204. package/src/adapters/devices/fortios/tree.ts +0 -121
  205. package/src/adapters/devices/index.ts +0 -9
  206. package/src/adapters/devices/junos/bgp.ts +0 -269
  207. package/src/adapters/devices/junos/index.ts +0 -862
  208. package/src/adapters/devices/junos/render.ts +0 -872
  209. package/src/adapters/devices/lines.ts +0 -212
  210. package/src/adapters/devices/raisecom-ros/TRAPS.md +0 -26
  211. package/src/adapters/devices/raisecom-ros/index.ts +0 -700
  212. package/src/adapters/devices/routeros/export.ts +0 -321
  213. package/src/adapters/devices/routeros/index.ts +0 -346
  214. package/src/adapters/devices/routeros/plan.ts +0 -398
  215. package/src/adapters/devices/routeros/render.ts +0 -1490
  216. package/src/adapters/devices/types.ts +0 -38
  217. package/src/adapters/devices/vrp/TRAPS.md +0 -26
  218. package/src/adapters/devices/vrp/index.ts +0 -865
  219. package/src/adapters/registries/index.ts +0 -19
  220. package/src/adapters/registries/librenms.ts +0 -92
  221. package/src/adapters/registries/peeringdb.ts +0 -169
  222. package/src/adapters/registries/prometheus.ts +0 -71
  223. package/src/cli/graph.ts +0 -89
  224. package/src/core/across.ts +0 -350
  225. package/src/core/addr.ts +0 -174
  226. package/src/core/addresses.ts +0 -316
  227. package/src/core/files.ts +0 -32
  228. package/src/core/graph.ts +0 -640
  229. package/src/core/merge.ts +0 -19
  230. package/src/core/time.ts +0 -15
  231. package/src/core/validate.ts +0 -348
  232. package/src/presets/index.ts +0 -579
  233. /package/dist/types/{adapters/devices → platforms}/fortios/models.d.ts +0 -0
  234. /package/dist/types/{adapters/devices → platforms}/junos/models.d.ts +0 -0
  235. /package/dist/types/{adapters/devices → platforms}/raisecom-ros/models.d.ts +0 -0
  236. /package/dist/types/{adapters/devices → platforms}/vrp/models.d.ts +0 -0
  237. /package/src/{adapters/devices → platforms}/fortios/models.ts +0 -0
  238. /package/src/{adapters/devices → platforms}/junos/models.ts +0 -0
  239. /package/src/{adapters/devices → platforms}/raisecom-ros/models.ts +0 -0
  240. /package/src/{adapters/devices → platforms}/vrp/models.ts +0 -0
@@ -4,8 +4,8 @@ import { execFile, spawn } from "node:child_process";
4
4
  import { existsSync, readFileSync, watch } from "node:fs";
5
5
  import { createServer, type ServerResponse } from "node:http";
6
6
  import { extname, join } from "node:path";
7
- import { packageRoot, type StudioData } from "./data";
8
- import { commitOf, history, networkAt, references } from "./git";
7
+ import { StudioModel, type StudioData } from "./data";
8
+ import { Git } from "./git";
9
9
 
10
10
  export type StudioOptions = {
11
11
  /** The config file. */
@@ -21,207 +21,213 @@ export type StudioOptions = {
21
21
  allowHosts: string[];
22
22
  };
23
23
 
24
- /** Whether an address to listen on is this machine's own, which nothing else on the network can reach. */
25
- export const isLoopback = (host: string) => host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host);
26
-
27
- /**
28
- * Whether a request's Host header names Studio, so a web page elsewhere cannot read the network through a name it points here.
29
- *
30
- * Studio answers to this machine's own names at its port, and to each name it was told to, with or without a port.
31
- */
32
- export function hostAllowed(header: string | undefined, port: number, allowHosts: readonly string[]): boolean {
33
- if (!header) return false;
34
- if (header === `127.0.0.1:${port}` || header === `localhost:${port}` || header === `[::1]:${port}`) return true;
35
-
36
- const name = header.replace(/:\d+$/, "").toLowerCase();
37
- return allowHosts.some((allowed) => {
38
- const wanted = allowed.toLowerCase();
39
- return wanted === header.toLowerCase() || wanted === name;
40
- });
41
- }
24
+ export class StudioServer {
25
+ /** Directories whose changes never change what Studio shows. */
26
+ private static readonly IGNORED = /(^|[\\/])(node_modules|\.git|\.circuit[\\/]cache)([\\/]|$)/;
42
27
 
43
- /** Directories whose changes never change what Studio shows. */
44
- const IGNORED = /(^|[\\/])(node_modules|\.git|\.circuit[\\/]cache)([\\/]|$)/;
28
+ private static readonly CONTENT_TYPES: Record<string, string> = {
29
+ ".html": "text/html; charset=utf-8",
30
+ ".css": "text/css; charset=utf-8",
31
+ ".js": "text/javascript; charset=utf-8",
32
+ ".svg": "image/svg+xml",
33
+ ".woff2": "font/woff2",
34
+ };
45
35
 
46
- const CONTENT_TYPES: Record<string, string> = {
47
- ".html": "text/html; charset=utf-8",
48
- ".css": "text/css; charset=utf-8",
49
- ".js": "text/javascript; charset=utf-8",
50
- ".svg": "image/svg+xml",
51
- ".woff2": "font/woff2",
52
- };
36
+ /** Whether an address to listen on is this machine's own, which nothing else on the network can reach. */
37
+ static isLoopback(host: string): boolean {
38
+ return host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host);
39
+ }
40
+
41
+ /**
42
+ * Whether a request's Host header names Studio, so a web page elsewhere cannot read the network through a name it points here.
43
+ *
44
+ * Studio answers to this machine's own names at its port, and to each name it was told to, with or without a port.
45
+ */
46
+ static hostAllowed(header: string | undefined, port: number, allowHosts: readonly string[]): boolean {
47
+ if (!header) return false;
48
+ if (header === `127.0.0.1:${port}` || header === `localhost:${port}` || header === `[::1]:${port}`) return true;
49
+
50
+ const name = header.replace(/:\d+$/, "").toLowerCase();
51
+ return allowHosts.some((allowed) => {
52
+ const wanted = allowed.toLowerCase();
53
+ return wanted === header.toLowerCase() || wanted === name;
54
+ });
55
+ }
53
56
 
54
- /** What Studio shows, as JSON, from `circuit studio --json` run as a new process, or what stopped it. */
55
- function gather(configPath: string): Promise<string> {
56
- const cli = process.argv[1]!;
57
- const args = [...process.execArgv, cli, "studio", "--json", `--config=${configPath}`];
57
+ /** What Studio shows, as JSON, from `circuit studio --json` run as a new process, or what stopped it. */
58
+ private static gather(configPath: string): Promise<string> {
59
+ const cli = process.argv[1]!;
60
+ const args = [...process.execArgv, cli, "studio", "--json", `--config=${configPath}`];
58
61
 
59
- return new Promise((done) => {
60
- execFile(process.execPath, args, { maxBuffer: 256 * 1024 * 1024 }, (error, stdout, stderr) => {
61
- if (!error) return done(stdout);
62
+ return new Promise((done) => {
63
+ execFile(process.execPath, args, { maxBuffer: 256 * 1024 * 1024 }, (error, stdout, stderr) => {
64
+ if (!error) return done(stdout);
62
65
 
63
- const message = (stderr || error.message).trim();
64
- done(JSON.stringify({ error: message }));
66
+ const message = (stderr || error.message).trim();
67
+ done(JSON.stringify({ error: message }));
68
+ });
69
+ });
70
+ }
71
+
72
+ /** Open the address in the browser. A machine without one only misses the convenience. */
73
+ private static openBrowser(url: string): void {
74
+ const [command, args] =
75
+ process.platform === "win32"
76
+ ? ["cmd", ["/c", "start", "", url]]
77
+ : process.platform === "darwin"
78
+ ? ["open", [url]]
79
+ : ["xdg-open", [url]];
80
+
81
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
82
+ child.on("error", () => {});
83
+ child.unref();
84
+ }
85
+
86
+ /** Serve Studio until the process stops, and gather what it shows again whenever a file beside the config changes. */
87
+ static async start(options: StudioOptions): Promise<void> {
88
+ const { configPath, root, port } = options;
89
+
90
+ // The page's files ship in the package's sources, which Bun runs as they are and Node's build leaves in place.
91
+ const installed = StudioModel.packageRoot();
92
+ if (!installed) throw new Error("cannot find where Circuit is installed, so Studio has no page to serve");
93
+ const pages = join(installed, "src", "studio", "public");
94
+
95
+ let data = await StudioServer.gather(configPath);
96
+
97
+ // Another commit's network is read once: a commit does not change. Each is kept as it is being read, so two asks share one read.
98
+ const past = new Map<string, Promise<string>>();
99
+
100
+ /** Answer with JSON, or with an error the page shows. */
101
+ const answer = async (response: ServerResponse, work: () => Promise<unknown>) => {
102
+ let body: string;
103
+ try {
104
+ const result = await work();
105
+ body = typeof result === "string" ? result : JSON.stringify(result);
106
+ } catch (error) {
107
+ body = JSON.stringify({ error: (error as Error).message });
108
+ }
109
+ response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(body);
110
+ };
111
+ const listeners = new Set<ServerResponse>();
112
+ const tell = (message: string) => {
113
+ for (const listener of listeners) listener.write(`data: ${message}\n\n`);
114
+ };
115
+
116
+ const server = createServer((request, response) => {
117
+ // Studio shows one team's network: every answer asks search engines to leave it out, and robots.txt says so for the whole site.
118
+ response.setHeader("x-robots-tag", "noindex, nofollow, noarchive");
119
+
120
+ if (!StudioServer.hostAllowed(request.headers.host, port, options.allowHosts)) {
121
+ response.writeHead(403).end();
122
+ return;
123
+ }
124
+
125
+ const url = new URL(request.url ?? "/", "http://studio");
126
+ const path = url.pathname;
127
+
128
+ // The commits that changed a device: its file and its snapshot.
129
+ if (path === "/history") {
130
+ void answer(response, async () => {
131
+ const name = url.searchParams.get("device") ?? "";
132
+ const files = (JSON.parse(data) as StudioData).files?.[name];
133
+ if (!files) throw new Error(`no device ${name}`);
134
+ return Git.history(
135
+ root,
136
+ [files.source, files.snapshot].filter((file): file is string => Boolean(file))
137
+ );
138
+ });
139
+ return;
140
+ }
141
+
142
+ // What Studio can compare with: branches, tags and the latest commits.
143
+ if (path === "/refs") {
144
+ void answer(response, () => Git.references(root));
145
+ return;
146
+ }
147
+
148
+ // The network as it was at a branch, tag or commit.
149
+ if (path === "/compare") {
150
+ void answer(response, async () => {
151
+ const asked = url.searchParams.get("ref") ?? "";
152
+ const commit = await Git.commitOf(root, asked);
153
+ if (!commit) throw new Error(`${asked} names no commit here`);
154
+
155
+ if (!past.has(commit.commit))
156
+ past.set(commit.commit, Git.networkAt(root, configPath, commit.commit, StudioServer.gather));
157
+ const network = await past.get(commit.commit)!;
158
+ if (network.startsWith('{"error"')) past.delete(commit.commit);
159
+
160
+ return `{"commit":${JSON.stringify(commit)},"network":${network}}`;
161
+ });
162
+ return;
163
+ }
164
+
165
+ if (path === "/robots.txt") {
166
+ response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("User-agent: *\nDisallow: /\n");
167
+ return;
168
+ }
169
+
170
+ if (path === "/data") {
171
+ response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(data);
172
+ return;
173
+ }
174
+
175
+ // Changes are told as they happen; a proxy such as nginx is asked not to hold them back.
176
+ if (path === "/events") {
177
+ response.writeHead(200, {
178
+ "content-type": "text/event-stream",
179
+ "cache-control": "no-store",
180
+ connection: "keep-alive",
181
+ "x-accel-buffering": "no",
182
+ });
183
+ response.write(": connected\n\n");
184
+
185
+ listeners.add(response);
186
+ request.on("close", () => listeners.delete(response));
187
+ return;
188
+ }
189
+
190
+ // Only a file directly in public/, by a plain name, is served: nothing above it can be reached.
191
+ const name = path === "/" ? "index.html" : path.slice(1);
192
+ const type = StudioServer.CONTENT_TYPES[extname(name)];
193
+ const file = join(pages, name);
194
+
195
+ if (!/^[a-z-]+\.[a-z0-9]+$/.test(name) || !type || !existsSync(file)) {
196
+ response.writeHead(404).end();
197
+ return;
198
+ }
199
+
200
+ response.writeHead(200, { "content-type": type, "cache-control": "no-store" }).end(readFileSync(file));
65
201
  });
66
- });
67
- }
68
202
 
69
- /** Open the address in the browser. A machine without one only misses the convenience. */
70
- function openBrowser(url: string): void {
71
- const [command, args] =
72
- process.platform === "win32"
73
- ? ["cmd", ["/c", "start", "", url]]
74
- : process.platform === "darwin"
75
- ? ["open", [url]]
76
- : ["xdg-open", [url]];
77
-
78
- const child = spawn(command, args, { stdio: "ignore", detached: true });
79
- child.on("error", () => {});
80
- child.unref();
81
- }
203
+ // Several files often change at once, such as on a save or a checkout: the config is read again once they settle.
204
+ let timer: NodeJS.Timeout | undefined;
82
205
 
83
- export async function studio(options: StudioOptions): Promise<void> {
84
- const { configPath, root, port } = options;
85
-
86
- // The page's files ship in the package's sources, which Bun runs as they are and Node's build leaves in place.
87
- const installed = packageRoot();
88
- if (!installed) throw new Error("cannot find where Circuit is installed, so Studio has no page to serve");
89
- const pages = join(installed, "src", "studio", "public");
90
-
91
- let data = await gather(configPath);
92
-
93
- // Another commit's network is read once: a commit does not change. Each is kept as it is being read, so two asks share one read.
94
- const past = new Map<string, Promise<string>>();
95
-
96
- /** Answer with JSON, or with an error the page shows. */
97
- const answer = async (response: ServerResponse, work: () => Promise<unknown>) => {
98
- let body: string;
99
- try {
100
- const result = await work();
101
- body = typeof result === "string" ? result : JSON.stringify(result);
102
- } catch (error) {
103
- body = JSON.stringify({ error: (error as Error).message });
104
- }
105
- response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(body);
106
- };
107
- const listeners = new Set<ServerResponse>();
108
- const tell = (message: string) => {
109
- for (const listener of listeners) listener.write(`data: ${message}\n\n`);
110
- };
206
+ watch(root, { recursive: true }, (_event, filename) => {
207
+ if (filename && StudioServer.IGNORED.test(filename)) return;
111
208
 
112
- const server = createServer((request, response) => {
113
- // Studio shows one team's network: every answer asks search engines to leave it out, and robots.txt says so for the whole site.
114
- response.setHeader("x-robots-tag", "noindex, nofollow, noarchive");
115
-
116
- if (!hostAllowed(request.headers.host, port, options.allowHosts)) {
117
- response.writeHead(403).end();
118
- return;
119
- }
120
-
121
- const url = new URL(request.url ?? "/", "http://studio");
122
- const path = url.pathname;
123
-
124
- // The commits that changed a device: its file and its snapshot.
125
- if (path === "/history") {
126
- void answer(response, async () => {
127
- const name = url.searchParams.get("device") ?? "";
128
- const files = (JSON.parse(data) as StudioData).files?.[name];
129
- if (!files) throw new Error(`no device ${name}`);
130
- return history(
131
- root,
132
- [files.source, files.snapshot].filter((file): file is string => Boolean(file))
133
- );
134
- });
135
- return;
136
- }
137
-
138
- // What Studio can compare with: branches, tags and the latest commits.
139
- if (path === "/refs") {
140
- void answer(response, () => references(root));
141
- return;
142
- }
143
-
144
- // The network as it was at a branch, tag or commit.
145
- if (path === "/compare") {
146
- void answer(response, async () => {
147
- const asked = url.searchParams.get("ref") ?? "";
148
- const commit = await commitOf(root, asked);
149
- if (!commit) throw new Error(`${asked} names no commit here`);
150
-
151
- if (!past.has(commit.commit)) past.set(commit.commit, networkAt(root, configPath, commit.commit, gather));
152
- const network = await past.get(commit.commit)!;
153
- if (network.startsWith('{"error"')) past.delete(commit.commit);
154
-
155
- return `{"commit":${JSON.stringify(commit)},"network":${network}}`;
156
- });
157
- return;
158
- }
159
-
160
- if (path === "/robots.txt") {
161
- response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("User-agent: *\nDisallow: /\n");
162
- return;
163
- }
164
-
165
- if (path === "/data") {
166
- response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(data);
167
- return;
168
- }
169
-
170
- // Changes are told as they happen; a proxy such as nginx is asked not to hold them back.
171
- if (path === "/events") {
172
- response.writeHead(200, {
173
- "content-type": "text/event-stream",
174
- "cache-control": "no-store",
175
- connection: "keep-alive",
176
- "x-accel-buffering": "no",
177
- });
178
- response.write(": connected\n\n");
179
-
180
- listeners.add(response);
181
- request.on("close", () => listeners.delete(response));
182
- return;
183
- }
184
-
185
- // Only a file directly in public/, by a plain name, is served: nothing above it can be reached.
186
- const name = path === "/" ? "index.html" : path.slice(1);
187
- const type = CONTENT_TYPES[extname(name)];
188
- const file = join(pages, name);
189
-
190
- if (!/^[a-z-]+\.[a-z0-9]+$/.test(name) || !type || !existsSync(file)) {
191
- response.writeHead(404).end();
192
- return;
193
- }
194
-
195
- response.writeHead(200, { "content-type": type, "cache-control": "no-store" }).end(readFileSync(file));
196
- });
197
-
198
- // Several files often change at once, such as on a save or a checkout: the config is read again once they settle.
199
- let timer: NodeJS.Timeout | undefined;
200
-
201
- watch(root, { recursive: true }, (_event, filename) => {
202
- if (filename && IGNORED.test(filename)) return;
203
-
204
- clearTimeout(timer);
205
- timer = setTimeout(async () => {
206
- data = await gather(configPath);
207
- tell("changed");
208
- console.log(`${new Date().toLocaleTimeString()} ${filename ?? "a file"} changed, read again`);
209
- }, 300);
210
- });
211
-
212
- // A change to the page's own files, while working on Circuit, loads the page again.
213
- watch(pages, () => tell("page"));
214
-
215
- server.on("error", (error: NodeJS.ErrnoException) => {
216
- const message = error.code === "EADDRINUSE" ? `port ${port} is in use; choose another with --port=N` : error.message;
217
- console.error(message);
218
- process.exit(1);
219
- });
220
-
221
- server.listen(port, options.host, () => {
222
- const local = isLoopback(options.host);
223
- const url = local ? `http://127.0.0.1:${port}` : `http://${options.allowHosts[0] ?? options.host}`;
224
- console.log(`Circuit Studio at ${url}, listening on ${options.host}:${port}, watching ${root}. Stop it with Ctrl+C.`);
225
- if (options.open && local) openBrowser(url);
226
- });
209
+ clearTimeout(timer);
210
+ timer = setTimeout(async () => {
211
+ data = await StudioServer.gather(configPath);
212
+ tell("changed");
213
+ console.log(`${new Date().toLocaleTimeString()} ${filename ?? "a file"} changed, read again`);
214
+ }, 300);
215
+ });
216
+
217
+ // A change to the page's own files, while working on Circuit, loads the page again.
218
+ watch(pages, () => tell("page"));
219
+
220
+ server.on("error", (error: NodeJS.ErrnoException) => {
221
+ const message = error.code === "EADDRINUSE" ? `port ${port} is in use; choose another with --port=N` : error.message;
222
+ console.error(message);
223
+ process.exit(1);
224
+ });
225
+
226
+ server.listen(port, options.host, () => {
227
+ const local = StudioServer.isLoopback(options.host);
228
+ const url = local ? `http://127.0.0.1:${port}` : `http://${options.allowHosts[0] ?? options.host}`;
229
+ console.log(`Circuit Studio at ${url}, listening on ${options.host}:${port}, watching ${root}. Stop it with Ctrl+C.`);
230
+ if (options.open && local) StudioServer.openBrowser(url);
231
+ });
232
+ }
227
233
  }