Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
# @suaveplan/media - Universal media detection, metadata extraction, and constraint validation. Magic-byte signature registry (pluggable), MIME-keyed type detection via @suaveplan/mime, Zod-validated constraints, typed MediaError hierarchy with stable MEDIA.* codes, OTel counters + histogram, injectable observability bundle, and an async validation pipeline supporting custom predicates.
Universal media detection, metadata extraction, and constraint
validation. Combines a pluggable magic-byte `SignatureRegistry` with the
MIME database from `@suaveplan/mime` to identify image, video, and audio
artefacts from raw bytes, filenames, or both. Ships typed errors via
`@suaveplan/error`, OpenTelemetry instrumentation, Zod-validated
constraints, and an async validation pipeline that supports custom
predicates and format-specific metadata extractors.
<!-- begin:auto-docs -->
## Documentation tree
- [detection](./src/detection/detection.md) — Byte-level and filename-level media type detection. The module pairs the pluggable `SignatureRegistry` from `signatures…
- [detection](./src/detection/detection.md) — Byte-level and filename-level media type detection. The module pairs the pluggable `SignatureRegistry` from `signatures…
- [errors](./src/errors/errors.md) — Owns the stable error-code registry, per-code metadata table, and the `MediaError` class that every public entrypoint t…
- [errors](./src/errors/errors.md) — Owns the stable error-code registry, per-code metadata table, and the `MediaError` class that every public entrypoint t…
- [metadata](./src/metadata/metadata.md) — Builds a `MediaMetadata` bundle (type, MIME, size, matched signature label) from raw bytes. The sync helper produces th…
- [metadata](./src/metadata/metadata.md) — Builds a `MediaMetadata` bundle (type, MIME, size, matched signature label) from raw bytes. The sync helper produces th…
- [observability](./src/observability/observability.md) — Constructs the OpenTelemetry instrument bundle threaded through every public entrypoint in `@suaveplan/media`. Each det…
- [observability](./src/observability/observability.md) — Constructs the OpenTelemetry instrument bundle threaded through every public entrypoint in `@suaveplan/media`. Each det…
- [signatures](./src/signatures/signatures.md) — Owns the magic-byte signature registry that powers byte-level detection. The module ships a frozen `BUILTIN_SIGNATURES`…
- [signatures](./src/signatures/signatures.md) — Owns the magic-byte signature registry that powers byte-level detection. The module ships a frozen `BUILTIN_SIGNATURES`…
- [validation](./src/validation/validation.md) — Validates media bytes against a `MediaConstraints` rule set covering size floors and ceilings, type allowlists, MIME al…
- [validation](./src/validation/validation.md) — Validates media bytes against a `MediaConstraints` rule set covering size floors and ceilings, type allowlists, MIME al…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/media
```
## Quick start
```ts
import {
detectMediaType,
getMetadata,
validateMedia,
} from "@suaveplan/media";
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
detectMediaType(png); // "image"
const meta = getMetadata(png, { filename: "logo.png" });
// { type: "image", mimeType: "image/png", size: 6, signatureLabel: "PNG" }
const result = validateMedia(png, {
maxSize: 1_000_000,
allowedTypes: ["image"],
});
if (!result.valid) console.warn(result.errors);
```
## Modules
- [`detection/detection.md`](./src/detection/detection.md) — byte +
filename detection helpers.
- [`metadata/metadata.md`](./src/metadata/metadata.md) — sync + async
metadata extraction.
- [`validation/validation.md`](./src/validation/validation.md) —
constraint checking, async predicate support, assert helper.
- [`signatures/signatures.md`](./src/signatures/signatures.md) —
pluggable magic-byte registry.
- [`errors/errors.md`](./src/errors/errors.md) — `MEDIA_CODES`,
`MediaError`, metadata table.
- [`observability/observability.md`](./src/observability/observability.md)
— OpenTelemetry bundle factory.
- [`types/media.types.md`](./src/types/media.types.md) — public type
contracts + Zod schemas.
## Features
- Magic-byte detection covering PNG, JPEG, GIF, BMP, WEBP/RIFF, MP4/MOV,
WebM/MKV, MP3 (ID3 + sync), FLAC, OGG.
- Pluggable `SignatureRegistry` with `register` / `unregister` / `list`
/ `match` / `detect`; duplicate labels rejected with
`MEDIA.DUPLICATE_SIGNATURE`.
- MIME-keyed filename detection via `@suaveplan/mime`.
- Zod-validated `MediaConstraints` (`minSize`, `maxSize`, `allowedTypes`,
`allowedMimeTypes`, `minWidth`/`maxWidth`, `minHeight`/`maxHeight`,
`maxDuration`, custom predicate).
- Async metadata extractor hook for layering width / height / duration
/ codec onto the base bundle.
- `assertValidMedia` throwing variant for guard sites.
- Eight stable `MEDIA.*` error codes with per-code HTTP / severity /
retryable metadata.
- Four counters + two histograms emitted from every public entrypoint.
## Error codes
| Code | HTTP | Retryable | Severity |
| --------------------------------- | ---- | --------- | -------- |
| `MEDIA.EMPTY_INPUT` | 400 | no | warning |
| `MEDIA.INVALID_CONSTRAINTS` | 400 | no | error |
| `MEDIA.VALIDATION_FAILED` | 422 | no | warning |
| `MEDIA.UNSUPPORTED_TYPE` | 415 | no | warning |
| `MEDIA.PREDICATE_FAILED` | 500 | no | error |
| `MEDIA.DUPLICATE_SIGNATURE` | 409 | no | error |
| `MEDIA.METADATA_EXTRACTOR_FAILED` | 500 | no | error |
| `MEDIA.INVALID_SIGNATURE` | 400 | no | error |
## Observability
Four counters: `media_detect_total`, `media_metadata_total`,
`media_validate_total`, `media_error_total`. Two histograms:
`media_op_latency_ms`, `media_input_size_bytes`. Wire your meter via
`createMediaObservability({ meter })` and thread the bundle into every
call.

View File

@@ -0,0 +1,106 @@
{
"name": "@suaveplan/media",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal media detection, metadata extraction, and constraint validation. Magic-byte signature registry (pluggable), MIME-keyed type detection via @suaveplan/mime, Zod-validated constraints, typed MediaError hierarchy with stable MEDIA.* codes, OTel counters + histogram, injectable observability bundle, and an async validation pipeline supporting custom predicates.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./detection/detection": {
"import": {
"types": "./dist/detection/detection.d.ts",
"default": "./dist/detection/detection.js"
}
},
"./metadata/metadata": {
"import": {
"types": "./dist/metadata/metadata.d.ts",
"default": "./dist/metadata/metadata.js"
}
},
"./validation/validation": {
"import": {
"types": "./dist/validation/validation.d.ts",
"default": "./dist/validation/validation.js"
}
},
"./errors/errors": {
"import": {
"types": "./dist/errors/errors.d.ts",
"default": "./dist/errors/errors.js"
}
},
"./observability/observability": {
"import": {
"types": "./dist/observability/observability.d.ts",
"default": "./dist/observability/observability.js"
}
},
"./signatures/signatures": {
"import": {
"types": "./dist/signatures/signatures.d.ts",
"default": "./dist/signatures/signatures.js"
}
},
"./types": {
"import": {
"types": "./dist/types/media.types.d.ts",
"default": "./dist/types/media.types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/mime": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,70 @@
# Detection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Byte-level and filename-level media type detection. The module pairs the
pluggable `SignatureRegistry` from `signatures/` with the MIME database
exposed by `@suaveplan/mime` so consumers can identify image, video, and
audio artefacts whether they have the bytes, the filename, or both.
## Features
- `detectMediaType(input, opts?)` — magic-byte detection via the registry.
- `detectSignature(input, opts?)` — return the matched `MediaSignature`
record, not just its `type`.
- `detectMediaTypeFromName(filename, opts?)` — MIME-database lookup.
- `inferMimeFromSignature(sig)` — canonical MIME for a matched signature
with a sane fallback when no signature matched.
- `defaultMimeForType(type)` — coarse fallback when only the
category is known.
- Counters and latency histograms emitted on every invocation.
## Usage: Basic
```ts
import { detectMediaType } from "@suaveplan/media";
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d]);
detectMediaType(png); // "image"
```
## Usage: Advanced
```ts
import {
detectSignature,
inferMimeFromSignature,
SignatureRegistry,
} from "@suaveplan/media";
const reg = new SignatureRegistry();
reg.register({
type: "image",
label: "AVIF",
bytes: [0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66],
offset: 4,
});
const sig = detectSignature(myBytes, { registry: reg });
const mime = inferMimeFromSignature(sig); // canonical MIME
```
## API Reference
- `DetectOptions` — `{ registry?, observability? }`.
- `detectMediaType(input, opts?)` — `MediaType`.
- `detectSignature(input, opts?)` — `MediaSignature | null`.
- `detectMediaTypeFromName(filename, opts?)` — `MediaType`.
- `inferMimeFromSignature(sig)` — canonical MIME string.
- `defaultMimeForType(type)` — coarse fallback MIME.
## Implementation Notes
`detectMediaType` and `detectMediaTypeFromName` both record an
`op_latency_ms` histogram entry in a `finally` block so the cost of a
detection call is always measured, even when the registry throws. The
filename pathway uses `@suaveplan/mime`'s `getMimeType` so the database
behind detection stays in lock-step with the rest of the project.

View File

@@ -0,0 +1,271 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createMediaObservability } from "../observability/observability.js";
import { SignatureRegistry } from "../signatures/signatures.js";
import {
defaultMimeForType,
detectMediaType,
detectMediaTypeFromName,
detectSignature,
inferMimeFromSignature,
} from "./detection.js";
describe("detectMediaType", () => {
it("detects PNG from magic bytes", { timeout: 5000 }, () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
expect(detectMediaType(png)).toBe("image");
});
it("detects JPEG", { timeout: 5000 }, () => {
expect(detectMediaType(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]))).toBe(
"image"
);
});
it("detects GIF", { timeout: 5000 }, () => {
expect(detectMediaType(new Uint8Array([0x47, 0x49, 0x46, 0x38]))).toBe(
"image"
);
});
it("detects MP3 ID3", { timeout: 5000 }, () => {
expect(detectMediaType(new Uint8Array([0x49, 0x44, 0x33, 0x04]))).toBe(
"audio"
);
});
it("detects FLAC", { timeout: 5000 }, () => {
expect(detectMediaType(new Uint8Array([0x66, 0x4c, 0x61, 0x43]))).toBe(
"audio"
);
});
it("detects WebM", { timeout: 5000 }, () => {
expect(detectMediaType(new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]))).toBe(
"video"
);
});
it("returns unknown for unrecognised bytes", { timeout: 5000 }, () => {
expect(detectMediaType(new Uint8Array([0x00, 0x01, 0x02]))).toBe(
"unknown"
);
});
it("returns unknown for empty input", { timeout: 5000 }, () => {
expect(detectMediaType(new Uint8Array([]))).toBe("unknown");
});
it("uses injected registry", { timeout: 5000 }, () => {
const reg = new SignatureRegistry([]);
reg.register({ type: "image", label: "X", bytes: [0xde, 0xad] });
expect(
detectMediaType(new Uint8Array([0xde, 0xad]), { registry: reg })
).toBe("image");
});
it("uses injected observability bundle", { timeout: 5000 }, () => {
const obs = createMediaObservability();
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
expect(detectMediaType(png, { observability: obs })).toBe("image");
});
});
describe("detectSignature", () => {
it("returns signature record on match", { timeout: 5000 }, () => {
const sig = detectSignature(new Uint8Array([0x89, 0x50, 0x4e, 0x47]));
expect(sig?.label).toBe("PNG");
});
it("returns null when no match", { timeout: 5000 }, () => {
expect(detectSignature(new Uint8Array([0x00]))).toBeNull();
});
it("honours custom registry", { timeout: 5000 }, () => {
const reg = new SignatureRegistry([]);
reg.register({ type: "image", label: "X", bytes: [0xde, 0xad] });
const sig = detectSignature(new Uint8Array([0xde, 0xad]), {
registry: reg,
});
expect(sig?.label).toBe("X");
});
});
describe("detectMediaTypeFromName", () => {
it("detects image from .png filename", { timeout: 5000 }, () => {
expect(detectMediaTypeFromName("photo.png")).toBe("image");
});
it("detects video from .mp4 filename", { timeout: 5000 }, () => {
expect(detectMediaTypeFromName("clip.mp4")).toBe("video");
});
it("detects audio from .mp3 filename", { timeout: 5000 }, () => {
expect(detectMediaTypeFromName("song.mp3")).toBe("audio");
});
it("returns unknown for unrecognised extension", {
timeout: 5000,
}, () => {
expect(detectMediaTypeFromName("file.xyz")).toBe("unknown");
});
it("uses injected observability", { timeout: 5000 }, () => {
const obs = createMediaObservability();
expect(
detectMediaTypeFromName("photo.png", { observability: obs })
).toBe("image");
});
});
describe("inferMimeFromSignature", () => {
it("returns canonical MIME for PNG signature", { timeout: 5000 }, () => {
expect(
inferMimeFromSignature({
type: "image",
label: "PNG",
bytes: [0x89, 0x50, 0x4e, 0x47],
})
).toBe("image/png");
});
it("returns image/jpeg for JPEG", { timeout: 5000 }, () => {
expect(
inferMimeFromSignature({
type: "image",
label: "JPEG",
bytes: [0xff, 0xd8, 0xff],
})
).toBe("image/jpeg");
});
it("returns image/gif for GIF", { timeout: 5000 }, () => {
expect(
inferMimeFromSignature({
type: "image",
label: "GIF",
bytes: [0x47, 0x49, 0x46],
})
).toBe("image/gif");
});
it("returns image/bmp for BMP", { timeout: 5000 }, () => {
expect(
inferMimeFromSignature({
type: "image",
label: "BMP",
bytes: [0x42, 0x4d],
})
).toBe("image/bmp");
});
it("returns image/webp for WEBP and RIFF labels", {
timeout: 5000,
}, () => {
expect(
inferMimeFromSignature({
type: "image",
label: "WEBP",
bytes: [0x57, 0x45, 0x42, 0x50],
})
).toBe("image/webp");
expect(
inferMimeFromSignature({
type: "image",
label: "RIFF",
bytes: [0x52, 0x49, 0x46, 0x46],
})
).toBe("image/webp");
});
it("returns video/mp4 for MP4/MOV", { timeout: 5000 }, () => {
expect(
inferMimeFromSignature({
type: "video",
label: "MP4/MOV",
bytes: [0x66, 0x74, 0x79, 0x70],
})
).toBe("video/mp4");
});
it("returns video/webm for WebM/MKV", { timeout: 5000 }, () => {
expect(
inferMimeFromSignature({
type: "video",
label: "WebM/MKV",
bytes: [0x1a, 0x45, 0xdf, 0xa3],
})
).toBe("video/webm");
});
it("returns audio/mpeg for MP3 labels", { timeout: 5000 }, () => {
expect(
inferMimeFromSignature({
type: "audio",
label: "MP3-ID3",
bytes: [0x49, 0x44, 0x33],
})
).toBe("audio/mpeg");
expect(
inferMimeFromSignature({
type: "audio",
label: "MP3-sync",
bytes: [0xff, 0xfb],
})
).toBe("audio/mpeg");
});
it("returns audio/flac for FLAC", { timeout: 5000 }, () => {
expect(
inferMimeFromSignature({
type: "audio",
label: "FLAC",
bytes: [0x66, 0x4c, 0x61, 0x43],
})
).toBe("audio/flac");
});
it("returns audio/ogg for OGG", { timeout: 5000 }, () => {
expect(
inferMimeFromSignature({
type: "audio",
label: "OGG",
bytes: [0x4f, 0x67, 0x67, 0x53],
})
).toBe("audio/ogg");
});
it("falls back to default for unknown label", { timeout: 5000 }, () => {
expect(
inferMimeFromSignature({
type: "image",
label: "CUSTOM",
bytes: [0x00],
})
).toBe("image/png");
});
it("falls back to default when label maps to unknown type", {
timeout: 5000,
}, () => {
expect(
inferMimeFromSignature({
type: "unknown",
label: "X",
bytes: [0x00],
})
).toBe("application/octet-stream");
});
it("returns octet-stream for null signature", { timeout: 5000 }, () => {
expect(inferMimeFromSignature(null)).toBe("application/octet-stream");
});
});
describe("defaultMimeForType", () => {
it("returns canonical MIME for each MediaType", { timeout: 5000 }, () => {
expect(defaultMimeForType("image")).toBe("image/png");
expect(defaultMimeForType("video")).toBe("video/mp4");
expect(defaultMimeForType("audio")).toBe("audio/mpeg");
expect(defaultMimeForType("unknown")).toBe("application/octet-stream");
});
});

View File

@@ -0,0 +1,125 @@
import { getMimeType } from "@suaveplan/mime";
import type { MediaObservability } from "../observability/observability.js";
import { createMediaObservability } from "../observability/observability.js";
import type { SignatureRegistry } from "../signatures/signatures.js";
import { defaultSignatureRegistry } from "../signatures/signatures.js";
import type { MediaSignature, MediaType } from "../types/media.types.js";
/** Options for the detection helpers. */
export interface DetectOptions {
/** Custom signature registry (defaults to {@link defaultSignatureRegistry}). */
readonly registry?: SignatureRegistry | undefined;
/** Observability bundle for counters / histograms. */
readonly observability?: MediaObservability | undefined;
}
/**
* Detect the {@link MediaType} of raw bytes by matching against the
* registered magic-byte signatures.
*/
export function detectMediaType(
input: Uint8Array,
opts?: DetectOptions
): MediaType {
const registry = opts?.registry ?? defaultSignatureRegistry;
const obs = opts?.observability ?? createMediaObservability();
const start = performance.now();
try {
return registry.detect(input);
} finally {
obs.counters.detectTotal.add(1, { kind: "bytes" });
obs.histograms.opLatencyMs.record(performance.now() - start, {
op: "detectMediaType",
});
obs.histograms.inputSizeBytes.record(input.byteLength, {
op: "detectMediaType",
});
}
}
/**
* Return the {@link MediaSignature} record that matched `input`, or `null`
* when no signature matches.
*/
export function detectSignature(
input: Uint8Array,
opts?: DetectOptions
): MediaSignature | null {
const registry = opts?.registry ?? defaultSignatureRegistry;
return registry.match(input);
}
/**
* Detect the {@link MediaType} from a filename by looking up its MIME type
* via `@suaveplan/mime` and bucketing by the top-level type.
*/
export function detectMediaTypeFromName(
filename: string,
opts?: DetectOptions
): MediaType {
const obs = opts?.observability ?? createMediaObservability();
const start = performance.now();
try {
const mime = getMimeType(filename);
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
return "unknown";
} finally {
obs.counters.detectTotal.add(1, { kind: "filename" });
obs.histograms.opLatencyMs.record(performance.now() - start, {
op: "detectMediaTypeFromName",
});
}
}
/**
* Best-effort MIME-type resolution for cases where no filename is known:
* derive a canonical MIME from the matched signature (PNG, JPEG, MP4, …),
* falling back to `application/octet-stream` for unknown bytes.
*/
export function inferMimeFromSignature(sig: MediaSignature | null): string {
if (!sig) return "application/octet-stream";
switch (sig.label) {
case "PNG":
return "image/png";
case "JPEG":
return "image/jpeg";
case "GIF":
return "image/gif";
case "BMP":
return "image/bmp";
case "WEBP":
return "image/webp";
case "RIFF":
return "image/webp";
case "MP4/MOV":
return "video/mp4";
case "WebM/MKV":
return "video/webm";
case "MP3-ID3":
return "audio/mpeg";
case "MP3-sync":
return "audio/mpeg";
case "FLAC":
return "audio/flac";
case "OGG":
return "audio/ogg";
default:
return defaultMimeForType(sig.type);
}
}
/** Default MIME for each {@link MediaType}. */
export function defaultMimeForType(type: MediaType): string {
switch (type) {
case "image":
return "image/png";
case "video":
return "video/mp4";
case "audio":
return "audio/mpeg";
default:
return "application/octet-stream";
}
}

View File

@@ -0,0 +1,8 @@
export {
type DetectOptions,
defaultMimeForType,
detectMediaType,
detectMediaTypeFromName,
detectSignature,
inferMimeFromSignature,
} from "./detection.js";

View File

@@ -0,0 +1,67 @@
# Errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the stable error-code registry, per-code metadata table, and the
`MediaError` class that every public entrypoint throws or wraps. Every
failure path in `@suaveplan/media` surfaces one of eight `MEDIA.*` codes
so consumers (loggers, retry policies, telemetry pipelines, UI surfaces)
can classify failures programmatically without parsing message strings.
## Features
- Frozen `MEDIA_CODES` registry created via `defineCodes("MEDIA", […])`.
- Per-code `MediaCodeMetadata` record (`httpStatus`, `retryable`,
`severity`, `description`) frozen at module load.
- `MediaError` extending `SuaveplanError<MediaErrorCode>` — cause chain,
redaction, OTel span-event adapter inherited.
- `asMediaError(code, cause, fallback)` lift helper for `catch` sites.
## Usage: Basic
```ts
import { MediaError, MEDIA_CODES } from "@suaveplan/media";
throw new MediaError(MEDIA_CODES.EMPTY_INPUT, "input is empty");
```
## Usage: Advanced
```ts
import { asMediaError, MEDIA_CODES, MEDIA_ERROR_METADATA } from "@suaveplan/media";
try {
/* boundary work */
} catch (cause) {
const err = asMediaError(
MEDIA_CODES.PREDICATE_FAILED,
cause,
"predicate threw"
);
const meta = MEDIA_ERROR_METADATA[err.code];
if (meta.retryable) {
// schedule retry
}
}
```
## API Reference
- `MEDIA_CODES` — frozen code registry with eight namespaced keys.
- `MediaErrorCode` — union of registry values.
- `MEDIA_ERROR_METADATA` — frozen record keyed by code.
- `MediaCodeMetadata` — per-code metadata shape.
- `MediaError` — concrete `SuaveplanError` subclass with `.metadata` getter.
- `asMediaError(code, cause, fallback)` — lift helper.
## Implementation Notes
`defineCodes` produces namespaced keys (`MEDIA.EMPTY_INPUT`, …). The
metadata table is `Object.freeze`'d at module top level so consumers
cannot tamper at runtime. `asMediaError` preserves the original error as
`.cause` when it's an `Error` instance, otherwise it wraps the stringified
value so the cause chain is always populated.

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
asMediaError,
MEDIA_CODES,
MEDIA_ERROR_METADATA,
MediaError,
} from "./errors.js";
describe("MEDIA_CODES", () => {
it("contains all eight stable codes", { timeout: 5000 }, () => {
const keys = Object.keys(MEDIA_CODES).sort();
expect(keys).toEqual(
[
"EMPTY_INPUT",
"INVALID_CONSTRAINTS",
"VALIDATION_FAILED",
"UNSUPPORTED_TYPE",
"PREDICATE_FAILED",
"DUPLICATE_SIGNATURE",
"METADATA_EXTRACTOR_FAILED",
"INVALID_SIGNATURE",
].sort()
);
});
it("registry is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(MEDIA_CODES)).toBe(true);
});
it("codes carry MEDIA namespace prefix", { timeout: 5000 }, () => {
for (const value of Object.values(MEDIA_CODES)) {
expect(value.startsWith("MEDIA.")).toBe(true);
}
});
});
describe("MEDIA_ERROR_METADATA", () => {
it("has metadata for every code", { timeout: 5000 }, () => {
for (const code of Object.values(MEDIA_CODES)) {
const meta = MEDIA_ERROR_METADATA[code];
expect(meta).toBeDefined();
expect(typeof meta.httpStatus).toBe("number");
expect(["info", "warning", "error"]).toContain(meta.severity);
expect(typeof meta.retryable).toBe("boolean");
expect(meta.description.length).toBeGreaterThan(0);
}
});
it("table is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(MEDIA_ERROR_METADATA)).toBe(true);
});
it("EMPTY_INPUT has 400 status", { timeout: 5000 }, () => {
expect(MEDIA_ERROR_METADATA[MEDIA_CODES.EMPTY_INPUT].httpStatus).toBe(
400
);
});
});
describe("MediaError", () => {
it("constructs with code, message, instanceof Error", {
timeout: 5000,
}, () => {
const err = new MediaError(MEDIA_CODES.EMPTY_INPUT, "boom");
expect(err).toBeInstanceOf(MediaError);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe(MEDIA_CODES.EMPTY_INPUT);
expect(err.message).toBe("boom");
expect(err.name).toBe("MediaError");
});
it("preserves cause through chain", { timeout: 5000 }, () => {
const root = new Error("root");
const err = new MediaError(MEDIA_CODES.VALIDATION_FAILED, "msg", {
cause: root,
});
expect(err.cause).toBe(root);
});
it("exposes metadata via getter", { timeout: 5000 }, () => {
const err = new MediaError(MEDIA_CODES.UNSUPPORTED_TYPE, "x");
expect(err.metadata.httpStatus).toBe(415);
expect(err.metadata.severity).toBe("warning");
});
});
describe("asMediaError", () => {
it("preserves Error cause when wrapping", { timeout: 5000 }, () => {
const root = new Error("root msg");
const err = asMediaError(MEDIA_CODES.PREDICATE_FAILED, root, "fb");
expect(err.code).toBe(MEDIA_CODES.PREDICATE_FAILED);
expect(err.message).toBe("root msg");
expect(err.cause).toBe(root);
});
it("falls back to default message when cause has none", {
timeout: 5000,
}, () => {
const err = asMediaError(MEDIA_CODES.PREDICATE_FAILED, "scalar", "fb");
expect(err.message).toBe("fb");
expect(err.cause).toBeInstanceOf(Error);
expect((err.cause as Error).message).toBe("scalar");
});
it("uses fallback when error message is empty", {
timeout: 5000,
}, () => {
const root = new Error("");
const err = asMediaError(MEDIA_CODES.PREDICATE_FAILED, root, "fb");
expect(err.message).toBe("fb");
});
});

View File

@@ -0,0 +1,132 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/media`. Eight codes cover every
* failure path the package can surface: detection edge cases, validation
* failures, schema rejection, predicate failure, signature-registry misuse,
* unsupported metadata input, and observability bundle failures.
*/
export const MEDIA_CODES = defineCodes("MEDIA", [
"EMPTY_INPUT",
"INVALID_CONSTRAINTS",
"VALIDATION_FAILED",
"UNSUPPORTED_TYPE",
"PREDICATE_FAILED",
"DUPLICATE_SIGNATURE",
"METADATA_EXTRACTOR_FAILED",
"INVALID_SIGNATURE",
] as const);
/** Union of every code surfaced by `@suaveplan/media`. */
export type MediaErrorCode = (typeof MEDIA_CODES)[keyof typeof MEDIA_CODES];
/** Per-code metadata for HTTP / retry / severity classification. */
export interface MediaCodeMetadata {
readonly httpStatus: number;
readonly severity: "info" | "warning" | "error";
readonly retryable: boolean;
readonly description: string;
}
/** Mapping of every {@link MediaErrorCode} to its metadata record. */
export const MEDIA_ERROR_METADATA: Readonly<
Record<MediaErrorCode, MediaCodeMetadata>
> = Object.freeze({
[MEDIA_CODES.EMPTY_INPUT]: {
httpStatus: 400,
severity: "warning",
retryable: false,
description:
"Input byte array was empty; cannot extract metadata or detect signature.",
},
[MEDIA_CODES.INVALID_CONSTRAINTS]: {
httpStatus: 400,
severity: "error",
retryable: false,
description:
"MediaConstraints failed Zod validation; supplied constraints are malformed.",
},
[MEDIA_CODES.VALIDATION_FAILED]: {
httpStatus: 422,
severity: "warning",
retryable: false,
description:
"One or more constraint checks failed; see attached errors array for the list.",
},
[MEDIA_CODES.UNSUPPORTED_TYPE]: {
httpStatus: 415,
severity: "warning",
retryable: false,
description:
"Detected media type is not in the caller's allowed-types list.",
},
[MEDIA_CODES.PREDICATE_FAILED]: {
httpStatus: 500,
severity: "error",
retryable: false,
description:
"A user-supplied constraint predicate threw during validation.",
},
[MEDIA_CODES.DUPLICATE_SIGNATURE]: {
httpStatus: 409,
severity: "error",
retryable: false,
description:
"A signature with the same label is already registered in the SignatureRegistry.",
},
[MEDIA_CODES.METADATA_EXTRACTOR_FAILED]: {
httpStatus: 500,
severity: "error",
retryable: false,
description:
"A user-supplied metadata extractor hook threw during execution.",
},
[MEDIA_CODES.INVALID_SIGNATURE]: {
httpStatus: 400,
severity: "error",
retryable: false,
description:
"Attempted to register a signature whose byte array is empty or contains non-byte values.",
},
});
/**
* Base error class for every failure raised by `@suaveplan/media`. Extends
* {@link SuaveplanError} so consumers receive cause-chain traversal,
* Zod-validated wire format, redaction hooks, and the OTel span-event
* adapter for free.
*/
export class MediaError extends SuaveplanError<MediaErrorCode> {
constructor(
code: MediaErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "MediaError";
}
/** Metadata record for this error's code. */
get metadata(): MediaCodeMetadata {
return MEDIA_ERROR_METADATA[this.code];
}
}
/**
* Lift an unknown thrown value into a {@link MediaError}. Used at every
* `catch` site so call sites can rely on the typed error contract.
*/
export function asMediaError(
code: MediaErrorCode,
cause: unknown,
fallbackMessage: string
): MediaError {
const message =
cause instanceof Error && cause.message
? cause.message
: fallbackMessage;
const causeError =
cause instanceof Error ? cause : new Error(String(cause));
return new MediaError(code, message, { cause: causeError });
}

View File

@@ -0,0 +1,8 @@
export {
asMediaError,
MEDIA_CODES,
MEDIA_ERROR_METADATA,
type MediaCodeMetadata,
MediaError,
type MediaErrorCode,
} from "./errors.js";

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as mod from "./index.js";
describe("@suaveplan/media barrel", () => {
it("exports detection helpers", { timeout: 5000 }, () => {
expect(typeof mod.detectMediaType).toBe("function");
expect(typeof mod.detectMediaTypeFromName).toBe("function");
expect(typeof mod.detectSignature).toBe("function");
expect(typeof mod.inferMimeFromSignature).toBe("function");
expect(typeof mod.defaultMimeForType).toBe("function");
});
it("exports metadata helpers", { timeout: 5000 }, () => {
expect(typeof mod.getMetadata).toBe("function");
expect(typeof mod.getMetadataAsync).toBe("function");
});
it("exports validation helpers", { timeout: 5000 }, () => {
expect(typeof mod.validateMedia).toBe("function");
expect(typeof mod.validateMediaAsync).toBe("function");
expect(typeof mod.assertValidMedia).toBe("function");
});
it("exports error surface", { timeout: 5000 }, () => {
expect(mod.MEDIA_CODES).toBeDefined();
expect(mod.MEDIA_ERROR_METADATA).toBeDefined();
expect(typeof mod.MediaError).toBe("function");
expect(typeof mod.asMediaError).toBe("function");
});
it("exports signature registry surface", { timeout: 5000 }, () => {
expect(typeof mod.SignatureRegistry).toBe("function");
expect(mod.defaultSignatureRegistry).toBeDefined();
expect(Array.isArray(mod.BUILTIN_SIGNATURES)).toBe(true);
});
it("exports observability factory", { timeout: 5000 }, () => {
expect(typeof mod.createMediaObservability).toBe("function");
});
it("exports Zod schemas", { timeout: 5000 }, () => {
expect(mod.MediaTypeSchema).toBeDefined();
expect(mod.MediaConstraintsSchema).toBeDefined();
expect(mod.MediaTypeSchema.parse("image")).toBe("image");
});
});

View File

@@ -0,0 +1,66 @@
/**
* `@suaveplan/media` — universal media detection, metadata, and
* validation.
*
* Re-exports every public symbol from the per-feature modules. Deep
* imports via subpath exports (`./detection/detection`, `./errors/errors`,
* etc.) are also supported for tree-shake-sensitive consumers.
*/
// Detection
export {
type DetectOptions,
defaultMimeForType,
detectMediaType,
detectMediaTypeFromName,
detectSignature,
inferMimeFromSignature,
} from "./detection/detection.js";
// Errors
export {
asMediaError,
MEDIA_CODES,
MEDIA_ERROR_METADATA,
type MediaCodeMetadata,
MediaError,
type MediaErrorCode,
} from "./errors/errors.js";
// Metadata
export {
getMetadata,
getMetadataAsync,
type MetadataOptions,
} from "./metadata/metadata.js";
// Observability
export {
createMediaObservability,
type MediaCounters,
type MediaHistograms,
type MediaObservability,
type MediaObservabilityOptions,
} from "./observability/observability.js";
// Signatures
export {
BUILTIN_SIGNATURES,
defaultSignatureRegistry,
SignatureRegistry,
} from "./signatures/signatures.js";
// Types
export {
type MediaConstraintPredicate,
type MediaConstraints,
MediaConstraintsSchema,
type MediaMetadata,
type MediaMetadataExtractor,
type MediaResult,
type MediaSignature,
type MediaType,
MediaTypeSchema,
} from "./types/media.types.js";
// Validation
export {
assertValidMedia,
type ValidateOptions,
validateMedia,
validateMediaAsync,
} from "./validation/validation.js";

View File

@@ -0,0 +1,5 @@
export {
getMetadata,
getMetadataAsync,
type MetadataOptions,
} from "./metadata.js";

View File

@@ -0,0 +1,65 @@
# Metadata
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds a `MediaMetadata` bundle (type, MIME, size, matched signature
label) from raw bytes. The sync helper produces the base bundle in a
single pass; the async helper layers a user-supplied
`MediaMetadataExtractor` over the base so callers can fill in
format-specific fields (width, height, duration) without forking the
package.
## Features
- `getMetadata(input, opts?)` — synchronous, no extractor.
- `getMetadataAsync(input, opts?)` — awaits the optional extractor and
merges its return into the base bundle.
- MIME resolution prefers the explicit `filename` field; otherwise it
falls back to `inferMimeFromSignature`.
- Signature label exposed via `metadata.signatureLabel` for downstream
diagnostics.
- Extractor errors are wrapped in `MediaError(MEDIA.METADATA_EXTRACTOR_FAILED)`
with the original error chained as `.cause`.
## Usage: Basic
```ts
import { getMetadata } from "@suaveplan/media";
const meta = getMetadata(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
filename: "logo.png",
});
console.log(meta.type, meta.mimeType, meta.size, meta.signatureLabel);
```
## Usage: Advanced
```ts
import { getMetadataAsync } from "@suaveplan/media";
const meta = await getMetadataAsync(buf, {
filename: "clip.mp4",
extractor: async (_bytes, base) => {
const { width, height, duration } = await probe(buf);
return { ...base, width, height, duration };
},
});
```
## API Reference
- `MetadataOptions` — `{ filename?, registry?, observability?, extractor? }`.
- `getMetadata(input, opts?)` — synchronous `MediaMetadata`.
- `getMetadataAsync(input, opts?)` — `Promise<MediaMetadata>`.
## Implementation Notes
The base bundle is built inside a private helper so both entrypoints
share signature detection + MIME resolution. The async variant only
forks off the base when an extractor is supplied, so unused await paths
incur near-zero overhead. Latency histograms are recorded in a
`finally` block so failed extractor calls still produce telemetry.

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MEDIA_CODES, MediaError } from "../errors/errors.js";
import { createMediaObservability } from "../observability/observability.js";
import { getMetadata, getMetadataAsync } from "./metadata.js";
describe("getMetadata", () => {
it("returns type, mime, size, label for PNG bytes", {
timeout: 5000,
}, () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0x00]);
const meta = getMetadata(png, { filename: "x.png" });
expect(meta.type).toBe("image");
expect(meta.mimeType).toBe("image/png");
expect(meta.size).toBe(6);
expect(meta.signatureLabel).toBe("PNG");
});
it("infers mime from signature when no filename", {
timeout: 5000,
}, () => {
const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
const meta = getMetadata(jpeg);
expect(meta.type).toBe("image");
expect(meta.mimeType).toBe("image/jpeg");
expect(meta.size).toBe(4);
});
it("returns unknown + octet-stream for unrecognised bytes", {
timeout: 5000,
}, () => {
const meta = getMetadata(new Uint8Array([0x00]));
expect(meta.type).toBe("unknown");
expect(meta.mimeType).toBe("application/octet-stream");
expect(meta.signatureLabel).toBeUndefined();
});
it("respects injected observability", { timeout: 5000 }, () => {
const obs = createMediaObservability();
const meta = getMetadata(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
observability: obs,
});
expect(meta.type).toBe("image");
});
});
describe("getMetadataAsync", () => {
it("returns base metadata when no extractor", {
timeout: 5000,
}, async () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const meta = await getMetadataAsync(png);
expect(meta.type).toBe("image");
expect(meta.size).toBe(4);
});
it("merges extractor output", { timeout: 5000 }, async () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const meta = await getMetadataAsync(png, {
extractor: async (_input, base) => ({
...base,
width: 100,
height: 50,
}),
});
expect(meta.width).toBe(100);
expect(meta.height).toBe(50);
expect(meta.type).toBe("image");
});
it("wraps extractor error in MediaError", { timeout: 5000 }, async () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
try {
await getMetadataAsync(png, {
extractor: () => {
throw new Error("boom");
},
});
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(MediaError);
expect((err as MediaError).code).toBe(
MEDIA_CODES.METADATA_EXTRACTOR_FAILED
);
expect((err as MediaError).cause).toBeInstanceOf(Error);
}
});
it("uses injected observability", { timeout: 5000 }, async () => {
const obs = createMediaObservability();
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const meta = await getMetadataAsync(png, { observability: obs });
expect(meta.type).toBe("image");
});
});

View File

@@ -0,0 +1,111 @@
import { getMimeType } from "@suaveplan/mime";
import {
detectMediaType,
detectSignature,
inferMimeFromSignature,
} from "../detection/detection.js";
import { asMediaError, MEDIA_CODES } from "../errors/errors.js";
import type { MediaObservability } from "../observability/observability.js";
import { createMediaObservability } from "../observability/observability.js";
import type { SignatureRegistry } from "../signatures/signatures.js";
import type {
MediaMetadata,
MediaMetadataExtractor,
} from "../types/media.types.js";
/** Options for the metadata helpers. */
export interface MetadataOptions {
/** Original filename, used to refine MIME-type detection. */
readonly filename?: string | undefined;
/** Custom signature registry. */
readonly registry?: SignatureRegistry | undefined;
/** Observability bundle. */
readonly observability?: MediaObservability | undefined;
/** Optional async hook to extend metadata with format-specific fields. */
readonly extractor?: MediaMetadataExtractor | undefined;
}
function buildBase(
input: Uint8Array,
opts: MetadataOptions | undefined
): MediaMetadata {
const sig = detectSignature(input, {
registry: opts?.registry,
observability: opts?.observability,
});
const type = sig?.type ?? "unknown";
const mimeType = opts?.filename
? getMimeType(opts.filename)
: inferMimeFromSignature(sig);
if (sig !== null) {
return {
type,
mimeType,
size: input.byteLength,
signatureLabel: sig.label,
};
}
return { type, mimeType, size: input.byteLength };
}
/**
* Extract synchronous metadata from media bytes. Detects the media type via
* the signature registry and resolves a MIME type either from the supplied
* filename or from the matched signature.
*/
export function getMetadata(
input: Uint8Array,
opts?: MetadataOptions
): MediaMetadata {
const obs = opts?.observability ?? createMediaObservability();
const start = performance.now();
try {
// Touch detectMediaType for shape consistency with prior surface.
detectMediaType(input, {
registry: opts?.registry,
observability: obs,
});
return buildBase(input, opts);
} finally {
obs.counters.metadataTotal.add(1, { mode: "sync" });
obs.histograms.opLatencyMs.record(performance.now() - start, {
op: "getMetadata",
});
}
}
/**
* Async variant of {@link getMetadata} that runs the optional
* `MediaMetadataExtractor` hook so callers can layer format-specific
* fields (width, height, duration, codec, …) onto the base metadata.
*
* If the extractor throws, the error is wrapped in a `MediaError` with
* code `MEDIA.METADATA_EXTRACTOR_FAILED` and the original error chained
* as `.cause`.
*/
export async function getMetadataAsync(
input: Uint8Array,
opts?: MetadataOptions
): Promise<MediaMetadata> {
const obs = opts?.observability ?? createMediaObservability();
const start = performance.now();
try {
const base = buildBase(input, opts);
if (!opts?.extractor) return base;
try {
const extra = await opts.extractor(input, base);
return { ...base, ...extra };
} catch (cause) {
throw asMediaError(
MEDIA_CODES.METADATA_EXTRACTOR_FAILED,
cause,
"metadata extractor threw"
);
}
} finally {
obs.counters.metadataTotal.add(1, { mode: "async" });
obs.histograms.opLatencyMs.record(performance.now() - start, {
op: "getMetadataAsync",
});
}
}

View File

@@ -0,0 +1,9 @@
export {
createMediaObservability,
type MediaCounters,
type MediaHistograms,
type MediaObservability,
type MediaObservabilityOptions,
type Meter,
type Tracer,
} from "./observability.js";

View File

@@ -0,0 +1,60 @@
# Observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Constructs the OpenTelemetry instrument bundle threaded through every
public entrypoint in `@suaveplan/media`. Each detection, metadata, and
validation call increments a counter, records a latency histogram, and
optionally emits a span. Consumers can inject their own meter / tracer
to wire instruments into a shared telemetry pipeline.
## Features
- Four counters: `media_detect_total`, `media_metadata_total`,
`media_validate_total`, `media_error_total`.
- Two histograms: `media_op_latency_ms`, `media_input_size_bytes`.
- Injectable meter / tracer or a pre-built `MediaObservability`.
- Falls back to the global no-op providers when nothing is supplied,
keeping the bundle safe to construct from library code.
## Usage: Basic
```ts
import { createMediaObservability } from "@suaveplan/media";
const obs = createMediaObservability();
obs.counters.detectTotal.add(1, { kind: "magic-bytes" });
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { createMediaObservability } from "@suaveplan/media";
const meter = metrics.getMeter("my-app");
const obs = createMediaObservability({ meter });
obs.histograms.opLatencyMs.record(12.4, { op: "validateMedia" });
obs.counters.errorTotal.add(1, { code: "MEDIA.VALIDATION_FAILED" });
```
## API Reference
- `MediaCounters` — counter set.
- `MediaHistograms` — histogram set.
- `MediaObservability` — bundle.
- `MediaObservabilityOptions` — `{ meter?, tracer?, observability? }`.
- `createMediaObservability(opts?)` — bundle factory.
## Implementation Notes
When `opts.observability` is supplied, it short-circuits before any
instrument creation so injected bundles round-trip without mutation.
Otherwise the function resolves a meter and tracer (defaulting to the
global providers) and creates instruments via the helpers in
`@suaveplan/opentelemetry` so naming + unit conventions stay consistent
across the project.

View File

@@ -0,0 +1,45 @@
import { metrics, trace } from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createMediaObservability } from "./observability.js";
describe("createMediaObservability", () => {
it("returns a fully-populated bundle with global providers by default", {
timeout: 5000,
}, () => {
const obs = createMediaObservability();
expect(obs.meter).toBeDefined();
expect(obs.tracer).toBeDefined();
expect(obs.counters.detectTotal).toBeDefined();
expect(obs.counters.metadataTotal).toBeDefined();
expect(obs.counters.validateTotal).toBeDefined();
expect(obs.counters.errorTotal).toBeDefined();
expect(obs.histograms.opLatencyMs).toBeDefined();
expect(obs.histograms.inputSizeBytes).toBeDefined();
});
it("uses an injected meter when supplied", { timeout: 5000 }, () => {
const meter = metrics.getMeter("media-test");
const obs = createMediaObservability({ meter });
expect(obs.meter).toBe(meter);
});
it("uses an injected tracer when supplied", { timeout: 5000 }, () => {
const tracer = trace.getTracer("media-test");
const obs = createMediaObservability({ tracer });
expect(obs.tracer).toBe(tracer);
});
it("short-circuits when a pre-built observability bundle is given", {
timeout: 5000,
}, () => {
const first = createMediaObservability();
const second = createMediaObservability({ observability: first });
expect(second).toBe(first);
});
it("counter add does not throw on no-op meter", { timeout: 5000 }, () => {
const obs = createMediaObservability();
expect(() => obs.counters.detectTotal.add(1)).not.toThrow();
expect(() => obs.histograms.opLatencyMs.record(5)).not.toThrow();
});
});

View File

@@ -0,0 +1,96 @@
import type { Counter, Histogram, Meter, Tracer } from "@opentelemetry/api";
import { metrics, trace } from "@opentelemetry/api";
import { createCounter, createHistogram } from "@suaveplan/opentelemetry";
const METER_NAME = "@suaveplan/media";
const TRACER_NAME = "@suaveplan/media";
/** Counter instruments exposed by the observability bundle. */
export interface MediaCounters {
readonly detectTotal: Counter;
readonly metadataTotal: Counter;
readonly validateTotal: Counter;
readonly errorTotal: Counter;
}
/** Histogram instruments exposed by the observability bundle. */
export interface MediaHistograms {
readonly opLatencyMs: Histogram;
readonly inputSizeBytes: Histogram;
}
/**
* Observability bundle threaded through every public entrypoint. Holds the
* meter, tracer, and the strongly-typed instruments used by the package.
*/
export interface MediaObservability {
readonly meter: Meter;
readonly tracer: Tracer;
readonly counters: MediaCounters;
readonly histograms: MediaHistograms;
}
/** Options for constructing a {@link MediaObservability} bundle. */
export interface MediaObservabilityOptions {
readonly meter?: Meter | undefined;
readonly tracer?: Tracer | undefined;
readonly observability?: MediaObservability | undefined;
}
/**
* Create a {@link MediaObservability} bundle on the supplied meter / tracer,
* or on the no-op providers from `@opentelemetry/api` when none are
* supplied. Safe to call from every entrypoint even when no telemetry
* pipeline is wired up.
*/
export function createMediaObservability(
opts?: MediaObservabilityOptions
): MediaObservability {
if (opts?.observability !== undefined) {
return opts.observability;
}
const meter: Meter = opts?.meter ?? metrics.getMeter(METER_NAME);
const tracer: Tracer = opts?.tracer ?? trace.getTracer(TRACER_NAME);
return {
meter,
tracer,
counters: {
detectTotal: createCounter(
meter,
"media_detect_total",
"Total media type detection operations (byte + filename)."
),
metadataTotal: createCounter(
meter,
"media_metadata_total",
"Total getMetadata invocations (sync + async)."
),
validateTotal: createCounter(
meter,
"media_validate_total",
"Total validateMedia invocations (success + failure)."
),
errorTotal: createCounter(
meter,
"media_error_total",
"Total media errors, partitioned by MEDIA.* error code."
),
},
histograms: {
opLatencyMs: createHistogram(
meter,
"media_op_latency_ms",
"Media operation latency in milliseconds.",
"ms"
),
inputSizeBytes: createHistogram(
meter,
"media_input_size_bytes",
"Media input size in bytes.",
"By"
),
},
};
}
export type { Meter, Tracer };

View File

@@ -0,0 +1,5 @@
export {
BUILTIN_SIGNATURES,
defaultSignatureRegistry,
SignatureRegistry,
} from "./signatures.js";

View File

@@ -0,0 +1,71 @@
# Signatures
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the magic-byte signature registry that powers byte-level detection.
The module ships a frozen `BUILTIN_SIGNATURES` list covering common image
(PNG, JPEG, GIF, BMP, WEBP, RIFF), video (MP4/MOV, WebM/MKV), and audio
(MP3, FLAC, OGG) formats, plus a mutable `SignatureRegistry` class that
lets downstream consumers add or remove entries without forking the
package.
## Features
- Frozen built-in registry covering twelve canonical formats.
- `SignatureRegistry` class with `register`, `unregister`, `list`,
`match`, and `detect` methods.
- Per-entry `offset` so ftyp-style signatures (MP4) and RIFF chunk markers
(WebP) can match at non-zero positions.
- Duplicate-label detection: `register` throws
`MEDIA.DUPLICATE_SIGNATURE` if a second entry uses the same label.
- Byte validation: registration rejects empty byte arrays and non-byte
numbers with `MEDIA.INVALID_SIGNATURE`.
- Process-wide `defaultSignatureRegistry` seeded with the built-ins.
## Usage: Basic
```ts
import { defaultSignatureRegistry } from "@suaveplan/media";
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
const sig = defaultSignatureRegistry.match(png);
console.log(sig?.label); // "PNG"
```
## Usage: Advanced
```ts
import { SignatureRegistry } from "@suaveplan/media";
const reg = new SignatureRegistry();
reg.register({
type: "image",
label: "AVIF",
bytes: [0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66],
offset: 4,
});
const type = reg.detect(myBytes);
```
## API Reference
- `BUILTIN_SIGNATURES` — readonly array of twelve canonical signatures.
- `SignatureRegistry` — mutable registry class.
- `register(sig)` — add entry; throws on duplicate label / invalid bytes.
- `unregister(label)` — remove by label; returns whether removed.
- `list()` — frozen snapshot.
- `match(input)` — first matching `MediaSignature` or `null`.
- `detect(input)` — `MediaType`, defaulting to `"unknown"`.
- `defaultSignatureRegistry` — process-wide default.
## Implementation Notes
`match` honours each signature's `offset` so non-zero-anchored formats
work correctly. The built-in list orders longer / more-specific
signatures before shorter ones (RIFF is listed after WEBP because the
WEBP marker lives inside a RIFF container at offset 8). The registry's
internal array is private so external mutation cannot break ordering.

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MEDIA_CODES, MediaError } from "../errors/errors.js";
import {
BUILTIN_SIGNATURES,
defaultSignatureRegistry,
SignatureRegistry,
} from "./signatures.js";
describe("BUILTIN_SIGNATURES", () => {
it("includes PNG, JPEG, MP3, FLAC, OGG, WebM, MP4, WEBP", {
timeout: 5000,
}, () => {
const labels = BUILTIN_SIGNATURES.map((s) => s.label);
for (const expected of [
"PNG",
"JPEG",
"MP3-ID3",
"FLAC",
"OGG",
"WebM/MKV",
"MP4/MOV",
"WEBP",
]) {
expect(labels).toContain(expected);
}
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(BUILTIN_SIGNATURES)).toBe(true);
});
});
describe("SignatureRegistry", () => {
it("detects PNG via default registry", { timeout: 5000 }, () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00]);
expect(defaultSignatureRegistry.detect(png)).toBe("image");
});
it("returns 'unknown' for unrecognised bytes", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0x00, 0x01, 0x02]);
expect(defaultSignatureRegistry.detect(bytes)).toBe("unknown");
});
it("match returns the signature record", { timeout: 5000 }, () => {
const flac = new Uint8Array([0x66, 0x4c, 0x61, 0x43, 0x00]);
const sig = defaultSignatureRegistry.match(flac);
expect(sig?.label).toBe("FLAC");
expect(sig?.type).toBe("audio");
});
it("match returns null for empty input", { timeout: 5000 }, () => {
expect(defaultSignatureRegistry.match(new Uint8Array([]))).toBeNull();
});
it("honours non-zero offset (MP4 ftyp)", { timeout: 5000 }, () => {
// 4 padding bytes then 'ftyp' (0x66 0x74 0x79 0x70)
const mp4 = new Uint8Array([
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f,
0x6d,
]);
expect(defaultSignatureRegistry.detect(mp4)).toBe("video");
});
it("ignores signature when input shorter than offset+bytes", {
timeout: 5000,
}, () => {
const short = new Uint8Array([0x00]);
expect(defaultSignatureRegistry.detect(short)).toBe("unknown");
});
it("can register custom signature", { timeout: 5000 }, () => {
const reg = new SignatureRegistry([]);
reg.register({ type: "image", label: "X", bytes: [0xde, 0xad] });
const detected = reg.detect(new Uint8Array([0xde, 0xad, 0xbe]));
expect(detected).toBe("image");
});
it("unregister removes signature and returns true", {
timeout: 5000,
}, () => {
const reg = new SignatureRegistry();
expect(reg.unregister("PNG")).toBe(true);
expect(reg.detect(new Uint8Array([0x89, 0x50, 0x4e, 0x47]))).toBe(
"unknown"
);
});
it("unregister returns false for unknown label", {
timeout: 5000,
}, () => {
const reg = new SignatureRegistry();
expect(reg.unregister("NOPE")).toBe(false);
});
it("list returns frozen snapshot", { timeout: 5000 }, () => {
const reg = new SignatureRegistry();
const snap = reg.list();
expect(Object.isFrozen(snap)).toBe(true);
expect(snap.length).toBe(BUILTIN_SIGNATURES.length);
});
it("register rejects empty byte array", { timeout: 5000 }, () => {
const reg = new SignatureRegistry([]);
expect(() =>
reg.register({ type: "image", label: "E", bytes: [] })
).toThrow(MediaError);
});
it("register rejects non-byte values", { timeout: 5000 }, () => {
const reg = new SignatureRegistry([]);
try {
reg.register({ type: "image", label: "N", bytes: [256] });
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(MediaError);
expect((err as MediaError).code).toBe(
MEDIA_CODES.INVALID_SIGNATURE
);
}
});
it("register rejects negative byte values", { timeout: 5000 }, () => {
const reg = new SignatureRegistry([]);
expect(() =>
reg.register({ type: "image", label: "N", bytes: [-1] })
).toThrow(MediaError);
});
it("register rejects non-integer byte values", { timeout: 5000 }, () => {
const reg = new SignatureRegistry([]);
expect(() =>
reg.register({ type: "image", label: "N", bytes: [1.5] })
).toThrow(MediaError);
});
it("rejects duplicate label", { timeout: 5000 }, () => {
const reg = new SignatureRegistry();
try {
reg.register({ type: "image", label: "PNG", bytes: [0x00] });
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(MediaError);
expect((err as MediaError).code).toBe(
MEDIA_CODES.DUPLICATE_SIGNATURE
);
}
});
});

View File

@@ -0,0 +1,120 @@
import { MEDIA_CODES, MediaError } from "../errors/errors.js";
import type { MediaSignature, MediaType } from "../types/media.types.js";
/**
* Built-in magic-byte signatures covering common image, video, and audio
* formats. The list is order-significant: longer / more-specific signatures
* are listed before shorter ones so the first match wins.
*/
export const BUILTIN_SIGNATURES: readonly MediaSignature[] = Object.freeze([
// Images
{ type: "image", label: "PNG", bytes: [0x89, 0x50, 0x4e, 0x47] },
{ type: "image", label: "JPEG", bytes: [0xff, 0xd8, 0xff] },
{ type: "image", label: "GIF", bytes: [0x47, 0x49, 0x46] },
{ type: "image", label: "BMP", bytes: [0x42, 0x4d] },
{
type: "image",
label: "WEBP",
bytes: [0x57, 0x45, 0x42, 0x50],
offset: 8,
},
{
type: "image",
label: "RIFF",
bytes: [0x52, 0x49, 0x46, 0x46],
},
// Video
{ type: "video", label: "WebM/MKV", bytes: [0x1a, 0x45, 0xdf, 0xa3] },
{
type: "video",
label: "MP4/MOV",
bytes: [0x66, 0x74, 0x79, 0x70],
offset: 4,
},
// Audio
{ type: "audio", label: "MP3-ID3", bytes: [0x49, 0x44, 0x33] },
{ type: "audio", label: "MP3-sync", bytes: [0xff, 0xfb] },
{ type: "audio", label: "FLAC", bytes: [0x66, 0x4c, 0x61, 0x43] },
{ type: "audio", label: "OGG", bytes: [0x4f, 0x67, 0x67, 0x53] },
]);
/**
* In-memory registry of magic-byte signatures. The default registry is seeded
* with {@link BUILTIN_SIGNATURES}, but consumers can construct an isolated
* registry and register or unregister entries at will.
*/
export class SignatureRegistry {
private readonly signatures: MediaSignature[] = [];
private readonly labels = new Set<string>();
constructor(initial: readonly MediaSignature[] = BUILTIN_SIGNATURES) {
for (const sig of initial) {
this.register(sig);
}
}
/** Register an additional signature. Throws on duplicate label. */
register(sig: MediaSignature): void {
if (sig.bytes.length === 0) {
throw new MediaError(
MEDIA_CODES.INVALID_SIGNATURE,
`signature '${sig.label}' has empty byte array`
);
}
for (const b of sig.bytes) {
if (!Number.isInteger(b) || b < 0 || b > 0xff) {
throw new MediaError(
MEDIA_CODES.INVALID_SIGNATURE,
`signature '${sig.label}' contains non-byte value ${b}`
);
}
}
if (this.labels.has(sig.label)) {
throw new MediaError(
MEDIA_CODES.DUPLICATE_SIGNATURE,
`signature with label '${sig.label}' already registered`
);
}
this.signatures.push(sig);
this.labels.add(sig.label);
}
/** Remove a signature by label. Returns `true` when one was removed. */
unregister(label: string): boolean {
const idx = this.signatures.findIndex((s) => s.label === label);
if (idx === -1) return false;
this.signatures.splice(idx, 1);
this.labels.delete(label);
return true;
}
/** Return a frozen snapshot of all registered signatures. */
list(): readonly MediaSignature[] {
return Object.freeze([...this.signatures]);
}
/** Find the first matching signature for `input`, or `null`. */
match(input: Uint8Array): MediaSignature | null {
for (const sig of this.signatures) {
const offset = sig.offset ?? 0;
if (input.length < offset + sig.bytes.length) continue;
let ok = true;
for (let i = 0; i < sig.bytes.length; i++) {
if (input[offset + i] !== sig.bytes[i]) {
ok = false;
break;
}
}
if (ok) return sig;
}
return null;
}
/** Resolve the type of `input` via the first matching signature. */
detect(input: Uint8Array): MediaType {
return this.match(input)?.type ?? "unknown";
}
}
/** Process-wide default registry. */
export const defaultSignatureRegistry = new SignatureRegistry();

View File

@@ -0,0 +1,11 @@
export {
type MediaConstraintPredicate,
type MediaConstraints,
MediaConstraintsSchema,
type MediaMetadata,
type MediaMetadataExtractor,
type MediaResult,
type MediaSignature,
type MediaType,
MediaTypeSchema,
} from "./media.types.js";

View File

@@ -0,0 +1,69 @@
# Media Types
## Purpose
Defines every public type and Zod schema exported by `@suaveplan/media`.
These contracts pin the package's surface so downstream consumers can write
runtime-validated boundaries and compile against stable shapes. Types
covered include `MediaType`, `MediaSignature`, `MediaMetadata`,
`MediaConstraints`, `MediaResult`, the constraint predicate, and the async
metadata-extractor hook.
## Features
- `MediaType` discriminated union (`image | video | audio | unknown`).
- `MediaTypeSchema` and `MediaConstraintsSchema` Zod parsers for boundary
validation.
- `MediaSignature` model for magic-byte registry entries with optional
non-zero offset.
- Extended `MediaConstraints` (size floor + ceiling, MIME allowlist,
duration ceiling, custom predicate).
- `MediaMetadataExtractor` hook used by the metadata pipeline.
## Usage: Basic
```ts
import { MediaTypeSchema, type MediaMetadata } from "@suaveplan/media";
const parsed = MediaTypeSchema.parse("image");
const meta: MediaMetadata = {
type: parsed,
mimeType: "image/png",
size: 1024,
};
```
## Usage: Advanced
```ts
import {
MediaConstraintsSchema,
type MediaConstraints,
} from "@suaveplan/media";
const constraints: MediaConstraints = MediaConstraintsSchema.parse({
maxSize: 2_000_000,
allowedTypes: ["image"],
allowedMimeTypes: ["image/png", "image/jpeg"],
predicate: (m) => (m.size > 0 ? null : "empty"),
});
```
## API Reference
- `MediaType` — recognised media categories.
- `MediaTypeSchema` — Zod enum for `MediaType`.
- `MediaSignature` — magic-byte registry entry.
- `MediaMetadata` — extracted metadata bundle.
- `MediaConstraints` / `MediaConstraintsSchema` — validation rule set.
- `MediaResult` — validation result with errors array.
- `MediaConstraintPredicate` — custom validation function.
- `MediaMetadataExtractor` — async metadata extension hook.
## Implementation Notes
All schemas use `z` default-imported per Bun-safety. The predicate field
in `MediaConstraintsSchema` uses `z.custom` because Zod cannot statically
describe arbitrary function shapes. Arrays surface as `readonly` arrays in
TypeScript so consumers cannot mutate constraint collections after
construction.

View File

@@ -0,0 +1,85 @@
/**
* Public type contracts for `@suaveplan/media`. Every interface, union, and
* Zod schema below is part of the package's stable surface.
*/
import z from "zod";
/** Recognised media categories returned by detection helpers. */
export type MediaType = "image" | "video" | "audio" | "unknown";
/** Zod schema for {@link MediaType}. */
export const MediaTypeSchema = z.enum(["image", "video", "audio", "unknown"]);
/**
* Single magic-byte signature entry. The `bytes` array is matched against the
* first `bytes.length` bytes of the candidate input. `offset` defaults to `0`
* for ftyp-style signatures requiring matches at non-zero positions.
*/
export interface MediaSignature {
readonly type: MediaType;
readonly bytes: readonly number[];
readonly offset?: number;
readonly label: string;
}
/** Metadata extracted from a media artefact. */
export interface MediaMetadata {
readonly type: MediaType;
readonly mimeType: string;
readonly width?: number | undefined;
readonly height?: number | undefined;
readonly duration?: number | undefined;
readonly size: number;
readonly signatureLabel?: string | undefined;
}
/** Custom predicate run during validation. Return `null` for pass, string error otherwise. */
export type MediaConstraintPredicate = (
metadata: MediaMetadata,
input: Uint8Array
) => string | null | Promise<string | null>;
/** Constraint set applied by `validateMedia` / `validateMediaAsync`. */
export interface MediaConstraints {
readonly minSize?: number | undefined;
readonly maxSize?: number | undefined;
readonly allowedTypes?: readonly MediaType[] | undefined;
readonly allowedMimeTypes?: readonly string[] | undefined;
readonly maxWidth?: number | undefined;
readonly maxHeight?: number | undefined;
readonly minWidth?: number | undefined;
readonly minHeight?: number | undefined;
readonly maxDuration?: number | undefined;
readonly predicate?: MediaConstraintPredicate | undefined;
}
/** Zod schema for {@link MediaConstraints}. */
export const MediaConstraintsSchema = z.object({
minSize: z.number().int().nonnegative().optional(),
maxSize: z.number().int().positive().optional(),
allowedTypes: z.array(MediaTypeSchema).readonly().optional(),
allowedMimeTypes: z.array(z.string().min(1)).readonly().optional(),
maxWidth: z.number().int().positive().optional(),
maxHeight: z.number().int().positive().optional(),
minWidth: z.number().int().nonnegative().optional(),
minHeight: z.number().int().nonnegative().optional(),
maxDuration: z.number().positive().optional(),
predicate: z
.custom<MediaConstraintPredicate>((v) => typeof v === "function")
.optional(),
});
/** Validation result returned from `validateMedia`. */
export interface MediaResult {
readonly type: MediaType;
readonly metadata: MediaMetadata;
readonly valid: boolean;
readonly errors: readonly string[];
}
/** Metadata-extractor hook. Returns partial metadata to merge into the base. */
export type MediaMetadataExtractor = (
input: Uint8Array,
base: MediaMetadata
) => Partial<MediaMetadata> | Promise<Partial<MediaMetadata>>;

View File

@@ -0,0 +1,6 @@
export {
assertValidMedia,
type ValidateOptions,
validateMedia,
validateMediaAsync,
} from "./validation.js";

View File

@@ -0,0 +1,78 @@
# Validation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Validates media bytes against a `MediaConstraints` rule set covering
size floors and ceilings, type allowlists, MIME allowlists, width /
height / duration limits, and an optional custom predicate. The module
exposes a synchronous helper for cheap byte-level checks and an async
helper that also honours the constraint predicate plus the metadata
extractor hook.
## Features
- `validateMedia(input, constraints, opts?)` — sync validation.
- `validateMediaAsync(input, constraints, opts?)` — async validation
that runs the predicate hook.
- `assertValidMedia(...)` — throwing variant that yields
`MediaError(MEDIA.VALIDATION_FAILED)` on failure.
- Zod-validated constraints — malformed input throws
`MediaError(MEDIA.INVALID_CONSTRAINTS)`; use `skipConstraintValidation`
to opt out on hot paths.
- Predicate failure wrapping — caller exceptions surface as
`MediaError(MEDIA.PREDICATE_FAILED)` with the original error chained.
- OTel `media_validate_total` counter + `media_op_latency_ms` histogram
emitted on every invocation.
## Usage: Basic
```ts
import { validateMedia } from "@suaveplan/media";
const result = validateMedia(myBytes, {
maxSize: 1_000_000,
allowedTypes: ["image"],
});
if (!result.valid) {
console.warn(result.errors);
}
```
## Usage: Advanced
```ts
import { validateMediaAsync } from "@suaveplan/media";
const result = await validateMediaAsync(myBytes, {
maxSize: 5_000_000,
allowedTypes: ["image", "video"],
allowedMimeTypes: ["image/png", "video/mp4"],
maxWidth: 4096,
maxHeight: 4096,
maxDuration: 60,
predicate: async (meta) =>
meta.size === 0 ? "empty payload" : null,
});
```
## API Reference
- `ValidateOptions` — extends `MetadataOptions` with
`skipConstraintValidation`.
- `validateMedia(input, constraints, opts?)` — sync `MediaResult`.
- `validateMediaAsync(input, constraints, opts?)` —
`Promise<MediaResult>`.
- `assertValidMedia(input, constraints, opts?)` — `MediaMetadata`,
throws on validation failure.
## Implementation Notes
`checkConstraints` is shared between the sync and async paths so the
rule semantics are identical. Empty input is rejected up-front with
`MEDIA.EMPTY_INPUT` before any metadata work happens. The predicate
runs after every other check so callers can rely on the base errors
being populated first.

View File

@@ -0,0 +1,255 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MEDIA_CODES, MediaError } from "../errors/errors.js";
import { createMediaObservability } from "../observability/observability.js";
import {
assertValidMedia,
validateMedia,
validateMediaAsync,
} from "./validation.js";
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
describe("validateMedia", () => {
it("passes when all constraints met", { timeout: 5000 }, () => {
const result = validateMedia(PNG, {
maxSize: 1024,
allowedTypes: ["image"],
});
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.type).toBe("image");
});
it("rejects empty input with MediaError", { timeout: 5000 }, () => {
try {
validateMedia(new Uint8Array([]), { maxSize: 1024 });
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(MediaError);
expect((err as MediaError).code).toBe(MEDIA_CODES.EMPTY_INPUT);
}
});
it("fails when file below minSize", { timeout: 5000 }, () => {
const r = validateMedia(PNG, { minSize: 1000 });
expect(r.valid).toBe(false);
expect(r.errors[0]).toContain("below minimum");
});
it("fails when file exceeds maxSize", { timeout: 5000 }, () => {
const big = new Uint8Array(100);
big[0] = 0x89;
big[1] = 0x50;
big[2] = 0x4e;
big[3] = 0x47;
const r = validateMedia(big, { maxSize: 50 });
expect(r.valid).toBe(false);
expect(r.errors[0]).toContain("exceeds maximum");
});
it("fails when type not in allowedTypes", { timeout: 5000 }, () => {
const r = validateMedia(PNG, { allowedTypes: ["video"] });
expect(r.valid).toBe(false);
expect(r.errors[0]).toContain("not in allowed types");
});
it("fails when MIME not in allowedMimeTypes", { timeout: 5000 }, () => {
const r = validateMedia(PNG, {
allowedMimeTypes: ["image/jpeg"],
});
expect(r.valid).toBe(false);
expect(r.errors[0]).toContain("not in allowed mime types");
});
it("reports multiple errors at once", { timeout: 5000 }, () => {
const big = new Uint8Array(100);
big[0] = 0x89;
big[1] = 0x50;
big[2] = 0x4e;
big[3] = 0x47;
const r = validateMedia(big, {
maxSize: 50,
allowedTypes: ["video"],
});
expect(r.valid).toBe(false);
expect(r.errors).toHaveLength(2);
});
it("throws INVALID_CONSTRAINTS for malformed input", {
timeout: 5000,
}, () => {
try {
// maxSize negative -> Zod rejects
validateMedia(PNG, { maxSize: -1 });
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(MediaError);
expect((err as MediaError).code).toBe(
MEDIA_CODES.INVALID_CONSTRAINTS
);
}
});
it("skips Zod check when skipConstraintValidation: true", {
timeout: 5000,
}, () => {
// Negative maxSize would normally reject; skip allows pass-through.
const r = validateMedia(PNG, { maxSize: 1000 } as never, {
skipConstraintValidation: true,
});
expect(r.valid).toBe(true);
});
it("respects injected observability bundle", { timeout: 5000 }, () => {
const obs = createMediaObservability();
const r = validateMedia(PNG, {}, { observability: obs });
expect(r.valid).toBe(true);
});
it("validates width/height ceilings via extractor (sync path skips)", {
timeout: 5000,
}, () => {
// Sync path cannot run extractor; width/height ceilings only fire if
// metadata has width/height. With no extractor, no width/height -> pass.
const r = validateMedia(PNG, { maxWidth: 10 });
expect(r.valid).toBe(true);
});
});
describe("validateMediaAsync", () => {
it("returns valid result for compliant payload", {
timeout: 5000,
}, async () => {
const r = await validateMediaAsync(PNG, { maxSize: 1024 });
expect(r.valid).toBe(true);
});
it("runs predicate and reports its error string", {
timeout: 5000,
}, async () => {
const r = await validateMediaAsync(PNG, {
predicate: () => "custom rule failed",
});
expect(r.valid).toBe(false);
expect(r.errors).toContain("custom rule failed");
});
it("predicate returning null passes", { timeout: 5000 }, async () => {
const r = await validateMediaAsync(PNG, {
predicate: async () => null,
});
expect(r.valid).toBe(true);
});
it("predicate throwing yields PREDICATE_FAILED", {
timeout: 5000,
}, async () => {
try {
await validateMediaAsync(PNG, {
predicate: () => {
throw new Error("boom");
},
});
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(MediaError);
expect((err as MediaError).code).toBe(MEDIA_CODES.PREDICATE_FAILED);
}
});
it("rejects empty input", { timeout: 5000 }, async () => {
try {
await validateMediaAsync(new Uint8Array([]), {});
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(MediaError);
expect((err as MediaError).code).toBe(MEDIA_CODES.EMPTY_INPUT);
}
});
it("checks width/height via async extractor", {
timeout: 5000,
}, async () => {
const r = await validateMediaAsync(
PNG,
{ maxWidth: 50, minWidth: 10, maxHeight: 50, minHeight: 10 },
{
extractor: async (_b, base) => ({
...base,
width: 100,
height: 100,
}),
}
);
expect(r.valid).toBe(false);
expect(r.errors.some((e) => e.includes("Width"))).toBe(true);
expect(r.errors.some((e) => e.includes("Height"))).toBe(true);
});
it("flags width/height below minimum", {
timeout: 5000,
}, async () => {
const r = await validateMediaAsync(
PNG,
{ minWidth: 100, minHeight: 100 },
{
extractor: async (_b, base) => ({
...base,
width: 10,
height: 10,
}),
}
);
expect(r.valid).toBe(false);
expect(r.errors.some((e) => e.includes("below minimum"))).toBe(true);
});
it("flags duration over maximum", { timeout: 5000 }, async () => {
const r = await validateMediaAsync(
PNG,
{ maxDuration: 5 },
{
extractor: async (_b, base) => ({ ...base, duration: 10 }),
}
);
expect(r.valid).toBe(false);
expect(r.errors[0]).toContain("Duration");
});
});
describe("assertValidMedia", () => {
it("returns metadata on success", { timeout: 5000 }, () => {
const meta = assertValidMedia(PNG, {
maxSize: 1024,
allowedTypes: ["image"],
});
expect(meta.type).toBe("image");
});
it("throws VALIDATION_FAILED on failure", { timeout: 5000 }, () => {
try {
assertValidMedia(PNG, { allowedTypes: ["video"] });
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(MediaError);
expect((err as MediaError).code).toBe(
MEDIA_CODES.VALIDATION_FAILED
);
}
});
it("emits error counter when failing", { timeout: 5000 }, () => {
const obs = createMediaObservability();
try {
assertValidMedia(
PNG,
{ allowedTypes: ["video"] },
{ observability: obs }
);
} catch {
/* expected */
}
// smoke: no throw on counter add path
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,247 @@
import { asMediaError, MEDIA_CODES, MediaError } from "../errors/errors.js";
import {
getMetadata,
getMetadataAsync,
type MetadataOptions,
} from "../metadata/metadata.js";
import { createMediaObservability } from "../observability/observability.js";
import type {
MediaConstraints,
MediaMetadata,
MediaResult,
} from "../types/media.types.js";
import { MediaConstraintsSchema } from "../types/media.types.js";
/** Options for the validation helpers. */
export interface ValidateOptions extends MetadataOptions {
/** Skip Zod validation of the supplied constraints (default `false`). */
readonly skipConstraintValidation?: boolean | undefined;
}
function checkConstraints(
metadata: MediaMetadata,
constraints: MediaConstraints
): string[] {
const errors: string[] = [];
if (
constraints.minSize !== undefined &&
metadata.size < constraints.minSize
) {
errors.push(
`File size ${metadata.size} is below minimum ${constraints.minSize}`
);
}
if (
constraints.maxSize !== undefined &&
metadata.size > constraints.maxSize
) {
errors.push(
`File size ${metadata.size} exceeds maximum ${constraints.maxSize}`
);
}
if (
constraints.allowedTypes !== undefined &&
!constraints.allowedTypes.includes(metadata.type)
) {
errors.push(
`Type '${metadata.type}' not in allowed types: ${constraints.allowedTypes.join(", ")}`
);
}
if (
constraints.allowedMimeTypes !== undefined &&
!constraints.allowedMimeTypes.includes(metadata.mimeType)
) {
errors.push(
`MIME '${metadata.mimeType}' not in allowed mime types: ${constraints.allowedMimeTypes.join(", ")}`
);
}
if (
constraints.maxWidth !== undefined &&
metadata.width !== undefined &&
metadata.width > constraints.maxWidth
) {
errors.push(
`Width ${metadata.width} exceeds maximum ${constraints.maxWidth}`
);
}
if (
constraints.minWidth !== undefined &&
metadata.width !== undefined &&
metadata.width < constraints.minWidth
) {
errors.push(
`Width ${metadata.width} below minimum ${constraints.minWidth}`
);
}
if (
constraints.maxHeight !== undefined &&
metadata.height !== undefined &&
metadata.height > constraints.maxHeight
) {
errors.push(
`Height ${metadata.height} exceeds maximum ${constraints.maxHeight}`
);
}
if (
constraints.minHeight !== undefined &&
metadata.height !== undefined &&
metadata.height < constraints.minHeight
) {
errors.push(
`Height ${metadata.height} below minimum ${constraints.minHeight}`
);
}
if (
constraints.maxDuration !== undefined &&
metadata.duration !== undefined &&
metadata.duration > constraints.maxDuration
) {
errors.push(
`Duration ${metadata.duration} exceeds maximum ${constraints.maxDuration}`
);
}
return errors;
}
function validateConstraints(
constraints: MediaConstraints,
skip: boolean
): void {
if (skip) return;
const parsed = MediaConstraintsSchema.safeParse(constraints);
if (!parsed.success) {
throw new MediaError(
MEDIA_CODES.INVALID_CONSTRAINTS,
`MediaConstraints failed Zod validation: ${parsed.error.message}`,
{ cause: parsed.error }
);
}
}
/**
* Validate media bytes against the supplied {@link MediaConstraints}. Runs
* synchronously and never invokes the predicate hook (use
* {@link validateMediaAsync} when constraints carry a predicate).
*/
export function validateMedia(
input: Uint8Array,
constraints: MediaConstraints,
opts?: ValidateOptions
): MediaResult {
const obs = opts?.observability ?? createMediaObservability();
const start = performance.now();
try {
validateConstraints(
constraints,
opts?.skipConstraintValidation ?? false
);
if (input.byteLength === 0) {
throw new MediaError(
MEDIA_CODES.EMPTY_INPUT,
"validateMedia received an empty Uint8Array"
);
}
const metadata = getMetadata(input, opts);
const errors = checkConstraints(metadata, constraints);
return {
type: metadata.type,
metadata,
valid: errors.length === 0,
errors,
};
} finally {
obs.counters.validateTotal.add(1, { mode: "sync" });
obs.histograms.opLatencyMs.record(performance.now() - start, {
op: "validateMedia",
});
obs.histograms.inputSizeBytes.record(input.byteLength, {
op: "validateMedia",
});
}
}
/**
* Async variant of {@link validateMedia} that supports the
* `MediaConstraints.predicate` hook and the async metadata extractor.
*
* The predicate runs after every other constraint check and contributes
* its return value to the `errors` array on failure. Predicate exceptions
* are wrapped in a `MediaError(MEDIA.PREDICATE_FAILED)`.
*/
export async function validateMediaAsync(
input: Uint8Array,
constraints: MediaConstraints,
opts?: ValidateOptions
): Promise<MediaResult> {
const obs = opts?.observability ?? createMediaObservability();
const start = performance.now();
try {
validateConstraints(
constraints,
opts?.skipConstraintValidation ?? false
);
if (input.byteLength === 0) {
throw new MediaError(
MEDIA_CODES.EMPTY_INPUT,
"validateMediaAsync received an empty Uint8Array"
);
}
const metadata = await getMetadataAsync(input, opts);
const errors = checkConstraints(metadata, constraints);
if (constraints.predicate) {
try {
const predicateError = await constraints.predicate(
metadata,
input
);
if (predicateError !== null && predicateError !== undefined) {
errors.push(predicateError);
}
} catch (cause) {
throw asMediaError(
MEDIA_CODES.PREDICATE_FAILED,
cause,
"constraint predicate threw"
);
}
}
return {
type: metadata.type,
metadata,
valid: errors.length === 0,
errors,
};
} finally {
obs.counters.validateTotal.add(1, { mode: "async" });
obs.histograms.opLatencyMs.record(performance.now() - start, {
op: "validateMediaAsync",
});
obs.histograms.inputSizeBytes.record(input.byteLength, {
op: "validateMediaAsync",
});
}
}
/**
* Throwing variant of {@link validateMedia}: returns the validated
* `MediaMetadata` on success, throws `MediaError(MEDIA.VALIDATION_FAILED)`
* on any failure.
*/
export function assertValidMedia(
input: Uint8Array,
constraints: MediaConstraints,
opts?: ValidateOptions
): MediaMetadata {
const result = validateMedia(input, constraints, opts);
if (!result.valid) {
const obs = opts?.observability ?? createMediaObservability();
obs.counters.errorTotal.add(1, { code: MEDIA_CODES.VALIDATION_FAILED });
throw new MediaError(
MEDIA_CODES.VALIDATION_FAILED,
`media failed validation: ${result.errors.join("; ")}`
);
}
return result.metadata;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,52 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/media",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"detection/detection": "src/detection/detection.ts",
"metadata/metadata": "src/metadata/metadata.ts",
"validation/validation": "src/validation/validation.ts",
"errors/errors": "src/errors/errors.ts",
"observability/observability":
"src/observability/observability.ts",
"signatures/signatures": "src/signatures/signatures.ts",
"types/media.types": "src/types/media.types.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/mime",
"@suaveplan/opentelemetry",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,5 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
testTimeout: 5000,
});