Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/RUNTIME-COMPAT.md
Original file line number Diff line number Diff line change
@@ -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`).
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
22 changes: 16 additions & 6 deletions src/lib/einvoice/generate.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<InvoiceServiceType> {
if (!service) {
const { InvoiceService } = await import("@e-invoice-eu/core");
service = new InvoiceService(console);
}
return service;
}

Expand Down Expand Up @@ -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),
});

Expand Down
10 changes: 7 additions & 3 deletions src/lib/einvoice/validate.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,8 +17,9 @@ const addFormats = ((addFormatsImport as unknown as { default?: unknown }).defau

let validator: ValidateFunction | undefined;

function getValidator(): ValidateFunction {
async function getValidator(): Promise<ValidateFunction> {
if (!validator) {
const { invoiceSchema } = await import("@e-invoice-eu/core");
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
validator = ajv.compile(invoiceSchema);
Expand All @@ -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) => ({
Expand Down
Loading