@beltype/cli 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,6 +36,7 @@ curl -F index.html=@index.html -F paper.css=@paper.css -F fonts/a.woff2=@fonts/a
36
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
37
  - `GET /health`: `{ "status": "up", "chromium": …, "prints": …, "queued": … }`, or `503` with `"status": "restarting"` while the browser starts again.
38
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.
39
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.
40
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/`.
41
42
 
@@ -70,3 +70,18 @@ export function chromiumSheetInPage(sheet) {
70
70
  shadow.textContent = sheet.shadow;
71
71
  document.querySelector('beltype-root')?.shadowRoot?.appendChild(shadow);
72
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/run.js CHANGED
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
12
12
  import { ciceroEngine } from '@beltype/cicero/engine';
13
13
  import { BuildError } from '@beltype/common/errors';
14
14
  import { contentType } from '@beltype/common/mime';
15
- import { cutInPage, printInPage, pageSizesInPage, chromiumSheetInPage } from './in-page.js';
15
+ import { cutInPage, printInPage, reseatInPage, pageSizesInPage, chromiumSheetInPage } from './in-page.js';
16
16
  import { chromiumSheet } from './chromium.js';
17
17
  import { screenshotPages } from './check.js';
18
18
  /** `.invalid` is reserved (RFC 2606), so nothing on a network can ever answer it instead. */
@@ -93,9 +93,13 @@ export async function routeDocument(page, missing, o) {
93
93
  */
94
94
  export async function printOnPage(page, missing, flags, o) {
95
95
  const { progress } = o;
96
- page.on('pageerror', (error) => console.warn(`page error: ${error.message}`));
97
96
  progress?.step('load');
98
- await page.goto(ORIGIN + o.docPath, { waitUntil: '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
+ }
99
103
  progress?.lap('load');
100
104
  progress?.step('cut');
101
105
  const cut = await page.evaluate(cutInPage, { cicero: flags.cicero, measures: o.measures === true });
package/dist/src/serve.js CHANGED
@@ -4,6 +4,8 @@
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.
8
+ *
7
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.
8
10
  *
9
11
  * @packageDocumentation
@@ -24,11 +26,19 @@ const c = await import('/_beltype/caster/src/index.js');
24
26
  window.sw = new SharedWorker(c.formeSharedWorkerUrl(), { type: 'module', name: 'forme' });
25
27
  </script></body></html>`;
26
28
  // Run from src/ or from dist/src/: the package root is the first folder up that holds `assets`.
27
- const WARMUP_FONT = [1, 2].map((up) => fileURLToPath(new URL(`${'../'.repeat(up)}assets/warmup/SourceSans3-Variable.woff2`, import.meta.url))).find((f) => fs.existsSync(f));
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));
28
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(' ');
29
31
  const paragraph = (n) => Array.from({ length: 90 }, (_, i) => WORDS[(i * 7 + n * 3) % WORDS.length]).join(' ') + '.';
30
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. */
31
- const WARMUP = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Warm-up</title><style>@font-face { font-family: Warm; src: url(warm.woff2) format("woff2"); font-weight: 200 900 } @page { size: A4; margin: 20mm } body { font: 11pt/1.4 Warm }</style></head><body><h1>Warm-up</h1>${Array.from({ length: 16 }, (_, n) => `<p>${paragraph(n)}</p>`).join('')}</body></html>`;
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);
32
42
  class HttpError extends Error {
33
43
  status;
34
44
  constructor(status, message) { super(message); this.status = status; }
@@ -37,6 +47,8 @@ export async function serve(options) {
37
47
  const log = options.log ?? ((line) => console.log(line));
38
48
  const assets = documentAssets();
39
49
  const missing = new Set();
50
+ let kept = null;
51
+ let reseats = 0;
40
52
  let browser = null;
41
53
  let context = null;
42
54
  let version = 'unknown';
@@ -48,11 +60,25 @@ export async function serve(options) {
48
60
  let tail = Promise.resolve();
49
61
  const stopBrowser = async () => {
50
62
  const old = browser;
51
- browser = context = null;
63
+ browser = context = kept = null;
52
64
  await old?.close().catch(() => { });
53
65
  };
54
- /** Run `html` and `files` through the print's road on a new page; the folder and the page are gone after. */
55
- const render = async (files, given, created) => {
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: {} }) => {
56
82
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'beltype-serve-'));
57
83
  try {
58
84
  for (const [name, bytes] of files) {
@@ -61,19 +87,34 @@ export async function serve(options) {
61
87
  fs.writeFileSync(file, bytes);
62
88
  }
63
89
  const flags = { caster: given.caster, cicero: ciceroFlags.resolve(given.cicero), beltype: cliFlags.resolve(given.beltype) };
64
- const docPath = '/index.html';
65
- const served = servedHtml(Buffer.from(files.get('index.html')).toString('utf8'));
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';
66
94
  missing.clear();
67
- const page = await context.newPage();
95
+ const lapped = lapper(trace.ms);
96
+ lapped.start('page');
97
+ const page = keep ? (kept ?? await openKept()) : await context.newPage();
68
98
  try {
69
- await routeDocument(page, missing, { root, docPath, served });
70
- const printed = await printOnPage(page, missing, flags, { docPath, sharedWriter: true, ...(created !== undefined ? { created } : {}) });
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');
71
106
  if (missing.size)
72
107
  throw refusal(missing, null);
108
+ if (!keep) {
109
+ lapped.start('close');
110
+ await closePage(page);
111
+ lapped.end('close');
112
+ }
73
113
  return printed;
74
114
  }
75
- finally {
76
- await page.close().catch(() => { });
115
+ catch (error) {
116
+ await closePage(page);
117
+ throw error;
77
118
  }
78
119
  }
79
120
  finally {
@@ -94,6 +135,7 @@ export async function serve(options) {
94
135
  const holder = await context.newPage();
95
136
  await holder.goto(ORIGIN + HOLDER_PATH, { waitUntil: 'load' });
96
137
  await holder.waitForFunction(() => window.sw !== undefined);
138
+ await openKept();
97
139
  const given = await parseFlagArgs(['caster.writer=forme']);
98
140
  for (const n of [1, 2]) {
99
141
  const printed = await render(new Map([['index.html', Buffer.from(WARMUP)], ['warm.woff2', fs.readFileSync(WARMUP_FONT)]]), given, undefined);
@@ -158,7 +200,8 @@ export async function serve(options) {
158
200
  const id = prints + 1;
159
201
  log(`beltype serve: print ${id} started`);
160
202
  const t0 = performance.now();
161
- const run = render(files, given, created);
203
+ const trace = { road: 'fresh', ms: {} };
204
+ const run = render(files, given, created, trace);
162
205
  let timer;
163
206
  const limit = new Promise((_, reject) => { timer = setTimeout(() => reject(new HttpError(504, `beltype serve: the print took over ${options.timeout / 1000} s.`)), options.timeout); });
164
207
  try {
@@ -166,12 +209,15 @@ export async function serve(options) {
166
209
  prints++;
167
210
  sinceStart++;
168
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)}`);
169
213
  return { bytes: printed.bytes, printed, writer };
170
214
  }
171
215
  catch (error) {
172
216
  prints++;
173
217
  sinceStart++;
174
218
  run.catch(() => { });
219
+ if (error instanceof HttpError && kept)
220
+ await closePage(kept);
175
221
  if (!(error instanceof BuildError) && !(error instanceof HttpError))
176
222
  needsRestart = true;
177
223
  throw error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beltype/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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",
@@ -33,8 +33,8 @@
33
33
  ]
34
34
  },
35
35
  "dependencies": {
36
- "@beltype/caster": "0.1.6",
37
- "@beltype/cicero": "0.1.5",
36
+ "@beltype/caster": "0.1.7",
37
+ "@beltype/cicero": "0.1.6",
38
38
  "@beltype/common": "0.1.2",
39
39
  "mupdf": "1.28.1",
40
40
  "playwright-core": "1.63.0"