@beltype/cli 0.1.4 → 0.1.6
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 +18 -0
- package/assets/warmup/OFL-SourceSans3.txt +93 -0
- package/assets/warmup/SourceSans3-Variable.woff2 +0 -0
- package/dist/src/flags.js +9 -0
- package/dist/src/in-page.js +19 -3
- package/dist/src/main.js +37 -6
- package/dist/src/print.js +17 -119
- package/dist/src/run.js +161 -0
- package/dist/src/serve.js +318 -0
- package/package.json +6 -5
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,26 @@ 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
|
+
- A document whose text holds no `<script`, `<iframe`, `<object`, `<embed`, `<frame`, `<portal`, `javascript:` or `on…=` attribute is printed on one kept page, re-seated for each print as Caster's kept frame is (`document.open()` keeps the realm, so Caster's registry and the companions' modules stay warm); any other document gets a page of its own, because a script's timers, listeners and globals would outlive it on a kept `Window`. The log line of each print names the road (`road kept` or `road fresh`) and the time of every step. A print that fails or times out closes the kept page.
|
|
40
|
+
- 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.
|
|
41
|
+
- 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/`.
|
|
42
|
+
|
|
25
43
|
## The sample
|
|
26
44
|
|
|
27
45
|
`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.
|
|
Binary file
|
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
|
+
}
|
package/dist/src/in-page.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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 = '';
|
|
@@ -69,3 +70,18 @@ export function chromiumSheetInPage(sheet) {
|
|
|
69
70
|
shadow.textContent = sheet.shadow;
|
|
70
71
|
document.querySelector('beltype-root')?.shadowRoot?.appendChild(shadow);
|
|
71
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Seat `html` as the page's document without a navigation: `document.open()` keeps the `Window`, so the realm Caster measures from, its modules and its capability registry stay (Caster's DESIGN.md S87, S89). The `adoptedStyleSheets` the last document left on the Document are emptied; `load` is awaited as a navigation's would be.
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* The previous Cicero client is released first (`release()`, Cicero 0.1.6, SPEC XII.7): the `Window` and its one `FontFaceSet` outlive `document.open()`, and an unreleased client heard a face of the next document's family arrive as a late fact and ran beside the host's run, which was let go after one page (measured: the paper after a document of another family cut 1 page, not 4). A released client deletes its handle, so the next document's module scripts must load under a URL of their own, which `serve.ts` keys per re-seat.
|
|
78
|
+
*/
|
|
79
|
+
export async function reseatInPage(html) {
|
|
80
|
+
await window.__beltypeCicero?.release?.();
|
|
81
|
+
document.open();
|
|
82
|
+
document.adoptedStyleSheets = [];
|
|
83
|
+
const loaded = new Promise((resolve) => window.addEventListener('load', () => resolve(), { once: true }));
|
|
84
|
+
document.write(html);
|
|
85
|
+
document.close();
|
|
86
|
+
await loaded;
|
|
87
|
+
}
|
package/dist/src/main.js
CHANGED
|
@@ -2,19 +2,48 @@
|
|
|
2
2
|
* The `beltype` command.
|
|
3
3
|
*
|
|
4
4
|
* @remarks
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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 {
|
|
22
|
-
import {
|
|
23
|
-
|
|
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
|
-
|
|
56
|
-
const
|
|
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
|
|
56
|
+
await routeCompanions(context, missing, assets);
|
|
73
57
|
const page = await context.newPage();
|
|
74
|
-
page
|
|
58
|
+
await routeDocument(page, missing, { root, docPath, served });
|
|
75
59
|
lap('context');
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
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();
|
package/dist/src/run.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
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, reseatInPage, 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
|
+
progress?.step('load');
|
|
97
|
+
if (o.reseat !== undefined)
|
|
98
|
+
await page.evaluate(reseatInPage, o.reseat);
|
|
99
|
+
else {
|
|
100
|
+
page.on('pageerror', (error) => console.warn(`page error: ${error.message}`));
|
|
101
|
+
await page.goto(ORIGIN + o.docPath, { waitUntil: 'load' });
|
|
102
|
+
}
|
|
103
|
+
progress?.lap('load');
|
|
104
|
+
progress?.step('cut');
|
|
105
|
+
const cut = await page.evaluate(cutInPage, { cicero: flags.cicero, measures: o.measures === true });
|
|
106
|
+
progress?.lap('cut');
|
|
107
|
+
const measures = 'refused' in cut ? [] : cut.measures;
|
|
108
|
+
progress?.cicero?.(measures);
|
|
109
|
+
if ('refused' in cut || missing.size)
|
|
110
|
+
throw refusal(missing, 'refused' in cut ? cut : null);
|
|
111
|
+
let screens = [];
|
|
112
|
+
if (flags.beltype.check) {
|
|
113
|
+
progress?.step('check');
|
|
114
|
+
screens = await screenshotPages(page);
|
|
115
|
+
progress?.lap('check');
|
|
116
|
+
}
|
|
117
|
+
progress?.step('print');
|
|
118
|
+
if (flags.beltype.printer === 'chromium') {
|
|
119
|
+
const sheet = chromiumSheet(await page.evaluate(pageSizesInPage));
|
|
120
|
+
await page.evaluate(chromiumSheetInPage, sheet);
|
|
121
|
+
const bytes = await page.pdf({ preferCSSPageSize: true, printBackground: true });
|
|
122
|
+
return { bytes, pages: await page.locator('beltype-page').count(), words: 0, warnings: [], caster: null, cicero: measures, screens };
|
|
123
|
+
}
|
|
124
|
+
const printed = await page.evaluate(printInPage, { options: flags.caster, ...(o.created !== undefined ? { created: o.created } : {}), ...(o.sharedWriter ? { sharedWriter: true } : {}) });
|
|
125
|
+
if ('refused' in printed)
|
|
126
|
+
throw refusal(missing, printed);
|
|
127
|
+
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 };
|
|
128
|
+
}
|
|
129
|
+
/** The run's refusal: what it could not answer comes first, because a missing file's name says more than the step it broke. */
|
|
130
|
+
export function refusal(missing, answer) {
|
|
131
|
+
if (missing.size)
|
|
132
|
+
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')}`);
|
|
133
|
+
return new BuildError(`beltype print: the ${answer?.at ?? 'run'} step refused:\n${answer?.refused || '(the refusal carried no message)'}`);
|
|
134
|
+
}
|
|
135
|
+
/** The percent-decoded path of `url`, or null (and the name of what it asked) where the escape is malformed. */
|
|
136
|
+
function decoded(url, missing) {
|
|
137
|
+
try {
|
|
138
|
+
return decodeURIComponent(url.pathname);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
missing.add(`${url.pathname} (a malformed percent escape, which names no file)`);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function fromAssets(assets, assetPath) {
|
|
146
|
+
const file = assets.files[assetPath];
|
|
147
|
+
if (file)
|
|
148
|
+
return isFile(file) ? file : null;
|
|
149
|
+
for (const [prefix, dir] of Object.entries(assets.dirs)) {
|
|
150
|
+
if (assetPath.startsWith(prefix + '/'))
|
|
151
|
+
return within(dir, assetPath.slice(prefix.length + 1));
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
/** `rel` under `dir`, if it is a file there and does not climb out. */
|
|
156
|
+
function within(dir, rel) {
|
|
157
|
+
const file = path.resolve(dir, rel);
|
|
158
|
+
const inside = path.relative(dir, file);
|
|
159
|
+
return !inside.startsWith('..') && !path.isAbsolute(inside) && isFile(file) ? file : null;
|
|
160
|
+
}
|
|
161
|
+
const isFile = (file) => fs.existsSync(file) && fs.statSync(file).isFile();
|
|
@@ -0,0 +1,318 @@
|
|
|
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 kept print page, for a document with no script of its own.** A print re-seats the kept page as Caster's kept frame does (`document.open()` keeps the `Window`, so the realm, Caster's registry and the companions' modules stay warm; *the printer runs in the realm it prints*, Caster's DESIGN.md S20, S87, S89): its routes are swapped for the print's folder, the served document is written in, and each module script the document names gets a query of its own. That page is shared only by a document whose text holds none of `<script`, `<iframe`, `<object`, `<embed`, `<frame`, `<portal`, `javascript:` or an `on…=` attribute ({@link keptRoad}); any other gets a new page, and the log line names the road. A print that fails or times out closes the kept page, and the next opens a new one.
|
|
8
|
+
*
|
|
9
|
+
* **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.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { chromium } from 'playwright-core';
|
|
19
|
+
import { ciceroFlags } from '@beltype/cicero/flags';
|
|
20
|
+
import { BuildError, ValidationError, FlagError } from '@beltype/common/errors';
|
|
21
|
+
import { cliFlags, createdFromEnv, parseFlagArgs } from './flags.js';
|
|
22
|
+
import { ARGS, ORIGIN, VIEWPORT, documentAssets, servedHtml, routeCompanions, routeDocument, printOnPage, refusal } from './run.js';
|
|
23
|
+
const HOLDER_PATH = '/_beltype/holder.html';
|
|
24
|
+
const HOLDER = `<!doctype html><html><head><meta charset="utf-8"><title>holder</title></head><body><script type="module">
|
|
25
|
+
const c = await import('/_beltype/caster/src/index.js');
|
|
26
|
+
window.sw = new SharedWorker(c.formeSharedWorkerUrl(), { type: 'module', name: 'forme' });
|
|
27
|
+
</script></body></html>`;
|
|
28
|
+
// Run from src/ or from dist/src/: the package root is the first folder up that holds `assets`.
|
|
29
|
+
export 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));
|
|
30
|
+
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(' ');
|
|
31
|
+
const paragraph = (n) => Array.from({ length: 90 }, (_, i) => WORDS[(i * 7 + n * 3) % WORDS.length]).join(' ') + '.';
|
|
32
|
+
/** 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. */
|
|
33
|
+
export 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>`;
|
|
34
|
+
const DOC_PATH = '/index.html';
|
|
35
|
+
const lapper = (steps) => {
|
|
36
|
+
const began = {};
|
|
37
|
+
return { start: (name) => { began[name] = performance.now(); }, end: (name) => { steps[name] = performance.now() - (began[name] ?? performance.now()); } };
|
|
38
|
+
};
|
|
39
|
+
const stepsLine = ({ road, ms: steps }, printed) => `road ${road}; ${['page', 'load', 'cut', 'print', 'close'].map((n) => `${n} ${Math.round(steps[n] ?? 0)}`).join(', ')}; caster ${Object.entries(printed.caster?.ms ?? {}).map(([k, v]) => `${k} ${Math.round(Number(v))}`).join(', ')}`;
|
|
40
|
+
/** Whether a document may share the kept page: only one with no script of its own (the `Window` outlives `document.open()`, and so does a timer, a listener or a global a script left on it). The test is conservative; a false alarm costs only speed. */
|
|
41
|
+
export const keptRoad = (html) => !/<script|<iframe|<object|<embed|<frame|<portal|javascript:|\son[a-z]+\s*=/i.test(html);
|
|
42
|
+
class HttpError extends Error {
|
|
43
|
+
status;
|
|
44
|
+
constructor(status, message) { super(message); this.status = status; }
|
|
45
|
+
}
|
|
46
|
+
export async function serve(options) {
|
|
47
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
48
|
+
const assets = documentAssets();
|
|
49
|
+
const missing = new Set();
|
|
50
|
+
let kept = null;
|
|
51
|
+
let reseats = 0;
|
|
52
|
+
let browser = null;
|
|
53
|
+
let context = null;
|
|
54
|
+
let version = 'unknown';
|
|
55
|
+
let restarting = false;
|
|
56
|
+
let needsRestart = false;
|
|
57
|
+
let prints = 0;
|
|
58
|
+
let sinceStart = 0;
|
|
59
|
+
let queued = 0;
|
|
60
|
+
let tail = Promise.resolve();
|
|
61
|
+
const stopBrowser = async () => {
|
|
62
|
+
const old = browser;
|
|
63
|
+
browser = context = kept = null;
|
|
64
|
+
await old?.close().catch(() => { });
|
|
65
|
+
};
|
|
66
|
+
/** The kept page, opened on a blank document at the document's own path so that relative references resolve as a print's would. */
|
|
67
|
+
const openKept = async () => {
|
|
68
|
+
const page = await context.newPage();
|
|
69
|
+
page.on('pageerror', (error) => console.warn(`page error: ${error.message}`));
|
|
70
|
+
await routeDocument(page, missing, { root: path.join(os.tmpdir(), 'beltype-serve-none'), docPath: DOC_PATH, served: servedHtml('<!doctype html><html><head></head><body></body></html>') });
|
|
71
|
+
await page.goto(ORIGIN + DOC_PATH, { waitUntil: 'load' });
|
|
72
|
+
return kept = page;
|
|
73
|
+
};
|
|
74
|
+
/** Closes `page`, and forgets it if it is the kept one: a page a failed or timed-out print used is never reused. */
|
|
75
|
+
const closePage = async (page) => {
|
|
76
|
+
if (kept === page)
|
|
77
|
+
kept = null;
|
|
78
|
+
await page.close().catch(() => { });
|
|
79
|
+
};
|
|
80
|
+
/** Run `html` and `files` through the print's road: re-seated on the kept page where the document carries no script of its own, else on a new page. The folder is gone after. */
|
|
81
|
+
const render = async (files, given, created, trace = { road: 'fresh', ms: {} }) => {
|
|
82
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'beltype-serve-'));
|
|
83
|
+
try {
|
|
84
|
+
for (const [name, bytes] of files) {
|
|
85
|
+
const file = path.join(root, ...name.split('/'));
|
|
86
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
87
|
+
fs.writeFileSync(file, bytes);
|
|
88
|
+
}
|
|
89
|
+
const flags = { caster: given.caster, cicero: ciceroFlags.resolve(given.cicero), beltype: cliFlags.resolve(given.beltype) };
|
|
90
|
+
const author = Buffer.from(files.get('index.html')).toString('utf8');
|
|
91
|
+
const served = servedHtml(author);
|
|
92
|
+
const keep = keptRoad(author);
|
|
93
|
+
trace.road = keep ? 'kept' : 'fresh';
|
|
94
|
+
missing.clear();
|
|
95
|
+
const lapped = lapper(trace.ms);
|
|
96
|
+
lapped.start('page');
|
|
97
|
+
const page = keep ? (kept ?? await openKept()) : await context.newPage();
|
|
98
|
+
try {
|
|
99
|
+
if (keep)
|
|
100
|
+
await page.unrouteAll({ behavior: 'wait' });
|
|
101
|
+
await routeDocument(page, missing, { root, docPath: DOC_PATH, served });
|
|
102
|
+
lapped.end('page');
|
|
103
|
+
const reseat = keep ? served.replace(/(<script type="module" src="[^"?]+)"/g, `$1?kept=${++reseats}"`) : undefined;
|
|
104
|
+
const printed = await printOnPage(page, missing, flags, { docPath: DOC_PATH, sharedWriter: true, progress: { step: lapped.start, lap: lapped.end }, ...(reseat !== undefined ? { reseat } : {}), ...(created !== undefined ? { created } : {}) });
|
|
105
|
+
lapped.end('print');
|
|
106
|
+
if (missing.size)
|
|
107
|
+
throw refusal(missing, null);
|
|
108
|
+
if (!keep) {
|
|
109
|
+
lapped.start('close');
|
|
110
|
+
await closePage(page);
|
|
111
|
+
lapped.end('close');
|
|
112
|
+
}
|
|
113
|
+
return printed;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
await closePage(page);
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const start = async () => {
|
|
125
|
+
browser = await chromium.launch({ headless: true, args: ARGS });
|
|
126
|
+
const mine = browser;
|
|
127
|
+
version = mine.version();
|
|
128
|
+
mine.on('disconnected', () => { if (browser === mine)
|
|
129
|
+
needsRestart = true; });
|
|
130
|
+
options.onBrowser?.(mine);
|
|
131
|
+
context = await mine.newContext({ viewport: VIEWPORT, serviceWorkers: 'block' });
|
|
132
|
+
await routeCompanions(context, missing, assets);
|
|
133
|
+
// Registered after the companions' routes, so it runs first.
|
|
134
|
+
await context.route(ORIGIN + HOLDER_PATH, (route) => route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: servedHtml(HOLDER) }));
|
|
135
|
+
const holder = await context.newPage();
|
|
136
|
+
await holder.goto(ORIGIN + HOLDER_PATH, { waitUntil: 'load' });
|
|
137
|
+
await holder.waitForFunction(() => window.sw !== undefined);
|
|
138
|
+
await openKept();
|
|
139
|
+
const given = await parseFlagArgs(['caster.writer=forme']);
|
|
140
|
+
for (const n of [1, 2]) {
|
|
141
|
+
const printed = await render(new Map([['index.html', Buffer.from(WARMUP)], ['warm.woff2', fs.readFileSync(WARMUP_FONT)]]), given, undefined);
|
|
142
|
+
log(`beltype serve: warm-up ${n}: ${printed.pages} pages, forme write ${printed.caster?.ms['emit'] ?? '?'} ms`);
|
|
143
|
+
}
|
|
144
|
+
needsRestart = false;
|
|
145
|
+
sinceStart = 0;
|
|
146
|
+
};
|
|
147
|
+
const restart = async () => {
|
|
148
|
+
restarting = true;
|
|
149
|
+
try {
|
|
150
|
+
await stopBrowser();
|
|
151
|
+
log('beltype serve: restarting the browser');
|
|
152
|
+
await start();
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
needsRestart = true;
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
restarting = false;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
/** Runs `job` after every earlier job and after any restart it needs; the restart a job asks for happens before the next one. */
|
|
163
|
+
const enqueue = (job) => {
|
|
164
|
+
queued++;
|
|
165
|
+
const result = tail.then(async () => {
|
|
166
|
+
queued--;
|
|
167
|
+
if (needsRestart || sinceStart >= options.restartAfter)
|
|
168
|
+
await restart();
|
|
169
|
+
return job();
|
|
170
|
+
});
|
|
171
|
+
tail = result.then(async () => { if (needsRestart || sinceStart >= options.restartAfter)
|
|
172
|
+
await restart(); }).catch(() => { });
|
|
173
|
+
return result;
|
|
174
|
+
};
|
|
175
|
+
await start();
|
|
176
|
+
const print = async (req) => {
|
|
177
|
+
const form = await readForm(req, options.maxUpload);
|
|
178
|
+
const files = new Map();
|
|
179
|
+
const args = [];
|
|
180
|
+
let created;
|
|
181
|
+
for (const [name, value] of form.entries()) {
|
|
182
|
+
if (typeof value !== 'string') {
|
|
183
|
+
files.set(plainName(name), new Uint8Array(await value.arrayBuffer()));
|
|
184
|
+
}
|
|
185
|
+
else if (name === 'created') {
|
|
186
|
+
created = instant(value);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
if (name === 'beltype.check')
|
|
190
|
+
throw new ValidationError('beltype serve: beltype.check is refused; the server writes no files of its own.');
|
|
191
|
+
args.push(`${name}=${value}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!files.has('index.html'))
|
|
195
|
+
throw new ValidationError('beltype serve: the form has no index.html file.');
|
|
196
|
+
const given = await parseFlagArgs(args);
|
|
197
|
+
created ??= createdFromEnv();
|
|
198
|
+
const writer = String(given.caster['writer'] ?? 'pdf-lib');
|
|
199
|
+
return enqueue(async () => {
|
|
200
|
+
const id = prints + 1;
|
|
201
|
+
log(`beltype serve: print ${id} started`);
|
|
202
|
+
const t0 = performance.now();
|
|
203
|
+
const trace = { road: 'fresh', ms: {} };
|
|
204
|
+
const run = render(files, given, created, trace);
|
|
205
|
+
let timer;
|
|
206
|
+
const limit = new Promise((_, reject) => { timer = setTimeout(() => reject(new HttpError(504, `beltype serve: the print took over ${options.timeout / 1000} s.`)), options.timeout); });
|
|
207
|
+
try {
|
|
208
|
+
const printed = await Promise.race([run, limit]);
|
|
209
|
+
prints++;
|
|
210
|
+
sinceStart++;
|
|
211
|
+
log(`beltype serve: print ${id}: 200, ${printed.pages} pages, ${writer}, ${Math.round(performance.now() - t0)} ms (write ${printed.caster?.ms['emit'] ?? '?'} ms)`);
|
|
212
|
+
log(`beltype serve: print ${id}: ${stepsLine(trace, printed)}`);
|
|
213
|
+
return { bytes: printed.bytes, printed, writer };
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
prints++;
|
|
217
|
+
sinceStart++;
|
|
218
|
+
run.catch(() => { });
|
|
219
|
+
if (error instanceof HttpError && kept)
|
|
220
|
+
await closePage(kept);
|
|
221
|
+
if (!(error instanceof BuildError) && !(error instanceof HttpError))
|
|
222
|
+
needsRestart = true;
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
};
|
|
230
|
+
const server = http.createServer(async (req, res) => {
|
|
231
|
+
const send = (status, type, body, headers = {}) => {
|
|
232
|
+
res.writeHead(status, { 'content-type': type, ...headers });
|
|
233
|
+
res.end(body);
|
|
234
|
+
};
|
|
235
|
+
const text = (status, message) => send(status, 'text/plain; charset=utf-8', message + '\n');
|
|
236
|
+
try {
|
|
237
|
+
const pathname = new URL(req.url ?? '/', 'http://x').pathname;
|
|
238
|
+
if (pathname === '/health') {
|
|
239
|
+
if (req.method !== 'GET')
|
|
240
|
+
return text(405, 'beltype serve: /health takes GET.');
|
|
241
|
+
return send(restarting ? 503 : 200, 'application/json', JSON.stringify({ status: restarting ? 'restarting' : 'up', chromium: version, prints, queued }));
|
|
242
|
+
}
|
|
243
|
+
if (pathname !== '/print')
|
|
244
|
+
return text(404, `beltype serve: no ${pathname}; there is POST /print and GET /health.`);
|
|
245
|
+
if (req.method !== 'POST')
|
|
246
|
+
return text(405, 'beltype serve: /print takes POST.');
|
|
247
|
+
const { bytes, printed, writer } = await print(req);
|
|
248
|
+
for (const warning of printed.warnings)
|
|
249
|
+
log(`beltype serve: warning: ${typeof warning === 'string' ? warning : JSON.stringify(warning)}`);
|
|
250
|
+
send(200, 'application/pdf', bytes, { 'x-beltype-pages': printed.pages, 'x-beltype-words': printed.words, 'x-beltype-warnings': printed.warnings.length });
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
254
|
+
const status = error instanceof HttpError ? error.status : error instanceof BuildError ? 422 : error instanceof ValidationError || error instanceof FlagError ? 400 : 500;
|
|
255
|
+
log(`beltype serve: ${req.method} ${req.url}: ${status}`);
|
|
256
|
+
if (status === 413) {
|
|
257
|
+
// 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.
|
|
258
|
+
res.writeHead(413, { 'content-type': 'text/plain; charset=utf-8' });
|
|
259
|
+
res.end(message + '\n', () => {
|
|
260
|
+
req.resume();
|
|
261
|
+
setTimeout(() => req.destroy(), 1000).unref();
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
else if (!res.headersSent)
|
|
265
|
+
text(status, message);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(options.port, options.host, resolve); });
|
|
269
|
+
const address = server.address();
|
|
270
|
+
const url = `http://${options.host}:${address.port}`;
|
|
271
|
+
log(`beltype serve: listening on ${url}`);
|
|
272
|
+
return {
|
|
273
|
+
url,
|
|
274
|
+
async close() {
|
|
275
|
+
server.closeAllConnections();
|
|
276
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
277
|
+
await tail;
|
|
278
|
+
await stopBrowser();
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/** 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. */
|
|
283
|
+
async function readForm(req, limit) {
|
|
284
|
+
const declared = Number(req.headers['content-length'] ?? 0);
|
|
285
|
+
if (declared > limit)
|
|
286
|
+
throw new HttpError(413, `beltype serve: the upload is ${declared} bytes, over the limit of ${limit}.`);
|
|
287
|
+
const chunks = [];
|
|
288
|
+
let size = 0;
|
|
289
|
+
for await (const chunk of req) {
|
|
290
|
+
size += chunk.length;
|
|
291
|
+
if (size > limit)
|
|
292
|
+
throw new HttpError(413, `beltype serve: the upload is over the limit of ${limit} bytes.`);
|
|
293
|
+
chunks.push(chunk);
|
|
294
|
+
}
|
|
295
|
+
const type = req.headers['content-type'] ?? '';
|
|
296
|
+
if (!/^multipart\/form-data/i.test(type))
|
|
297
|
+
throw new ValidationError('beltype serve: POST /print takes multipart/form-data.');
|
|
298
|
+
try {
|
|
299
|
+
return await new Response(Buffer.concat(chunks), { headers: { 'content-type': type } }).formData();
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
throw new ValidationError('beltype serve: the body is not a well-formed multipart/form-data.');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** 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. */
|
|
306
|
+
function plainName(name) {
|
|
307
|
+
const segments = name.split('/');
|
|
308
|
+
if (!name || segments.some((s) => s === '' || s === '.' || s === '..' || /[\\\0:]/.test(s)))
|
|
309
|
+
throw new ValidationError(`beltype serve: the file name "${name}" is not a plain relative path with / separators (no absolute path, no "..").`);
|
|
310
|
+
return name;
|
|
311
|
+
}
|
|
312
|
+
/** An ISO 8601 instant, normalised. @throws {ValidationError} */
|
|
313
|
+
function instant(value) {
|
|
314
|
+
const date = new Date(value);
|
|
315
|
+
if (!/^\d{4}-\d\d-\d\dT/.test(value) || Number.isNaN(date.getTime()))
|
|
316
|
+
throw new ValidationError(`beltype serve: created is an ISO 8601 instant; got "${value}".`);
|
|
317
|
+
return date.toISOString();
|
|
318
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beltype/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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": "
|
|
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,8 +33,8 @@
|
|
|
32
33
|
]
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
|
-
"@beltype/caster": "0.1.
|
|
36
|
-
"@beltype/cicero": "0.1.
|
|
36
|
+
"@beltype/caster": "0.1.6",
|
|
37
|
+
"@beltype/cicero": "0.1.6",
|
|
37
38
|
"@beltype/common": "0.1.2",
|
|
38
39
|
"mupdf": "1.28.1",
|
|
39
40
|
"playwright-core": "1.63.0"
|