@beltype/cli 0.1.7 → 0.1.8

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/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 } 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,15 @@ export async function print(input, output, options = {}) {
41
41
  let step = 'launch';
42
42
  let chromiumVersion = 'unknown';
43
43
  try {
44
+ if (flags.beltype.check)
45
+ await requireMupdf();
44
46
  const html = fs.readFileSync(input, 'utf8');
45
47
  const root = path.dirname(path.resolve(input));
46
48
  const docPath = `/${encodeURIComponent(path.basename(input))}`;
47
49
  const assets = documentAssets();
48
50
  const served = servedHtml(html);
49
51
  const missing = new Set();
50
- const browser = await chromium.launch({ headless: true, args: ARGS });
52
+ const browser = await chromium.launch({ headless: true, args: ARGS, chromiumSandbox: options.sandbox ?? true });
51
53
  chromiumVersion = browser.version();
52
54
  lap('launch');
53
55
  try {
@@ -70,10 +72,12 @@ export async function print(input, output, options = {}) {
70
72
  trace.caster = printed.caster;
71
73
  for (const warning of printed.warnings)
72
74
  console.warn(typeof warning === 'string' ? warning : JSON.stringify(warning));
75
+ for (const deferral of printed.deferred)
76
+ console.warn(deferralLine(deferral));
73
77
  }
74
78
  else
75
79
  lap('print');
76
- const { bytes, pages, words, screens } = printed;
80
+ const { bytes, pages, words, screens, confessions } = printed;
77
81
  step = 'close';
78
82
  await browser.close();
79
83
  lap('close');
@@ -86,10 +90,10 @@ export async function print(input, output, options = {}) {
86
90
  let check;
87
91
  if (flags.beltype.check) {
88
92
  step = 'check';
89
- check = await checkPages(screens, bytes, path.resolve(output).replace(/\.pdf$/i, '') + '.check', flags.beltype.printer);
93
+ check = await checkPages(screens, bytes, path.resolve(output).replace(/\.pdf$/i, '') + '.check', flags.beltype.printer, confessions);
90
94
  lap('check');
91
95
  }
92
- return { output, pages, words, ms: performance.now() - started, ...(check ? { check } : {}) };
96
+ return { output, pages, words, deferred: printed.deferred.length, ms: performance.now() - started, ...(check ? { check } : {}) };
93
97
  }
94
98
  finally {
95
99
  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
- return { files: cicero.files, dirs: { ...cicero.dirs, caster: distOf('@beltype/caster', 1), common: distOf('@beltype/common/errors', 0) } };
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) {
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 } 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
- /** 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);
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
- const missing = new Set();
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, missing, flags, { docPath: DOC_PATH, sharedWriter: true, progress: { step: lapped.start, lap: lapped.end }, ...(reseat !== undefined ? { reseat } : {}), ...(created !== undefined ? { created } : {}) });
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 (missing.size)
107
- throw refusal(missing, null);
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
- const print = async (req) => {
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
- 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)
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
- if (!(error instanceof BuildError) && !(error instanceof HttpError))
222
- needsRestart = true;
223
- throw error;
312
+ await running.catch(() => { });
224
313
  }
225
- finally {
226
- clearTimeout(timer);
227
- }
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 !== '/print')
244
- return text(404, `beltype serve: no ${pathname}; there is POST /print and GET /health.`);
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, 'beltype serve: /print takes POST.');
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
- send(200, 'application/pdf', bytes, { 'x-beltype-pages': printed.pages, 'x-beltype-words': printed.words, 'x-beltype-warnings': printed.warnings.length });
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 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' });
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
- const url = `http://${options.host}:${address.port}`;
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 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) {
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(Buffer.concat(chunks), { headers: { 'content-type': type } }).formData();
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,5 @@ 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) => conformance === null ? {} : { 'x-beltype-standard': conformance === 'pdfua2' ? 'pdf-2.0; pdf-ua-2' : 'pdf-2.0' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beltype/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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",
@@ -27,21 +27,33 @@
27
27
  },
28
28
  "pnpm": {
29
29
  "onlyBuiltDependencies": [
30
- "@beltype/cicero",
31
30
  "@beltype/caster",
32
- "@beltype/common"
31
+ "@beltype/cicero",
32
+ "@beltype/common",
33
+ "esbuild"
33
34
  ]
34
35
  },
35
36
  "dependencies": {
36
- "@beltype/caster": "0.1.7",
37
- "@beltype/cicero": "0.1.6",
37
+ "@beltype/caster": "0.2.5",
38
+ "@beltype/cicero": "0.1.10",
38
39
  "@beltype/common": "0.1.2",
39
- "mupdf": "1.28.1",
40
40
  "playwright-core": "1.63.0"
41
41
  },
42
+ "peerDependencies": {
43
+ "mupdf": "1.28.1"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "mupdf": {
47
+ "optional": true
48
+ }
49
+ },
42
50
  "devDependencies": {
43
- "@beltype/specimens": "0.1.0",
51
+ "@beltype/specimens": "0.1.1",
44
52
  "@types/node": "^22.20.5",
53
+ "esbuild": "0.28.2",
54
+ "linkedom": "0.18.14",
55
+ "mupdf": "1.28.1",
56
+ "prosemirror-stores": "git+https://codeberg.org/egildo/prosemirror-stores.git#3b281922616bf674c9338de73626b98b8f0a1d8d",
45
57
  "typescript": "7.0.2"
46
58
  },
47
59
  "bugs": {