Release Helper Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 01:01:56 +02:00
parent 5fffd48642
commit 6c763fcb5a
30 changed files with 2022 additions and 51 deletions
+39
View File
@@ -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
+11 -1
View File
@@ -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
+17 -2
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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 |
+84
View File
@@ -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<BytesLike> | AsyncIterable<BytesLike>;
interface ByteSourceProgress { processedBytes: number; totalBytes?: number }
iterateByteChunks(source: ByteSource, options?: ConsumeByteSourceOptions): AsyncGenerator<Uint8Array>;
consumeByteSource(source: ByteSource, consume: (chunk: Uint8Array) => void | Promise<void>, options?: ConsumeByteSourceOptions): Promise<number>;
checksumByteSource(source: ByteSource, checksum?: ChecksumAlgorithm | IncrementalChecksum, options?: ConsumeByteSourceOptions): Promise<number>;
digestByteSource(source: ByteSource, algorithm?: DigestAlgorithm, options?: DigestByteSourceOptions): Promise<Uint8Array>;
digestByteSourceHex(source: ByteSource, algorithm?: DigestAlgorithm, options?: DigestByteSourceOptions): Promise<string>;
```
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<typeof URL, "createObjectURL" | "revokeObjectURL">,
): ObjectUrlLease;
createObjectUrlLeasePool<TKey = string>(urlApi?: UrlApi): ObjectUrlLeasePool<TKey>;
planBlobDownloads(input: Iterable<BlobDownload>, options?: PlanBlobDownloadsOptions): readonly PlannedBlobDownload[];
triggerBlobDownloads(input: Iterable<BlobDownload>, 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<TPayload, TResult, TProgress, TError>(
worker: WorkerJobEndpoint,
payload: TPayload,
options?: StartWorkerJobOptions<TProgress, TError>,
): WorkerJobHandle<TResult>;
createWorkerJobMessageHandler<TPayload, TResult, TProgress, TError>(
run: (payload: TPayload, context: WorkerJobContext<TProgress>) => TResult | Promise<TResult>,
postMessage: (response: WorkerJobResponse<TResult, TProgress, TError>) => void,
options: WorkerJobHandlerOptions<TError>,
): (event: MessageEvent<WorkerJobCommand<TPayload>>) => 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 |
+17 -17
View File
@@ -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": {
+4 -4
View File
@@ -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",
+20 -2
View File
@@ -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"] },
},
],
});
+11 -1
View File
@@ -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
+2 -2
View File
@@ -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
==============================================================================
+17 -2
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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 |
+84
View File
@@ -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<BytesLike> | AsyncIterable<BytesLike>;
interface ByteSourceProgress { processedBytes: number; totalBytes?: number }
iterateByteChunks(source: ByteSource, options?: ConsumeByteSourceOptions): AsyncGenerator<Uint8Array>;
consumeByteSource(source: ByteSource, consume: (chunk: Uint8Array) => void | Promise<void>, options?: ConsumeByteSourceOptions): Promise<number>;
checksumByteSource(source: ByteSource, checksum?: ChecksumAlgorithm | IncrementalChecksum, options?: ConsumeByteSourceOptions): Promise<number>;
digestByteSource(source: ByteSource, algorithm?: DigestAlgorithm, options?: DigestByteSourceOptions): Promise<Uint8Array>;
digestByteSourceHex(source: ByteSource, algorithm?: DigestAlgorithm, options?: DigestByteSourceOptions): Promise<string>;
```
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<typeof URL, "createObjectURL" | "revokeObjectURL">,
): ObjectUrlLease;
createObjectUrlLeasePool<TKey = string>(urlApi?: UrlApi): ObjectUrlLeasePool<TKey>;
planBlobDownloads(input: Iterable<BlobDownload>, options?: PlanBlobDownloadsOptions): readonly PlannedBlobDownload[];
triggerBlobDownloads(input: Iterable<BlobDownload>, 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<TPayload, TResult, TProgress, TError>(
worker: WorkerJobEndpoint,
payload: TPayload,
options?: StartWorkerJobOptions<TProgress, TError>,
): WorkerJobHandle<TResult>;
createWorkerJobMessageHandler<TPayload, TResult, TProgress, TError>(
run: (payload: TPayload, context: WorkerJobContext<TProgress>) => TResult | Promise<TResult>,
postMessage: (response: WorkerJobResponse<TResult, TProgress, TError>) => void,
options: WorkerJobHandlerOptions<TError>,
): (event: MessageEvent<WorkerJobCommand<TPayload>>) => 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 |
+1 -1
View File
@@ -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) => {
+14 -1
View File
@@ -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,
+240
View File
@@ -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<ArrayBufferLike>;
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<ArrayBufferLike> {
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<ArrayBufferLike> {
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<ArrayBuffer> {
const source = assertBoundedBytes(input, maximumBytes);
const result = new Uint8Array(source.byteLength);
result.set(source);
return result;
}
+86
View File
@@ -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,
+47
View File
@@ -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]}`;
}
+213 -5
View File
@@ -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<TKey = string> {
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<typeof URL, "createObjectURL" | "revokeObjectURL">;
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<TKey = string>(
urlApi: Pick<typeof URL, "createObjectURL" | "revokeObjectURL"> = URL,
): ObjectUrlLeasePool<TKey> {
const leases = new Map<TKey, ObjectUrlLease>();
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<typeof URL, "createObjectURL" | "revokeObjectURL"> = 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<BlobDownload>,
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<string>();
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<BlobDownload>,
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<string>,
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");
}
+4
View File
@@ -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";
+346
View File
@@ -0,0 +1,346 @@
import { assertPositiveSafeInteger } from "./limits";
export const WORKER_JOB_PROTOCOL = "add-ideas.worker-job/v1" as const;
export interface WorkerJobRun<TPayload> {
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<TPayload> =
WorkerJobRun<TPayload> | WorkerJobCancel;
export interface WorkerJobProgress<TProgress> {
readonly protocol: typeof WORKER_JOB_PROTOCOL;
readonly type: "progress";
readonly jobId: string;
readonly progress: TProgress;
}
export interface WorkerJobResult<TResult> {
readonly protocol: typeof WORKER_JOB_PROTOCOL;
readonly type: "result";
readonly jobId: string;
readonly result: TResult;
}
export interface WorkerJobFailure<TError> {
readonly protocol: typeof WORKER_JOB_PROTOCOL;
readonly type: "error";
readonly jobId: string;
readonly error: TError;
}
export type WorkerJobResponse<TResult, TProgress, TError> =
| WorkerJobProgress<TProgress>
| WorkerJobResult<TResult>
| WorkerJobFailure<TError>;
export interface WorkerJobEndpoint {
postMessage(message: unknown, transfer?: readonly Transferable[]): void;
terminate(): void;
addEventListener(
type: "message",
listener: (event: MessageEvent<unknown>) => void,
): void;
addEventListener(type: "error", listener: (event: ErrorEvent) => void): void;
removeEventListener(
type: "message",
listener: (event: MessageEvent<unknown>) => void,
): void;
removeEventListener(
type: "error",
listener: (event: ErrorEvent) => void,
): void;
}
export interface StartWorkerJobOptions<TProgress, TError> {
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<TResult> {
readonly jobId: string;
readonly promise: Promise<TResult>;
cancel(reason?: string): void;
}
export interface WorkerJobContext<TProgress> {
readonly signal: AbortSignal;
readonly deadlineAt?: number;
report(progress: TProgress): void;
throwIfCancelled(): void;
}
export interface WorkerJobHandlerOptions<TError> {
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<TProgress, TError> = {},
): WorkerJobHandle<TResult> {
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<typeof globalThis.setTimeout> | 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<unknown>) => {
const message = event.data as Partial<
WorkerJobResponse<TResult, TProgress, TError>
>;
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<TResult>) => void) | undefined;
const promise = new Promise<TResult>((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<TPayload>,
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<TProgress>,
) => TResult | Promise<TResult>,
postMessage: (
response: WorkerJobResponse<TResult, TProgress, TError>,
) => void,
options: WorkerJobHandlerOptions<TError>,
): (event: MessageEvent<WorkerJobCommand<TPayload>>) => void {
const controllers = new Map<string, AbortController>();
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<TProgress> = {
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;
}
+264
View File
@@ -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<BytesLike> | AsyncIterable<BytesLike>;
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<Crypto, "subtle">;
}
export async function* iterateByteChunks(
source: ByteSource,
options: ConsumeByteSourceOptions = {},
): AsyncGenerator<Uint8Array<ArrayBufferLike>, 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<ArrayBufferLike> => {
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<ArrayBufferLike>) => void | Promise<void>,
options: ConsumeByteSourceOptions = {},
): Promise<number> {
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<number> {
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<Uint8Array<ArrayBuffer>> {
const chunks: Uint8Array<ArrayBufferLike>[] = [];
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<string> {
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<BytesLike> {
return typeof (source as ReadableStream<BytesLike>).getReader === "function";
}
function isAsyncIterable(
source: ByteSource,
): source is AsyncIterable<BytesLike> {
return (
typeof (source as AsyncIterable<BytesLike>)[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");
}
+35 -1
View File
@@ -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,
+1 -1
View File
@@ -1 +1 @@
export const APPLICATION_VERSION = "0.1.0";
export const APPLICATION_VERSION = "0.2.0";
+1 -1
View File
@@ -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",
});
+18
View File
@@ -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);
});
+325
View File
@@ -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<Uint8Array>({
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<unknown>) => 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<unknown>) => void) | ((event: ErrorEvent) => void),
): void {
if (type === "message")
this.#messages.add(listener as (event: MessageEvent<unknown>) => void);
else this.#errors.add(listener as (event: ErrorEvent) => void);
}
removeEventListener(
type: "message" | "error",
listener:
((event: MessageEvent<unknown>) => void) | ((event: ErrorEvent) => void),
): void {
if (type === "message")
this.#messages.delete(listener as (event: MessageEvent<unknown>) => 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<null, number, number>(
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<number, string, { message: string }>;
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<WorkerJobCommand<number>>("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 }),
]);
});
});
@@ -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", () => {