Initial Commit
This commit is contained in:
112
packages/foundation/data/media/README.md
Normal file
112
packages/foundation/data/media/README.md
Normal 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.
|
||||
|
||||
106
packages/foundation/data/media/package.json
Normal file
106
packages/foundation/data/media/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
70
packages/foundation/data/media/src/detection/detection.md
Normal file
70
packages/foundation/data/media/src/detection/detection.md
Normal 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.
|
||||
271
packages/foundation/data/media/src/detection/detection.test.ts
Normal file
271
packages/foundation/data/media/src/detection/detection.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
125
packages/foundation/data/media/src/detection/detection.ts
Normal file
125
packages/foundation/data/media/src/detection/detection.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
8
packages/foundation/data/media/src/detection/index.ts
Normal file
8
packages/foundation/data/media/src/detection/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type DetectOptions,
|
||||
defaultMimeForType,
|
||||
detectMediaType,
|
||||
detectMediaTypeFromName,
|
||||
detectSignature,
|
||||
inferMimeFromSignature,
|
||||
} from "./detection.js";
|
||||
67
packages/foundation/data/media/src/errors/errors.md
Normal file
67
packages/foundation/data/media/src/errors/errors.md
Normal 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.
|
||||
112
packages/foundation/data/media/src/errors/errors.test.ts
Normal file
112
packages/foundation/data/media/src/errors/errors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
132
packages/foundation/data/media/src/errors/errors.ts
Normal file
132
packages/foundation/data/media/src/errors/errors.ts
Normal 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 });
|
||||
}
|
||||
8
packages/foundation/data/media/src/errors/index.ts
Normal file
8
packages/foundation/data/media/src/errors/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
asMediaError,
|
||||
MEDIA_CODES,
|
||||
MEDIA_ERROR_METADATA,
|
||||
type MediaCodeMetadata,
|
||||
MediaError,
|
||||
type MediaErrorCode,
|
||||
} from "./errors.js";
|
||||
46
packages/foundation/data/media/src/index.test.ts
Normal file
46
packages/foundation/data/media/src/index.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
66
packages/foundation/data/media/src/index.ts
Normal file
66
packages/foundation/data/media/src/index.ts
Normal 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";
|
||||
5
packages/foundation/data/media/src/metadata/index.ts
Normal file
5
packages/foundation/data/media/src/metadata/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getMetadata,
|
||||
getMetadataAsync,
|
||||
type MetadataOptions,
|
||||
} from "./metadata.js";
|
||||
65
packages/foundation/data/media/src/metadata/metadata.md
Normal file
65
packages/foundation/data/media/src/metadata/metadata.md
Normal 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.
|
||||
94
packages/foundation/data/media/src/metadata/metadata.test.ts
Normal file
94
packages/foundation/data/media/src/metadata/metadata.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
111
packages/foundation/data/media/src/metadata/metadata.ts
Normal file
111
packages/foundation/data/media/src/metadata/metadata.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
createMediaObservability,
|
||||
type MediaCounters,
|
||||
type MediaHistograms,
|
||||
type MediaObservability,
|
||||
type MediaObservabilityOptions,
|
||||
type Meter,
|
||||
type Tracer,
|
||||
} from "./observability.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
5
packages/foundation/data/media/src/signatures/index.ts
Normal file
5
packages/foundation/data/media/src/signatures/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
BUILTIN_SIGNATURES,
|
||||
defaultSignatureRegistry,
|
||||
SignatureRegistry,
|
||||
} from "./signatures.js";
|
||||
71
packages/foundation/data/media/src/signatures/signatures.md
Normal file
71
packages/foundation/data/media/src/signatures/signatures.md
Normal 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.
|
||||
148
packages/foundation/data/media/src/signatures/signatures.test.ts
Normal file
148
packages/foundation/data/media/src/signatures/signatures.test.ts
Normal 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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
120
packages/foundation/data/media/src/signatures/signatures.ts
Normal file
120
packages/foundation/data/media/src/signatures/signatures.ts
Normal 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();
|
||||
11
packages/foundation/data/media/src/types/index.ts
Normal file
11
packages/foundation/data/media/src/types/index.ts
Normal 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";
|
||||
69
packages/foundation/data/media/src/types/media.types.md
Normal file
69
packages/foundation/data/media/src/types/media.types.md
Normal 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.
|
||||
85
packages/foundation/data/media/src/types/media.types.ts
Normal file
85
packages/foundation/data/media/src/types/media.types.ts
Normal 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>>;
|
||||
6
packages/foundation/data/media/src/validation/index.ts
Normal file
6
packages/foundation/data/media/src/validation/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
assertValidMedia,
|
||||
type ValidateOptions,
|
||||
validateMedia,
|
||||
validateMediaAsync,
|
||||
} from "./validation.js";
|
||||
78
packages/foundation/data/media/src/validation/validation.md
Normal file
78
packages/foundation/data/media/src/validation/validation.md
Normal 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.
|
||||
255
packages/foundation/data/media/src/validation/validation.test.ts
Normal file
255
packages/foundation/data/media/src/validation/validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
247
packages/foundation/data/media/src/validation/validation.ts
Normal file
247
packages/foundation/data/media/src/validation/validation.ts
Normal 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;
|
||||
}
|
||||
12
packages/foundation/data/media/tsconfig.lib.json
Normal file
12
packages/foundation/data/media/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
52
packages/foundation/data/media/vite.config.ts
Normal file
52
packages/foundation/data/media/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
5
packages/foundation/data/media/vitest.config.ts
Normal file
5
packages/foundation/data/media/vitest.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
testTimeout: 5000,
|
||||
});
|
||||
Reference in New Issue
Block a user