@cosmicdrift/kumiko-renderer-puppeteer-client 0.377.0 → 0.379.0
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/document-pdf.js +88 -6
- package/dist/feature.js +3 -2
- package/kumiko-es-tables.json +6 -0
- package/package.json +7 -6
- package/src/changes.json +12 -0
package/dist/document-pdf.js
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
import { sanitizePdfAttachmentFileName } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
5
5
|
import { FileRefTooLargeError, readFileRefContent, } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
|
|
6
6
|
import { RendererError, } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
|
|
7
|
-
import { collectDocumentResourceKeys, compileDocumentBodyToHandlebars, createTemplateResolverApi, DEFAULT_PDF_LAYOUT_SLUG, PDF_DEFAULT_MARGIN_MM, PDF_FOOTER_MARGIN_MM, PDF_HEADER_MARGIN_MM, prepareDocumentForPrint, readLayoutRegions, readPdfTemplateMeta, readTemplateResourceMaxBytes, resolveTemplateVariables, sanitizeDocumentHtml, sanitizeLayoutHtml, TemplateNotFoundError, toResourceDataUri, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
7
|
+
import { collectDocumentImageVariables, collectDocumentResourceKeys, compileDocumentBodyToHandlebars, createTemplateResolverApi, DEFAULT_PDF_LAYOUT_SLUG, isResourceImageContentType, PDF_DEFAULT_MARGIN_MM, PDF_FOOTER_MARGIN_MM, PDF_HEADER_MARGIN_MM, prepareDocumentForPrint, readLayoutRegions, readPdfTemplateMeta, readTemplateResourceMaxBytes, resolveTemplateVariables, sanitizeDocumentHtml, sanitizeLayoutHtml, TemplateNotFoundError, toResourceDataUri, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
8
8
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
9
|
import { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, mergeResourceFileRefs, readBrandingAccentColor, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "@cosmicdrift/kumiko-renderer-mail-html";
|
|
10
10
|
const LINE_BREAKS = /[\r\n]+/g;
|
|
11
11
|
const IMAGE_CONTENT_TYPE_PREFIX = "image/";
|
|
12
|
+
const MAX_RECORD_IMAGES = 24;
|
|
13
|
+
const MAX_RECORD_IMAGES_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
12
14
|
const PREVIEW_PAGE_NUMBER = "1";
|
|
13
15
|
const EMPTY_CHROMIUM_REGION = "<span></span>";
|
|
14
16
|
const PAGE_NUMBER_HTML = '<span class="pageNumber"></span>';
|
|
@@ -26,6 +28,9 @@ const PRINT_CSS = [
|
|
|
26
28
|
"[data-align=justify]{text-align:justify}",
|
|
27
29
|
"[data-size=small]{font-size:8pt;color:#555}",
|
|
28
30
|
"img{max-width:100%}",
|
|
31
|
+
".kumiko-doc-images{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin:8px 0}",
|
|
32
|
+
".kumiko-doc-images:empty{display:none}",
|
|
33
|
+
".kumiko-doc-images img{width:100%;break-inside:avoid;page-break-inside:avoid}",
|
|
29
34
|
].join("");
|
|
30
35
|
const EMPTY_REGIONS = { header: "", signature: "", footer: "" };
|
|
31
36
|
function describe(e) {
|
|
@@ -129,6 +134,67 @@ async function loadResourceDataUris(ctx, fileRefIds) {
|
|
|
129
134
|
}
|
|
130
135
|
return dataUris;
|
|
131
136
|
}
|
|
137
|
+
function fileRefIdsOf(value) {
|
|
138
|
+
if (typeof value === "string")
|
|
139
|
+
return [value];
|
|
140
|
+
return Array.isArray(value)
|
|
141
|
+
? value.filter((entry) => typeof entry === "string")
|
|
142
|
+
: [];
|
|
143
|
+
}
|
|
144
|
+
// Own tenant only (no system files): the ids come from the app's record, so a foreign or
|
|
145
|
+
// unreadable id drops silently like a missing image instead of failing the document.
|
|
146
|
+
async function readRecordImage(ctx, filesForTenant, fileRefId, maxBytes) {
|
|
147
|
+
try {
|
|
148
|
+
const found = await readFileRefContent({
|
|
149
|
+
db: ctx.db,
|
|
150
|
+
filesForTenant,
|
|
151
|
+
fileRefId,
|
|
152
|
+
allowedTenantIds: [ctx.tenantId],
|
|
153
|
+
maxBytes,
|
|
154
|
+
});
|
|
155
|
+
if (found === null || !isResourceImageContentType(found.contentType))
|
|
156
|
+
return null;
|
|
157
|
+
return { dataUri: toResourceDataUri(found.contentType, found.content), size: found.size };
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
if (e instanceof FileRefTooLargeError)
|
|
161
|
+
return null;
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function loadRecordImageDataUris(ctx, variables, imageVariableNames) {
|
|
166
|
+
const dataUrisByVariable = new Map();
|
|
167
|
+
const { filesForTenant } = ctx;
|
|
168
|
+
if (filesForTenant === undefined || imageVariableNames.length === 0)
|
|
169
|
+
return dataUrisByVariable;
|
|
170
|
+
const maxBytes = await readTemplateResourceMaxBytes(ctx.config, ctx.registry);
|
|
171
|
+
const seenIds = new Set();
|
|
172
|
+
let loadedCount = 0;
|
|
173
|
+
let loadedBytes = 0;
|
|
174
|
+
for (const name of imageVariableNames) {
|
|
175
|
+
const dataUris = [];
|
|
176
|
+
for (const fileRefId of fileRefIdsOf(variables[name])) {
|
|
177
|
+
if (seenIds.has(fileRefId))
|
|
178
|
+
continue;
|
|
179
|
+
seenIds.add(fileRefId);
|
|
180
|
+
if (loadedCount >= MAX_RECORD_IMAGES)
|
|
181
|
+
break;
|
|
182
|
+
const image = await readRecordImage(ctx, filesForTenant, fileRefId, Math.min(maxBytes, MAX_RECORD_IMAGES_TOTAL_BYTES - loadedBytes));
|
|
183
|
+
if (image === null)
|
|
184
|
+
continue;
|
|
185
|
+
loadedCount += 1;
|
|
186
|
+
loadedBytes += image.size;
|
|
187
|
+
dataUris.push(image.dataUri);
|
|
188
|
+
}
|
|
189
|
+
dataUrisByVariable.set(name, dataUris);
|
|
190
|
+
}
|
|
191
|
+
return dataUrisByVariable;
|
|
192
|
+
}
|
|
193
|
+
// Text context without images variables: their value is file ids, which no placeholder,
|
|
194
|
+
// title or file name pattern may print.
|
|
195
|
+
function withoutImageVariables(variables, definitions) {
|
|
196
|
+
return Object.fromEntries(Object.entries(variables).filter(([name]) => !(Object.hasOwn(definitions, name) && definitions[name]?.type === "images")));
|
|
197
|
+
}
|
|
132
198
|
function renderRegion(hbs, region, context, resourceSrc) {
|
|
133
199
|
if (region.trim() === "")
|
|
134
200
|
return "";
|
|
@@ -221,9 +287,9 @@ async function findOwnPdfTemplate(tplApi, ctx, slug, locale, scope) {
|
|
|
221
287
|
...(scope === "system" && { scope }),
|
|
222
288
|
});
|
|
223
289
|
}
|
|
224
|
-
async function assembleDocument(input, ctx, markers) {
|
|
290
|
+
async function assembleDocument(input, ctx, markers, recordImages = "placeholder") {
|
|
225
291
|
const { slug, locale, variables } = input;
|
|
226
|
-
const tplApi = createTemplateResolverApi(ctx.db);
|
|
292
|
+
const tplApi = createTemplateResolverApi(ctx.db, ctx.registry);
|
|
227
293
|
const template = input.draft === undefined
|
|
228
294
|
? await resolvePdfTemplate(tplApi, ctx, slug, locale)
|
|
229
295
|
: applyPdfDraft(await findStoredPdfTemplate(tplApi, ctx, slug, locale), await findOwnPdfTemplate(tplApi, ctx, slug, locale, input.draftScope ?? "tenant"), input.draft, slug, locale);
|
|
@@ -237,7 +303,18 @@ async function assembleDocument(input, ctx, markers) {
|
|
|
237
303
|
readBrandingAccentColor(ctx),
|
|
238
304
|
]);
|
|
239
305
|
const hbs = createDocumentHandlebars(format);
|
|
240
|
-
const
|
|
306
|
+
const variableDefinitions = resolveTemplateVariables(ctx.registry, slug, template.variableSchema);
|
|
307
|
+
const context = {
|
|
308
|
+
...withoutImageVariables(variables, variableDefinitions),
|
|
309
|
+
tenantName,
|
|
310
|
+
signature: "",
|
|
311
|
+
imprint: "",
|
|
312
|
+
page: "",
|
|
313
|
+
pages: "",
|
|
314
|
+
};
|
|
315
|
+
const recordImageUris = recordImages === "resolve"
|
|
316
|
+
? await loadRecordImageDataUris(ctx, variables, collectDocumentImageVariables(template.content))
|
|
317
|
+
: new Map();
|
|
241
318
|
const bodyKeys = collectDocumentResourceKeys(template.content);
|
|
242
319
|
const layoutKeys = collectRegionResourceKeys(layout.regions);
|
|
243
320
|
const bodyRefs = await resolveResourceFileRefs(tplApi, ctx, template, bodyKeys);
|
|
@@ -247,7 +324,12 @@ async function assembleDocument(input, ctx, markers) {
|
|
|
247
324
|
const dataUris = await loadResourceDataUris(ctx, mergeResourceFileRefs(bodyRefs, layoutRefs));
|
|
248
325
|
const resourceSrc = (key) => dataUris.get(key);
|
|
249
326
|
const missingResourceKeys = [...new Set([...bodyKeys, ...layoutKeys])].filter((key) => !dataUris.has(key));
|
|
250
|
-
const bodySource = compileDocumentBodyToHandlebars(template.content,
|
|
327
|
+
const bodySource = compileDocumentBodyToHandlebars(template.content, variableDefinitions, {
|
|
328
|
+
resourceSrc,
|
|
329
|
+
recordImagesSrc: (variable) => recordImageUris.get(variable) ?? [],
|
|
330
|
+
recordImagesPlaceholder: recordImages === "placeholder",
|
|
331
|
+
...(accentColor !== undefined && { stepsColor: accentColor }),
|
|
332
|
+
});
|
|
251
333
|
const bodyHtml = prepareDocumentForPrint(compileDocumentSource(hbs, bodySource)(context));
|
|
252
334
|
const signatureHtml = renderRegion(hbs, layout.regions.signature, context, resourceSrc);
|
|
253
335
|
const pagedContext = markers === "chromium"
|
|
@@ -283,7 +365,7 @@ async function assembleDocument(input, ctx, markers) {
|
|
|
283
365
|
}
|
|
284
366
|
}
|
|
285
367
|
export async function buildDocumentPdfRequest(req, ctx) {
|
|
286
|
-
return toPdfRequest(await assembleDocument(req.payload, ctx, "chromium"), req.payload.locale);
|
|
368
|
+
return toPdfRequest(await assembleDocument(req.payload, ctx, "chromium", "resolve"), req.payload.locale);
|
|
287
369
|
}
|
|
288
370
|
// Prints an unsaved draft; the stored template of the slug only supplies images and the variable schema.
|
|
289
371
|
export async function buildDocumentDraftPdfRequest(req, ctx) {
|
package/dist/feature.js
CHANGED
|
@@ -14,11 +14,12 @@ import { puppeteerStatusQuery } from "./status.query.js";
|
|
|
14
14
|
// - PUPPETEER_CLIENT_TIMEOUT_MS default 60000
|
|
15
15
|
// - PUPPETEER_CLIENT_MAX_RETRIES default 3
|
|
16
16
|
//
|
|
17
|
-
// **Retry strategy:**
|
|
17
|
+
// **Retry strategy:** 502/503 + network errors → exponential backoff (200ms,
|
|
18
18
|
// 400ms, 800ms) + jitter. 4xx → NO retry (config bug, fail immediately).
|
|
19
|
+
// Other 5xx and client timeouts → NO retry (the render already ran).
|
|
19
20
|
export function createRendererPuppeteerClientFeature() {
|
|
20
21
|
return defineFeature("renderer-puppeteer-client", (r) => {
|
|
21
|
-
r.describe("A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`, which must embed all resources as data: URIs because the service fetches no http(s) resources. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries
|
|
22
|
+
r.describe("A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`, which must embed all resources as data: URIs because the service fetches no http(s) resources. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries 502, 503 and network errors with exponential backoff (default 3 retries); 4xx, other 5xx and client timeouts fail at once, because the render has already run. The feature adds the `rendered_pdfs` table, so apps that mount it need a schema migration.");
|
|
22
23
|
r.requires({ features: ["renderer-foundation"] });
|
|
23
24
|
r.useExtension("renderer", "puppeteer", {
|
|
24
25
|
kinds: ["document-pdf", "image-snapshot"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-puppeteer-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.379.0",
|
|
4
4
|
"description": "Renderer-Foundation plugin: HTTP client for puppeteer-service (PDF + image snapshot via remote browser).",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
}
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
23
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
24
|
-
"@cosmicdrift/kumiko-renderer-mail-html": "0.
|
|
25
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
22
|
+
"@cosmicdrift/kumiko-bundled-features": "0.379.0",
|
|
23
|
+
"@cosmicdrift/kumiko-framework": "0.379.0",
|
|
24
|
+
"@cosmicdrift/kumiko-renderer-mail-html": "0.379.0",
|
|
25
|
+
"@cosmicdrift/kumiko-types": "0.379.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
28
|
+
"@cosmicdrift/kumiko-testing": "0.379.0"
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"files": [
|
|
54
54
|
"dist",
|
|
55
|
+
"kumiko-es-tables.json",
|
|
55
56
|
"src/changes.json",
|
|
56
57
|
"README.md",
|
|
57
58
|
"LICENSE"
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.379.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Retry documentation matches the client",
|
|
6
|
+
"detail": "The client retries 502, 503 and network errors; other 5xx and client timeouts fail at once because the render already ran. The feature description no longer promises a retry for every 5xx."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.379.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Package ships its event-store table catalog",
|
|
12
|
+
"detail": "kumiko-es-tables.json is included in the published files; no runtime change."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.365.0",
|
|
4
16
|
"type": "improvement",
|