Skip to content

Commit 9d1dcdc

Browse files
authored
feat: add framed Snappy streaming APIs (#351)
* feat: add framed Snappy streaming APIs Add Compressor/Decompressor classes, Web Streams, and Node Duplex factories mirroring @napi-rs/lzma. Streaming uses the Snappy frame format (distinct from the existing raw one-shot compress/uncompress). * fix: satisfy clippy new_without_default for Compressor Implement Default for Compressor and apply prettier on streaming sources.
1 parent b0ee4c7 commit 9d1dcdc

18 files changed

Lines changed: 1398 additions & 31 deletions

Cargo.toml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@ version = "0.1.0"
88
crate-type = ["cdylib"]
99

1010
[dependencies]
11-
napi = { version = "3.0.0", features = ["napi5", "serde-json"] }
12-
napi-derive = { version = "3.0.0" }
11+
napi = { version = "3", features = ["napi5", "serde-json"] }
12+
napi-derive = "3"
1313
snap = "1"
1414

15+
# Web Streams transforms (`src/stream_web.rs`) need tokio via napi's `web_stream`
16+
# feature. Enable it ONLY for non-wasm targets so the wasm build keeps plain
17+
# `napi` (no tokio) and drops the streaming module. `web_stream` only pulls
18+
# `napi4`, but napi's ReadableStream finalizer calls `napi_add_finalizer`
19+
# (Node-API 5), so `napi5` is required alongside it.
20+
[target.'cfg(not(target_family = "wasm"))'.dependencies]
21+
napi = { version = "3", features = ["web_stream", "napi5"] }
22+
1523
[target.'cfg(all(not(target_os = "linux"), not(target_family = "wasm")))'.dependencies]
1624
mimalloc-safe = { version = "0.1", features = ["skip_collect_on_exit"] }
1725

README.md

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -45,25 +45,25 @@ yarn add snappy
4545

4646
### Targets
4747

48-
| Rust triple | Platform | CI |
49-
| ------------------------------- | -------------------- | ------------------------------- |
50-
| `x86_64-pc-windows-msvc` | Windows x64 | tested — node 22, 24 |
51-
| `aarch64-pc-windows-msvc` | Windows arm64 | tested — node 22, 24 |
52-
| `i686-pc-windows-msvc` | Windows x32 | built, not tested |
53-
| `x86_64-apple-darwin` | macOS x64 | tested — node 22, 24 |
54-
| `aarch64-apple-darwin` | macOS arm64 | tested — node 22, 24 |
55-
| `x86_64-unknown-linux-gnu` | Linux x64 gnu | tested — node 22, 24 |
56-
| `x86_64-unknown-linux-musl` | Linux x64 musl | tested — node 22, 24 |
57-
| `aarch64-unknown-linux-gnu` | Linux arm64 gnu | tested — node 22, 24 |
58-
| `aarch64-unknown-linux-musl` | Linux arm64 musl | tested — node 22, 24 |
59-
| `armv7-unknown-linux-gnueabihf` | Linux armv7 gnu | tested — node 22 only |
60-
| `s390x-unknown-linux-gnu` | Linux s390x | tested — node 22, 24 |
61-
| `x86_64-unknown-freebsd` | FreeBSD x64 | built, not tested |
62-
| `powerpc64le-unknown-linux-gnu` | Linux ppc64le | built, not tested |
63-
| `riscv64gc-unknown-linux-gnu` | Linux riscv64 | built, not tested |
64-
| `aarch64-linux-android` | Android arm64 | built, not tested |
65-
| `arm-linux-androideabi` | Android armv7 | built, not tested |
66-
| `aarch64-unknown-linux-ohos` | OpenHarmony arm64 | built, not tested |
48+
| Rust triple | Platform | CI |
49+
| ------------------------------- | -------------------- | --------------------------------------- |
50+
| `x86_64-pc-windows-msvc` | Windows x64 | tested — node 22, 24 |
51+
| `aarch64-pc-windows-msvc` | Windows arm64 | tested — node 22, 24 |
52+
| `i686-pc-windows-msvc` | Windows x32 | built, not tested |
53+
| `x86_64-apple-darwin` | macOS x64 | tested — node 22, 24 |
54+
| `aarch64-apple-darwin` | macOS arm64 | tested — node 22, 24 |
55+
| `x86_64-unknown-linux-gnu` | Linux x64 gnu | tested — node 22, 24 |
56+
| `x86_64-unknown-linux-musl` | Linux x64 musl | tested — node 22, 24 |
57+
| `aarch64-unknown-linux-gnu` | Linux arm64 gnu | tested — node 22, 24 |
58+
| `aarch64-unknown-linux-musl` | Linux arm64 musl | tested — node 22, 24 |
59+
| `armv7-unknown-linux-gnueabihf` | Linux armv7 gnu | tested — node 22 only |
60+
| `s390x-unknown-linux-gnu` | Linux s390x | tested — node 22, 24 |
61+
| `x86_64-unknown-freebsd` | FreeBSD x64 | built, not tested |
62+
| `powerpc64le-unknown-linux-gnu` | Linux ppc64le | built, not tested |
63+
| `riscv64gc-unknown-linux-gnu` | Linux riscv64 | built, not tested |
64+
| `aarch64-linux-android` | Android arm64 | built, not tested |
65+
| `arm-linux-androideabi` | Android armv7 | built, not tested |
66+
| `aarch64-unknown-linux-ohos` | OpenHarmony arm64 | built, not tested |
6767
| `wasm32-wasip1-threads` | wasm32-wasi, browser | tested — node 24 (`NAPI_RS_FORCE_WASI`) |
6868

6969
Eighteen targets: eleven CI-tested, seven built but not exercised.
@@ -81,13 +81,62 @@ served with `Cross-Origin-Opener-Policy: same-origin` and
8181

8282
## API
8383

84+
### One-shot (raw Snappy block format)
85+
8486
```ts
8587
export function compressSync(input: Buffer | string | ArrayBuffer | Uint8Array): Buffer
8688
export function compress(input: Buffer | string | ArrayBuffer | Uint8Array): Promise<Buffer>
8789
export function uncompressSync(compressed: Buffer): Buffer
8890
export function uncompress(compressed: Buffer): Promise<Buffer>
8991
```
9092

93+
### Streaming (framed Snappy format)
94+
95+
Streaming uses the [Snappy frame format](https://github.com/google/snappy/blob/master/framing_format.txt)
96+
(file extension `.sz`). This is **not** the same wire format as the one-shot APIs
97+
aboveframed output cannot be passed to `uncompress()`, and raw blocks cannot
98+
be passed to the stream decompressors.
99+
100+
#### Incremental classes
101+
102+
```js
103+
import { Compressor, Decompressor } from 'snappy'
104+
105+
const compressor = new Compressor()
106+
const parts = [compressor.update('Hello '), compressor.update('snappy 🚀'), await compressor.finish()]
107+
const compressed = Buffer.concat(parts)
108+
109+
const decompressor = new Decompressor()
110+
const restored = Buffer.concat([decompressor.update(compressed), await decompressor.finish()])
111+
console.log(restored.toString('utf8')) // Hello snappy 🚀
112+
```
113+
114+
The valid stream is the concatenation of every `update()` output plus the `finish()` tail.
115+
116+
#### Web Streams
117+
118+
```js
119+
import { compressStream, uncompressStream } from 'snappy'
120+
121+
const restored = uncompressStream(compressStream(source)) // ReadableStream<Uint8Array>
122+
```
123+
124+
`input` must be a WHATWG `ReadableStream`; wrap a Node `Readable` with `Readable.toWeb()`.
125+
126+
On wasm / browser builds the native transforms are unavailable; a buffered class-API
127+
polyfill is used automatically.
128+
129+
#### Node Duplex factories
130+
131+
```js
132+
import { createReadStream, createWriteStream } from 'node:fs'
133+
import { createCompressStream, createUncompressStream } from 'snappy'
134+
135+
createReadStream('input.txt').pipe(createCompressStream()).pipe(createWriteStream('input.txt.sz'))
136+
```
137+
138+
Requires a modern Node.js with Web Streams and `Duplex.fromWeb` (effectively Node 18+).
139+
91140
## Performance
92141

93142
### Hardware

__test__/helpers.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Shared streaming-test helpers for framed Snappy.
3+
*/
4+
import { createRequire } from 'node:module'
5+
6+
const requireFrom = createRequire(import.meta.url)
7+
8+
/** True when the suite runs against the WASI-forced binding (`NAPI_RS_FORCE_WASI`). */
9+
export const IS_WASI = !!process.env.NAPI_RS_FORCE_WASI
10+
11+
export const IS_SLOW_EMULATED_ARCH = ['s390x', 'ppc64', 'ppc64le'].includes(process.arch)
12+
13+
export const MAX_EMULATED_FIXTURE_BYTES = 4 * 1024 * 1024
14+
15+
export const runsFixtureOfSize = (byteLength: number): boolean =>
16+
!IS_SLOW_EMULATED_ARCH || byteLength <= MAX_EMULATED_FIXTURE_BYTES
17+
18+
export interface CompressorInstance {
19+
update(chunk: string | Uint8Array): Buffer
20+
finish(): Promise<Buffer>
21+
}
22+
23+
export interface DecompressorInstance {
24+
update(chunk: Uint8Array): Buffer
25+
finish(): Promise<Buffer>
26+
}
27+
28+
export function loadBinding() {
29+
// Prefer the honest package entry (main.js) so stream factories resolve.
30+
return requireFrom('..') as typeof import('..')
31+
}
32+
33+
export function chunkBySize(buf: Buffer, size: number): Uint8Array[] {
34+
if (buf.length === 0) {
35+
return []
36+
}
37+
const chunks: Uint8Array[] = []
38+
for (let i = 0; i < buf.length; i += size) {
39+
chunks.push(buf.subarray(i, Math.min(i + size, buf.length)))
40+
}
41+
return chunks
42+
}
43+
44+
export function chunkByByte(buf: Buffer): Uint8Array[] {
45+
return chunkBySize(buf, 1)
46+
}
47+
48+
/** Drive class compressor over chunks; return full framed stream. */
49+
export async function driveClassCompress(chunks: Array<string | Uint8Array>): Promise<Buffer> {
50+
const { Compressor } = loadBinding()
51+
const compressor = new Compressor()
52+
const parts: Buffer[] = []
53+
for (const chunk of chunks) {
54+
parts.push(Buffer.from(compressor.update(chunk)))
55+
}
56+
parts.push(Buffer.from(await compressor.finish()))
57+
return Buffer.concat(parts)
58+
}
59+
60+
/** Drive class decompressor over framed chunks; return plaintext. */
61+
export async function driveClassUncompress(chunks: Uint8Array[]): Promise<Buffer> {
62+
const { Decompressor } = loadBinding()
63+
const decompressor = new Decompressor()
64+
const parts: Buffer[] = []
65+
for (const chunk of chunks) {
66+
parts.push(Buffer.from(decompressor.update(chunk)))
67+
}
68+
parts.push(Buffer.from(await decompressor.finish()))
69+
return Buffer.concat(parts)
70+
}
71+
72+
/** Collect a Web ReadableStream into one Buffer. */
73+
export async function collectWebStream(stream: ReadableStream<Uint8Array>): Promise<Buffer> {
74+
const reader = stream.getReader()
75+
const chunks: Buffer[] = []
76+
try {
77+
for (;;) {
78+
const { done, value } = await reader.read()
79+
if (done) break
80+
if (value && value.length) {
81+
chunks.push(Buffer.from(value))
82+
}
83+
}
84+
} finally {
85+
try {
86+
reader.releaseLock()
87+
} catch {
88+
// ignore
89+
}
90+
}
91+
return Buffer.concat(chunks)
92+
}
93+
94+
/** Wrap Buffer chunks as a WHATWG ReadableStream. */
95+
export function bufferToStream(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
96+
let i = 0
97+
return new ReadableStream({
98+
pull(controller) {
99+
if (i >= chunks.length) {
100+
controller.close()
101+
return
102+
}
103+
controller.enqueue(chunks[i++])
104+
},
105+
})
106+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { createReadStream, createWriteStream } from 'node:fs'
2+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { Readable } from 'node:stream'
6+
import { pipeline } from 'node:stream/promises'
7+
8+
import test from 'ava'
9+
10+
import { chunkBySize, driveClassCompress, loadBinding } from './helpers'
11+
12+
const INPUT = Buffer.from('Node-stream factory 🚀 snappy bridge '.repeat(4096), 'utf8')
13+
14+
const collect = async (readable: NodeJS.ReadableStream): Promise<Buffer> => {
15+
const chunks: Buffer[] = []
16+
for await (const chunk of readable) {
17+
chunks.push(Buffer.from(chunk as Uint8Array))
18+
}
19+
return Buffer.concat(chunks)
20+
}
21+
22+
test('createCompressStream → createUncompressStream round-trips (piped, multi-chunk)', async (t) => {
23+
const { createCompressStream, createUncompressStream } = loadBinding()
24+
const compressed = await collect(Readable.from(chunkBySize(INPUT, 64 * 1024)).pipe(createCompressStream()))
25+
const restored = await collect(Readable.from(chunkBySize(compressed, 4096)).pipe(createUncompressStream()))
26+
t.deepEqual(restored, INPUT)
27+
})
28+
29+
test('createCompressStream output decodes via class Decompressor', async (t) => {
30+
const { createCompressStream } = loadBinding()
31+
const compressed = await collect(Readable.from([INPUT]).pipe(createCompressStream()))
32+
const { Decompressor } = loadBinding()
33+
const d = new Decompressor()
34+
const head = Buffer.from(d.update(compressed))
35+
const tail = Buffer.from(await d.finish())
36+
t.deepEqual(Buffer.concat([head, tail]), INPUT)
37+
})
38+
39+
test('createUncompressStream decodes class-compressed framed stream', async (t) => {
40+
const { createUncompressStream } = loadBinding()
41+
const compressed = await driveClassCompress([INPUT])
42+
const restored = await collect(Readable.from(chunkBySize(compressed, 7)).pipe(createUncompressStream()))
43+
t.deepEqual(restored, INPUT)
44+
})
45+
46+
test('fs.createReadStream → createCompressStream → createUncompressStream → file round-trips', async (t) => {
47+
const { createCompressStream, createUncompressStream } = loadBinding()
48+
const dir = await mkdtemp(join(tmpdir(), 'snappy-factory-'))
49+
t.teardown(() => rm(dir, { recursive: true, force: true }))
50+
const srcPath = join(dir, 'input.bin')
51+
const szPath = join(dir, 'output.sz')
52+
const outPath = join(dir, 'restored.bin')
53+
await writeFile(srcPath, INPUT)
54+
55+
await pipeline(createReadStream(srcPath), createCompressStream(), createWriteStream(szPath))
56+
await pipeline(createReadStream(szPath), createUncompressStream(), createWriteStream(outPath))
57+
58+
t.deepEqual(await readFile(outPath), INPUT)
59+
})

__test__/streaming-class.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import test from 'ava'
2+
3+
import { chunkByByte, chunkBySize, driveClassCompress, driveClassUncompress, loadBinding } from './helpers'
4+
5+
const INPUT = Buffer.from('Hello 🚀'.repeat(500), 'utf8')
6+
7+
const emptyAnd1Byte = (buf: Buffer): Uint8Array[] => {
8+
const chunks: Uint8Array[] = [Buffer.alloc(0)]
9+
for (const byte of chunkByByte(buf)) {
10+
chunks.push(byte)
11+
chunks.push(Buffer.alloc(0))
12+
}
13+
return chunks
14+
}
15+
16+
const CHUNKINGS: ReadonlyArray<{ name: string; split: (buf: Buffer) => Uint8Array[] }> = [
17+
{ name: '1-byte', split: (buf) => chunkByByte(buf) },
18+
{ name: '64-byte', split: (buf) => chunkBySize(buf, 64) },
19+
{ name: 'single-chunk', split: (buf) => [buf] },
20+
{ name: 'awkward empty+1-byte', split: (buf) => emptyAnd1Byte(buf) },
21+
]
22+
23+
for (const { name, split } of CHUNKINGS) {
24+
test(`class compress round-trips via class uncompress (${name})`, async (t) => {
25+
const compressed = await driveClassCompress(split(INPUT))
26+
const restored = await driveClassUncompress(chunkBySize(compressed, 64))
27+
t.deepEqual(restored, INPUT)
28+
})
29+
}
30+
31+
test('class compress output is byte-identical across all chunkings', async (t) => {
32+
const reference = await driveClassCompress([INPUT])
33+
for (const { name, split } of CHUNKINGS) {
34+
const got = await driveClassCompress(split(INPUT))
35+
t.true(got.equals(reference), `${name} output diverged from the single-chunk reference`)
36+
}
37+
})
38+
39+
test('class compress of empty input decodes back to empty', async (t) => {
40+
const compressed = await driveClassCompress([])
41+
const restored = await driveClassUncompress([compressed])
42+
t.is(restored.length, 0)
43+
})
44+
45+
const STRING_CHUNK = 'Hello 🚀 streaming string chunk — Ünïcöde'
46+
47+
test('class compress accepts a string chunk (UTF-8) and round-trips', async (t) => {
48+
const compressed = await driveClassCompress([STRING_CHUNK])
49+
const restored = await driveClassUncompress([compressed])
50+
t.deepEqual(restored, Buffer.from(STRING_CHUNK, 'utf8'))
51+
})
52+
53+
test('a string chunk compresses byte-identically to the equivalent Uint8Array', async (t) => {
54+
const fromString = await driveClassCompress([STRING_CHUNK])
55+
const fromBytes = await driveClassCompress([Buffer.from(STRING_CHUNK, 'utf8')])
56+
t.true(fromString.equals(fromBytes))
57+
})
58+
59+
test('double finish on Compressor rejects', async (t) => {
60+
const { Compressor } = loadBinding()
61+
const c = new Compressor()
62+
await c.finish()
63+
// finish() takes the encoder synchronously, so a second call throws (not rejects).
64+
t.throws(() => c.finish(), { code: 'InvalidArg' })
65+
})
66+
67+
test('double finish on Decompressor rejects', async (t) => {
68+
const { Decompressor } = loadBinding()
69+
const d = new Decompressor()
70+
await d.finish()
71+
t.throws(() => d.finish(), { code: 'InvalidArg' })
72+
})
73+
74+
test('framed stream output is NOT valid raw uncompress input', async (t) => {
75+
const { uncompressSync } = loadBinding()
76+
const framed = await driveClassCompress([INPUT])
77+
t.throws(() => uncompressSync(framed), { any: true })
78+
})
79+
80+
test('corrupt framed input surfaces InvalidArg', async (t) => {
81+
const { Decompressor } = loadBinding()
82+
const d = new Decompressor()
83+
// Snappy frame magic is sNaPpY; garbage should fail at finish or update.
84+
const garbage = Buffer.from('this is not framed snappy data at all!!!!!')
85+
try {
86+
d.update(garbage)
87+
await d.finish()
88+
t.fail('expected decode error')
89+
} catch (err) {
90+
t.true(err instanceof Error)
91+
t.is((err as { code?: string }).code, 'InvalidArg')
92+
}
93+
})

0 commit comments

Comments
 (0)