diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..b84a7cb --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -0,0 +1,39 @@ +name: Verify + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: verify-${{ gitea.repository }}-${{ gitea.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CI: "true" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - name: Select declared npm version + run: npm install --global npm@11.17.0 + - name: Install dependencies + run: npm ci + - name: Audit runtime dependencies + run: npm audit --omit=dev --audit-level=moderate + - name: Check, test, and build + run: npm run check + - name: Install browser engines + run: npx playwright install --with-deps chromium firefox webkit + - name: Browser tests + run: npm run test:browser diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c79eb7..5493b71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,17 @@ All notable changes are documented here. -## Unreleased +## 0.2.0 - 2026-09-02 + +- Added deterministic IEC/SI byte-size formatting, checked byte-range + arithmetic and a bounded typed byte cursor. +- Added cancellable disposable-worker jobs with typed progress, serialized + failures, abort propagation and hard client deadlines. +- Added explicit Blob URL pools plus deterministic, collision-free multi-file + download planning and triggering. +- Added incremental CRC-32, Adler-32 and FNV-1a state and bounded Blob, + ReadableStream and async-iterable consumption for checksums and Web Crypto + digests. ## 0.1.0 - 2026-09-01 diff --git a/README.md b/README.md index c7b0a0e..0e1e03d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Helper Tools is a local-first browser workbench and the source of the bounded building blocks for common developer calculations without network requests, telemetry, persistence, or server processing. -Version 0.1.0 includes: +The reusable package includes: - strict Base64, Base64URL, hexadecimal, UTF-8, UTF-16 and Latin-1 conversion; - URL component and form encoding, Unicode scalar/grapheme inspection, case, @@ -16,7 +16,13 @@ Version 0.1.0 includes: - timestamp and bounded duration parsing and formatting; - hardened JSON parsing, deterministic JSON and bounded CSV conversion; and - secure random bytes/integers plus explicitly non-cryptographic seeded random - primitives for repeatable tests and samples. + primitives for repeatable tests and samples; +- checked offset arithmetic and a bounded typed byte cursor for binary parsers; +- deterministic byte-size formatting, collision-free multi-file download plans, + and explicit Blob URL lease pools; +- cancellable disposable-worker jobs with progress and hard deadlines; and +- incremental CRC-32, Adler-32 and FNV-1a processing plus bounded, + cancellable byte-source reads for Web Crypto digests. ## Browser app @@ -38,8 +44,11 @@ exported from one stable root: ```ts import { + ByteCursor, bytesToBase64Url, + createIncrementalChecksum, digestHex, + formatBytes, parseCidr, safeJsonParse, } from "@add-ideas/toolbox-helpers"; @@ -48,6 +57,12 @@ const token = bytesToBase64Url(new Uint8Array([1, 2, 3])); const hash = await digestHex(new TextEncoder().encode("local")); const network = parseCidr("2001:db8::1/64"); const data = safeJsonParse('{"enabled":true}'); +const size = formatBytes(1_572_864); // 1.50 MiB +const cursor = new ByteCursor(new Uint8Array([0, 1, 0, 2])); +const first = cursor.readUint16(); +const checksum = createIncrementalChecksum("CRC-32").update( + new Uint8Array([1, 2, 3]), +); ``` See [`docs/API.md`](docs/API.md) for the export groups and their security diff --git a/SOURCE.md b/SOURCE.md index cc42698..4c973a6 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,8 +1,8 @@ # Corresponding source and provenance -The corresponding source for Helper Tools 0.1.0 will be published at: +The corresponding source for Helper Tools 0.2.0 will be published at: -https://git.add-ideas.de/lotobo/helper-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/helper-tools/src/tag/v0.2.0 Before that tag exists, the `main` branch in the same public repository is the preferred source under active review. Build the release with Node.js 22 and the @@ -19,7 +19,7 @@ not the preferred form for modification. No runtime code is loaded from a CDN. ## Implementation provenance - The helpers and interface are original TypeScript and React project code. -- Toolbox manifest, shell and artifact checks use the shared Toolbox SDK 0.2.3. +- Toolbox manifest, shell and artifact checks use the shared Toolbox SDK 0.3.0. - Encoding formats, IP notation, JSON, CSV, SI/IEC units, Unicode, ISO 8601, checksum and digest algorithms were implemented from public specifications and established interoperable formats; no source from an online calculator diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7376bd1..654a1fc 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,8 +5,8 @@ browser application uses these packages: | Package | Version | Licence | Role | | -------------------------------- | ------- | ---------- | ------------------------------- | -| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | manifest and context contract | -| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | shared shell, theme and actions | +| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 | manifest and context contract | +| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | shared shell, theme and actions | | `react` | 19.2.8 | MIT | browser interface | | `react-dom` | 19.2.8 | MIT | browser rendering | diff --git a/docs/API.md b/docs/API.md index 2d42538..8cee850 100644 --- a/docs/API.md +++ b/docs/API.md @@ -21,6 +21,13 @@ assertBoundedBytes(value: BytesLike, maximum?: number, label?: string): Uint8Arr assertBoundedItems(count: number, maximum?: number, label?: string): number; assertPositiveSafeInteger(value: number, label: string): number; asUint8Array(value: BytesLike): Uint8Array; + +interface ByteRange { offset: number; length: number; end: number } +assertSafeOffset(value: number, label?: string): number; +checkedOffsetAdd(offset: number, length: number, maximum?: number, label?: string): number; +checkedByteRange(availableBytes: number, offset: number, length: number, label?: string): ByteRange; +class ByteCursor { /* bounded typed reads, seek/skip and subcursors */ } +ownedBytes(input: BytesLike, maximumBytes?: number): Uint8Array; ``` `HelperLimitError` extends `RangeError` and exposes numeric `actual` and `limit` @@ -28,6 +35,10 @@ properties. Per-operation ceilings can be made narrower. They are compatibility and denial-of-service boundaries, not a substitute for an application-wide memory budget. +`ByteCursor` defaults to the shared 16 MiB input ceiling and never advances +after a failed read. It is non-owning by default; `readBytes(length, true)`, +`bytes(true)` and bounded `ownedBytes` make explicit retained copies. + ## Encoding and URLs ```ts @@ -65,6 +76,18 @@ opens or fetches the result. ```ts type DigestAlgorithm = "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"; +type ChecksumAlgorithm = "CRC-32" | "Adler-32" | "FNV-1a-32"; + +interface IncrementalChecksum { + readonly algorithm: ChecksumAlgorithm; + readonly bytesProcessed: number; + update(input: BytesLike): this; + digest(): number; + digestHex(): string; + reset(): this; +} + +createIncrementalChecksum(algorithm: ChecksumAlgorithm, maximumBytes?: number): IncrementalChecksum; crc32(input: BytesLike, maximumBytes?: number): number; adler32(input: BytesLike, maximumBytes?: number): number; @@ -88,6 +111,26 @@ Digests use Web Crypto. SHA-1 is available only for interoperability. Checksums and hashes do not establish authorship, and none of these functions is a password-hashing API. +## Byte sources + +```ts +type ByteSource = Blob | ReadableStream | AsyncIterable; +interface ByteSourceProgress { processedBytes: number; totalBytes?: number } + +iterateByteChunks(source: ByteSource, options?: ConsumeByteSourceOptions): AsyncGenerator; +consumeByteSource(source: ByteSource, consume: (chunk: Uint8Array) => void | Promise, options?: ConsumeByteSourceOptions): Promise; +checksumByteSource(source: ByteSource, checksum?: ChecksumAlgorithm | IncrementalChecksum, options?: ConsumeByteSourceOptions): Promise; +digestByteSource(source: ByteSource, algorithm?: DigestAlgorithm, options?: DigestByteSourceOptions): Promise; +digestByteSourceHex(source: ByteSource, algorithm?: DigestAlgorithm, options?: DigestByteSourceOptions): Promise; +``` + +Every source is bounded and supports cancellation and progress. A supplied +`knownTotalBytes` is an exact contract rather than an unchecked progress hint. +CRC-32, +Adler-32 and FNV-1a are genuinely incremental and constant-memory. Web Crypto +does not expose incremental SHA state, so `digestByteSource` retains chunks up +to the caller's explicit ceiling before invoking its one-shot digest API. + ## Network ```ts @@ -140,6 +183,9 @@ createObjectUrlLease( blob: Blob, urlApi?: Pick, ): ObjectUrlLease; +createObjectUrlLeasePool(urlApi?: UrlApi): ObjectUrlLeasePool; +planBlobDownloads(input: Iterable, options?: PlanBlobDownloadsOptions): readonly PlannedBlobDownload[]; +triggerBlobDownloads(input: Iterable, options?: TriggerBlobDownloadsOptions): TriggeredBlobDownloads; triggerBlobDownload( blob: Blob, filename: string, @@ -151,6 +197,44 @@ triggerBlobDownload( object URL revocation for the next task. Long-lived previews should instead keep a lease and call `revoke()` when replaced or unmounted. +Batch planning consumes at most the configured item ceiling, preserves input +order by default, sanitizes every name and adds +stable ` (2)`, ` (3)` suffixes for case-insensitive collisions. All batch URLs +are revocable as one group. Browsers may still ask users to permit multiple +downloads. + +## Worker jobs + +```ts +const WORKER_JOB_PROTOCOL = "add-ideas.worker-job/v1"; + +startWorkerJob( + worker: WorkerJobEndpoint, + payload: TPayload, + options?: StartWorkerJobOptions, +): WorkerJobHandle; + +createWorkerJobMessageHandler( + run: (payload: TPayload, context: WorkerJobContext) => TResult | Promise, + postMessage: (response: WorkerJobResponse) => void, + options: WorkerJobHandlerOptions, +): (event: MessageEvent>) => void; +``` + +The client filters responses by protocol and job ID, forwards progress, +propagates abort reasons, enforces a hard timeout by terminating disposable +workers and removes all listeners when settled. Worker handlers expose the +same deadline and cancellation signal for cooperative asynchronous work. + +## Display + +```ts +formatBytes(bytes: number, options?: FormatBytesOptions): string; +``` + +Formatting is deterministic and defaults to IEC units. It supports SI units, +fixed precision, signed quantities and an explicit invalid-value label. + ## Remaining groups | Group | Exports | diff --git a/package-lock.json b/package-lock.json index fdd5dfe..5cf5834 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@add-ideas/toolbox-helpers", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@add-ideas/toolbox-helpers", - "version": "0.1.0", + "version": "0.2.0", "license": "GPL-3.0-or-later", "devDependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-shell-react": "0.2.3", - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-shell-react": "0.3.0", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", @@ -39,19 +39,20 @@ } }, "node_modules/@add-ideas/toolbox-contract": { - "version": "0.2.3", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz", + "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=20" - } + "license": "Apache-2.0" }, "node_modules/@add-ideas/toolbox-shell-react": { - "version": "0.2.3", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz", + "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "peerDependencies": { "react": ">=18 <20", @@ -59,17 +60,16 @@ } }, "node_modules/@add-ideas/toolbox-testkit": { - "version": "0.2.3", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz", + "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "bin": { "toolbox-check": "dist/cli.js" - }, - "engines": { - "node": ">=20" } }, "node_modules/@adobe/css-tools": { diff --git a/package.json b/package.json index f291300..5787b10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-helpers", - "version": "0.1.0", + "version": "0.2.0", "description": "Bounded, local-first TypeScript helpers and browser calculators for the add·ideas Toolbox.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", @@ -64,9 +64,9 @@ "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force && npm run package:library -- --force" }, "devDependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-shell-react": "0.2.3", - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-shell-react": "0.3.0", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts index e4b3d1d..7a2a0d7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,7 +18,25 @@ export default defineConfig({ timeout: 180_000, }, projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { + name: "chromium", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Safari"] }, + }, + { + name: "mobile-chromium", + testMatch: /responsive\.spec\.ts/, + use: { ...devices["Pixel 5"] }, + }, ], }); diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index 6c79eb7..5493b71 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -2,7 +2,17 @@ All notable changes are documented here. -## Unreleased +## 0.2.0 - 2026-09-02 + +- Added deterministic IEC/SI byte-size formatting, checked byte-range + arithmetic and a bounded typed byte cursor. +- Added cancellable disposable-worker jobs with typed progress, serialized + failures, abort propagation and hard client deadlines. +- Added explicit Blob URL pools plus deterministic, collision-free multi-file + download planning and triggering. +- Added incremental CRC-32, Adler-32 and FNV-1a state and bounded Blob, + ReadableStream and async-iterable consumption for checksums and Web Crypto + digests. ## 0.1.0 - 2026-09-01 diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt index de3b6b2..bb25117 100644 --- a/public/LICENSES/npm-runtime-licenses.txt +++ b/public/LICENSES/npm-runtime-licenses.txt @@ -1,5 +1,5 @@ ============================================================================== -@add-ideas/toolbox-contract@0.2.3 +@add-ideas/toolbox-contract@0.3.0 Declared licence: Apache-2.0 Installed from: node_modules/@add-ideas/toolbox-contract ============================================================================== @@ -199,7 +199,7 @@ Installed from: node_modules/@add-ideas/toolbox-contract ============================================================================== -@add-ideas/toolbox-shell-react@0.2.3 +@add-ideas/toolbox-shell-react@0.3.0 Declared licence: Apache-2.0 Installed from: node_modules/@add-ideas/toolbox-shell-react ============================================================================== diff --git a/public/README.md b/public/README.md index c7b0a0e..0e1e03d 100644 --- a/public/README.md +++ b/public/README.md @@ -5,7 +5,7 @@ Helper Tools is a local-first browser workbench and the source of the bounded building blocks for common developer calculations without network requests, telemetry, persistence, or server processing. -Version 0.1.0 includes: +The reusable package includes: - strict Base64, Base64URL, hexadecimal, UTF-8, UTF-16 and Latin-1 conversion; - URL component and form encoding, Unicode scalar/grapheme inspection, case, @@ -16,7 +16,13 @@ Version 0.1.0 includes: - timestamp and bounded duration parsing and formatting; - hardened JSON parsing, deterministic JSON and bounded CSV conversion; and - secure random bytes/integers plus explicitly non-cryptographic seeded random - primitives for repeatable tests and samples. + primitives for repeatable tests and samples; +- checked offset arithmetic and a bounded typed byte cursor for binary parsers; +- deterministic byte-size formatting, collision-free multi-file download plans, + and explicit Blob URL lease pools; +- cancellable disposable-worker jobs with progress and hard deadlines; and +- incremental CRC-32, Adler-32 and FNV-1a processing plus bounded, + cancellable byte-source reads for Web Crypto digests. ## Browser app @@ -38,8 +44,11 @@ exported from one stable root: ```ts import { + ByteCursor, bytesToBase64Url, + createIncrementalChecksum, digestHex, + formatBytes, parseCidr, safeJsonParse, } from "@add-ideas/toolbox-helpers"; @@ -48,6 +57,12 @@ const token = bytesToBase64Url(new Uint8Array([1, 2, 3])); const hash = await digestHex(new TextEncoder().encode("local")); const network = parseCidr("2001:db8::1/64"); const data = safeJsonParse('{"enabled":true}'); +const size = formatBytes(1_572_864); // 1.50 MiB +const cursor = new ByteCursor(new Uint8Array([0, 1, 0, 2])); +const first = cursor.readUint16(); +const checksum = createIncrementalChecksum("CRC-32").update( + new Uint8Array([1, 2, 3]), +); ``` See [`docs/API.md`](docs/API.md) for the export groups and their security diff --git a/public/SOURCE.md b/public/SOURCE.md index cc42698..4c973a6 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -1,8 +1,8 @@ # Corresponding source and provenance -The corresponding source for Helper Tools 0.1.0 will be published at: +The corresponding source for Helper Tools 0.2.0 will be published at: -https://git.add-ideas.de/lotobo/helper-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/helper-tools/src/tag/v0.2.0 Before that tag exists, the `main` branch in the same public repository is the preferred source under active review. Build the release with Node.js 22 and the @@ -19,7 +19,7 @@ not the preferred form for modification. No runtime code is loaded from a CDN. ## Implementation provenance - The helpers and interface are original TypeScript and React project code. -- Toolbox manifest, shell and artifact checks use the shared Toolbox SDK 0.2.3. +- Toolbox manifest, shell and artifact checks use the shared Toolbox SDK 0.3.0. - Encoding formats, IP notation, JSON, CSV, SI/IEC units, Unicode, ISO 8601, checksum and digest algorithms were implemented from public specifications and established interoperable formats; no source from an online calculator diff --git a/public/THIRD_PARTY_NOTICES.md b/public/THIRD_PARTY_NOTICES.md index 7376bd1..654a1fc 100644 --- a/public/THIRD_PARTY_NOTICES.md +++ b/public/THIRD_PARTY_NOTICES.md @@ -5,8 +5,8 @@ browser application uses these packages: | Package | Version | Licence | Role | | -------------------------------- | ------- | ---------- | ------------------------------- | -| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | manifest and context contract | -| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | shared shell, theme and actions | +| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 | manifest and context contract | +| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | shared shell, theme and actions | | `react` | 19.2.8 | MIT | browser interface | | `react-dom` | 19.2.8 | MIT | browser rendering | diff --git a/public/docs/API.md b/public/docs/API.md index 2d42538..8cee850 100644 --- a/public/docs/API.md +++ b/public/docs/API.md @@ -21,6 +21,13 @@ assertBoundedBytes(value: BytesLike, maximum?: number, label?: string): Uint8Arr assertBoundedItems(count: number, maximum?: number, label?: string): number; assertPositiveSafeInteger(value: number, label: string): number; asUint8Array(value: BytesLike): Uint8Array; + +interface ByteRange { offset: number; length: number; end: number } +assertSafeOffset(value: number, label?: string): number; +checkedOffsetAdd(offset: number, length: number, maximum?: number, label?: string): number; +checkedByteRange(availableBytes: number, offset: number, length: number, label?: string): ByteRange; +class ByteCursor { /* bounded typed reads, seek/skip and subcursors */ } +ownedBytes(input: BytesLike, maximumBytes?: number): Uint8Array; ``` `HelperLimitError` extends `RangeError` and exposes numeric `actual` and `limit` @@ -28,6 +35,10 @@ properties. Per-operation ceilings can be made narrower. They are compatibility and denial-of-service boundaries, not a substitute for an application-wide memory budget. +`ByteCursor` defaults to the shared 16 MiB input ceiling and never advances +after a failed read. It is non-owning by default; `readBytes(length, true)`, +`bytes(true)` and bounded `ownedBytes` make explicit retained copies. + ## Encoding and URLs ```ts @@ -65,6 +76,18 @@ opens or fetches the result. ```ts type DigestAlgorithm = "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"; +type ChecksumAlgorithm = "CRC-32" | "Adler-32" | "FNV-1a-32"; + +interface IncrementalChecksum { + readonly algorithm: ChecksumAlgorithm; + readonly bytesProcessed: number; + update(input: BytesLike): this; + digest(): number; + digestHex(): string; + reset(): this; +} + +createIncrementalChecksum(algorithm: ChecksumAlgorithm, maximumBytes?: number): IncrementalChecksum; crc32(input: BytesLike, maximumBytes?: number): number; adler32(input: BytesLike, maximumBytes?: number): number; @@ -88,6 +111,26 @@ Digests use Web Crypto. SHA-1 is available only for interoperability. Checksums and hashes do not establish authorship, and none of these functions is a password-hashing API. +## Byte sources + +```ts +type ByteSource = Blob | ReadableStream | AsyncIterable; +interface ByteSourceProgress { processedBytes: number; totalBytes?: number } + +iterateByteChunks(source: ByteSource, options?: ConsumeByteSourceOptions): AsyncGenerator; +consumeByteSource(source: ByteSource, consume: (chunk: Uint8Array) => void | Promise, options?: ConsumeByteSourceOptions): Promise; +checksumByteSource(source: ByteSource, checksum?: ChecksumAlgorithm | IncrementalChecksum, options?: ConsumeByteSourceOptions): Promise; +digestByteSource(source: ByteSource, algorithm?: DigestAlgorithm, options?: DigestByteSourceOptions): Promise; +digestByteSourceHex(source: ByteSource, algorithm?: DigestAlgorithm, options?: DigestByteSourceOptions): Promise; +``` + +Every source is bounded and supports cancellation and progress. A supplied +`knownTotalBytes` is an exact contract rather than an unchecked progress hint. +CRC-32, +Adler-32 and FNV-1a are genuinely incremental and constant-memory. Web Crypto +does not expose incremental SHA state, so `digestByteSource` retains chunks up +to the caller's explicit ceiling before invoking its one-shot digest API. + ## Network ```ts @@ -140,6 +183,9 @@ createObjectUrlLease( blob: Blob, urlApi?: Pick, ): ObjectUrlLease; +createObjectUrlLeasePool(urlApi?: UrlApi): ObjectUrlLeasePool; +planBlobDownloads(input: Iterable, options?: PlanBlobDownloadsOptions): readonly PlannedBlobDownload[]; +triggerBlobDownloads(input: Iterable, options?: TriggerBlobDownloadsOptions): TriggeredBlobDownloads; triggerBlobDownload( blob: Blob, filename: string, @@ -151,6 +197,44 @@ triggerBlobDownload( object URL revocation for the next task. Long-lived previews should instead keep a lease and call `revoke()` when replaced or unmounted. +Batch planning consumes at most the configured item ceiling, preserves input +order by default, sanitizes every name and adds +stable ` (2)`, ` (3)` suffixes for case-insensitive collisions. All batch URLs +are revocable as one group. Browsers may still ask users to permit multiple +downloads. + +## Worker jobs + +```ts +const WORKER_JOB_PROTOCOL = "add-ideas.worker-job/v1"; + +startWorkerJob( + worker: WorkerJobEndpoint, + payload: TPayload, + options?: StartWorkerJobOptions, +): WorkerJobHandle; + +createWorkerJobMessageHandler( + run: (payload: TPayload, context: WorkerJobContext) => TResult | Promise, + postMessage: (response: WorkerJobResponse) => void, + options: WorkerJobHandlerOptions, +): (event: MessageEvent>) => void; +``` + +The client filters responses by protocol and job ID, forwards progress, +propagates abort reasons, enforces a hard timeout by terminating disposable +workers and removes all listeners when settled. Worker handlers expose the +same deadline and cancellation signal for cooperative asynchronous work. + +## Display + +```ts +formatBytes(bytes: number, options?: FormatBytesOptions): string; +``` + +Formatting is deterministic and defaults to IEC units. It supports SI units, +fixed precision, signed quantities and an explicit invalid-value label. + ## Remaining groups | Group | Exports | diff --git a/public/sw.js b/public/sw.js index 6e93485..e1631a6 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ const CACHE_PREFIX = "helper-tools-shell-"; -const CACHE_NAME = `${CACHE_PREFIX}0.1.0`; +const CACHE_NAME = `${CACHE_PREFIX}0.2.0`; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"]; self.addEventListener("install", (event) => { diff --git a/public/toolbox-app.json b/public/toolbox-app.json index ffac3bc..ff7ef53 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.helper-tools", "name": "Helper Tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Encode, convert, inspect and calculate locally.", "entry": "./", "icon": "./favicon.svg", @@ -32,6 +32,19 @@ "crossOriginIsolated": false, "topLevelContext": false }, + "io": { + "accepts": [ + { "mediaType": "text/plain", "extensions": [".txt"] }, + { "mediaType": "application/json", "extensions": [".json"] }, + { "mediaType": "text/csv", "extensions": [".csv"] } + ], + "produces": [ + { "mediaType": "text/plain", "extensions": [".txt"] }, + { "mediaType": "application/json", "extensions": [".json"] }, + { "mediaType": "text/csv", "extensions": [".csv"] } + ] + }, + "capabilities": { "required": [], "optional": ["web-crypto", "workers"] }, "privacy": { "processing": "local", "fileUploads": false, diff --git a/src/helpers/bytes.ts b/src/helpers/bytes.ts new file mode 100644 index 0000000..900c602 --- /dev/null +++ b/src/helpers/bytes.ts @@ -0,0 +1,240 @@ +import { + DEFAULT_HELPER_LIMITS, + HelperLimitError, + assertBoundedBytes, + type BytesLike, +} from "./limits"; + +export interface ByteRange { + readonly offset: number; + readonly length: number; + readonly end: number; +} + +export interface ByteCursorOptions { + readonly byteOffset?: number; + readonly byteLength?: number; + readonly littleEndian?: boolean; + readonly maximumBytes?: number; +} + +export function assertSafeOffset(value: number, label = "Byte offset"): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`${label} must be a safe non-negative integer`); + } + return value; +} + +export function checkedOffsetAdd( + offset: number, + length: number, + maximum = Number.MAX_SAFE_INTEGER, + label = "Byte range", +): number { + assertSafeOffset(offset, "Byte offset"); + assertSafeOffset(length, "Byte length"); + assertSafeOffset(maximum, "Maximum byte offset"); + const end = offset + length; + if (!Number.isSafeInteger(end) || end > maximum) { + throw new HelperLimitError( + label, + Number.isSafeInteger(end) ? end : Number.MAX_SAFE_INTEGER, + maximum, + ); + } + return end; +} + +export function checkedByteRange( + availableBytes: number, + offset: number, + length: number, + label = "Byte range", +): ByteRange { + assertSafeOffset(availableBytes, "Available byte length"); + const end = checkedOffsetAdd(offset, length, availableBytes, label); + return Object.freeze({ offset, length, end }); +} + +/** + * A bounds-checked, non-owning cursor over an existing byte array. + * + * Reads throw before moving the cursor. `readBytes` returns a view by default; + * request a copy before retaining bytes from a mutable input buffer. + */ +export class ByteCursor { + readonly #bytes: Uint8Array; + readonly #view: DataView; + readonly #littleEndian: boolean; + #offset = 0; + + constructor(input: BytesLike, options: ByteCursorOptions = {}) { + const bounded = assertBoundedBytes( + input, + options.maximumBytes ?? DEFAULT_HELPER_LIMITS.maxInputBytes, + ); + const start = options.byteOffset ?? 0; + const length = options.byteLength ?? bounded.byteLength - start; + const range = checkedByteRange( + bounded.byteLength, + start, + length, + "Cursor byte range", + ); + this.#bytes = bounded.subarray(range.offset, range.end); + this.#view = new DataView( + this.#bytes.buffer, + this.#bytes.byteOffset, + this.#bytes.byteLength, + ); + this.#littleEndian = options.littleEndian ?? false; + } + + get length(): number { + return this.#bytes.byteLength; + } + + get offset(): number { + return this.#offset; + } + + get remaining(): number { + return this.length - this.#offset; + } + + get done(): boolean { + return this.#offset === this.length; + } + + seek(offset: number): this { + checkedByteRange(this.length, offset, 0, "Cursor position"); + this.#offset = offset; + return this; + } + + skip(length: number): this { + this.#offset = this.#claim(length).end; + return this; + } + + ensure(length: number): ByteRange { + return checkedByteRange(this.length, this.#offset, length, "Cursor read"); + } + + peekUint8(relativeOffset = 0): number { + assertSafeOffset(relativeOffset, "Relative byte offset"); + const position = checkedOffsetAdd( + this.#offset, + relativeOffset, + this.length, + "Cursor peek", + ); + checkedByteRange(this.length, position, 1, "Cursor peek"); + return this.#view.getUint8(position); + } + + readUint8(): number { + const range = this.#claim(1); + return this.#view.getUint8(range.offset); + } + + readInt8(): number { + const range = this.#claim(1); + return this.#view.getInt8(range.offset); + } + + readUint16(littleEndian = this.#littleEndian): number { + const range = this.#claim(2); + return this.#view.getUint16(range.offset, littleEndian); + } + + readInt16(littleEndian = this.#littleEndian): number { + const range = this.#claim(2); + return this.#view.getInt16(range.offset, littleEndian); + } + + readUint24(littleEndian = this.#littleEndian): number { + const range = this.#claim(3); + const first = this.#view.getUint8(range.offset); + const second = this.#view.getUint8(range.offset + 1); + const third = this.#view.getUint8(range.offset + 2); + return littleEndian + ? first + second * 0x100 + third * 0x10000 + : first * 0x10000 + second * 0x100 + third; + } + + readUint32(littleEndian = this.#littleEndian): number { + const range = this.#claim(4); + return this.#view.getUint32(range.offset, littleEndian); + } + + readInt32(littleEndian = this.#littleEndian): number { + const range = this.#claim(4); + return this.#view.getInt32(range.offset, littleEndian); + } + + readBigUint64(littleEndian = this.#littleEndian): bigint { + const range = this.#claim(8); + return this.#view.getBigUint64(range.offset, littleEndian); + } + + readBigInt64(littleEndian = this.#littleEndian): bigint { + const range = this.#claim(8); + return this.#view.getBigInt64(range.offset, littleEndian); + } + + readFloat32(littleEndian = this.#littleEndian): number { + const range = this.#claim(4); + return this.#view.getFloat32(range.offset, littleEndian); + } + + readFloat64(littleEndian = this.#littleEndian): number { + const range = this.#claim(8); + return this.#view.getFloat64(range.offset, littleEndian); + } + + readBytes(length: number, copy = false): Uint8Array { + const range = this.#claim(length); + const result = this.#bytes.subarray(range.offset, range.end); + return copy ? new Uint8Array(result) : result; + } + + readAscii(length: number): string { + const range = this.ensure(length); + const bytes = this.#bytes.subarray(range.offset, range.end); + let result = ""; + for (const byte of bytes) { + if (byte > 0x7f) throw new SyntaxError("Byte is not ASCII"); + result += String.fromCharCode(byte); + } + this.#offset = range.end; + return result; + } + + subcursor(length: number, littleEndian = this.#littleEndian): ByteCursor { + return new ByteCursor(this.readBytes(length), { + littleEndian, + maximumBytes: length || 1, + }); + } + + bytes(copy = false): Uint8Array { + return copy ? new Uint8Array(this.#bytes) : this.#bytes; + } + + #claim(length: number): ByteRange { + const range = this.ensure(length); + this.#offset = range.end; + return range; + } +} + +export function ownedBytes( + input: BytesLike, + maximumBytes: number = DEFAULT_HELPER_LIMITS.maxInputBytes, +): Uint8Array { + const source = assertBoundedBytes(input, maximumBytes); + const result = new Uint8Array(source.byteLength); + result.set(source); + return result; +} diff --git a/src/helpers/checksums.ts b/src/helpers/checksums.ts index d367748..c47591c 100644 --- a/src/helpers/checksums.ts +++ b/src/helpers/checksums.ts @@ -1,11 +1,24 @@ import { + HelperLimitError, + asUint8Array, assertBoundedBytes, + assertPositiveSafeInteger, DEFAULT_HELPER_LIMITS, type BytesLike, } from "./limits"; import { bytesToHex } from "./encoding"; export type DigestAlgorithm = "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"; +export type ChecksumAlgorithm = "CRC-32" | "Adler-32" | "FNV-1a-32"; + +export interface IncrementalChecksum { + readonly algorithm: ChecksumAlgorithm; + readonly bytesProcessed: number; + update(input: BytesLike): this; + digest(): number; + digestHex(): string; + reset(): this; +} const CRC_TABLE = (() => { const result = new Uint32Array(256); @@ -31,6 +44,79 @@ export function crc32( return (value ^ 0xffffffff) >>> 0; } +export function createIncrementalChecksum( + algorithm: ChecksumAlgorithm, + maximumBytes: number = DEFAULT_HELPER_LIMITS.maxInputBytes, +): IncrementalChecksum { + if ( + algorithm !== "CRC-32" && + algorithm !== "Adler-32" && + algorithm !== "FNV-1a-32" + ) + throw new TypeError(`Unsupported checksum algorithm: ${String(algorithm)}`); + assertPositiveSafeInteger(maximumBytes, "Maximum checksum byte length"); + let processed = 0; + let first = 1; + let second = 0; + let crc = 0xffffffff; + let fnv = 0x811c9dc5; + const result: IncrementalChecksum = { + algorithm, + get bytesProcessed() { + return processed; + }, + update(input) { + const bytes = asUint8Array(input); + const next = processed + bytes.byteLength; + if (!Number.isSafeInteger(next) || next > maximumBytes) { + throw new HelperLimitError( + "Checksum byte length", + Number.isSafeInteger(next) ? next : Number.MAX_SAFE_INTEGER, + maximumBytes, + ); + } + if (algorithm === "CRC-32") { + for (const byte of bytes) + crc = (CRC_TABLE[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8); + } else if (algorithm === "Adler-32") { + for (let offset = 0; offset < bytes.length; offset += 5552) { + const end = Math.min(bytes.length, offset + 5552); + for (let index = offset; index < end; index += 1) { + first += bytes[index] ?? 0; + second += first; + } + first %= 65_521; + second %= 65_521; + } + } else { + for (const byte of bytes) { + fnv ^= byte; + fnv = Math.imul(fnv, 0x01000193); + } + } + processed = next; + return this; + }, + digest() { + if (algorithm === "CRC-32") return (crc ^ 0xffffffff) >>> 0; + if (algorithm === "Adler-32") return ((second << 16) | first) >>> 0; + return fnv >>> 0; + }, + digestHex() { + return formatChecksum(this.digest()); + }, + reset() { + processed = 0; + first = 1; + second = 0; + crc = 0xffffffff; + fnv = 0x811c9dc5; + return this; + }, + }; + return result; +} + export function adler32( input: BytesLike, maximumBytes: number = DEFAULT_HELPER_LIMITS.maxInputBytes, diff --git a/src/helpers/display.ts b/src/helpers/display.ts new file mode 100644 index 0000000..9dc0e85 --- /dev/null +++ b/src/helpers/display.ts @@ -0,0 +1,47 @@ +export type ByteUnitSystem = "iec" | "si"; + +export interface FormatBytesOptions { + /** IEC uses powers of 1024 and KiB; SI uses powers of 1000 and kB. */ + readonly system?: ByteUnitSystem; + /** Fixed precision. When omitted, precision adapts to the displayed value. */ + readonly fractionDigits?: number; + /** Text returned for NaN and infinities. */ + readonly invalidValue?: string; + /** Separator between the numeric value and unit. */ + readonly separator?: string; +} + +const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] as const; +const SI_UNITS = ["B", "kB", "MB", "GB", "TB", "PB"] as const; + +export function formatBytes( + bytes: number, + options: FormatBytesOptions = {}, +): string { + if (!Number.isFinite(bytes)) return options.invalidValue ?? "unknown"; + if (options.fractionDigits !== undefined) { + if ( + !Number.isInteger(options.fractionDigits) || + options.fractionDigits < 0 || + options.fractionDigits > 20 + ) { + throw new RangeError( + "Fraction digits must be an integer from 0 through 20", + ); + } + } + const system = options.system ?? "iec"; + const base = system === "iec" ? 1024 : 1000; + const units = system === "iec" ? IEC_UNITS : SI_UNITS; + const sign = bytes < 0 ? "-" : ""; + let amount = Math.abs(bytes); + let unit = 0; + while (amount >= base && unit < units.length - 1) { + amount /= base; + unit += 1; + } + const digits = + options.fractionDigits ?? + (unit === 0 ? 0 : amount < 10 ? 2 : amount < 100 ? 1 : 0); + return `${sign}${amount.toFixed(digits)}${options.separator ?? " "}${units[unit]}`; +} diff --git a/src/helpers/downloads.ts b/src/helpers/downloads.ts index f3c365b..6e957e6 100644 --- a/src/helpers/downloads.ts +++ b/src/helpers/downloads.ts @@ -1,4 +1,10 @@ -import { assertBoundedText, assertPositiveSafeInteger } from "./limits"; +import { + DEFAULT_HELPER_LIMITS, + HelperLimitError, + assertBoundedItems, + assertBoundedText, + assertPositiveSafeInteger, +} from "./limits"; export interface ObjectUrlLease { readonly url: string; @@ -6,6 +12,44 @@ export interface ObjectUrlLease { revoke(): void; } +export interface ObjectUrlLeasePool { + readonly size: number; + create(key: TKey, blob: Blob): ObjectUrlLease; + get(key: TKey): ObjectUrlLease | undefined; + revoke(key: TKey): boolean; + revokeAll(): void; +} + +export interface BlobDownload { + readonly blob: Blob; + readonly filename: string; +} + +export interface PlannedBlobDownload extends BlobDownload { + readonly requestedFilename: string; + readonly sourceIndex: number; +} + +export interface PlanBlobDownloadsOptions { + readonly fallbackFilename?: string; + readonly maximumFilenameLength?: number; + readonly maximumFiles?: number; + readonly order?: "input" | "filename"; +} + +export interface TriggerBlobDownloadsOptions extends PlanBlobDownloadsOptions { + readonly ownerDocument?: Document; + readonly urlApi?: Pick; + readonly revokeDelayMs?: number; + readonly schedule?: (callback: () => void, delayMs: number) => unknown; +} + +export interface TriggeredBlobDownloads { + readonly downloads: readonly PlannedBlobDownload[]; + readonly leases: readonly ObjectUrlLease[]; + revoke(): void; +} + export function sanitizeDownloadFilename( input: string, fallback = "download.bin", @@ -17,7 +61,10 @@ export function sanitizeDownloadFilename( const safe = cleaned || fallbackName; if (safe.length <= maximumLength) return safe; const dot = safe.lastIndexOf("."); - const extension = dot > 0 && safe.length - dot <= 16 ? safe.slice(dot) : ""; + const extension = + dot > 0 && safe.length - dot <= Math.min(16, maximumLength - 1) + ? safe.slice(dot) + : ""; let stem = safe.slice(0, Math.max(1, maximumLength - extension.length)); const finalCodeUnit = stem.charCodeAt(stem.length - 1); if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) @@ -69,12 +116,153 @@ export function createObjectUrlLease( }; } +export function createObjectUrlLeasePool( + urlApi: Pick = URL, +): ObjectUrlLeasePool { + const leases = new Map(); + return { + get size() { + return leases.size; + }, + create(key, blob) { + const previous = leases.get(key); + leases.delete(key); + previous?.revoke(); + const lease = createObjectUrlLease(blob, urlApi); + leases.set(key, lease); + return lease; + }, + get(key) { + return leases.get(key); + }, + revoke(key) { + const lease = leases.get(key); + if (!lease) return false; + leases.delete(key); + lease.revoke(); + return true; + }, + revokeAll() { + for (const lease of leases.values()) lease.revoke(); + leases.clear(); + }, + }; +} + export function triggerBlobDownload( blob: Blob, filename: string, ownerDocument: Document = document, + urlApi: Pick = URL, ): ObjectUrlLease { - const lease = createObjectUrlLease(blob); + const lease = createObjectUrlLease(blob, urlApi); + try { + clickDownloadLease(lease, filename, ownerDocument); + } finally { + globalThis.setTimeout(() => lease.revoke(), 0); + } + return lease; +} + +export function planBlobDownloads( + input: Iterable, + options: PlanBlobDownloadsOptions = {}, +): readonly PlannedBlobDownload[] { + const maximumFiles = options.maximumFiles ?? DEFAULT_HELPER_LIMITS.maxItems; + const maximumLength = options.maximumFilenameLength ?? 180; + assertPositiveSafeInteger(maximumFiles, "Maximum download count"); + assertPositiveSafeInteger(maximumLength, "Maximum filename length"); + if (maximumLength < 8) + throw new RangeError("Batch filenames require at least 8 code units"); + const requested: Array<{ + blob: Blob; + requestedFilename: string; + sourceIndex: number; + }> = []; + for (const item of input) { + if (requested.length >= maximumFiles) + throw new HelperLimitError( + "Download count", + requested.length + 1, + maximumFiles, + ); + if (typeof item.filename !== "string") + throw new TypeError("Download filename must be text"); + requested.push({ + blob: item.blob, + requestedFilename: item.filename, + sourceIndex: requested.length, + }); + } + assertBoundedItems(requested.length, maximumFiles, "Download count"); + if (options.order === "filename") { + requested.sort((left, right) => { + const leftName = left.requestedFilename.normalize("NFC").toLowerCase(); + const rightName = right.requestedFilename.normalize("NFC").toLowerCase(); + return leftName < rightName + ? -1 + : leftName > rightName + ? 1 + : left.sourceIndex - right.sourceIndex; + }); + } + const used = new Set(); + return Object.freeze( + requested.map((item) => { + const safe = sanitizeDownloadFilename( + item.requestedFilename, + options.fallbackFilename, + maximumLength, + ); + const filename = uniqueFilename(safe, used, maximumLength); + used.add(filename.toLowerCase()); + return Object.freeze({ ...item, filename }); + }), + ); +} + +export function triggerBlobDownloads( + input: Iterable, + options: TriggerBlobDownloadsOptions = {}, +): TriggeredBlobDownloads { + const downloads = planBlobDownloads(input, options); + const delay = options.revokeDelayMs ?? 0; + if (!Number.isSafeInteger(delay) || delay < 0) + throw new TypeError( + "Download revocation delay must be a safe non-negative integer", + ); + const ownerDocument = options.ownerDocument ?? document; + const urlApi = options.urlApi ?? URL; + const leases: ObjectUrlLease[] = []; + let revoked = false; + const revoke = () => { + if (revoked) return; + revoked = true; + for (const lease of leases) lease.revoke(); + }; + try { + for (const download of downloads) { + const lease = createObjectUrlLease(download.blob, urlApi); + leases.push(lease); + clickDownloadLease(lease, download.filename, ownerDocument); + } + (options.schedule ?? globalThis.setTimeout)(revoke, delay); + } catch (error) { + revoke(); + throw error; + } + return Object.freeze({ + downloads, + leases: Object.freeze(leases.slice()), + revoke, + }); +} + +function clickDownloadLease( + lease: ObjectUrlLease, + filename: string, + ownerDocument: Document, +): void { const anchor = ownerDocument.createElement("a"); anchor.href = lease.url; anchor.download = sanitizeDownloadFilename(filename); @@ -85,7 +273,27 @@ export function triggerBlobDownload( anchor.click(); } finally { anchor.remove(); - globalThis.setTimeout(() => lease.revoke(), 0); } - return lease; +} + +function uniqueFilename( + filename: string, + used: ReadonlySet, + maximumLength: number, +): string { + if (!used.has(filename.toLowerCase())) return filename; + const dot = filename.lastIndexOf("."); + const extension = + dot > 0 && filename.length - dot <= 16 ? filename.slice(dot) : ""; + const stem = extension ? filename.slice(0, dot) : filename; + for (let index = 2; index <= 100_000; index += 1) { + const suffix = ` (${index})`; + const candidate = sanitizeDownloadFilename( + `${stem.slice(0, Math.max(1, maximumLength - extension.length - suffix.length))}${suffix}${extension}`, + "download.bin", + maximumLength, + ); + if (!used.has(candidate.toLowerCase())) return candidate; + } + throw new RangeError("Could not allocate a unique download filename"); } diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 4a07376..19a7da4 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,4 +1,6 @@ +export * from "./bytes"; export * from "./checksums"; +export * from "./display"; export * from "./downloads"; export * from "./encoding"; export * from "./limits"; @@ -6,7 +8,9 @@ export * from "./network"; export * from "./numbers"; export * from "./random"; export * from "./structured"; +export * from "./streams"; export * from "./time"; export * from "./unicode"; export * from "./units"; export * from "./url"; +export * from "./jobs"; diff --git a/src/helpers/jobs.ts b/src/helpers/jobs.ts new file mode 100644 index 0000000..97a0151 --- /dev/null +++ b/src/helpers/jobs.ts @@ -0,0 +1,346 @@ +import { assertPositiveSafeInteger } from "./limits"; + +export const WORKER_JOB_PROTOCOL = "add-ideas.worker-job/v1" as const; + +export interface WorkerJobRun { + readonly protocol: typeof WORKER_JOB_PROTOCOL; + readonly type: "run"; + readonly jobId: string; + readonly payload: TPayload; + readonly deadlineAt?: number; +} + +export interface WorkerJobCancel { + readonly protocol: typeof WORKER_JOB_PROTOCOL; + readonly type: "cancel"; + readonly jobId: string; + readonly reason?: string; +} + +export type WorkerJobCommand = + WorkerJobRun | WorkerJobCancel; + +export interface WorkerJobProgress { + readonly protocol: typeof WORKER_JOB_PROTOCOL; + readonly type: "progress"; + readonly jobId: string; + readonly progress: TProgress; +} + +export interface WorkerJobResult { + readonly protocol: typeof WORKER_JOB_PROTOCOL; + readonly type: "result"; + readonly jobId: string; + readonly result: TResult; +} + +export interface WorkerJobFailure { + readonly protocol: typeof WORKER_JOB_PROTOCOL; + readonly type: "error"; + readonly jobId: string; + readonly error: TError; +} + +export type WorkerJobResponse = + | WorkerJobProgress + | WorkerJobResult + | WorkerJobFailure; + +export interface WorkerJobEndpoint { + postMessage(message: unknown, transfer?: readonly Transferable[]): void; + terminate(): void; + addEventListener( + type: "message", + listener: (event: MessageEvent) => void, + ): void; + addEventListener(type: "error", listener: (event: ErrorEvent) => void): void; + removeEventListener( + type: "message", + listener: (event: MessageEvent) => void, + ): void; + removeEventListener( + type: "error", + listener: (event: ErrorEvent) => void, + ): void; +} + +export interface StartWorkerJobOptions { + readonly jobId?: string; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; + readonly transfer?: readonly Transferable[]; + readonly onProgress?: (progress: TProgress) => void; + readonly deserializeError?: (error: TError) => Error; + readonly workerFailureMessage?: string; + readonly terminateWhenSettled?: boolean; +} + +export interface WorkerJobHandle { + readonly jobId: string; + readonly promise: Promise; + cancel(reason?: string): void; +} + +export interface WorkerJobContext { + readonly signal: AbortSignal; + readonly deadlineAt?: number; + report(progress: TProgress): void; + throwIfCancelled(): void; +} + +export interface WorkerJobHandlerOptions { + readonly serializeError: (error: unknown) => TError; + readonly now?: () => number; +} + +export class WorkerJobTimeoutError extends Error { + readonly jobId: string; + readonly timeoutMs: number; + + constructor(jobId: string, timeoutMs: number) { + super(`Worker job ${jobId} exceeded its ${timeoutMs}-millisecond deadline`); + this.name = "WorkerJobTimeoutError"; + this.jobId = jobId; + this.timeoutMs = timeoutMs; + } +} + +let workerJobSequence = 0; + +export function startWorkerJob< + TPayload, + TResult, + TProgress = never, + TError = string, +>( + worker: WorkerJobEndpoint, + payload: TPayload, + options: StartWorkerJobOptions = {}, +): WorkerJobHandle { + const jobId = options.jobId ?? `toolbox-job-${++workerJobSequence}`; + if (!jobId.trim()) throw new TypeError("Worker job ID cannot be empty"); + const timeoutMs = options.timeoutMs; + if (timeoutMs !== undefined) { + assertPositiveSafeInteger(timeoutMs, "Worker job timeout"); + if (timeoutMs > 2_147_483_647) + throw new RangeError( + "Worker job timeout exceeds the browser timer limit", + ); + } + + let settled = false; + let rejectJob: ((reason?: unknown) => void) | undefined; + let timeout: ReturnType | undefined; + + const dispose = () => { + if (timeout !== undefined) globalThis.clearTimeout(timeout); + options.signal?.removeEventListener("abort", onAbort); + worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); + if (options.terminateWhenSettled !== false) worker.terminate(); + }; + const settle = (callback: () => void) => { + if (settled) return false; + settled = true; + dispose(); + callback(); + return true; + }; + const cancel = (reason = "Worker job cancelled") => { + if (settled) return; + try { + worker.postMessage({ + protocol: WORKER_JOB_PROTOCOL, + type: "cancel", + jobId, + reason, + } satisfies WorkerJobCancel); + } catch { + // A disposable worker may already have stopped; local settlement remains authoritative. + } + settle(() => rejectJob?.(createAbortError(reason))); + }; + const onAbort = () => + cancel(abortReasonMessage(options.signal?.reason, "Worker job cancelled")); + const onMessage = (event: MessageEvent) => { + const message = event.data as Partial< + WorkerJobResponse + >; + if ( + message.protocol !== WORKER_JOB_PROTOCOL || + message.jobId !== jobId || + settled + ) + return; + if (message.type === "progress") { + try { + options.onProgress?.(message.progress as TProgress); + } catch (error) { + settle(() => rejectJob?.(error)); + } + return; + } + if (message.type === "result") { + settle(() => resolveJob?.(message.result as TResult)); + return; + } + if (message.type === "error") { + const value = message.error as TError; + let reason: unknown; + try { + reason = + options.deserializeError?.(value) ?? + new Error(typeof value === "string" ? value : "Worker job failed"); + } catch (error) { + reason = error; + } + settle(() => rejectJob?.(reason)); + } + }; + const onError = (event: ErrorEvent) => { + settle(() => + rejectJob?.( + new Error( + event.message || + options.workerFailureMessage || + "The worker stopped unexpectedly", + ), + ), + ); + }; + let resolveJob: ((value: TResult | PromiseLike) => void) | undefined; + const promise = new Promise((resolve, reject) => { + resolveJob = resolve; + rejectJob = reject; + }); + + worker.addEventListener("message", onMessage); + worker.addEventListener("error", onError); + if (options.signal?.aborted) { + onAbort(); + return { jobId, promise, cancel }; + } + options.signal?.addEventListener("abort", onAbort, { once: true }); + + const deadlineAt = + timeoutMs === undefined ? undefined : Date.now() + timeoutMs; + if (timeoutMs !== undefined) { + timeout = globalThis.setTimeout(() => { + if (settled) return; + try { + worker.postMessage({ + protocol: WORKER_JOB_PROTOCOL, + type: "cancel", + jobId, + reason: "Deadline exceeded", + } satisfies WorkerJobCancel); + } catch { + // Termination below is the hard deadline boundary. + } + settle(() => rejectJob?.(new WorkerJobTimeoutError(jobId, timeoutMs))); + }, timeoutMs); + } + try { + worker.postMessage( + { + protocol: WORKER_JOB_PROTOCOL, + type: "run", + jobId, + payload, + ...(deadlineAt === undefined ? {} : { deadlineAt }), + } satisfies WorkerJobRun, + options.transfer, + ); + } catch (error) { + settle(() => rejectJob?.(error)); + } + return { jobId, promise, cancel }; +} + +export function createWorkerJobMessageHandler< + TPayload, + TResult, + TProgress = never, + TError = string, +>( + run: ( + payload: TPayload, + context: WorkerJobContext, + ) => TResult | Promise, + postMessage: ( + response: WorkerJobResponse, + ) => void, + options: WorkerJobHandlerOptions, +): (event: MessageEvent>) => void { + const controllers = new Map(); + const now = options.now ?? Date.now; + return (event) => { + const command = event.data; + if (command?.protocol !== WORKER_JOB_PROTOCOL) return; + if (command.type === "cancel") { + controllers + .get(command.jobId) + ?.abort(createAbortError(command.reason ?? "Worker job cancelled")); + return; + } + if (controllers.has(command.jobId)) return; + const controller = new AbortController(); + controllers.set(command.jobId, controller); + const throwIfCancelled = () => { + controller.signal.throwIfAborted(); + if (command.deadlineAt !== undefined && now() >= command.deadlineAt) { + throw new WorkerJobTimeoutError( + command.jobId, + Math.max(1, Math.trunc(command.deadlineAt - now())), + ); + } + }; + const context: WorkerJobContext = { + signal: controller.signal, + deadlineAt: command.deadlineAt, + report(progress) { + throwIfCancelled(); + postMessage({ + protocol: WORKER_JOB_PROTOCOL, + type: "progress", + jobId: command.jobId, + progress, + }); + }, + throwIfCancelled, + }; + void Promise.resolve() + .then(() => { + throwIfCancelled(); + return run(command.payload, context); + }) + .then((result) => { + throwIfCancelled(); + postMessage({ + protocol: WORKER_JOB_PROTOCOL, + type: "result", + jobId: command.jobId, + result, + }); + }) + .catch((error: unknown) => { + postMessage({ + protocol: WORKER_JOB_PROTOCOL, + type: "error", + jobId: command.jobId, + error: options.serializeError(error), + }); + }) + .finally(() => controllers.delete(command.jobId)); + }; +} + +function createAbortError(message: string): Error { + return typeof DOMException === "undefined" + ? Object.assign(new Error(message), { name: "AbortError" }) + : new DOMException(message, "AbortError"); +} + +function abortReasonMessage(reason: unknown, fallback: string): string { + return reason instanceof Error && reason.message ? reason.message : fallback; +} diff --git a/src/helpers/streams.ts b/src/helpers/streams.ts new file mode 100644 index 0000000..6152520 --- /dev/null +++ b/src/helpers/streams.ts @@ -0,0 +1,264 @@ +import { + createIncrementalChecksum, + type ChecksumAlgorithm, + type DigestAlgorithm, + type IncrementalChecksum, +} from "./checksums"; +import { bytesToHex } from "./encoding"; +import { + DEFAULT_HELPER_LIMITS, + HelperLimitError, + asUint8Array, + assertPositiveSafeInteger, + type BytesLike, +} from "./limits"; + +export type ByteSource = + Blob | ReadableStream | AsyncIterable; + +export interface ByteSourceProgress { + readonly processedBytes: number; + readonly totalBytes?: number; +} + +export interface ConsumeByteSourceOptions { + readonly maximumBytes?: number; + readonly chunkBytes?: number; + readonly knownTotalBytes?: number; + readonly signal?: AbortSignal; + readonly onProgress?: (progress: ByteSourceProgress) => void; +} + +export interface DigestByteSourceOptions extends ConsumeByteSourceOptions { + readonly cryptoProvider?: Pick; +} + +export async function* iterateByteChunks( + source: ByteSource, + options: ConsumeByteSourceOptions = {}, +): AsyncGenerator, number> { + const maximum = options.maximumBytes ?? DEFAULT_HELPER_LIMITS.maxInputBytes; + const chunkBytes = options.chunkBytes ?? 1024 * 1024; + assertPositiveSafeInteger(maximum, "Maximum source byte length"); + assertPositiveSafeInteger(chunkBytes, "Source chunk byte length"); + if (options.knownTotalBytes !== undefined) + assertKnownTotal(options.knownTotalBytes, maximum); + throwIfAborted(options.signal); + + let processed = 0; + const report = (totalBytes?: number) => + options.onProgress?.({ + processedBytes: processed, + ...(totalBytes === undefined ? {} : { totalBytes }), + }); + const accept = (input: BytesLike): Uint8Array => { + throwIfAborted(options.signal); + const bytes = asUint8Array(input); + const next = processed + bytes.byteLength; + if (!Number.isSafeInteger(next) || next > maximum) { + throw new HelperLimitError( + "Source byte length", + Number.isSafeInteger(next) ? next : Number.MAX_SAFE_INTEGER, + maximum, + ); + } + if ( + options.knownTotalBytes !== undefined && + next > options.knownTotalBytes + ) { + throw new HelperLimitError( + "Source byte length", + next, + options.knownTotalBytes, + ); + } + processed = next; + return bytes; + }; + + if (isBlob(source)) { + assertKnownTotal(source.size, maximum); + assertCompletedTotal(source.size, options.knownTotalBytes); + if (source.size === 0) report(0); + for (let offset = 0; offset < source.size; offset += chunkBytes) { + throwIfAborted(options.signal); + const bytes = accept( + await source + .slice(offset, Math.min(offset + chunkBytes, source.size)) + .arrayBuffer(), + ); + yield bytes; + report(source.size); + } + return processed; + } + + if (isReadableStream(source)) { + const reader = source.getReader(); + let completed = false; + try { + while (true) { + throwIfAborted(options.signal); + const item = await reader.read(); + if (item.done) { + completed = true; + break; + } + const bytes = accept(item.value); + if (bytes.byteLength) yield bytes; + report(options.knownTotalBytes); + } + } finally { + if (!completed) { + try { + await reader.cancel(options.signal?.reason); + } catch { + // Preserve the original consumer, limit, or cancellation error. + } + } + reader.releaseLock(); + } + if (processed === 0) report(options.knownTotalBytes); + assertCompletedTotal(processed, options.knownTotalBytes); + return processed; + } + + if (!isAsyncIterable(source)) { + throw new TypeError( + "Byte source must be a Blob, ReadableStream, or async iterable", + ); + } + for await (const input of source) { + const bytes = accept(input); + if (bytes.byteLength) yield bytes; + report(options.knownTotalBytes); + } + if (processed === 0) report(options.knownTotalBytes); + assertCompletedTotal(processed, options.knownTotalBytes); + return processed; +} + +export async function consumeByteSource( + source: ByteSource, + consume: (chunk: Uint8Array) => void | Promise, + options: ConsumeByteSourceOptions = {}, +): Promise { + let processed = 0; + for await (const chunk of iterateByteChunks(source, options)) { + await consume(chunk); + processed += chunk.byteLength; + } + return processed; +} + +export async function checksumByteSource( + source: ByteSource, + checksum: ChecksumAlgorithm | IncrementalChecksum = "CRC-32", + options: ConsumeByteSourceOptions = {}, +): Promise { + const instance = + typeof checksum === "string" + ? createIncrementalChecksum( + checksum, + options.maximumBytes ?? DEFAULT_HELPER_LIMITS.maxInputBytes, + ) + : checksum; + await consumeByteSource( + source, + (chunk) => { + instance.update(chunk); + }, + options, + ); + return instance.digest(); +} + +/** + * Hashes a byte source with Web Crypto after bounded, cancellable chunk reads. + * + * Web Crypto exposes only a one-shot digest API, so chunks are retained until + * the configured byte ceiling is reached. Use `checksumByteSource` for true + * constant-memory incremental CRC-32, Adler-32, or FNV-1a operation. + */ +export async function digestByteSource( + source: ByteSource, + algorithm: DigestAlgorithm = "SHA-256", + options: DigestByteSourceOptions = {}, +): Promise> { + const chunks: Uint8Array[] = []; + let byteLength = 0; + await consumeByteSource( + source, + (chunk) => { + chunks.push(new Uint8Array(chunk)); + byteLength += chunk.byteLength; + }, + options, + ); + throwIfAborted(options.signal); + const input = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + input.set(chunk, offset); + offset += chunk.byteLength; + } + const provider = options.cryptoProvider ?? crypto; + const output = await provider.subtle.digest(algorithm, input); + throwIfAborted(options.signal); + return new Uint8Array(output); +} + +export async function digestByteSourceHex( + source: ByteSource, + algorithm: DigestAlgorithm = "SHA-256", + options: DigestByteSourceOptions = {}, +): Promise { + return bytesToHex(await digestByteSource(source, algorithm, options)); +} + +function isBlob(source: ByteSource): source is Blob { + return typeof Blob !== "undefined" && source instanceof Blob; +} + +function isReadableStream( + source: ByteSource, +): source is ReadableStream { + return typeof (source as ReadableStream).getReader === "function"; +} + +function isAsyncIterable( + source: ByteSource, +): source is AsyncIterable { + return ( + typeof (source as AsyncIterable)[Symbol.asyncIterator] === + "function" + ); +} + +function assertKnownTotal(value: number, maximum: number): void { + if (!Number.isSafeInteger(value) || value < 0) + throw new TypeError( + "Known source byte length must be a safe non-negative integer", + ); + if (value > maximum) + throw new HelperLimitError("Source byte length", value, maximum); +} + +function assertCompletedTotal( + processedBytes: number, + knownTotalBytes: number | undefined, +): void { + if (knownTotalBytes === undefined || processedBytes === knownTotalBytes) + return; + throw new RangeError( + `Source ended after ${processedBytes} bytes; expected ${knownTotalBytes}`, + ); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (!signal?.aborted) return; + if (signal.reason instanceof Error) throw signal.reason; + throw typeof DOMException === "undefined" + ? Object.assign(new Error("Operation cancelled"), { name: "AbortError" }) + : new DOMException("Operation cancelled", "AbortError"); +} diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json index ffac3bc..40650f5 100644 --- a/src/toolbox/manifest.source.json +++ b/src/toolbox/manifest.source.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.helper-tools", "name": "Helper Tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Encode, convert, inspect and calculate locally.", "entry": "./", "icon": "./favicon.svg", @@ -32,6 +32,40 @@ "crossOriginIsolated": false, "topLevelContext": false }, + "io": { + "accepts": [ + { + "mediaType": "text/plain", + "extensions": [".txt"] + }, + { + "mediaType": "application/json", + "extensions": [".json"] + }, + { + "mediaType": "text/csv", + "extensions": [".csv"] + } + ], + "produces": [ + { + "mediaType": "text/plain", + "extensions": [".txt"] + }, + { + "mediaType": "application/json", + "extensions": [".json"] + }, + { + "mediaType": "text/csv", + "extensions": [".csv"] + } + ] + }, + "capabilities": { + "required": [], + "optional": ["web-crypto", "workers"] + }, "privacy": { "processing": "local", "fileUploads": false, diff --git a/src/version.ts b/src/version.ts index 162ff34..da2384c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const APPLICATION_VERSION = "0.1.0"; +export const APPLICATION_VERSION = "0.2.0"; diff --git a/tests/browser/helper-tools.spec.ts b/tests/browser/helper-tools.spec.ts index 2987dd7..82c3429 100644 --- a/tests/browser/helper-tools.spec.ts +++ b/tests/browser/helper-tools.spec.ts @@ -124,7 +124,7 @@ test("serves a relocatable production artifact with hardened headers", async ({ expect(manifest.headers()["content-type"]).toContain("application/json"); await expect(manifest.json()).resolves.toMatchObject({ id: "de.add-ideas.helper-tools", - version: "0.1.0", + version: "0.2.0", entry: "./", icon: "./favicon.svg", }); diff --git a/tests/browser/responsive.spec.ts b/tests/browser/responsive.spec.ts new file mode 100644 index 0000000..4ed63cd --- /dev/null +++ b/tests/browser/responsive.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; + +test("keeps the primary workspace inside a narrow viewport", async ({ + page, +}) => { + await page.goto("/deep/nested/helpers/"); + await expect(page.locator("main").first()).toBeVisible(); + await expect( + page.locator("main .loading, main .workbench-loading"), + ).toHaveCount(0); + + const widths = await page.evaluate(() => ({ + content: document.documentElement.scrollWidth, + viewport: document.documentElement.clientWidth, + })); + expect(widths.viewport).toBeLessThanOrEqual(430); + expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1); +}); diff --git a/tests/helpers/bytes-streams-jobs.test.ts b/tests/helpers/bytes-streams-jobs.test.ts new file mode 100644 index 0000000..99c36d7 --- /dev/null +++ b/tests/helpers/bytes-streams-jobs.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + ByteCursor, + HelperLimitError, + WORKER_JOB_PROTOCOL, + checkedByteRange, + checkedOffsetAdd, + checksumByteSource, + createIncrementalChecksum, + createWorkerJobMessageHandler, + digestByteSourceHex, + encodeText, + iterateByteChunks, + ownedBytes, + startWorkerJob, + WorkerJobTimeoutError, + type WorkerJobCommand, + type WorkerJobEndpoint, + type WorkerJobResponse, +} from "../../src/helpers"; + +describe("bounded byte access", () => { + it("checks offset arithmetic before slicing", () => { + expect(checkedOffsetAdd(4, 5, 9)).toBe(9); + expect(checkedByteRange(9, 4, 5)).toEqual({ + offset: 4, + length: 5, + end: 9, + }); + expect(() => checkedOffsetAdd(Number.MAX_SAFE_INTEGER, 1)).toThrow( + HelperLimitError, + ); + expect(() => checkedByteRange(8, 7, 2)).toThrow(HelperLimitError); + expect(() => checkedByteRange(8, -1, 1)).toThrow(/non-negative/u); + }); + + it("reads typed values without moving after a failed read", () => { + const cursor = new ByteCursor( + new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x41, 0x42]), + ); + expect(cursor.readUint16()).toBe(0x0102); + expect(cursor.readUint16(true)).toBe(0x0403); + expect(cursor.readAscii(2)).toBe("AB"); + expect(cursor.done).toBe(true); + expect(() => cursor.readUint8()).toThrow(HelperLimitError); + expect(cursor.offset).toBe(6); + }); + + it("supports bounded subcursors and 24-bit integers", () => { + const cursor = new ByteCursor(new Uint8Array([1, 2, 3, 4]), { + littleEndian: true, + maximumBytes: 4, + }); + expect(cursor.readUint24()).toBe(0x030201); + expect(cursor.subcursor(1).readUint8()).toBe(4); + expect( + () => new ByteCursor(new Uint8Array(5), { maximumBytes: 4 }), + ).toThrow(HelperLimitError); + }); + + it("does not advance after invalid ASCII and makes bounded owned copies", () => { + const cursor = new ByteCursor(new Uint8Array([0x41, 0xff])); + expect(() => cursor.readAscii(2)).toThrow(/ASCII/u); + expect(cursor.offset).toBe(0); + const source = new Uint8Array([1, 2]); + const copy = ownedBytes(source, 2); + source[0] = 9; + expect(copy).toEqual(new Uint8Array([1, 2])); + expect(() => ownedBytes(source, 1)).toThrow(HelperLimitError); + }); +}); + +describe("incremental byte sources", () => { + it("produces the same checksums across arbitrary chunk boundaries", async () => { + async function* chunks() { + yield encodeText("123"); + yield encodeText("456"); + yield encodeText("789"); + } + await expect( + checksumByteSource(chunks(), "CRC-32", { maximumBytes: 9 }), + ).resolves.toBe(0xcbf43926); + const checksum = createIncrementalChecksum("Adler-32", 9); + checksum.update(encodeText("1234")).update(encodeText("56789")); + expect(checksum.digestHex()).toBe("091e01de"); + expect(checksum.bytesProcessed).toBe(9); + expect(() => checksum.update(new Uint8Array([0]))).toThrow( + HelperLimitError, + ); + expect(checksum.reset().bytesProcessed).toBe(0); + expect(() => createIncrementalChecksum("unknown" as "CRC-32")).toThrow( + /Unsupported checksum/u, + ); + }); + + it("hashes bounded Blob chunks and reports progress", async () => { + const progress: number[] = []; + await expect( + digestByteSourceHex(new Blob(["abc"]), "SHA-256", { + chunkBytes: 2, + maximumBytes: 3, + onProgress: (event) => progress.push(event.processedBytes), + }), + ).resolves.toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + expect(progress).toEqual([2, 3]); + }); + + it("cancels streams and enforces a total ceiling", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(3)); + controller.enqueue(new Uint8Array(3)); + controller.close(); + }, + }); + const consume = async () => { + for await (const chunk of iterateByteChunks(stream, { + maximumBytes: 5, + })) { + // Consume until the shared total limit rejects the second chunk. + void chunk; + } + }; + await expect(consume()).rejects.toThrow(HelperLimitError); + + async function* shortSource() { + yield new Uint8Array(2); + } + const short = async () => { + for await (const chunk of iterateByteChunks(shortSource(), { + maximumBytes: 4, + knownTotalBytes: 3, + })) + void chunk; + }; + await expect(short()).rejects.toThrow(/expected 3/u); + + const controller = new AbortController(); + controller.abort(); + const aborted = async () => { + for await (const chunk of iterateByteChunks(new Blob(["x"]), { + signal: controller.signal, + })) { + // No chunk may escape after cancellation. + void chunk; + } + }; + await expect(aborted()).rejects.toMatchObject({ name: "AbortError" }); + }); +}); + +class FakeWorker implements WorkerJobEndpoint { + readonly posted: unknown[] = []; + terminated = 0; + readonly #messages = new Set<(event: MessageEvent) => void>(); + readonly #errors = new Set<(event: ErrorEvent) => void>(); + + postMessage(message: unknown): void { + this.posted.push(message); + } + + terminate(): void { + this.terminated += 1; + } + + addEventListener( + type: "message" | "error", + listener: + ((event: MessageEvent) => void) | ((event: ErrorEvent) => void), + ): void { + if (type === "message") + this.#messages.add(listener as (event: MessageEvent) => void); + else this.#errors.add(listener as (event: ErrorEvent) => void); + } + + removeEventListener( + type: "message" | "error", + listener: + ((event: MessageEvent) => void) | ((event: ErrorEvent) => void), + ): void { + if (type === "message") + this.#messages.delete(listener as (event: MessageEvent) => void); + else this.#errors.delete(listener as (event: ErrorEvent) => void); + } + + respond(message: unknown): void { + const event = new MessageEvent("message", { data: message }); + for (const listener of this.#messages) listener(event); + } +} + +describe("disposable worker job protocol", () => { + it("routes matching progress and result messages and disposes the worker", async () => { + const worker = new FakeWorker(); + const progress = vi.fn(); + const task = startWorkerJob<{ value: number }, number, string>( + worker, + { value: 2 }, + { jobId: "job-a", onProgress: progress }, + ); + expect(worker.posted[0]).toMatchObject({ + protocol: WORKER_JOB_PROTOCOL, + type: "run", + jobId: "job-a", + }); + worker.respond({ + protocol: WORKER_JOB_PROTOCOL, + type: "progress", + jobId: "someone-else", + progress: "ignored", + }); + worker.respond({ + protocol: WORKER_JOB_PROTOCOL, + type: "progress", + jobId: "job-a", + progress: "half", + }); + worker.respond({ + protocol: WORKER_JOB_PROTOCOL, + type: "result", + jobId: "job-a", + result: 4, + }); + await expect(task.promise).resolves.toBe(4); + expect(progress).toHaveBeenCalledWith("half"); + expect(worker.terminated).toBe(1); + }); + + it("hard-stops a worker at its deadline", async () => { + vi.useFakeTimers(); + try { + const worker = new FakeWorker(); + const task = startWorkerJob(worker, "work", { + jobId: "slow", + timeoutMs: 25, + }); + const rejected = expect(task.promise).rejects.toBeInstanceOf( + WorkerJobTimeoutError, + ); + await vi.advanceTimersByTimeAsync(25); + await rejected; + expect(worker.posted.at(-1)).toMatchObject({ + type: "cancel", + jobId: "slow", + }); + expect(worker.terminated).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it("settles and disposes when posting or progress handling fails", async () => { + const postingWorker = new FakeWorker(); + postingWorker.postMessage = () => { + throw new DOMException("Not cloneable", "DataCloneError"); + }; + const postingTask = startWorkerJob(postingWorker, { invalid: true }); + await expect(postingTask.promise).rejects.toMatchObject({ + name: "DataCloneError", + }); + expect(postingWorker.terminated).toBe(1); + + const progressWorker = new FakeWorker(); + const progressTask = startWorkerJob( + progressWorker, + null, + { + jobId: "progress-error", + onProgress: () => { + throw new Error("Progress consumer failed"); + }, + }, + ); + progressWorker.respond({ + protocol: WORKER_JOB_PROTOCOL, + type: "progress", + jobId: "progress-error", + progress: 1, + }); + await expect(progressTask.promise).rejects.toThrow( + "Progress consumer failed", + ); + expect(progressWorker.terminated).toBe(1); + }); + + it("provides a worker-side progress/result/error adapter", async () => { + type Response = WorkerJobResponse; + const responses: Response[] = []; + const handler = createWorkerJobMessageHandler< + number, + number, + string, + { message: string } + >( + (payload: number, context) => { + context.report("working"); + return payload * 2; + }, + (response) => responses.push(response), + { + serializeError: (error) => ({ + message: error instanceof Error ? error.message : String(error), + }), + }, + ); + handler( + new MessageEvent>("message", { + data: { + protocol: WORKER_JOB_PROTOCOL, + type: "run", + jobId: "worker-side", + payload: 3, + }, + }), + ); + await vi.waitFor(() => expect(responses).toHaveLength(2)); + expect(responses).toEqual([ + expect.objectContaining({ type: "progress", progress: "working" }), + expect.objectContaining({ type: "result", result: 6 }), + ]); + }); +}); diff --git a/tests/helpers/random-limits-downloads.test.ts b/tests/helpers/random-limits-downloads.test.ts index b8b6994..e0a9ee5 100644 --- a/tests/helpers/random-limits-downloads.test.ts +++ b/tests/helpers/random-limits-downloads.test.ts @@ -6,12 +6,16 @@ import { assertBoundedItems, assertBoundedText, createObjectUrlLease, + createObjectUrlLeasePool, createSeededRandom, + formatBytes, + planBlobDownloads, sanitizeDownloadFilename, secureRandomBytes, secureRandomInt, shuffleSeeded, triggerBlobDownload, + triggerBlobDownloads, } from "../../src/helpers"; describe("shared ceilings and download safety", () => { @@ -85,6 +89,113 @@ describe("shared ceilings and download safety", () => { vi.stubGlobal("URL", originalUrl); } }); + + it("formats IEC and SI byte quantities deterministically", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(1536)).toBe("1.50 KiB"); + expect(formatBytes(1500, { system: "si", fractionDigits: 1 })).toBe( + "1.5 kB", + ); + expect(formatBytes(-1024)).toBe("-1.00 KiB"); + expect(formatBytes(Number.NaN)).toBe("unknown"); + }); + + it("replaces and revokes named object URL leases", () => { + let sequence = 0; + const revokeObjectURL = vi.fn(); + const pool = createObjectUrlLeasePool({ + createObjectURL: vi.fn(() => `blob:${++sequence}`), + revokeObjectURL, + }); + const first = pool.create("preview", new Blob(["one"])); + const second = pool.create("preview", new Blob(["two"])); + expect(first.revoked).toBe(true); + expect(second.url).toBe("blob:2"); + expect(pool.size).toBe(1); + expect(pool.revoke("missing")).toBe(false); + pool.revokeAll(); + expect(second.revoked).toBe(true); + expect(pool.size).toBe(0); + expect(revokeObjectURL).toHaveBeenCalledTimes(2); + }); + + it("plans collision-free batch names and revokes every URL", () => { + const items = [ + { blob: new Blob(["a"]), filename: "Report.txt" }, + { blob: new Blob(["b"]), filename: "report.txt" }, + { blob: new Blob(["c"]), filename: "../unsafe?.txt" }, + ]; + expect(planBlobDownloads(items).map((item) => item.filename)).toEqual([ + "Report.txt", + "report (2).txt", + "_unsafe_.txt", + ]); + + const revokeObjectURL = vi.fn(); + let index = 0; + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + let scheduled: (() => void) | undefined; + const batch = triggerBlobDownloads(items, { + ownerDocument: document, + urlApi: { + createObjectURL: () => `blob:batch-${++index}`, + revokeObjectURL, + }, + schedule: (callback) => { + scheduled = callback; + }, + }); + expect(click).toHaveBeenCalledTimes(3); + expect(batch.leases.every((lease) => !lease.revoked)).toBe(true); + scheduled?.(); + expect(batch.leases.every((lease) => lease.revoked)).toBe(true); + expect(revokeObjectURL).toHaveBeenCalledTimes(3); + }); + + it("bounds lazy download plans before consuming the whole iterable", () => { + let yielded = 0; + function* many() { + while (true) { + yielded += 1; + yield { blob: new Blob(["x"]), filename: "same.txt" }; + } + } + expect(() => planBlobDownloads(many(), { maximumFiles: 2 })).toThrow( + HelperLimitError, + ); + expect(yielded).toBe(3); + expect(() => planBlobDownloads([], { maximumFilenameLength: 7 })).toThrow( + /at least 8/u, + ); + }); + + it("revokes already-created batch URLs when a click fails", () => { + const revokeObjectURL = vi.fn(); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw new Error("blocked"); + }); + expect(() => + triggerBlobDownloads( + [ + { blob: new Blob(["a"]), filename: "a.txt" }, + { blob: new Blob(["b"]), filename: "b.txt" }, + ], + { + ownerDocument: document, + urlApi: { + createObjectURL: () => `blob:test:${click.mock.calls.length}`, + revokeObjectURL, + }, + }, + ), + ).toThrow("blocked"); + expect(revokeObjectURL).toHaveBeenCalledTimes(2); + }); }); describe("secure and seeded randomness", () => {