Skip to content

Commit ffd235a

Browse files
cinderblockclaude
andcommitted
Add a programmatic library API (buffer cores + decodeFile)
Exposes the decode/encode functionality as an importable API alongside the CLIs, with ESM + CJS builds and TypeScript declarations. API (src/index.ts): - decodeBuffer(bytes) -> { format, xml } and encodeBuffer(xml, format, { blockSize? }) -> Buffer: pure buffer-in/buffer-out cores for the compressed-xml containers, factored out of the file/CLI wrappers. - decodeMdbBuffer(bytes) -> MdbJson: Jet database -> plain JS object. - decodeFile(path) -> DecodeResult: reads a file and returns decoded values in memory, discriminated by kind (compressed-xml -> xml bytes, mdb -> data object). This is the "filename -> decoded JS objects" path. - the format registry (FORMATS, formatByKey, detectFormatByHeader, …) and all supporting types. Build / packaging: - build.ts now also emits dist/index.mjs + dist/index.cjs (deps inlined); tsconfig.build.json emits self-contained dist/*.d.ts via tsc. - package.json gains main/module/types/exports; bin unchanged. - Decode/encode functions now take a minimal local `Logger` interface (src/util/logger.ts) instead of winston's type, so winston stays out of the published type declarations (consumers don't install it — it's bundled). winston loggers remain structurally compatible. Verified: installed the packed tarball into a clean project and round-tripped through both `require` (CJS) and `import` (ESM). 81 tests pass (+9 API tests); types are self-contained (no external imports). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c0f4c08 commit ffd235a

13 files changed

Lines changed: 434 additions & 166 deletions

‎README.md‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,43 @@ bloat) thanks to fixes in the `v5-fix` branch of the
209209
context and the fork's `v5-fix` branch for the specific bugs that
210210
were patched.
211211

212+
## Programmatic API
213+
214+
The package also ships a library API (ESM + CJS, with TypeScript types) — the
215+
same functions the CLIs are built on.
216+
217+
```ts
218+
import {
219+
decodeFile,
220+
decodeBuffer,
221+
encodeBuffer,
222+
decodeMdbBuffer,
223+
formatByKey,
224+
} from 'electronics-workbench-decoder';
225+
226+
// Filename → decoded JS values (format detected from the file's magic bytes)
227+
const result = await decodeFile('Design1.ms14');
228+
if (result.kind === 'compressed-xml') {
229+
console.log(result.xml.toString()); // the decoded XML
230+
} else {
231+
console.log(result.data.tables.SYS_COMPONENT.rows); // Jet DB as a JS object
232+
}
233+
234+
// Buffer in, buffer out — no file I/O
235+
const { xml } = decodeBuffer(ewprjBytes);
236+
const ewprj = encodeBuffer(xml, formatByKey('ewprj')!);
237+
const db = decodeMdbBuffer(prjBytes); // → { format, source, tables: {...} }
238+
```
239+
240+
Exposed: `decodeFile`, `decodeBuffer`, `encodeBuffer`, `decodeMdbBuffer`, the
241+
format registry (`FORMATS`, `formatByKey`, `formatForExtension`,
242+
`detectFormatByHeader`, `detectFileFormat`), and the supporting types
243+
(`EwbFormat`, `MdbJson`, `DecodeResult`, `DecodedXml`, …).
244+
245+
Decoding covers every supported format. Encoding via the API covers the
246+
compressed-xml containers (`encodeBuffer`); `.prj` in-place editing remains
247+
CLI-only (`ewe`).
248+
212249
## Tests
213250

214251
```bash
@@ -218,9 +255,10 @@ bun test
218255
Covers the format registry (extension matching, header detection
219256
including the Jet magic prefix with leading NUL bytes, on-disk
220257
detection), encoder filename helpers, `mdb` value normalization
221-
(`Date` → ISO, OLE blobs → base64), and compressed-xml round-trip
222-
integration (tiny `.ewprj`, tiny `.ms14`, multi-block payload that
223-
spans more than one PKWare section, and an empty payload).
258+
(`Date` → ISO, OLE blobs → base64), the public library API (buffer
259+
round-trips, `decodeFile` against a temp file), and compressed-xml
260+
round-trip integration (tiny `.ewprj`, tiny `.ms14`, multi-block
261+
payload that spans more than one PKWare section, and an empty payload).
224262

225263
## Development
226264

‎build.ts‎

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,48 @@
11
/**
2-
* Bundles the two CLIs into self-contained, node-runnable scripts under
3-
* `dist/`. All dependencies (winston, command-line-args, mdb-reader, the
4-
* node-pkware fork) are inlined, so the published package declares no
5-
* runtime dependencies. A `#!/usr/bin/env node` banner makes the output
6-
* directly executable as the `ewd` / `ewe` bin commands.
2+
* Builds everything that ships in the npm package:
3+
*
4+
* - `dist/ewd.js`, `dist/ewe.js` — the CLIs, CJS bundles with a node shebang.
5+
* - `dist/index.mjs`, `dist/index.cjs` — the library API (ESM + CJS).
6+
*
7+
* All dependencies are inlined, so the published package declares no runtime
8+
* dependencies. Type declarations (`dist/index.d.ts` + friends) are emitted
9+
* separately by `tsc -p tsconfig.build.json` in the `build` npm script.
710
*/
811

9-
import { build } from 'bun';
12+
import { build, type BuildConfig } from 'bun';
1013

11-
const result = await build({
14+
async function run(label: string, config: BuildConfig) {
15+
const result = await build(config);
16+
if (!result.success) {
17+
console.error(`build failed: ${label}`);
18+
for (const log of result.logs) console.error(log);
19+
process.exit(1);
20+
}
21+
for (const out of result.outputs) console.log(`built ${out.path}`);
22+
}
23+
24+
// CLIs — CJS bundles, executable.
25+
await run('clis', {
1226
entrypoints: ['src/ewd.ts', 'src/ewe.ts'],
1327
outdir: 'dist',
1428
target: 'node',
1529
format: 'cjs',
1630
banner: '#!/usr/bin/env node',
1731
});
1832

19-
if (!result.success) {
20-
for (const log of result.logs) console.error(log);
21-
process.exit(1);
22-
}
33+
// Library — ESM and CJS.
34+
await run('lib-esm', {
35+
entrypoints: ['src/index.ts'],
36+
outdir: 'dist',
37+
target: 'node',
38+
format: 'esm',
39+
naming: '[name].mjs',
40+
});
2341

24-
for (const out of result.outputs) {
25-
console.log(`built ${out.path}`);
26-
}
42+
await run('lib-cjs', {
43+
entrypoints: ['src/index.ts'],
44+
outdir: 'dist',
45+
target: 'node',
46+
format: 'cjs',
47+
naming: '[name].cjs',
48+
});

‎package.json‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@
2828
"ewd": "dist/ewd.js",
2929
"ewe": "dist/ewe.js"
3030
},
31+
"main": "./dist/index.cjs",
32+
"module": "./dist/index.mjs",
33+
"types": "./dist/index.d.ts",
34+
"exports": {
35+
".": {
36+
"types": "./dist/index.d.ts",
37+
"import": "./dist/index.mjs",
38+
"require": "./dist/index.cjs"
39+
},
40+
"./package.json": "./package.json"
41+
},
3142
"files": [
3243
"dist",
3344
"THIRD-PARTY-LICENSES.md"
@@ -44,7 +55,7 @@
4455
"check": "biome check",
4556
"check:fix": "biome check --write",
4657
"format": "biome format --write",
47-
"build": "bun run build.ts",
58+
"build": "bun run build.ts && tsc -p tsconfig.build.json",
4859
"licenses": "bun run scripts/collect-licenses.ts",
4960
"prepublishOnly": "bun run check && bun run typecheck && bun test && bun run licenses && bun run build"
5061
},

‎src/decode.ts‎

Lines changed: 70 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { promises as fs } from 'node:fs';
22
import { explode } from 'node-pkware/simple';
3-
import type winston from 'winston';
4-
import { detectFormatByHeader, knownFormatsList, MAX_HEADER_LENGTH } from './formats';
5-
import { bufferToArrayBuffer } from './util/buffer';
3+
import { detectFormatByHeader, type EwbFormat, knownFormatsList, MAX_HEADER_LENGTH } from './formats';
4+
import { asBuffer, bufferToArrayBuffer } from './util/buffer';
5+
import type { Logger } from './util/logger';
66
import { UnexpectedValue } from './util/UnexpectedValue';
77

88
function decodeBlock(block: Buffer, expectedLength: number): Buffer {
@@ -13,107 +13,82 @@ function decodeBlock(block: Buffer, expectedLength: number): Buffer {
1313
return result;
1414
}
1515

16-
export async function decode(filename: string, logger: winston.Logger, outFile = `${filename}.xml`): Promise<void> {
17-
if (!filename) throw new Error('No filename provided');
16+
export interface DecodedXml {
17+
/** The detected container format. */
18+
format: EwbFormat;
19+
/** The decompressed XML bytes. */
20+
xml: Buffer;
21+
}
1822

19-
const file = await fs.open(filename, 'r');
23+
/**
24+
* Decode a compressed-XML EW container (`.ewprj`, `.ms1x`) held entirely in
25+
* memory. Detects the format from the magic header, decompresses every PKWare
26+
* section, and returns the concatenated XML. Pure: no file or console I/O.
27+
*/
28+
export function decodeBuffer(input: Uint8Array): DecodedXml {
29+
const buf = asBuffer(input);
2030
let pos = 0;
2131

22-
async function read(length: number) {
23-
const { bytesRead, buffer } = await file.read(Buffer.allocUnsafe(length), 0, length, pos);
24-
25-
pos += bytesRead;
26-
27-
if (bytesRead !== length) {
28-
throw new UnexpectedValue('Failed to read as many bytes as we expect', length, bytesRead);
32+
const readBytes = (length: number): Buffer => {
33+
if (pos + length > buf.length) {
34+
throw new UnexpectedValue('Unexpected end of input', length, buf.length - pos);
2935
}
30-
31-
return buffer;
32-
}
33-
34-
async function readNumber(size: 1): Promise<number>;
35-
async function readNumber(size: 2): Promise<number>;
36-
async function readNumber(size: 3): Promise<number>;
37-
async function readNumber(size: 4): Promise<number>;
38-
async function readNumber(size: 5): Promise<number>;
39-
async function readNumber(size: 6): Promise<number>;
40-
async function readNumber(size: 7): Promise<number | bigint>;
41-
async function readNumber(size: 8): Promise<number | bigint>;
42-
async function readNumber(size: number) {
43-
const buffer = await read(size);
44-
if ((size === 8 && buffer[6] && buffer[7]) || (size === 7 && buffer[6])) {
45-
return buffer.readBigUInt64LE();
36+
const slice = buf.subarray(pos, pos + length);
37+
pos += length;
38+
return slice;
39+
};
40+
41+
// Reads a little-endian unsigned integer of `size` bytes (1-8). Values that
42+
// wouldn't fit in a JS safe integer (bytes 6/7 set in a 7/8-byte field)
43+
// are rejected rather than silently truncated.
44+
const readUInt = (size: number): number => {
45+
const slice = readBytes(size);
46+
if ((size === 8 && (slice[6] || slice[7])) || (size === 7 && slice[6])) {
47+
throw new Error('Cannot handle files this large');
4648
}
47-
48-
if (size > 8) throw new RangeError('Cannot handle size > 8');
49-
50-
// Buffer.readUIntLE() can only handle number up to 6 bytes
51-
if (size >= 6) size = 6;
52-
53-
return buffer.readUIntLE(0, size);
49+
// Buffer.readUIntLE handles at most 6 bytes; higher bytes are zero here.
50+
return slice.readUIntLE(0, Math.min(size, 6));
51+
};
52+
53+
const header = buf.subarray(0, Math.min(MAX_HEADER_LENGTH, buf.length));
54+
const format = detectFormatByHeader(header);
55+
if (!format) {
56+
throw new UnexpectedValue(
57+
`Input doesn't match any known EW format. Supported: ${knownFormatsList()}`,
58+
`one of ${knownFormatsList()}`,
59+
header.toString('ascii'),
60+
);
61+
}
62+
if (format.kind !== 'compressed-xml') {
63+
throw new Error(
64+
`Input is "${format.label}" (kind=${format.kind}); decodeBuffer only handles compressed-xml. ` +
65+
`Use decodeMdbBuffer for ${format.kind} containers.`,
66+
);
5467
}
5568

56-
let written = 0;
57-
58-
try {
59-
logger.silly(`Opening ${outFile} for output`);
60-
const outputFile = await fs.open(outFile, 'w');
61-
62-
try {
63-
const headerBuffer = await read(MAX_HEADER_LENGTH);
64-
const format = detectFormatByHeader(headerBuffer);
65-
66-
if (!format) {
67-
throw new UnexpectedValue(
68-
`File header doesn't match any known EW format. Supported: ${knownFormatsList()}`,
69-
`one of ${knownFormatsList()}`,
70-
headerBuffer.toString('ascii'),
71-
);
72-
}
73-
74-
if (format.kind !== 'compressed-xml') {
75-
throw new Error(
76-
`This file is "${format.label}" (kind=${format.kind}); decode() only handles compressed-xml. ` +
77-
`Dispatch through ewd.ts which routes to the correct decoder for each kind.`,
78-
);
79-
}
80-
81-
// Rewind past any bytes we read beyond the actual header length.
82-
pos -= MAX_HEADER_LENGTH - format.header.length;
83-
logger.silly(`Detected format: ${format.label}`);
84-
85-
const finalLength = await readNumber(8);
86-
87-
if (typeof finalLength === 'bigint') throw new Error('Cannot handle files this large');
88-
89-
logger.silly(`Full size: ${finalLength}`);
90-
91-
let section = -1;
92-
93-
let decompressedBytesRead = 0;
94-
95-
while (decompressedBytesRead < finalLength) {
96-
section++;
97-
const length = await readNumber(4);
98-
const blockSize = await readNumber(4);
99-
100-
logger.silly(`Section #${section} read ${blockSize} bytes, decompresses to ${length}`);
101-
102-
const compressedData = await read(blockSize);
69+
pos = format.header.length;
70+
const finalLength = readUInt(8);
71+
72+
const blocks: Buffer[] = [];
73+
let decompressed = 0;
74+
while (decompressed < finalLength) {
75+
const length = readUInt(4);
76+
const blockSize = readUInt(4);
77+
const compressed = readBytes(blockSize);
78+
blocks.push(decodeBlock(compressed, length));
79+
decompressed += length;
80+
}
10381

104-
decompressedBytesRead += length;
82+
return { format, xml: Buffer.concat(blocks) };
83+
}
10584

106-
const decodedBlock = decodeBlock(compressedData, length);
85+
export async function decode(filename: string, logger: Logger, outFile = `${filename}.xml`): Promise<void> {
86+
if (!filename) throw new Error('No filename provided');
10787

108-
written += (await outputFile.write(decodedBlock)).bytesWritten;
109-
}
88+
logger.silly(`Reading ${filename}`);
89+
const { format, xml } = decodeBuffer(await fs.readFile(filename));
90+
logger.silly(`Detected format: ${format.label}`);
11091

111-
logger.verbose(`Wrote ${written} bytes to ${outFile}`);
112-
logger.silly(`Finished reading file: ${filename}`);
113-
} finally {
114-
await outputFile.close();
115-
}
116-
} finally {
117-
await file.close();
118-
}
92+
await fs.writeFile(outFile, xml);
93+
logger.verbose(`Wrote ${xml.length} bytes to ${outFile}`);
11994
}

‎src/decodeFile.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { promises as fs } from 'node:fs';
2+
import { decodeBuffer } from './decode';
3+
import { decodeMdbBuffer, type MdbJson } from './decodeMdb';
4+
import { detectFormatByHeader, type EwbFormat, knownFormatsList, MAX_HEADER_LENGTH } from './formats';
5+
import { UnexpectedValue } from './util/UnexpectedValue';
6+
7+
/**
8+
* The in-memory decoded representation of an EW file, discriminated by the
9+
* container kind:
10+
*
11+
* - `compressed-xml` (`.ewprj`, `.ms1x`): `xml` holds the decompressed XML
12+
* bytes (call `.toString()` for text).
13+
* - `mdb` (`.prj`, `.usr`): `data` holds the database as a plain JS object
14+
* (tables → `{ columns, rows }`).
15+
*/
16+
export type DecodeResult =
17+
| { kind: 'compressed-xml'; format: EwbFormat; xml: Buffer }
18+
| { kind: 'mdb'; format: EwbFormat; data: MdbJson };
19+
20+
/**
21+
* Read an EW file from disk and decode it into in-memory JS values. The
22+
* container format is detected from the file's magic bytes, so the extension
23+
* doesn't matter.
24+
*/
25+
export async function decodeFile(filename: string): Promise<DecodeResult> {
26+
const buf = await fs.readFile(filename);
27+
const format = detectFormatByHeader(buf.subarray(0, MAX_HEADER_LENGTH));
28+
29+
if (!format) {
30+
throw new UnexpectedValue(
31+
`"${filename}" doesn't match any known EW format. Supported: ${knownFormatsList()}`,
32+
`one of ${knownFormatsList()}`,
33+
buf.subarray(0, MAX_HEADER_LENGTH).toString('ascii'),
34+
);
35+
}
36+
37+
switch (format.kind) {
38+
case 'compressed-xml':
39+
return { kind: 'compressed-xml', format, xml: decodeBuffer(buf).xml };
40+
case 'mdb':
41+
return { kind: 'mdb', format, data: decodeMdbBuffer(buf, filename) };
42+
}
43+
}

0 commit comments

Comments
 (0)