@beltype/cli 0.1.3 → 0.1.5

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,272 @@
1
+ /**
2
+ * `beltype serve`: `beltype print` behind an HTTP door, with one Chromium kept warm, as Gotenberg does.
3
+ *
4
+ * @remarks
5
+ * **One browser, one context, one holder page.** The context carries the companions' routes; the holder page opens the shared writer worker (Caster's `formeSharedWorkerUrl()`) and keeps it for as long as the page lives, so forme's WebAssembly stays warm across prints. A print is a new page in that context, routed to its own folder alone ({@link routeDocument}), run through {@link printOnPage} with `sharedWriter`, and closed.
6
+ *
7
+ * **One at a time, and kept alive.** A promise chain runs the prints in turn. The browser is closed and started again (routes, holder page, warm-up) after `restartAfter` prints and after a print that failed with a 500, and a disconnected browser is started again before the next print. The warm-up prints a built-in document twice under forme, so the first request is warm too.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ import fs from 'node:fs';
12
+ import http from 'node:http';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { chromium } from 'playwright-core';
17
+ import { ciceroFlags } from '@beltype/cicero/flags';
18
+ import { BuildError, ValidationError, FlagError } from '@beltype/common/errors';
19
+ import { cliFlags, createdFromEnv, parseFlagArgs } from './flags.js';
20
+ import { ARGS, ORIGIN, VIEWPORT, documentAssets, servedHtml, routeCompanions, routeDocument, printOnPage, refusal } from './run.js';
21
+ const HOLDER_PATH = '/_beltype/holder.html';
22
+ const HOLDER = `<!doctype html><html><head><meta charset="utf-8"><title>holder</title></head><body><script type="module">
23
+ const c = await import('/_beltype/caster/src/index.js');
24
+ window.sw = new SharedWorker(c.formeSharedWorkerUrl(), { type: 'module', name: 'forme' });
25
+ </script></body></html>`;
26
+ // Run from src/ or from dist/src/: the package root is the first folder up that holds `assets`.
27
+ const WARMUP_FONT = [1, 2].map((up) => fileURLToPath(new URL(`${'../'.repeat(up)}assets/warmup/SourceSans3-Variable.woff2`, import.meta.url))).find((f) => fs.existsSync(f));
28
+ const WORDS = 'the quick brown fox jumps over a lazy dog while leaf litter decomposes along an altitudinal gradient and soil carbon settles slowly beneath the canopy of old beech and spruce'.split(' ');
29
+ const paragraph = (n) => Array.from({ length: 90 }, (_, i) => WORDS[(i * 7 + n * 3) % WORDS.length]).join(' ') + '.';
30
+ /** Plain paragraphs and one heading, in the one face the CLI ships for it (`assets/warmup/`, SIL OFL): neither Cicero nor Caster supplies a default face, and Caster refuses a document with none. */
31
+ const WARMUP = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Warm-up</title><style>@font-face { font-family: Warm; src: url(warm.woff2) format("woff2"); font-weight: 200 900 } @page { size: A4; margin: 20mm } body { font: 11pt/1.4 Warm }</style></head><body><h1>Warm-up</h1>${Array.from({ length: 16 }, (_, n) => `<p>${paragraph(n)}</p>`).join('')}</body></html>`;
32
+ class HttpError extends Error {
33
+ status;
34
+ constructor(status, message) { super(message); this.status = status; }
35
+ }
36
+ export async function serve(options) {
37
+ const log = options.log ?? ((line) => console.log(line));
38
+ const assets = documentAssets();
39
+ const missing = new Set();
40
+ let browser = null;
41
+ let context = null;
42
+ let version = 'unknown';
43
+ let restarting = false;
44
+ let needsRestart = false;
45
+ let prints = 0;
46
+ let sinceStart = 0;
47
+ let queued = 0;
48
+ let tail = Promise.resolve();
49
+ const stopBrowser = async () => {
50
+ const old = browser;
51
+ browser = context = null;
52
+ await old?.close().catch(() => { });
53
+ };
54
+ /** Run `html` and `files` through the print's road on a new page; the folder and the page are gone after. */
55
+ const render = async (files, given, created) => {
56
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'beltype-serve-'));
57
+ try {
58
+ for (const [name, bytes] of files) {
59
+ const file = path.join(root, ...name.split('/'));
60
+ fs.mkdirSync(path.dirname(file), { recursive: true });
61
+ fs.writeFileSync(file, bytes);
62
+ }
63
+ const flags = { caster: given.caster, cicero: ciceroFlags.resolve(given.cicero), beltype: cliFlags.resolve(given.beltype) };
64
+ const docPath = '/index.html';
65
+ const served = servedHtml(Buffer.from(files.get('index.html')).toString('utf8'));
66
+ missing.clear();
67
+ const page = await context.newPage();
68
+ try {
69
+ await routeDocument(page, missing, { root, docPath, served });
70
+ const printed = await printOnPage(page, missing, flags, { docPath, sharedWriter: true, ...(created !== undefined ? { created } : {}) });
71
+ if (missing.size)
72
+ throw refusal(missing, null);
73
+ return printed;
74
+ }
75
+ finally {
76
+ await page.close().catch(() => { });
77
+ }
78
+ }
79
+ finally {
80
+ fs.rmSync(root, { recursive: true, force: true });
81
+ }
82
+ };
83
+ const start = async () => {
84
+ browser = await chromium.launch({ headless: true, args: ARGS });
85
+ const mine = browser;
86
+ version = mine.version();
87
+ mine.on('disconnected', () => { if (browser === mine)
88
+ needsRestart = true; });
89
+ options.onBrowser?.(mine);
90
+ context = await mine.newContext({ viewport: VIEWPORT, serviceWorkers: 'block' });
91
+ await routeCompanions(context, missing, assets);
92
+ // Registered after the companions' routes, so it runs first.
93
+ await context.route(ORIGIN + HOLDER_PATH, (route) => route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: servedHtml(HOLDER) }));
94
+ const holder = await context.newPage();
95
+ await holder.goto(ORIGIN + HOLDER_PATH, { waitUntil: 'load' });
96
+ await holder.waitForFunction(() => window.sw !== undefined);
97
+ const given = await parseFlagArgs(['caster.writer=forme']);
98
+ for (const n of [1, 2]) {
99
+ const printed = await render(new Map([['index.html', Buffer.from(WARMUP)], ['warm.woff2', fs.readFileSync(WARMUP_FONT)]]), given, undefined);
100
+ log(`beltype serve: warm-up ${n}: ${printed.pages} pages, forme write ${printed.caster?.ms['emit'] ?? '?'} ms`);
101
+ }
102
+ needsRestart = false;
103
+ sinceStart = 0;
104
+ };
105
+ const restart = async () => {
106
+ restarting = true;
107
+ try {
108
+ await stopBrowser();
109
+ log('beltype serve: restarting the browser');
110
+ await start();
111
+ }
112
+ catch (error) {
113
+ needsRestart = true;
114
+ throw error;
115
+ }
116
+ finally {
117
+ restarting = false;
118
+ }
119
+ };
120
+ /** Runs `job` after every earlier job and after any restart it needs; the restart a job asks for happens before the next one. */
121
+ const enqueue = (job) => {
122
+ queued++;
123
+ const result = tail.then(async () => {
124
+ queued--;
125
+ if (needsRestart || sinceStart >= options.restartAfter)
126
+ await restart();
127
+ return job();
128
+ });
129
+ tail = result.then(async () => { if (needsRestart || sinceStart >= options.restartAfter)
130
+ await restart(); }).catch(() => { });
131
+ return result;
132
+ };
133
+ await start();
134
+ const print = async (req) => {
135
+ const form = await readForm(req, options.maxUpload);
136
+ const files = new Map();
137
+ const args = [];
138
+ let created;
139
+ for (const [name, value] of form.entries()) {
140
+ if (typeof value !== 'string') {
141
+ files.set(plainName(name), new Uint8Array(await value.arrayBuffer()));
142
+ }
143
+ else if (name === 'created') {
144
+ created = instant(value);
145
+ }
146
+ else {
147
+ if (name === 'beltype.check')
148
+ throw new ValidationError('beltype serve: beltype.check is refused; the server writes no files of its own.');
149
+ args.push(`${name}=${value}`);
150
+ }
151
+ }
152
+ if (!files.has('index.html'))
153
+ throw new ValidationError('beltype serve: the form has no index.html file.');
154
+ const given = await parseFlagArgs(args);
155
+ created ??= createdFromEnv();
156
+ const writer = String(given.caster['writer'] ?? 'pdf-lib');
157
+ return enqueue(async () => {
158
+ const id = prints + 1;
159
+ log(`beltype serve: print ${id} started`);
160
+ const t0 = performance.now();
161
+ const run = render(files, given, created);
162
+ let timer;
163
+ const limit = new Promise((_, reject) => { timer = setTimeout(() => reject(new HttpError(504, `beltype serve: the print took over ${options.timeout / 1000} s.`)), options.timeout); });
164
+ try {
165
+ const printed = await Promise.race([run, limit]);
166
+ prints++;
167
+ sinceStart++;
168
+ log(`beltype serve: print ${id}: 200, ${printed.pages} pages, ${writer}, ${Math.round(performance.now() - t0)} ms (write ${printed.caster?.ms['emit'] ?? '?'} ms)`);
169
+ return { bytes: printed.bytes, printed, writer };
170
+ }
171
+ catch (error) {
172
+ prints++;
173
+ sinceStart++;
174
+ run.catch(() => { });
175
+ if (!(error instanceof BuildError) && !(error instanceof HttpError))
176
+ needsRestart = true;
177
+ throw error;
178
+ }
179
+ finally {
180
+ clearTimeout(timer);
181
+ }
182
+ });
183
+ };
184
+ const server = http.createServer(async (req, res) => {
185
+ const send = (status, type, body, headers = {}) => {
186
+ res.writeHead(status, { 'content-type': type, ...headers });
187
+ res.end(body);
188
+ };
189
+ const text = (status, message) => send(status, 'text/plain; charset=utf-8', message + '\n');
190
+ try {
191
+ const pathname = new URL(req.url ?? '/', 'http://x').pathname;
192
+ if (pathname === '/health') {
193
+ if (req.method !== 'GET')
194
+ return text(405, 'beltype serve: /health takes GET.');
195
+ return send(restarting ? 503 : 200, 'application/json', JSON.stringify({ status: restarting ? 'restarting' : 'up', chromium: version, prints, queued }));
196
+ }
197
+ if (pathname !== '/print')
198
+ return text(404, `beltype serve: no ${pathname}; there is POST /print and GET /health.`);
199
+ if (req.method !== 'POST')
200
+ return text(405, 'beltype serve: /print takes POST.');
201
+ const { bytes, printed, writer } = await print(req);
202
+ for (const warning of printed.warnings)
203
+ log(`beltype serve: warning: ${typeof warning === 'string' ? warning : JSON.stringify(warning)}`);
204
+ send(200, 'application/pdf', bytes, { 'x-beltype-pages': printed.pages, 'x-beltype-words': printed.words, 'x-beltype-warnings': printed.warnings.length });
205
+ }
206
+ catch (error) {
207
+ const message = error instanceof Error ? error.message : String(error);
208
+ const status = error instanceof HttpError ? error.status : error instanceof BuildError ? 422 : error instanceof ValidationError || error instanceof FlagError ? 400 : 500;
209
+ log(`beltype serve: ${req.method} ${req.url}: ${status}`);
210
+ if (status === 413) {
211
+ // Keep nothing more: answer, discard what still arrives (a client cut off mid-write sees EPIPE, not the 413), and drop the connection after a second if the client is still sending.
212
+ res.writeHead(413, { 'content-type': 'text/plain; charset=utf-8' });
213
+ res.end(message + '\n', () => {
214
+ req.resume();
215
+ setTimeout(() => req.destroy(), 1000).unref();
216
+ });
217
+ }
218
+ else if (!res.headersSent)
219
+ text(status, message);
220
+ }
221
+ });
222
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(options.port, options.host, resolve); });
223
+ const address = server.address();
224
+ const url = `http://${options.host}:${address.port}`;
225
+ log(`beltype serve: listening on ${url}`);
226
+ return {
227
+ url,
228
+ async close() {
229
+ server.closeAllConnections();
230
+ await new Promise((resolve) => server.close(() => resolve()));
231
+ await tail;
232
+ await stopBrowser();
233
+ }
234
+ };
235
+ }
236
+ /** The request's multipart form, read up to `limit` bytes. @throws {HttpError} 413 as soon as the count passes the limit; {ValidationError} where the body is not a form. */
237
+ async function readForm(req, limit) {
238
+ const declared = Number(req.headers['content-length'] ?? 0);
239
+ if (declared > limit)
240
+ throw new HttpError(413, `beltype serve: the upload is ${declared} bytes, over the limit of ${limit}.`);
241
+ const chunks = [];
242
+ let size = 0;
243
+ for await (const chunk of req) {
244
+ size += chunk.length;
245
+ if (size > limit)
246
+ throw new HttpError(413, `beltype serve: the upload is over the limit of ${limit} bytes.`);
247
+ chunks.push(chunk);
248
+ }
249
+ const type = req.headers['content-type'] ?? '';
250
+ if (!/^multipart\/form-data/i.test(type))
251
+ throw new ValidationError('beltype serve: POST /print takes multipart/form-data.');
252
+ try {
253
+ return await new Response(Buffer.concat(chunks), { headers: { 'content-type': type } }).formData();
254
+ }
255
+ catch {
256
+ throw new ValidationError('beltype serve: the body is not a well-formed multipart/form-data.');
257
+ }
258
+ }
259
+ /** A file's name as a path relative to the document: plain segments joined by `/`. @throws {ValidationError} where it is absolute, climbs out or is not a plain name. */
260
+ function plainName(name) {
261
+ const segments = name.split('/');
262
+ if (!name || segments.some((s) => s === '' || s === '.' || s === '..' || /[\\\0:]/.test(s)))
263
+ throw new ValidationError(`beltype serve: the file name "${name}" is not a plain relative path with / separators (no absolute path, no "..").`);
264
+ return name;
265
+ }
266
+ /** An ISO 8601 instant, normalised. @throws {ValidationError} */
267
+ function instant(value) {
268
+ const date = new Date(value);
269
+ if (!/^\d{4}-\d\d-\d\dT/.test(value) || Number.isNaN(date.getTime()))
270
+ throw new ValidationError(`beltype serve: created is an ISO 8601 instant; got "${value}".`);
271
+ return date.toISOString();
272
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beltype/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "beltype, the command line: a finished HTML document printed to PDF by Cicero and Caster in Chromium's headless shell.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,10 +13,11 @@
13
13
  "node": ">=22"
14
14
  },
15
15
  "bin": {
16
- "beltype": "./dist/src/cli.js"
16
+ "beltype": "dist/src/cli.js"
17
17
  },
18
18
  "files": [
19
- "dist"
19
+ "dist",
20
+ "assets"
20
21
  ],
21
22
  "scripts": {
22
23
  "build": "tsc -p tsconfig.build.json",
@@ -32,15 +33,15 @@
32
33
  ]
33
34
  },
34
35
  "dependencies": {
35
- "@beltype/caster": "0.1.3",
36
- "@beltype/cicero": "0.1.4",
37
- "@beltype/common": "0.1.1",
36
+ "@beltype/caster": "0.1.6",
37
+ "@beltype/cicero": "0.1.5",
38
+ "@beltype/common": "0.1.2",
39
+ "mupdf": "1.28.1",
38
40
  "playwright-core": "1.63.0"
39
41
  },
40
42
  "devDependencies": {
41
43
  "@beltype/specimens": "0.1.0",
42
44
  "@types/node": "^22.20.5",
43
- "mupdf": "1.28.1",
44
45
  "typescript": "7.0.2"
45
46
  },
46
47
  "bugs": {