From ae3782bc354d67ba6ef706f83acf3ff7d30bedae Mon Sep 17 00:00:00 2001 From: chippytech <80016011+chippytech@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:01:46 -0600 Subject: [PATCH 1/2] Add ZSTD format handler --- src/handlers/index.ts | 2 + src/handlers/zstd.ts | 79 ++++++++++++++++++++++++++++++++++++++++ src/normalizeMimeType.ts | 2 + 3 files changed, 83 insertions(+) create mode 100644 src/handlers/zstd.ts diff --git a/src/handlers/index.ts b/src/handlers/index.ts index f5fdb663..36c92693 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -40,6 +40,7 @@ import textToShellHandler from "./texttoshell.ts"; import batchHandler from "./batch.ts"; import bsorHandler from "./bsor.ts"; import fontHandler from "./font.ts"; +import zstdHandler from "./zstd.ts"; const handlers: FormatHandler[] = []; try { handlers.push(new svgTraceHandler()) } catch (_) { }; @@ -85,5 +86,6 @@ try { handlers.push(new textToShellHandler()) } catch (_) { }; try { handlers.push(new batchHandler()) } catch (_) { }; try { handlers.push(new bsorHandler()) } catch (_) { }; try { handlers.push(new fontHandler()) } catch (_) { }; +try { handlers.push(new zstdHandler()) } catch (_) { }; export default handlers; diff --git a/src/handlers/zstd.ts b/src/handlers/zstd.ts new file mode 100644 index 00000000..551b074a --- /dev/null +++ b/src/handlers/zstd.ts @@ -0,0 +1,79 @@ +import CommonFormats from "src/CommonFormats.ts"; +import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import JSZip from "jszip"; + +async function applyCompressionStream(bytes: Uint8Array, format: string, compress: boolean): Promise { + const stream = new Blob([bytes as BlobPart]).stream(); + const transform = compress + ? new CompressionStream(format as any) + : new DecompressionStream(format as any); + const outputStream = stream.pipeThrough(transform); + return new Uint8Array(await new Response(outputStream).arrayBuffer()); +} + +class zstdHandler implements FormatHandler { + public name = "zstd"; + + public supportAnyInput = true; + + public supportedFormats: FileFormat[] = [ + { + name: "Zstandard Compressed Data", + format: "zst", + extension: "zst", + mime: "application/zstd", + from: true, + to: true, + internal: "zstd", + category: "archive", + lossless: true + }, + CommonFormats.ZIP.builder("zip").allowTo().markLossless() + ]; + + public ready = false; + + async init() { + if (typeof CompressionStream !== "function" || typeof DecompressionStream !== "function") { + throw new Error("Compression Streams API is unavailable."); + } + try { + new CompressionStream("zstd" as any); + new DecompressionStream("zstd" as any); + } catch (_) { + throw new Error("Zstandard is not supported by this browser."); + } + this.ready = true; + } + + async doConvert(inputFiles: FileData[], inputFormat: FileFormat, outputFormat: FileFormat): Promise { + if (outputFormat.internal === "zstd") { + return Promise.all(inputFiles.map(async (inputFile) => { + const bytes = await applyCompressionStream(inputFile.bytes, "zstd", true); + return { + name: /\.(zst|zstd)$/i.test(inputFile.name) ? inputFile.name : `${inputFile.name}.zst`, + bytes + }; + })); + } + + if (inputFormat.internal === "zstd" && outputFormat.internal === "zip") { + const zip = new JSZip(); + + for (const inputFile of inputFiles) { + const bytes = await applyCompressionStream(inputFile.bytes, "zstd", false); + const outputName = inputFile.name.replace(/\.(zst|zstd)$/i, "") || `${inputFile.name}.bin`; + zip.file(outputName, bytes); + } + + return [{ + name: "output.zip", + bytes: await zip.generateAsync({ type: "uint8array" }) + }]; + } + + throw "Invalid conversion path."; + } +} + +export default zstdHandler; diff --git a/src/normalizeMimeType.ts b/src/normalizeMimeType.ts index c58dc5ec..d091bb0e 100644 --- a/src/normalizeMimeType.ts +++ b/src/normalizeMimeType.ts @@ -3,6 +3,8 @@ function normalizeMimeType (mime: string) { case "audio/x-wav": return "audio/wav"; case "audio/vnd.wave": return "audio/wav"; case "application/x-gzip": return "application/gzip"; + case "application/x-zstd": return "application/zstd"; + case "application/zst": return "application/zstd"; case "image/x-icon": return "image/vnd.microsoft.icon"; case "image/vtf": return "image/x-vtf"; case "image/qoi": return "image/x-qoi"; From 97bd3111c0a28353cc15558e22b4916392c2e68f Mon Sep 17 00:00:00 2001 From: chippytech <80016011+chippytech@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:15:06 -0600 Subject: [PATCH 2/2] Switch ZSTD handler to WASM decoder and add test --- src/handlers/index.ts | 2 ++ src/handlers/zstd.ts | 54 ++++++++++++++++++++++++++++++++++++++++ src/normalizeMimeType.ts | 2 ++ test/zstd.test.ts | 41 ++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 src/handlers/zstd.ts create mode 100644 test/zstd.test.ts diff --git a/src/handlers/index.ts b/src/handlers/index.ts index f5fdb663..36c92693 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -40,6 +40,7 @@ import textToShellHandler from "./texttoshell.ts"; import batchHandler from "./batch.ts"; import bsorHandler from "./bsor.ts"; import fontHandler from "./font.ts"; +import zstdHandler from "./zstd.ts"; const handlers: FormatHandler[] = []; try { handlers.push(new svgTraceHandler()) } catch (_) { }; @@ -85,5 +86,6 @@ try { handlers.push(new textToShellHandler()) } catch (_) { }; try { handlers.push(new batchHandler()) } catch (_) { }; try { handlers.push(new bsorHandler()) } catch (_) { }; try { handlers.push(new fontHandler()) } catch (_) { }; +try { handlers.push(new zstdHandler()) } catch (_) { }; export default handlers; diff --git a/src/handlers/zstd.ts b/src/handlers/zstd.ts new file mode 100644 index 00000000..8ac48c76 --- /dev/null +++ b/src/handlers/zstd.ts @@ -0,0 +1,54 @@ +import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import { ZSTDDecoder } from "three/examples/jsm/libs/zstddec.module.js"; + +const decoder = new ZSTDDecoder(); + +class zstdHandler implements FormatHandler { + public name = "zstd"; + + public supportedFormats: FileFormat[] = [ + { + name: "Zstandard Compressed Data", + format: "zst", + extension: "zst", + mime: "application/zstd", + from: true, + to: false, + internal: "zstd", + category: "archive", + lossless: true + }, + { + name: "Raw Binary Data", + format: "bin", + extension: "bin", + mime: "application/octet-stream", + from: false, + to: true, + internal: "raw", + category: "data", + lossless: true + } + ]; + + public ready = false; + + async init() { + await decoder.init(); + this.ready = true; + } + + async doConvert(inputFiles: FileData[], inputFormat: FileFormat, outputFormat: FileFormat): Promise { + if (inputFormat.internal === "zstd" && outputFormat.internal === "raw") { + return inputFiles.map((inputFile) => { + const bytes = decoder.decode(inputFile.bytes); + const name = inputFile.name.replace(/\.(zst|zstd)$/i, "") || `${inputFile.name}.bin`; + return { name, bytes }; + }); + } + + throw "Invalid conversion path."; + } +} + +export default zstdHandler; diff --git a/src/normalizeMimeType.ts b/src/normalizeMimeType.ts index c58dc5ec..d091bb0e 100644 --- a/src/normalizeMimeType.ts +++ b/src/normalizeMimeType.ts @@ -3,6 +3,8 @@ function normalizeMimeType (mime: string) { case "audio/x-wav": return "audio/wav"; case "audio/vnd.wave": return "audio/wav"; case "application/x-gzip": return "application/gzip"; + case "application/x-zstd": return "application/zstd"; + case "application/zst": return "application/zstd"; case "image/x-icon": return "image/vnd.microsoft.icon"; case "image/vtf": return "image/x-vtf"; case "image/qoi": return "image/x-qoi"; diff --git a/test/zstd.test.ts b/test/zstd.test.ts new file mode 100644 index 00000000..6c53c3ff --- /dev/null +++ b/test/zstd.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import zstdHandler from "../src/handlers/zstd.ts"; +import type { FileFormat } from "../src/FormatHandler.ts"; + +const zstdFormat: FileFormat = { + name: "Zstandard Compressed Data", + format: "zst", + extension: "zst", + mime: "application/zstd", + from: true, + to: false, + internal: "zstd" +}; + +const rawFormat: FileFormat = { + name: "Raw Binary Data", + format: "bin", + extension: "bin", + mime: "application/octet-stream", + from: false, + to: true, + internal: "raw" +}; + +test("zstd handler decodes .zst to raw bytes", async () => { + const input = new TextEncoder().encode("hello zstd"); + const compressed = Bun.zstdCompressSync(input); + + const handler = new zstdHandler(); + await handler.init(); + + const output = await handler.doConvert( + [{ name: "sample.zst", bytes: new Uint8Array(compressed) }], + zstdFormat, + rawFormat + ); + + expect(output).toHaveLength(1); + expect(output[0].name).toBe("sample"); + expect(new TextDecoder().decode(output[0].bytes)).toBe("hello zstd"); +});