@cs-object/cli 0.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.
@@ -0,0 +1,732 @@
1
+ import {
2
+ UsageError
3
+ } from "./chunk-OYQDZWT7.js";
4
+ import {
5
+ LayoutInspectionError,
6
+ buildPreparedHtml,
7
+ captureAssets,
8
+ jsonBytes,
9
+ renderPreparedPdf,
10
+ sha256
11
+ } from "./chunk-7XCJ6AF3.js";
12
+ import {
13
+ executeAndVerify,
14
+ parseStrictJson
15
+ } from "./chunk-KYWIVB2V.js";
16
+ import "./chunk-MOM6Q2VG.js";
17
+
18
+ // src/dev-server.ts
19
+ import {
20
+ createServer
21
+ } from "http";
22
+ import { resolve } from "path";
23
+ import {
24
+ PythonIdentifierSchema
25
+ } from "@cs-object/core";
26
+
27
+ // src/dev-errors.ts
28
+ var HttpError = class extends Error {
29
+ constructor(status, message, diagnostics = []) {
30
+ super(message);
31
+ this.status = status;
32
+ this.diagnostics = diagnostics;
33
+ }
34
+ };
35
+
36
+ // src/dev-runtime.ts
37
+ import { spawnSync } from "child_process";
38
+ import { randomUUID } from "crypto";
39
+ import {
40
+ CalculationDefinitionResponseSchema
41
+ } from "@cs-object/core";
42
+ import {
43
+ prepareExecutionDocument,
44
+ DocumentPreparationError
45
+ } from "@cs-object/react";
46
+ var defaults = {
47
+ maximumRuns: 10,
48
+ maximumBytes: 128 * 1024 * 1024,
49
+ maximumAgeMs: 30 * 6e4
50
+ };
51
+ function describe(options) {
52
+ const env = { ...process.env };
53
+ for (const key of ["PYTHONPATH", "PYTHONHOME", "NODE_PATH"])
54
+ Reflect.deleteProperty(env, key);
55
+ const result = spawnSync(
56
+ process.env.PYTHON ?? "python3",
57
+ [
58
+ "-I",
59
+ "-m",
60
+ "cso_python",
61
+ "describe",
62
+ options.sourcePath,
63
+ "--function",
64
+ options.functionName
65
+ ],
66
+ {
67
+ env,
68
+ timeout: 3e4,
69
+ killSignal: "SIGKILL",
70
+ maxBuffer: 4 * 1024 * 1024
71
+ }
72
+ );
73
+ if (result.error || result.signal)
74
+ throw new HttpError(
75
+ 422,
76
+ result.error?.message ?? `Python terminated by ${result.signal}`
77
+ );
78
+ const response = CalculationDefinitionResponseSchema.parse(
79
+ parseStrictJson(
80
+ new TextDecoder("utf-8", { fatal: true }).decode(result.stdout)
81
+ )
82
+ );
83
+ if (!response.ok)
84
+ throw new HttpError(
85
+ 422,
86
+ "Cannot describe the calculation",
87
+ response.diagnostics
88
+ );
89
+ if (result.status !== 0)
90
+ throw new HttpError(422, "Python describe did not exit successfully");
91
+ return response.definition;
92
+ }
93
+ function createDevRuntime(options, limits = defaults) {
94
+ describe(options);
95
+ const runs = /* @__PURE__ */ new Map();
96
+ let activePdf;
97
+ let closed = false;
98
+ const retainedBytes = () => [...runs.values()].reduce((sum, run) => sum + run.bytes, 0);
99
+ function prune(extraBytes = 0, extraRuns = 0, keep) {
100
+ if (extraBytes + (keep?.bytes ?? 0) > limits.maximumBytes || extraRuns > limits.maximumRuns)
101
+ throw new HttpError(
102
+ 413,
103
+ "The retained report exceeds the local session limit"
104
+ );
105
+ for (const [id, run] of runs)
106
+ if (run.expires <= Date.now() && run !== activePdf?.run && run !== keep)
107
+ runs.delete(id);
108
+ for (const [id, run] of runs) {
109
+ if (retainedBytes() + extraBytes <= limits.maximumBytes && runs.size + extraRuns <= limits.maximumRuns)
110
+ break;
111
+ if (run !== activePdf?.run && run !== keep) runs.delete(id);
112
+ }
113
+ if (retainedBytes() + extraBytes > limits.maximumBytes || runs.size + extraRuns > limits.maximumRuns)
114
+ throw new HttpError(
115
+ 413,
116
+ "The retained report exceeds the local session limit"
117
+ );
118
+ }
119
+ async function renderPdf(run) {
120
+ if (run.pdf.kind === "ready") return run.pdf.bytes;
121
+ if (run.pdf.kind === "rendering") return run.pdf.promise;
122
+ if (run.pdf.kind === "failed") throw run.pdf.error;
123
+ if (activePdf)
124
+ throw new HttpError(429, "Another PDF is rendering; retry shortly");
125
+ const abort = new AbortController();
126
+ const timer = setTimeout(
127
+ () => abort.abort(new Error("PDF rendering timed out")),
128
+ 9e4
129
+ );
130
+ const promise = renderPreparedPdf(
131
+ run.document,
132
+ run.html.toString("utf8"),
133
+ abort.signal
134
+ ).then((rendered) => {
135
+ const addedBytes = rendered.pdf.length + jsonBytes(rendered.presentation).length;
136
+ prune(addedBytes, 0, run);
137
+ run.bytes += addedBytes;
138
+ run.pdf = {
139
+ kind: "ready",
140
+ bytes: rendered.pdf,
141
+ presentation: rendered.presentation
142
+ };
143
+ run.checks.rendering = { status: "passed" };
144
+ return rendered.pdf;
145
+ }).catch((error) => {
146
+ if (error instanceof HttpError && error.status === 413) {
147
+ run.pdf = { kind: "pending" };
148
+ throw error;
149
+ }
150
+ const message = error instanceof Error ? error.message : "PDF rendering failed";
151
+ const failure = new HttpError(
152
+ 422,
153
+ message,
154
+ error instanceof LayoutInspectionError ? error.diagnostics : []
155
+ );
156
+ run.pdf = { kind: "failed", error: failure };
157
+ run.checks.rendering = { status: "failed" };
158
+ throw failure;
159
+ }).finally(() => {
160
+ clearTimeout(timer);
161
+ activePdf = void 0;
162
+ });
163
+ run.pdf = { kind: "rendering", promise };
164
+ activePdf = { run, abort, promise };
165
+ return promise;
166
+ }
167
+ function assertOpen() {
168
+ if (closed) throw new HttpError(503, "Server is shutting down");
169
+ }
170
+ function execute({ definition, inputs }) {
171
+ assertOpen();
172
+ const verified = executeAndVerify(
173
+ { ...options, command: "verify", inputs },
174
+ 3e4
175
+ );
176
+ if (verified.kind === "failed")
177
+ throw new HttpError(
178
+ 422,
179
+ "Calculation failed verification",
180
+ verified.outcome.report.diagnostics
181
+ );
182
+ const { execution } = verified.capture;
183
+ if (execution.entry.moduleId !== definition.entryModuleId || execution.entry.sourceHash !== definition.entrySourceHash || execution.sourceClosureHash !== definition.sourceClosureHash || execution.entry.function !== definition.function)
184
+ throw new HttpError(
185
+ 409,
186
+ "Calculation source changed during the request; calculate again"
187
+ );
188
+ if (!execution.authoring)
189
+ throw new HttpError(422, "Public output evidence is required");
190
+ const outputs = Object.fromEntries(
191
+ execution.authoring.outputs.filter(
192
+ (output) => output.invocationId === execution.entry.invocationId
193
+ ).map((output) => [output.name, output.value])
194
+ );
195
+ return { verified, execution, outputs };
196
+ }
197
+ function lookup(id) {
198
+ assertOpen();
199
+ prune();
200
+ const run = runs.get(id);
201
+ if (!run)
202
+ throw new HttpError(
203
+ 404,
204
+ "Run expired or does not exist; calculate again"
205
+ );
206
+ return run;
207
+ }
208
+ function summary(run) {
209
+ return structuredClone({
210
+ id: run.id,
211
+ outputs: run.outputs,
212
+ checks: run.checks
213
+ });
214
+ }
215
+ function createRun(invocation) {
216
+ const { verified, execution, outputs } = execute(invocation);
217
+ try {
218
+ const document = prepareExecutionDocument({
219
+ execution,
220
+ assets: captureAssets(options.sourcePath, execution)
221
+ });
222
+ const independent = verified.report.checks.independentReferenceAgreement.status;
223
+ const notice = `Source-to-document consistency passed. Independent numerical reference: ${independent === "passed" ? "passed" : "pending, no matching reference"}. Human visual inspection is pending. These checks do not establish engineering approval.`;
224
+ const html = Buffer.from(buildPreparedHtml(document, notice));
225
+ const bytes = html.length + jsonBytes(document).length + verified.capture.executionBytes.length + (verified.capture.referenceBytes?.length ?? 0) + jsonBytes(verified.capture.verification).length;
226
+ prune(bytes, 1);
227
+ const run = {
228
+ id: randomUUID(),
229
+ outputs,
230
+ checks: {
231
+ ...verified.report.checks,
232
+ documentContent: { status: "passed" },
233
+ visualInspection: { status: "pending" }
234
+ },
235
+ capture: verified.capture,
236
+ document,
237
+ html,
238
+ bytes,
239
+ expires: Date.now() + limits.maximumAgeMs,
240
+ pdf: { kind: "pending" }
241
+ };
242
+ runs.set(run.id, run);
243
+ return summary(run);
244
+ } catch (error) {
245
+ if (error instanceof DocumentPreparationError)
246
+ throw new HttpError(422, error.message, error.diagnostics);
247
+ throw error;
248
+ }
249
+ }
250
+ const expiry = setInterval(
251
+ () => prune(),
252
+ Math.min(limits.maximumAgeMs, 6e4)
253
+ );
254
+ expiry.unref();
255
+ return {
256
+ definition() {
257
+ assertOpen();
258
+ return describe(options);
259
+ },
260
+ calculate(invocation) {
261
+ return execute(invocation).outputs;
262
+ },
263
+ createRun,
264
+ summary(id) {
265
+ return summary(lookup(id));
266
+ },
267
+ html(id) {
268
+ return lookup(id).html;
269
+ },
270
+ async pdf(id) {
271
+ const bytes = await renderPdf(lookup(id));
272
+ return { bytes, sha256: sha256(bytes) };
273
+ },
274
+ evidence(id) {
275
+ const run = lookup(id);
276
+ return jsonBytes({
277
+ runId: run.id,
278
+ execution: parseStrictJson(run.capture.executionBytes.toString("utf8")),
279
+ document: run.document,
280
+ verification: run.capture.verification,
281
+ reference: run.capture.referenceBytes ? {
282
+ sha256: sha256(run.capture.referenceBytes),
283
+ content: parseStrictJson(
284
+ run.capture.referenceBytes.toString("utf8")
285
+ )
286
+ } : null,
287
+ checks: run.checks,
288
+ artifacts: {
289
+ html: { sha256: sha256(run.html) },
290
+ pdf: run.pdf.kind === "ready" ? { sha256: sha256(run.pdf.bytes) } : null
291
+ },
292
+ presentation: run.pdf.kind === "ready" ? run.pdf.presentation : []
293
+ });
294
+ },
295
+ async close() {
296
+ closed = true;
297
+ clearInterval(expiry);
298
+ const rendering = activePdf;
299
+ rendering?.abort.abort(new Error("Server is shutting down"));
300
+ if (rendering) await Promise.allSettled([rendering.promise]);
301
+ runs.clear();
302
+ }
303
+ };
304
+ }
305
+
306
+ // src/dev-ui.ts
307
+ var devPage = {
308
+ html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Calculation report</title><style>
309
+ *{box-sizing:border-box}body{margin:0;background:#f5f6f8;color:#17212b;font:16px system-ui,sans-serif}main{max-width:1500px;margin:auto;padding:28px}h1{font-size:28px;margin-top:0}.workspace{display:grid;grid-template-columns:minmax(260px,340px) minmax(0,1fr);gap:24px}.controls,section{background:white;border:1px solid #d9dee5;border-radius:8px;padding:22px}label{display:block;margin:18px 0 6px}label small{display:block;color:#536172}input{width:100%;padding:10px;border:1px solid #a8b3c0;border-radius:4px;font:inherit}button,.download{display:inline-block;margin:18px 8px 0 0;padding:10px 14px;border:0;border-radius:4px;background:#164d87;color:white;font:inherit;cursor:pointer;text-decoration:none}button:disabled{opacity:.55;cursor:wait}a[hidden],iframe[hidden]{display:none}#status{line-height:1.5;min-height:24px}#checks{padding-left:20px;font-size:14px;line-height:1.6}#outputs{display:grid;grid-template-columns:1fr auto;gap:6px}#outputs dt,#outputs dd{margin:0}#report{width:100%;height:1000px;border:1px solid #d9dee5;background:white}pre{overflow:auto;padding:14px;background:#edf1f6;border-radius:4px;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere}h2{font-size:19px}@media(max-width:900px){main{padding:16px}.workspace{grid-template-columns:1fr}#report{height:850px}}:focus-visible{outline:3px solid #d99726;outline-offset:2px}
310
+ </style></head><body><main><h1>Calculation report</h1><div class="workspace"><div class="controls"><form id="inputs"><div id="fields"></div><button id="calculate" type="submit" disabled>Calculate</button></form><p id="status" role="status" aria-live="polite">Loading calculation\u2026</p><p>Reload this page after changing the calculation input definitions.</p><h2>Outputs</h2><dl id="outputs"></dl><ul id="checks"></ul><a id="pdf" class="download" hidden>Download PDF</a><a id="evidence" hidden>Download evidence</a><h2>Calculate with curl</h2><pre id="curl"></pre></div><section aria-label="Calculation report preview"><iframe id="report" title="Calculation report" sandbox="" hidden></iframe></section></div></main><script src="/app.js"></script></body></html>`,
311
+ script: String.raw`
312
+ const form = document.querySelector('#inputs');
313
+ const fields = document.querySelector('#fields');
314
+ const button = document.querySelector('#calculate');
315
+ const status = document.querySelector('#status');
316
+ const outputs = document.querySelector('#outputs');
317
+ const checks = document.querySelector('#checks');
318
+ const report = document.querySelector('#report');
319
+ const pdf = document.querySelector('#pdf');
320
+ const evidence = document.querySelector('#evidence');
321
+ let revision = 0;
322
+ let currentRun;
323
+ let definition;
324
+ const numberText = value => Object.is(value, -0) ? '-0.0' : Number.isInteger(value) && !Number.isSafeInteger(value) ? value.toExponential() : JSON.stringify(value);
325
+ function inputJson() {
326
+ const entries = Array.from(fields.querySelectorAll('input'), input => {
327
+ if (input.value.trim() === '' || !Number.isFinite(Number(input.value))) throw new Error('Enter a finite value for ' + input.name);
328
+ return JSON.stringify(input.name) + ':' + numberText(Number(input.value));
329
+ });
330
+ return '{"inputs":{' + entries.join(',') + '}}';
331
+ }
332
+ function updateCurl() {
333
+ try {
334
+ const body = inputJson().replaceAll("'", "'\\''");
335
+ document.querySelector('#curl').textContent = 'curl -sS --fail-with-body ' + location.origin + '/api/calculate -H \'Content-Type: application/json\' -d \'' + body + '\'';
336
+ } catch { document.querySelector('#curl').textContent = 'Enter input values to show the request.'; }
337
+ }
338
+ function invalidate() {
339
+ revision += 1; currentRun = undefined;
340
+ report.hidden = true; report.removeAttribute('src');
341
+ pdf.hidden = true; pdf.removeAttribute('href');
342
+ evidence.hidden = true; evidence.removeAttribute('href');
343
+ outputs.replaceChildren(); checks.replaceChildren();
344
+ status.textContent = 'Inputs changed. Calculate again to update the report.';
345
+ updateCurl();
346
+ }
347
+ function showChecks(values) {
348
+ checks.replaceChildren();
349
+ const labels = [
350
+ ['sourceToDocumentConsistency', 'Source-to-document consistency'],
351
+ ['independentReferenceAgreement', 'Independent reference agreement'],
352
+ ['rendering', 'PDF layout'],
353
+ ['visualInspection', 'Human visual inspection'],
354
+ ];
355
+ for (const [key, label] of labels) {
356
+ const item = document.createElement('li');
357
+ const state = values[key].status;
358
+ item.textContent = label + ': ' + (state === 'not_applicable' ? key === 'independentReferenceAgreement' ? 'pending, no matching reference' : 'pending' : state);
359
+ checks.append(item);
360
+ }
361
+ }
362
+ async function readJson(response) {
363
+ const body = await response.json();
364
+ if (!response.ok) throw new Error([body.error, ...(body.diagnostics ?? []).map(item => item.message)].join('\n'));
365
+ return body;
366
+ }
367
+ form.addEventListener('input', invalidate);
368
+ form.addEventListener('submit', async event => {
369
+ event.preventDefault();
370
+ invalidate();
371
+ const started = revision;
372
+ button.disabled = true;
373
+ status.textContent = 'Calculating…';
374
+ try {
375
+ const run = await readJson(await fetch('/api/runs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: inputJson() }));
376
+ if (started !== revision) return;
377
+ currentRun = run;
378
+ for (const [name, value] of Object.entries(run.outputs)) {
379
+ const metadata = definition.outputs.find(item => item.name === name);
380
+ const term = document.createElement('dt'); term.textContent = name;
381
+ const result = document.createElement('dd'); result.textContent = numberText(value) + (metadata?.unit ? ' ' + metadata.unit : '');
382
+ outputs.append(term, result);
383
+ }
384
+ showChecks(run.checks);
385
+ report.src = run.html; report.hidden = false;
386
+ pdf.href = run.pdf; pdf.hidden = false;
387
+ evidence.href = run.evidence; evidence.hidden = true;
388
+ status.textContent = 'Report ready. Review its assumptions and results.';
389
+ } catch (error) { if (started === revision) status.textContent = error.message; }
390
+ finally { button.disabled = false; }
391
+ });
392
+ pdf.addEventListener('click', async event => {
393
+ event.preventDefault();
394
+ const run = currentRun;
395
+ if (!run) return;
396
+ const started = revision;
397
+ pdf.hidden = true;
398
+ status.textContent = 'Preparing PDF…';
399
+ try {
400
+ const response = await fetch(run.pdf);
401
+ if (!response.ok) await readJson(response);
402
+ const blob = await response.blob();
403
+ if (started !== revision) return;
404
+ const url = URL.createObjectURL(blob);
405
+ const download = document.createElement('a'); download.href = url; download.download = 'calculation.pdf'; download.click();
406
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
407
+ const latest = await readJson(await fetch('/api/runs/' + run.id));
408
+ if (started === revision) { showChecks(latest.checks); evidence.href = run.evidence; evidence.hidden = false; status.textContent = 'PDF ready. Human review remains pending.'; }
409
+ } catch (error) {
410
+ if (started === revision) {
411
+ try {
412
+ const latest = await readJson(await fetch('/api/runs/' + run.id));
413
+ if (started === revision) showChecks(latest.checks);
414
+ } catch { /* Keep the original PDF failure if the run is unavailable. */ }
415
+ if (started === revision) status.textContent = error.message;
416
+ }
417
+ }
418
+ finally { if (started === revision) pdf.hidden = false; }
419
+ });
420
+ (async () => {
421
+ try {
422
+ definition = await readJson(await fetch('/api/definition'));
423
+ for (const [index, input] of definition.inputs.entries()) {
424
+ const label = document.createElement('label'); label.htmlFor = 'input-' + index;
425
+ label.textContent = input.description + (input.unit ? ' (' + input.unit + ')' : '');
426
+ const name = document.createElement('small'); name.textContent = input.name; label.append(name);
427
+ const field = document.createElement('input'); field.id = label.htmlFor; field.name = input.name; field.type = 'number'; field.step = input.numericType === 'int' ? '1' : 'any'; field.required = true;
428
+ if (input.default !== undefined) field.value = Object.is(input.default, -0) ? '-0' : String(input.default);
429
+ fields.append(label, field);
430
+ }
431
+ button.disabled = false; status.textContent = 'Enter inputs and calculate.'; updateCurl();
432
+ if (definition.inputs.every(input => input.default !== undefined)) form.requestSubmit();
433
+ } catch (error) { status.textContent = error.message; }
434
+ })();
435
+ `
436
+ };
437
+
438
+ // src/dev-server.ts
439
+ var devHelp = `Usage: cso dev <file.cso.py> --function <name> [--port <0-65535>] [--reference <file.json>]
440
+
441
+ Serve a verified calculation on 127.0.0.1. Port 0 chooses an available port.
442
+ PYTHON selects the installed cso-python interpreter. Source path and function are fixed at startup.
443
+ `;
444
+ function parseDevArgs(args) {
445
+ const [source, ...rest] = args;
446
+ if (!source || source.startsWith("-")) throw new UsageError(devHelp);
447
+ const values = /* @__PURE__ */ new Map();
448
+ for (let index = 0; index < rest.length; index += 2) {
449
+ const key = rest[index];
450
+ const value = rest[index + 1];
451
+ if (!["--function", "--port", "--reference"].includes(key) || values.has(key) || !value || value.startsWith("--"))
452
+ throw new UsageError(devHelp);
453
+ values.set(key, value);
454
+ }
455
+ const functionName = values.get("--function");
456
+ if (!functionName || !PythonIdentifierSchema.safeParse(functionName).success)
457
+ throw new UsageError("--function requires a Python identifier");
458
+ const normalizedFunction = functionName.normalize("NFKC");
459
+ if (!PythonIdentifierSchema.safeParse(normalizedFunction).success)
460
+ throw new UsageError(
461
+ "--function does not normalize to a valid Python identifier"
462
+ );
463
+ const spelling = values.get("--port") ?? "3000";
464
+ const port = Number(spelling);
465
+ if (!/^\d+$/.test(spelling) || !Number.isInteger(port) || port > 65535)
466
+ throw new UsageError("--port must be an integer between 0 and 65535");
467
+ const reference = values.get("--reference");
468
+ return {
469
+ sourcePath: resolve(source),
470
+ functionName: normalizedFunction,
471
+ port,
472
+ ...reference ? { referencePath: resolve(reference) } : {}
473
+ };
474
+ }
475
+ function parseDevInputs(text, definition) {
476
+ let body;
477
+ try {
478
+ body = parseStrictJson(text);
479
+ } catch (error) {
480
+ throw new HttpError(
481
+ 400,
482
+ error instanceof Error ? error.message : "Invalid JSON"
483
+ );
484
+ }
485
+ if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || !("inputs" in body))
486
+ throw new HttpError(400, "Request must contain only an inputs object");
487
+ const values = body.inputs;
488
+ if (!values || typeof values !== "object" || Array.isArray(values))
489
+ throw new HttpError(400, "inputs must be an object");
490
+ const parameters = new Map(
491
+ definition.inputs.map((input) => [input.name, input])
492
+ );
493
+ const entries = [];
494
+ for (const [name, value] of Object.entries(values)) {
495
+ const input = parameters.get(name);
496
+ if (!input) throw new HttpError(400, `Unknown input ${name}`);
497
+ if (typeof value !== "number" || !Number.isFinite(value) || input.numericType === "int" && !Number.isSafeInteger(value))
498
+ throw new HttpError(
499
+ 400,
500
+ `${name} requires a finite ${input.numericType === "int" ? "safe integer" : "number"}`
501
+ );
502
+ entries.push([
503
+ name,
504
+ input.numericType === "int" && Object.is(value, -0) ? 0 : value
505
+ ]);
506
+ }
507
+ for (const match of text.matchAll(
508
+ /("(?:[^"\\]|\\.)*")\s*:\s*(-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/g
509
+ )) {
510
+ const name = JSON.parse(match[1]);
511
+ const token = match[2];
512
+ if (typeof name === "string" && parameters.get(name)?.numericType === "int" && /[.eE]/.test(token))
513
+ throw new HttpError(
514
+ 400,
515
+ `${name} requires an integer token without a decimal or exponent`
516
+ );
517
+ if (!/[.eE]/.test(token) && !Number.isSafeInteger(Number(token)))
518
+ throw new HttpError(
519
+ 400,
520
+ "Integer tokens must be within the safe integer range; use a decimal or exponent for floats"
521
+ );
522
+ }
523
+ for (const input of definition.inputs) {
524
+ if (!Object.hasOwn(values, input.name) && input.default === void 0)
525
+ throw new HttpError(400, `Missing input ${input.name}`);
526
+ }
527
+ return Object.fromEntries(entries);
528
+ }
529
+ function withLinks(summary) {
530
+ const base = `/api/runs/${summary.id}`;
531
+ return {
532
+ ...summary,
533
+ html: `${base}/report.html`,
534
+ pdf: `${base}/report.pdf`,
535
+ evidence: `${base}/evidence.json`
536
+ };
537
+ }
538
+ function send(response, status, data, type) {
539
+ response.writeHead(status, {
540
+ "Content-Type": type,
541
+ "Content-Length": data.length,
542
+ "Cache-Control": "no-store",
543
+ "X-Content-Type-Options": "nosniff"
544
+ });
545
+ response.end(data);
546
+ }
547
+ async function readBody(request) {
548
+ const chunks = [];
549
+ let length = 0;
550
+ for await (const chunk of request.iterator({ destroyOnReturn: false })) {
551
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
552
+ length += bytes.length;
553
+ if (length > 64 * 1024) {
554
+ request.resume();
555
+ throw new HttpError(413, "Request body exceeds 64 KiB");
556
+ }
557
+ chunks.push(bytes);
558
+ }
559
+ try {
560
+ return new TextDecoder("utf-8", { fatal: true }).decode(
561
+ Buffer.concat(chunks)
562
+ );
563
+ } catch {
564
+ throw new HttpError(400, "Request must be UTF-8 JSON");
565
+ }
566
+ }
567
+ async function startDevServer(options, limits) {
568
+ const runtime = createDevRuntime(options, limits);
569
+ let origin = "";
570
+ let stopping = false;
571
+ const server = createServer(async (request, response) => {
572
+ try {
573
+ if (stopping) throw new HttpError(503, "Server is shutting down");
574
+ if (request.headers.host !== new URL(origin).host || request.headers.origin !== void 0 && request.headers.origin !== origin)
575
+ throw new HttpError(
576
+ 403,
577
+ "Only same-origin localhost requests are accepted"
578
+ );
579
+ const url = new URL(request.url ?? "/", origin);
580
+ if (url.origin !== origin)
581
+ throw new HttpError(403, "Invalid request origin");
582
+ if (request.method === "GET" && url.pathname === "/") {
583
+ response.setHeader(
584
+ "Content-Security-Policy",
585
+ "default-src 'self'; script-src 'self'; style-src 'unsafe-inline'; frame-src 'self'; object-src 'none'; base-uri 'none'"
586
+ );
587
+ return send(
588
+ response,
589
+ 200,
590
+ Buffer.from(devPage.html),
591
+ "text/html; charset=utf-8"
592
+ );
593
+ }
594
+ if (request.method === "GET" && url.pathname === "/app.js")
595
+ return send(
596
+ response,
597
+ 200,
598
+ Buffer.from(devPage.script),
599
+ "text/javascript; charset=utf-8"
600
+ );
601
+ if (request.method === "GET" && url.pathname === "/api/definition")
602
+ return send(
603
+ response,
604
+ 200,
605
+ jsonBytes(runtime.definition()),
606
+ "application/json"
607
+ );
608
+ if (request.method === "POST" && ["/api/calculate", "/api/runs"].includes(url.pathname)) {
609
+ if (!/^application\/json(?:\s*;.*)?$/i.test(
610
+ request.headers["content-type"] ?? ""
611
+ ))
612
+ throw new HttpError(415, "Content-Type must be application/json");
613
+ const body = await readBody(request);
614
+ const definition = runtime.definition();
615
+ const invocation = {
616
+ definition,
617
+ inputs: parseDevInputs(body, definition)
618
+ };
619
+ if (url.pathname === "/api/calculate")
620
+ return send(
621
+ response,
622
+ 200,
623
+ jsonBytes(runtime.calculate(invocation)),
624
+ "application/json"
625
+ );
626
+ return send(
627
+ response,
628
+ 201,
629
+ jsonBytes(withLinks(runtime.createRun(invocation))),
630
+ "application/json"
631
+ );
632
+ }
633
+ const match = /^\/api\/runs\/([\da-f-]{36})(?:\/(report\.html|report\.pdf|evidence\.json))?$/.exec(
634
+ url.pathname
635
+ );
636
+ if (request.method === "GET" && match) {
637
+ const id = match[1];
638
+ if (match[2] === "report.html")
639
+ return send(
640
+ response,
641
+ 200,
642
+ runtime.html(id),
643
+ "text/html; charset=utf-8"
644
+ );
645
+ if (match[2] === "report.pdf") {
646
+ const artifact = await runtime.pdf(id);
647
+ response.setHeader(
648
+ "Content-Disposition",
649
+ 'attachment; filename="calculation.pdf"'
650
+ );
651
+ response.setHeader("X-CSO-PDF-SHA256", artifact.sha256);
652
+ return send(response, 200, artifact.bytes, "application/pdf");
653
+ }
654
+ if (match[2] === "evidence.json") {
655
+ response.setHeader(
656
+ "Content-Disposition",
657
+ 'attachment; filename="calculation-evidence.json"'
658
+ );
659
+ return send(response, 200, runtime.evidence(id), "application/json");
660
+ }
661
+ return send(
662
+ response,
663
+ 200,
664
+ jsonBytes(withLinks(runtime.summary(id))),
665
+ "application/json"
666
+ );
667
+ }
668
+ throw new HttpError(404, "Not found");
669
+ } catch (error) {
670
+ if (response.destroyed || response.headersSent) return;
671
+ const failure = error instanceof HttpError ? error : new HttpError(
672
+ 500,
673
+ error instanceof Error ? error.message : "Request failed"
674
+ );
675
+ if (failure.status === 413) response.setHeader("Connection", "close");
676
+ send(
677
+ response,
678
+ failure.status,
679
+ jsonBytes({ error: failure.message, diagnostics: failure.diagnostics }),
680
+ "application/json"
681
+ );
682
+ }
683
+ });
684
+ server.requestTimeout = 15e3;
685
+ server.headersTimeout = 1e4;
686
+ server.maxConnections = 16;
687
+ server.keepAliveTimeout = 5e3;
688
+ try {
689
+ await new Promise((success, failure) => {
690
+ server.once("error", failure);
691
+ server.listen(options.port, "127.0.0.1", () => {
692
+ server.off("error", failure);
693
+ success();
694
+ });
695
+ });
696
+ } catch (error) {
697
+ await runtime.close();
698
+ throw error;
699
+ }
700
+ const address = server.address();
701
+ if (!address || typeof address === "string")
702
+ throw new Error("Missing localhost address");
703
+ origin = `http://127.0.0.1:${address.port}`;
704
+ return {
705
+ origin,
706
+ close: async () => {
707
+ stopping = true;
708
+ const closed = new Promise((done) => server.close(() => done()));
709
+ server.closeAllConnections();
710
+ await Promise.allSettled([closed, runtime.close()]);
711
+ }
712
+ };
713
+ }
714
+ async function devCommand(args) {
715
+ const server = await startDevServer(parseDevArgs(args));
716
+ process.stdout.write(`CSO dev ${server.origin}
717
+ `);
718
+ const stop = () => {
719
+ process.off("SIGINT", stop);
720
+ process.off("SIGTERM", stop);
721
+ void server.close();
722
+ };
723
+ process.on("SIGINT", stop);
724
+ process.on("SIGTERM", stop);
725
+ }
726
+ export {
727
+ devCommand,
728
+ devHelp,
729
+ parseDevArgs,
730
+ parseDevInputs,
731
+ startDevServer
732
+ };