Release Helper Tools 0.2.0
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Generated
+17
-17
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -1 +1 @@
|
||||
export const APPLICATION_VERSION = "0.1.0";
|
||||
export const APPLICATION_VERSION = "0.2.0";
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user