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
15 changes: 13 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,22 @@ jobs:
- name: Install dependencies
run: yarn install --immutable

# Build before type check: the /node subpath type fixture resolves
# `ksef-client-ts/node` against the built dist (dist/node.d.ts / .d.cts).
- name: Build
run: yarn build

- name: Type check
run: yarn lint

- name: Build
run: yarn build
# Resolves the /node subpath types against the built dist; requires the
# Build step above (kept separate from `lint` so a clean `yarn lint`
# needs no prior build).
- name: Check /node entry types
run: yarn check:node-types

- name: Check fs-free core bundles
run: node packages/ksef-client-ts/scripts/check-fs-free-core.mjs

- name: Run tests with coverage
run: yarn test --coverage
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"test:e2e": "yarn workspace ksef-client-ts test:e2e",
"test:watch": "yarn workspace ksef-client-ts test:watch",
"lint": "yarn workspace ksef-client-ts lint",
"check:node-types": "yarn workspace ksef-client-ts check:node-types",
"lint:md": "yarn workspace ksef-client-ts lint:md",
"lint:md:fix": "yarn workspace ksef-client-ts lint:md:fix",
"docs:dev": "yarn workspace ksef-client-ts docs:dev",
Expand Down
10 changes: 10 additions & 0 deletions packages/ksef-client-ts/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to this project will be documented in this file.

## [0.10.0] - 2026-06-14

### Changed (breaking)

- **Node.js-specific features moved to a separate entry point** — filesystem-dependent and other Node-only features are no longer exported from the main `ksef-client-ts` entry point, to improve portability and reduce bundle size for web/edge environments. They are now available via `ksef-client-ts/node`; see the [v0.10 migration guide](docs/migration-v0.10.md) for the affected exports and how to update imports.

### Changed

- **Smaller core bundle** — certificate-based authentication now loads its cryptographic signing modules on demand instead of bundling them upfront, reducing the core bundle size. This is transparent to callers; the public API is unchanged.

## [0.9.1] - 2026-06-13

### Changed
Expand Down
1 change: 1 addition & 0 deletions packages/ksef-client-ts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ TypeScript client for the Polish National e-Invoice System (KSeF) API v2.
- **Comprehensive test coverage** — unit + E2E tests across HTTP, crypto, services, workflows; CI on every change
- **Interactive setup wizard** — `ksef setup` guides through environment selection, authentication, and token generation in one flow
- **Zero HTTP dependencies** — native `fetch` (Node 18+); dual ESM/CJS via tsup
- **fs-free core** — the main entry point is free of filesystem access, so it runs on Node, Deno, and edge runtimes; Node-only features (filesystem storage, XSD validation) are available via the `ksef-client-ts/node` export

Requires **Node.js 18+**.

Expand Down
1 change: 1 addition & 0 deletions packages/ksef-client-ts/docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default defineConfig({
items: [
{ text: 'Home', link: '/' },
{ text: 'Quick Start', link: '/quick-start' },
{ text: 'Migration Guide (v0.10)', link: '/migration-v0.10' },
{ text: 'Architecture', link: '/architecture' },
{ text: 'Authentication', link: '/authentication' },
],
Expand Down
47 changes: 12 additions & 35 deletions packages/ksef-client-ts/docs/deno.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,51 +84,28 @@ All cryptographic operations — RSA-OAEP token encryption, AES-256-CBC session

---

## What doesn't work: `validateAgainstXsd`
## What doesn't work: Node.js-specific Features

The one API that does not work on Deno is `validateAgainstXsd(xml, xsdPath)`, used for strict XSD-level invoice validation against the official KSeF schemas. It depends on `libxmljs2`, which wraps the native `libxml2` C library via N-API. Deno's Node compatibility layer cannot load that binding (any attempt to `require('libxmljs2')` or `import('libxmljs2')` will segfault the runtime — an upstream Deno limitation, not something fixable in the library).
As of v0.10.0, features that rely on Node.js-specific APIs (like the filesystem or native C++ add-ons) are no longer part of the main library bundle. They are now available via a separate `ksef-client-ts/node` entry point.

### What happens if you call it anyway
This means these features are definitively not available on Deno, as Deno cannot consume the Node.js-specific APIs they rely on.

Assuming `libxmljs2` is **not** installed (the expected state on Deno — you should not install it, see below), the call throws a clear, catchable error:
### `validateAgainstXsd`

```ts
try {
validateAgainstXsd(xml, xsdPath);
} catch (err) {
// Error: libxmljs2 is not installed; cannot run XSD validation.
// Install it as an optional peer dependency (e.g. `yarn add -O libxmljs2` or `npm i -O libxmljs2`).
}
```
The primary example is `validateAgainstXsd(xml, xsdPath)`, used for strict XSD-level invoice validation. It depends on `libxmljs2`, which wraps the native `libxml2` C library via N-API. Deno's Node compatibility layer cannot load this binding.

The library exports a helper to identify this specific failure so you can fall back gracefully:
Attempting to import it on Deno will fail:

```ts
import { validateAgainstXsd, validate, isMissingLibxmljsError } from 'npm:ksef-client-ts@0.8.0';

try {
const result = validateAgainstXsd(xml, xsdPath);
// ...
} catch (err) {
if (isMissingLibxmljsError(err)) {
// Fall back to Zod-based schema validation — pure JS, works on Deno.
const result = await validate(xml);
// ...
} else {
throw err;
}
}
```typescript
// This will fail on Deno
import { validateAgainstXsd } from 'npm:ksef-client-ts/node';
```

### Zod validation is usually enough

For the common case — "build an invoice XML, verify it's structurally correct before sending" — Zod-based validation (`validate()`, `validateSchema()`) is sufficient. It checks every required field, format (NIP, PESEL, KSeF numbers), enum values, multi-rate VAT group constraints, and business rules end-to-end. XSD validation adds pedantic structural checks against the official schema; it's useful for debugging hard-to-explain server-side rejections, not for routine validation.

### Why you should not `install` libxmljs2 on Deno
The Zod-based validator (`validate()`) is the recommended alternative and works perfectly on Deno.

If `libxmljs2` somehow ends up in a `node_modules/` that Deno can see (for example, a shared folder with a Node project), the segfault happens **at library import time**, not at call time. Your whole app fails to start with a segmentation fault rather than a catchable error.
### Filesystem Storage

The library marks `libxmljs2` as an optional peer dependency, so a plain `deno add npm:ksef-client-ts` or `npm install ksef-client-ts` will never pull it in transitively. Only an explicit direct install will. On a Deno-only setup, there is no reason to do that.
Similarly, filesystem-based storage classes like `FileOfflineInvoiceStorage` and `FileHwmStore` are in the `ksef-client-ts/node` entry point and will not work in Deno's environment. Use `InMemoryOfflineInvoiceStorage` or implement a custom storage backend (e.g., using Deno's file APIs or a database).

---

Expand Down
2 changes: 2 additions & 0 deletions packages/ksef-client-ts/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,6 @@ features:
details: Get started in one command — ksef setup walks you through environment selection, NIP configuration, external signature authentication, and API token generation. Credentials are securely stored in ~/.ksef/credentials.json.
- title: Zero HTTP Dependencies
details: Uses native fetch (Node 18+) with no external HTTP libraries. Dual ESM/CJS output via tsup. Resilient transport with exponential backoff retry, token bucket rate limiting, opt-in circuit breaker, and presigned URL validation.
- title: fs-free Core
details: The main entry point is free of filesystem access and runs on Node, Deno, and edge runtimes. Node.js-only features (filesystem access, XSD validation) are available via the `ksef-client-ts/node` export, keeping the core library small.
---
93 changes: 93 additions & 0 deletions packages/ksef-client-ts/docs/migration-v0.10.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Migration Guide: v0.10.0

Version `0.10.0` introduces a significant refactoring to improve portability and reduce the bundle size for web and edge environments. This guide outlines the breaking changes and how to update your code.

## Node.js-specific Features Moved to a Separate Entry Point

To keep the core `ksef-client-ts` library free of filesystem dependencies (so it runs on Deno and edge functions without Node-specific filesystem access), filesystem-dependent and other Node-only features have been moved to a separate entry point: `ksef-client-ts/node`.

If your application runs on Node.js and uses any of the features listed below, you will need to update your import paths.

### Affected Features

1. **Offline Invoice Storage:** The `FileOfflineInvoiceStorage` class, which uses the filesystem to store offline invoices, is now exposed via the `ksef-client-ts/node` entry point.

**Before:**

```typescript
import { FileOfflineInvoiceStorage } from 'ksef-client-ts';
```

**After:**

```typescript
import { FileOfflineInvoiceStorage } from 'ksef-client-ts/node';
```

2. **XSD Schema Validation:** The `validateAgainstXsd` function and its related helpers rely on `libxmljs2`, a native Node.js module. These are now available from the `ksef-client-ts/node` entry point.

**Before:**

```typescript
import { validateAgainstXsd } from 'ksef-client-ts';
```

**After:**

```typescript
import { validateAgainstXsd } from 'ksef-client-ts/node';
```

This also applies to `FA_XSD_PATHS`, `PEF_XSD_PATHS`, `libxmljsAvailable`, `resolveXsdFor`, and `isMissingLibxmljsError`.

3. **High-Water-Mark Storage for Incremental Export:** The `FileHwmStore`, used for persisting the high-water-mark in incremental invoice exports, has been moved.

**Before:**

```typescript
import { FileHwmStore } from 'ksef-client-ts';
```

**After:**

```typescript
import { FileHwmStore } from 'ksef-client-ts/node';
```

### Rationale

This change allows developers to use `ksef-client-ts` in a wider range of JavaScript environments without including unnecessary Node.js-specific code, leading to smaller bundles and better performance, especially in serverless and edge computing contexts.

## Troubleshooting

The `ksef-client-ts/node` entry point is published as a [subpath export](https://nodejs.org/api/packages.html#subpath-exports). Two toolchain setups need attention after you update an import.

### TypeScript: `Cannot find module 'ksef-client-ts/node'` (TS2307)

If TypeScript reports `TS2307` for the new import even though the code runs fine at runtime, your project is using the legacy module resolution that does not read the package `exports` map. Set `moduleResolution` to a value that supports subpath exports:

```json
{
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16"
}
}
```

`nodenext` or `bundler` work too. The legacy `node` (a.k.a. `node10`) resolution cannot resolve subpath exports and will keep failing.

### CommonJS: the moved symbol is `undefined`

Under CommonJS, importing a moved symbol from the root entry point no longer throws at `require` time — it silently resolves to `undefined`, and you only see the failure later when you use it:

```js
// ❌ FileHwmStore is undefined here — no error yet
const { FileHwmStore } = require('ksef-client-ts');
new FileHwmStore(); // TypeError: FileHwmStore is not a constructor

// ✅ require from the /node entry point instead
const { FileHwmStore } = require('ksef-client-ts/node');
```

If you hit a `TypeError: X is not a constructor` (or a call on `undefined`) for any of the moved symbols, update the `require` path to `ksef-client-ts/node`.
2 changes: 1 addition & 1 deletion packages/ksef-client-ts/docs/offline-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ const storage = new InMemoryOfflineInvoiceStorage();
For CLI and persistent use. Stores each invoice as `{uuid}.json` in a directory. Default: `~/.ksef/offline/`.

```typescript
import { FileOfflineInvoiceStorage } from 'ksef-client-ts';
import { FileOfflineInvoiceStorage } from 'ksef-client-ts/node';

const storage = new FileOfflineInvoiceStorage(); // ~/.ksef/offline/
const custom = new FileOfflineInvoiceStorage('/tmp/offline'); // custom dir
Expand Down
6 changes: 3 additions & 3 deletions packages/ksef-client-ts/docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,8 +487,8 @@ The KSeF export API has a limit on how many invoices it returns per export. If t
```typescript
import {
incrementalExportAndDownload,
FileHwmStore,
} from 'ksef-client-ts';
import { FileHwmStore } from 'ksef-client-ts/node';

const result = await incrementalExportAndDownload(client, {
subjectType: 'Subject1',
Expand Down Expand Up @@ -570,8 +570,8 @@ Two built-in implementations:

| Class | Storage | Use case |
|-------|---------|----------|
| `InMemoryHwmStore` | In-process memory | Tests, one-off exports |
| `FileHwmStore` | JSON file on disk | Persistent synchronization, cron jobs |
| `InMemoryHwmStore` | In-process memory | Tests, one-off exports, non-Node environments |
| `FileHwmStore` | JSON file on disk | Persistent synchronization, cron jobs (Node.js only) |

`FileHwmStore` gracefully handles missing files (returns `{}` on `ENOENT`).

Expand Down
2 changes: 1 addition & 1 deletion packages/ksef-client-ts/docs/xml-serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ Schema inference mirrors the serializer: `Invoice` key → PEF, `CreditNote` →

## Limitations

- **Runtime XSD validation is opt-in and requires an optional peer dependency.** The CLI's `--validate-xsd` flag and the `validateAgainstXsd` helper both depend on `libxmljs2`, which the package declares as an optional peer dependency rather than bundling: it is ~2 MB, native, and historically flaky across Node versions. Install it with `npm install --save-optional libxmljs2` when you want XSD-level validation; otherwise the Zod-based [XML validator](./validation.md) (generated from the same XSDs) is always available.
- **Runtime XSD validation is opt-in, Node.js-only, and requires an optional peer dependency.** The CLI's `--validate-xsd` flag and the `validateAgainstXsd` helper both depend on `libxmljs2`, a native add-on. As of v0.10.0, these are exposed via the `ksef-client-ts/node` entry point and will not work in other runtimes (like Deno or browsers). The package declares `libxmljs2` as an optional peer dependency rather than bundling it: it is ~2 MB, native, and historically flaky across Node versions. Install it with `npm install --save-optional libxmljs2` when you want XSD-level validation; otherwise the Zod-based [XML validator](./validation.md) (generated from the same XSDs) is always available.
- **No FA_RR structural builder.** The FA_RR v1-1E schema shipped in December 2025. No reference implementation has a structural builder for it yet. FA_RR XML is supported via the pass-through path — build the XML yourself (or with another tool) and pipe it through `serializeInvoiceXml` to get a `Buffer`.
- **No round-trip parsing.** The serializer is one-way: typed object → XML. Parsing XML back into a `FakturaInput` is out of scope.
- **No fluent builder DSL.** Plain typed objects only. A fluent wrapper can be layered on top later without breaking the object-based API; demand has not materialised yet.
Expand Down
14 changes: 13 additions & 1 deletion packages/ksef-client-ts/package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
{
"name": "ksef-client-ts",
"version": "0.9.1",
"version": "0.10.0",
"description": "TypeScript client for the Polish National e-Invoice System (KSeF) API",
"type": "module",
"sideEffects": false,
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand All @@ -16,6 +17,16 @@
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./node": {
"import": {
"types": "./dist/node.d.ts",
"default": "./dist/node.js"
},
"require": {
"types": "./dist/node.d.cts",
"default": "./dist/node.cjs"
}
}
},
"bin": {
Expand All @@ -33,6 +44,7 @@
"test:e2e:watch": "vitest tests/e2e --reporter=verbose",
"test:watch": "vitest",
"lint": "tsc --noEmit",
"check:node-types": "tsc --project tsconfig.node-check.json",
"lint:md": "markdownlint-cli2",
"lint:md:fix": "markdownlint-cli2 --fix",
"link": "npm link",
Expand Down
90 changes: 90 additions & 0 deletions packages/ksef-client-ts/scripts/check-fs-free-core.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";

const packageRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);

export const coreBundlePaths = ["dist/index.js", "dist/index.cjs"];
export const nodeBundlePaths = ["dist/node.js", "dist/node.cjs"];

export const fsLeakPatterns = [
{ label: "node:fs/promises", pattern: /node:fs\/promises/ },
{ label: "node:fs", pattern: /node:fs/ },
{ label: "fs/promises", pattern: /(?<!node:)fs\/promises/ },
{
label: "require(\"fs\")",
pattern: /require\s*\(\s*["'](?:node:)?fs["']\s*\)/,
},
{
label: "require(\"fs/promises\")",
pattern: /require\s*\(\s*["'](?:node:)?fs\/promises["']\s*\)/,
},
{
label: "from \"fs\"",
pattern: /from\s*["'](?:node:)?fs["']/,
},
{
label: "from \"fs/promises\"",
pattern: /from\s*["'](?:node:)?fs\/promises["']/,
},
{ label: "readFileSync", pattern: /\breadFileSync\b/ },
{ label: "writeFileSync", pattern: /\bwriteFileSync\b/ },
{ label: "readFile", pattern: /\breadFile\b/ },
{ label: "writeFile", pattern: /\bwriteFile\b/ },
{ label: "homedir", pattern: /\bhomedir\b/ },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
];

export function findFsMarkers(source) {
return fsLeakPatterns
.filter(({ pattern }) => pattern.test(source))
.map(({ label }) => label);
}

async function readBundle(relativePath) {
const absolutePath = path.join(packageRoot, relativePath);

try {
return await readFile(absolutePath, "utf8");
} catch (error) {
if (error?.code === "ENOENT") {
throw new Error(`Missing built bundle: ${relativePath}. Run yarn build first.`);
}

throw error;
}
}

export async function checkFsFreeCore() {
const failures = [];

for (const relativePath of coreBundlePaths) {
const source = await readBundle(relativePath);
const markers = findFsMarkers(source);

if (markers.length > 0) {
failures.push(`${relativePath}: ${markers.join(", ")}`);
}
}

for (const relativePath of nodeBundlePaths) {
const nodeBundleMarkers = findFsMarkers(await readBundle(relativePath));

if (nodeBundleMarkers.length === 0) {
failures.push(`${relativePath}: expected fs marker for node-only entry`);
}
}

if (failures.length > 0) {
throw new Error(`fs cleanliness guard failed:\n- ${failures.join("\n- ")}`);
}
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
checkFsFreeCore().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
Loading
Loading