From 0746b657cab5fabdd85703dc7d5af5ce01d692ed Mon Sep 17 00:00:00 2001 From: Nik Divjak Date: Mon, 25 May 2026 08:58:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v0.1.3=20=E2=80=94=20lazy-load=20@e-inv?= =?UTF-8?q?oice-eu/core=20(engine=20importable=20on=20Workers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @e-invoice-eu/core is Node-only (module-init crash under Cloudflare Workers: tmp-promise → fs.realpathSync, unimplemented in nodejs_compat). Until now, merely importing @grunt-it/fiscalize crashed a Worker at load. Now its value-imports (InvoiceService, invoiceSchema) are dynamic-imported inside generateEInvoice (ubl/cii) + validateEn16931, so importing the engine is Workers-safe; those two paths throw only-if-called on a non-Node runtime. e-SLOG generation + all FURS crypto + (Node) UBL/validation unaffected. Verified: 44/1 tests + tsc on Node (lazy-load preserves behavior); under local workerd, top-level import no longer crashes and generateEInvoice('eslog') works. Adds docs/RUNTIME-COMPAT.md (the verified Workers-vs-Node capability matrix): Workers-safe = e-SLOG gen + FURS crypto; Node-only = @e-invoice-eu/core (UBL + EN16931 validation) + xmllint-wasm (validateEslogXml, "Worker is not defined"). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/RUNTIME-COMPAT.md | 45 ++++++++++++++++++++++++++++++++++++ package.json | 2 +- src/lib/einvoice/generate.ts | 22 +++++++++++++----- src/lib/einvoice/validate.ts | 10 +++++--- 4 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 docs/RUNTIME-COMPAT.md diff --git a/docs/RUNTIME-COMPAT.md b/docs/RUNTIME-COMPAT.md new file mode 100644 index 0000000..949f034 --- /dev/null +++ b/docs/RUNTIME-COMPAT.md @@ -0,0 +1,45 @@ +# Runtime compatibility + +`@grunt-it/fiscalize` runs on **Node** (full) and on **non-Node runtimes like +Cloudflare Workers / workerd** (a subset). This matrix is the verified ground +truth — tested under local `workerd` (`wrangler dev`), per function. + +| Capability | Module / dep | Node | Workers (workerd) | +|---|---|---|---| +| e-SLOG 2.0 **generation** (`serializeEslog`) | `xmlbuilder2` | ✅ | ✅ | +| FURS ZOI (`calculateZoi`) | `node:crypto` MD5 + RSA-SHA256 | ✅ | ✅ | +| FURS request JWS (`signFursJws`) | `node:crypto` sign | ✅ | ✅ | +| FURS response verify (`verifyFursResponse`) | `node:crypto` `X509Certificate` | ✅ | ✅ | +| Cert load (`loadP12`) + demo keygen | `node-forge` (p12 parse, RSA keygen) | ✅ | ✅ | +| UBL / CII generation (`generateEInvoice` ubl/cii) | `@e-invoice-eu/core` | ✅ | ❌ | +| EN16931 validation (`validateEn16931`) | `@e-invoice-eu/core` (`invoiceSchema`) | ✅ | ❌ | +| e-SLOG **XSD** output validation (`validateEslogXml`) | `xmllint-wasm` | ✅ | ❌ | + +## The two Workers-incompatible paths + +1. **`@e-invoice-eu/core`** (UBL/CII + EN16931 validation) — its ESM eager-imports + `tmp-promise` → `tmp` → `fs.realpathSync`, which Cloudflare's `nodejs_compat` + (unenv) does not implement. **As of v0.1.3 it is dynamically imported**, so + merely importing `@grunt-it/fiscalize` is Workers-safe — `generateEInvoice('ubl'|'cii')` + and `validateEn16931` throw **only if called** on a non-Node runtime. + +2. **`xmllint-wasm`** (`validateEslogXml`) — fails under workerd with + `"Worker is not defined"` (it expects a `Worker` global workerd lacks). + Importing `@grunt-it/fiscalize/eslog` is fine (`serializeEslog` works); only + `validateEslogXml` fails when called. The serializer's output is + **XSD-conformant regardless** — the engine's CI validates it against the + official e-SLOG 2.0 XSD on every build; you just can't *re-validate* at + runtime on Workers. + +## Guidance for Workers consumers + +Use the **`/eslog`** and **`/furs`** subpath exports (or the top-level, since +v0.1.3 makes import safe). On Workers: + +- ✅ Generate e-SLOG 2.0 + run the full FURS fiscal-verification flow (ZOI / EOR / + JWS sign + verify). +- ❌ Don't call UBL/CII generation, `validateEn16931`, or `validateEslogXml` — + those need a Node runtime. Gate them behind a runtime check, or run that part + on Node (a container / `coolster` service). + +Set `nodejs_compat` (the FURS path uses `node:crypto`/`node-forge`). diff --git a/package.json b/package.json index 533d727..2136165 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@grunt-it/fiscalize", - "version": "0.1.2", + "version": "0.1.3", "description": "Private/internal grunt-it Slovenian fiscalization + e-invoicing toolkit (TS/Effect). P1: EN16931 → e-SLOG 2.0 / UBL e-invoice generation + validation, deriving from E-Invoice-EU. Framework-agnostic engine.", "type": "module", "license": "UNLICENSED", diff --git a/src/lib/einvoice/generate.ts b/src/lib/einvoice/generate.ts index b6cd4c5..a2e3236 100644 --- a/src/lib/einvoice/generate.ts +++ b/src/lib/einvoice/generate.ts @@ -1,4 +1,9 @@ -import { type Invoice as EInvoiceEUInvoice, InvoiceService } from "@e-invoice-eu/core"; +// Type-only import (erased at runtime). The VALUE (`InvoiceService`) is loaded +// lazily in getService() so merely importing the engine does NOT pull +// @e-invoice-eu/core — it's Node-only (crashes at module-init under Cloudflare +// Workers via tmp-promise → fs.realpathSync). UBL/CII therefore throw only if +// *called* on a non-Node runtime, not on import. See docs/RUNTIME-COMPAT.md. +import type { Invoice as EInvoiceEUInvoice, InvoiceService as InvoiceServiceType } from "@e-invoice-eu/core"; import { Effect } from "effect"; import { EInvoiceGenerationError, UnsupportedFormatError } from "../foundation/errors"; import type { EslogOptions } from "../eslog/serialize"; @@ -23,10 +28,14 @@ export interface GenerateOptions extends EslogOptions { validateOutput?: boolean; } -// `InvoiceService` is stateless across calls; build once. -let service: InvoiceService | undefined; -function getService(): InvoiceService { - if (!service) service = new InvoiceService(console); +// `InvoiceService` is stateless across calls; build once. Loaded lazily via +// dynamic import so the engine stays importable on non-Node runtimes. +let service: InvoiceServiceType | undefined; +async function getService(): Promise { + if (!service) { + const { InvoiceService } = await import("@e-invoice-eu/core"); + service = new InvoiceService(console); + } return service; } @@ -59,7 +68,8 @@ export const generateEInvoice = Effect.fn("generateEInvoice")(function* ( const internal = toEInvoiceInternal(invoice) as unknown as EInvoiceEUInvoice; const rendered = yield* Effect.tryPromise({ - try: () => getService().generate(internal, { format: LIB_FORMAT[format], lang: options.lang ?? "sl" }), + try: async () => + (await getService()).generate(internal, { format: LIB_FORMAT[format], lang: options.lang ?? "sl" }), catch: (cause) => new EInvoiceGenerationError(format, cause), }); diff --git a/src/lib/einvoice/validate.ts b/src/lib/einvoice/validate.ts index a568791..ec2eccc 100644 --- a/src/lib/einvoice/validate.ts +++ b/src/lib/einvoice/validate.ts @@ -1,4 +1,7 @@ -import { invoiceSchema } from "@e-invoice-eu/core"; +// `invoiceSchema` is loaded lazily (dynamic import) so the engine stays +// importable on non-Node runtimes — @e-invoice-eu/core is Node-only (module-init +// crash under Cloudflare Workers). validateEn16931 therefore works on Node and +// throws only-if-called elsewhere. See docs/RUNTIME-COMPAT.md. // `@e-invoice-eu/core`'s invoiceSchema is JSON Schema draft 2019-09 → use Ajv2019. import AjvImport, { type ValidateFunction } from "ajv/dist/2019.js"; import addFormatsImport from "ajv-formats"; @@ -14,8 +17,9 @@ const addFormats = ((addFormatsImport as unknown as { default?: unknown }).defau let validator: ValidateFunction | undefined; -function getValidator(): ValidateFunction { +async function getValidator(): Promise { if (!validator) { + const { invoiceSchema } = await import("@e-invoice-eu/core"); const ajv = new Ajv({ allErrors: true, strict: false }); addFormats(ajv); validator = ajv.compile(invoiceSchema); @@ -31,7 +35,7 @@ function getValidator(): ValidateFunction { */ export const validateEn16931 = Effect.fn("validateEn16931")(function* (invoice: Invoice) { const internal = toEInvoiceInternal(invoice); - const validate = getValidator(); + const validate = yield* Effect.promise(() => getValidator()); if (validate(internal)) return internal; const issues: ValidationIssue[] = (validate.errors ?? []).map((e) => ({