@plurnk/plurnk-web 0.0.0-stage → 1.1.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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +337 -2
  3. package/SPEC.md +869 -0
  4. package/ai.plurnk/.env.defaults +38 -0
  5. package/ai.plurnk/assets.json +193 -0
  6. package/ai.plurnk/creations.js +65 -0
  7. package/ai.plurnk/diagram-compiler.js +15 -0
  8. package/ai.plurnk/diagrams.js +130 -0
  9. package/ai.plurnk/directory-addresses.js +35 -0
  10. package/ai.plurnk/dist/assets/Temml-CCjoacWt.woff2 +0 -0
  11. package/ai.plurnk/dist/assets/index-B4w6iDKw.css +1 -0
  12. package/ai.plurnk/dist/assets/index-B4w6iDKw.css.br +0 -0
  13. package/ai.plurnk/dist/assets/index-B4w6iDKw.css.gz +0 -0
  14. package/ai.plurnk/dist/assets/index-CGRGblLU.js +2 -0
  15. package/ai.plurnk/dist/assets/index-CGRGblLU.js.br +0 -0
  16. package/ai.plurnk/dist/assets/index-CGRGblLU.js.gz +0 -0
  17. package/ai.plurnk/dist/assets/math-renderer-CZ_XtOMq.css +1 -0
  18. package/ai.plurnk/dist/assets/math-renderer-CZ_XtOMq.css.br +0 -0
  19. package/ai.plurnk/dist/assets/math-renderer-CZ_XtOMq.css.gz +0 -0
  20. package/ai.plurnk/dist/assets/math-renderer-CnlgS6FH.js +96 -0
  21. package/ai.plurnk/dist/assets/math-renderer-CnlgS6FH.js.br +0 -0
  22. package/ai.plurnk/dist/assets/math-renderer-CnlgS6FH.js.gz +0 -0
  23. package/ai.plurnk/dist/assets/overview-DEhW9HUG.js +1 -0
  24. package/ai.plurnk/dist/assets/overview-DEhW9HUG.js.br +0 -0
  25. package/ai.plurnk/dist/assets/overview-DEhW9HUG.js.gz +0 -0
  26. package/ai.plurnk/dist/assets/preload-helper-BjS5LQBu.js +1 -0
  27. package/ai.plurnk/dist/assets/preload-helper-BjS5LQBu.js.br +0 -0
  28. package/ai.plurnk/dist/assets/preload-helper-BjS5LQBu.js.gz +0 -0
  29. package/ai.plurnk/dist/assets/session-BcdGbgAa.js +4 -0
  30. package/ai.plurnk/dist/assets/session-BcdGbgAa.js.br +0 -0
  31. package/ai.plurnk/dist/assets/session-BcdGbgAa.js.gz +0 -0
  32. package/ai.plurnk/dist/assets/session-agent-DwY8S586.js +111 -0
  33. package/ai.plurnk/dist/assets/session-agent-DwY8S586.js.br +0 -0
  34. package/ai.plurnk/dist/assets/session-agent-DwY8S586.js.gz +0 -0
  35. package/ai.plurnk/dist/assets/src-Du01yeMu.js +1 -0
  36. package/ai.plurnk/dist/assets/src-Du01yeMu.js.br +0 -0
  37. package/ai.plurnk/dist/assets/src-J2qT9xpv.js +6 -0
  38. package/ai.plurnk/dist/assets/src-J2qT9xpv.js.br +0 -0
  39. package/ai.plurnk/dist/assets/src-J2qT9xpv.js.gz +0 -0
  40. package/ai.plurnk/dist/icons/APACHE-2.0.txt +202 -0
  41. package/ai.plurnk/dist/icons/APACHE-2.0.txt.br +0 -0
  42. package/ai.plurnk/dist/icons/APACHE-2.0.txt.gz +0 -0
  43. package/ai.plurnk/dist/icons/NOTO-NOTICE.txt +10 -0
  44. package/ai.plurnk/dist/icons/NOTO-NOTICE.txt.br +0 -0
  45. package/ai.plurnk/dist/icons/NOTO-NOTICE.txt.gz +0 -0
  46. package/ai.plurnk/dist/icons/plurnk-maskable.svg +64 -0
  47. package/ai.plurnk/dist/icons/plurnk-maskable.svg.br +0 -0
  48. package/ai.plurnk/dist/icons/plurnk-maskable.svg.gz +0 -0
  49. package/ai.plurnk/dist/icons/plurnk.svg +63 -0
  50. package/ai.plurnk/dist/icons/plurnk.svg.br +0 -0
  51. package/ai.plurnk/dist/icons/plurnk.svg.gz +0 -0
  52. package/ai.plurnk/dist/index.html +421 -0
  53. package/ai.plurnk/dist/manifest.webmanifest +27 -0
  54. package/ai.plurnk/dist/manifest.webmanifest.br +0 -0
  55. package/ai.plurnk/dist/manifest.webmanifest.gz +0 -0
  56. package/ai.plurnk/dist/vendor/CFWORKER-JSON-SCHEMA-LICENSE.txt +25 -0
  57. package/ai.plurnk/dist/vendor/CFWORKER-JSON-SCHEMA-LICENSE.txt.br +0 -0
  58. package/ai.plurnk/dist/vendor/CFWORKER-JSON-SCHEMA-LICENSE.txt.gz +0 -0
  59. package/ai.plurnk/dist/vendor/DEPENDENCIES-LICENSE.txt +571 -0
  60. package/ai.plurnk/dist/vendor/DEPENDENCIES-LICENSE.txt.br +0 -0
  61. package/ai.plurnk/dist/vendor/DEPENDENCIES-LICENSE.txt.gz +0 -0
  62. package/ai.plurnk/dist/vendor/MARKED-FOOTNOTE-LICENSE.txt +25 -0
  63. package/ai.plurnk/dist/vendor/MARKED-FOOTNOTE-LICENSE.txt.br +0 -0
  64. package/ai.plurnk/dist/vendor/MARKED-FOOTNOTE-LICENSE.txt.gz +0 -0
  65. package/ai.plurnk/dist/vendor/OTM-LICENSE.txt +21 -0
  66. package/ai.plurnk/dist/vendor/OTM-LICENSE.txt.br +0 -0
  67. package/ai.plurnk/dist/vendor/OTM-LICENSE.txt.gz +0 -0
  68. package/ai.plurnk/dist/vendor/PICO-LICENSE.txt +21 -0
  69. package/ai.plurnk/dist/vendor/PICO-LICENSE.txt.br +0 -0
  70. package/ai.plurnk/dist/vendor/PICO-LICENSE.txt.gz +0 -0
  71. package/ai.plurnk/documents.js +483 -0
  72. package/ai.plurnk/http.js +69 -0
  73. package/ai.plurnk/index-document.js +5 -0
  74. package/ai.plurnk/module.js +326 -0
  75. package/ai.plurnk/session.js +319 -0
  76. package/ai.plurnk/static-assets.js +40 -0
  77. package/package.json +92 -5
  78. package/plugin.json +13 -0
@@ -0,0 +1,38 @@
1
+ # {§web-host} {§web-configuration} @plurnk/plurnk-web — optional document host. Sign-in uses the shared daemon authority.
2
+ # 1 = claim / on the daemon's listener; 0 = no routes. Read at daemon startup.
3
+ PLURNK_WEB_ENABLED=1
4
+ # Initial page preference: 0 = explicit review; 1 = automatically accept proposals.
5
+ # A session page reached from another site, or on plain HTTP other than localhost, starts in explicit review.
6
+ # Read at startup; /yolo overrides this page only. Reload restores this setting.
7
+ # Questions and MCP input always require an answer. Daemon permission policy still applies.
8
+ PLURNK_WEB_YOLO=0
9
+ # Newest Unicode code points retained in the ephemeral live reasoning preview.
10
+ # Positive integer, read at startup. Full reasoning remains available on demand.
11
+ PLURNK_WEB_REASONING_PREVIEW_CHARS=4096
12
+ # Sign-in transactions, not authenticated session lifetimes. Each travels sealed in its
13
+ # browser's cookie. Seconds available to complete sign-in; positive integer. Read at startup.
14
+ PLURNK_WEB_LOGIN_TTL_SEC=600
15
+ # Workspace and session creation forms, not sign-in transactions or workspace lifetimes.
16
+ # Seconds a settled creation stays addressable, so a retry after a lost
17
+ # response converges to the same place; positive integer, read at startup.
18
+ PLURNK_WEB_CREATE_TTL_SEC=600
19
+ # Maximum retained in-flight and settled creations per module, workspaces and sessions together.
20
+ # Beyond it the oldest settled token yields; in-flight ones are kept. Positive integer, read at startup.
21
+ PLURNK_WEB_CREATE_TOKENS=128
22
+ # Maximum UTF-8 bytes of a creation, rename or session form body. Larger bodies answer 413.
23
+ # Positive integer, read at startup.
24
+ PLURNK_WEB_FORM_BYTES=16384
25
+ # Diagram compilation, not agent work. Positive integers, read at startup.
26
+ # Maximum parallel compiler threads. Threads start on demand and retire when idle.
27
+ PLURNK_WEB_DIAGRAM_THREADS=2
28
+ # Maximum admitted compilations, including queued work. Excess requests answer 429.
29
+ PLURNK_WEB_DIAGRAM_PENDING=16
30
+ # Milliseconds for queueing and compilation after source intake (1–2147483647).
31
+ # Expiry answers 408; the ceiling is Node's timer range, not a render policy.
32
+ PLURNK_WEB_DIAGRAM_TIMEOUT_MS=1000
33
+ # Maximum UTF-8 JSON request bytes. Excess input answers 413.
34
+ PLURNK_WEB_DIAGRAM_INPUT_BYTES=65536
35
+ # Maximum UTF-8 SVG response bytes. Compiler/output limits answer 422.
36
+ PLURNK_WEB_DIAGRAM_OUTPUT_BYTES=4000000
37
+ # Ondine's flowchart edge bound; other syntax limits remain the compiler's contract.
38
+ PLURNK_WEB_DIAGRAM_EDGES=5000
@@ -0,0 +1,193 @@
1
+ [
2
+ [
3
+ "/assets/index-B4w6iDKw.css",
4
+ "assets/index-B4w6iDKw.css",
5
+ "text/css",
6
+ {
7
+ "br": "assets/index-B4w6iDKw.css.br",
8
+ "gzip": "assets/index-B4w6iDKw.css.gz"
9
+ }
10
+ ],
11
+ [
12
+ "/assets/index-CGRGblLU.js",
13
+ "assets/index-CGRGblLU.js",
14
+ "text/javascript",
15
+ {
16
+ "br": "assets/index-CGRGblLU.js.br",
17
+ "gzip": "assets/index-CGRGblLU.js.gz"
18
+ }
19
+ ],
20
+ [
21
+ "/assets/math-renderer-CnlgS6FH.js",
22
+ "assets/math-renderer-CnlgS6FH.js",
23
+ "text/javascript",
24
+ {
25
+ "br": "assets/math-renderer-CnlgS6FH.js.br",
26
+ "gzip": "assets/math-renderer-CnlgS6FH.js.gz"
27
+ }
28
+ ],
29
+ [
30
+ "/assets/math-renderer-CZ_XtOMq.css",
31
+ "assets/math-renderer-CZ_XtOMq.css",
32
+ "text/css",
33
+ {
34
+ "br": "assets/math-renderer-CZ_XtOMq.css.br",
35
+ "gzip": "assets/math-renderer-CZ_XtOMq.css.gz"
36
+ }
37
+ ],
38
+ [
39
+ "/assets/overview-DEhW9HUG.js",
40
+ "assets/overview-DEhW9HUG.js",
41
+ "text/javascript",
42
+ {
43
+ "br": "assets/overview-DEhW9HUG.js.br",
44
+ "gzip": "assets/overview-DEhW9HUG.js.gz"
45
+ }
46
+ ],
47
+ [
48
+ "/assets/preload-helper-BjS5LQBu.js",
49
+ "assets/preload-helper-BjS5LQBu.js",
50
+ "text/javascript",
51
+ {
52
+ "br": "assets/preload-helper-BjS5LQBu.js.br",
53
+ "gzip": "assets/preload-helper-BjS5LQBu.js.gz"
54
+ }
55
+ ],
56
+ [
57
+ "/assets/session-agent-DwY8S586.js",
58
+ "assets/session-agent-DwY8S586.js",
59
+ "text/javascript",
60
+ {
61
+ "br": "assets/session-agent-DwY8S586.js.br",
62
+ "gzip": "assets/session-agent-DwY8S586.js.gz"
63
+ }
64
+ ],
65
+ [
66
+ "/assets/session-BcdGbgAa.js",
67
+ "assets/session-BcdGbgAa.js",
68
+ "text/javascript",
69
+ {
70
+ "br": "assets/session-BcdGbgAa.js.br",
71
+ "gzip": "assets/session-BcdGbgAa.js.gz"
72
+ }
73
+ ],
74
+ [
75
+ "/assets/src-Du01yeMu.js",
76
+ "assets/src-Du01yeMu.js",
77
+ "text/javascript",
78
+ {
79
+ "br": "assets/src-Du01yeMu.js.br"
80
+ }
81
+ ],
82
+ [
83
+ "/assets/src-J2qT9xpv.js",
84
+ "assets/src-J2qT9xpv.js",
85
+ "text/javascript",
86
+ {
87
+ "br": "assets/src-J2qT9xpv.js.br",
88
+ "gzip": "assets/src-J2qT9xpv.js.gz"
89
+ }
90
+ ],
91
+ [
92
+ "/assets/Temml-CCjoacWt.woff2",
93
+ "assets/Temml-CCjoacWt.woff2",
94
+ "font/woff2",
95
+ {}
96
+ ],
97
+ [
98
+ "/icons/APACHE-2.0.txt",
99
+ "icons/APACHE-2.0.txt",
100
+ "text/plain",
101
+ {
102
+ "br": "icons/APACHE-2.0.txt.br",
103
+ "gzip": "icons/APACHE-2.0.txt.gz"
104
+ }
105
+ ],
106
+ [
107
+ "/icons/NOTO-NOTICE.txt",
108
+ "icons/NOTO-NOTICE.txt",
109
+ "text/plain",
110
+ {
111
+ "br": "icons/NOTO-NOTICE.txt.br",
112
+ "gzip": "icons/NOTO-NOTICE.txt.gz"
113
+ }
114
+ ],
115
+ [
116
+ "/icons/plurnk-maskable.svg",
117
+ "icons/plurnk-maskable.svg",
118
+ "image/svg+xml",
119
+ {
120
+ "br": "icons/plurnk-maskable.svg.br",
121
+ "gzip": "icons/plurnk-maskable.svg.gz"
122
+ }
123
+ ],
124
+ [
125
+ "/icons/plurnk.svg",
126
+ "icons/plurnk.svg",
127
+ "image/svg+xml",
128
+ {
129
+ "br": "icons/plurnk.svg.br",
130
+ "gzip": "icons/plurnk.svg.gz"
131
+ }
132
+ ],
133
+ [
134
+ "/index.html",
135
+ "index.html",
136
+ "text/html",
137
+ {}
138
+ ],
139
+ [
140
+ "/manifest.webmanifest",
141
+ "manifest.webmanifest",
142
+ "application/manifest+json",
143
+ {
144
+ "br": "manifest.webmanifest.br",
145
+ "gzip": "manifest.webmanifest.gz"
146
+ }
147
+ ],
148
+ [
149
+ "/vendor/CFWORKER-JSON-SCHEMA-LICENSE.txt",
150
+ "vendor/CFWORKER-JSON-SCHEMA-LICENSE.txt",
151
+ "text/plain",
152
+ {
153
+ "br": "vendor/CFWORKER-JSON-SCHEMA-LICENSE.txt.br",
154
+ "gzip": "vendor/CFWORKER-JSON-SCHEMA-LICENSE.txt.gz"
155
+ }
156
+ ],
157
+ [
158
+ "/vendor/DEPENDENCIES-LICENSE.txt",
159
+ "vendor/DEPENDENCIES-LICENSE.txt",
160
+ "text/plain",
161
+ {
162
+ "br": "vendor/DEPENDENCIES-LICENSE.txt.br",
163
+ "gzip": "vendor/DEPENDENCIES-LICENSE.txt.gz"
164
+ }
165
+ ],
166
+ [
167
+ "/vendor/MARKED-FOOTNOTE-LICENSE.txt",
168
+ "vendor/MARKED-FOOTNOTE-LICENSE.txt",
169
+ "text/plain",
170
+ {
171
+ "br": "vendor/MARKED-FOOTNOTE-LICENSE.txt.br",
172
+ "gzip": "vendor/MARKED-FOOTNOTE-LICENSE.txt.gz"
173
+ }
174
+ ],
175
+ [
176
+ "/vendor/OTM-LICENSE.txt",
177
+ "vendor/OTM-LICENSE.txt",
178
+ "text/plain",
179
+ {
180
+ "br": "vendor/OTM-LICENSE.txt.br",
181
+ "gzip": "vendor/OTM-LICENSE.txt.gz"
182
+ }
183
+ ],
184
+ [
185
+ "/vendor/PICO-LICENSE.txt",
186
+ "vendor/PICO-LICENSE.txt",
187
+ "text/plain",
188
+ {
189
+ "br": "vendor/PICO-LICENSE.txt.br",
190
+ "gzip": "vendor/PICO-LICENSE.txt.gz"
191
+ }
192
+ ]
193
+ ]
@@ -0,0 +1,65 @@
1
+ import { directoryPath } from "./directory-addresses.js";
2
+ import { RequestError } from "./http.js";
3
+
4
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
5
+
6
+ /** @typedef {{ kind: "flight", creation: Promise<string>, expires: number } | { kind: "done", path: string, expires: number } | { kind: "failed", error: Error, expires: number }} CreationEntry */
7
+
8
+ /** {§web-workspace-creation} Owns form-token convergence at the module's HTTP boundary;
9
+ * the daemon's creation contract has no idempotency key of its own.
10
+ */
11
+ export default class Creations {
12
+ #ttl;
13
+ #tokens;
14
+ /** @type {Map<string, CreationEntry>} */
15
+ #entries = new Map();
16
+
17
+ /** @param {{ ttl: number, tokens: number }} limits */
18
+ constructor({ ttl, tokens }) {
19
+ this.#ttl = ttl;
20
+ this.#tokens = tokens;
21
+ }
22
+
23
+ /** @param {number} now */
24
+ #prune(now) {
25
+ for (const [key, entry] of this.#entries) if (entry.kind !== "flight" && entry.expires <= now) this.#entries.delete(key);
26
+ }
27
+
28
+ /**
29
+ * @param {string} token The form's hidden token.
30
+ * @param {() => Promise<string | { workspaceName: string, projectRoot: string | null }>} create
31
+ * @returns {Promise<string>} The canonical address of the created workspace.
32
+ */
33
+ async submit(token, create) {
34
+ if (!UUID_PATTERN.test(token)) throw new RequestError(400, "Invalid form token.");
35
+ const now = Date.now();
36
+ this.#prune(now);
37
+ const existing = this.#entries.get(token);
38
+ if (existing !== undefined) {
39
+ if (existing.kind === "flight") return existing.creation;
40
+ if (existing.kind === "done") return existing.path;
41
+ throw existing.error;
42
+ }
43
+
44
+ while (this.#entries.size >= this.#tokens) {
45
+ const completed = [...this.#entries].find(([, entry]) => entry.kind !== "flight");
46
+ if (completed === undefined) break;
47
+ this.#entries.delete(completed[0]);
48
+ }
49
+
50
+ const creation = (async () => {
51
+ const result = await create();
52
+ if (typeof result === "string") return result;
53
+ if (result.projectRoot !== null) throw new Error("The daemon created a workspace with a project folder from a folderless request.");
54
+ const path = directoryPath({ kind: "workspace", workspace: result.workspaceName });
55
+ if (path === null) throw new Error("The daemon returned a workspace name without a directory address.");
56
+ return path;
57
+ })();
58
+ this.#entries.set(token, { kind: "flight", creation, expires: now + this.#ttl * 1000 });
59
+ creation.then(
60
+ (path) => { this.#entries.set(token, { kind: "done", path, expires: Date.now() + this.#ttl * 1000 }); },
61
+ (error) => { this.#entries.set(token, { kind: "failed", error, expires: Date.now() + this.#ttl * 1000 }); },
62
+ );
63
+ return creation;
64
+ }
65
+ }
@@ -0,0 +1,15 @@
1
+ import { render, RenderError } from "ondine/compiler";
2
+
3
+ /** {§web-diagram-service} This synchronous compiler runs only in a Piscina thread.
4
+ * @param {{ source: string, limit: number, outputLimit: number, maxEdges: number }} input
5
+ */
6
+ export default function compile({ source, ...limits }) {
7
+ try {
8
+ const { svg } = render(source, { ...limits, autoDirection: false, responsive: false });
9
+ if (Buffer.byteLength(svg) > limits.outputLimit) return { error: "Diagram output exceeds the configured limit." };
10
+ return { svg };
11
+ } catch (error) {
12
+ if (!(error instanceof RenderError)) throw error;
13
+ return { error: error.message };
14
+ }
15
+ }
@@ -0,0 +1,130 @@
1
+ import { ConfigurationError, Knob } from "@plurnk/plurnk-meta";
2
+ import { Piscina } from "piscina";
3
+ import { MIMEType } from "node:util";
4
+ import { privateHeaders, RequestError } from "./http.js";
5
+
6
+ // Node timers use a signed 32-bit millisecond delay; overflow becomes a 1 ms timer.
7
+ const MAX_TIMER_DELAY = 2 ** 31 - 1;
8
+
9
+ /** {§web-diagram-service} Owns bounded presentation work, never daemon agent work. */
10
+ export default class Diagrams {
11
+ #threads;
12
+ #pending;
13
+ #timeout;
14
+ #input;
15
+ #output;
16
+ #edges;
17
+ /** @type {Piscina<Parameters<typeof import("./diagram-compiler.js").default>[0], ReturnType<typeof import("./diagram-compiler.js").default>> | undefined} */
18
+ #pool;
19
+ #active = 0;
20
+ #stop = new AbortController();
21
+
22
+ /**
23
+ * @param {{ threads: number, pending: number, timeout: number, input: number, output: number, edges: number }} [limits]
24
+ */
25
+ constructor(limits) {
26
+ this.#threads = limits?.threads ?? Knob.integer("PLURNK_WEB_DIAGRAM_THREADS", 1);
27
+ this.#pending = limits?.pending ?? Knob.integer("PLURNK_WEB_DIAGRAM_PENDING", 1);
28
+ this.#timeout = limits?.timeout ?? Knob.integer("PLURNK_WEB_DIAGRAM_TIMEOUT_MS", 1);
29
+ this.#input = limits?.input ?? Knob.integer("PLURNK_WEB_DIAGRAM_INPUT_BYTES", 1);
30
+ this.#output = limits?.output ?? Knob.integer("PLURNK_WEB_DIAGRAM_OUTPUT_BYTES", 1);
31
+ this.#edges = limits?.edges ?? Knob.integer("PLURNK_WEB_DIAGRAM_EDGES", 1);
32
+ if (this.#timeout > MAX_TIMER_DELAY) {
33
+ throw new ConfigurationError("PLURNK_WEB_DIAGRAM_TIMEOUT_MS", `must not exceed ${MAX_TIMER_DELAY} milliseconds`);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * @param {<T>(read: () => T) => T | null} contain
39
+ * @returns {Diagrams | null}
40
+ */
41
+ static create(contain) {
42
+ const threads = contain(() => Knob.integer("PLURNK_WEB_DIAGRAM_THREADS", 1));
43
+ const pending = contain(() => Knob.integer("PLURNK_WEB_DIAGRAM_PENDING", 1));
44
+ const timeout = contain(() => {
45
+ const ms = Knob.integer("PLURNK_WEB_DIAGRAM_TIMEOUT_MS", 1);
46
+ if (ms > MAX_TIMER_DELAY) {
47
+ throw new ConfigurationError("PLURNK_WEB_DIAGRAM_TIMEOUT_MS", `must not exceed ${MAX_TIMER_DELAY} milliseconds`);
48
+ }
49
+ return ms;
50
+ });
51
+ const input = contain(() => Knob.integer("PLURNK_WEB_DIAGRAM_INPUT_BYTES", 1));
52
+ const output = contain(() => Knob.integer("PLURNK_WEB_DIAGRAM_OUTPUT_BYTES", 1));
53
+ const edges = contain(() => Knob.integer("PLURNK_WEB_DIAGRAM_EDGES", 1));
54
+ if (threads === null || pending === null || timeout === null || input === null || output === null || edges === null) {
55
+ return null;
56
+ }
57
+ return new Diagrams({ threads, pending, timeout, input, output, edges });
58
+ }
59
+
60
+ /** @param {string} source @param {AbortSignal} signal */
61
+ async #compile(source, signal) {
62
+ if (this.#stop.signal.aborted) throw new RequestError(503, "Diagram service is stopping.");
63
+ if (this.#active >= this.#pending) throw new RequestError(429, "Diagram service is busy.");
64
+ const deadline = AbortSignal.timeout(this.#timeout);
65
+ const combined = AbortSignal.any([signal, deadline, this.#stop.signal]);
66
+ this.#pool ??= new Piscina({ filename: new URL("./diagram-compiler.js", import.meta.url).href,
67
+ minThreads: 0, maxThreads: this.#threads, maxQueue: this.#pending });
68
+ this.#active++;
69
+ try {
70
+ const result = await this.#pool.run({ source, limit: this.#input, outputLimit: this.#output, maxEdges: this.#edges }, { signal: combined });
71
+ if (result.error !== undefined) throw new RequestError(422, result.error);
72
+ return Buffer.from(result.svg);
73
+ } catch (cause) {
74
+ if (this.#stop.signal.aborted) throw new RequestError(503, "Diagram service is stopping.", { cause });
75
+ if (deadline.aborted) throw new RequestError(408, "Diagram rendering timed out.", { cause });
76
+ throw cause;
77
+ } finally { this.#active--; }
78
+ }
79
+
80
+ /** @param {import("node:http").IncomingMessage} req @param {import("node:http").ServerResponse} res */
81
+ async serve(req, res) {
82
+ if (req.method !== "POST") {
83
+ res.setHeader("allow", "POST");
84
+ throw new RequestError(405, "Use POST for diagram compilation.");
85
+ }
86
+ // JSON is not CORS-safelisted: cross-origin forms cannot invoke the compiler.
87
+ let type;
88
+ try { type = new MIMEType(req.headers["content-type"] ?? ""); }
89
+ catch (cause) { throw new RequestError(415, "Use UTF-8 application/json.", { cause }); }
90
+ if (type.essence !== "application/json" || (type.params.has("charset") && type.params.get("charset")?.toLowerCase() !== "utf-8")) {
91
+ throw new RequestError(415, "Use UTF-8 application/json.");
92
+ }
93
+ if (req.headers["sec-fetch-site"] !== undefined && req.headers["sec-fetch-site"] !== "same-origin") {
94
+ throw new RequestError(403, "Same-origin request required.");
95
+ }
96
+ const disconnected = new AbortController();
97
+ const depart = () => disconnected.abort();
98
+ res.once("close", depart);
99
+ try {
100
+ let size = 0;
101
+ const chunks = [];
102
+ for await (const chunk of req.iterator({ destroyOnReturn: false })) {
103
+ size += chunk.byteLength;
104
+ if (size > this.#input) throw new RequestError(413, "Diagram source exceeds the configured limit.");
105
+ chunks.push(chunk);
106
+ }
107
+ let input;
108
+ try { input = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks))); }
109
+ catch (cause) { throw new RequestError(400, "Invalid diagram JSON.", { cause }); }
110
+ if (input === null || typeof input !== "object" || Array.isArray(input)
111
+ || typeof input.source !== "string" || Object.keys(input).length !== 1) {
112
+ throw new RequestError(400, "Expected a source string.");
113
+ }
114
+ const svg = await this.#compile(input.source, disconnected.signal);
115
+ res.writeHead(200, { ...privateHeaders, "content-type": "image/svg+xml", "content-length": svg.byteLength,
116
+ "content-security-policy": "sandbox; default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" });
117
+ res.end(svg);
118
+ } catch (error) {
119
+ if (!(disconnected.signal.aborted && error instanceof Error && (error.name === "AbortError"
120
+ || ("code" in error && ["ECONNRESET", "ERR_STREAM_PREMATURE_CLOSE"].includes(String(error.code)))))) throw error;
121
+ } finally {
122
+ res.off("close", depart);
123
+ }
124
+ }
125
+
126
+ async close() {
127
+ this.#stop.abort();
128
+ await this.#pool?.destroy();
129
+ }
130
+ }
@@ -0,0 +1,35 @@
1
+ import indexDocument from "./index-document.js";
2
+
3
+ /** @typedef {{ kind: "daemon" } | { kind: "workspace", workspace: string } | { kind: "session", workspace: string, session: string }} Directory */
4
+
5
+ /** @param {Directory} directory @returns {string | null} */
6
+ export function directoryPath(directory) {
7
+ // {§web-directory-addresses} No normalization or substitute identity for legacy names.
8
+ if (directory.kind === "daemon") return "/~/";
9
+ const { workspace } = directory;
10
+ if (!workspace || !workspace.isWellFormed() || workspace.split("/").some((part) => part === "." || part === "..")) return null;
11
+ const path = `/~/${workspace.split("/").map(encodeURIComponent).join("/")}`;
12
+ if (directory.kind === "workspace") return `${path}/`;
13
+ if (!directory.session || !directory.session.isWellFormed()) return null;
14
+ return `${path}@${encodeURIComponent(directory.session)}/`;
15
+ }
16
+
17
+ /** @param {string} target Origin-relative request target. @returns {Directory | null} */
18
+ export function parseDirectory(target) {
19
+ const pathname = indexDocument(target.split(/[?#]/u, 1)[0]);
20
+ // Split syntax before decoding; an encoded @ or slash is part of the name.
21
+ const match = /^\/(?:~|%7[eE])\/(?:(?<workspace>[^@]+)(?:@(?<session>[^/@]+))?\/)?index\.html$/u.exec(pathname);
22
+ if (!match) return null;
23
+ const { workspace, session } = match.groups ?? {};
24
+ if (workspace === undefined) return { kind: "daemon" };
25
+ try {
26
+ /** @type {Directory} */
27
+ const directory = session === undefined
28
+ ? { kind: "workspace", workspace: decodeURIComponent(workspace) }
29
+ : { kind: "session", workspace: decodeURIComponent(workspace), session: decodeURIComponent(session) };
30
+ return directoryPath(directory) === null ? null : directory;
31
+ } catch (error) {
32
+ if (error instanceof URIError) return null;
33
+ throw error;
34
+ }
35
+ }