@beltype/cli 0.1.7 → 0.1.9
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 +13 -6
- package/assets/merge/NOTICE +7 -0
- package/assets/merge/merge.js +106 -0
- package/dist/src/check.js +127 -13
- package/dist/src/compare.js +47 -8
- package/dist/src/flags.js +2 -2
- package/dist/src/in-page.js +33 -6
- package/dist/src/main.js +24 -13
- package/dist/src/merge-tab.js +139 -0
- package/dist/src/print.js +12 -6
- package/dist/src/run.js +24 -5
- package/dist/src/serve.js +252 -57
- package/package.json +19 -7
package/dist/src/print.js
CHANGED
|
@@ -14,8 +14,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
14
14
|
import { chromium } from 'playwright-core';
|
|
15
15
|
import { ciceroFlags } from '@beltype/cicero/flags';
|
|
16
16
|
import { cliFlags, tableOf } from './flags.js';
|
|
17
|
-
import { checkPages } from './check.js';
|
|
18
|
-
import { ARGS, VIEWPORT, documentAssets, servedHtml, routeCompanions, routeDocument, printOnPage, refusal } from './run.js';
|
|
17
|
+
import { checkPages, requireMupdf } from './check.js';
|
|
18
|
+
import { ARGS, VIEWPORT, documentAssets, servedHtml, routeCompanions, routeDocument, printOnPage, refusal, deferralLine, standardOf } from './run.js';
|
|
19
19
|
export { spliceHead } from './run.js';
|
|
20
20
|
const round = (ms) => Math.round(ms * 10) / 10;
|
|
21
21
|
/**
|
|
@@ -41,13 +41,17 @@ export async function print(input, output, options = {}) {
|
|
|
41
41
|
let step = 'launch';
|
|
42
42
|
let chromiumVersion = 'unknown';
|
|
43
43
|
try {
|
|
44
|
+
// A run that refuses writes no file, so an earlier print at this path must not stand for it.
|
|
45
|
+
fs.rmSync(output, { force: true });
|
|
46
|
+
if (flags.beltype.check)
|
|
47
|
+
await requireMupdf();
|
|
44
48
|
const html = fs.readFileSync(input, 'utf8');
|
|
45
49
|
const root = path.dirname(path.resolve(input));
|
|
46
50
|
const docPath = `/${encodeURIComponent(path.basename(input))}`;
|
|
47
51
|
const assets = documentAssets();
|
|
48
52
|
const served = servedHtml(html);
|
|
49
53
|
const missing = new Set();
|
|
50
|
-
const browser = await chromium.launch({ headless: true, args: ARGS });
|
|
54
|
+
const browser = await chromium.launch({ headless: true, args: ARGS, chromiumSandbox: options.sandbox ?? true });
|
|
51
55
|
chromiumVersion = browser.version();
|
|
52
56
|
lap('launch');
|
|
53
57
|
try {
|
|
@@ -70,10 +74,12 @@ export async function print(input, output, options = {}) {
|
|
|
70
74
|
trace.caster = printed.caster;
|
|
71
75
|
for (const warning of printed.warnings)
|
|
72
76
|
console.warn(typeof warning === 'string' ? warning : JSON.stringify(warning));
|
|
77
|
+
for (const deferral of printed.deferred)
|
|
78
|
+
console.warn(deferralLine(deferral));
|
|
73
79
|
}
|
|
74
80
|
else
|
|
75
81
|
lap('print');
|
|
76
|
-
const { bytes, pages, words, screens } = printed;
|
|
82
|
+
const { bytes, pages, words, screens, confessions, conformance } = printed;
|
|
77
83
|
step = 'close';
|
|
78
84
|
await browser.close();
|
|
79
85
|
lap('close');
|
|
@@ -86,10 +92,10 @@ export async function print(input, output, options = {}) {
|
|
|
86
92
|
let check;
|
|
87
93
|
if (flags.beltype.check) {
|
|
88
94
|
step = 'check';
|
|
89
|
-
check = await checkPages(screens, bytes, path.resolve(output).replace(/\.pdf$/i, '') + '.check', flags.beltype.printer);
|
|
95
|
+
check = await checkPages(screens, bytes, path.resolve(output).replace(/\.pdf$/i, '') + '.check', flags.beltype.printer, confessions);
|
|
90
96
|
lap('check');
|
|
91
97
|
}
|
|
92
|
-
return { output, pages, words, ms: performance.now() - started, ...(check ? { check } : {}) };
|
|
98
|
+
return { output, pages, words, deferred: printed.deferred.length, standard: standardOf(conformance), ms: performance.now() - started, ...(check ? { check } : {}) };
|
|
93
99
|
}
|
|
94
100
|
finally {
|
|
95
101
|
await browser.close();
|
package/dist/src/run.js
CHANGED
|
@@ -24,7 +24,9 @@ export const ARGS = ['--font-render-hinting=none', '--force-device-scale-factor=
|
|
|
24
24
|
export function documentAssets() {
|
|
25
25
|
const distOf = (specifier, up) => path.resolve(path.dirname(fileURLToPath(import.meta.resolve(specifier))), ...Array(up).fill('..'));
|
|
26
26
|
const cicero = ciceroEngine.vendorAssets();
|
|
27
|
-
|
|
27
|
+
// Run from src/ or from dist/src/: the package root is the first folder up that holds `assets`.
|
|
28
|
+
const merge = [1, 2].map((up) => fileURLToPath(new URL(`${'../'.repeat(up)}assets/merge`, import.meta.url))).find((dir) => fs.existsSync(dir));
|
|
29
|
+
return { files: cicero.files, dirs: { ...cicero.dirs, caster: distOf('@beltype/caster', 1), common: distOf('@beltype/common/errors', 0), merge } };
|
|
28
30
|
}
|
|
29
31
|
/** The document as the page is served it: Caster's import map and Cicero's head ahead of the author's own. */
|
|
30
32
|
export function servedHtml(html) {
|
|
@@ -32,6 +34,8 @@ export function servedHtml(html) {
|
|
|
32
34
|
const imports = Object.fromEntries(['errors', 'data-beltype', 'flags', 'mime'].map((m) => [`@beltype/common/${m}`, `/_beltype/common/${m}.js`]));
|
|
33
35
|
return spliceHead(html, `<script type="importmap">${JSON.stringify({ imports })}</script>\n ${ciceroEngine.headHtml('/')}`);
|
|
34
36
|
}
|
|
37
|
+
/** One deferral as a line of its own after the run, so a print that approximated or dropped something says so (codeberg.org/BelType/cli issue 1). */
|
|
38
|
+
export const deferralLine = (d) => `deferred: ${d.path}: ${d.feature}, painted as ${d.paintedAs}`;
|
|
35
39
|
/** `fragment` at the top of the document's `<head>`, ahead of the author's own sheets and scripts. */
|
|
36
40
|
export function spliceHead(html, fragment) {
|
|
37
41
|
const head = /<head\b[^>]*>/i.exec(html);
|
|
@@ -101,8 +105,17 @@ export async function printOnPage(page, missing, flags, o) {
|
|
|
101
105
|
await page.goto(ORIGIN + o.docPath, { waitUntil: 'load' });
|
|
102
106
|
}
|
|
103
107
|
progress?.lap('load');
|
|
108
|
+
return cutAndPrint(page, missing, flags, o);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The second half of {@link printOnPage}, on a document already in the page: a host that writes the document itself, as a mail merge does, comes in here.
|
|
112
|
+
*
|
|
113
|
+
* @throws {BuildError} where the cut or the print refused.
|
|
114
|
+
*/
|
|
115
|
+
export async function cutAndPrint(page, missing, flags, o) {
|
|
116
|
+
const { progress } = o;
|
|
104
117
|
progress?.step('cut');
|
|
105
|
-
const cut = await page.evaluate(cutInPage, { cicero: flags.cicero, measures: o.measures === true });
|
|
118
|
+
const cut = await page.evaluate(cutInPage, { cicero: flags.cicero, measures: o.measures === true, ...(o.resumeAt ? { resumeAt: o.resumeAt } : {}) });
|
|
106
119
|
progress?.lap('cut');
|
|
107
120
|
const measures = 'refused' in cut ? [] : cut.measures;
|
|
108
121
|
progress?.cicero?.(measures);
|
|
@@ -119,12 +132,12 @@ export async function printOnPage(page, missing, flags, o) {
|
|
|
119
132
|
const sheet = chromiumSheet(await page.evaluate(pageSizesInPage));
|
|
120
133
|
await page.evaluate(chromiumSheetInPage, sheet);
|
|
121
134
|
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 };
|
|
135
|
+
return { bytes, pages: await page.locator('beltype-page').count(), words: 0, warnings: [], deferred: [], conformance: null, caster: null, cicero: measures, screens, confessions: [] };
|
|
123
136
|
}
|
|
124
137
|
const printed = await page.evaluate(printInPage, { options: flags.caster, ...(o.created !== undefined ? { created: o.created } : {}), ...(o.sharedWriter ? { sharedWriter: true } : {}) });
|
|
125
138
|
if ('refused' in printed)
|
|
126
139
|
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 };
|
|
140
|
+
return { bytes: Buffer.from(printed.base64, 'base64'), pages: printed.pages, words: printed.words, warnings: printed.warnings, deferred: printed.deferred, conformance: printed.conformance, caster: printed.caster, cicero: measures, screens, confessions: printed.confessions, printMs: printed.printMs };
|
|
128
141
|
}
|
|
129
142
|
/** 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
143
|
export function refusal(missing, answer) {
|
|
@@ -156,6 +169,12 @@ function fromAssets(assets, assetPath) {
|
|
|
156
169
|
function within(dir, rel) {
|
|
157
170
|
const file = path.resolve(dir, rel);
|
|
158
171
|
const inside = path.relative(dir, file);
|
|
159
|
-
|
|
172
|
+
if (inside.startsWith('..') || path.isAbsolute(inside) || !isFile(file))
|
|
173
|
+
return null;
|
|
174
|
+
// A link is followed to where it lands, and only a landing inside the folder is served.
|
|
175
|
+
const landed = path.relative(fs.realpathSync(dir), fs.realpathSync(file));
|
|
176
|
+
return !landed.startsWith('..') && !path.isAbsolute(landed) ? file : null;
|
|
160
177
|
}
|
|
178
|
+
/** What a print's file claims, as Caster's report says it: `pdf-2.0; pdf-ua-2`, or `pdf-2.0` alone where the claim was not asked for or was withdrawn; `null` where Caster never saw the file (`--printer chromium`). */
|
|
179
|
+
export const standardOf = (conformance) => conformance === null ? null : conformance === 'pdfua2' ? 'pdf-2.0; pdf-ua-2' : 'pdf-2.0';
|
|
161
180
|
const isFile = (file) => fs.existsSync(file) && fs.statSync(file).isFile();
|
package/dist/src/serve.js
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
* @remarks
|
|
5
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
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.
|
|
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, its JSON-LD data blocks aside, 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
|
+
* **A mail merge, on the same page.** `POST /templates` takes a template's folder once, and `POST /templates/<id>/print` takes one customer's values and rows as JSON. The first customer seats the template and merges in the tab; each later one replaces only the top-level boxes that changed and resumes the cut (`merge-tab.ts`), which keeps the sheets, the faces and the rewrite, and leaves nothing of the customer before. Where the shape of the body changed, or Cicero will not promise the patch, the template is seated again and the log line says why. Measured 2026-11-07 on the bolletta: the PDF byte for byte the folder route's, and 30 to 50 ms less on a 61-day bill (codeberg.org/BelType/cli issue 2).
|
|
8
10
|
*
|
|
9
11
|
* **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
12
|
*
|
|
11
13
|
* @packageDocumentation
|
|
12
14
|
*/
|
|
15
|
+
import crypto from 'node:crypto';
|
|
13
16
|
import fs from 'node:fs';
|
|
14
17
|
import http from 'node:http';
|
|
15
18
|
import os from 'node:os';
|
|
@@ -19,7 +22,8 @@ import { chromium } from 'playwright-core';
|
|
|
19
22
|
import { ciceroFlags } from '@beltype/cicero/flags';
|
|
20
23
|
import { BuildError, ValidationError, FlagError } from '@beltype/common/errors';
|
|
21
24
|
import { cliFlags, createdFromEnv, parseFlagArgs } from './flags.js';
|
|
22
|
-
import { ARGS, ORIGIN, VIEWPORT, documentAssets, servedHtml, routeCompanions, routeDocument, printOnPage, refusal } from './run.js';
|
|
25
|
+
import { ARGS, ORIGIN, VIEWPORT, documentAssets, servedHtml, routeCompanions, routeDocument, printOnPage, cutAndPrint, refusal, deferralLine, standardOf } from './run.js';
|
|
26
|
+
import { keepMergeInPage, reseatInPage, seatMergeInPage } from './in-page.js';
|
|
23
27
|
const HOLDER_PATH = '/_beltype/holder.html';
|
|
24
28
|
const HOLDER = `<!doctype html><html><head><meta charset="utf-8"><title>holder</title></head><body><script type="module">
|
|
25
29
|
const c = await import('/_beltype/caster/src/index.js');
|
|
@@ -31,23 +35,41 @@ const WORDS = 'the quick brown fox jumps over a lazy dog while leaf litter decom
|
|
|
31
35
|
const paragraph = (n) => Array.from({ length: 90 }, (_, i) => WORDS[(i * 7 + n * 3) % WORDS.length]).join(' ') + '.';
|
|
32
36
|
/** 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
37
|
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>`;
|
|
38
|
+
/** ponytail: the oldest template is dropped past this, and its id answers 404; a named delete when a host needs to hold more. */
|
|
39
|
+
const TEMPLATES_KEPT = 16;
|
|
34
40
|
const DOC_PATH = '/index.html';
|
|
41
|
+
/** The binds that listen on every address, under which a request's `Host` cannot be checked against one name. */
|
|
42
|
+
const WILDCARD = new Set(['0.0.0.0', '::', '[::]']);
|
|
35
43
|
const lapper = (steps) => {
|
|
36
44
|
const began = {};
|
|
37
45
|
return { start: (name) => { began[name] = performance.now(); }, end: (name) => { steps[name] = performance.now() - (began[name] ?? performance.now()); } };
|
|
38
46
|
};
|
|
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
|
-
/**
|
|
41
|
-
|
|
47
|
+
const stepsLine = ({ road, ms: steps }, printed) => `road ${road}; ${['page', 'load', 'merge', '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(', ')}`;
|
|
48
|
+
/** A JSON-LD data block, which the browser never runs: an opening tag with that attribute and at most a plain `id`, as a store host saves it, to the first `</script` (the tokenizer ends the element there or later, never sooner, so what is cut is always inside the block). */
|
|
49
|
+
const DATA_BLOCK = /<script\s+type\s*=\s*(["']?)application\/ld\+json\1(?:\s+id\s*=\s*(["']?)[\w-]+\2)?\s*>[\s\S]*?<\/script/gi;
|
|
50
|
+
/** 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). A JSON-LD data block is data, not script, and does not count. The test is conservative; a false alarm costs only speed. */
|
|
51
|
+
export const keptRoad = (html) => !/<script|<iframe|<object|<embed|<frame|<portal|javascript:|\bon[a-z]+\s*=/i.test(html.replace(DATA_BLOCK, ''));
|
|
42
52
|
class HttpError extends Error {
|
|
43
53
|
status;
|
|
44
54
|
constructor(status, message) { super(message); this.status = status; }
|
|
45
55
|
}
|
|
46
56
|
export async function serve(options) {
|
|
47
57
|
const log = options.log ?? ((line) => console.log(line));
|
|
58
|
+
const wildcard = WILDCARD.has(options.host);
|
|
59
|
+
const allowed = new Set((options.allowedHosts ?? []).map((h) => h.toLowerCase()));
|
|
60
|
+
if (wildcard && !allowed.size)
|
|
61
|
+
throw new ValidationError(`beltype serve: --host ${options.host} listens on every address, so the server cannot tell its own name from a rebound one; name the hosts it answers with --allowed-hosts <name[:port],…>.`);
|
|
48
62
|
const assets = documentAssets();
|
|
49
|
-
|
|
63
|
+
/** What the print running now could not answer; null between prints, so a page left over from one never writes into the next. */
|
|
64
|
+
let active = null;
|
|
65
|
+
/** The routes' one sink, context's and pages' alike: a kept page's routes outlive the print that set them. */
|
|
66
|
+
const missing = { add: (entry) => active?.add(entry) };
|
|
67
|
+
/** The page the print running now uses, which a timeout closes. */
|
|
68
|
+
let current = null;
|
|
50
69
|
let kept = null;
|
|
70
|
+
/** The template whose merge the kept page shows, or null when it shows anything else. */
|
|
71
|
+
let seated = null;
|
|
72
|
+
const templates = new Map();
|
|
51
73
|
let reseats = 0;
|
|
52
74
|
let browser = null;
|
|
53
75
|
let context = null;
|
|
@@ -60,7 +82,7 @@ export async function serve(options) {
|
|
|
60
82
|
let tail = Promise.resolve();
|
|
61
83
|
const stopBrowser = async () => {
|
|
62
84
|
const old = browser;
|
|
63
|
-
browser = context = kept = null;
|
|
85
|
+
browser = context = kept = seated = null;
|
|
64
86
|
await old?.close().catch(() => { });
|
|
65
87
|
};
|
|
66
88
|
/** The kept page, opened on a blank document at the document's own path so that relative references resolve as a print's would. */
|
|
@@ -69,17 +91,19 @@ export async function serve(options) {
|
|
|
69
91
|
page.on('pageerror', (error) => console.warn(`page error: ${error.message}`));
|
|
70
92
|
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
93
|
await page.goto(ORIGIN + DOC_PATH, { waitUntil: 'load' });
|
|
94
|
+
seated = null;
|
|
72
95
|
return kept = page;
|
|
73
96
|
};
|
|
74
97
|
/** Closes `page`, and forgets it if it is the kept one: a page a failed or timed-out print used is never reused. */
|
|
75
98
|
const closePage = async (page) => {
|
|
76
99
|
if (kept === page)
|
|
77
|
-
kept = null;
|
|
100
|
+
kept = seated = null;
|
|
78
101
|
await page.close().catch(() => { });
|
|
79
102
|
};
|
|
80
103
|
/** 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
104
|
const render = async (files, given, created, trace = { road: 'fresh', ms: {} }) => {
|
|
82
105
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'beltype-serve-'));
|
|
106
|
+
const mine = new Set();
|
|
83
107
|
try {
|
|
84
108
|
for (const [name, bytes] of files) {
|
|
85
109
|
const file = path.join(root, ...name.split('/'));
|
|
@@ -91,20 +115,22 @@ export async function serve(options) {
|
|
|
91
115
|
const served = servedHtml(author);
|
|
92
116
|
const keep = keptRoad(author);
|
|
93
117
|
trace.road = keep ? 'kept' : 'fresh';
|
|
94
|
-
missing.clear();
|
|
95
118
|
const lapped = lapper(trace.ms);
|
|
96
119
|
lapped.start('page');
|
|
97
|
-
const page = keep ? (kept ?? await openKept()) : await context.newPage();
|
|
120
|
+
const page = current = keep ? (kept ?? await openKept()) : await context.newPage();
|
|
121
|
+
active = mine;
|
|
98
122
|
try {
|
|
99
|
-
if (keep)
|
|
123
|
+
if (keep) {
|
|
124
|
+
seated = null;
|
|
100
125
|
await page.unrouteAll({ behavior: 'wait' });
|
|
126
|
+
}
|
|
101
127
|
await routeDocument(page, missing, { root, docPath: DOC_PATH, served });
|
|
102
128
|
lapped.end('page');
|
|
103
129
|
const reseat = keep ? served.replace(/(<script type="module" src="[^"?]+)"/g, `$1?kept=${++reseats}"`) : undefined;
|
|
104
|
-
const printed = await printOnPage(page,
|
|
130
|
+
const printed = await printOnPage(page, mine, flags, { docPath: DOC_PATH, sharedWriter: true, progress: { step: lapped.start, lap: lapped.end }, ...(reseat !== undefined ? { reseat } : {}), ...(created !== undefined ? { created } : {}) });
|
|
105
131
|
lapped.end('print');
|
|
106
|
-
if (
|
|
107
|
-
throw refusal(
|
|
132
|
+
if (mine.size)
|
|
133
|
+
throw refusal(mine, null);
|
|
108
134
|
if (!keep) {
|
|
109
135
|
lapped.start('close');
|
|
110
136
|
await closePage(page);
|
|
@@ -118,11 +144,69 @@ export async function serve(options) {
|
|
|
118
144
|
}
|
|
119
145
|
}
|
|
120
146
|
finally {
|
|
147
|
+
if (active === mine)
|
|
148
|
+
active = null;
|
|
121
149
|
fs.rmSync(root, { recursive: true, force: true });
|
|
122
150
|
}
|
|
123
151
|
};
|
|
152
|
+
/** One customer of `template` on the kept page: the changed boxes replaced where the template is seated, else the template seated and merged. */
|
|
153
|
+
const renderMerge = async (template, data, created, trace) => {
|
|
154
|
+
const flags = { caster: template.given.caster, cicero: ciceroFlags.resolve(template.given.cicero), beltype: cliFlags.resolve(template.given.beltype) };
|
|
155
|
+
const mine = new Set();
|
|
156
|
+
const lapped = lapper(trace.ms);
|
|
157
|
+
lapped.start('page');
|
|
158
|
+
const page = current = kept ?? await openKept();
|
|
159
|
+
active = mine;
|
|
160
|
+
lapped.end('page');
|
|
161
|
+
const merged = (answer) => {
|
|
162
|
+
if ('refused' in answer)
|
|
163
|
+
throw refusal(mine, answer);
|
|
164
|
+
return answer;
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
let resumeAt = null;
|
|
168
|
+
lapped.start('merge');
|
|
169
|
+
if (seated === template.id) {
|
|
170
|
+
const answer = merged(await page.evaluate(keepMergeInPage, data));
|
|
171
|
+
if ('again' in answer) {
|
|
172
|
+
log(`beltype serve: the template is seated again: ${answer.again}`);
|
|
173
|
+
seated = null;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
resumeAt = answer.resumeAt;
|
|
177
|
+
trace.road = 'merge kept';
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (seated !== template.id) {
|
|
181
|
+
lapped.start('load');
|
|
182
|
+
await page.unrouteAll({ behavior: 'wait' });
|
|
183
|
+
const served = servedHtml(template.author);
|
|
184
|
+
await routeDocument(page, missing, { root: template.root, docPath: DOC_PATH, served });
|
|
185
|
+
await page.evaluate(reseatInPage, served.replace(/(<script type="module" src="[^"?]+)"/g, `$1?kept=${++reseats}"`));
|
|
186
|
+
lapped.end('load');
|
|
187
|
+
lapped.start('merge');
|
|
188
|
+
merged(await page.evaluate(seatMergeInPage, { template: template.author, data }));
|
|
189
|
+
seated = template.id;
|
|
190
|
+
trace.road = 'merge seated';
|
|
191
|
+
}
|
|
192
|
+
lapped.end('merge');
|
|
193
|
+
const printed = await cutAndPrint(page, mine, flags, { sharedWriter: true, resumeAt, progress: { step: lapped.start, lap: lapped.end }, ...(created !== undefined ? { created } : {}) });
|
|
194
|
+
lapped.end('print');
|
|
195
|
+
if (mine.size)
|
|
196
|
+
throw refusal(mine, null);
|
|
197
|
+
return printed;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
await closePage(page);
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
if (active === mine)
|
|
205
|
+
active = null;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
124
208
|
const start = async () => {
|
|
125
|
-
browser = await chromium.launch({ headless: true, args: ARGS });
|
|
209
|
+
browser = await chromium.launch({ headless: true, args: ARGS, chromiumSandbox: options.sandbox ?? true });
|
|
126
210
|
const mine = browser;
|
|
127
211
|
version = mine.version();
|
|
128
212
|
mine.on('disconnected', () => { if (browser === mine)
|
|
@@ -173,7 +257,8 @@ export async function serve(options) {
|
|
|
173
257
|
return result;
|
|
174
258
|
};
|
|
175
259
|
await start();
|
|
176
|
-
|
|
260
|
+
/** A request's form: the document's files, the flags, and the instant the file says it was made. */
|
|
261
|
+
const readDocument = async (req) => {
|
|
177
262
|
const form = await readForm(req, options.maxUpload);
|
|
178
263
|
const files = new Map();
|
|
179
264
|
const args = [];
|
|
@@ -195,37 +280,103 @@ export async function serve(options) {
|
|
|
195
280
|
throw new ValidationError('beltype serve: the form has no index.html file.');
|
|
196
281
|
const given = await parseFlagArgs(args);
|
|
197
282
|
created ??= createdFromEnv();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
283
|
+
return { files, args, given, created, writer: String(given.caster['writer'] ?? 'pdf-lib') };
|
|
284
|
+
};
|
|
285
|
+
/** A print's turn: logged, held to the timeout, and counted towards a restart. */
|
|
286
|
+
const inTurn = (writer, run) => enqueue(async () => {
|
|
287
|
+
const id = prints + 1;
|
|
288
|
+
log(`beltype serve: print ${id} started`);
|
|
289
|
+
const t0 = performance.now();
|
|
290
|
+
const trace = { road: 'fresh', ms: {} };
|
|
291
|
+
const running = run(trace);
|
|
292
|
+
let timer;
|
|
293
|
+
const limit = new Promise((_, reject) => { timer = setTimeout(() => reject(new HttpError(504, `beltype serve: the print took over ${options.timeout / 1000} s.`)), options.timeout); });
|
|
294
|
+
try {
|
|
295
|
+
const printed = await Promise.race([running, limit]);
|
|
296
|
+
prints++;
|
|
297
|
+
sinceStart++;
|
|
298
|
+
log(`beltype serve: print ${id}: 200, ${printed.pages} pages, ${writer}, ${Math.round(performance.now() - t0)} ms (write ${printed.caster?.ms['emit'] ?? '?'} ms)`);
|
|
299
|
+
log(`beltype serve: print ${id}: ${stepsLine(trace, printed)}`);
|
|
300
|
+
return { bytes: printed.bytes, printed, writer };
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
prints++;
|
|
304
|
+
sinceStart++;
|
|
305
|
+
running.catch(() => { });
|
|
306
|
+
if (error instanceof HttpError) {
|
|
307
|
+
// The page the print still runs on is closed, so it asks for nothing more, and the print is let end, so its folder goes before the answer.
|
|
308
|
+
if (current)
|
|
309
|
+
await closePage(current);
|
|
310
|
+
if (kept)
|
|
220
311
|
await closePage(kept);
|
|
221
|
-
|
|
222
|
-
needsRestart = true;
|
|
223
|
-
throw error;
|
|
224
|
-
}
|
|
225
|
-
finally {
|
|
226
|
-
clearTimeout(timer);
|
|
312
|
+
await running.catch(() => { });
|
|
227
313
|
}
|
|
228
|
-
|
|
314
|
+
if (!(error instanceof BuildError) && !(error instanceof HttpError))
|
|
315
|
+
needsRestart = true;
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
clearTimeout(timer);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
const print = async (req) => {
|
|
323
|
+
const { files, given, created, writer } = await readDocument(req);
|
|
324
|
+
return inTurn(writer, (trace) => render(files, given, created, trace));
|
|
325
|
+
};
|
|
326
|
+
/** A template's folder, kept on disk under the hash of what it holds, so uploading it again is the same template. */
|
|
327
|
+
const uploadTemplate = async (req) => {
|
|
328
|
+
const { files, args, given } = await readDocument(req);
|
|
329
|
+
const author = Buffer.from(files.get('index.html')).toString('utf8');
|
|
330
|
+
if (!keptRoad(author))
|
|
331
|
+
throw new ValidationError('beltype serve: a template holds no script of its own, nor an <iframe>, <object>, <embed>, <frame>, <portal>, javascript: or on…= attribute: the merge runs in one kept page, which they would outlive.');
|
|
332
|
+
if (author.search(DATA_BLOCK) < 0)
|
|
333
|
+
throw new ValidationError('beltype serve: a template has a data block, a <script type="application/ld+json">; this one has none.');
|
|
334
|
+
const hash = crypto.createHash('sha256');
|
|
335
|
+
for (const name of [...files.keys()].sort())
|
|
336
|
+
hash.update(name).update('\0').update(files.get(name)).update('\0');
|
|
337
|
+
const id = hash.update(args.sort().join('\0')).digest('hex').slice(0, 32);
|
|
338
|
+
if (templates.has(id))
|
|
339
|
+
return id;
|
|
340
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'beltype-template-'));
|
|
341
|
+
for (const [name, bytes] of files) {
|
|
342
|
+
const file = path.join(root, ...name.split('/'));
|
|
343
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
344
|
+
fs.writeFileSync(file, bytes);
|
|
345
|
+
}
|
|
346
|
+
templates.set(id, { id, root, author, given });
|
|
347
|
+
for (const old of templates.values()) {
|
|
348
|
+
if (templates.size <= TEMPLATES_KEPT)
|
|
349
|
+
break;
|
|
350
|
+
if (old.id === seated)
|
|
351
|
+
continue;
|
|
352
|
+
templates.delete(old.id);
|
|
353
|
+
fs.rmSync(old.root, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
return id;
|
|
356
|
+
};
|
|
357
|
+
/** One customer of a template: the body is JSON, `{ values, rows, created }`. */
|
|
358
|
+
const printMerge = async (id, req) => {
|
|
359
|
+
const template = templates.get(id);
|
|
360
|
+
if (!template)
|
|
361
|
+
throw new HttpError(404, `beltype serve: no template ${id}; POST its folder to /templates (the oldest are dropped past ${TEMPLATES_KEPT}, and a restart of the server forgets them all).`);
|
|
362
|
+
const { data, created } = mergeBody(await readBody(req, options.maxUpload));
|
|
363
|
+
return inTurn(String(template.given.caster['writer'] ?? 'pdf-lib'), (trace) => renderMerge(template, data, created ?? createdFromEnv(), trace));
|
|
364
|
+
};
|
|
365
|
+
/** The `Host` a request must name; set once the server listens. */
|
|
366
|
+
let authority = '';
|
|
367
|
+
/**
|
|
368
|
+
* Whether a request does not name this server, or comes from a page of another origin: a page in the user's browser can POST a form here with no preflight, and a rebound name reaches it under its own `Host`.
|
|
369
|
+
*
|
|
370
|
+
* @remarks
|
|
371
|
+
* Under a wildcard bind (`0.0.0.0`, `::`) the server cannot know which name it is reached by, so the `Host` must be on `allowedHosts`, which `serve` demands there. An entry with no port names the host on any port.
|
|
372
|
+
*/
|
|
373
|
+
const foreign = (req) => {
|
|
374
|
+
const host = (req.headers.host ?? '').toLowerCase();
|
|
375
|
+
const named = (!wildcard && host === authority) || allowed.has(host) || allowed.has(host.replace(/:\d+$/, ''));
|
|
376
|
+
if (!named)
|
|
377
|
+
return true;
|
|
378
|
+
const origin = req.headers.origin;
|
|
379
|
+
return origin !== undefined && origin !== '' && origin.toLowerCase() !== `http://${host}`;
|
|
229
380
|
};
|
|
230
381
|
const server = http.createServer(async (req, res) => {
|
|
231
382
|
const send = (status, type, body, headers = {}) => {
|
|
@@ -234,28 +385,38 @@ export async function serve(options) {
|
|
|
234
385
|
};
|
|
235
386
|
const text = (status, message) => send(status, 'text/plain; charset=utf-8', message + '\n');
|
|
236
387
|
try {
|
|
388
|
+
if (foreign(req))
|
|
389
|
+
throw new HttpError(403, `beltype serve: refused a request for ${req.headers.host ?? 'no host'} from ${req.headers.origin ?? 'no origin'}; it answers ${[...(wildcard ? [] : [authority]), ...allowed].join(', ')} and no page of another origin.`);
|
|
237
390
|
const pathname = new URL(req.url ?? '/', 'http://x').pathname;
|
|
238
391
|
if (pathname === '/health') {
|
|
239
392
|
if (req.method !== 'GET')
|
|
240
393
|
return text(405, 'beltype serve: /health takes GET.');
|
|
241
394
|
return send(restarting ? 503 : 200, 'application/json', JSON.stringify({ status: restarting ? 'restarting' : 'up', chromium: version, prints, queued }));
|
|
242
395
|
}
|
|
243
|
-
if (pathname
|
|
244
|
-
|
|
396
|
+
if (pathname === '/templates') {
|
|
397
|
+
if (req.method !== 'POST')
|
|
398
|
+
return text(405, 'beltype serve: /templates takes POST.');
|
|
399
|
+
return send(201, 'application/json', JSON.stringify({ template: await uploadTemplate(req) }));
|
|
400
|
+
}
|
|
401
|
+
const merge = /^\/templates\/([0-9a-f]+)\/print$/.exec(pathname);
|
|
402
|
+
if (pathname !== '/print' && !merge)
|
|
403
|
+
return text(404, `beltype serve: no ${pathname}; there is POST /print, POST /templates, POST /templates/<id>/print and GET /health.`);
|
|
245
404
|
if (req.method !== 'POST')
|
|
246
|
-
return text(405,
|
|
247
|
-
const { bytes, printed, writer } = await print(req);
|
|
405
|
+
return text(405, `beltype serve: ${pathname} takes POST.`);
|
|
406
|
+
const { bytes, printed, writer } = await (merge ? printMerge(merge[1], req) : print(req));
|
|
248
407
|
for (const warning of printed.warnings)
|
|
249
408
|
log(`beltype serve: warning: ${typeof warning === 'string' ? warning : JSON.stringify(warning)}`);
|
|
250
|
-
|
|
409
|
+
for (const deferral of printed.deferred)
|
|
410
|
+
log(`beltype serve: ${deferralLine(deferral)}`);
|
|
411
|
+
send(200, 'application/pdf', bytes, { 'x-beltype-pages': printed.pages, 'x-beltype-words': printed.words, 'x-beltype-warnings': printed.warnings.length, 'x-beltype-deferred': printed.deferred.length, ...standardHeader(printed.conformance) });
|
|
251
412
|
}
|
|
252
413
|
catch (error) {
|
|
253
414
|
const message = error instanceof Error ? error.message : String(error);
|
|
254
415
|
const status = error instanceof HttpError ? error.status : error instanceof BuildError ? 422 : error instanceof ValidationError || error instanceof FlagError ? 400 : 500;
|
|
255
416
|
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
|
|
258
|
-
res.writeHead(
|
|
417
|
+
if (status === 413 || status === 403) {
|
|
418
|
+
// Keep nothing more: answer, discard what still arrives (a client cut off mid-write sees EPIPE, not the answer), and drop the connection after a second if the client is still sending.
|
|
419
|
+
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
|
|
259
420
|
res.end(message + '\n', () => {
|
|
260
421
|
req.resume();
|
|
261
422
|
setTimeout(() => req.destroy(), 1000).unref();
|
|
@@ -267,7 +428,8 @@ export async function serve(options) {
|
|
|
267
428
|
});
|
|
268
429
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(options.port, options.host, resolve); });
|
|
269
430
|
const address = server.address();
|
|
270
|
-
|
|
431
|
+
authority = `${options.host.includes(':') ? `[${options.host}]` : options.host}:${address.port}`.toLowerCase();
|
|
432
|
+
const url = `http://${authority}`;
|
|
271
433
|
log(`beltype serve: listening on ${url}`);
|
|
272
434
|
return {
|
|
273
435
|
url,
|
|
@@ -276,11 +438,13 @@ export async function serve(options) {
|
|
|
276
438
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
277
439
|
await tail;
|
|
278
440
|
await stopBrowser();
|
|
441
|
+
for (const { root } of templates.values())
|
|
442
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
279
443
|
}
|
|
280
444
|
};
|
|
281
445
|
}
|
|
282
|
-
/** The request's
|
|
283
|
-
async function
|
|
446
|
+
/** The request's body, read up to `limit` bytes. @throws {HttpError} 413 as soon as the count passes the limit. */
|
|
447
|
+
async function readBody(req, limit) {
|
|
284
448
|
const declared = Number(req.headers['content-length'] ?? 0);
|
|
285
449
|
if (declared > limit)
|
|
286
450
|
throw new HttpError(413, `beltype serve: the upload is ${declared} bytes, over the limit of ${limit}.`);
|
|
@@ -292,11 +456,37 @@ async function readForm(req, limit) {
|
|
|
292
456
|
throw new HttpError(413, `beltype serve: the upload is over the limit of ${limit} bytes.`);
|
|
293
457
|
chunks.push(chunk);
|
|
294
458
|
}
|
|
459
|
+
return Buffer.concat(chunks);
|
|
460
|
+
}
|
|
461
|
+
/** One customer's JSON: `values` a flat object of strings, numbers and booleans, `rows` a table of arrays of flat objects, `created` an instant. @throws {ValidationError} */
|
|
462
|
+
function mergeBody(body) {
|
|
463
|
+
let parsed;
|
|
464
|
+
try {
|
|
465
|
+
parsed = JSON.parse(body.toString('utf8'));
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
throw new ValidationError('beltype serve: the customer is JSON: { "values": { … }, "rows": { "<table>": [ { … } ] } }; the body does not parse.');
|
|
469
|
+
}
|
|
470
|
+
const isObject = (x) => typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
471
|
+
if (!isObject(parsed))
|
|
472
|
+
throw new ValidationError('beltype serve: the customer is a JSON object with "values" and "rows".');
|
|
473
|
+
const { values = {}, rows = {}, created } = parsed;
|
|
474
|
+
if (!isObject(values) || Object.values(values).some((v) => !['string', 'number', 'boolean'].includes(typeof v)))
|
|
475
|
+
throw new ValidationError('beltype serve: "values" is an object of strings, numbers and booleans.');
|
|
476
|
+
if (!isObject(rows) || Object.values(rows).some((table) => !Array.isArray(table) || table.some((row) => !isObject(row))))
|
|
477
|
+
throw new ValidationError('beltype serve: "rows" is an object of tables, each an array of objects.');
|
|
478
|
+
if (created !== undefined && typeof created !== 'string')
|
|
479
|
+
throw new ValidationError('beltype serve: "created" is an ISO 8601 instant.');
|
|
480
|
+
return { data: { values, rows }, created: created === undefined ? undefined : instant(created) };
|
|
481
|
+
}
|
|
482
|
+
/** 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. */
|
|
483
|
+
async function readForm(req, limit) {
|
|
484
|
+
const body = await readBody(req, limit);
|
|
295
485
|
const type = req.headers['content-type'] ?? '';
|
|
296
486
|
if (!/^multipart\/form-data/i.test(type))
|
|
297
487
|
throw new ValidationError('beltype serve: POST /print takes multipart/form-data.');
|
|
298
488
|
try {
|
|
299
|
-
return await new Response(
|
|
489
|
+
return await new Response(body, { headers: { 'content-type': type } }).formData();
|
|
300
490
|
}
|
|
301
491
|
catch {
|
|
302
492
|
throw new ValidationError('beltype serve: the body is not a well-formed multipart/form-data.');
|
|
@@ -316,3 +506,8 @@ function instant(value) {
|
|
|
316
506
|
throw new ValidationError(`beltype serve: created is an ISO 8601 instant; got "${value}".`);
|
|
317
507
|
return date.toISOString();
|
|
318
508
|
}
|
|
509
|
+
/** What the file claims, as Caster's report says it: `x-beltype-standard: pdf-2.0; pdf-ua-2`, or `pdf-2.0` alone where the claim was not asked for or was withdrawn. A print under `beltype.printer=chromium` is Chromium's own file, which Caster never saw, and carries no header. */
|
|
510
|
+
export const standardHeader = (conformance) => {
|
|
511
|
+
const standard = standardOf(conformance);
|
|
512
|
+
return standard === null ? {} : { 'x-beltype-standard': standard };
|
|
513
|
+
};
|