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. 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 ## 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 bounded building blocks for common developer calculations without network
requests, telemetry, persistence, or server processing. 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; - strict Base64, Base64URL, hexadecimal, UTF-8, UTF-16 and Latin-1 conversion;
- URL component and form encoding, Unicode scalar/grapheme inspection, case, - 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; - timestamp and bounded duration parsing and formatting;
- hardened JSON parsing, deterministic JSON and bounded CSV conversion; and - hardened JSON parsing, deterministic JSON and bounded CSV conversion; and
- secure random bytes/integers plus explicitly non-cryptographic seeded random - 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 ## Browser app
@@ -38,8 +44,11 @@ exported from one stable root:
```ts ```ts
import { import {
ByteCursor,
bytesToBase64Url, bytesToBase64Url,
createIncrementalChecksum,
digestHex, digestHex,
formatBytes,
parseCidr, parseCidr,
safeJsonParse, safeJsonParse,
} from "@add-ideas/toolbox-helpers"; } 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 hash = await digestHex(new TextEncoder().encode("local"));
const network = parseCidr("2001:db8::1/64"); const network = parseCidr("2001:db8::1/64");
const data = safeJsonParse('{"enabled":true}'); 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 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 # 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 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 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 ## Implementation provenance
- The helpers and interface are original TypeScript and React project code. - 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, - Encoding formats, IP notation, JSON, CSV, SI/IEC units, Unicode, ISO 8601,
checksum and digest algorithms were implemented from public specifications checksum and digest algorithms were implemented from public specifications
and established interoperable formats; no source from an online calculator 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 | | Package | Version | Licence | Role |
| -------------------------------- | ------- | ---------- | ------------------------------- | | -------------------------------- | ------- | ---------- | ------------------------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | manifest and context contract | | `@add-ideas/toolbox-contract` | 0.3.0 | 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-shell-react` | 0.3.0 | Apache-2.0 | shared shell, theme and actions |
| `react` | 19.2.8 | MIT | browser interface | | `react` | 19.2.8 | MIT | browser interface |
| `react-dom` | 19.2.8 | MIT | browser rendering | | `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; assertBoundedItems(count: number, maximum?: number, label?: string): number;
assertPositiveSafeInteger(value: number, label: string): number; assertPositiveSafeInteger(value: number, label: string): number;
asUint8Array(value: BytesLike): Uint8Array; 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` `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 and denial-of-service boundaries, not a substitute for an application-wide
memory budget. 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 ## Encoding and URLs
```ts ```ts
@@ -65,6 +76,18 @@ opens or fetches the result.
```ts ```ts
type DigestAlgorithm = "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"; 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; crc32(input: BytesLike, maximumBytes?: number): number;
adler32(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 and hashes do not establish authorship, and none of these functions is a
password-hashing API. 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 ## Network
```ts ```ts
@@ -140,6 +183,9 @@ createObjectUrlLease(
blob: Blob, blob: Blob,
urlApi?: Pick<typeof URL, "createObjectURL" | "revokeObjectURL">, urlApi?: Pick<typeof URL, "createObjectURL" | "revokeObjectURL">,
): ObjectUrlLease; ): ObjectUrlLease;
createObjectUrlLeasePool<TKey = string>(urlApi?: UrlApi): ObjectUrlLeasePool<TKey>;
planBlobDownloads(input: Iterable<BlobDownload>, options?: PlanBlobDownloadsOptions): readonly PlannedBlobDownload[];
triggerBlobDownloads(input: Iterable<BlobDownload>, options?: TriggerBlobDownloadsOptions): TriggeredBlobDownloads;
triggerBlobDownload( triggerBlobDownload(
blob: Blob, blob: Blob,
filename: string, filename: string,
@@ -151,6 +197,44 @@ triggerBlobDownload(
object URL revocation for the next task. Long-lived previews should instead object URL revocation for the next task. Long-lived previews should instead
keep a lease and call `revoke()` when replaced or unmounted. 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 ## Remaining groups
| Group | Exports | | Group | Exports |
+17 -17
View File
@@ -1,17 +1,17 @@
{ {
"name": "@add-ideas/toolbox-helpers", "name": "@add-ideas/toolbox-helpers",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@add-ideas/toolbox-helpers", "name": "@add-ideas/toolbox-helpers",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -39,19 +39,20 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "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, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0"
"engines": {
"node": ">=20"
}
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "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, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -59,17 +60,16 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "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, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@add-ideas/toolbox-helpers", "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.", "description": "Bounded, local-first TypeScript helpers and browser calculators for the add·ideas Toolbox.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "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" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force && npm run package:library -- --force"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -18,7 +18,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ 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. 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 ## 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 Declared licence: Apache-2.0
Installed from: node_modules/@add-ideas/toolbox-contract 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 Declared licence: Apache-2.0
Installed from: node_modules/@add-ideas/toolbox-shell-react 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 bounded building blocks for common developer calculations without network
requests, telemetry, persistence, or server processing. 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; - strict Base64, Base64URL, hexadecimal, UTF-8, UTF-16 and Latin-1 conversion;
- URL component and form encoding, Unicode scalar/grapheme inspection, case, - 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; - timestamp and bounded duration parsing and formatting;
- hardened JSON parsing, deterministic JSON and bounded CSV conversion; and - hardened JSON parsing, deterministic JSON and bounded CSV conversion; and
- secure random bytes/integers plus explicitly non-cryptographic seeded random - 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 ## Browser app
@@ -38,8 +44,11 @@ exported from one stable root:
```ts ```ts
import { import {
ByteCursor,
bytesToBase64Url, bytesToBase64Url,
createIncrementalChecksum,
digestHex, digestHex,
formatBytes,
parseCidr, parseCidr,
safeJsonParse, safeJsonParse,
} from "@add-ideas/toolbox-helpers"; } 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 hash = await digestHex(new TextEncoder().encode("local"));
const network = parseCidr("2001:db8::1/64"); const network = parseCidr("2001:db8::1/64");
const data = safeJsonParse('{"enabled":true}'); 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 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 # 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 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 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 ## Implementation provenance
- The helpers and interface are original TypeScript and React project code. - 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, - Encoding formats, IP notation, JSON, CSV, SI/IEC units, Unicode, ISO 8601,
checksum and digest algorithms were implemented from public specifications checksum and digest algorithms were implemented from public specifications
and established interoperable formats; no source from an online calculator 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 | | Package | Version | Licence | Role |
| -------------------------------- | ------- | ---------- | ------------------------------- | | -------------------------------- | ------- | ---------- | ------------------------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | manifest and context contract | | `@add-ideas/toolbox-contract` | 0.3.0 | 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-shell-react` | 0.3.0 | Apache-2.0 | shared shell, theme and actions |
| `react` | 19.2.8 | MIT | browser interface | | `react` | 19.2.8 | MIT | browser interface |
| `react-dom` | 19.2.8 | MIT | browser rendering | | `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; assertBoundedItems(count: number, maximum?: number, label?: string): number;
assertPositiveSafeInteger(value: number, label: string): number; assertPositiveSafeInteger(value: number, label: string): number;
asUint8Array(value: BytesLike): Uint8Array; 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` `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 and denial-of-service boundaries, not a substitute for an application-wide
memory budget. 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 ## Encoding and URLs
```ts ```ts
@@ -65,6 +76,18 @@ opens or fetches the result.
```ts ```ts
type DigestAlgorithm = "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"; 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; crc32(input: BytesLike, maximumBytes?: number): number;
adler32(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 and hashes do not establish authorship, and none of these functions is a
password-hashing API. 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 ## Network
```ts ```ts
@@ -140,6 +183,9 @@ createObjectUrlLease(
blob: Blob, blob: Blob,
urlApi?: Pick<typeof URL, "createObjectURL" | "revokeObjectURL">, urlApi?: Pick<typeof URL, "createObjectURL" | "revokeObjectURL">,
): ObjectUrlLease; ): ObjectUrlLease;
createObjectUrlLeasePool<TKey = string>(urlApi?: UrlApi): ObjectUrlLeasePool<TKey>;
planBlobDownloads(input: Iterable<BlobDownload>, options?: PlanBlobDownloadsOptions): readonly PlannedBlobDownload[];
triggerBlobDownloads(input: Iterable<BlobDownload>, options?: TriggerBlobDownloadsOptions): TriggeredBlobDownloads;
triggerBlobDownload( triggerBlobDownload(
blob: Blob, blob: Blob,
filename: string, filename: string,
@@ -151,6 +197,44 @@ triggerBlobDownload(
object URL revocation for the next task. Long-lived previews should instead object URL revocation for the next task. Long-lived previews should instead
keep a lease and call `revoke()` when replaced or unmounted. 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 ## Remaining groups
| Group | Exports | | Group | Exports |
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "helper-tools-shell-"; 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"]; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
+14 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.helper-tools", "id": "de.add-ideas.helper-tools",
"name": "Helper Tools", "name": "Helper Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Encode, convert, inspect and calculate locally.", "description": "Encode, convert, inspect and calculate locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -32,6 +32,19 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": false, "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 { import {
HelperLimitError,
asUint8Array,
assertBoundedBytes, assertBoundedBytes,
assertPositiveSafeInteger,
DEFAULT_HELPER_LIMITS, DEFAULT_HELPER_LIMITS,
type BytesLike, type BytesLike,
} from "./limits"; } from "./limits";
import { bytesToHex } from "./encoding"; import { bytesToHex } from "./encoding";
export type DigestAlgorithm = "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"; 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 CRC_TABLE = (() => {
const result = new Uint32Array(256); const result = new Uint32Array(256);
@@ -31,6 +44,79 @@ export function crc32(
return (value ^ 0xffffffff) >>> 0; 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( export function adler32(
input: BytesLike, input: BytesLike,
maximumBytes: number = DEFAULT_HELPER_LIMITS.maxInputBytes, 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 { export interface ObjectUrlLease {
readonly url: string; readonly url: string;
@@ -6,6 +12,44 @@ export interface ObjectUrlLease {
revoke(): void; 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( export function sanitizeDownloadFilename(
input: string, input: string,
fallback = "download.bin", fallback = "download.bin",
@@ -17,7 +61,10 @@ export function sanitizeDownloadFilename(
const safe = cleaned || fallbackName; const safe = cleaned || fallbackName;
if (safe.length <= maximumLength) return safe; if (safe.length <= maximumLength) return safe;
const dot = safe.lastIndexOf("."); 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)); let stem = safe.slice(0, Math.max(1, maximumLength - extension.length));
const finalCodeUnit = stem.charCodeAt(stem.length - 1); const finalCodeUnit = stem.charCodeAt(stem.length - 1);
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) 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( export function triggerBlobDownload(
blob: Blob, blob: Blob,
filename: string, filename: string,
ownerDocument: Document = document, ownerDocument: Document = document,
urlApi: Pick<typeof URL, "createObjectURL" | "revokeObjectURL"> = URL,
): ObjectUrlLease { ): 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"); const anchor = ownerDocument.createElement("a");
anchor.href = lease.url; anchor.href = lease.url;
anchor.download = sanitizeDownloadFilename(filename); anchor.download = sanitizeDownloadFilename(filename);
@@ -85,7 +273,27 @@ export function triggerBlobDownload(
anchor.click(); anchor.click();
} finally { } finally {
anchor.remove(); 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 "./checksums";
export * from "./display";
export * from "./downloads"; export * from "./downloads";
export * from "./encoding"; export * from "./encoding";
export * from "./limits"; export * from "./limits";
@@ -6,7 +8,9 @@ export * from "./network";
export * from "./numbers"; export * from "./numbers";
export * from "./random"; export * from "./random";
export * from "./structured"; export * from "./structured";
export * from "./streams";
export * from "./time"; export * from "./time";
export * from "./unicode"; export * from "./unicode";
export * from "./units"; export * from "./units";
export * from "./url"; 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, "schemaVersion": 1,
"id": "de.add-ideas.helper-tools", "id": "de.add-ideas.helper-tools",
"name": "Helper Tools", "name": "Helper Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Encode, convert, inspect and calculate locally.", "description": "Encode, convert, inspect and calculate locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -32,6 +32,40 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": false, "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"); expect(manifest.headers()["content-type"]).toContain("application/json");
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.helper-tools", id: "de.add-ideas.helper-tools",
version: "0.1.0", version: "0.2.0",
entry: "./", entry: "./",
icon: "./favicon.svg", 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, assertBoundedItems,
assertBoundedText, assertBoundedText,
createObjectUrlLease, createObjectUrlLease,
createObjectUrlLeasePool,
createSeededRandom, createSeededRandom,
formatBytes,
planBlobDownloads,
sanitizeDownloadFilename, sanitizeDownloadFilename,
secureRandomBytes, secureRandomBytes,
secureRandomInt, secureRandomInt,
shuffleSeeded, shuffleSeeded,
triggerBlobDownload, triggerBlobDownload,
triggerBlobDownloads,
} from "../../src/helpers"; } from "../../src/helpers";
describe("shared ceilings and download safety", () => { describe("shared ceilings and download safety", () => {
@@ -85,6 +89,113 @@ describe("shared ceilings and download safety", () => {
vi.stubGlobal("URL", originalUrl); 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", () => { describe("secure and seeded randomness", () => {