@beltype/cli 0.1.4 → 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.
package/README.md CHANGED
@@ -6,6 +6,7 @@ The BelType command line. It prints a finished HTML document to PDF: Cicero cuts
6
6
  beltype print paper/index.html # writes paper/index.pdf
7
7
  beltype print paper/index.html -o paper.pdf
8
8
  beltype print paper/index.html --printer chromium --check --trace trace.json
9
+ beltype serve # the same, behind HTTP: see below
9
10
  ```
10
11
 
11
12
  ## Flags
@@ -19,9 +20,25 @@ beltype print paper/index.html --flag caster.shaper=fontkit --flag cicero.compos
19
20
  - `--printer caster|chromium`: who prints the pages Cicero presented. Chromium prints them one sheet to a page; it refuses, naming `--printer caster`, when the pages are not all one size.
20
21
  - `--trace <file.json>`: where the run's time went. `process` is node's clock around each step (launch, context, load, cut, print, transfer, close, write), `cicero` is Cicero's own `performance.measure` entries, `caster` is Caster's report (`ms` per stage and `timing`; `null` under Chromium), with the versions and the resolved flags. It is written even when the run refuses, with the step that refused.
21
22
  - `--check`: the proof that the PDF is the screen. Each presented page is screenshotted at 96 dpi, the PDF is rasterised at 96 dpi and the pairs are diffed into `<name>.check/` (`screen-N.png`, `print-N.png`, `diff-N.png`, `check.json`). It reports a share of differing pixels per page and a mean, and never fails the run. It tolerates one pixel of slip, because the screen's pages sit at fractional heights (A4 is 1122.52 px) and Skia and mupdf shade letter edges differently.
23
+ - `SOURCE_DATE_EPOCH=<seconds>` (an environment variable, not a flag): the moment the file says it was made, whole seconds since 1970 as the reproducible-builds convention has it. Set, the file's `/CreationDate` and `/ModDate` are that instant and the same document printed twice gives the same bytes; unset, they are the wall clock.
22
24
 
23
25
  The document is plain HTML, CSS and the files beside it. The run answers every request from the document's folder, on a made-up `.invalid` address, and refuses anything else by name: a document that asks for a file it does not have, or for anything on a network, prints nothing. Fonts must be declared with `@font-face` and their files, because Caster embeds every face it draws.
24
26
 
27
+ ## beltype serve
28
+
29
+ `beltype serve` is `beltype print` behind an HTTP door, in Gotenberg's shape: one Chromium kept warm, and forme kept warm in a shared worker that every document's page hands its display list to, so a print skips the browser's launch and forme's cold first write. A print is still `beltype print`'s: Cicero cuts and Caster prints in the document's own page, answered from the files it was posted with.
30
+
31
+ ```bash
32
+ beltype serve --port 3000
33
+ curl -F index.html=@index.html -F paper.css=@paper.css -F fonts/a.woff2=@fonts/a.woff2 -F caster.writer=forme http://127.0.0.1:3000/print -o out.pdf
34
+ ```
35
+
36
+ - `POST /print`, `multipart/form-data`. `index.html` is required; every other file keeps its form field name as its path relative to the document (`fonts/a.woff2`), plain segments with `/` separators, never absolute and never `..`. A field that is not a file is a flag, `<owner>.<name>` = value (`caster.writer=forme`, `cicero.…`); `beltype.check` is refused. A field `created` (an ISO 8601 instant) fixes the file's dates, else `SOURCE_DATE_EPOCH`, else the clock. The answer is `200 application/pdf` with `X-Beltype-Pages`, `X-Beltype-Words` and `X-Beltype-Warnings` (the count); `422` with `beltype print`'s sentence where the document is refused (a file it asked for and was not given, among them); `400` for a bad request; `413` over the upload limit; `504` over the time limit; `500` otherwise, after which the browser is restarted.
37
+ - `GET /health`: `{ "status": "up", "chromium": …, "prints": …, "queued": … }`, or `503` with `"status": "restarting"` while the browser starts again.
38
+ - `--host 127.0.0.1`, `--port 3000`, `--max-upload 50` (MB), `--timeout 60` (seconds per print), `--restart-after 100` (prints before the browser is restarted).
39
+ - One print runs at a time; the others wait their turn. After every start the server prints a built-in document twice under forme (the log shows both writes), so the first request is warm.
40
+ - It binds `127.0.0.1` unless `--host` says otherwise, and fetches nothing from a network: a document that asks for anything it was not posted is refused by name, as in `beltype print`. The built-in warm-up document ships one font, Source Sans 3 (SIL OFL), in `assets/warmup/`.
41
+
25
42
  ## The sample
26
43
 
27
44
  `paper`, from [`@beltype/specimens`](https://codeberg.org/BelType/specimens), is a short scientific article on A4 (its data are invented). It exercises what a paged document needs:
@@ -0,0 +1,93 @@
1
+ Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+
5
+ This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
package/dist/src/flags.js CHANGED
@@ -52,3 +52,12 @@ export async function flagsHelp() {
52
52
  const valuesWidth = Math.max(...rows.map((r) => r[1].length));
53
53
  return `${rows.map(([flag, values, def]) => ` ${flag.padEnd(width)} ${values.padEnd(valuesWidth)} default ${def}`).join('\n')}`;
54
54
  }
55
+ /** The instant `SOURCE_DATE_EPOCH` names (whole seconds since 1970, the reproducible-builds convention), as an ISO 8601 string; undefined where the variable is not set. @throws {ValidationError} where it is not whole seconds. */
56
+ export function createdFromEnv(env = process.env) {
57
+ const epoch = env['SOURCE_DATE_EPOCH'];
58
+ if (epoch === undefined || epoch === '')
59
+ return undefined;
60
+ if (!/^\d{1,15}$/.test(epoch))
61
+ throw new ValidationError(`beltype: SOURCE_DATE_EPOCH is whole seconds since 1970; got "${epoch}".`);
62
+ return new Date(Number(epoch) * 1000).toISOString();
63
+ }
@@ -33,14 +33,15 @@ export async function cutInPage(run) {
33
33
  /**
34
34
  * Caster prints the presented pages; the bytes cross back as base64, the one form `evaluate` carries intact.
35
35
  *
36
- * @param options - the resolved Caster flags. `printMs` in the answer is Caster's own time in the page, before the bytes are encoded.
36
+ * @param run - `options` is the resolved Caster flags; `created` is the instant the file says it was made, absent for the wall clock; `sharedWriter` has forme write through a `SharedWorker` the page opens (Caster's `formeSharedWorkerUrl()`), so a host that keeps the page's browser alive keeps the writer warm. `printMs` in the answer is Caster's own time in the page, before the bytes are encoded.
37
37
  */
38
- export async function printInPage(options) {
38
+ export async function printInPage(run) {
39
39
  try {
40
40
  const t0 = performance.now();
41
41
  // @ts-expect-error a URL the page resolves, not a module the checker can find. A literal on purpose: a computed `import()` is wrapped by `rewriteRelativeImportExtensions` in a helper the page does not have.
42
42
  const caster = await import('/_beltype/caster/src/index.js');
43
- const printed = await caster.print({ document }, options);
43
+ const formePort = run.sharedWriter ? new SharedWorker(caster.formeSharedWorkerUrl(), { type: 'module', name: 'forme' }).port : undefined;
44
+ const printed = await caster.print({ document, ...(run.created !== undefined ? { created: run.created } : {}), ...(formePort ? { formePort } : {}) }, run.options);
44
45
  const printMs = performance.now() - t0;
45
46
  const bytes = printed.bytes;
46
47
  let binary = '';
package/dist/src/main.js CHANGED
@@ -2,19 +2,48 @@
2
2
  * The `beltype` command.
3
3
  *
4
4
  * @remarks
5
- * One verb so far: `beltype print <file.html> [-o <file.pdf>]`. The output defaults to the input's name with `.pdf`, beside it. A flag the parser does not know is refused, never ignored.
5
+ * Two verbs: `beltype print <file.html> [-o <file.pdf>]` and `beltype serve` (see `serve.ts`). The output defaults to the input's name with `.pdf`, beside it. A flag the parser does not know is refused, never ignored.
6
6
  *
7
7
  * Flags have one shape across BelType: `--flag <owner>.<name>=<value>`, repeatable, names a row of a package's table (`beltype print --help` lists them all). `--printer` and `--check` are the command line's own rows spelled plainly; `--trace <file>` takes a free string, so it is a plain option beside the table.
8
8
  *
9
9
  * @packageDocumentation
10
10
  */
11
11
  import { parseArgs } from 'node:util';
12
- import { BelTypeError } from '@beltype/common/errors';
12
+ import { BelTypeError, ValidationError } from '@beltype/common/errors';
13
13
  import { checkLines } from './check.js';
14
- import { cliFlags, flagsHelp, parseFlagArgs } from './flags.js';
14
+ import { cliFlags, createdFromEnv, flagsHelp, parseFlagArgs } from './flags.js';
15
15
  import { print } from './print.js';
16
- const USAGE = 'usage: beltype print <file.html> [-o <file.pdf>] [--printer caster|chromium] [--check] [--trace <file.json>] [--flag <owner>.<name>=<value>]...';
16
+ import { serve } from './serve.js';
17
+ const USAGE = 'usage: beltype serve [--host 127.0.0.1] [--port 3000] [--max-upload 50] [--timeout 60] [--restart-after 100]\n beltype print <file.html> [-o <file.pdf>] [--printer caster|chromium] [--check] [--trace <file.json>] [--flag <owner>.<name>=<value>]...';
18
+ async function serveVerb(argv) {
19
+ const { values } = parseArgs({ args: argv, options: {
20
+ host: { type: 'string', default: '127.0.0.1' },
21
+ port: { type: 'string', default: '3000' },
22
+ 'max-upload': { type: 'string', default: '50' },
23
+ timeout: { type: 'string', default: '60' },
24
+ 'restart-after': { type: 'string', default: '100' }
25
+ } });
26
+ const number = (name, raw, min) => {
27
+ if (!/^\d+$/.test(raw) || Number(raw) < min)
28
+ throw new ValidationError(`beltype serve: --${name} is a whole number of at least ${min}; got "${raw}".`);
29
+ return Number(raw);
30
+ };
31
+ const server = await serve({
32
+ host: values.host,
33
+ port: number('port', values.port, 0),
34
+ maxUpload: number('max-upload', values['max-upload'], 1) * 1024 * 1024,
35
+ timeout: number('timeout', values.timeout, 1) * 1000,
36
+ restartAfter: number('restart-after', values['restart-after'], 1)
37
+ });
38
+ const stop = () => { void server.close().then(() => process.exit(0)); };
39
+ process.on('SIGINT', stop);
40
+ process.on('SIGTERM', stop);
41
+ }
17
42
  async function main(argv) {
43
+ if (argv[0] === 'serve') {
44
+ await serveVerb(argv.slice(1));
45
+ return -1;
46
+ }
18
47
  const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
19
48
  output: { type: 'string', short: 'o' },
20
49
  help: { type: 'boolean', short: 'h' },
@@ -38,14 +67,16 @@ async function main(argv) {
38
67
  if (values.check)
39
68
  given.beltype['check'] = true;
40
69
  const output = values.output ?? input.replace(/\.html?$/i, '') + '.pdf';
41
- const result = await print(input, output, { given, ...(values.trace !== undefined ? { trace: values.trace } : {}) });
70
+ const created = createdFromEnv();
71
+ const result = await print(input, output, { given, ...(values.trace !== undefined ? { trace: values.trace } : {}), ...(created ? { created } : {}) });
42
72
  console.log(`${result.output}: ${result.pages} pages, ${result.words} words, ${(result.ms / 1000).toFixed(1)} s`);
43
73
  if (result.check)
44
74
  for (const line of checkLines(result.check))
45
75
  console.log(line);
46
76
  return 0;
47
77
  }
48
- main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
78
+ main(process.argv.slice(2)).then((code) => { if (code >= 0)
79
+ process.exit(code); }, (error) => {
49
80
  console.error(error instanceof BelTypeError ? error.message : error);
50
81
  process.exit(1);
51
82
  });
package/dist/src/print.js CHANGED
@@ -12,19 +12,11 @@ import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { chromium } from 'playwright-core';
15
- import { ciceroEngine } from '@beltype/cicero/engine';
16
15
  import { ciceroFlags } from '@beltype/cicero/flags';
17
- import { BuildError } from '@beltype/common/errors';
18
- import { contentType } from '@beltype/common/mime';
19
- import { cutInPage, printInPage, pageSizesInPage, chromiumSheetInPage } from './in-page.js';
20
16
  import { cliFlags, tableOf } from './flags.js';
21
- import { chromiumSheet } from './chromium.js';
22
- import { screenshotPages, checkPages } from './check.js';
23
- /** `.invalid` is reserved (RFC 2606), so nothing on a network can ever answer it instead. */
24
- const ORIGIN = 'https://print.invalid';
25
- const VIEWPORT = { width: 1200, height: 1600 };
26
- /** Deterministic text and timers that run while nobody watches the page. */
27
- const ARGS = ['--font-render-hinting=none', '--force-device-scale-factor=1', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'];
17
+ import { checkPages } from './check.js';
18
+ import { ARGS, VIEWPORT, documentAssets, servedHtml, routeCompanions, routeDocument, printOnPage, refusal } from './run.js';
19
+ export { spliceHead } from './run.js';
28
20
  const round = (ms) => Math.round(ms * 10) / 10;
29
21
  /**
30
22
  * Print `input`, an HTML file, to `output`.
@@ -52,16 +44,8 @@ export async function print(input, output, options = {}) {
52
44
  const html = fs.readFileSync(input, 'utf8');
53
45
  const root = path.dirname(path.resolve(input));
54
46
  const docPath = `/${encodeURIComponent(path.basename(input))}`;
55
- // Each companion's built folder, found from a module it exports: common exports no `package.json`.
56
- const distOf = (specifier, up) => path.resolve(path.dirname(fileURLToPath(import.meta.resolve(specifier))), ...Array(up).fill('..'));
57
- const cicero = ciceroEngine.vendorAssets();
58
- const assets = {
59
- files: cicero.files,
60
- dirs: { ...cicero.dirs, caster: distOf('@beltype/caster', 1), common: distOf('@beltype/common/errors', 0) },
61
- };
62
- // Caster's modules import common by its package names, which a page resolves only through an import map.
63
- const imports = Object.fromEntries(['errors', 'data-beltype', 'flags', 'mime'].map((m) => [`@beltype/common/${m}`, `/_beltype/common/${m}.js`]));
64
- const served = spliceHead(html, `<script type="importmap">${JSON.stringify({ imports })}</script>\n ${ciceroEngine.headHtml('/')}`);
47
+ const assets = documentAssets();
48
+ const served = servedHtml(html);
65
49
  const missing = new Set();
66
50
  const browser = await chromium.launch({ headless: true, args: ARGS });
67
51
  chromiumVersion = browser.version();
@@ -69,52 +53,27 @@ export async function print(input, output, options = {}) {
69
53
  try {
70
54
  step = 'context';
71
55
  const context = await browser.newContext({ viewport: VIEWPORT, serviceWorkers: 'block' });
72
- await answerFromDisk(context, missing, { root, docPath, served, assets });
56
+ await routeCompanions(context, missing, assets);
73
57
  const page = await context.newPage();
74
- page.on('pageerror', (error) => console.warn(`page error: ${error.message}`));
58
+ await routeDocument(page, missing, { root, docPath, served });
75
59
  lap('context');
76
- step = 'load';
77
- await page.goto(ORIGIN + docPath, { waitUntil: 'load' });
78
- lap('load');
79
- step = 'cut';
80
- const cut = await page.evaluate(cutInPage, { cicero: flags.cicero, measures: options.trace !== undefined });
81
- lap('cut');
82
- if (!('refused' in cut))
83
- trace.cicero = cut.measures;
84
- if ('refused' in cut || missing.size)
85
- throw refusal(missing, 'refused' in cut ? cut : null);
86
- let screens = [];
87
- if (flags.beltype.check) {
88
- step = 'check';
89
- screens = await screenshotPages(page);
90
- lap('check');
91
- }
92
- step = 'print';
93
- let bytes;
94
- let pages;
95
- let words;
96
- if (flags.beltype.printer === 'chromium') {
97
- const sheet = chromiumSheet(await page.evaluate(pageSizesInPage));
98
- await page.evaluate(chromiumSheetInPage, sheet);
99
- bytes = await page.pdf({ preferCSSPageSize: true, printBackground: true });
100
- pages = await page.locator('beltype-page').count();
101
- words = 0;
102
- lap('print');
103
- }
104
- else {
105
- const printed = await page.evaluate(printInPage, flags.caster);
106
- if ('refused' in printed)
107
- throw refusal(missing, printed);
60
+ const printed = await printOnPage(page, missing, flags, {
61
+ docPath,
62
+ measures: options.trace !== undefined,
63
+ ...(options.created !== undefined ? { created: options.created } : {}),
64
+ progress: { step: (name) => { step = name; }, lap, cicero: (measures) => { trace.cicero = measures; } }
65
+ });
66
+ if (printed.printMs !== undefined) {
108
67
  trace.process['print'] = round(printed.printMs);
109
68
  trace.process['transfer'] = round(performance.now() - mark - printed.printMs);
110
69
  mark = performance.now();
111
70
  trace.caster = printed.caster;
112
71
  for (const warning of printed.warnings)
113
72
  console.warn(typeof warning === 'string' ? warning : JSON.stringify(warning));
114
- bytes = Buffer.from(printed.base64, 'base64');
115
- pages = printed.pages;
116
- words = printed.words;
117
73
  }
74
+ else
75
+ lap('print');
76
+ const { bytes, pages, words, screens } = printed;
118
77
  step = 'close';
119
78
  await browser.close();
120
79
  lap('close');
@@ -177,64 +136,3 @@ function versions(chromiumVersion) {
177
136
  chromium: chromiumVersion
178
137
  };
179
138
  }
180
- /** The run's refusal: what it could not answer comes first, because a missing file's name says more than the step it broke. */
181
- function refusal(missing, answer) {
182
- if (missing.size)
183
- return new BuildError(`beltype print: the document asked for what the run cannot give it, so the print would not be the document:\n${[...missing].map((m) => ` ${m}`).join('\n')}`);
184
- return new BuildError(`beltype print: the ${answer?.at ?? 'run'} step refused:\n${answer?.refused || '(the refusal carried no message)'}`);
185
- }
186
- /** `fragment` at the top of the document's `<head>`, ahead of the author's own sheets and scripts. */
187
- export function spliceHead(html, fragment) {
188
- const head = /<head\b[^>]*>/i.exec(html);
189
- if (head)
190
- return html.slice(0, head.index + head[0].length) + `\n ${fragment}` + html.slice(head.index + head[0].length);
191
- const before = /^\s*(<!doctype[^>]*>\s*)?(<html\b[^>]*>)?/i.exec(html)?.[0] ?? '';
192
- return `${before}<head>\n ${fragment}\n</head>${html.slice(before.length)}`;
193
- }
194
- // ponytail: routes only. A dedicated or shared worker's WebSocket and fetches pass no route, so a document that opens one is not refused; the monorepo's `cast` closes that with a proxy the run owns (packages/cli/src/cast.js), to be brought over when a document needs it.
195
- async function answerFromDisk(context, missing, o) {
196
- await context.routeWebSocket(/.*/, (ws) => {
197
- missing.add(`${ws.url()} (a WebSocket; print opens none)`);
198
- return ws.close({ code: 1008, reason: 'beltype print reaches no network' });
199
- });
200
- await context.route('**/*', async (route) => {
201
- const url = new URL(route.request().url());
202
- if (url.origin !== ORIGIN) {
203
- missing.add(`${url.href} (another origin; print fetches nothing from a network)`);
204
- return route.abort('blockedbyclient');
205
- }
206
- let pathname;
207
- try {
208
- pathname = decodeURIComponent(url.pathname);
209
- }
210
- catch {
211
- missing.add(`${url.pathname} (a malformed percent escape, which names no file)`);
212
- return route.fulfill({ status: 400, body: '' });
213
- }
214
- if (url.pathname === o.docPath)
215
- return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: o.served });
216
- const file = pathname.startsWith('/_beltype/') ? fromAssets(o.assets, pathname.slice('/_beltype/'.length)) : within(o.root, `.${pathname}`);
217
- if (file)
218
- return route.fulfill({ status: 200, path: file, contentType: contentType(file) });
219
- if (pathname !== '/favicon.ico')
220
- missing.add(pathname.startsWith('/_beltype/') ? `${pathname} (no companion holds it)` : `${pathname} (not in ${o.root})`);
221
- return route.fulfill({ status: 404, body: '' });
222
- });
223
- }
224
- function fromAssets(assets, assetPath) {
225
- const file = assets.files[assetPath];
226
- if (file)
227
- return isFile(file) ? file : null;
228
- for (const [prefix, dir] of Object.entries(assets.dirs)) {
229
- if (assetPath.startsWith(prefix + '/'))
230
- return within(dir, assetPath.slice(prefix.length + 1));
231
- }
232
- return null;
233
- }
234
- /** `rel` under `dir`, if it is a file there and does not climb out. */
235
- function within(dir, rel) {
236
- const file = path.resolve(dir, rel);
237
- const inside = path.relative(dir, file);
238
- return !inside.startsWith('..') && !path.isAbsolute(inside) && isFile(file) ? file : null;
239
- }
240
- const isFile = (file) => fs.existsSync(file) && fs.statSync(file).isFile();
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The steps of a print as functions of a page, so `beltype print` and a server that keeps a browser alive print the same way.
3
+ *
4
+ * @remarks
5
+ * **Every request is answered from disk, on one made-up origin.** {@link routeCompanions} answers on the browser context what no document owns: a WebSocket and another origin are refused and named, `/_beltype/` is answered from the companions' built files. {@link routeDocument} answers on a page the document at its own name and its folder at the root, and lets everything else fall back to the context's routes (Playwright runs a page's routes before its context's). {@link printOnPage} then loads, cuts, screenshots when asked and prints, and throws the refusals `print` has always thrown.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { ciceroEngine } from '@beltype/cicero/engine';
13
+ import { BuildError } from '@beltype/common/errors';
14
+ import { contentType } from '@beltype/common/mime';
15
+ import { cutInPage, printInPage, pageSizesInPage, chromiumSheetInPage } from './in-page.js';
16
+ import { chromiumSheet } from './chromium.js';
17
+ import { screenshotPages } from './check.js';
18
+ /** `.invalid` is reserved (RFC 2606), so nothing on a network can ever answer it instead. */
19
+ export const ORIGIN = 'https://print.invalid';
20
+ export const VIEWPORT = { width: 1200, height: 1600 };
21
+ /** Deterministic text and timers that run while nobody watches the page. */
22
+ export const ARGS = ['--font-render-hinting=none', '--force-device-scale-factor=1', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'];
23
+ /** The companions' built folders and files, each folder found from a module it exports: common exports no `package.json`. */
24
+ export function documentAssets() {
25
+ const distOf = (specifier, up) => path.resolve(path.dirname(fileURLToPath(import.meta.resolve(specifier))), ...Array(up).fill('..'));
26
+ const cicero = ciceroEngine.vendorAssets();
27
+ return { files: cicero.files, dirs: { ...cicero.dirs, caster: distOf('@beltype/caster', 1), common: distOf('@beltype/common/errors', 0) } };
28
+ }
29
+ /** The document as the page is served it: Caster's import map and Cicero's head ahead of the author's own. */
30
+ export function servedHtml(html) {
31
+ // Caster's modules import common by its package names, which a page resolves only through an import map.
32
+ const imports = Object.fromEntries(['errors', 'data-beltype', 'flags', 'mime'].map((m) => [`@beltype/common/${m}`, `/_beltype/common/${m}.js`]));
33
+ return spliceHead(html, `<script type="importmap">${JSON.stringify({ imports })}</script>\n ${ciceroEngine.headHtml('/')}`);
34
+ }
35
+ /** `fragment` at the top of the document's `<head>`, ahead of the author's own sheets and scripts. */
36
+ export function spliceHead(html, fragment) {
37
+ const head = /<head\b[^>]*>/i.exec(html);
38
+ if (head)
39
+ return html.slice(0, head.index + head[0].length) + `\n ${fragment}` + html.slice(head.index + head[0].length);
40
+ const before = /^\s*(<!doctype[^>]*>\s*)?(<html\b[^>]*>)?/i.exec(html)?.[0] ?? '';
41
+ return `${before}<head>\n ${fragment}\n</head>${html.slice(before.length)}`;
42
+ }
43
+ // ponytail: routes only. A dedicated or shared worker's WebSocket and fetches pass no route, so a document that opens one is not refused; the monorepo's `cast` closes that with a proxy the run owns (packages/cli/src/cast.js), to be brought over when a document needs it.
44
+ /** What no document owns, on the context: refuse a WebSocket or another origin by name, and answer `/_beltype/` from the companions. */
45
+ export async function routeCompanions(context, missing, assets = documentAssets()) {
46
+ await context.routeWebSocket(/.*/, (ws) => {
47
+ missing.add(`${ws.url()} (a WebSocket; print opens none)`);
48
+ return ws.close({ code: 1008, reason: 'beltype print reaches no network' });
49
+ });
50
+ await context.route('**/*', async (route) => {
51
+ const url = new URL(route.request().url());
52
+ if (url.origin !== ORIGIN) {
53
+ missing.add(`${url.href} (another origin; print fetches nothing from a network)`);
54
+ return route.abort('blockedbyclient');
55
+ }
56
+ const pathname = decoded(url, missing);
57
+ if (pathname === null)
58
+ return route.fulfill({ status: 400, body: '' });
59
+ const file = pathname.startsWith('/_beltype/') ? fromAssets(assets, pathname.slice('/_beltype/'.length)) : null;
60
+ if (file)
61
+ return route.fulfill({ status: 200, path: file, contentType: contentType(file) });
62
+ missing.add(`${pathname} (${pathname.startsWith('/_beltype/') ? 'no companion holds it' : 'no document answered it'})`);
63
+ return route.fulfill({ status: 404, body: '' });
64
+ });
65
+ }
66
+ /** The document's own files on a page: the document at `docPath`, its folder `root` at the root. Another origin and `/_beltype/` fall back to the context's routes. */
67
+ export async function routeDocument(page, missing, o) {
68
+ await page.route('**/*', async (route) => {
69
+ const url = new URL(route.request().url());
70
+ if (url.origin !== ORIGIN)
71
+ return route.fallback();
72
+ const pathname = decoded(url, missing);
73
+ if (pathname === null)
74
+ return route.fulfill({ status: 400, body: '' });
75
+ if (pathname.startsWith('/_beltype/'))
76
+ return route.fallback();
77
+ if (url.pathname === o.docPath)
78
+ return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: o.served });
79
+ const file = within(o.root, `.${pathname}`);
80
+ if (file)
81
+ return route.fulfill({ status: 200, path: file, contentType: contentType(file) });
82
+ if (pathname !== '/favicon.ico')
83
+ missing.add(`${pathname} (not in ${o.root})`);
84
+ return route.fulfill({ status: 404, body: '' });
85
+ });
86
+ }
87
+ /**
88
+ * Load the document on `page`, cut it, screenshot its pages when `--check` asks, and print it under the flags' printer.
89
+ *
90
+ * @remarks
91
+ * What the run could not answer after the print (`missing`) is the caller's to test with {@link refusal}: `print` tests it after closing the browser, as it always has.
92
+ * @throws {BuildError} where the cut or the print refused, or the document asked for what the run could not give it.
93
+ */
94
+ export async function printOnPage(page, missing, flags, o) {
95
+ const { progress } = o;
96
+ page.on('pageerror', (error) => console.warn(`page error: ${error.message}`));
97
+ progress?.step('load');
98
+ await page.goto(ORIGIN + o.docPath, { waitUntil: 'load' });
99
+ progress?.lap('load');
100
+ progress?.step('cut');
101
+ const cut = await page.evaluate(cutInPage, { cicero: flags.cicero, measures: o.measures === true });
102
+ progress?.lap('cut');
103
+ const measures = 'refused' in cut ? [] : cut.measures;
104
+ progress?.cicero?.(measures);
105
+ if ('refused' in cut || missing.size)
106
+ throw refusal(missing, 'refused' in cut ? cut : null);
107
+ let screens = [];
108
+ if (flags.beltype.check) {
109
+ progress?.step('check');
110
+ screens = await screenshotPages(page);
111
+ progress?.lap('check');
112
+ }
113
+ progress?.step('print');
114
+ if (flags.beltype.printer === 'chromium') {
115
+ const sheet = chromiumSheet(await page.evaluate(pageSizesInPage));
116
+ await page.evaluate(chromiumSheetInPage, sheet);
117
+ const bytes = await page.pdf({ preferCSSPageSize: true, printBackground: true });
118
+ return { bytes, pages: await page.locator('beltype-page').count(), words: 0, warnings: [], caster: null, cicero: measures, screens };
119
+ }
120
+ const printed = await page.evaluate(printInPage, { options: flags.caster, ...(o.created !== undefined ? { created: o.created } : {}), ...(o.sharedWriter ? { sharedWriter: true } : {}) });
121
+ if ('refused' in printed)
122
+ throw refusal(missing, printed);
123
+ return { bytes: Buffer.from(printed.base64, 'base64'), pages: printed.pages, words: printed.words, warnings: printed.warnings, caster: printed.caster, cicero: measures, screens, printMs: printed.printMs };
124
+ }
125
+ /** The run's refusal: what it could not answer comes first, because a missing file's name says more than the step it broke. */
126
+ export function refusal(missing, answer) {
127
+ if (missing.size)
128
+ return new BuildError(`beltype print: the document asked for what the run cannot give it, so the print would not be the document:\n${[...missing].map((m) => ` ${m}`).join('\n')}`);
129
+ return new BuildError(`beltype print: the ${answer?.at ?? 'run'} step refused:\n${answer?.refused || '(the refusal carried no message)'}`);
130
+ }
131
+ /** The percent-decoded path of `url`, or null (and the name of what it asked) where the escape is malformed. */
132
+ function decoded(url, missing) {
133
+ try {
134
+ return decodeURIComponent(url.pathname);
135
+ }
136
+ catch {
137
+ missing.add(`${url.pathname} (a malformed percent escape, which names no file)`);
138
+ return null;
139
+ }
140
+ }
141
+ function fromAssets(assets, assetPath) {
142
+ const file = assets.files[assetPath];
143
+ if (file)
144
+ return isFile(file) ? file : null;
145
+ for (const [prefix, dir] of Object.entries(assets.dirs)) {
146
+ if (assetPath.startsWith(prefix + '/'))
147
+ return within(dir, assetPath.slice(prefix.length + 1));
148
+ }
149
+ return null;
150
+ }
151
+ /** `rel` under `dir`, if it is a file there and does not climb out. */
152
+ function within(dir, rel) {
153
+ const file = path.resolve(dir, rel);
154
+ const inside = path.relative(dir, file);
155
+ return !inside.startsWith('..') && !path.isAbsolute(inside) && isFile(file) ? file : null;
156
+ }
157
+ const isFile = (file) => fs.existsSync(file) && fs.statSync(file).isFile();
@@ -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.4",
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,7 +33,7 @@
32
33
  ]
33
34
  },
34
35
  "dependencies": {
35
- "@beltype/caster": "0.1.5",
36
+ "@beltype/caster": "0.1.6",
36
37
  "@beltype/cicero": "0.1.5",
37
38
  "@beltype/common": "0.1.2",
38
39
  "mupdf": "1.28.1",