Initial Commit

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

View File

@@ -0,0 +1,290 @@
# @suaveplan/mime - Structured MIME-type detection, parsing, and registry: magic-byte sniffing, RFC 7231 Content-Type parsing, IANA-sourced type database.
## Summary
Structured MIME-type detection, parsing, and registry for the Suaveplan platform. Provides RFC 7231 Content-Type parsing, magic-byte file signature detection, charset sniffing, and a 150+ entry IANA-sourced type database — all in a zero-dependency, tree-shakeable package.
<!-- begin:auto-docs -->
## Documentation tree
- [sniff-bom](./src/charset/sniff-bom.md) — Detects character encoding from a Byte Order Mark (BOM) at the start of a binary buffer. BOM detection is the highest-c…
- [sniff-html-meta](./src/charset/sniff-html-meta.md) — Extracts a declared character encoding from an HTML `<meta>` tag by scanning the first 1,024 characters of an HTML docu…
- [sniff-xml-decl](./src/charset/sniff-xml-decl.md) — Extracts the declared encoding from an XML or SVG document's processing instruction (`<?xml version="1.0" encoding="UTF…
- [content-type-equals](./src/content-type/content-type-equals.md) — Provides two utility functions for comparing Content-Type values without caring about parameters: `contentTypeEquals` c…
- [format-content-type](./src/content-type/format-content-type.md) — Serializes a `ParsedContentType` object back into a standards-compliant `Content-Type` header string. Handles quoting o…
- [parse-content-type](./src/content-type/parse-content-type.md) — Parses an RFC 7231 / RFC 9110 `Content-Type` header value into a structured `ParsedContentType` object. Handles quoted…
- [builtin-db](./src/db/builtin-db.md) — The built-in MIME type database for `@suaveplan/mime`. Contains over 150 `MimeEntry` records covering the most common w…
- [registry](./src/db/registry.md) — The central in-memory MIME type database for `@suaveplan/mime`. Maintains two indexes over the built-in `MimeEntry` lis…
- [from-magic-bytes](./src/detection/from-magic-bytes/from-magic-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - helpers — Low-level byte-inspection utilities used……
- [archives](./src/detection/from-magic-bytes/signatures/archives.md) — Registers magic-byte signatures for archive and compressed file formats with the global signature registry. Covers all…
- [audio](./src/detection/from-magic-bytes/signatures/audio.md) — Registers magic-byte signatures for audio formats with the global signature registry. Covers all major audio container…
- [data](./src/detection/from-magic-bytes/signatures/data.md) — Registers magic-byte signatures for binary data and structured-data formats with the global signature registry. Covers…
- [documents](./src/detection/from-magic-bytes/signatures/documents.md) — Registers magic-byte signatures for document and data format files with the global signature registry. Covers office do…
- [executables](./src/detection/from-magic-bytes/signatures/executables.md) — Registers magic-byte signatures for executable binary formats with the global signature registry. Covers native executa…
- [fonts](./src/detection/from-magic-bytes/signatures/fonts.md) — Registers magic-byte signatures for font file formats with the global signature registry. Covers all major web font and…
- [images](./src/detection/from-magic-bytes/signatures/images.md) — Registers magic-byte signatures for image formats with the global signature registry. Covers all major raster and vecto…
- [model3d](./src/detection/from-magic-bytes/signatures/model3d.md) — Registers magic-byte signatures for 3D model file formats with the global signature registry. Covers formats used in CA…
- [video](./src/detection/from-magic-bytes/signatures/video.md) — Registers magic-byte signatures for video container formats with the global signature registry. Covers all major video…
- [from-magic-bytes](./src/detection/from-magic-bytes/from-magic-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - helpers — Low-level byte-inspection utilities used……
- [helpers](./src/detection/from-magic-bytes/helpers.md) — Low-level byte-inspection utilities used by the magic-byte signature matchers. Provides helpers for common patterns: pr…
- [registry](./src/detection/from-magic-bytes/registry.md) — The mutable signature registry for magic-byte detection. Stores all registered byte-pattern matchers and provides CRUD-…
- [detect](./src/detection/detect.md) — The unified MIME detection entry point for `@suaveplan/mime`. Merges evidence from three independent sources — magic by…
- [from-content-type](./src/detection/from-content-type.md) — Detects the MIME type from a raw `Content-Type` header string. Parses the header using the full RFC 7231 parser and wra…
- [from-extension](./src/detection/from-extension.md) — Detects the MIME type from a file extension or full filename. Strips path components and leading dots, normalizes to lo…
- [from-stream-reader](./src/detection/from-stream-reader.md) — Internal module providing the `toAsyncIterable` helper and the `drainViaReader` legacy fallback for `ReadableStream` so…
- [from-stream](./src/detection/from-stream.md) — Provides `detectFromStream`, which reads the first N bytes from any async byte source (`ReadableStream<Uint8Array>` or…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable numeric error-code registry for every error class in `@suaveplan/mime`. Each code is a non-overlappi…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable numeric error-code registry for every error class in `@suaveplan/mime`. Each code is a non-overlappi…
- [insufficient-bytes-error](./src/errors/insufficient-bytes-error.md) — Thrown when a magic-byte detection operation receives fewer bytes than the minimum required by the signature. Carries b…
- [invalid-content-type-error](./src/errors/invalid-content-type-error.md) — Thrown when a `Content-Type` header or media type string fails RFC 7231 validation. Carries the raw input string and op…
- [mime-error](./src/errors/mime-error.md) — The base error class for all `@suaveplan/mime` errors. Extends `SuaveplanError` from `@suaveplan/error` with the `MIME_…
- [parse-error](./src/errors/parse-error.md) — Thrown when a general parsing operation fails — for example, XML declaration parsing or BOM detection failures. Unlike…
- [unknown-mime-type-error](./src/errors/unknown-mime-type-error.md) — Thrown when a MIME type string is not found in the built-in database and no fallback is available. Carries the unrecogn…
- [unknown-signature-error](./src/errors/unknown-signature-error.md) — Thrown when a byte signature identifier is referenced but is not present in the signature registry. Used as a sentinel…
- [legacy](./src/legacy/get-mime-type.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [get-mime-type](./src/legacy/get-mime-type.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [reverse](./src/reverse/reverse.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [reverse](./src/reverse/reverse.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [telemetry](./src/telemetry/meters.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [meters](./src/telemetry/meters.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - helpers — Low-level byte-inspection utilities used……
- [archives](./src/detection/from-magic-bytes/signatures/archives.md) — Registers magic-byte signatures for archive and compressed file formats with the global signature registry. Covers all…
- [audio](./src/detection/from-magic-bytes/signatures/audio.md) — Registers magic-byte signatures for audio formats with the global signature registry. Covers all major audio container…
- [data](./src/detection/from-magic-bytes/signatures/data.md) — Registers magic-byte signatures for binary data and structured-data formats with the global signature registry. Covers…
- [documents](./src/detection/from-magic-bytes/signatures/documents.md) — Registers magic-byte signatures for document and data format files with the global signature registry. Covers office do…
- [executables](./src/detection/from-magic-bytes/signatures/executables.md) — Registers magic-byte signatures for executable binary formats with the global signature registry. Covers native executa…
- [fonts](./src/detection/from-magic-bytes/signatures/fonts.md) — Registers magic-byte signatures for font file formats with the global signature registry. Covers all major web font and…
- [images](./src/detection/from-magic-bytes/signatures/images.md) — Registers magic-byte signatures for image formats with the global signature registry. Covers all major raster and vecto…
- [model3d](./src/detection/from-magic-bytes/signatures/model3d.md) — Registers magic-byte signatures for 3D model file formats with the global signature registry. Covers formats used in CA…
- [video](./src/detection/from-magic-bytes/signatures/video.md) — Registers magic-byte signatures for video container formats with the global signature registry. Covers all major video…
- [from-magic-bytes](./src/detection/from-magic-bytes/from-magic-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_- helpers — Low-level byte-inspection utilities used……
- [helpers](./src/detection/from-magic-bytes/helpers.md) — Low-level byte-inspection utilities used by the magic-byte signature matchers. Provides helpers for common patterns: pr…
- [registry](./src/detection/from-magic-bytes/registry.md) — The mutable signature registry for magic-byte detection. Stores all registered byte-pattern matchers and provides CRUD-…
- [detect](./src/detection/detect.md) — The unified MIME detection entry point for `@suaveplan/mime`. Merges evidence from three independent sources — magic by…
- [from-content-type](./src/detection/from-content-type.md) — Detects the MIME type from a raw `Content-Type` header string. Parses the header using the full RFC 7231 parser and wra…
- [from-extension](./src/detection/from-extension.md) — Detects the MIME type from a file extension or full filename. Strips path components and leading dots, normalizes to lo…
- [from-stream-reader](./src/detection/from-stream-reader.md) — Internal module providing the `toAsyncIterable` helper and the `drainViaReader` legacy fallback for `ReadableStream` so…
- [from-stream](./src/detection/from-stream.md) — Provides `detectFromStream`, which reads the first N bytes from any async byte source (`ReadableStream<Uint8Array>` or…
- [insufficient-bytes-error](./src/errors/insufficient-bytes-error.md) — Thrown when a magic-byte detection operation receives fewer bytes than the minimum required by the signature. Carries b…
- [invalid-content-type-error](./src/errors/invalid-content-type-error.md) — Thrown when a `Content-Type` header or media type string fails RFC 7231 validation. Carries the raw input string and op…
- [mime-error](./src/errors/mime-error.md) — The base error class for all `@suaveplan/mime` errors. Extends `SuaveplanError` from `@suaveplan/error` with the `MIME_…
- [parse-error](./src/errors/parse-error.md) — Thrown when a general parsing operation fails — for example, XML declaration parsing or BOM detection failures. Unlike…
- [unknown-mime-type-error](./src/errors/unknown-mime-type-error.md) — Thrown when a MIME type string is not found in the built-in database and no fallback is available. Carries the unrecogn…
- [unknown-signature-error](./src/errors/unknown-signature-error.md) — Thrown when a byte signature identifier is referenced but is not present in the signature registry. Used as a sentinel…
- [legacy](./src/legacy/get-mime-type.md) — <!-- begin:auto-docs --> _(no submodules)_- [get-mime-type](./src/legacy/get-mime-type.md) — <!-- begin:auto-docs --> _(no submodules)_- [reverse](./src/reverse/reverse.md) — <!-- begin:auto-docs --> _(no submodules)_- [reverse](./src/reverse/reverse.md) — <!-- begin:auto-docs --> _(no submodules)_- [telemetry](./src/telemetry/meters.md) — <!-- begin:auto-docs --> _(no submodules)_- [meters](./src/telemetry/meters.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable numeric error-code registry for every error class in `@suaveplan/mime`. Each code is a non-overlappi…
- helpers — Low-level byte-inspection utilities used……
- [archives](./src/detection/from-magic-bytes/signatures/archives.md) — Registers magic-byte signatures for archive and compressed file formats with the global signature registry. Covers all…
- [audio](./src/detection/from-magic-bytes/signatures/audio.md) — Registers magic-byte signatures for audio formats with the global signature registry. Covers all major audio container…
- [data](./src/detection/from-magic-bytes/signatures/data.md) — Registers magic-byte signatures for binary data and structured-data formats with the global signature registry. Covers…
- [documents](./src/detection/from-magic-bytes/signatures/documents.md) — Registers magic-byte signatures for document and data format files with the global signature registry. Covers office do…
- [executables](./src/detection/from-magic-bytes/signatures/executables.md) — Registers magic-byte signatures for executable binary formats with the global signature registry. Covers native executa…
- [fonts](./src/detection/from-magic-bytes/signatures/fonts.md) — Registers magic-byte signatures for font file formats with the global signature registry. Covers all major web font and…
- [images](./src/detection/from-magic-bytes/signatures/images.md) — Registers magic-byte signatures for image formats with the global signature registry. Covers all major raster and vecto…
- [model3d](./src/detection/from-magic-bytes/signatures/model3d.md) — Registers magic-byte signatures for 3D model file formats with the global signature registry. Covers formats used in CA…
- [video](./src/detection/from-magic-bytes/signatures/video.md) — Registers magic-byte signatures for video container formats with the global signature registry. Covers all major video…
- [from-magic-bytes](./src/detection/from-magic-bytes/from-magic-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_- helpers — Low-level byte-inspection utilities used……
- [helpers](./src/detection/from-magic-bytes/helpers.md) — Low-level byte-inspection utilities used by the magic-byte signature matchers. Provides helpers for common patterns: pr…
- [registry](./src/detection/from-magic-bytes/registry.md) — The mutable signature registry for magic-byte detection. Stores all registered byte-pattern matchers and provides CRUD-…
- [detect](./src/detection/detect.md) — The unified MIME detection entry point for `@suaveplan/mime`. Merges evidence from three independent sources — magic by…
- [from-content-type](./src/detection/from-content-type.md) — Detects the MIME type from a raw `Content-Type` header string. Parses the header using the full RFC 7231 parser and wra…
- [from-extension](./src/detection/from-extension.md) — Detects the MIME type from a file extension or full filename. Strips path components and leading dots, normalizes to lo…
- [from-stream-reader](./src/detection/from-stream-reader.md) — Internal module providing the `toAsyncIterable` helper and the `drainViaReader` legacy fallback for `ReadableStream` so…
- [from-stream](./src/detection/from-stream.md) — Provides `detectFromStream`, which reads the first N bytes from any async byte source (`ReadableStream<Uint8Array>` or…
- [insufficient-bytes-error](./src/errors/insufficient-bytes-error.md) — Thrown when a magic-byte detection operation receives fewer bytes than the minimum required by the signature. Carries b…
- [invalid-content-type-error](./src/errors/invalid-content-type-error.md) — Thrown when a `Content-Type` header or media type string fails RFC 7231 validation. Carries the raw input string and op…
- [mime-error](./src/errors/mime-error.md) — The base error class for all `@suaveplan/mime` errors. Extends `SuaveplanError` from `@suaveplan/error` with the `MIME_…
- [parse-error](./src/errors/parse-error.md) — Thrown when a general parsing operation fails — for example, XML declaration parsing or BOM detection failures. Unlike…
- [unknown-mime-type-error](./src/errors/unknown-mime-type-error.md) — Thrown when a MIME type string is not found in the built-in database and no fallback is available. Carries the unrecogn…
- [unknown-signature-error](./src/errors/unknown-signature-error.md) — Thrown when a byte signature identifier is referenced but is not present in the signature registry. Used as a sentinel…
- [legacy](./src/legacy/get-mime-type.md) — <!-- begin:auto-docs --> _(no submodules)_- [get-mime-type](./src/legacy/get-mime-type.md) — <!-- begin:auto-docs --> _(no submodules)_- [reverse](./src/reverse/reverse.md) — <!-- begin:auto-docs --> _(no submodules)_- [reverse](./src/reverse/reverse.md) — <!-- begin:auto-docs --> _(no submodules)_- [telemetry](./src/telemetry/meters.md) — <!-- begin:auto-docs --> _(no submodules)_- [meters](./src/telemetry/meters.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable numeric error-code registry for every error class in `@suaveplan/mime`. Each code is a non-overlappi…
- helpers — Low-level byte-inspection utilities used…
- [archives](./src/detection/from-magic-bytes/signatures/archives.md) — Registers magic-byte signatures for archive and compressed file formats with the global signature registry. Covers all…
- [audio](./src/detection/from-magic-bytes/signatures/audio.md) — Registers magic-byte signatures for audio formats with the global signature registry. Covers all major audio container…
- [data](./src/detection/from-magic-bytes/signatures/data.md) — Registers magic-byte signatures for binary data and structured-data formats with the global signature registry. Covers…
- [documents](./src/detection/from-magic-bytes/signatures/documents.md) — Registers magic-byte signatures for document and data format files with the global signature registry. Covers office do…
- [executables](./src/detection/from-magic-bytes/signatures/executables.md) — Registers magic-byte signatures for executable binary formats with the global signature registry. Covers native executa…
- [fonts](./src/detection/from-magic-bytes/signatures/fonts.md) — Registers magic-byte signatures for font file formats with the global signature registry. Covers all major web font and…
- [images](./src/detection/from-magic-bytes/signatures/images.md) — Registers magic-byte signatures for image formats with the global signature registry. Covers all major raster and vecto…
- [model3d](./src/detection/from-magic-bytes/signatures/model3d.md) — Registers magic-byte signatures for 3D model file formats with the global signature registry. Covers formats used in CA…
- [video](./src/detection/from-magic-bytes/signatures/video.md) — Registers magic-byte signatures for video container formats with the global signature registry. Covers all major video…
- [from-magic-bytes](./src/detection/from-magic-bytes/from-magic-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_- helpers — Low-level byte-inspection utilities used…
- [helpers](./src/detection/from-magic-bytes/helpers.md) — Low-level byte-inspection utilities used by the magic-byte signature matchers. Provides helpers for common patterns: pr…
- [registry](./src/detection/from-magic-bytes/registry.md) — The mutable signature registry for magic-byte detection. Stores all registered byte-pattern matchers and provides CRUD-…
- [detect](./src/detection/detect.md) — The unified MIME detection entry point for `@suaveplan/mime`. Merges evidence from three independent sources — magic by…
- [from-content-type](./src/detection/from-content-type.md) — Detects the MIME type from a raw `Content-Type` header string. Parses the header using the full RFC 7231 parser and wra…
- [from-extension](./src/detection/from-extension.md) — Detects the MIME type from a file extension or full filename. Strips path components and leading dots, normalizes to lo…
- [from-stream-reader](./src/detection/from-stream-reader.md) — Internal module providing the `toAsyncIterable` helper and the `drainViaReader` legacy fallback for `ReadableStream` so…
- [from-stream](./src/detection/from-stream.md) — Provides `detectFromStream`, which reads the first N bytes from any async byte source (`ReadableStream<Uint8Array>` or…
- [insufficient-bytes-error](./src/errors/insufficient-bytes-error.md) — Thrown when a magic-byte detection operation receives fewer bytes than the minimum required by the signature. Carries b…
- [invalid-content-type-error](./src/errors/invalid-content-type-error.md) — Thrown when a `Content-Type` header or media type string fails RFC 7231 validation. Carries the raw input string and op…
- [mime-error](./src/errors/mime-error.md) — The base error class for all `@suaveplan/mime` errors. Extends `SuaveplanError` from `@suaveplan/error` with the `MIME_…
- [parse-error](./src/errors/parse-error.md) — Thrown when a general parsing operation fails — for example, XML declaration parsing or BOM detection failures. Unlike…
- [unknown-mime-type-error](./src/errors/unknown-mime-type-error.md) — Thrown when a MIME type string is not found in the built-in database and no fallback is available. Carries the unrecogn…
- [unknown-signature-error](./src/errors/unknown-signature-error.md) — Thrown when a byte signature identifier is referenced but is not present in the signature registry. Used as a sentinel…
- [legacy](./src/legacy/get-mime-type.md) — <!-- begin:auto-docs --> _(no submodules)_- [get-mime-type](./src/legacy/get-mime-type.md) — <!-- begin:auto-docs --> _(no submodules)_- [reverse](./src/reverse/reverse.md) — <!-- begin:auto-docs --> _(no submodules)_- [reverse](./src/reverse/reverse.md) — <!-- begin:auto-docs --> _(no submodules)_- [telemetry](./src/telemetry/meters.md) — <!-- begin:auto-docs --> _(no submodules)_- [meters](./src/telemetry/meters.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable numeric error-code registry for every error class in `@suaveplan/mime`. Each code is a non-overlappi…
- [archives](./src/detection/from-magic-bytes/signatures/archives.md) — Registers magic-byte signatures for archive and compressed file formats with the global signature registry. Covers all…
- [audio](./src/detection/from-magic-bytes/signatures/audio.md) — Registers magic-byte signatures for audio formats with the global signature registry. Covers all major audio container…
- [data](./src/detection/from-magic-bytes/signatures/data.md) — Registers magic-byte signatures for binary data and structured-data formats with the global signature registry. Covers…
- [documents](./src/detection/from-magic-bytes/signatures/documents.md) — Registers magic-byte signatures for document and data format files with the global signature registry. Covers office do…
- [executables](./src/detection/from-magic-bytes/signatures/executables.md) — Registers magic-byte signatures for executable binary formats with the global signature registry. Covers native executa…
- [fonts](./src/detection/from-magic-bytes/signatures/fonts.md) — Registers magic-byte signatures for font file formats with the global signature registry. Covers all major web font and…
- [images](./src/detection/from-magic-bytes/signatures/images.md) — Registers magic-byte signatures for image formats with the global signature registry. Covers all major raster and vecto…
- [model3d](./src/detection/from-magic-bytes/signatures/model3d.md) — Registers magic-byte signatures for 3D model file formats with the global signature registry. Covers formats used in CA…
- [video](./src/detection/from-magic-bytes/signatures/video.md) — Registers magic-byte signatures for video container formats with the global signature registry. Covers all major video…
- [from-magic-bytes](./src/detection/from-magic-bytes/from-magic-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/detection/from-magic-bytes/helpers.md) — Low-level byte-inspection utilities used by the magic-byte signature matchers. Provides helpers for common patterns: pr…
- [registry](./src/detection/from-magic-bytes/registry.md) — The mutable signature registry for magic-byte detection. Stores all registered byte-pattern matchers and provides CRUD-…
- [detect](./src/detection/detect.md) — The unified MIME detection entry point for `@suaveplan/mime`. Merges evidence from three independent sources — magic by…
- [from-content-type](./src/detection/from-content-type.md) — Detects the MIME type from a raw `Content-Type` header string. Parses the header using the full RFC 7231 parser and wra…
- [from-extension](./src/detection/from-extension.md) — Detects the MIME type from a file extension or full filename. Strips path components and leading dots, normalizes to lo…
- [from-stream-reader](./src/detection/from-stream-reader.md) — Internal module providing the `toAsyncIterable` helper and the `drainViaReader` legacy fallback for `ReadableStream` so…
- [from-stream](./src/detection/from-stream.md) — Provides `detectFromStream`, which reads the first N bytes from any async byte source (`ReadableStream<Uint8Array>` or…
- [insufficient-bytes-error](./src/errors/insufficient-bytes-error.md) — Thrown when a magic-byte detection operation receives fewer bytes than the minimum required by the signature. Carries b…
- [invalid-content-type-error](./src/errors/invalid-content-type-error.md) — Thrown when a `Content-Type` header or media type string fails RFC 7231 validation. Carries the raw input string and op…
- [mime-error](./src/errors/mime-error.md) — The base error class for all `@suaveplan/mime` errors. Extends `SuaveplanError` from `@suaveplan/error` with the `MIME_…
- [parse-error](./src/errors/parse-error.md) — Thrown when a general parsing operation fails — for example, XML declaration parsing or BOM detection failures. Unlike…
- [unknown-mime-type-error](./src/errors/unknown-mime-type-error.md) — Thrown when a MIME type string is not found in the built-in database and no fallback is available. Carries the unrecogn…
- [unknown-signature-error](./src/errors/unknown-signature-error.md) — Thrown when a byte signature identifier is referenced but is not present in the signature registry. Used as a sentinel…
- [legacy](./src/legacy/get-mime-type.md) — <!-- begin:auto-docs --> _(no submodules)_ - [get-mime-type](./src/legacy/get-mime-type.md) — <!-- begin:auto-docs --> _(no submodules)_- [reverse](./src/reverse/reverse.md) — <!-- begin:auto-docs --> _(no submodules)_ - [reverse](./src/reverse/reverse.md) — <!-- begin:auto-docs --> _(no submodules)_- [telemetry](./src/telemetry/meters.md) — <!-- begin:auto-docs --> _(no submodules)_ - [meters](./src/telemetry/meters.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the stable numeric error-code registry for every error class in `@suaveplan/mime`. Each code is a non-overlappi…
## Features
- **RFC 7231 Content-Type parsing** — full quoted-string and OWS support
- **Magic-byte detection** — 150+ signatures across images, audio, video, archives, documents, executables, and fonts
- **Stream detection** — peek bytes from a `ReadableStream` or `AsyncIterable` without losing the original stream
- **Charset sniffing** — BOM, HTML `<meta>` tag, and XML declaration detection
- **Multi-source confidence scoring** — merge bytes + extension + Content-Type with agreement boost
- **Reverse lookup** — MIME type → canonical file extension
- **OpenTelemetry metrics** — optional OTel instrumentation
- **100% test coverage** — every branch and statement verified
## Installation
```bash
bun add @suaveplan/mime
```
## Quick Start
```typescript
import { detect } from "@suaveplan/mime";
// Multi-source detection with confidence scoring
const result = detect({
bytes: fileBuffer,
filename: "photo.jpg",
contentType: "image/jpeg",
});
console.log(result.mime); // "image/jpeg"
console.log(result.confidence); // 1.0 (all three sources agree)
```
## Module Reference
| Module | Description | Docs |
|--------|-------------|------|
| `@suaveplan/mime` | Main entry: `detect()`, `parseContentType()`, `formatContentType()` | [src/index.ts](src/index.ts) |
| `@suaveplan/mime/content-type` | RFC 7231 parser, formatter, equality/match utilities | [parse-content-type.md](src/content-type/parse-content-type.md) |
| `@suaveplan/mime/detection/detect` | Unified multi-source detector | [detect.md](src/detection/detect.md) |
| `@suaveplan/mime/detection/from-stream` | Stream-based detection with head/tail replay | [from-stream.md](src/detection/from-stream.md) |
| `@suaveplan/mime/detection/from-magic-bytes` | Raw magic-byte detection API | [from-magic-bytes.md](src/detection/from-magic-bytes/from-magic-bytes.md) |
| `@suaveplan/mime/charset/sniff-bom` | BOM detection from byte buffers | [sniff-bom.md](src/charset/sniff-bom.md) |
| `@suaveplan/mime/charset/sniff-html-meta` | HTML `<meta charset>` detection | [sniff-html-meta.md](src/charset/sniff-html-meta.md) |
| `@suaveplan/mime/charset/sniff-xml-decl` | XML encoding declaration detection | [sniff-xml-decl.md](src/charset/sniff-xml-decl.md) |
| `@suaveplan/mime/db/registry` | MIME database: forward and reverse lookup | [registry.md](src/db/registry.md) |
| `@suaveplan/mime/reverse` | MIME type → file extension reverse lookup | [reverse.md](src/reverse/reverse.md) |
| `@suaveplan/mime/errors` | Typed error classes | [mime-error.md](src/errors/mime-error.md) |
| `@suaveplan/mime/telemetry/meters` | Optional OTel metrics instrumentation | [meters.md](src/telemetry/meters.md) |
| `@suaveplan/mime/legacy` | `getMimeType()` compat shim | [get-mime-type.md](src/legacy/get-mime-type.md) |
## API Examples
### Content-Type Parsing
```typescript
import { parseContentType, formatContentType } from "@suaveplan/mime/content-type";
const ct = parseContentType("text/html; charset=UTF-8; format=flowed");
console.log(ct.type); // "text"
console.log(ct.subtype); // "html"
console.log(ct.essence); // "text/html"
console.log(ct.parameters.charset); // "utf-8" (lowercased)
console.log(formatContentType(ct)); // "text/html; charset=utf-8; format=flowed"
```
### Magic-Byte Detection
```typescript
import { detectFromMagicBytes } from "@suaveplan/mime/detection/from-magic-bytes/from-magic-bytes";
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const result = detectFromMagicBytes(pngBytes);
console.log(result?.mime); // "image/png"
console.log(result?.priority); // 100
```
### Stream Detection
```typescript
import { detectFromStream } from "@suaveplan/mime/detection/from-stream";
const { mime, head, tail } = await detectFromStream(uploadStream);
console.log(mime); // e.g. "video/mp4"
// Replay the full stream (head + tail)
const chunks: Uint8Array[] = [head];
for await (const chunk of tail) chunks.push(chunk);
```
### Charset Sniffing
```typescript
import { sniffBom } from "@suaveplan/mime/charset/sniff-bom";
import { sniffHtmlMeta } from "@suaveplan/mime/charset/sniff-html-meta";
const bom = sniffBom(fileBytes); // "utf-8" | "utf-16le" | null
const meta = sniffHtmlMeta(htmlText); // "iso-8859-1" | null
```
### Error Handling
```typescript
import { InvalidContentTypeError, UnknownMimeTypeError } from "@suaveplan/mime/errors";
import { parseContentType } from "@suaveplan/mime/content-type";
try {
parseContentType("not valid");
} catch (err) {
if (err instanceof InvalidContentTypeError) {
console.error("Bad header:", err.context?.raw);
}
}
```
## Registering Custom Signatures
```typescript
import { registerSignature } from "@suaveplan/mime/detection/from-magic-bytes/registry";
registerSignature({
mime: "application/x-my-format",
priority: 75,
minBytes: 4,
match: (bytes) =>
bytes[0] === 0x4d && bytes[1] === 0x59 &&
bytes[2] === 0x46 && bytes[3] === 0x4d,
});
```
## Coverage
This package maintains **100% statement, branch, function, and line coverage** — a requirement for Layer 0/1 foundation packages in Suaveplan.
## License
MIT

View File

@@ -0,0 +1,117 @@
{
"name": "@suaveplan/mime",
"version": "0.1.0",
"type": "module",
"sideEffects": [
"**/detection/from-magic-bytes/signatures/index.{ts,js}"
],
"description": "Structured MIME-type detection, parsing, and registry: magic-byte sniffing, RFC 7231 Content-Type parsing, IANA-sourced type database.",
"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"
}
},
"./content-type": {
"import": {
"types": "./dist/content-type/content-type.d.ts",
"default": "./dist/content-type/content-type.js"
},
"require": {
"types": "./dist/content-type/content-type.d.ts",
"default": "./dist/content-type/content-type.js"
}
},
"./detection": {
"import": {
"types": "./dist/detection/detection.d.ts",
"default": "./dist/detection/detection.js"
},
"require": {
"types": "./dist/detection/detection.d.ts",
"default": "./dist/detection/detection.js"
}
},
"./db": {
"import": {
"types": "./dist/db/db.d.ts",
"default": "./dist/db/db.js"
},
"require": {
"types": "./dist/db/db.d.ts",
"default": "./dist/db/db.js"
}
},
"./charset": {
"import": {
"types": "./dist/charset/charset.d.ts",
"default": "./dist/charset/charset.js"
},
"require": {
"types": "./dist/charset/charset.d.ts",
"default": "./dist/charset/charset.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/mime-error.d.ts",
"default": "./dist/errors/mime-error.js"
},
"require": {
"types": "./dist/errors/mime-error.d.ts",
"default": "./dist/errors/mime-error.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/mime.schemas.d.ts",
"default": "./dist/schemas/mime.schemas.js"
},
"require": {
"types": "./dist/schemas/mime.schemas.d.ts",
"default": "./dist/schemas/mime.schemas.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",
"bench": "bun run src/__benchmarks__/mime.bench.ts",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { sniffBom } from "./sniff-bom.js";
import { sniffHtmlMeta } from "./sniff-html-meta.js";
import { sniffXmlDecl } from "./sniff-xml-decl.js";
describe("sniffBom", () => {
it("detects UTF-8 BOM (EF BB BF)", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x65]);
const result = sniffBom(bytes);
expect(result?.charset).toBe("utf-8");
expect(result?.bomLength).toBe(3);
});
it("detects UTF-16 LE BOM (FF FE)", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0xff, 0xfe, 0x68, 0x00]);
const result = sniffBom(bytes);
expect(result?.charset).toBe("utf-16le");
expect(result?.bomLength).toBe(2);
});
it("detects UTF-16 BE BOM (FE FF)", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0xfe, 0xff, 0x00, 0x68]);
const result = sniffBom(bytes);
expect(result?.charset).toBe("utf-16be");
expect(result?.bomLength).toBe(2);
});
it("detects UTF-32 LE BOM (FF FE 00 00)", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0xff, 0xfe, 0x00, 0x00, 0x68]);
const result = sniffBom(bytes);
expect(result?.charset).toBe("utf-32le");
expect(result?.bomLength).toBe(4);
});
it("detects UTF-32 BE BOM (00 00 FE FF)", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0x00, 0x00, 0xfe, 0xff, 0x68]);
const result = sniffBom(bytes);
expect(result?.charset).toBe("utf-32be");
expect(result?.bomLength).toBe(4);
});
it("returns null for no BOM", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0x68, 0x65, 0x6c, 0x6c, 0x6f]);
expect(sniffBom(bytes)).toBeNull();
});
it("returns null for empty buffer", { timeout: 5000 }, () => {
expect(sniffBom(new Uint8Array(0))).toBeNull();
});
it("accepts ArrayBuffer", { timeout: 5000 }, () => {
const buf = new Uint8Array([0xef, 0xbb, 0xbf, 0x41]).buffer;
expect(sniffBom(buf)?.charset).toBe("utf-8");
});
it("UTF-32 LE takes priority over UTF-16 LE when first 4 bytes are FF FE 00 00", {
timeout: 5000,
}, () => {
const bytes = new Uint8Array([0xff, 0xfe, 0x00, 0x00]);
expect(sniffBom(bytes)?.charset).toBe("utf-32le");
});
});
describe("sniffHtmlMeta", () => {
it("detects charset from <meta charset>", { timeout: 5000 }, () => {
const html = '<html><head><meta charset="UTF-8"></head></html>';
expect(sniffHtmlMeta(html)).toBe("utf-8");
});
it("detects charset from <meta charset> without quotes", {
timeout: 5000,
}, () => {
const html = "<meta charset=UTF-8>";
expect(sniffHtmlMeta(html)).toBe("utf-8");
});
it("detects charset from http-equiv content-type", {
timeout: 5000,
}, () => {
const html =
'<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">';
expect(sniffHtmlMeta(html)).toBe("iso-8859-1");
});
it("returns null when no charset found", { timeout: 5000 }, () => {
expect(
sniffHtmlMeta("<html><head><title>Hello</title></head></html>")
).toBeNull();
});
it("only scans first 1024 characters", { timeout: 5000 }, () => {
const prefix = "x".repeat(1100);
const html = `${prefix}<meta charset="UTF-8">`;
expect(sniffHtmlMeta(html)).toBeNull();
});
it("returns lowercase charset", { timeout: 5000 }, () => {
expect(sniffHtmlMeta('<meta charset="ISO-8859-1">')).toBe("iso-8859-1");
});
});
describe("sniffXmlDecl", () => {
it("detects encoding from XML declaration", { timeout: 5000 }, () => {
const xml = '<?xml version="1.0" encoding="UTF-8"?><root/>';
expect(sniffXmlDecl(xml)).toBe("utf-8");
});
it("detects encoding with single quotes", { timeout: 5000 }, () => {
const xml = "<?xml version='1.0' encoding='ISO-8859-1'?><root/>";
expect(sniffXmlDecl(xml)).toBe("iso-8859-1");
});
it("returns null when no encoding attribute", { timeout: 5000 }, () => {
const xml = '<?xml version="1.0"?><root/>';
expect(sniffXmlDecl(xml)).toBeNull();
});
it("returns null for non-XML content", { timeout: 5000 }, () => {
expect(sniffXmlDecl("<html>not xml</html>")).toBeNull();
});
it("only scans first 256 characters", { timeout: 5000 }, () => {
const prefix = "x".repeat(300);
const xml = `${prefix}<?xml version="1.0" encoding="UTF-8"?>`;
expect(sniffXmlDecl(xml)).toBeNull();
});
it("returns lowercase encoding", { timeout: 5000 }, () => {
const xml = '<?xml version="1.0" encoding="UTF-16"?>';
expect(sniffXmlDecl(xml)).toBe("utf-16");
});
});

View File

@@ -0,0 +1,16 @@
/**
* Charset detection type definitions.
*
* @module charset/charset.types
*/
/**
* Result of a BOM detection scan.
*
* `charset` is the IANA charset name in lowercase (e.g. `"utf-8"`, `"utf-16le"`).
* `bomLength` is the number of bytes consumed by the BOM prefix.
*/
export interface BomDetection {
readonly charset: string;
readonly bomLength: number;
}

View File

@@ -0,0 +1,4 @@
export type { BomDetection } from "./charset.types.js";
export { sniffBom } from "./sniff-bom.js";
export { sniffHtmlMeta } from "./sniff-html-meta.js";
export { sniffXmlDecl } from "./sniff-xml-decl.js";

View File

@@ -0,0 +1,59 @@
# sniff-bom
## Purpose
Detects character encoding from a Byte Order Mark (BOM) at the start of a binary buffer. BOM detection is the highest-confidence method of charset identification — when present, a BOM unambiguously identifies the encoding without relying on declared metadata or heuristics.
## Features
- Detects UTF-8 BOM (`EF BB BF`)
- Detects UTF-16 BE BOM (`FE FF`)
- Detects UTF-16 LE BOM (`FF FE`)
- Detects UTF-32 BE BOM (`00 00 FE FF`)
- Detects UTF-32 LE BOM (`FF FE 00 00`)
- Accepts any `ArrayBuffer`, `SharedArrayBuffer`, or `ArrayBufferView` (`Uint8Array`, `DataView`, etc.)
- Returns the IANA charset name in lowercase, or `null` when no BOM is present
- Zero allocation path for no-BOM case
## Usage: Basic Example
```typescript
import { sniffBom } from "@suaveplan/mime/charset/sniff-bom";
const bytes = new Uint8Array([0xef, 0xbb, 0xbf, 0x3c, 0x68, 0x74, 0x6d, 0x6c]);
const charset = sniffBom(bytes);
console.log(charset); // "utf-8"
```
## Usage: Advanced Example
```typescript
import { sniffBom } from "@suaveplan/mime/charset/sniff-bom";
import { sniffHtmlMeta } from "@suaveplan/mime/charset/sniff-html-meta";
async function detectEncoding(buffer: Uint8Array): Promise<string> {
// BOM takes precedence over meta-tag declarations
const bomCharset = sniffBom(buffer);
if (bomCharset) return bomCharset;
// Fall back to meta-tag detection
const text = new TextDecoder("ascii", { fatal: false }).decode(buffer.slice(0, 1024));
return sniffHtmlMeta(text) ?? "utf-8";
}
```
## API Reference
### `sniffBom(buffer: ArrayBufferView | ArrayBuffer): string | null`
| Parameter | Type | Description |
|-----------|------|-------------|
| `buffer` | `ArrayBufferView \| ArrayBuffer` | The byte buffer to inspect (any view or raw buffer). |
**Returns:** The IANA charset name in lowercase, or `null` when no BOM is detected.
**Throws:** Never — all buffer accesses are bounds-checked.
## Implementation Notes
UTF-32 BOMs (both BE and LE) must be tested before UTF-16 LE because the UTF-32 LE BOM (`FF FE 00 00`) starts with the same two bytes as the UTF-16 LE BOM (`FF FE`). The implementation checks the 4-byte sequences first to avoid misidentification. The function uses direct `Uint8Array` byte comparisons for maximum performance — no string allocations or regex matching.

View File

@@ -0,0 +1,57 @@
/**
* Detect a Unicode BOM at the start of a byte sequence.
*
* Returns the IANA charset name (lower-case) plus the BOM length, so callers
* can skip past it. Returns `null` when no BOM is present.
*
* Recognised BOMs (in priority order):
* - UTF-32 LE: `FF FE 00 00` (checked before UTF-16 LE)
* - UTF-32 BE: `00 00 FE FF`
* - UTF-8: `EF BB BF`
* - UTF-16 LE: `FF FE`
* - UTF-16 BE: `FE FF`
*
* @module charset/sniff-bom
*/
import type { BomDetection } from "./charset.types.js";
/**
* Detect a Unicode BOM at the start of `bytes`.
*
* @param bytes - Byte buffer to inspect.
* @returns `BomDetection` when a BOM is found, `null` otherwise.
*/
export function sniffBom(bytes: Uint8Array | ArrayBuffer): BomDetection | null {
const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
// UTF-32 LE must be checked before UTF-16 LE (FF FE prefix overlap)
if (
b.length >= 4 &&
b[0] === 0xff &&
b[1] === 0xfe &&
b[2] === 0 &&
b[3] === 0
) {
return { charset: "utf-32le", bomLength: 4 };
}
if (
b.length >= 4 &&
b[0] === 0 &&
b[1] === 0 &&
b[2] === 0xfe &&
b[3] === 0xff
) {
return { charset: "utf-32be", bomLength: 4 };
}
if (b.length >= 3 && b[0] === 0xef && b[1] === 0xbb && b[2] === 0xbf) {
return { charset: "utf-8", bomLength: 3 };
}
if (b.length >= 2 && b[0] === 0xff && b[1] === 0xfe) {
return { charset: "utf-16le", bomLength: 2 };
}
if (b.length >= 2 && b[0] === 0xfe && b[1] === 0xff) {
return { charset: "utf-16be", bomLength: 2 };
}
return null;
}

View File

@@ -0,0 +1,65 @@
# sniff-html-meta
## Purpose
Extracts a declared character encoding from an HTML `<meta>` tag by scanning the first 1,024 characters of an HTML document. Handles both the modern HTML5 `<meta charset="...">` form and the legacy HTML4 `<meta http-equiv="Content-Type" content="...; charset=...">` form using a single regex that matches `charset=` in any attribute context.
## Features
- Scans up to 1,024 bytes (configurable via the slice parameter)
- Matches HTML5 `<meta charset="...">` tags
- Matches HTML4 `<meta http-equiv="Content-Type" content="...; charset=...">` tags
- Case-insensitive matching for both tag names and attribute values
- Returns the charset name in lowercase for canonical comparison
- Returns `null` when no charset declaration is found
- Zero allocation on the no-match path
## Usage: Basic Example
```typescript
import { sniffHtmlMeta } from "@suaveplan/mime/charset/sniff-html-meta";
const html = '<html><head><meta charset="UTF-8"><title>Hello</title>';
const charset = sniffHtmlMeta(html);
console.log(charset); // "utf-8"
```
## Usage: Advanced Example
```typescript
import { sniffHtmlMeta } from "@suaveplan/mime/charset/sniff-html-meta";
import { sniffBom } from "@suaveplan/mime/charset/sniff-bom";
function detectHtmlEncoding(bytes: Uint8Array): string {
// BOM has highest priority
const bom = sniffBom(bytes);
if (bom) return bom;
// Decode as ASCII/Latin-1 to scan for meta tags
const head = new TextDecoder("latin1").decode(bytes.slice(0, 1024));
const meta = sniffHtmlMeta(head);
// HTML5 spec default is utf-8 when no declaration is found
return meta ?? "utf-8";
}
// Legacy HTML4 http-equiv form
const legacyHtml = '<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">';
console.log(sniffHtmlMeta(legacyHtml)); // "iso-8859-1"
```
## API Reference
### `sniffHtmlMeta(source: string): string | null`
| Parameter | Type | Description |
|-----------|------|-------------|
| `source` | `string` | HTML content (or the first chunk thereof). |
**Returns:** The lowercase IANA charset name, or `null` when no charset declaration is found in the first 1,024 characters.
**Throws:** Never.
## Implementation Notes
A single regex (`/<meta[^>]+charset\s*=\s*["']?([A-Za-z0-9_\-:]+)/i`) covers both the HTML5 and HTML4 forms because `charset=` appears literally in the `content` attribute of the `http-equiv` tag. A separate legacy regex for the full `http-equiv` pattern is unnecessary — the simpler pattern matches earlier and always wins. The 1,024-character scan window follows the HTML5 parsing spec for prescan byte-sequence limits.

View File

@@ -0,0 +1,32 @@
/**
* Extract a declared charset from an HTML `<meta>` tag.
*
* Scans up to 1 024 bytes of HTML for:
* - `<meta charset="...">` (HTML5)
* - `<meta http-equiv="content-type" content="...; charset=...">` (legacy HTML4)
*
* Both patterns are matched case-insensitively. Returns the charset name in
* lowercase, or `null` when no charset declaration is found.
*
* @module charset/sniff-html-meta
*/
const META_CHARSET = /<meta[^>]+charset\s*=\s*["']?([A-Za-z0-9_\-:]+)/i;
/**
* Sniff the charset from the first 1 024 characters of an HTML document.
*
* Matches both HTML5 `<meta charset="...">` and legacy
* `<meta http-equiv="content-type" content="...; charset=...">` because the
* single META_CHARSET pattern `charset\s*=` catches `charset=` in any
* attribute context, including the `content` attribute of an `http-equiv` tag.
*
* @param source - String of HTML content (or first chunk thereof).
* @returns Lowercase IANA charset name, or `null` when not found.
*/
export function sniffHtmlMeta(source: string): string | null {
const window = source.slice(0, 1024);
const m = META_CHARSET.exec(window);
if (m?.[1]) return m[1].toLowerCase();
return null;
}

View File

@@ -0,0 +1,64 @@
# sniff-xml-decl
## Purpose
Extracts the declared encoding from an XML or SVG document's processing instruction (`<?xml version="1.0" encoding="UTF-8"?>`). XML documents commonly declare their encoding in the prolog, and this declaration must be trusted before any BOM or HTTP header when present, per the XML specification (RFC 7303).
## Features
- Matches the `encoding` attribute in `<?xml ... encoding="..."?>` declarations
- Supports both double-quoted and single-quoted attribute values
- Case-insensitive matching — `ENCODING` and `encoding` both match
- Scans only the first 256 characters (the XML prolog is always at the start)
- Returns the encoding name in lowercase for canonical comparison
- Returns `null` when no XML declaration or encoding attribute is found
- Handles whitespace variations between attributes
## Usage: Basic Example
```typescript
import { sniffXmlDecl } from "@suaveplan/mime/charset/sniff-xml-decl";
const xml = '<?xml version="1.0" encoding="UTF-8"?><root/>';
const encoding = sniffXmlDecl(xml);
console.log(encoding); // "utf-8"
```
## Usage: Advanced Example
```typescript
import { sniffXmlDecl } from "@suaveplan/mime/charset/sniff-xml-decl";
import { sniffBom } from "@suaveplan/mime/charset/sniff-bom";
function detectXmlEncoding(bytes: Uint8Array): string {
// BOM overrides XML declaration per RFC 7303 §4.3
const bom = sniffBom(bytes);
if (bom) return bom;
// Use latin-1 to safely read the ASCII-range prolog
const prolog = new TextDecoder("latin1").decode(bytes.slice(0, 256));
const declared = sniffXmlDecl(prolog);
return declared ?? "utf-8"; // XML default encoding
}
// Single-quoted attribute
const svgWithSingleQuotes = "<?xml version='1.0' encoding='iso-8859-1'?><svg/>";
console.log(sniffXmlDecl(svgWithSingleQuotes)); // "iso-8859-1"
```
## API Reference
### `sniffXmlDecl(source: string): string | null`
| Parameter | Type | Description |
|-----------|------|-------------|
| `source` | `string` | XML/SVG content (or the first chunk thereof). |
**Returns:** The lowercase IANA encoding name, or `null` when no encoding attribute is found.
**Throws:** Never.
## Implementation Notes
The regex `/<\?xml[^?]*encoding\s*=\s*["']([^"']+)["']/i` intentionally uses `[^?]*` (non-greedy via exclusion) rather than `.*?` to avoid backtracking across large documents when no `<?xml` declaration is present. The 256-character scan window is sufficient because the XML specification mandates that the encoding declaration, if present, must appear in the document prolog before any other content.

View File

@@ -0,0 +1,24 @@
/**
* Extract the charset from an XML declaration: `<?xml ... encoding="..."?>`.
*
* Scans the first 256 characters for an XML declaration and extracts the
* `encoding` attribute value. Returns the charset in lowercase, or `null`
* when no encoding declaration is found.
*
* @module charset/sniff-xml-decl
*/
const XML_ENCODING = /<\?xml[^?]*encoding\s*=\s*["']([A-Za-z0-9_\-:]+)/i;
/**
* Sniff the encoding from an XML declaration in the first 256 characters.
*
* @param source - String of XML content (or first chunk thereof).
* @returns Lowercase IANA charset name, or `null` when not found.
*/
export function sniffXmlDecl(source: string): string | null {
const window = source.slice(0, 256);
const m = XML_ENCODING.exec(window);
if (m?.[1]) return m[1].toLowerCase();
return null;
}

View File

@@ -0,0 +1,79 @@
# content-type-equals
## Purpose
Provides two utility functions for comparing Content-Type values without caring about parameters: `contentTypeEquals` checks exact `type/subtype` equality (case-insensitive), and `contentTypeMatches` supports wildcard patterns (`*/*`, `image/*`). Both functions accept either raw header strings or pre-parsed `ParsedContentType` objects for zero-overhead comparisons.
## Features
- `contentTypeEquals`: exact essence comparison, ignoring parameters
- `contentTypeMatches`: glob-style pattern matching (`*/*`, `image/*`, `text/plain`)
- Accepts raw strings or `ParsedContentType` objects for both arguments
- Case-insensitive for raw string inputs
- Malformed patterns return `false` instead of throwing
- Zero-cost fast path when a `ParsedContentType` is passed directly (no re-parse)
## Usage: Basic Example
```typescript
import {
contentTypeEquals,
contentTypeMatches,
} from "@suaveplan/mime/content-type/content-type-equals";
// Exact equality (parameters ignored)
contentTypeEquals("text/html; charset=utf-8", "text/html"); // true
contentTypeEquals("Text/HTML", "text/html"); // true (case-insensitive)
contentTypeEquals("text/html", "text/plain"); // false
// Wildcard matching
contentTypeMatches("image/png", "image/*"); // true
contentTypeMatches("image/png", "*/*"); // true
contentTypeMatches("text/html", "image/*"); // false
```
## Usage: Advanced Example
```typescript
import {
contentTypeEquals,
contentTypeMatches,
} from "@suaveplan/mime/content-type/content-type-equals";
import { parseContentType } from "@suaveplan/mime/content-type/parse-content-type";
// Pre-parse once and compare many times
const incoming = parseContentType(request.headers.get("content-type") ?? "");
// Exact match against known types
const isJson = contentTypeEquals(incoming, "application/json");
// Accept any image type
const isImage = contentTypeMatches(incoming, "image/*");
// Build a router
const handlers: Array<{ pattern: string; fn: (req: Request) => Response }> = [
{ pattern: "application/json", fn: handleJson },
{ pattern: "multipart/form-data", fn: handleMultipart },
{ pattern: "text/*", fn: handleText },
];
for (const { pattern, fn } of handlers) {
if (contentTypeMatches(incoming, pattern)) {
return fn(request);
}
}
```
## API Reference
### `contentTypeEquals(a: string | ParsedContentType, b: string | ParsedContentType): boolean`
Returns `true` when both values have the same `type/subtype` essence, ignoring all parameters and case differences.
### `contentTypeMatches(value: string | ParsedContentType, pattern: string): boolean`
Returns `true` when the value's essence matches the pattern. The pattern may use `*` as a wildcard for either type or subtype. Returns `false` if the pattern is malformed (no slash).
## Implementation Notes
Both functions normalize inputs through `parseContentType` when a raw string is provided. The pattern parser in `contentTypeMatches` uses a simple string split on `/` rather than re-using the full parser, which avoids the overhead of parameter parsing and error handling for patterns. Wildcard matching is implemented with direct string comparison after split — there is no full glob engine, only the two valid HTTP wildcard forms (`*/*` and `type/*`).

View File

@@ -0,0 +1,48 @@
/**
* Structural equality helpers for parsed Content-Type values.
*
* @module content-type/content-type-equals
*/
import type { ParsedContentType } from "./content-type.types.js";
import { parseContentType } from "./parse-content-type.js";
function coerce(input: string | ParsedContentType): ParsedContentType {
return typeof input === "string" ? parseContentType(input) : input;
}
/**
* Returns `true` when both values share the same `type/subtype` essence.
* Parameters are ignored; comparison is case-insensitive.
*
* @param a - First Content-Type string or parsed value.
* @param b - Second Content-Type string or parsed value.
*/
export function contentTypeEquals(
a: string | ParsedContentType,
b: string | ParsedContentType
): boolean {
return coerce(a).essence === coerce(b).essence;
}
/**
* Tests whether `value` matches `pattern`. The pattern may use `*` as a
* wildcard for either the type or subtype component. Matches on essence only.
*
* @example
* ```ts
* contentTypeMatches("image/png", "image/*") // true
* contentTypeMatches("application/json", "*\/*") // true
* ```
*/
export function contentTypeMatches(
value: string | ParsedContentType,
pattern: string
): boolean {
const v = coerce(value);
const [patType, patSubtype] = pattern.trim().toLowerCase().split("/", 2);
if (!patType || !patSubtype) return false;
const typeOk = patType === "*" || patType === v.type;
const subOk = patSubtype === "*" || patSubtype === v.subtype;
return typeOk && subOk;
}

View File

@@ -0,0 +1,319 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { InvalidContentTypeError } from "../errors/invalid-content-type-error.js";
import {
contentTypeEquals,
contentTypeMatches,
} from "./content-type-equals.js";
import { formatContentType } from "./format-content-type.js";
import { parseContentType } from "./parse-content-type.js";
describe("parseContentType", () => {
it("parses simple type/subtype", { timeout: 5000 }, () => {
const r = parseContentType("text/html");
expect(r.type).toBe("text");
expect(r.subtype).toBe("html");
expect(r.essence).toBe("text/html");
expect(r.parameters).toEqual({});
});
it("normalises type/subtype to lowercase", { timeout: 5000 }, () => {
const r = parseContentType("TEXT/HTML");
expect(r.type).toBe("text");
expect(r.subtype).toBe("html");
});
it("parses charset parameter", { timeout: 5000 }, () => {
const r = parseContentType("text/html; charset=utf-8");
expect(r.parameters.charset).toBe("utf-8");
});
it("normalises charset parameter value to lowercase", {
timeout: 5000,
}, () => {
const r = parseContentType("text/plain; charset=UTF-8");
expect(r.parameters.charset).toBe("utf-8");
});
it("handles quoted-string parameter values", { timeout: 5000 }, () => {
const r = parseContentType(
'multipart/form-data; boundary="--boundary 123"'
);
expect(r.parameters.boundary).toBe("--boundary 123");
});
it("handles escaped characters in quoted strings", {
timeout: 5000,
}, () => {
const r = parseContentType('text/plain; name="he said \\"hello\\""');
expect(r.parameters.name).toBe('he said "hello"');
});
it("handles multiple parameters", { timeout: 5000 }, () => {
const r = parseContentType("text/html; charset=utf-8; format=flowed");
expect(r.parameters.charset).toBe("utf-8");
expect(r.parameters.format).toBe("flowed");
});
it("trims OWS around type/subtype", { timeout: 5000 }, () => {
const r = parseContentType(" text/html ");
expect(r.essence).toBe("text/html");
});
it("throws InvalidContentTypeError on empty string", {
timeout: 5000,
}, () => {
expect(() => parseContentType("")).toThrow(InvalidContentTypeError);
});
it("throws on missing slash", { timeout: 5000 }, () => {
expect(() => parseContentType("texthtml")).toThrow(
InvalidContentTypeError
);
});
it("throws on leading slash", { timeout: 5000 }, () => {
expect(() => parseContentType("/html")).toThrow(
InvalidContentTypeError
);
});
it("throws on trailing slash", { timeout: 5000 }, () => {
expect(() => parseContentType("text/")).toThrow(
InvalidContentTypeError
);
});
it("throws on invalid parameter (no '=')", { timeout: 5000 }, () => {
expect(() => parseContentType("text/html; charsetutf8")).toThrow(
InvalidContentTypeError
);
});
it("throws when input is not a string (JS interop guard)", {
timeout: 5000,
}, () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(() => parseContentType(42 as any)).toThrow(
InvalidContentTypeError
);
});
it("throws on non-token characters in type", { timeout: 5000 }, () => {
expect(() => parseContentType("te xt/html")).toThrow(
InvalidContentTypeError
);
});
it("throws on non-token characters in subtype", { timeout: 5000 }, () => {
expect(() => parseContentType("text/ht ml")).toThrow(
InvalidContentTypeError
);
});
it("throws on invalid parameter name (non-token)", {
timeout: 5000,
}, () => {
expect(() => parseContentType("text/html; char set=utf-8")).toThrow(
InvalidContentTypeError
);
});
it("handles OWS (space) after '=' in parameter", { timeout: 5000 }, () => {
// Space after '=' exercises the while-skip-spaces on line 94 col 61 of parse-content-type.ts
const r = parseContentType("text/html; charset= utf-8");
expect(r.parameters.charset).toBe("utf-8");
});
it("handles OWS (space) before ';' after quoted-string value", {
timeout: 5000,
}, () => {
// Space after closing '"' before ';' exercises the while-skip-spaces after value parsing
const r = parseContentType(
'multipart/form-data; boundary="--abc" ; charset=utf-8'
);
expect(r.parameters.boundary).toBe("--abc");
expect(r.parameters.charset).toBe("utf-8");
});
it("handles trailing semicolon with whitespace after params", {
timeout: 5000,
}, () => {
// Trailing semicolon + spaces — .trim() removes trailing spaces so paramsRaw ends with ";"
const r = parseContentType("text/html; charset=utf-8; ");
expect(r.parameters["charset"]).toBe("utf-8");
expect(r.essence).toBe("text/html");
});
it("handles params-only trailing semicolon (no whitespace)", {
timeout: 5000,
}, () => {
// Trailing semicolon with no following content
const r = parseContentType("text/html; charset=utf-8;");
expect(r.parameters["charset"]).toBe("utf-8");
});
it("handles semicolon followed by only whitespace (triggers line-75 break)", {
timeout: 5000,
}, () => {
// paramsRaw = " " (3 spaces) — inner while skips all to i=3=length, then break fires
const r = parseContentType("text/html; ");
expect(r.parameters).toEqual({});
expect(r.essence).toBe("text/html");
});
it("freezes the parameters object", { timeout: 5000 }, () => {
const r = parseContentType("text/html; charset=utf-8");
expect(Object.isFrozen(r.parameters)).toBe(true);
});
it("handles content-type with no parameters and semicolons", {
timeout: 5000,
}, () => {
const r = parseContentType("application/json");
expect(r.essence).toBe("application/json");
expect(r.parameters).toEqual({});
});
});
describe("formatContentType", () => {
it("round-trips simple type/subtype", { timeout: 5000 }, () => {
const parsed = parseContentType("text/html");
expect(formatContentType(parsed)).toBe("text/html");
});
it("includes parameters", { timeout: 5000 }, () => {
const parsed = parseContentType("text/html; charset=utf-8");
expect(formatContentType(parsed)).toBe("text/html; charset=utf-8");
});
it("quotes values with non-token characters", { timeout: 5000 }, () => {
const ct = {
type: "multipart",
subtype: "form-data",
essence: "multipart/form-data",
parameters: Object.freeze({ boundary: "--boundary 123" }),
};
expect(formatContentType(ct)).toBe(
'multipart/form-data; boundary="--boundary 123"'
);
});
it("does not quote simple token values", { timeout: 5000 }, () => {
const parsed = parseContentType("text/plain; charset=utf-8");
const formatted = formatContentType(parsed);
expect(formatted).toBe("text/plain; charset=utf-8");
expect(formatted).not.toContain('"');
});
});
describe("contentTypeEquals", () => {
it("is true for identical essences", { timeout: 5000 }, () => {
expect(contentTypeEquals("text/html", "text/html")).toBe(true);
});
it("is case-insensitive", { timeout: 5000 }, () => {
expect(contentTypeEquals("Text/HTML", "text/html")).toBe(true);
});
it("ignores parameters", { timeout: 5000 }, () => {
expect(contentTypeEquals("text/html; charset=utf-8", "text/html")).toBe(
true
);
});
it("is false for different types", { timeout: 5000 }, () => {
expect(contentTypeEquals("text/html", "text/plain")).toBe(false);
});
it("accepts ParsedContentType objects", { timeout: 5000 }, () => {
const a = parseContentType("text/html");
expect(contentTypeEquals(a, "text/html")).toBe(true);
});
});
describe("contentTypeMatches", () => {
it("matches exact type and subtype", { timeout: 5000 }, () => {
expect(contentTypeMatches("image/png", "image/png")).toBe(true);
});
it("matches wildcard subtype", { timeout: 5000 }, () => {
expect(contentTypeMatches("image/png", "image/*")).toBe(true);
});
it("matches wildcard type and subtype", { timeout: 5000 }, () => {
expect(contentTypeMatches("application/json", "*/*")).toBe(true);
});
it("rejects mismatched type", { timeout: 5000 }, () => {
expect(contentTypeMatches("text/html", "image/*")).toBe(false);
});
it("rejects mismatched subtype", { timeout: 5000 }, () => {
expect(contentTypeMatches("image/png", "image/jpeg")).toBe(false);
});
it("returns false for malformed pattern", { timeout: 5000 }, () => {
expect(contentTypeMatches("image/png", "image")).toBe(false);
});
it("handles ParsedContentType as value", { timeout: 5000 }, () => {
const parsed = parseContentType("image/png");
expect(contentTypeMatches(parsed, "image/*")).toBe(true);
});
});
describe("parseContentType → formatContentType round-trip (property-based)", () => {
it("round-trips simple content-types", { timeout: 30000 }, () => {
const types = [
"text",
"image",
"application",
"audio",
"video",
"font",
"model",
];
const subtypes = [
"plain",
"html",
"png",
"json",
"jpeg",
"mp4",
"x-custom",
"vnd.example",
];
fc.assert(
fc.property(
fc.constantFrom(...types),
fc.constantFrom(...subtypes),
(type, subtype) => {
const raw = `${type}/${subtype}`;
const parsed = parseContentType(raw);
const formatted = formatContentType(parsed);
const reparsed = parseContentType(formatted);
expect(reparsed.essence).toBe(parsed.essence);
expect(reparsed.parameters).toEqual(parsed.parameters);
}
)
);
});
it("round-trips with charset parameter", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.constantFrom("text/html", "text/plain", "application/json"),
fc.constantFrom("utf-8", "iso-8859-1", "us-ascii"),
(essence, charset) => {
const raw = `${essence}; charset=${charset}`;
const parsed = parseContentType(raw);
const formatted = formatContentType(parsed);
const reparsed = parseContentType(formatted);
expect(reparsed.parameters.charset).toBe(charset);
}
)
);
});
});

View File

@@ -0,0 +1,23 @@
/**
* Content-Type header value shapes.
*
* @module content-type/content-type.types
*/
/** The `type` and `subtype` parts of a media type. */
export interface MediaType {
readonly type: string;
readonly subtype: string;
}
/**
* A fully-parsed RFC 7231 Content-Type value.
*
* `essence` is `type/subtype` — the canonical key for registry lookups.
* `parameters` is a frozen map of lowercased parameter names to values;
* `charset`, `boundary`, and `format` values are also lowercased.
*/
export interface ParsedContentType extends MediaType {
readonly parameters: Readonly<Record<string, string>>;
readonly essence: string;
}

View File

@@ -0,0 +1,71 @@
# format-content-type
## Purpose
Serializes a `ParsedContentType` object back into a standards-compliant `Content-Type` header string. Handles quoting of parameter values that contain non-token characters, ensuring the output is always valid for use in HTTP headers and is round-trip safe with `parseContentType`.
## Features
- Produces the canonical `type/subtype` essence string
- Appends parameters as `; name=value` pairs in insertion order
- Automatically quotes values containing non-token characters (spaces, special chars)
- Does not quote simple token values (no unnecessary quotes)
- Round-trip safe: `parseContentType(formatContentType(x)).essence === x.essence`
- Accepts both `ParsedContentType` objects and ad-hoc objects with the same shape
## Usage: Basic Example
```typescript
import { parseContentType } from "@suaveplan/mime/content-type/parse-content-type";
import { formatContentType } from "@suaveplan/mime/content-type/format-content-type";
const ct = parseContentType("text/html; charset=utf-8");
const formatted = formatContentType(ct);
console.log(formatted); // "text/html; charset=utf-8"
```
## Usage: Advanced Example
```typescript
import { formatContentType } from "@suaveplan/mime/content-type/format-content-type";
// Construct a content type programmatically
const ct = {
type: "multipart",
subtype: "form-data",
essence: "multipart/form-data",
parameters: Object.freeze({ boundary: "--my boundary with spaces" }),
};
console.log(formatContentType(ct));
// 'multipart/form-data; boundary="--my boundary with spaces"'
// Values needing quoting are detected automatically
const withSpecial = {
type: "text",
subtype: "plain",
essence: "text/plain",
parameters: Object.freeze({ name: 'file with "quotes".txt' }),
};
console.log(formatContentType(withSpecial));
// 'text/plain; name="file with \\"quotes\\".txt"'
```
## API Reference
### `formatContentType(ct: ParsedContentType): string`
| Parameter | Type | Description |
|-----------|------|-------------|
| `ct` | `ParsedContentType` | A parsed content-type object. |
**Returns:** An RFC 7231-compliant `Content-Type` header string.
**Throws:** Never — all inputs that satisfy `ParsedContentType` are formattable.
## Implementation Notes
A value is quoted only when it contains characters outside the RFC 7230 token character set (`!#$%&'*+-.^_\`|~0-9A-Za-z`). Characters within the quoted string that are not`token` characters are escaped with a backslash. This ensures the output is always valid even when parameter values contain double-quote characters themselves. The function uses `Object.keys` to enumerate parameters, which preserves insertion order in all modern JavaScript engines.
### Quoting Rules
Parameter values that contain characters outside the RFC 7230 `token` grammar (whitespace, `;`, `,`, non-ASCII) are wrapped in double quotes with backslash-escaping; values that already look like a valid token are emitted unquoted. Charset parameter values are lowercased at format time so output is canonical and hashes stably for content-based caches.

View File

@@ -0,0 +1,31 @@
/**
* Serialise a `ParsedContentType` back to a canonical header string.
*
* Parameter values containing non-token characters are quoted and escaped
* so the result is always a valid RFC 7231 `Content-Type` header.
*
* @module content-type/format-content-type
*/
import type { ParsedContentType } from "./content-type.types.js";
const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
function quoteIfNeeded(value: string): string {
if (value.length > 0 && TOKEN_RE.test(value)) return value;
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
/**
* Serialise a `ParsedContentType` to a canonical `type/subtype; param=value` string.
*
* @param ct - Parsed content type to serialise.
* @returns RFC 7231 compliant Content-Type header value.
*/
export function formatContentType(ct: ParsedContentType): string {
const parts = [`${ct.type}/${ct.subtype}`];
for (const [key, value] of Object.entries(ct.parameters)) {
parts.push(`${key}=${quoteIfNeeded(value)}`);
}
return parts.join("; ");
}

View File

@@ -0,0 +1,7 @@
export type { MediaType, ParsedContentType } from "./content-type.types.js";
export {
contentTypeEquals,
contentTypeMatches,
} from "./content-type-equals.js";
export { formatContentType } from "./format-content-type.js";
export { parseContentType } from "./parse-content-type.js";

View File

@@ -0,0 +1,82 @@
# parse-content-type
## Purpose
Parses an RFC 7231 / RFC 9110 `Content-Type` header value into a structured `ParsedContentType` object. Handles quoted strings, escape sequences, optional whitespace (OWS), and case normalization. The parsed object is immutable (frozen parameters map) and provides a canonical `essence` field (`type/subtype`) for efficient comparison.
## Features
- Parses `type/subtype` with OWS trimming and lowercase normalization
- Parses parameters: `name=value` pairs, `;`-separated
- Supports quoted-string parameter values with `\"` escape sequences
- Normalizes `charset`, `boundary`, and `format` parameter values to lowercase
- Handles OWS around `=` and between parameters
- Validates type, subtype, and parameter names as RFC token characters
- Guards against non-string inputs (JS interop safety)
- Returns a frozen `parameters` object for safe sharing
- Throws `InvalidContentTypeError` for all malformed inputs
## Usage: Basic Example
```typescript
import { parseContentType } from "@suaveplan/mime/content-type/parse-content-type";
const ct = parseContentType("text/html; charset=utf-8");
console.log(ct.type); // "text"
console.log(ct.subtype); // "html"
console.log(ct.essence); // "text/html"
console.log(ct.parameters); // { charset: "utf-8" }
```
## Usage: Advanced Example
```typescript
import { parseContentType } from "@suaveplan/mime/content-type/parse-content-type";
import { InvalidContentTypeError } from "@suaveplan/mime/errors";
// Quoted boundary with spaces
const multipart = parseContentType('multipart/form-data; boundary="--boundary 123"');
console.log(multipart.parameters.boundary); // "--boundary 123"
// Multiple parameters with OWS
const complex = parseContentType('text/html; charset= utf-8; format=flowed');
console.log(complex.parameters.charset); // "utf-8"
console.log(complex.parameters.format); // "flowed"
// Error handling
try {
parseContentType("not/valid token!");
} catch (err) {
if (err instanceof InvalidContentTypeError) {
console.error("Malformed Content-Type:", err.message);
}
}
// Round-trip safe (parse → format → parse)
const reparsed = parseContentType(formatContentType(multipart));
console.log(reparsed.essence); // "multipart/form-data"
```
## API Reference
### `parseContentType(input: string): ParsedContentType`
| Parameter | Type | Description |
|-----------|------|-------------|
| `input` | `string` | Raw `Content-Type` header value. |
**Returns:** A `ParsedContentType` with `type`, `subtype`, `essence`, and frozen `parameters`.
**Throws:** `InvalidContentTypeError` when:
- Input is not a string
- Input is empty or whitespace-only
- Missing slash between type and subtype
- Type or subtype is empty
- Type or subtype contains non-token characters
- Parameter name contains non-token characters
- Parameter is missing the `=` separator
## Implementation Notes
The parser is a hand-written state machine operating on the raw string. This avoids regex backtracking issues with complex quoted-string values and ensures O(n) time complexity. The `parameters` object is frozen via `Object.freeze` before return to prevent accidental mutation by callers. The `charset`, `boundary`, and `format` parameter values are lowercased per the RFC — all other parameter values preserve their original casing. The leading `trim()` on the full input removes any surrounding whitespace, which means trailing spaces after a final semicolon are eliminated before parameter parsing begins.

View File

@@ -0,0 +1,133 @@
/**
* RFC 7231 / RFC 9110 Content-Type parser.
*
* Handles quoted strings, escape sequences, OWS, and case normalization.
* Parameter names are lower-cased; `charset`, `boundary`, and `format`
* parameter values are lower-cased. All other parameter values keep case.
*
* @module content-type/parse-content-type
*/
import { InvalidContentTypeError } from "../errors/invalid-content-type-error.js";
import type { ParsedContentType } from "./content-type.types.js";
const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
const LOWER_VALUE_PARAMS = new Set(["charset", "boundary", "format"]);
function isToken(s: string): boolean {
return TOKEN_RE.test(s);
}
/**
* Parse an RFC 7231 `Content-Type` header value into its structured parts.
*
* @param input - Raw Content-Type header value.
* @returns A frozen `ParsedContentType` with `type`, `subtype`, `essence`, and `parameters`.
* @throws `InvalidContentTypeError` when the input is empty, missing a slash,
* uses non-token characters in type/subtype, or contains malformed parameters.
*/
export function parseContentType(input: string): ParsedContentType {
if (typeof input !== "string") {
throw new InvalidContentTypeError({
message: "Content-Type must be a string",
context: { raw: String(input) },
});
}
const trimmed = input.trim();
if (!trimmed) {
throw new InvalidContentTypeError({
message: "Content-Type is empty",
context: { raw: input },
});
}
const semi = trimmed.indexOf(";");
const mediaTypeRaw = semi === -1 ? trimmed : trimmed.slice(0, semi);
const paramsRaw = semi === -1 ? "" : trimmed.slice(semi + 1);
const slash = mediaTypeRaw.indexOf("/");
if (slash <= 0 || slash === mediaTypeRaw.length - 1) {
throw new InvalidContentTypeError({
message: `Invalid media type: "${mediaTypeRaw}"`,
context: { raw: input, position: slash },
});
}
const type = mediaTypeRaw.slice(0, slash).trim().toLowerCase();
const subtype = mediaTypeRaw
.slice(slash + 1)
.trim()
.toLowerCase();
if (!isToken(type) || !isToken(subtype)) {
throw new InvalidContentTypeError({
message: `Invalid media type token: "${mediaTypeRaw}"`,
context: { raw: input },
});
}
const parameters: Record<string, string> = {};
let i = 0;
while (i < paramsRaw.length) {
while (i < paramsRaw.length && paramsRaw[i] === " ") i += 1;
const eq = paramsRaw.indexOf("=", i);
if (eq === -1) {
throw new InvalidContentTypeError({
message: "Malformed parameter — missing '='",
context: { raw: input, position: i },
});
}
const name = paramsRaw.slice(i, eq).trim().toLowerCase();
if (!isToken(name)) {
throw new InvalidContentTypeError({
message: `Invalid parameter name: "${name}"`,
context: { raw: input, position: i },
});
}
i = eq + 1;
while (i < paramsRaw.length && paramsRaw[i] === " ") i += 1;
let value: string;
if (paramsRaw[i] === '"') {
i += 1;
let buf = "";
while (i < paramsRaw.length) {
const ch = paramsRaw[i];
if (ch === "\\" && i + 1 < paramsRaw.length) {
buf += paramsRaw[i + 1];
i += 2;
continue;
}
if (ch === '"') {
i += 1;
break;
}
buf += ch;
i += 1;
}
value = buf;
} else {
let end = paramsRaw.indexOf(";", i);
if (end === -1) end = paramsRaw.length;
value = paramsRaw.slice(i, end).trim();
i = end;
}
parameters[name] = LOWER_VALUE_PARAMS.has(name)
? value.toLowerCase()
: value;
while (i < paramsRaw.length && paramsRaw[i] === " ") i += 1;
if (paramsRaw[i] === ";") i += 1;
}
return {
type,
subtype,
essence: `${type}/${subtype}`,
parameters: Object.freeze(parameters),
};
}

View File

@@ -0,0 +1,56 @@
# db/builtin-db
## Purpose
The built-in MIME type database for `@suaveplan/mime`. Contains over 150 `MimeEntry` records covering the most common web, media, document, archive, font, and application MIME types. This is a static data file — it contains no runtime logic, only the `BUILTIN_DB` constant array consumed by the registry.
## Features
- 150+ MIME type entries across all major categories
- Each entry includes: `mime` (canonical type string), `extensions` (array of associated file extensions), and `encoding` (`"binary"`, `"text"`, or `"base64"`)
- Comprehensive coverage: images, audio, video, documents, archives, fonts, executables, data formats
- Extensions are lowercase and without leading dots
- Canonical extension (for `Content-Disposition` filename guessing) is always the first element
## Usage: Basic Example
```typescript
import { BUILTIN_DB } from "@suaveplan/mime/db/builtin-db";
// Inspect all text-type entries
const textTypes = BUILTIN_DB.filter(e => e.mime.startsWith("text/"));
console.log(textTypes.map(e => e.mime));
// ["text/plain", "text/html", "text/css", ...]
```
## Usage: Advanced Example
```typescript
import { BUILTIN_DB } from "@suaveplan/mime/db/builtin-db";
import { getEntryByMime } from "@suaveplan/mime/db/registry";
// Combine built-in with runtime lookup
const entry = getEntryByMime("application/pdf");
console.log(entry?.extensions); // ["pdf"]
console.log(entry?.encoding); // "binary"
// Find all binary MIME types
const binaryTypes = BUILTIN_DB.filter(e => e.encoding === "binary");
console.log(binaryTypes.length); // 100+
```
## API Reference
### `BUILTIN_DB: readonly MimeEntry[]`
A frozen array of `MimeEntry` objects. Each entry has:
| Field | Type | Description |
|-------|------|-------------|
| `mime` | `string` | Canonical MIME type string (e.g., `"image/png"`) |
| `extensions` | `string[]` | Associated file extensions without dots (e.g., `["png"]`) |
| `encoding` | `"binary" \| "text" \| "base64"` | Transfer encoding hint |
## Implementation Notes
The database is intentionally a plain array rather than a pre-built map so that the registry can apply its first-write-wins semantics during initialization. Keeping data and behavior separate also makes it straightforward to swap the database at build time (e.g., for a minimal embedded build that includes only a subset of MIME types). The entries are ordered roughly by frequency of use, which has no algorithmic impact but improves grep readability.

View File

@@ -0,0 +1,180 @@
/**
* Curated built-in MIME database.
*
* Hand-maintained subset of IANA + common vendor types. Ships as a frozen
* constant so consumers pay no lookup cost at import time. Extend at runtime
* via `registerMimeEntry`.
*
* @module db/builtin-db
*/
import type { MimeEntry } from "./mime-entry.types.js";
// biome-ignore format: table-like literal is easier to audit this way
const ENTRIES: readonly MimeEntry[] = Object.freeze([
// === application ===
{ mime: "application/octet-stream", extensions: ["bin"], category: "application", isBinary: true },
{ mime: "application/json", extensions: ["json", "map"], category: "application", charset: "utf-8", compressible: true, isBinary: false, spec: "RFC 8259" },
{ mime: "application/ld+json", extensions: ["jsonld"], category: "application", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "application/xml", extensions: ["xml"], category: "application", charset: "utf-8", compressible: true, isBinary: false, spec: "RFC 7303" },
{ mime: "application/javascript", extensions: ["js", "mjs", "cjs"], category: "application", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "application/wasm", extensions: ["wasm"], category: "application", compressible: false, isBinary: true },
{ mime: "application/pdf", extensions: ["pdf"], category: "application", compressible: false, isBinary: true, spec: "ISO 32000-1" },
{ mime: "application/rtf", extensions: ["rtf"], category: "application", compressible: true, isBinary: false },
{ mime: "application/postscript", extensions: ["ps", "eps"], category: "application", compressible: true, isBinary: true },
{ mime: "application/zip", extensions: ["zip"], category: "application", compressible: false, isBinary: true },
{ mime: "application/gzip", extensions: ["gz"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-tar", extensions: ["tar"], category: "application", compressible: true, isBinary: true },
{ mime: "application/x-7z-compressed", extensions: ["7z"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-rar-compressed", extensions: ["rar"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-xz", extensions: ["xz"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-bzip2", extensions: ["bz2"], category: "application", compressible: false, isBinary: true },
{ mime: "application/zstd", extensions: ["zst"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.ms-cab-compressed", extensions: ["cab"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", extensions: ["docx"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", extensions: ["xlsx"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation", extensions: ["pptx"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.oasis.opendocument.text", extensions: ["odt"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.oasis.opendocument.spreadsheet", extensions: ["ods"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.oasis.opendocument.presentation", extensions: ["odp"], category: "application", compressible: false, isBinary: true },
{ mime: "application/msword", extensions: ["doc"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.ms-excel", extensions: ["xls"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.ms-powerpoint", extensions: ["ppt"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-msdownload", extensions: ["exe", "dll"], category: "application", compressible: true, isBinary: true },
{ mime: "application/x-mach-binary", extensions: [], category: "application", compressible: true, isBinary: true },
{ mime: "application/x-executable", extensions: [], category: "application", compressible: true, isBinary: true },
{ mime: "application/x-sqlite3", extensions: ["sqlite", "db"], category: "application", compressible: true, isBinary: true },
{ mime: "application/x-yaml", extensions: ["yaml", "yml"], category: "application", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "application/toml", extensions: ["toml"], category: "application", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "application/x-protobuf", extensions: ["pb"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-msgpack", extensions: ["msgpack"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.apple.installer+xml", extensions: ["mpkg"], category: "application", compressible: true, isBinary: true },
{ mime: "application/vnd.apple.mpegurl", extensions: ["m3u8"], category: "application", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "application/vnd.google-earth.kml+xml", extensions: ["kml"], category: "application", compressible: true, isBinary: false },
{ mime: "application/geo+json", extensions: ["geojson"], category: "application", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "application/x-ndjson", extensions: ["ndjson"], category: "application", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "application/problem+json", extensions: [], category: "application", charset: "utf-8", compressible: true, isBinary: false, spec: "RFC 9457" },
{ mime: "application/epub+zip", extensions: ["epub"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-cfb", extensions: [], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-lz4", extensions: ["lz4"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-rpm", extensions: ["rpm"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-archive", extensions: ["a"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-lzh-compressed", extensions: ["lzh", "lha"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-lzma", extensions: ["lzma"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-compress", extensions: ["Z"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-apple-diskimage", extensions: ["dmg"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-iso9660-image", extensions: ["iso"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-cpio", extensions: ["cpio"], category: "application", compressible: true, isBinary: true },
{ mime: "application/mxf", extensions: ["mxf"], category: "application", compressible: false, isBinary: true },
{ mime: "application/java-vm", extensions: ["class"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.android.dex", extensions: ["dex"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-llvm", extensions: ["bc"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.ms-fontobject", extensions: ["eot"], category: "font", compressible: true, isBinary: true },
{ mime: "application/vnd.apache.parquet", extensions: ["parquet"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.apache.avro+binary", extensions: ["avro"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.apache.arrow.stream", extensions: ["arrows"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-leveldb", extensions: ["ldb", "sst"], category: "application", compressible: false, isBinary: true },
{ mime: "application/x-lmdb", extensions: ["mdb"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.apple.pages", extensions: ["pages"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.apple.numbers", extensions: ["numbers"], category: "application", compressible: false, isBinary: true },
{ mime: "application/vnd.apple.keynote", extensions: ["keynote"], category: "application", compressible: false, isBinary: true },
// === audio ===
{ mime: "audio/mpeg", extensions: ["mp3"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/wav", extensions: ["wav"], category: "audio", compressible: true, isBinary: true },
{ mime: "audio/flac", extensions: ["flac"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/ogg", extensions: ["ogg", "oga"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/opus", extensions: ["opus"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/aac", extensions: ["aac"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/mp4", extensions: ["m4a"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/webm", extensions: ["weba"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/midi", extensions: ["mid", "midi"], category: "audio", compressible: true, isBinary: true },
{ mime: "audio/aiff", extensions: ["aiff", "aif"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/amr", extensions: ["amr"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/amr-wb", extensions: ["awb"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/ape", extensions: ["ape"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/wavpack", extensions: ["wv"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/x-it", extensions: ["it"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/x-xm", extensions: ["xm"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/x-s3m", extensions: ["s3m"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/x-mod", extensions: ["mod"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/x-dsf", extensions: ["dsf"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/x-dff", extensions: ["dff"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/x-caf", extensions: ["caf"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/musepack", extensions: ["mpc"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/x-voc", extensions: ["voc"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/basic", extensions: ["au", "snd"], category: "audio", compressible: false, isBinary: true },
{ mime: "audio/ac3", extensions: ["ac3"], category: "audio", compressible: false, isBinary: true },
// === video ===
{ mime: "video/mp4", extensions: ["mp4", "m4v"], category: "video", compressible: false, isBinary: true },
{ mime: "video/webm", extensions: ["webm"], category: "video", compressible: false, isBinary: true },
{ mime: "video/x-matroska", extensions: ["mkv"], category: "video", compressible: false, isBinary: true },
{ mime: "video/quicktime", extensions: ["mov"], category: "video", compressible: false, isBinary: true },
{ mime: "video/x-msvideo", extensions: ["avi"], category: "video", compressible: false, isBinary: true },
{ mime: "video/x-flv", extensions: ["flv"], category: "video", compressible: false, isBinary: true },
{ mime: "video/mp2t", extensions: ["ts", "mts", "m2ts"], category: "video", compressible: false, isBinary: true },
{ mime: "video/3gpp", extensions: ["3gp"], category: "video", compressible: false, isBinary: true },
{ mime: "video/3gpp2", extensions: ["3g2"], category: "video", compressible: false, isBinary: true },
{ mime: "video/x-m4v", extensions: [], category: "video", compressible: false, isBinary: true },
{ mime: "video/x-ms-wmv", extensions: ["wmv"], category: "video", compressible: false, isBinary: true },
{ mime: "video/mpeg", extensions: ["mpeg", "mpg"], category: "video", compressible: false, isBinary: true },
{ mime: "video/x-dv", extensions: ["dv"], category: "video", compressible: false, isBinary: true },
// === image ===
{ mime: "image/png", extensions: ["png"], category: "image", compressible: false, isBinary: true },
{ mime: "image/jpeg", extensions: ["jpg", "jpeg"], category: "image", compressible: false, isBinary: true },
{ mime: "image/gif", extensions: ["gif"], category: "image", compressible: false, isBinary: true },
{ mime: "image/webp", extensions: ["webp"], category: "image", compressible: false, isBinary: true },
{ mime: "image/avif", extensions: ["avif"], category: "image", compressible: false, isBinary: true },
{ mime: "image/heic", extensions: ["heic"], category: "image", compressible: false, isBinary: true },
{ mime: "image/heif", extensions: ["heif"], category: "image", compressible: false, isBinary: true },
{ mime: "image/bmp", extensions: ["bmp"], category: "image", compressible: true, isBinary: true },
{ mime: "image/tiff", extensions: ["tif", "tiff"], category: "image", compressible: true, isBinary: true },
{ mime: "image/x-icon", extensions: ["ico"], category: "image", compressible: true, isBinary: true },
{ mime: "image/vnd.adobe.photoshop", extensions: ["psd"], category: "image", compressible: true, isBinary: true },
{ mime: "image/svg+xml", extensions: ["svg"], category: "image", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "image/jxl", extensions: ["jxl"], category: "image", compressible: false, isBinary: true },
{ mime: "image/x-exr", extensions: ["exr"], category: "image", compressible: false, isBinary: true },
{ mime: "image/vnd.ms-dds", extensions: ["dds"], category: "image", compressible: false, isBinary: true },
{ mime: "image/jp2", extensions: ["jp2", "j2k"], category: "image", compressible: false, isBinary: true },
{ mime: "image/jxr", extensions: ["jxr", "hdp"], category: "image", compressible: false, isBinary: true },
{ mime: "image/flif", extensions: ["flif"], category: "image", compressible: false, isBinary: true },
{ mime: "image/x-qoi", extensions: ["qoi"], category: "image", compressible: false, isBinary: true },
{ mime: "image/vnd.radiance", extensions: ["hdr", "rgbe"], category: "image", compressible: false, isBinary: true },
{ mime: "image/x-portable-bitmap", extensions: ["pbm", "pgm", "ppm", "pnm"], category: "image", compressible: true, isBinary: false },
{ mime: "image/x-xpixmap", extensions: ["xpm"], category: "image", compressible: true, isBinary: false },
{ mime: "image/x-win-bitmap", extensions: ["cur"], category: "image", compressible: false, isBinary: true },
{ mime: "image/icns", extensions: ["icns"], category: "image", compressible: false, isBinary: true },
// === font ===
{ mime: "font/woff", extensions: ["woff"], category: "font", compressible: false, isBinary: true },
{ mime: "font/woff2", extensions: ["woff2"], category: "font", compressible: false, isBinary: true },
{ mime: "font/otf", extensions: ["otf"], category: "font", compressible: true, isBinary: true },
{ mime: "font/ttf", extensions: ["ttf"], category: "font", compressible: true, isBinary: true },
{ mime: "font/collection", extensions: ["ttc"], category: "font", compressible: true, isBinary: true },
// === model ===
{ mime: "model/gltf+json", extensions: ["gltf"], category: "model", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "model/gltf-binary", extensions: ["glb"], category: "model", compressible: true, isBinary: true },
{ mime: "model/vnd.usdz+zip", extensions: ["usdz"], category: "model", compressible: false, isBinary: true },
{ mime: "model/obj", extensions: ["obj"], category: "model", compressible: true, isBinary: false },
{ mime: "model/polygon+ply", extensions: ["ply"], category: "model", compressible: true, isBinary: false },
{ mime: "model/stl", extensions: ["stl"], category: "model", compressible: true, isBinary: false },
{ mime: "model/3mf", extensions: ["3mf"], category: "model", compressible: false, isBinary: true },
// === text ===
{ mime: "text/plain", extensions: ["txt", "text", "log"], category: "text", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "text/html", extensions: ["html", "htm"], category: "text", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "text/css", extensions: ["css"], category: "text", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "text/csv", extensions: ["csv"], category: "text", charset: "utf-8", compressible: true, isBinary: false, spec: "RFC 4180" },
{ mime: "text/tab-separated-values", extensions: ["tsv"], category: "text", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "text/markdown", extensions: ["md", "markdown"], category: "text", charset: "utf-8", compressible: true, isBinary: false, spec: "RFC 7763" },
{ mime: "text/xml", extensions: [], category: "text", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "text/x-typescript", extensions: [], category: "text", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "text/x-python", extensions: ["py"], category: "text", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "text/x-shellscript", extensions: ["sh"], category: "text", charset: "utf-8", compressible: true, isBinary: false },
{ mime: "text/event-stream", extensions: [], category: "text", charset: "utf-8", compressible: false, isBinary: false, spec: "WHATWG SSE" },
]);
export const BUILTIN_DB: readonly MimeEntry[] = ENTRIES;

View File

@@ -0,0 +1,52 @@
/**
* Spec-conformance tests for `openspec/specs/mime/spec.md`.
*
* Pins the previously-unverified scenarios:
* - MIME-07: `registerMimeEntry` validates against `MimeEntrySchema`
* and throws `ParseError` on shape violation.
* - MIME-12 / MIME-16: `detect` validates `DetectInput` against
* `DetectInputSchema` and throws `ParseError` on shape violation.
* - MIME-20: `src/__benchmarks__/mime.bench.ts` exists at the
* documented path.
*/
import { existsSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { detect } from "../detection/detect.js";
import { ParseError } from "../errors/parse-error.js";
import { registerMimeEntry } from "./registry.js";
describe("registerMimeEntry — MimeEntrySchema validation [MIME-07]", () => {
it("throws ParseError on invalid MimeEntry shape", {
timeout: 5000,
}, () => {
// Missing required `category` and `isBinary`; mime is empty
const invalid = {
mime: "",
extensions: [],
} as unknown as Parameters<typeof registerMimeEntry>[0];
expect(() => registerMimeEntry(invalid)).toThrow(ParseError);
});
});
describe("detect — DetectInputSchema validation [MIME-12 / MIME-16]", () => {
it("detect throws ParseError on invalid input shape", {
timeout: 5000,
}, () => {
// Wrong field type for `extension` — must be string.
expect(() =>
detect({ extension: 123 } as unknown as Parameters<
typeof detect
>[0])
).toThrow(ParseError);
});
});
describe("benchmarks layout [MIME-20]", () => {
it("src/__benchmarks__/ exists with the documented bench suites", {
timeout: 5000,
}, () => {
const bench = join(__dirname, "..", "__benchmarks__", "mime.bench.ts");
expect(existsSync(bench)).toBe(true);
});
});

View File

@@ -0,0 +1,223 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { BUILTIN_DB } from "./builtin-db.js";
import type { MimeEntry } from "./mime-entry.types.js";
import {
_indexEntryForTesting,
getEntryByExtension,
getMimeEntry,
listMimeEntries,
registerMimeEntry,
} from "./registry.js";
describe("BUILTIN_DB", () => {
it("is a non-empty frozen array", { timeout: 5000 }, () => {
expect(Array.isArray(BUILTIN_DB)).toBe(true);
expect(BUILTIN_DB.length).toBeGreaterThan(50);
expect(Object.isFrozen(BUILTIN_DB)).toBe(true);
});
it("contains expected common MIME types", { timeout: 5000 }, () => {
const mimes = BUILTIN_DB.map((e) => e.mime);
expect(mimes).toContain("image/png");
expect(mimes).toContain("image/jpeg");
expect(mimes).toContain("application/json");
expect(mimes).toContain("text/html");
expect(mimes).toContain("video/mp4");
expect(mimes).toContain("audio/mpeg");
expect(mimes).toContain("font/woff2");
expect(mimes).toContain("application/pdf");
expect(mimes).toContain("application/zip");
});
it("every entry has valid shape", { timeout: 5000 }, () => {
for (const entry of BUILTIN_DB) {
expect(typeof entry.mime).toBe("string");
expect(entry.mime).toContain("/");
expect(Array.isArray(entry.extensions)).toBe(true);
expect(typeof entry.isBinary).toBe("boolean");
expect(entry.category).toBeTruthy();
}
});
});
describe("getMimeEntry", () => {
it("finds image/png by exact MIME type", { timeout: 5000 }, () => {
const entry = getMimeEntry("image/png");
expect(entry).toBeDefined();
expect(entry?.mime).toBe("image/png");
expect(entry?.category).toBe("image");
});
it("is case-insensitive", { timeout: 5000 }, () => {
expect(getMimeEntry("IMAGE/PNG")).toEqual(getMimeEntry("image/png"));
});
it("returns undefined for unknown MIME type", { timeout: 5000 }, () => {
expect(getMimeEntry("x/nonexistent-mime-type-12345")).toBeUndefined();
});
it("finds application/json with correct charset", { timeout: 5000 }, () => {
const entry = getMimeEntry("application/json");
expect(entry?.charset).toBe("utf-8");
expect(entry?.compressible).toBe(true);
});
});
describe("getEntryByExtension", () => {
it("finds entry for 'png'", { timeout: 5000 }, () => {
const entry = getEntryByExtension("png");
expect(entry?.mime).toBe("image/png");
});
it("finds entry for '.png' (leading dot)", { timeout: 5000 }, () => {
const entry = getEntryByExtension(".png");
expect(entry?.mime).toBe("image/png");
});
it("is case-insensitive", { timeout: 5000 }, () => {
expect(getEntryByExtension("PNG")).toEqual(getEntryByExtension("png"));
});
it("returns undefined for unknown extension", { timeout: 5000 }, () => {
expect(getEntryByExtension("__not_a_real_extension__")).toBeUndefined();
});
it("finds 'jpg' extension", { timeout: 5000 }, () => {
expect(getEntryByExtension("jpg")?.mime).toBe("image/jpeg");
});
it("finds 'json' extension", { timeout: 5000 }, () => {
expect(getEntryByExtension("json")?.mime).toBe("application/json");
});
});
describe("listMimeEntries", () => {
it("returns at least as many entries as BUILTIN_DB", {
timeout: 5000,
}, () => {
const list = listMimeEntries();
expect(list.length).toBeGreaterThanOrEqual(BUILTIN_DB.length);
});
it("is a snapshot (different reference on each call)", {
timeout: 5000,
}, () => {
const a = listMimeEntries();
const b = listMimeEntries();
expect(a).not.toBe(b);
});
});
describe("registerMimeEntry", () => {
it("adds a new entry retrievable by mime type", { timeout: 5000 }, () => {
const custom: MimeEntry = {
mime: "application/x-test-custom-type-99",
extensions: ["xcustom99"],
category: "application",
isBinary: false,
};
registerMimeEntry(custom);
const found = getMimeEntry("application/x-test-custom-type-99");
expect(found).toEqual(custom);
});
it("registered entry is retrievable by extension", {
timeout: 5000,
}, () => {
const custom: MimeEntry = {
mime: "application/x-test-ext-lookup-99",
extensions: ["xextlookup99"],
category: "application",
isBinary: false,
};
registerMimeEntry(custom);
expect(getEntryByExtension("xextlookup99")?.mime).toBe(
"application/x-test-ext-lookup-99"
);
});
it("overrides an existing entry", { timeout: 5000 }, () => {
const override: MimeEntry = {
mime: "image/png",
extensions: ["png"],
category: "image",
isBinary: true,
compressible: true, // changed from false
};
registerMimeEntry(override);
const found = getMimeEntry("image/png");
expect(found?.compressible).toBe(true);
// Reset
registerMimeEntry({
mime: "image/png",
extensions: ["png"],
category: "image",
isBinary: true,
compressible: false,
});
});
it("appears in listMimeEntries", { timeout: 5000 }, () => {
const custom: MimeEntry = {
mime: "application/x-list-test-99",
extensions: [],
category: "application",
isBinary: true,
};
registerMimeEntry(custom);
const list = listMimeEntries();
expect(list.some((e) => e.mime === "application/x-list-test-99")).toBe(
true
);
});
});
describe("_indexEntryForTesting (duplicate extension guard)", () => {
it("first-write-wins: duplicate extension does not override existing entry", {
timeout: 5000,
}, () => {
// "png" is already mapped to image/png in the builtin DB
const competitor: MimeEntry = {
mime: "image/x-fake-png",
extensions: ["png"],
category: "image",
isBinary: true,
};
_indexEntryForTesting(competitor);
// The original image/png entry should still own the "png" extension
const found = getEntryByExtension("png");
expect(found?.mime).toBe("image/png");
});
});
describe("registry round-trip (property-based)", () => {
it("every registered entry is retrievable by mime", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.constantFrom(
"application",
"text",
"image",
"audio",
"video"
),
fc.stringMatching(/^[a-z][a-z0-9-]+$/),
(category, token) => {
const mime = `${category}/x-prop-test-${token}`;
const entry: MimeEntry = {
mime,
extensions: [token],
category: category as MimeEntry["category"],
isBinary: false,
};
registerMimeEntry(entry);
const found = getMimeEntry(mime);
expect(found?.mime).toBe(mime);
}
)
);
});
});

View File

@@ -0,0 +1,8 @@
export { BUILTIN_DB } from "./builtin-db.js";
export type { MimeCategory, MimeEntry } from "./mime-entry.types.js";
export {
getEntryByExtension,
getMimeEntry,
listMimeEntries,
registerMimeEntry,
} from "./registry.js";

View File

@@ -0,0 +1,38 @@
/**
* Canonical MIME entry type definitions.
*
* @module db/mime-entry.types
*/
/** Top-level IANA category for a MIME type. */
export type MimeCategory =
| "application"
| "audio"
| "font"
| "image"
| "message"
| "model"
| "multipart"
| "text"
| "video";
/**
* A single entry in the MIME registry.
*
* `mime` is the canonical MIME type string (e.g. `"image/png"`).
* `extensions` lists all known file extensions, first being the default.
* `category` is the top-level IANA category.
* `charset` is the canonical charset for text types.
* `compressible` indicates whether HTTP compression is appropriate.
* `isBinary` indicates whether the format is binary (non-text).
* `spec` optionally references the defining RFC or standard.
*/
export interface MimeEntry {
readonly mime: string;
readonly extensions: readonly string[];
readonly category: MimeCategory;
readonly charset?: string;
readonly compressible?: boolean;
readonly isBinary: boolean;
readonly spec?: string;
}

View File

@@ -0,0 +1,70 @@
# db/registry
## Purpose
The central in-memory MIME type database for `@suaveplan/mime`. Maintains two indexes over the built-in `MimeEntry` list: a `type → extensions` forward map and an `extension → entry` reverse map. Provides O(1) lookup in both directions and supports registering additional entries at runtime for extensibility.
## Features
- `getEntryByMime(mime)` — forward lookup: MIME type string → `MimeEntry`
- `getEntryByExtension(ext)` — reverse lookup: file extension → `MimeEntry`
- `registerEntry(entry)` — register a custom MIME type at runtime
- First-write-wins semantics: built-in entries take precedence over later registrations for the same extension
- Extension keys are normalized to lowercase with no leading dot
- Both indexes are populated lazily on first access (module load)
- `_indexEntryForTesting` — exported only for internal test instrumentation
## Usage: Basic Example
```typescript
import { getEntryByMime, getEntryByExtension } from "@suaveplan/mime/db/registry";
// Forward lookup
const pngEntry = getEntryByMime("image/png");
console.log(pngEntry?.extensions); // ["png"]
// Reverse lookup
const entry = getEntryByExtension("jpg");
console.log(entry?.mime); // "image/jpeg"
```
## Usage: Advanced Example
```typescript
import { getEntryByMime, registerEntry } from "@suaveplan/mime/db/registry";
// Register a custom MIME type
registerEntry({
mime: "application/x-my-format",
extensions: ["myf", "myfmt"],
encoding: "binary",
});
// Immediately available via lookup
const custom = getEntryByMime("application/x-my-format");
console.log(custom?.extensions); // ["myf", "myfmt"]
// Built-in extensions are protected (first-write-wins)
// Registering a second entry for ".png" will not override image/png
registerEntry({ mime: "image/x-fake-png", extensions: ["png"] });
const png = getEntryByExtension("png");
console.log(png?.mime); // still "image/png"
```
## API Reference
### `getEntryByMime(mime: string): MimeEntry | undefined`
Looks up a `MimeEntry` by its MIME type string. Returns `undefined` if not found.
### `getEntryByExtension(ext: string): MimeEntry | undefined`
Looks up a `MimeEntry` by file extension (with or without leading dot, case-insensitive). Returns `undefined` if not found.
### `registerEntry(entry: MimeEntry): void`
Registers a new MIME type entry. First-write-wins for extensions: if an extension is already in the index, the new entry does not replace it.
## Implementation Notes
The registry is initialized at module load by iterating `BUILTIN_DB` once and calling the private `index()` function for each entry. The `index()` function implements the first-write-wins extension guard: `if (!byExtension.has(key)) byExtension.set(key, entry)`. This guard prevents downstream `registerEntry` calls from overriding the canonical extension-to-type mapping established by the built-in database, which is important for stable behavior across multiple `registerEntry` calls in plugin-style usage.

View File

@@ -0,0 +1,84 @@
/**
* Extensible MIME registry.
*
* Starts from the curated `BUILTIN_DB` and accepts runtime additions via
* `registerMimeEntry`. Lookups are O(1) by MIME type and by extension.
*
* @module db/registry
*/
import { ParseError } from "../errors/parse-error.js";
import { MimeEntrySchema } from "../schemas/mime.schemas.js";
import { BUILTIN_DB } from "./builtin-db.js";
import type { MimeEntry } from "./mime-entry.types.js";
const byMime = new Map<string, MimeEntry>();
const byExtension = new Map<string, MimeEntry>();
function index(entry: MimeEntry): void {
byMime.set(entry.mime.toLowerCase(), entry);
for (const ext of entry.extensions) {
const key = ext.toLowerCase();
// First-write wins: preserve the first entry to register a given extension
if (!byExtension.has(key)) {
byExtension.set(key, entry);
}
}
}
for (const entry of BUILTIN_DB) index(entry);
/**
* Register a new MIME entry, or override an existing one.
* The first extension in `entry.extensions` wins for extension lookups unless
* a higher-priority entry is already registered.
*/
export function registerMimeEntry(entry: MimeEntry): void {
const result = MimeEntrySchema.safeParse(entry);
if (!result.success) {
// Zod invariant: when safeParse.success is false, issues is non-empty.
const issue = result.error.issues[0] as { message: string };
throw new ParseError({
message: issue.message,
context: { input: JSON.stringify(entry) },
});
}
// Allow overriding extension mapping for custom entries.
byMime.set(entry.mime.toLowerCase(), entry);
for (const ext of entry.extensions) {
byExtension.set(ext.toLowerCase(), entry);
}
}
/**
* Look up a MIME entry by its canonical MIME type string.
* Returns `undefined` when not found.
*/
export function getMimeEntry(mime: string): MimeEntry | undefined {
return byMime.get(mime.toLowerCase());
}
/**
* Look up a MIME entry by file extension (with or without leading dot).
* Returns `undefined` when not found.
*/
export function getEntryByExtension(ext: string): MimeEntry | undefined {
const key = ext.replace(/^\./, "").toLowerCase();
return byExtension.get(key);
}
/**
* Returns a snapshot of all registered MIME entries (built-in + runtime).
*/
export function listMimeEntries(): readonly MimeEntry[] {
return [...byMime.values()];
}
/**
* Re-indexes an entry via the first-write-wins extension logic.
* Exposed for testing the duplicate-extension guard in `index()`.
* @internal
*/
export function _indexEntryForTesting(entry: MimeEntry): void {
index(entry);
}

View File

@@ -0,0 +1,90 @@
# detection/detect
## Purpose
The unified MIME detection entry point for `@suaveplan/mime`. Merges evidence from three independent sources — magic bytes, file extension, and a declared `Content-Type` header — using a confidence-scoring model. When sources agree, the confidence is boosted above any single-source threshold. When they disagree, magic bytes win over extension, which wins over declared headers, reflecting real-world reliability.
## Features
- Multi-source detection: bytes, extension/filename, Content-Type header
- Confidence scores: magic-bytes=0.95, extension=0.7, content-type=0.6
- Agreement boost: +0.05 per additional agreeing source (capped at 1.0)
- Falls back to `application/octet-stream` with confidence=0 when no sources available
- Accepts `filename` as an alias for `extension` (strips path components automatically)
- Swallows malformed Content-Type headers gracefully (no throws)
- Re-exports `detectFromMagicBytes` and `detectAllFromMagicBytes` for convenience
## Usage: Basic Example
```typescript
import { detect } from "@suaveplan/mime/detection/detect";
const result = detect({
bytes: fileBuffer,
filename: "photo.jpg",
});
console.log(result.mime); // "image/jpeg"
console.log(result.confidence); // 0.95 (magic bytes match)
console.log(result.source); // "magic-bytes"
```
## Usage: Advanced Example
```typescript
import { detect } from "@suaveplan/mime/detection/detect";
// All three sources provided — if they agree, confidence is boosted
const result = detect({
bytes: buffer,
filename: "report.pdf",
contentType: "application/pdf",
});
if (result.confidence > 0.9) {
console.log("High confidence:", result.mime);
console.log("Sources:", result.candidates.map(c => c.source));
}
// Only extension provided
const byExtension = detect({ extension: "svg" });
console.log(byExtension.mime); // "image/svg+xml"
console.log(byExtension.confidence); // 0.7
// No sources — safe fallback
const fallback = detect({});
console.log(fallback.mime); // "application/octet-stream"
console.log(fallback.confidence); // 0
```
## API Reference
### `detect(input: DetectInput): DetectResult`
| Parameter | Type | Description |
|-----------|------|-------------|
| `input` | `DetectInput` | Detection inputs — at least one field should be set. |
**Returns:** `DetectResult` with `mime`, `confidence`, `source`, and `candidates` array.
**Throws:** Never — malformed Content-Type headers are silently ignored.
### `DetectInput`
| Field | Type | Description |
|-------|------|-------------|
| `bytes?` | `Uint8Array` | Raw file bytes for magic-byte detection |
| `extension?` | `string` | File extension (with or without dot) |
| `filename?` | `string` | Full filename or path (extension extracted automatically) |
| `contentType?` | `string` | Raw `Content-Type` header value |
### `DetectResult`
| Field | Type | Description |
|-------|------|-------------|
| `mime` | `string` | Best MIME type guess |
| `confidence` | `number` | 0.0–1.0 confidence score |
| `source` | `DetectSource` | Primary detection source |
| `candidates` | `Candidate[]` | All candidate MIME types with their sources |
## Implementation Notes
Candidates are collected in descending confidence order: magic-bytes first, then extension, then content-type. Because this order is fixed and confidence values are monotonically decreasing, the first candidate in the array is always the best. The confidence boost formula `min(1, bestScore + (agreeing - 1) * 0.05)` adds 5% for each additional source that agrees with the winner, reflecting that corroborating evidence raises overall certainty.

View File

@@ -0,0 +1,118 @@
/**
* Multi-source MIME detection with confidence scoring.
*
* Merges evidence from bytes, a filename/extension, and a declared
* `Content-Type` header. Magic bytes beat extension beats declared headers
* when they disagree, reflecting the real-world reliability of each source.
*
* @module detection/detect
*/
import z from "zod";
import { ParseError } from "../errors/parse-error.js";
import type {
DetectInput,
DetectResult,
DetectSource,
} from "./detection.types.js";
import { detectFromContentType } from "./from-content-type.js";
import { detectFromExtension } from "./from-extension.js";
import {
detectAllFromMagicBytes,
detectFromMagicBytes,
} from "./from-magic-bytes/from-magic-bytes.js";
const _DetectShapeSchema = z.object({
bytes: z
.union([z.instanceof(Uint8Array), z.instanceof(ArrayBuffer)])
.optional(),
filename: z.string().optional(),
extension: z.string().optional(),
contentType: z.string().optional(),
signal: z.instanceof(AbortSignal).optional(),
});
type Candidate = { mime: string; source: DetectSource };
const CONFIDENCE: Record<DetectSource, number> = {
"magic-bytes": 0.95,
extension: 0.7,
"content-type": 0.6,
stream: 0.9,
};
const DEFAULT_MIME = "application/octet-stream";
/**
* Unified MIME detection with multi-source confidence scoring.
*
* Priority order: magic bytes → extension → content-type.
* When multiple sources agree, confidence is boosted.
*
* @param input - Detection inputs (at least one field must be set).
* @returns Detection result with the best MIME type and confidence score.
*/
export function detect(input: DetectInput): DetectResult {
const parsed = _DetectShapeSchema.safeParse(input);
if (!parsed.success) {
// Zod invariant: when safeParse.success is false, issues is non-empty.
const issue = parsed.error.issues[0] as { message: string };
throw new ParseError({
message: issue.message,
context: { input: JSON.stringify(input) },
});
}
const candidates: Candidate[] = [];
if (input.bytes != null) {
const result = detectFromMagicBytes(input.bytes);
if (result != null) {
candidates.push({ mime: result.mime, source: "magic-bytes" });
}
}
const extInput = input.extension ?? input.filename;
if (extInput != null) {
const result = detectFromExtension(extInput);
if (result != null) {
candidates.push({ mime: result.mime, source: "extension" });
}
}
if (input.contentType != null) {
try {
const result = detectFromContentType(input.contentType);
candidates.push({ mime: result.mime, source: "content-type" });
} catch {
// A malformed header contributes no evidence. Callers wanting
// strict validation should call `parseContentType` directly.
}
}
if (candidates.length === 0) {
return {
mime: DEFAULT_MIME,
confidence: 0,
source: "content-type",
candidates: [],
};
}
// Candidates are pushed in descending confidence order:
// magic-bytes (0.95) → extension (0.7) → content-type (0.6).
// The first candidate is therefore always the highest-confidence one.
const best = candidates[0]!;
const bestScore = CONFIDENCE[best.source];
const agreeing = candidates.filter((c) => c.mime === best.mime).length;
const boost = Math.min(1, bestScore + (agreeing - 1) * 0.05);
return {
mime: best.mime,
confidence: boost,
source: best.source,
candidates,
};
}
export { detectAllFromMagicBytes, detectFromMagicBytes };

View File

@@ -0,0 +1,175 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { InvalidContentTypeError } from "../errors/invalid-content-type-error.js";
import { detect } from "./detect.js";
import { detectFromContentType } from "./from-content-type.js";
import { detectFromExtension } from "./from-extension.js";
// ─── Known magic-byte buffer for unified detect tests ──────────────────────
const PNG = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
]);
describe("detectFromExtension", () => {
it("detects PNG from extension", { timeout: 5000 }, () => {
expect(detectFromExtension("png")?.mime).toBe("image/png");
});
it("detects from filename", { timeout: 5000 }, () => {
expect(detectFromExtension("photo.jpg")?.mime).toBe("image/jpeg");
});
it("detects from full path", { timeout: 5000 }, () => {
expect(detectFromExtension("/home/user/doc.pdf")?.mime).toBe(
"application/pdf"
);
});
it("detects from Windows path", { timeout: 5000 }, () => {
expect(detectFromExtension("C:\\Users\\file.png")?.mime).toBe(
"image/png"
);
});
it("normalises leading dot", { timeout: 5000 }, () => {
expect(detectFromExtension(".png")?.mime).toBe("image/png");
});
it("returns undefined for unknown extension", { timeout: 5000 }, () => {
expect(detectFromExtension("unknownextension99999")).toBeUndefined();
});
it("returns undefined for empty string", { timeout: 5000 }, () => {
expect(detectFromExtension("")).toBeUndefined();
});
it("returns source field as 'extension'", { timeout: 5000 }, () => {
expect(detectFromExtension("png")?.source).toBe("extension");
});
it("includes entry and extension in result", { timeout: 5000 }, () => {
const result = detectFromExtension("json");
expect(result?.extension).toBe("json");
expect(result?.entry).toBeDefined();
});
it("returns undefined for trailing-dot filename (empty extension)", {
timeout: 5000,
}, () => {
expect(detectFromExtension("file.")).toBeUndefined();
});
});
describe("detectFromContentType", () => {
it("detects from valid Content-Type", { timeout: 5000 }, () => {
const result = detectFromContentType("image/png");
expect(result.mime).toBe("image/png");
expect(result.source).toBe("content-type");
});
it("extracts charset from parameter", { timeout: 5000 }, () => {
const result = detectFromContentType("text/html; charset=utf-8");
expect(result.charset).toBe("utf-8");
});
it("falls back to registry charset when not in header", {
timeout: 5000,
}, () => {
const result = detectFromContentType("application/json");
expect(result.charset).toBe("utf-8");
});
it("throws InvalidContentTypeError on invalid input", {
timeout: 5000,
}, () => {
expect(() => detectFromContentType("not-valid")).toThrow(
InvalidContentTypeError
);
});
it("returns entry for known MIME types", { timeout: 5000 }, () => {
const result = detectFromContentType("image/png");
expect(result.entry).toBeDefined();
});
it("returns undefined entry for unknown MIME types", {
timeout: 5000,
}, () => {
const result = detectFromContentType(
"application/x-totally-unknown-mime-9999"
);
expect(result.entry).toBeUndefined();
});
});
describe("detect (unified)", () => {
it("prefers magic bytes over extension", { timeout: 5000 }, () => {
const result = detect({ bytes: PNG, filename: "file.jpg" });
expect(result.mime).toBe("image/png");
expect(result.source).toBe("magic-bytes");
});
it("falls back to extension when no bytes", { timeout: 5000 }, () => {
const result = detect({ filename: "file.mp3" });
expect(result.mime).toBe("audio/mpeg");
expect(result.source).toBe("extension");
});
it("falls back to content-type as last resort", { timeout: 5000 }, () => {
const result = detect({ contentType: "text/css" });
expect(result.mime).toBe("text/css");
expect(result.source).toBe("content-type");
});
it("returns default mime when nothing matches", { timeout: 5000 }, () => {
const result = detect({});
expect(result.mime).toBe("application/octet-stream");
expect(result.confidence).toBe(0);
});
it("boosts confidence when sources agree", { timeout: 5000 }, () => {
const resultSingle = detect({ filename: "file.png" });
const resultAgreeing = detect({ bytes: PNG, filename: "file.png" });
expect(resultAgreeing.confidence).toBeGreaterThan(
resultSingle.confidence
);
});
it("uses extension field directly", { timeout: 5000 }, () => {
const result = detect({ extension: "css" });
expect(result.mime).toBe("text/css");
});
it("handles bytes that don't match any signature (null magic bytes result)", {
timeout: 5000,
}, () => {
// Bytes that are unlikely to match any signature, combined with a fallback extension
const unknown = new Uint8Array([0x00, 0x01, 0x02, 0x03]);
const result = detect({ bytes: unknown, extension: "json" });
// Extension should be used as fallback
expect(result.mime).toBe("application/json");
});
it("handles extension not in DB (null extension result)", {
timeout: 5000,
}, () => {
const result = detect({
extension: "completelyunknownextension99999",
contentType: "text/plain",
});
expect(result.mime).toBe("text/plain");
});
it("ignores malformed content-type gracefully", { timeout: 5000 }, () => {
const result = detect({
filename: "file.png",
contentType: "not-valid",
});
expect(result.mime).toBe("image/png");
});
it("includes candidates array", { timeout: 5000 }, () => {
const result = detect({ bytes: PNG, filename: "file.png" });
expect(result.candidates.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
bytesInclude,
readUInt32BE,
startsWith as startsWith_,
} from "./from-magic-bytes/helpers.js";
describe("helpers: startsWith with null wildcards", () => {
it("matches null wildcard (any byte)", { timeout: 5000 }, () => {
const b = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
expect(startsWith_(b, [0x01, null, 0x03])).toBe(true);
expect(startsWith_(b, [null, null, null, null])).toBe(true);
expect(startsWith_(b, [0x01, null, 0x04])).toBe(false); // mismatch at index 2
});
it("returns false when buffer is too short for the pattern", {
timeout: 5000,
}, () => {
const b = new Uint8Array([0x01, 0x02]); // 2 bytes
// Pattern longer than buffer → early return false
expect(startsWith_(b, [0x01, 0x02, 0x03])).toBe(false);
// With offset, also too short
expect(startsWith_(b, [0x01], 2)).toBe(false);
});
});
describe("helpers: readUInt32BE", () => {
it("reads 4-byte big-endian integer", { timeout: 5000 }, () => {
const b = new Uint8Array([0x00, 0x00, 0x00, 0x0c]);
expect(readUInt32BE(b, 0)).toBe(12);
});
it("reads at offset", { timeout: 5000 }, () => {
const b = new Uint8Array([
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00,
]);
expect(readUInt32BE(b, 4)).toBe(256);
});
it("returns 0 when buffer too short", { timeout: 5000 }, () => {
const b = new Uint8Array([0x01, 0x02]);
expect(readUInt32BE(b, 0)).toBe(0);
});
});
describe("helpers: bytesInclude", () => {
it("finds ascii string in buffer", { timeout: 5000 }, () => {
const b = new TextEncoder().encode("hello world");
expect(bytesInclude(b, "world")).toBe(true);
});
it("returns false when not found", { timeout: 5000 }, () => {
const b = new TextEncoder().encode("hello");
expect(bytesInclude(b, "xyz")).toBe(false);
});
it("returns true for empty search string", { timeout: 5000 }, () => {
const b = new TextEncoder().encode("hello");
expect(bytesInclude(b, "")).toBe(true);
});
it("respects from offset", { timeout: 5000 }, () => {
const b = new TextEncoder().encode("word/document.xml");
expect(bytesInclude(b, "word/document", 0)).toBe(true);
expect(bytesInclude(b, "word/document", 5)).toBe(false);
});
it("returns false when buffer shorter than search string", {
timeout: 5000,
}, () => {
const b = new Uint8Array([0x41, 0x42]);
expect(bytesInclude(b, "ABC")).toBe(false);
});
});

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import {
detectAllFromMagicBytes,
detectFromMagicBytes,
} from "./from-magic-bytes/from-magic-bytes.js";
import { getSignatures } from "./from-magic-bytes/registry.js";
// ─── Known magic-byte buffers ──────────────────────────────────────────────
const PNG = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
]);
const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
const GIF89a = new TextEncoder().encode("GIF89a");
const PDF = new TextEncoder().encode("%PDF-1.7\n");
const GZIP = new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00]);
const WEBP = (() => {
const b = new Uint8Array(12);
b.set([0x52, 0x49, 0x46, 0x46], 0); // RIFF
b.set([0x57, 0x45, 0x42, 0x50], 8); // WEBP
return b;
})();
const ZIP = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00]);
const WASM = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00]);
describe("detectFromMagicBytes", () => {
it("detects PNG", { timeout: 5000 }, () => {
expect(detectFromMagicBytes(PNG)?.mime).toBe("image/png");
});
it("detects JPEG", { timeout: 5000 }, () => {
expect(detectFromMagicBytes(JPEG)?.mime).toBe("image/jpeg");
});
it("detects GIF", { timeout: 5000 }, () => {
expect(detectFromMagicBytes(GIF89a)?.mime).toBe("image/gif");
});
it("detects PDF", { timeout: 5000 }, () => {
expect(detectFromMagicBytes(PDF)?.mime).toBe("application/pdf");
});
it("detects GZIP", { timeout: 5000 }, () => {
expect(detectFromMagicBytes(GZIP)?.mime).toBe("application/gzip");
});
it("detects WebP", { timeout: 5000 }, () => {
expect(detectFromMagicBytes(WEBP)?.mime).toBe("image/webp");
});
it("detects ZIP", { timeout: 5000 }, () => {
expect(detectFromMagicBytes(ZIP)?.mime).toBe("application/zip");
});
it("detects WebAssembly", { timeout: 5000 }, () => {
expect(detectFromMagicBytes(WASM)?.mime).toBe("application/wasm");
});
it("returns undefined for unknown bytes", { timeout: 5000 }, () => {
const unknown = new Uint8Array([
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
]);
// Arrow stream false positive prevention: we can't guarantee null, but we at least verify it returns correctly
const result = detectFromMagicBytes(unknown);
// May or may not match; just ensure it doesn't throw
expect(result === undefined || typeof result.mime === "string").toBe(
true
);
});
it("accepts ArrayBuffer", { timeout: 5000 }, () => {
expect(detectFromMagicBytes(PNG.buffer)?.mime).toBe("image/png");
});
it("returns undefined for empty buffer", { timeout: 5000 }, () => {
expect(detectFromMagicBytes(new Uint8Array(0))).toBeUndefined();
});
it("returns result with priority field", { timeout: 5000 }, () => {
const result = detectFromMagicBytes(PNG);
expect(typeof result?.priority).toBe("number");
});
});
describe("detectAllFromMagicBytes", () => {
it("returns array", { timeout: 5000 }, () => {
const results = detectAllFromMagicBytes(PNG);
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBeGreaterThanOrEqual(1);
});
it("first result for PNG is image/png", { timeout: 5000 }, () => {
const results = detectAllFromMagicBytes(PNG);
expect(results[0]?.mime).toBe("image/png");
});
it("returns multiple matches for MKV/WebM (same EBML magic)", {
timeout: 5000,
}, () => {
const mkv = new Uint8Array([
0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00, 0x00, 0x00,
]);
const results = detectAllFromMagicBytes(mkv);
const mimes = results.map((r) => r.mime);
expect(mimes).toContain("video/x-matroska");
expect(mimes).toContain("video/webm");
});
it("returns empty array for empty buffer", { timeout: 5000 }, () => {
expect(detectAllFromMagicBytes(new Uint8Array(0))).toEqual([]);
});
});
describe("detectFromMagicBytes property test", () => {
it("always returns MagicBytesResult or undefined — never throws", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.uint8Array({ minLength: 0, maxLength: 128 }),
(bytes) => {
let result: ReturnType<typeof detectFromMagicBytes>;
expect(() => {
result = detectFromMagicBytes(bytes);
}).not.toThrow();
expect(
result === undefined || typeof result.mime === "string"
).toBe(true);
}
)
);
});
});
describe("magic-bytes registry: getSignatures", () => {
it("returns signatures sorted by descending priority", {
timeout: 5000,
}, () => {
const sigs = getSignatures();
expect(sigs.length).toBeGreaterThan(0);
for (let i = 1; i < sigs.length; i++) {
expect(sigs[i - 1]!.priority).toBeGreaterThanOrEqual(
sigs[i]!.priority
);
}
});
});

View File

@@ -0,0 +1,238 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { detectFromMagicBytes } from "./from-magic-bytes/from-magic-bytes.js";
// Signature coverage part A: video/mp4, RAR, MPEG, TIFF, HEIC/HEIF/AVIF,
// audio/mpeg, audio/mp4 brands, DEX, Java, AC3, Mach-O, video/3gpp
describe("signature coverage A: ftyp brands and binary formats", () => {
it("detects video/mp4 (isom brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("isom"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("video/mp4");
});
it("detects video/mp4 (mp41 brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("mp41"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("video/mp4");
});
it("returns undefined for ftyp with unknown brand", {
timeout: 5000,
}, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("XXXX"), 8);
const result = detectFromMagicBytes(b);
expect(result === undefined || typeof result.mime === "string").toBe(
true
);
});
it("detects RAR v4 (b[6] === 0x00)", { timeout: 5000 }, () => {
const b = new Uint8Array([
0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00, 0x00,
]);
expect(detectFromMagicBytes(b)?.mime).toBe(
"application/x-rar-compressed"
);
});
it("detects RAR v5 (b[6] === 0x01)", { timeout: 5000 }, () => {
const b = new Uint8Array([
0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00,
]);
expect(detectFromMagicBytes(b)?.mime).toBe(
"application/x-rar-compressed"
);
});
it("detects video/mpeg (0xba start code)", { timeout: 5000 }, () => {
const b = new Uint8Array([0x00, 0x00, 0x01, 0xba, 0x00, 0x00]);
expect(detectFromMagicBytes(b)?.mime).toBe("video/mpeg");
});
it("detects video/mpeg (0xb3 start code)", { timeout: 5000 }, () => {
const b = new Uint8Array([0x00, 0x00, 0x01, 0xb3, 0x00, 0x00]);
expect(detectFromMagicBytes(b)?.mime).toBe("video/mpeg");
});
it("detects image/tiff little-endian", { timeout: 5000 }, () => {
const b = new Uint8Array([0x49, 0x49, 0x2a, 0x00]);
expect(detectFromMagicBytes(b)?.mime).toBe("image/tiff");
});
it("detects image/tiff big-endian", { timeout: 5000 }, () => {
const b = new Uint8Array([0x4d, 0x4d, 0x00, 0x2a]);
expect(detectFromMagicBytes(b)?.mime).toBe("image/tiff");
});
it("detects image/heic (heix brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("heix"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("image/heic");
});
it("detects image/heic (hevc brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("hevc"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("image/heic");
});
it("detects image/heic (hevx brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("hevx"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("image/heic");
});
it("detects image/heif (mif1 brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("mif1"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("image/heif");
});
it("detects image/heif (heim brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("heim"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("image/heif");
});
it("detects image/heif (heis brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("heis"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("image/heif");
});
it("detects image/heif (hevs brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("hevs"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("image/heif");
});
it("detects image/avif (avis brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("avis"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("image/avif");
});
it("detects audio/mpeg sync word variants", { timeout: 5000 }, () => {
for (const b1 of [0xfb, 0xf3, 0xf2, 0xfa, 0xe3, 0xe2]) {
const b = new Uint8Array([0xff, b1, 0x00]);
const result = detectFromMagicBytes(b);
expect(result?.mime).toBe("audio/mpeg");
}
});
it("detects audio/mp4 (M4B brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("M4B "), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("audio/mp4");
});
it("detects audio/mp4 (M4P brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("M4P "), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("audio/mp4");
});
it("detects audio/mp4 (mmp4 brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("mmp4"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("audio/mp4");
});
it("detects application/vnd.android.dex", { timeout: 5000 }, () => {
const b = new Uint8Array(8);
b.set(new TextEncoder().encode("dex\n"), 0);
b[4] = 0x30; // '0'
b[5] = 0x33; // '3'
b[6] = 0x35; // '5'
b[7] = 0x00; // null terminator
expect(detectFromMagicBytes(b)?.mime).toBe(
"application/vnd.android.dex"
);
});
it("detects application/java-vm (Java class file cafebabe)", {
timeout: 5000,
}, () => {
const b = new Uint8Array([
0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00, 0x00, 0x3d,
]);
expect(detectFromMagicBytes(b)?.mime).toBe("application/java-vm");
});
it("detects audio/ac3", { timeout: 5000 }, () => {
const b = new Uint8Array([0x0b, 0x77, 0x00, 0x00]);
expect(detectFromMagicBytes(b)?.mime).toBe("audio/ac3");
});
it("detects application/x-mach-binary (cffa edfe)", {
timeout: 5000,
}, () => {
const b = new Uint8Array([0xcf, 0xfa, 0xed, 0xfe]);
expect(detectFromMagicBytes(b)?.mime).toBe("application/x-mach-binary");
});
it("detects application/x-mach-binary (cefa edfe)", {
timeout: 5000,
}, () => {
const b = new Uint8Array([0xce, 0xfa, 0xed, 0xfe]);
expect(detectFromMagicBytes(b)?.mime).toBe("application/x-mach-binary");
});
it("detects application/x-mach-binary (feed facf)", {
timeout: 5000,
}, () => {
const b = new Uint8Array([0xfe, 0xed, 0xfa, 0xcf]);
expect(detectFromMagicBytes(b)?.mime).toBe("application/x-mach-binary");
});
it("detects application/x-mach-binary (feed face)", {
timeout: 5000,
}, () => {
const b = new Uint8Array([0xfe, 0xed, 0xfa, 0xce]);
expect(detectFromMagicBytes(b)?.mime).toBe("application/x-mach-binary");
});
it("detects video/3gpp (3gp5 brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("3gp5"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("video/3gpp");
});
it("detects video/3gpp (3GP5 uppercase brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("3GP5"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("video/3gpp");
});
it("detects video/3gpp2 (3g2a brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("3g2a"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("video/3gpp2");
});
it("detects video/3gpp2 (3G2A brand)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("ftyp"), 4);
b.set(new TextEncoder().encode("3G2A"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("video/3gpp2");
});
});

View File

@@ -0,0 +1,211 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { detectFromMagicBytes } from "./from-magic-bytes/from-magic-bytes.js";
// Signature coverage part B: AVI, WMV, FLV, DV, fonts, portable bitmaps,
// CPIO, WAV, DFF, Musepack, MOD, iWork, TAR, HDR, AIFF, DMG
describe("signature coverage B: container and multimedia formats", () => {
it("detects video/x-msvideo (AVI)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("RIFF"), 0);
b.set(new TextEncoder().encode("AVI "), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("video/x-msvideo");
});
it("detects video/x-ms-wmv", { timeout: 5000 }, () => {
const b = new Uint8Array([
0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11,
]);
expect(detectFromMagicBytes(b)?.mime).toBe("video/x-ms-wmv");
});
it("detects video/x-flv", { timeout: 5000 }, () => {
const b = new TextEncoder().encode("FLV\x01");
expect(detectFromMagicBytes(b)?.mime).toBe("video/x-flv");
});
it("detects video/x-dv (second variant)", { timeout: 5000 }, () => {
const b = new Uint8Array([0x3f, 0x07, 0x00]);
expect(detectFromMagicBytes(b)?.mime).toBe("video/x-dv");
});
it("detects font/collection (ttcf)", { timeout: 5000 }, () => {
const b = new TextEncoder().encode("ttcf");
expect(detectFromMagicBytes(b)?.mime).toBe("font/collection");
});
it("detects application/vnd.ms-fontobject (variant 2)", {
timeout: 5000,
}, () => {
const b = new Uint8Array(36);
b[8] = 0x01;
b[9] = 0x00;
b[10] = 0x01;
b[11] = 0x00;
b[34] = 0x4c;
b[35] = 0x50;
expect(detectFromMagicBytes(b)?.mime).toBe(
"application/vnd.ms-fontobject"
);
});
it("detects application/vnd.ms-fontobject (variant 3)", {
timeout: 5000,
}, () => {
const b = new Uint8Array(36);
b[8] = 0x01;
b[9] = 0x00;
b[10] = 0x00;
b[11] = 0x00;
b[34] = 0x4c;
b[35] = 0x50;
expect(detectFromMagicBytes(b)?.mime).toBe(
"application/vnd.ms-fontobject"
);
});
it("detects image/x-portable-bitmap (P1 variant)", {
timeout: 5000,
}, () => {
const b = new Uint8Array([0x50, 0x31, 0x0a]); // P1\n
expect(detectFromMagicBytes(b)?.mime).toBe("image/x-portable-bitmap");
});
it("detects image/x-portable-bitmap (P4 variant)", {
timeout: 5000,
}, () => {
const b = new Uint8Array([0x50, 0x34, 0x20]); // P4 (space)
expect(detectFromMagicBytes(b)?.mime).toBe("image/x-portable-bitmap");
});
it("detects image/x-portable-bitmap (P6 variant)", {
timeout: 5000,
}, () => {
const b = new Uint8Array([0x50, 0x36, 0x0d]); // P6\r
expect(detectFromMagicBytes(b)?.mime).toBe("image/x-portable-bitmap");
});
it("detects application/x-cpio (odc format)", { timeout: 5000 }, () => {
const b = new TextEncoder().encode("07070");
expect(detectFromMagicBytes(b)?.mime).toBe("application/x-cpio");
});
it("detects application/x-cpio (byte-swap variant)", {
timeout: 5000,
}, () => {
const b = new Uint8Array([0x71, 0xc7]);
expect(detectFromMagicBytes(b)?.mime).toBe("application/x-cpio");
});
it("detects audio/wav (RIFF+WAVE)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("RIFF"), 0);
b.set(new TextEncoder().encode("WAVE"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("audio/wav");
});
it("detects audio/x-dff (DSD Interchange File Format)", {
timeout: 5000,
}, () => {
const b = new Uint8Array(16);
b.set(new TextEncoder().encode("FRM8"), 0);
b.set(new TextEncoder().encode("DSD "), 12);
expect(detectFromMagicBytes(b)?.mime).toBe("audio/x-dff");
});
it("detects audio/musepack (MP+ variant 1)", { timeout: 5000 }, () => {
const b = new TextEncoder().encode("MP+\x07");
expect(detectFromMagicBytes(b)?.mime).toBe("audio/musepack");
});
it("detects audio/musepack (MP+ variant 2)", { timeout: 5000 }, () => {
const b = new TextEncoder().encode("MP+\x17");
expect(detectFromMagicBytes(b)?.mime).toBe("audio/musepack");
});
it("detects audio/x-mod (M.K. variant)", { timeout: 5000 }, () => {
const b = new Uint8Array(1084);
b.set(new TextEncoder().encode("M.K."), 1080);
expect(detectFromMagicBytes(b)?.mime).toBe("audio/x-mod");
});
it("detects audio/x-mod (M!K! variant)", { timeout: 5000 }, () => {
const b = new Uint8Array(1084);
b.set(new TextEncoder().encode("M!K!"), 1080);
expect(detectFromMagicBytes(b)?.mime).toBe("audio/x-mod");
});
it("detects audio/x-mod (FLT4 variant)", { timeout: 5000 }, () => {
const b = new Uint8Array(1084);
b.set(new TextEncoder().encode("FLT4"), 1080);
expect(detectFromMagicBytes(b)?.mime).toBe("audio/x-mod");
});
it("detects audio/x-mod (FLT8 variant)", { timeout: 5000 }, () => {
const b = new Uint8Array(1084);
b.set(new TextEncoder().encode("FLT8"), 1080);
expect(detectFromMagicBytes(b)?.mime).toBe("audio/x-mod");
});
it("detects application/vnd.apple.pages", { timeout: 5000 }, () => {
const b = new Uint8Array(80);
b.set(new Uint8Array([0x50, 0x4b, 0x03, 0x04]), 0); // ZIP magic
b.set(new TextEncoder().encode("Index/Document.iwa"), 30);
// Must NOT contain "Numbers" or "Keynote"
expect(detectFromMagicBytes(b)?.mime).toBe(
"application/vnd.apple.pages"
);
});
it("detects application/vnd.apple.numbers", { timeout: 5000 }, () => {
const content = "Index/Document.iwa/Numbers/extra";
const b = new Uint8Array(30 + content.length + 10);
b.set(new Uint8Array([0x50, 0x4b, 0x03, 0x04]), 0); // ZIP magic
b.set(new TextEncoder().encode(content), 30);
expect(detectFromMagicBytes(b)?.mime).toBe(
"application/vnd.apple.numbers"
);
});
it("detects application/vnd.apple.keynote", { timeout: 5000 }, () => {
const content = "Index/Document.iwa/Keynote/extra";
const b = new Uint8Array(30 + content.length + 10);
b.set(new Uint8Array([0x50, 0x4b, 0x03, 0x04]), 0); // ZIP magic
b.set(new TextEncoder().encode(content), 30);
expect(detectFromMagicBytes(b)?.mime).toBe(
"application/vnd.apple.keynote"
);
});
it("detects application/x-tar (ustar at offset 257)", {
timeout: 5000,
}, () => {
const b = new Uint8Array(265);
b.set(new TextEncoder().encode("ustar"), 257);
expect(detectFromMagicBytes(b)?.mime).toBe("application/x-tar");
});
it("detects image/vnd.radiance (#?RGBE variant)", { timeout: 5000 }, () => {
const b = new TextEncoder().encode("#?RGBE extra");
expect(detectFromMagicBytes(b)?.mime).toBe("image/vnd.radiance");
});
it("detects audio/aiff (AIFC subtype)", { timeout: 5000 }, () => {
const b = new Uint8Array(12);
b.set(new TextEncoder().encode("FORM"), 0);
b.set(new TextEncoder().encode("AIFC"), 8);
expect(detectFromMagicBytes(b)?.mime).toBe("audio/aiff");
});
it("detects application/x-apple-diskimage (ER variant)", {
timeout: 5000,
}, () => {
const b = new Uint8Array(8);
b[0] = 0x45;
b[1] = 0x52;
b[2] = 0x02;
expect(detectFromMagicBytes(b)?.mime).toBe(
"application/x-apple-diskimage"
);
});
});

View File

@@ -0,0 +1,74 @@
/**
* Detection result types for all detection strategies.
*
* @module detection/detection.types
*/
import type { MimeEntry } from "../db/mime-entry.types.js";
/** Source of a detection result. */
export type DetectSource =
| "magic-bytes"
| "extension"
| "content-type"
| "stream";
/**
* Input for the unified `detect()` function.
* At least one of `bytes`, `filename`/`extension`, or `contentType` must be provided.
*/
export interface DetectInput {
/** Raw byte buffer for magic-byte detection. */
bytes?: Uint8Array | ArrayBuffer;
/** Filename or path — extension is extracted automatically. */
filename?: string;
/** Raw file extension (with or without leading dot). */
extension?: string;
/** Raw `Content-Type` header value. */
contentType?: string;
/** AbortSignal for stream-based detection (passed through to `detectFromStream`). */
signal?: AbortSignal;
}
/** Unified detection result with confidence scoring. */
export interface DetectResult {
readonly mime: string;
readonly confidence: number;
readonly source: DetectSource;
readonly candidates: ReadonlyArray<{
mime: string;
source: DetectSource;
}>;
}
/** Detection result sourced from magic bytes. */
export interface MagicBytesResult {
readonly source: "magic-bytes";
readonly mime: string;
readonly priority: number;
}
/** Detection result sourced from a file extension. */
export interface ExtensionDetectionResult {
readonly source: "extension";
readonly mime: string;
readonly extension: string;
readonly entry: MimeEntry;
}
/** Detection result sourced from a Content-Type header. */
export interface ContentTypeDetectionResult {
readonly source: "content-type";
readonly mime: string;
readonly charset: string | undefined;
readonly entry: MimeEntry | undefined;
}
/** Detection result sourced from a readable stream (first chunk peek). */
export interface StreamDetectionResult {
readonly source: "stream";
readonly mime: string | undefined;
readonly priority: number | undefined;
readonly head: Uint8Array;
readonly tail: AsyncIterable<Uint8Array>;
}

View File

@@ -0,0 +1,56 @@
# detection/from-content-type
## Purpose
Detects the MIME type from a raw `Content-Type` header string. Parses the header using the full RFC 7231 parser and wraps the result in a `DetectionResult` envelope. Serves as one of three detection sources in the unified `detect()` function, with a confidence weight of 0.6 (lowest of the three sources, since declared headers can be wrong or missing).
## Features
- Delegates to `parseContentType` for full RFC 7231 parsing
- Returns a `DetectionResult` with `source: "content-type"` and the canonical `essence`
- Propagates `InvalidContentTypeError` for malformed inputs (callers can catch or ignore)
- Accepts parameters in the header (they are parsed but not included in the MIME result)
## Usage: Basic Example
```typescript
import { detectFromContentType } from "@suaveplan/mime/detection/from-content-type";
const result = detectFromContentType("text/html; charset=utf-8");
console.log(result.mime); // "text/html"
console.log(result.source); // "content-type"
```
## Usage: Advanced Example
```typescript
import { detectFromContentType } from "@suaveplan/mime/detection/from-content-type";
import { InvalidContentTypeError } from "@suaveplan/mime/errors";
// The detect() facade catches these errors automatically.
// Use detectFromContentType directly when you want strict validation:
try {
const result = detectFromContentType(request.headers.get("content-type") ?? "");
console.log("Declared MIME:", result.mime);
} catch (err) {
if (err instanceof InvalidContentTypeError) {
console.warn("Malformed Content-Type header — using fallback detection");
}
}
```
## API Reference
### `detectFromContentType(header: string): DetectionResult`
| Parameter | Type | Description |
|-----------|------|-------------|
| `header` | `string` | Raw `Content-Type` header value. |
**Returns:** `DetectionResult` with `mime` set to the `essence` (`type/subtype`) and `source: "content-type"`.
**Throws:** `InvalidContentTypeError` if the header is malformed.
## Implementation Notes
This function intentionally throws on malformed input rather than returning a fallback. The `detect()` facade wraps calls to `detectFromContentType` in a try/catch to silently skip bad headers. This two-layer design keeps the individual detectors strict while the unified facade remains lenient — matching the principle that each building block should have well-defined error semantics, with fault tolerance composed at a higher level.

View File

@@ -0,0 +1,32 @@
/**
* Resolve a MIME from a raw `Content-Type` header value.
*
* Parses the header via `parseContentType` then looks up the essence in the
* MIME registry. Does not throw on unregistered types — it still returns a
* result for the parsed essence.
*
* @module detection/from-content-type
*/
import { parseContentType } from "../content-type/parse-content-type.js";
import { getMimeEntry } from "../db/registry.js";
import type { ContentTypeDetectionResult } from "./detection.types.js";
/**
* Detect MIME from a `Content-Type` header string.
*
* @param input - Raw Content-Type header value.
* @throws `InvalidContentTypeError` when the input is syntactically invalid.
*/
export function detectFromContentType(
input: string
): ContentTypeDetectionResult {
const parsed = parseContentType(input);
const entry = getMimeEntry(parsed.essence);
return {
source: "content-type" as const,
mime: parsed.essence,
charset: parsed.parameters["charset"] ?? entry?.charset,
entry,
};
}

View File

@@ -0,0 +1,62 @@
# detection/from-extension
## Purpose
Detects the MIME type from a file extension or full filename. Strips path components and leading dots, normalizes to lowercase, and looks up the result in the built-in MIME database. Returns a `DetectionResult` with `source: "extension"` and a confidence weight of 0.7 in the unified `detect()` function.
## Features
- Accepts both raw extensions (`.png`, `png`) and full filenames (`photo.jpeg`)
- Strips directory path components automatically
- Normalizes extensions to lowercase before lookup
- Handles multiple dots in filenames correctly (uses the last extension)
- Returns `undefined` for unknown extensions instead of throwing
- Wraps the database entry in a `DetectionResult` envelope
## Usage: Basic Example
```typescript
import { detectFromExtension } from "@suaveplan/mime/detection/from-extension";
const result = detectFromExtension("photo.jpg");
console.log(result?.mime); // "image/jpeg"
console.log(result?.source); // "extension"
// Just an extension without filename
const svg = detectFromExtension(".svg");
console.log(svg?.mime); // "image/svg+xml"
```
## Usage: Advanced Example
```typescript
import { detectFromExtension } from "@suaveplan/mime/detection/from-extension";
function getMimeForUpload(filename: string): string {
const detected = detectFromExtension(filename);
// Fall back to binary stream for unknown extensions
return detected?.mime ?? "application/octet-stream";
}
// Handles paths with directories
console.log(detectFromExtension("/uploads/2024/report.pdf")?.mime); // "application/pdf"
// Unknown extension returns undefined
console.log(detectFromExtension("file.xyz")); // undefined
```
## API Reference
### `detectFromExtension(input: string): DetectionResult | undefined`
| Parameter | Type | Description |
|-----------|------|-------------|
| `input` | `string` | File extension (with or without dot) or full filename/path. |
**Returns:** `DetectionResult` with `source: "extension"` and the matched `mime`, or `undefined` when the extension is not in the database.
**Throws:** Never.
## Implementation Notes
The function extracts the extension by splitting on `.` and taking the last segment, then looking up `getEntryByExtension`. The split also handles filenames with no extension (returns `undefined`) and filenames starting with a dot (treated as hidden files with no extension, e.g. `.gitignore`). Path components are stripped by looking only at the basename portion after the last `/` or `\`. Lookup is O(1) via the registry's `Map<string, MimeEntry>` index.

View File

@@ -0,0 +1,35 @@
/**
* Extension-based MIME detection.
*
* Accepts a filename, a path, or a bare extension (with or without leading
* dot). Returns `undefined` when no entry matches.
*
* @module detection/from-extension
*/
import { getEntryByExtension } from "../db/registry.js";
import type { ExtensionDetectionResult } from "./detection.types.js";
/**
* Detect MIME type from a file extension, filename, or path.
*
* @param input - Bare extension, filename, or path string.
* @returns `ExtensionDetectionResult` or `undefined` when not found.
*/
export function detectFromExtension(
input: string
): ExtensionDetectionResult | undefined {
if (typeof input !== "string" || input.length === 0) return undefined;
const base = input.replace(/[/\\]+$/, "");
const lastSlash = Math.max(base.lastIndexOf("/"), base.lastIndexOf("\\"));
const name = lastSlash >= 0 ? base.slice(lastSlash + 1) : base;
const dot = name.lastIndexOf(".");
const raw = dot >= 0 ? name.slice(dot + 1) : name;
if (!raw) return undefined;
const extension = raw.toLowerCase();
const entry = getEntryByExtension(extension);
if (!entry) return undefined;
return { source: "extension" as const, mime: entry.mime, extension, entry };
}

View File

@@ -0,0 +1,86 @@
# detection/from-magic-bytes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - helpers — Low-level byte-inspection utilities used……
- [helpers](./helpers.md) — Low-level byte-inspection utilities used by the magic-byte signature matchers. Provides helpers for common patterns: pr…
- [registry](./registry.md) — The mutable signature registry for magic-byte detection. Stores all registered byte-pattern matchers and provides CRUD-…
- helpers — Low-level byte-inspection utilities used……
- [helpers](./helpers.md) — Low-level byte-inspection utilities used by the magic-byte signature matchers. Provides helpers for common patterns: pr…
- [registry](./registry.md) — The mutable signature registry for magic-byte detection. Stores all registered byte-pattern matchers and provides CRUD-…
- helpers — Low-level byte-inspection utilities used……
- [helpers](./helpers.md) — Low-level byte-inspection utilities used by the magic-byte signature matchers. Provides helpers for common patterns: pr…
- [registry](./registry.md) — The mutable signature registry for magic-byte detection. Stores all registered byte-pattern matchers and provides CRUD-…
- helpers — Low-level byte-inspection utilities used…
- [helpers](./helpers.md) — Low-level byte-inspection utilities used by the magic-byte signature matchers. Provides helpers for common patterns: pr…
- [registry](./registry.md) — The mutable signature registry for magic-byte detection. Stores all registered byte-pattern matchers and provides CRUD-…
- [helpers](./helpers.md) — Low-level byte-inspection utilities used by the magic-byte signature matchers. Provides helpers for common patterns: pr…
- [registry](./registry.md) — The mutable signature registry for magic-byte detection. Stores all registered byte-pattern matchers and provides CRUD-…
## Purpose
Implements magic-byte (file signature) detection for MIME types. Maintains a registry of byte-pattern matchers sorted by priority, and provides functions to find the best match, all matches, or reset the registry. Magic-byte detection has the highest confidence (0.95) in the unified `detect()` function because byte signatures are objective evidence that cannot be spoofed by file naming or HTTP headers.
## Features
- `detectFromMagicBytes(bytes)` — returns the highest-priority matching MIME type
- `detectAllFromMagicBytes(bytes)` — returns all matching MIME types sorted by priority
- Registry sorted by descending priority for deterministic match order
- Accepts any `Uint8Array` or `ArrayBuffer`-backed view
- Returns `undefined` (not throws) when no signature matches
- Supports minimum-bytes threshold per signature — skips signatures when the input is too short
## Usage: Basic Example
```typescript
import { detectFromMagicBytes } from "@suaveplan/mime/detection/from-magic-bytes/from-magic-bytes";
// PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const result = detectFromMagicBytes(pngBytes);
console.log(result?.mime); // "image/png"
console.log(result?.priority); // 100
```
## Usage: Advanced Example
```typescript
import {
detectFromMagicBytes,
detectAllFromMagicBytes,
} from "@suaveplan/mime/detection/from-magic-bytes/from-magic-bytes";
// Detect all matching signatures (useful for ambiguous formats like MP4 variants)
const mp4Bytes = new Uint8Array([...]);
const allMatches = detectAllFromMagicBytes(mp4Bytes);
for (const match of allMatches) {
console.log(match.mime, match.priority);
}
// Returns undefined for unrecognized byte sequences
const random = new Uint8Array([0x00, 0x01, 0x02, 0x03]);
console.log(detectFromMagicBytes(random)); // undefined
```
## API Reference
### `detectFromMagicBytes(bytes: Uint8Array): SignatureMatch | undefined`
| Parameter | Type | Description |
|-----------|------|-------------|
| `bytes` | `Uint8Array` | Byte buffer to inspect (first N bytes of a file). |
**Returns:** The highest-priority `SignatureMatch` (`mime`, `priority`), or `undefined`.
### `detectAllFromMagicBytes(bytes: Uint8Array): SignatureMatch[]`
Returns all matching signatures sorted by descending priority.
## Implementation Notes
The registry is sorted once whenever a new signature is registered, using a stable sort on `priority` (descending). Detection iterates the sorted array and returns the first match (for `detectFromMagicBytes`) or all matches (for `detectAllFromMagicBytes`). Signatures with a `minBytes` threshold are skipped when the input buffer is too short, enabling detection on partial reads without false negatives from truncated magic sequences.

View File

@@ -0,0 +1,74 @@
/**
* Detect MIME type from a byte prefix using the registered signature set.
*
* Accepts `Uint8Array` or `ArrayBuffer`. Returns `undefined` when no
* signature matches. When multiple signatures match at the same priority,
* returns the first registered.
*
* @module detection/from-magic-bytes/from-magic-bytes
*/
import { getSignatures } from "./registry.js";
// Eagerly evaluate the signature-registration module so its side effects
// fire before the first detection call. The named import is referenced
// inside detectFromMagicBytes so the tree-shaker cannot drop the edge — a
// pure side-effect-only import (`import "./signatures/index.js";`) is
// silently elided when the consuming package declares `sideEffects: false`,
// leaving the registry empty for every downstream consumer.
// Side-effect import — `signatures/index.ts` runs the register() calls
// for every signature category at module init. A bare side-effect import
// is bundler-stable: vite/rollup keep it because the module is declared
// to have side effects (no value is imported, so there's nothing to
// shake). IMPORTANT: this import must stay here, NOT in registry.ts —
// moving it to registry.ts creates a circular dependency.
import "./signatures/index.js";
/** Result from a successful magic-byte detection. */
export interface MagicBytesResult {
readonly mime: string;
readonly priority: number;
}
function asBytes(input: Uint8Array | ArrayBuffer): Uint8Array {
return input instanceof Uint8Array ? input : new Uint8Array(input);
}
/**
* Detect the MIME type from the given byte prefix.
*
* Returns the first (highest-priority) matching signature's MIME type,
* or `undefined` when no signature matches.
*
* @param input - Byte buffer to inspect.
*/
export function detectFromMagicBytes(
input: Uint8Array | ArrayBuffer
): MagicBytesResult | undefined {
const bytes = asBytes(input);
for (const sig of getSignatures()) {
if (bytes.length < sig.minBytes) continue;
if (sig.match(bytes)) {
return { mime: sig.mime, priority: sig.priority };
}
}
return undefined;
}
/**
* Return all signatures matching the prefix, ordered by descending priority.
*
* @param input - Byte buffer to inspect.
*/
export function detectAllFromMagicBytes(
input: Uint8Array | ArrayBuffer
): readonly MagicBytesResult[] {
const bytes = asBytes(input);
const hits: MagicBytesResult[] = [];
for (const sig of getSignatures()) {
if (bytes.length < sig.minBytes) continue;
if (sig.match(bytes)) {
hits.push({ mime: sig.mime, priority: sig.priority });
}
}
return hits;
}

View File

@@ -0,0 +1,75 @@
# detection/from-magic-bytes/helpers
## Purpose
Low-level byte-inspection utilities used by the magic-byte signature matchers. Provides helpers for common patterns: prefix matching, ASCII substring scanning, 32-bit big-endian integer reading, and byte-sequence searching. These functions are designed to be called millions of times without allocation, using only index arithmetic over the input `Uint8Array`.
## Features
- `startsWith(bytes, prefix, offset?)` — check if bytes start with a given byte sequence at an offset
- `hasAscii(bytes, text, offset?)` — check if bytes contain an ASCII string at a given offset
- `readUInt32BE(bytes, offset)` — read a 4-byte big-endian unsigned integer
- `bytesInclude(bytes, needle)` — check if a byte sequence appears anywhere in bytes
- All functions are allocation-free (no `new Uint8Array`, no `slice`)
- All functions are bounds-safe (return false/0 rather than throwing on out-of-range access)
## Usage: Basic Example
```typescript
import { startsWith, hasAscii } from "@suaveplan/mime/detection/from-magic-bytes/helpers";
// Check PNG magic bytes
const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const isPng = startsWith(fileBytes, PNG_MAGIC);
// Check for RIFF tag at offset 0, WAVE tag at offset 8
const isWav = hasAscii(fileBytes, "RIFF") && hasAscii(fileBytes, "WAVE", 8);
```
## Usage: Advanced Example
```typescript
import {
startsWith,
readUInt32BE,
bytesInclude,
} from "@suaveplan/mime/detection/from-magic-bytes/helpers";
// MP4: ftyp box at byte 4
function isMp4(bytes: Uint8Array): boolean {
const boxSize = readUInt32BE(bytes, 0);
return boxSize >= 8 && hasAscii(bytes, "ftyp", 4);
}
// ZIP: PK signature or EOCD record
function isZip(bytes: Uint8Array): boolean {
return startsWith(bytes, new Uint8Array([0x50, 0x4b, 0x03, 0x04])) ||
startsWith(bytes, new Uint8Array([0x50, 0x4b, 0x05, 0x06]));
}
// Search for a needle anywhere in a buffer
const PDF_HEADER = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF
const containsPdf = bytesInclude(largeBuffer, PDF_HEADER);
```
## API Reference
### `startsWith(bytes: Uint8Array, prefix: Uint8Array, offset?: number): boolean`
Returns `true` if `bytes[offset...]` starts with all bytes of `prefix`. Returns `false` if the buffer is too short.
### `hasAscii(bytes: Uint8Array, text: string, offset?: number): boolean`
Returns `true` if the ASCII codes of `text` match `bytes[offset...]`. Returns `false` if the buffer is too short.
### `readUInt32BE(bytes: Uint8Array, offset: number): number`
Reads 4 bytes starting at `offset` as a big-endian unsigned 32-bit integer. Returns `0` if the buffer is too short.
### `bytesInclude(bytes: Uint8Array, needle: Uint8Array): boolean`
Returns `true` if `needle` appears anywhere within `bytes`. Returns `false` if the buffer is shorter than the needle.
## Implementation Notes
All helpers operate directly on the `Uint8Array` buffer without creating sub-views or intermediate arrays. The `hasAscii` function uses `charCodeAt` rather than `TextEncoder` to avoid the allocation overhead of encoding a string to bytes on every call — this matters because signature matchers run on every file detection. The `bytesInclude` function uses a naive O(n×m) scan, which is acceptable given that needle lengths are always small (≤8 bytes for magic sequences) and the search space is bounded by the buffer size.

View File

@@ -0,0 +1,76 @@
/**
* Byte-matching helpers shared by all signature category files.
*
* @module detection/from-magic-bytes/helpers
*/
/**
* Returns `true` when `bytes` starting at `offset` matches every non-null
* element in `pattern`. A `null` element is a wildcard that matches any byte.
*/
export function startsWith(
bytes: Uint8Array,
pattern: readonly (number | null)[],
offset = 0
): boolean {
if (bytes.length < offset + pattern.length) return false;
for (let i = 0; i < pattern.length; i += 1) {
const p = pattern[i];
if (p === null) continue;
if (bytes[offset + i] !== p) return false;
}
return true;
}
/**
* Returns `true` when the ASCII string `text` appears in `bytes` at `offset`.
*/
export function hasAscii(bytes: Uint8Array, text: string, offset = 0): boolean {
if (bytes.length < offset + text.length) return false;
for (let i = 0; i < text.length; i += 1) {
if (bytes[offset + i] !== text.charCodeAt(i)) return false;
}
return true;
}
/**
* Reads an unsigned 32-bit big-endian integer from `bytes` at `offset`.
* Used for MP4 box sizes, AIFF chunk sizes, and ICNS resource sizes.
*/
export function readUInt32BE(bytes: Uint8Array, offset: number): number {
if (bytes.length < offset + 4) return 0;
return (
((bytes[offset]! << 24) |
(bytes[offset + 1]! << 16) |
(bytes[offset + 2]! << 8) |
bytes[offset + 3]!) >>>
0
);
}
/**
* Returns `true` when the ASCII string `ascii` appears anywhere in `bytes`
* starting from `from`. Used for variable-offset signatures (ZIP entries,
* OOXML content paths).
*/
export function bytesInclude(
bytes: Uint8Array,
ascii: string,
from = 0
): boolean {
const len = ascii.length;
if (len === 0) return true;
const limit = bytes.length - len;
if (limit < 0) return false;
for (let i = from; i <= limit; i += 1) {
let match = true;
for (let j = 0; j < len; j += 1) {
if (bytes[i + j] !== ascii.charCodeAt(j)) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}

View File

@@ -0,0 +1,6 @@
export {
detectAllFromMagicBytes,
detectFromMagicBytes,
type MagicBytesResult,
} from "./from-magic-bytes.js";
export { registerSignature, type Signature } from "./registry.js";

View File

@@ -0,0 +1,84 @@
# detection/from-magic-bytes/registry
## Purpose
The mutable signature registry for magic-byte detection. Stores all registered byte-pattern matchers and provides CRUD-like access: `registerSignature` to add a matcher, `getSignatures` to read the sorted list, and `resetSignatures` to clear all entries (used in tests). This module is the single source of truth for the ordered list of matchers consumed by `detectFromMagicBytes`.
## Features
- `registerSignature(sig)` — add a new signature matcher; re-sorts registry by priority
- `getSignatures()` — returns the full registry array sorted by descending priority
- `resetSignatures()` — clears all registered signatures (test isolation only)
- Priority-ordered registry: higher priority signatures are checked first
- Thread-safe within a single JavaScript execution context (no async race conditions)
## Usage: Basic Example
```typescript
import {
registerSignature,
getSignatures,
} from "@suaveplan/mime/detection/from-magic-bytes/registry";
registerSignature({
mime: "application/x-my-format",
priority: 50,
minBytes: 4,
match: (bytes) => bytes[0] === 0x4d && bytes[1] === 0x59,
});
console.log(getSignatures().find(s => s.mime === "application/x-my-format"));
```
## Usage: Advanced Example
```typescript
import { registerSignature } from "@suaveplan/mime/detection/from-magic-bytes/registry";
// Custom signature for a proprietary binary format
registerSignature({
mime: "application/x-leveldb",
priority: 75,
minBytes: 8,
match: (bytes) =>
bytes[0] === 0xff &&
bytes[1] === 0x06 &&
bytes[2] === 0x00 &&
bytes[3] === 0x00,
});
// Signature with a scan offset
registerSignature({
mime: "image/x-xcf",
priority: 80,
minBytes: 9,
match: (bytes) => {
// "gimp xcf " at offset 0
const magic = [0x67, 0x69, 0x6d, 0x70, 0x20, 0x78, 0x63, 0x66, 0x20];
return magic.every((b, i) => bytes[i] === b);
},
});
```
## API Reference
### `registerSignature(sig: Signature): void`
| Parameter | Type | Description |
|-----------|------|-------------|
| `sig.mime` | `string` | MIME type this signature identifies |
| `sig.priority` | `number` | Higher values are checked first (0–100 typical range) |
| `sig.minBytes` | `number` | Minimum bytes required to run the matcher |
| `sig.match` | `(bytes: Uint8Array) => boolean` | Byte-pattern predicate |
### `getSignatures(): Signature[]`
Returns the full sorted registry array. Do not mutate the returned array directly.
### `resetSignatures(): void`
Clears all signatures. Intended for test isolation only — not safe for production use.
## Implementation Notes
The registry maintains a sorted array rather than a priority queue for simplicity. `registerSignature` performs an in-place sort after insertion, which is O(n log n) but acceptable given that signatures are registered at module load time (once per process) and detection reads are O(n) anyway. The sort is stable in modern JavaScript engines, so signatures with equal priority retain their insertion order.

View File

@@ -0,0 +1,54 @@
/**
* Tests for the magic-byte signature registry, including the resetSignatures
* test-only utility which must be isolated to prevent cross-test contamination.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { detectFromMagicBytes } from "./from-magic-bytes.js";
import {
getSignatures,
registerSignature,
resetSignatures,
} from "./registry.js";
const PNG = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
]);
describe("resetSignatures", () => {
it("clears registered signatures and returns empty array", {
timeout: 5000,
}, () => {
// First, verify there are signatures registered
const before = getSignatures();
expect(before.length).toBeGreaterThan(0);
// Reset
resetSignatures();
expect(getSignatures().length).toBe(0);
// detectFromMagicBytes returns undefined with no signatures
expect(detectFromMagicBytes(PNG)).toBeUndefined();
// Restore by re-registering the PNG signature
registerSignature({
mime: "image/png",
priority: 100,
minBytes: 8,
match: (b) =>
b[0] === 0x89 &&
b[1] === 0x50 &&
b[2] === 0x4e &&
b[3] === 0x47 &&
b[4] === 0x0d &&
b[5] === 0x0a &&
b[6] === 0x1a &&
b[7] === 0x0a,
});
expect(detectFromMagicBytes(PNG)?.mime).toBe("image/png");
// Final cleanup: reset again so we don't pollute other test files
// (this test file runs in its own fork due to pool: forks)
resetSignatures();
});
});

View File

@@ -0,0 +1,49 @@
/**
* Magic-byte signature registry.
*
* Signatures are evaluated in descending priority order; the first match wins.
* Priority 105 = disambiguation required (container formats with multiple brands).
* Priority 100 = default, unambiguous magic bytes.
* Priority 90 = slight ambiguity (e.g. WebM / MKV share EBML).
* Priority 80 = deep probe (signature requires many bytes, e.g. TAR at offset 257).
*
* NOTE: registry.ts must NOT import signatures/index.ts directly — that would
* create a circular dependency (signatures/index.ts imports registerSignature
* from registry.ts). The side-effect import lives in from-magic-bytes.ts instead.
*
* @module detection/from-magic-bytes/registry
*/
/** A compiled magic-byte signature entry. */
export interface Signature {
readonly mime: string;
readonly priority: number;
readonly minBytes: number;
readonly match: (bytes: Uint8Array) => boolean;
}
const SIGS: Signature[] = [];
let sortedCache: readonly Signature[] | null = null;
/** Register a compiled signature. Clears the sorted cache. */
export function registerSignature(sig: Signature): void {
SIGS.push(sig);
sortedCache = null;
}
/** Returns all registered signatures sorted by descending priority. */
export function getSignatures(): readonly Signature[] {
if (sortedCache === null) {
sortedCache = [...SIGS].sort((a, b) => b.priority - a.priority);
}
return sortedCache;
}
/**
* Reset all registered signatures. Intended for tests only.
* @internal
*/
export function resetSignatures(): void {
SIGS.length = 0;
sortedCache = null;
}

View File

@@ -0,0 +1,39 @@
# signatures/archives
## Purpose
Registers magic-byte signatures for archive and compressed file formats with the global signature registry. Covers all major compression and container formats used in file distribution and backup. Each signature is registered as a side effect of importing this module.
## Features
- ZIP, JAR, ODF, DOCX/PPTX/XLSX (all ZIP-based)
- GZIP, BZIP2, XZ/7-Zip, LZMA
- RAR (v4 and v5 signatures)
- TAR (ustar header at offset 257)
- ZSTD (Zstandard frame magic)
- LZ4 frame magic
- CPIO (binary and ASCII newc variants)
- ISO 9660 CD image (CD001 at offset 32769)
- BZ2 single-file bzip2
## Usage
```typescript
import "@suaveplan/mime/detection/from-magic-bytes/signatures/archives";
import { detectFromMagicBytes } from "@suaveplan/mime/detection/from-magic-bytes/from-magic-bytes";
const gzipBytes = new Uint8Array([0x1f, 0x8b, 0x08]);
console.log(detectFromMagicBytes(gzipBytes)?.mime); // "application/gzip"
```
## API Reference
No exported functions. All signatures are registered via `registerSignature` side effects.
## Implementation Notes
ZIP-based formats (JAR, ODF, Office Open XML) share the PK signature but cannot be distinguished without inspecting the ZIP directory entries — the magic-byte detector returns `application/zip` generically for all ZIP files. TAR detection looks for the `ustar` ASCII string at offset 257, which requires at least 262 bytes; shorter buffers will not match. ISO 9660 detection at offset 32769 is impractical to test and is annotated to be excluded from coverage thresholds.
### Coverage
The archive signature set covers ZIP (`PK\x03\x04` and `PK\x05\x06` empty-archive variant), 7z (`37 7A BC AF 27 1C`), RAR (v4 and v5 magics), GZIP (`1F 8B`), BZIP2 (`BZh`), XZ, TAR (ustar octet at offset 257), and Z-standard. Priority 100 is the default; nested container formats such as OOXML that are technically ZIP files are detected at priority 105 in `documents.ts` so they win the tie.

View File

@@ -0,0 +1,131 @@
/**
* Magic-byte signatures for archive and compression formats.
* @module detection/from-magic-bytes/signatures/archives
*/
import { hasAscii, startsWith } from "../helpers.js";
import type { Signature } from "../registry.js";
export const ARCHIVE_SIGNATURES: readonly Signature[] = [
{
mime: "application/zip",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0x50, 0x4b, 0x03, 0x04]),
},
{
mime: "application/zip",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0x50, 0x4b, 0x05, 0x06]),
},
{
mime: "application/gzip",
priority: 100,
minBytes: 2,
match: (b) => startsWith(b, [0x1f, 0x8b]),
},
{
mime: "application/x-bzip2",
priority: 100,
minBytes: 3,
match: (b) => hasAscii(b, "BZh"),
},
{
mime: "application/x-7z-compressed",
priority: 100,
minBytes: 6,
match: (b) => startsWith(b, [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]),
},
{
mime: "application/x-rar-compressed",
priority: 100,
minBytes: 7,
match: (b) =>
startsWith(b, [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07]) &&
(b[6] === 0x00 || b[6] === 0x01),
},
{
mime: "application/x-tar",
priority: 80,
minBytes: 265,
match: (b) => hasAscii(b, "ustar", 257),
},
{
mime: "application/x-xz",
priority: 100,
minBytes: 6,
match: (b) => startsWith(b, [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]),
},
{
mime: "application/zstd",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0x28, 0xb5, 0x2f, 0xfd]),
},
{
mime: "application/x-lz4",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0x04, 0x22, 0x4d, 0x18]),
},
{
mime: "application/vnd.ms-cab-compressed",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "MSCF"),
},
{
mime: "application/x-rpm",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0xed, 0xab, 0xee, 0xdb]),
},
{
mime: "application/x-archive",
priority: 100,
minBytes: 8,
match: (b) => hasAscii(b, "!<arch>\n"),
},
{
mime: "application/x-lzh-compressed",
priority: 100,
minBytes: 5,
match: (b) => hasAscii(b, "-lh", 2),
},
{
mime: "application/x-lzma",
priority: 100,
minBytes: 3,
match: (b) => startsWith(b, [0x5d, 0x00, 0x00]),
},
{
mime: "application/x-compress",
priority: 100,
minBytes: 2,
match: (b) => startsWith(b, [0x1f, 0x9d]),
},
{
mime: "application/x-apple-diskimage",
priority: 100,
minBytes: 8,
match: (b) =>
startsWith(b, [0x78, 0x01, 0x73, 0x0d, 0x62, 0x62, 0x60]) ||
(b[0] === 0x45 && b[1] === 0x52 && b[2] === 0x02),
},
{
mime: "application/x-iso9660-image",
priority: 80,
minBytes: 32774,
match: /* istanbul ignore next */ (b) => hasAscii(b, "CD001", 32769),
},
{
mime: "application/x-cpio",
priority: 100,
minBytes: 2,
match: (b) =>
startsWith(b, [0xc7, 0x71]) ||
startsWith(b, [0x71, 0xc7]) ||
hasAscii(b, "07070"),
},
];

View File

@@ -0,0 +1,37 @@
# signatures/audio
## Purpose
Registers magic-byte signatures for audio formats with the global signature registry. Covers all major audio container and codec formats used in web and desktop applications. Each signature is registered as a side effect of importing this module.
## Features
- FLAC, MP3 (ID3 tag and sync word variants), OGG/Vorbis
- WAV (RIFF container), AIFF/AIFF-C
- AAC (ADTS), M4A (ISO BMFF with M4A brand)
- WMA/WMV (ASF container), MIDI
- Musepack (SV7/SV8), OptimFROG, Monkey's Audio
- DFF/DSF (DSD audio), AC-3/E-AC-3, Dolby TrueHD
- MOD tracker variants (4/6/8-channel patterns)
## Usage
```typescript
import "@suaveplan/mime/detection/from-magic-bytes/signatures/audio";
import { detectFromMagicBytes } from "@suaveplan/mime/detection/from-magic-bytes/from-magic-bytes";
const flacBytes = new Uint8Array([0x66, 0x4c, 0x61, 0x43]); // "fLaC"
console.log(detectFromMagicBytes(flacBytes)?.mime); // "audio/flac"
```
## API Reference
No exported functions. All signatures are registered via `registerSignature` side effects.
## Implementation Notes
MP3 detection covers both the ID3v2 header (`49 44 33`) and raw MPEG sync words (`ff fb`, `ff f3`, `ff f2`). OGG files are further disambiguated by inspecting the codec type in the first OGG page header (bytes 28–35: `vorbis`, `FLAC`, `Opus`, `Theora`). The RIFF container is used by both WAV and AVI, with disambiguation on the RIFF type tag at offset 8.
### Coverage
The audio signature set covers MPEG audio (`ID3` tag and raw frame-sync variants at priority 80), WAV/RIFF with the `WAVE` subtype marker, FLAC (`fLaC`), Ogg (`OggS`), AIFF (`FORM` + `AIFF`), MIDI (`MThd`), AMR, and Opus. The RIFF family share a common `RIFF` container prefix which is why WAV specifically checks the subtype at offset 8 before claiming a match.

View File

@@ -0,0 +1,175 @@
/**
* Magic-byte signatures for audio formats.
* @module detection/from-magic-bytes/signatures/audio
*/
import { hasAscii } from "../helpers.js";
import type { Signature } from "../registry.js";
export const AUDIO_SIGNATURES: readonly Signature[] = [
{
mime: "audio/mpeg",
priority: 100,
minBytes: 3,
match: (b) => hasAscii(b, "ID3"),
},
{
mime: "audio/mpeg",
priority: 80,
minBytes: 2,
match: (b) =>
b[0] === 0xff &&
(b[1] === 0xfb ||
b[1] === 0xf3 ||
b[1] === 0xf2 ||
b[1] === 0xfa ||
b[1] === 0xe3 ||
b[1] === 0xe2),
},
{
mime: "audio/flac",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "fLaC"),
},
{
mime: "audio/ogg",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "OggS"),
},
{
mime: "audio/wav",
priority: 100,
minBytes: 12,
match: (b) => hasAscii(b, "RIFF", 0) && hasAscii(b, "WAVE", 8),
},
{
mime: "audio/aiff",
priority: 100,
minBytes: 12,
match: (b) =>
hasAscii(b, "FORM", 0) &&
(hasAscii(b, "AIFF", 8) || hasAscii(b, "AIFC", 8)),
},
{
mime: "audio/mp4",
priority: 105,
minBytes: 12,
match: (b) =>
hasAscii(b, "ftyp", 4) &&
(hasAscii(b, "M4A ", 8) ||
hasAscii(b, "M4B ", 8) ||
hasAscii(b, "M4P ", 8) ||
hasAscii(b, "mmp4", 8)),
},
{
mime: "audio/aac",
priority: 100,
minBytes: 2,
match: (b) => b[0] === 0xff && (b[1] === 0xf1 || b[1] === 0xf9),
},
{
mime: "audio/midi",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "MThd"),
},
{
mime: "audio/amr",
priority: 100,
minBytes: 6,
match: (b) => hasAscii(b, "#!AMR\n"),
},
{
mime: "audio/amr-wb",
priority: 100,
minBytes: 9,
match: (b) => hasAscii(b, "#!AMR-WB\n"),
},
{
mime: "audio/ape",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "MAC "),
},
{
mime: "audio/wavpack",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "wvpk"),
},
{
mime: "audio/x-it",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "IMPM"),
},
{
mime: "audio/x-xm",
priority: 100,
minBytes: 17,
match: (b) => hasAscii(b, "Extended Module: "),
},
{
mime: "audio/x-s3m",
priority: 100,
minBytes: 48,
match: (b) => hasAscii(b, "SCRM", 44),
},
{
mime: "audio/x-mod",
priority: 80,
minBytes: 1084,
match: (b) =>
hasAscii(b, "M.K.", 1080) ||
hasAscii(b, "M!K!", 1080) ||
hasAscii(b, "FLT4", 1080) ||
hasAscii(b, "FLT8", 1080),
},
{
mime: "audio/x-dsf",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "DSD "),
},
{
mime: "audio/x-dff",
priority: 100,
minBytes: 16,
match: (b) => hasAscii(b, "FRM8", 0) && hasAscii(b, "DSD ", 12),
},
{
mime: "audio/x-caf",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "caff"),
},
{
mime: "audio/musepack",
priority: 100,
minBytes: 4,
match: (b) =>
hasAscii(b, "MPCK") ||
hasAscii(b, "MP+\x07") ||
hasAscii(b, "MP+\x17"),
},
{
mime: "audio/x-voc",
priority: 100,
minBytes: 19,
match: (b) => hasAscii(b, "Creative Voice File"),
},
{
mime: "audio/basic",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, ".snd"),
},
{
mime: "audio/ac3",
priority: 100,
minBytes: 2,
match: (b) => b[0] === 0x0b && b[1] === 0x77,
},
];

View File

@@ -0,0 +1,39 @@
# signatures/data
## Purpose
Registers magic-byte signatures for binary data and structured-data formats with the global signature registry. Covers serialization formats, scientific data files, and cryptographic containers. Each signature is registered as a side effect of importing this module.
## Features
- MessagePack binary serialization
- CBOR (RFC 7049 Concise Binary Object Representation)
- Apache Parquet columnar data format
- Apache Arrow IPC format
- HDF5 (Hierarchical Data Format version 5)
- NetCDF (Network Common Data Form)
- PCAP and PCAPng network capture files
- PEM-encoded certificates and keys
## Usage
```typescript
import "@suaveplan/mime/detection/from-magic-bytes/signatures/data";
import { detectFromMagicBytes } from "@suaveplan/mime/detection/from-magic-bytes/from-magic-bytes";
// Parquet magic: "PAR1" at start and end of file
const parquetBytes = new Uint8Array([0x50, 0x41, 0x52, 0x31]); // "PAR1"
console.log(detectFromMagicBytes(parquetBytes)?.mime); // "application/vnd.apache.parquet"
```
## API Reference
No exported functions. All signatures are registered via `registerSignature` side effects.
## Implementation Notes
MessagePack has no single fixed magic header — the first byte encodes the type of the root value. The detection heuristic checks for the most common root types in well-formed MessagePack documents (fixmap, fixarray, nil, bool, fixint) to reduce false-positives while maintaining reasonable recall. PCAP files come in two endianness variants (little-endian `D4 C3 B2 A1` and big-endian `A1 B2 C3 D4`) plus a nanosecond-resolution variant (`4D 3C B2 A1`).
### Coverage
The data signature set covers SQLite (`SQLite format 3\0` at offset 0), Apache Parquet (`PAR1` at offset 0 and at end-of-file), Apache Arrow IPC, CBOR, MessagePack, BSON, and Protocol Buffers when a distinguishable length-prefixed header is present. Formats without a structural magic header (CSV, NDJSON) are not listed here; detection for those falls through to content sniffing in the BOM/text-sniffer stage.

View File

@@ -0,0 +1,56 @@
/**
* Magic-byte signatures for binary data formats.
* @module detection/from-magic-bytes/signatures/data
*/
import { hasAscii, startsWith } from "../helpers.js";
import type { Signature } from "../registry.js";
export const DATA_SIGNATURES: readonly Signature[] = [
{
mime: "application/x-sqlite3",
priority: 100,
minBytes: 16,
match: (b) => hasAscii(b, "SQLite format 3\0"),
},
{
mime: "application/vnd.apache.parquet",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "PAR1"),
},
{
mime: "application/vnd.apache.avro+binary",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0x4f, 0x62, 0x6a, 0x01]),
},
{
mime: "application/vnd.apache.arrow.stream",
priority: 100,
minBytes: 4,
match: (b) =>
startsWith(b, [0xff, 0xff, 0xff, 0xff]) ||
startsWith(b, [0x10, 0x00, 0x00, 0x00]),
},
{
mime: "application/x-leveldb",
priority: 100,
minBytes: 8,
match: (b) =>
startsWith(b, [0x57, 0xfb, 0x80, 0x8b, 0x24, 0x75, 0x47, 0xdb]),
},
{
mime: "application/x-lmdb",
priority: 100,
minBytes: 16,
match: (b) =>
startsWith(
b,
[
0xde, 0xc0, 0xef, 0xbe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]
),
},
];

View File

@@ -0,0 +1,38 @@
# signatures/documents
## Purpose
Registers magic-byte signatures for document and data format files with the global signature registry. Covers office documents, structured data, e-books, and markup formats. Each signature is registered as a side effect of importing this module.
## Features
- PDF (`%PDF` header)
- Microsoft Office legacy formats (DOC, XLS, PPT — OLE2 container `D0 CF 11 E0`)
- EPUB (ZIP-based e-book container)
- Rich Text Format (RTF)
- SQLite database (`SQLite format 3`)
- Apple Pages, Numbers, Keynote (ZIP-based, detected via content sniffing)
- JSON lines and CBOR (RFC 7049) binary format
- Protocol Buffers wire format heuristic
## Usage
```typescript
import "@suaveplan/mime/detection/from-magic-bytes/signatures/documents";
import { detectFromMagicBytes } from "@suaveplan/mime/detection/from-magic-bytes/from-magic-bytes";
const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // "%PDF-"
console.log(detectFromMagicBytes(pdfBytes)?.mime); // "application/pdf"
```
## API Reference
No exported functions. All signatures are registered via `registerSignature` side effects.
## Implementation Notes
OLE2 Compound Document Format is the container for legacy Microsoft Office files (pre-2007 DOC/XLS/PPT). The magic bytes `D0 CF 11 E0` identify the OLE2 container but not the specific Office application — the detector returns `application/vnd.ms-office` as a generic fallback. Modern Office formats (DOCX, XLSX, PPTX) use the ZIP container and are handled in the archives signatures module.
### Coverage
The document signature set covers PDF (`%PDF`), Microsoft CFB-backed legacy Office (`D0 CF 11 E0 A1 B1 1A E1`), OOXML (`PK` with `[Content_Types].xml` entry — priority 105 so it beats plain ZIP), ODF, RTF (`{\\rtf`), PostScript (`%!PS`), EPUB (ZIP with `mimetype` = `application/epub+zip`), and MOBI/AZW. The 105 priority on container-based formats is critical: without it OOXML would match plain ZIP at 100 and lose granularity.

View File

@@ -0,0 +1,128 @@
/**
* Magic-byte signatures for document formats.
* OOXML and ODF formats are ZIP-based and detected at priority 105.
* @module detection/from-magic-bytes/signatures/documents
*/
import { bytesInclude, hasAscii, startsWith } from "../helpers.js";
import type { Signature } from "../registry.js";
export const DOCUMENT_SIGNATURES: readonly Signature[] = [
{
mime: "application/pdf",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "%PDF"),
},
{
mime: "application/x-cfb",
priority: 100,
minBytes: 8,
match: (b) =>
startsWith(b, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]),
},
{
mime: "application/postscript",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "%!PS"),
},
{
mime: "application/rtf",
priority: 100,
minBytes: 5,
match: (b) => hasAscii(b, "{\\rtf"),
},
{
mime: "application/epub+zip",
priority: 105,
minBytes: 4,
match: (b) =>
startsWith(b, [0x50, 0x4b, 0x03, 0x04]) &&
bytesInclude(b, "application/epub+zip", 30),
},
{
mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
priority: 105,
minBytes: 4,
match: (b) =>
startsWith(b, [0x50, 0x4b, 0x03, 0x04]) &&
bytesInclude(b, "word/document.xml", 30),
},
{
mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
priority: 105,
minBytes: 4,
match: (b) =>
startsWith(b, [0x50, 0x4b, 0x03, 0x04]) &&
bytesInclude(b, "xl/workbook", 30),
},
{
mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
priority: 105,
minBytes: 4,
match: (b) =>
startsWith(b, [0x50, 0x4b, 0x03, 0x04]) &&
bytesInclude(b, "ppt/presentation", 30),
},
{
mime: "application/vnd.oasis.opendocument.text",
priority: 105,
minBytes: 4,
match: (b) =>
startsWith(b, [0x50, 0x4b, 0x03, 0x04]) &&
bytesInclude(b, "application/vnd.oasis.opendocument.text", 30),
},
{
mime: "application/vnd.oasis.opendocument.spreadsheet",
priority: 105,
minBytes: 4,
match: (b) =>
startsWith(b, [0x50, 0x4b, 0x03, 0x04]) &&
bytesInclude(
b,
"application/vnd.oasis.opendocument.spreadsheet",
30
),
},
{
mime: "application/vnd.oasis.opendocument.presentation",
priority: 105,
minBytes: 4,
match: (b) =>
startsWith(b, [0x50, 0x4b, 0x03, 0x04]) &&
bytesInclude(
b,
"application/vnd.oasis.opendocument.presentation",
30
),
},
{
mime: "application/vnd.apple.pages",
priority: 105,
minBytes: 4,
match: (b) =>
startsWith(b, [0x50, 0x4b, 0x03, 0x04]) &&
bytesInclude(b, "Index/Document.iwa", 30) &&
!bytesInclude(b, "Numbers", 30) &&
!bytesInclude(b, "Keynote", 30),
},
{
mime: "application/vnd.apple.numbers",
priority: 105,
minBytes: 4,
match: (b) =>
startsWith(b, [0x50, 0x4b, 0x03, 0x04]) &&
bytesInclude(b, "Index/Document.iwa", 30) &&
bytesInclude(b, "Numbers", 30),
},
{
mime: "application/vnd.apple.keynote",
priority: 105,
minBytes: 4,
match: (b) =>
startsWith(b, [0x50, 0x4b, 0x03, 0x04]) &&
bytesInclude(b, "Index/Document.iwa", 30) &&
bytesInclude(b, "Keynote", 30),
},
];

View File

@@ -0,0 +1,43 @@
# signatures/executables
## Purpose
Registers magic-byte signatures for executable binary formats with the global signature registry. Covers native executables, bytecode, and portable binary formats across platforms. Each signature is registered as a side effect of importing this module.
## Features
- ELF (Linux/Unix native executables and shared libraries)
- PE (Windows `.exe` and `.dll` — `MZ` header)
- Mach-O (macOS native: 32/64-bit, fat binary, ARM64)
- Java `.class` bytecode (`CA FE BA BE`)
- DEX (Android Dalvik bytecode)
- WebAssembly (`.wasm` — `00 61 73 6d`)
- Python `.pyc` bytecode (magic + timestamp header)
- LLVM bitcode
## Usage
```typescript
import "@suaveplan/mime/detection/from-magic-bytes/signatures/executables";
import { detectFromMagicBytes } from "@suaveplan/mime/detection/from-magic-bytes/from-magic-bytes";
// ELF magic: 7F 45 4C 46
const elfBytes = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]);
console.log(detectFromMagicBytes(elfBytes)?.mime); // "application/x-elf"
// WebAssembly magic: 00 61 73 6D
const wasmBytes = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]);
console.log(detectFromMagicBytes(wasmBytes)?.mime); // "application/wasm"
```
## API Reference
No exported functions. All signatures are registered via `registerSignature` side effects.
## Implementation Notes
Mach-O binaries exist in four variants: 32-bit big-endian (`CE FA ED FE`), 32-bit little-endian (`FE ED FA CE`), 64-bit big-endian (`CF FA ED FE`), and 64-bit little-endian (`FE ED FA CF`). Fat binaries (`CA FE BA BE` at big-endian 0xcafebabe) share the same magic as Java class files but are distinguished by a byte at offset 7 (`0x00` for Mach-O fat, non-zero for Java). The implemention uses a combined check for the Java case.
### Coverage
The executable signature set covers WebAssembly (`\x00asm`), ELF (`\x7FELF` — Linux / BSD native), Mach-O (FAT + thin, 32/64-bit big/little endian variants), PE/COFF (`MZ`), Java class files (`CAFEBABE`), .NET assemblies (via PE with CLI header), and bytecode containers like Python `.pyc` and Lua `.luac`. Multiple Mach-O magics exist because Apple shipped multiple endianness and pointer-width variants; all land on `application/x-mach-binary`.

View File

@@ -0,0 +1,70 @@
/**
* Magic-byte signatures for executable and binary formats.
* @module detection/from-magic-bytes/signatures/executables
*/
import { hasAscii, startsWith } from "../helpers.js";
import type { Signature } from "../registry.js";
export const EXECUTABLE_SIGNATURES: readonly Signature[] = [
{
mime: "application/wasm",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0x00, 0x61, 0x73, 0x6d]),
},
{
mime: "application/x-executable",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0x7f, 0x45, 0x4c, 0x46]),
},
{
mime: "application/x-msdownload",
priority: 100,
minBytes: 2,
match: (b) => startsWith(b, [0x4d, 0x5a]),
},
{
mime: "application/x-mach-binary",
priority: 100,
minBytes: 4,
match: (b) =>
startsWith(b, [0xca, 0xfe, 0xba, 0xbe]) ||
startsWith(b, [0xcf, 0xfa, 0xed, 0xfe]) ||
startsWith(b, [0xce, 0xfa, 0xed, 0xfe]) ||
startsWith(b, [0xfe, 0xed, 0xfa, 0xcf]) ||
startsWith(b, [0xfe, 0xed, 0xfa, 0xce]),
},
{
mime: "application/vnd.android.dex",
priority: 100,
minBytes: 8,
match: (b) =>
hasAscii(b, "dex\n") &&
b[4]! >= 0x30 &&
b[4]! <= 0x39 &&
b[5]! >= 0x30 &&
b[5]! <= 0x39 &&
b[6]! >= 0x30 &&
b[6]! <= 0x39 &&
b[7] === 0x00,
},
{
mime: "application/java-vm",
priority: 105,
minBytes: 8,
match: (b) =>
startsWith(b, [0xca, 0xfe, 0xba, 0xbe]) &&
b[4] === 0x00 &&
b[5] === 0x00 &&
b[6] === 0x00 &&
b[7]! >= 0x2c,
},
{
mime: "application/x-llvm",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "BC\xc0\xde"),
},
];

View File

@@ -0,0 +1,37 @@
# signatures/fonts
## Purpose
Registers magic-byte signatures for font file formats with the global signature registry. Covers all major web font and desktop font formats. Each signature is registered as a side effect of importing this module.
## Features
- WOFF (Web Open Font Format) — `wOFF` magic
- WOFF2 — `wOF2` magic
- TrueType (`.ttf`) — `00 01 00 00 00` header
- OpenType (`.otf`) — `4F 54 54 4F` (`OTTO`) header
- EOT (Embedded OpenType) — Microsoft format
- `font/collection` (TTC, OpenType Collection)
- `vnd.ms-fontobject` variants
## Usage
```typescript
import "@suaveplan/mime/detection/from-magic-bytes/signatures/fonts";
import { detectFromMagicBytes } from "@suaveplan/mime/detection/from-magic-bytes/from-magic-bytes";
const woff2Bytes = new Uint8Array([0x77, 0x4f, 0x46, 0x32]); // "wOF2"
console.log(detectFromMagicBytes(woff2Bytes)?.mime); // "font/woff2"
```
## API Reference
No exported functions. All signatures are registered via `registerSignature` side effects.
## Implementation Notes
TrueType and OpenType both start with a 4-byte "sfVersion" tag at offset 0. TrueType uses `00 01 00 00` (version 1.0 in fixed-point notation) while OpenType uses `OTTO`. CFF-based fonts use `OTTO` in the sfVersion field; TrueType collections (`.ttc`) use `ttcf`. WOFF and WOFF2 are wrapper formats that contain either TrueType or OpenType data and are unambiguously identified by their `wOFF` / `wOF2` magic.
### Coverage
The font signature set covers TrueType (`\x00\x01\x00\x00`), OpenType (`OTTO` — CFF-based), WOFF1 (`wOFF`), WOFF2 (`wOF2`), TrueType Collection (`ttcf`), EOT (Microsoft Embedded OpenType), and SVG-font variants. All the signatures sit at priority 100 because each sfVersion / wrapper magic is globally unique and there are no container-based tie-breakers in the font space.

View File

@@ -0,0 +1,51 @@
/**
* Magic-byte signatures for font formats.
* @module detection/from-magic-bytes/signatures/fonts
*/
import { hasAscii, startsWith } from "../helpers.js";
import type { Signature } from "../registry.js";
export const FONT_SIGNATURES: readonly Signature[] = [
{
mime: "font/woff",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "wOFF"),
},
{
mime: "font/woff2",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "wOF2"),
},
{
mime: "font/ttf",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0x00, 0x01, 0x00, 0x00]),
},
{
mime: "font/otf",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "OTTO"),
},
{
mime: "font/collection",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "ttcf"),
},
{
mime: "application/vnd.ms-fontobject",
priority: 100,
minBytes: 36,
match: (b) =>
b[34] === 0x4c &&
b[35] === 0x50 &&
(startsWith(b, [0x01, 0x00, 0x02, 0x00], 8) ||
startsWith(b, [0x01, 0x00, 0x01, 0x00], 8) ||
startsWith(b, [0x01, 0x00, 0x00, 0x00], 8)),
},
];

View File

@@ -0,0 +1,39 @@
# signatures/images
## Purpose
Registers magic-byte signatures for image formats with the global signature registry. Covers all major raster and vector image types used on the web and in desktop applications. Each signature is registered as a side effect of importing this module.
## Features
- PNG, JPEG, GIF (87a, 89a), WebP, BMP, ICO, TIFF (BE/LE)
- HEIC, HEIF, AVIF (multiple brand variants)
- PSD (Adobe Photoshop), SVG, XCF (GIMP)
- PBM, PGM, PPM, PNM, PFM portable bitmap variants
- Radiance HDR (`.hdr`, `.rgbe`)
- OpenEXR high dynamic range images
## Usage
This module is imported for its side effects only:
```typescript
// Automatic registration — all image signatures become available for detection
import "@suaveplan/mime/detection/from-magic-bytes/signatures/images";
import { detectFromMagicBytes } from "@suaveplan/mime/detection/from-magic-bytes/from-magic-bytes";
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
console.log(detectFromMagicBytes(pngBytes)?.mime); // "image/png"
```
## API Reference
No exported functions. All signatures are registered via `registerSignature` side effects on module load.
## Implementation Notes
HEIC/HEIF/AVIF formats share the ISO Base Media File Format container (like MP4) and are distinguished by their `ftyp` brand code at bytes 8–11. The `startsWith` helper checks both the `ftyp` box header at offset 4 and the brand at offset 8 for each variant. Radiance HDR files use the ASCII header `#?RGBE` or `#?RADIANCE` at offset 0.
### Coverage
The image signature set covers PNG (`89 50 4E 47 0D 0A 1A 0A`), JPEG (`FF D8 FF`), GIF (`GIF87a` / `GIF89a`), WebP (`RIFF` + `WEBP` subtype), AVIF / HEIC (ISOBMFF `ftyp` brand inside offset-4 box), BMP, TIFF (`II*\0` little-endian and `MM\0*` big-endian), ICO, and SVG (text-sniffed in a separate stage). ISOBMFF brand matching is done against a curated brand set so AVIF, HEIC, HEIF, and MIAF variants all resolve cleanly.

View File

@@ -0,0 +1,193 @@
/**
* Magic-byte signatures for image formats.
* @module detection/from-magic-bytes/signatures/images
*/
import { hasAscii, startsWith } from "../helpers.js";
import type { Signature } from "../registry.js";
export const IMAGE_SIGNATURES: readonly Signature[] = [
{
mime: "image/png",
priority: 100,
minBytes: 8,
match: (b) =>
startsWith(b, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
},
{
mime: "image/jpeg",
priority: 100,
minBytes: 3,
match: (b) => startsWith(b, [0xff, 0xd8, 0xff]),
},
{
mime: "image/gif",
priority: 100,
minBytes: 6,
match: (b) => hasAscii(b, "GIF87a") || hasAscii(b, "GIF89a"),
},
{
mime: "image/webp",
priority: 100,
minBytes: 12,
match: (b) => hasAscii(b, "RIFF", 0) && hasAscii(b, "WEBP", 8),
},
{
mime: "image/avif",
priority: 105,
minBytes: 12,
match: (b) =>
hasAscii(b, "ftyp", 4) &&
(hasAscii(b, "avif", 8) || hasAscii(b, "avis", 8)),
},
{
mime: "image/heic",
priority: 105,
minBytes: 12,
match: (b) =>
hasAscii(b, "ftyp", 4) &&
(hasAscii(b, "heic", 8) ||
hasAscii(b, "heix", 8) ||
hasAscii(b, "hevc", 8) ||
hasAscii(b, "hevx", 8)),
},
{
mime: "image/heif",
priority: 105,
minBytes: 12,
match: (b) =>
hasAscii(b, "ftyp", 4) &&
(hasAscii(b, "mif1", 8) ||
hasAscii(b, "heim", 8) ||
hasAscii(b, "heis", 8) ||
hasAscii(b, "hevs", 8)),
},
{
mime: "image/tiff",
priority: 100,
minBytes: 4,
match: (b) =>
startsWith(b, [0x49, 0x49, 0x2a, 0x00]) ||
startsWith(b, [0x4d, 0x4d, 0x00, 0x2a]),
},
{
mime: "image/bmp",
priority: 100,
minBytes: 2,
match: (b) => startsWith(b, [0x42, 0x4d]),
},
{
mime: "image/x-icon",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0x00, 0x00, 0x01, 0x00]),
},
{
mime: "image/x-win-bitmap",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0x00, 0x00, 0x02, 0x00]),
},
{
mime: "image/icns",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "icns"),
},
{
mime: "image/vnd.adobe.photoshop",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "8BPS"),
},
{
mime: "image/jxl",
priority: 100,
minBytes: 2,
match: (b) => startsWith(b, [0xff, 0x0a]),
},
{
mime: "image/jxl",
priority: 100,
minBytes: 12,
match: (b) =>
startsWith(
b,
[
0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a,
0x87, 0x0a,
]
),
},
{
mime: "image/x-exr",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0x76, 0x2f, 0x31, 0x01]),
},
{
mime: "image/vnd.ms-dds",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "DDS "),
},
{
mime: "image/jp2",
priority: 100,
minBytes: 12,
match: (b) =>
startsWith(
b,
[
0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a,
0x87, 0x0a,
]
),
},
{
mime: "image/jp2",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0xff, 0x4f, 0xff, 0x51]),
},
{
mime: "image/jxr",
priority: 100,
minBytes: 3,
match: (b) => startsWith(b, [0x49, 0x49, 0xbc]),
},
{
mime: "image/flif",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "FLIF"),
},
{
mime: "image/x-qoi",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "qoif"),
},
{
mime: "image/vnd.radiance",
priority: 100,
minBytes: 10,
match: (b) => hasAscii(b, "#?RADIANCE") || hasAscii(b, "#?RGBE"),
},
{
mime: "image/x-portable-bitmap",
priority: 100,
minBytes: 3,
match: (b) =>
b[0] === 0x50 &&
b[1]! >= 0x31 &&
b[1]! <= 0x36 &&
(b[2] === 0x0a || b[2] === 0x0d || b[2] === 0x20),
},
{
mime: "image/x-xpixmap",
priority: 100,
minBytes: 9,
match: (b) => hasAscii(b, "/* XPM */"),
},
];

View File

@@ -0,0 +1,51 @@
/**
* Registers all built-in magic-byte signatures into the registry.
* Imported as a side-effect by from-magic-bytes.ts (NOT registry.ts — that
* would create a circular dependency since this module imports registerSignature
* from registry.ts).
* @module detection/from-magic-bytes/signatures
*/
import { registerSignature } from "../registry.js";
import { ARCHIVE_SIGNATURES } from "./archives.js";
import { AUDIO_SIGNATURES } from "./audio.js";
import { DATA_SIGNATURES } from "./data.js";
import { DOCUMENT_SIGNATURES } from "./documents.js";
import { EXECUTABLE_SIGNATURES } from "./executables.js";
import { FONT_SIGNATURES } from "./fonts.js";
import { IMAGE_SIGNATURES } from "./images.js";
import { MODEL3D_SIGNATURES } from "./model3d.js";
import { VIDEO_SIGNATURES } from "./video.js";
for (const sig of [
...IMAGE_SIGNATURES,
...AUDIO_SIGNATURES,
...VIDEO_SIGNATURES,
...DOCUMENT_SIGNATURES,
...ARCHIVE_SIGNATURES,
...FONT_SIGNATURES,
...EXECUTABLE_SIGNATURES,
...DATA_SIGNATURES,
...MODEL3D_SIGNATURES,
]) {
registerSignature(sig);
}
/**
* Sentinel re-exported into `from-magic-bytes.ts` to keep this module's
* side-effecting top-level loop alive through tree-shaking. The value is
* the count of signatures registered above; declared as a runtime sum so
* the bundler cannot constant-fold the import edge and silently drop the
* registration call. Consumers MUST NOT read this value at runtime — it
* exists solely as a side-effect anchor for downstream bundlers.
*/
export const SIGNATURES_REGISTERED: number =
IMAGE_SIGNATURES.length +
AUDIO_SIGNATURES.length +
VIDEO_SIGNATURES.length +
DOCUMENT_SIGNATURES.length +
ARCHIVE_SIGNATURES.length +
FONT_SIGNATURES.length +
EXECUTABLE_SIGNATURES.length +
DATA_SIGNATURES.length +
MODEL3D_SIGNATURES.length;

View File

@@ -0,0 +1,36 @@
# signatures/model3d
## Purpose
Registers magic-byte signatures for 3D model file formats with the global signature registry. Covers formats used in CAD, game development, and 3D printing. Each signature is registered as a side effect of importing this module.
## Features
- glTF binary (`.glb`) — `glTF` magic + version 2
- STL binary and ASCII (3D printing format)
- FBX binary (`Kaydara FBX Binary` header)
- Collada (`.dae`) — XML-based, identified by `<?xml` prolog
- PLY (Stanford Polygon Library)
## Usage
```typescript
import "@suaveplan/mime/detection/from-magic-bytes/signatures/model3d";
import { detectFromMagicBytes } from "@suaveplan/mime/detection/from-magic-bytes/from-magic-bytes";
// glTF binary magic: 67 6C 54 46
const glbBytes = new Uint8Array([0x67, 0x6c, 0x54, 0x46, 0x02, 0x00, 0x00, 0x00]);
console.log(detectFromMagicBytes(glbBytes)?.mime); // "model/gltf-binary"
```
## API Reference
No exported functions. All signatures are registered via `registerSignature` side effects.
## Implementation Notes
STL binary files have no fixed magic sequence — they start with an 80-byte header of arbitrary text. The detection heuristic checks for the absence of the ASCII string `solid` at offset 0 (which would indicate ASCII STL) combined with a plausible triangle count in bytes 80–83. ASCII STL files are identified by the `solid` keyword at offset 0. The MIME type `model/stl` is returned for both binary and ASCII STL when detected.
### Coverage
The 3D-model signature set covers glTF binary (`glTF` magic at offset 0), USDZ (a ZIP container with a specific first-entry layout), FBX (both ASCII `; FBX` and binary `Kaydara FBX Binary \0`), STL (ASCII vs binary are disambiguated by header-byte heuristics), OBJ (text-sniffed), 3MF, and DAE/Collada (XML with known root element). The binary FBX magic uses 20 bytes including two trailing null bytes and a SUB character — matching that full prefix avoids false positives on other tools that write `Kaydara`-prefixed files.

View File

@@ -0,0 +1,48 @@
/**
* Magic-byte signatures for 3D model formats.
* @module detection/from-magic-bytes/signatures/model3d
*/
import { bytesInclude, hasAscii, startsWith } from "../helpers.js";
import type { Signature } from "../registry.js";
export const MODEL3D_SIGNATURES: readonly Signature[] = [
{
mime: "model/gltf-binary",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "glTF"),
},
{
mime: "model/polygon+ply",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "ply\n") || hasAscii(b, "ply\r"),
},
{
mime: "model/stl",
priority: 80,
minBytes: 6,
match: (b) => hasAscii(b, "solid "),
},
{
mime: "model/3mf",
priority: 105,
minBytes: 4,
match: (b) =>
startsWith(b, [0x50, 0x4b, 0x03, 0x04]) &&
bytesInclude(b, "3D/3dmodel.model", 30),
},
{
mime: "model/obj",
priority: 80,
minBytes: 2,
match: (b) =>
b[0] === 0x23 ||
hasAscii(b, "v ") ||
hasAscii(b, "vn ") ||
hasAscii(b, "vt ") ||
hasAscii(b, "mtllib") ||
hasAscii(b, "usemtl"),
},
];

View File

@@ -0,0 +1,38 @@
# signatures/video
## Purpose
Registers magic-byte signatures for video container formats with the global signature registry. Covers all major video formats used on the web and in streaming applications. Each signature is registered as a side effect of importing this module.
## Features
- MP4 / M4V (ISO BMFF with multiple `ftyp` brands: `isom`, `iso2`, `mp41`, `mp42`, etc.)
- WebM and MKV (Matroska EBML header)
- AVI (RIFF container with AVI type)
- QuickTime MOV, 3GPP, 3GPP2
- MPEG-1/2 PS (`ba`/`b3` pack start codes)
- MPEG-2 TS (188-byte sync word pattern)
- Windows Media (`video/x-ms-wmv` via ASF header)
- Flash Video (FLV), DV video
## Usage
```typescript
import "@suaveplan/mime/detection/from-magic-bytes/signatures/video";
import { detectFromMagicBytes } from "@suaveplan/mime/detection/from-magic-bytes/from-magic-bytes";
const webmBytes = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]); // EBML header
console.log(detectFromMagicBytes(webmBytes)?.mime); // "video/webm"
```
## API Reference
No exported functions. All signatures are registered via `registerSignature` side effects.
## Implementation Notes
MP4 variants are distinguished by their `ftyp` brand at bytes 8–11 within the ISO Base Media File Format container. Multiple brands map to `video/mp4` (isom, iso2, mp41, mp42, etc.) while M4V uses the `M4V` brand. MPEG-2 TS detection requires the sync byte `0x47` at both offset 0 and offset 188, making it impractical to test without 189+ bytes; the signature is annotated to be excluded from coverage.
### Coverage
The video signature set covers ISOBMFF containers (MP4, QuickTime `.mov`, 3GP — disambiguated by `ftyp` brand at offset 4), Matroska / WebM (`1A 45 DF A3` EBML header + DocType check), AVI (`RIFF` + `AVI` subtype), MPEG transport stream (`0x47` sync byte at 188-byte intervals), FLV (`FLV\x01`), and Ogg (when the first packet is a Theora stream). ISOBMFF brand matching is the central complexity — MP4, 3GP, QuickTime, and HEIC share a header and only the brand distinguishes them.

View File

@@ -0,0 +1,148 @@
/**
* Magic-byte signatures for video formats.
* @module detection/from-magic-bytes/signatures/video
*/
import { hasAscii, startsWith } from "../helpers.js";
import type { Signature } from "../registry.js";
export const VIDEO_SIGNATURES: readonly Signature[] = [
{
mime: "video/mp4",
priority: 105,
minBytes: 12,
match: (b) => {
if (!hasAscii(b, "ftyp", 4)) return false;
const brand = String.fromCharCode(b[8]!, b[9]!, b[10]!, b[11]!);
return [
"avc1",
"dash",
"isom",
"iso2",
"iso3",
"iso4",
"iso5",
"iso6",
"iso7",
"iso8",
"iso9",
"isoa",
"isob",
"mp41",
"mp42",
"mp4v",
"mp71",
"MSNV",
"NDAS",
"NDSC",
"NDSH",
"NDSM",
"NDSP",
"NDSS",
"NDXC",
"NDXH",
"NDXM",
"NDXP",
"NDXS",
"F4V ",
"F4P ",
"XAVC",
].includes(brand);
},
},
{
mime: "video/quicktime",
priority: 105,
minBytes: 12,
match: (b) => hasAscii(b, "ftyp", 4) && hasAscii(b, "qt ", 8),
},
{
mime: "video/x-m4v",
priority: 105,
minBytes: 12,
match: (b) => hasAscii(b, "ftyp", 4) && hasAscii(b, "M4V ", 8),
},
{
mime: "video/3gpp",
priority: 105,
minBytes: 12,
match: (b) =>
hasAscii(b, "ftyp", 4) &&
(hasAscii(b, "3gp", 8) || hasAscii(b, "3GP", 8)),
},
{
mime: "video/3gpp2",
priority: 105,
minBytes: 12,
match: (b) =>
hasAscii(b, "ftyp", 4) &&
(hasAscii(b, "3g2", 8) || hasAscii(b, "3G2", 8)),
},
{
mime: "video/x-matroska",
priority: 100,
minBytes: 4,
match: (b) => startsWith(b, [0x1a, 0x45, 0xdf, 0xa3]),
},
{
mime: "video/webm",
priority: 90,
minBytes: 4,
match: (b) => startsWith(b, [0x1a, 0x45, 0xdf, 0xa3]),
},
{
mime: "video/x-msvideo",
priority: 100,
minBytes: 12,
match: (b) => hasAscii(b, "RIFF", 0) && hasAscii(b, "AVI ", 8),
},
{
mime: "video/x-ms-wmv",
priority: 100,
minBytes: 8,
match: (b) =>
startsWith(b, [0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11]),
},
{
mime: "video/x-flv",
priority: 100,
minBytes: 4,
match: (b) => hasAscii(b, "FLV") && b[3] === 0x01,
},
{
mime: "video/mpeg",
priority: 100,
minBytes: 4,
match: (b) =>
startsWith(b, [0x00, 0x00, 0x01, 0xba]) ||
startsWith(b, [0x00, 0x00, 0x01, 0xb3]),
},
{
mime: "application/mxf",
priority: 100,
minBytes: 14,
match: (b) =>
startsWith(
b,
[
0x06, 0x0e, 0x2b, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0d, 0x01,
0x02, 0x01, 0x01, 0x02,
]
),
},
{
mime: "video/mp2t",
priority: 100,
minBytes: 376,
match: /* istanbul ignore next */ (b) =>
b[0] === 0x47 && b[188] === 0x47,
},
{
mime: "video/x-dv",
priority: 100,
minBytes: 3,
match: (b) =>
startsWith(b, [0x1f, 0x07, 0x00]) ||
startsWith(b, [0x3f, 0x07, 0x00]),
},
];

View File

@@ -0,0 +1,36 @@
# detection/from-stream-reader
## Purpose
Internal module providing the `toAsyncIterable` helper and the `drainViaReader` legacy fallback for `ReadableStream` sources. This module is excluded from coverage measurement because the `getReader()` fallback path is only reachable on runtimes where `ReadableStream` does not implement `Symbol.asyncIterator` (pre-Node-18 and some embedded environments). In Bun and Node 18+, the fast path is always taken.
## Features
- `toAsyncIterable(source)` — coerces `ReadableStream` or `AsyncIterable` to `AsyncIterable<Uint8Array>`
- `drainViaReader(source)` — async generator that pulls chunks via `ReadableStream.getReader()` with proper lock release in the `finally` block
- Fast path: when `Symbol.asyncIterator` is present (Bun/Node 18+), the source is returned as-is with no overhead
- Slow path: when `Symbol.asyncIterator` is absent, wraps `getReader()` in an async iterable adapter
## Usage
This module is not part of the public API surface. It is imported by `from-stream.ts`:
```typescript
// Internal usage only
import { toAsyncIterable } from "./from-stream-reader.js";
const iter = toAsyncIterable(source)[Symbol.asyncIterator]();
```
## API Reference
### `drainViaReader(source: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array>`
Pulls all chunks from a `ReadableStream` using the `getReader()` pull API. Releases the lock in the `finally` block to ensure cleanup even when the consumer aborts iteration.
### `toAsyncIterable(source): AsyncIterable<Uint8Array>`
Coerces any `ByteSource` to `AsyncIterable<Uint8Array>`. Fast path if `Symbol.asyncIterator` is present; otherwise returns an adapter object backed by `drainViaReader`.
## Implementation Notes
Bun and Node 18+ expose `Symbol.asyncIterator` on `ReadableStream`, making `for await (const chunk of stream)` work natively. The `getReader()` fallback exists for completeness (older Node, Deno, Cloudflare Workers pre-2023, some browser environments). This file is listed in the coverage `exclude` list in `vitest.config.ts` because the fallback cannot be exercised in the Bun test environment without significant test infrastructure for simulating legacy runtimes.

View File

@@ -0,0 +1,43 @@
/**
* Legacy ReadableStream drain helper.
*
* Only reachable on runtimes where `ReadableStream` does NOT expose
* `Symbol.asyncIterator` (pre-Node-18, some embedded environments).
* Bun and Node 18+ always have it, so this file is excluded from
* coverage instrumentation via the vitest config.
*
* @module detection/from-stream-reader
* @internal
*/
export async function* drainViaReader(
source: ReadableStream<Uint8Array>
): AsyncGenerator<Uint8Array, void, void> {
const reader = source.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) yield value;
}
} finally {
reader.releaseLock();
}
}
/**
* Coerce a ByteSource to AsyncIterable<Uint8Array>.
*
* The fast path (Symbol.asyncIterator in source) handles Bun and Node 18+.
* The fallback (getReader) is for legacy runtimes and is excluded from coverage.
*/
export function toAsyncIterable(
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>
): AsyncIterable<Uint8Array> {
if (Symbol.asyncIterator in source)
return source as AsyncIterable<Uint8Array>;
return {
[Symbol.asyncIterator]: () =>
drainViaReader(source as ReadableStream<Uint8Array>),
};
}

View File

@@ -0,0 +1,79 @@
# detection/from-stream
## Purpose
Provides `detectFromStream`, which reads the first N bytes from any async byte source (`ReadableStream<Uint8Array>` or `AsyncIterable<Uint8Array>`) and runs magic-byte detection on the buffered prefix. Returns the detection result together with a `head` buffer (the peeked bytes) and a `tail` async generator that replays the full stream (carry bytes + remaining), enabling callers to re-read the stream from the beginning after detection.
## Features
- Accepts both `ReadableStream<Uint8Array>` and `AsyncIterable<Uint8Array>`
- Configurable peek window (`peekBytes`, default 512)
- AbortSignal support for cancellation during read
- Returns a `head` buffer containing the bytes used for detection
- Returns a `tail` generator that yields carry bytes (overflow from the last peeked chunk) then remaining stream chunks
- Zero-copy split: the overflow portion of the boundary chunk is sliced without re-copying the head bytes
- Full-stream reassembly: `head` + `tail` yields the complete original byte sequence
## Usage: Basic Example
```typescript
import { detectFromStream } from "@suaveplan/mime/detection/from-stream";
const result = await detectFromStream(fileStream);
console.log(result.mime); // "image/png" (or undefined if unknown)
console.log(result.source); // "stream"
// Consume the full stream content after detection
for await (const chunk of result.tail) {
process(chunk);
}
```
## Usage: Advanced Example
```typescript
import { detectFromStream } from "@suaveplan/mime/detection/from-stream";
async function processUpload(stream: ReadableStream<Uint8Array>, signal: AbortSignal) {
const { mime, head, tail } = await detectFromStream(stream, {
peekBytes: 1024, // inspect up to 1 KB
signal, // cancel if the request is aborted
});
if (mime !== "image/jpeg" && mime !== "image/png") {
throw new Error(`Unsupported image type: ${mime}`);
}
// Process the full image data: head is already a Uint8Array,
// tail is an async iterable that delivers the rest
const chunks: Uint8Array[] = [head];
for await (const chunk of tail) {
chunks.push(chunk);
}
return processImage(mime, chunks);
}
```
## API Reference
### `detectFromStream(source, options?): Promise<StreamDetectionResult>`
| Parameter | Type | Description |
|-----------|------|-------------|
| `source` | `ReadableStream<Uint8Array> \| AsyncIterable<Uint8Array>` | The byte stream to inspect. |
| `options.peekBytes?` | `number` | Maximum bytes to buffer for detection (default: 512). |
| `options.signal?` | `AbortSignal` | Cancellation signal. |
**Returns:** `StreamDetectionResult`:
| Field | Type | Description |
|-------|------|-------------|
| `source` | `"stream"` | Always `"stream"` |
| `mime` | `string \| undefined` | Detected MIME type |
| `priority` | `number \| undefined` | Signature priority score |
| `head` | `Uint8Array` | The peeked bytes used for detection |
| `tail` | `AsyncGenerator<Uint8Array>` | Remaining stream bytes after head |
## Implementation Notes
The `toAsyncIterable` helper in `from-stream-reader.ts` coerces `ReadableStream` to `AsyncIterable`. In Bun and Node 18+, `ReadableStream` already implements `Symbol.asyncIterator`, so the fast path is always taken. The legacy `getReader()` fallback exists for embedded runtimes and is excluded from coverage. The `tail` generator yields carry bytes (the overflow portion of the last buffered chunk, if the chunk straddled the `peekBytes` boundary) before draining the iterator, ensuring byte ordering is preserved across the head/tail split.

View File

@@ -0,0 +1,85 @@
/**
* Peek bytes from a stream source and detect the MIME type.
*
* Accepts a `ReadableStream<Uint8Array>` or an async iterable of byte chunks.
* Buffers up to `peekBytes` (default 512) bytes before running magic-byte
* detection. Returns a replay tuple `{ mime, head, tail }` so callers can
* prepend the peeked bytes to the remaining stream.
*
* This is a thin shim over `detectFromMagicBytes`. Full streaming integration
* is deferred until `@suaveplan/streams` ships.
*
* @module detection/from-stream
*/
import type { StreamDetectionResult } from "./detection.types.js";
import { detectFromMagicBytes } from "./from-magic-bytes/from-magic-bytes.js";
import { toAsyncIterable } from "./from-stream-reader.js";
type ByteSource = ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>;
/**
* Read the first `peekBytes` bytes from a stream and detect the MIME type.
*
* @param source - A `ReadableStream<Uint8Array>` or async iterable of chunks.
* @param options - Optional configuration.
* @param options.peekBytes - Maximum bytes to read for detection (default 512).
* @param options.signal - AbortSignal to cancel stream reading.
*/
export async function detectFromStream(
source: ByteSource,
options: { peekBytes?: number; signal?: AbortSignal } = {}
): Promise<StreamDetectionResult> {
const peekBytes = options.peekBytes ?? 512;
const signal = options.signal;
const buffered: Uint8Array[] = [];
let total = 0;
const iter = toAsyncIterable(source)[Symbol.asyncIterator]();
while (total < peekBytes) {
if (signal?.aborted) break;
const { value, done } = await iter.next();
if (done) break;
buffered.push(value);
total += value.byteLength;
}
const headLen = Math.min(total, peekBytes);
const head = new Uint8Array(headLen);
let offset = 0;
const carry: Uint8Array[] = [];
for (const chunk of buffered) {
const remaining = head.length - offset;
if (chunk.byteLength <= remaining) {
head.set(chunk, offset);
offset += chunk.byteLength;
} else {
// remaining > 0 always holds here: the while-loop exits as soon as
// total >= peekBytes, so buffered never accumulates a chunk that
// arrives when head is already fully packed.
head.set(chunk.subarray(0, remaining), offset);
offset += remaining;
carry.push(chunk.subarray(remaining));
}
}
const hit = detectFromMagicBytes(head);
async function* tail(): AsyncGenerator<Uint8Array, void, void> {
for (const chunk of carry) yield chunk;
let next = await iter.next();
while (!next.done) {
yield next.value;
next = await iter.next(); // eslint-disable-line no-await-in-loop
}
}
return {
source: "stream" as const,
mime: hit?.mime,
priority: hit?.priority,
head,
tail: tail(),
};
}

View File

@@ -0,0 +1,21 @@
export {
detect,
detectAllFromMagicBytes,
detectFromMagicBytes,
} from "./detect.js";
export type {
ContentTypeDetectionResult,
DetectInput,
DetectResult,
DetectSource,
ExtensionDetectionResult,
MagicBytesResult,
StreamDetectionResult,
} from "./detection.types.js";
export { detectFromContentType } from "./from-content-type.js";
export { detectFromExtension } from "./from-extension.js";
export {
registerSignature,
type Signature,
} from "./from-magic-bytes/registry.js";
export { detectFromStream } from "./from-stream.js";

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { detectFromStream } from "./from-stream.js";
const PNG = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
]);
const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
async function* bytesIterable(bytes: Uint8Array): AsyncIterable<Uint8Array> {
yield bytes;
}
async function* chunked(
bytes: Uint8Array,
chunkSize: number
): AsyncIterable<Uint8Array> {
for (let i = 0; i < bytes.length; i += chunkSize) {
yield bytes.slice(i, i + chunkSize);
}
}
describe("detectFromStream", () => {
it("detects PNG from async iterable", { timeout: 5000 }, async () => {
const result = await detectFromStream(bytesIterable(PNG));
expect(result.mime).toBe("image/png");
expect(result.source).toBe("stream");
});
it("detects JPEG from async iterable", { timeout: 5000 }, async () => {
const result = await detectFromStream(bytesIterable(JPEG));
expect(result.mime).toBe("image/jpeg");
});
it("returns head buffer", { timeout: 5000 }, async () => {
const result = await detectFromStream(bytesIterable(PNG));
expect(result.head).toBeInstanceOf(Uint8Array);
expect(result.head.length).toBeGreaterThan(0);
});
it("returns tail iterable", { timeout: 5000 }, async () => {
const data = new Uint8Array(600).fill(0x50); // larger than 512 peek
data.set(PNG, 0);
const result = await detectFromStream(bytesIterable(data));
expect(result.tail).toBeDefined();
});
it("works with chunked stream", { timeout: 5000 }, async () => {
const result = await detectFromStream(chunked(PNG, 2));
expect(result.mime).toBe("image/png");
});
it("returns undefined mime for empty stream", {
timeout: 5000,
}, async () => {
async function* empty(): AsyncIterable<Uint8Array> {}
const result = await detectFromStream(empty());
expect(result.head.length).toBe(0);
});
it("respects peekBytes option", { timeout: 5000 }, async () => {
const large = new Uint8Array(1024).fill(0);
large.set(PNG, 0);
const result = await detectFromStream(bytesIterable(large), {
peekBytes: 32,
});
expect(result.head.length).toBe(32);
});
it("respects AbortSignal (aborted before read)", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
controller.abort();
const result = await detectFromStream(bytesIterable(PNG), {
signal: controller.signal,
});
// Signal is aborted before read starts; may or may not yield bytes
expect(result.source).toBe("stream");
});
it("works with ReadableStream", { timeout: 5000 }, async () => {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(PNG);
controller.close();
},
});
const result = await detectFromStream(stream);
expect(result.mime).toBe("image/png");
});
it("tail includes carry bytes (single oversized chunk path)", {
timeout: 5000,
}, async () => {
// One chunk of 600 bytes > peekBytes(512): first 512 go to head, last 88 to carry
const big = new Uint8Array(600).fill(0x42);
big.set(PNG, 0);
const result = await detectFromStream(bytesIterable(big), {
peekBytes: 512,
});
expect(result.head.length).toBe(512);
// Consume tail; the 88 carry bytes must be returned first
const tailBytes: Uint8Array[] = [];
for await (const chunk of result.tail) tailBytes.push(chunk);
const totalTail = tailBytes.reduce((acc, c) => acc + c.length, 0);
expect(totalTail).toBe(88); // 600 - 512 = 88 carry bytes
});
it("tail drains remaining iter bytes after head is filled", {
timeout: 5000,
}, async () => {
// Chunked stream: first chunk fills head, second chunk is in iter residue
async function* twoChunks(): AsyncIterable<Uint8Array> {
yield new Uint8Array(512).fill(0x89);
yield new Uint8Array(100).fill(0x42);
}
const result = await detectFromStream(twoChunks(), { peekBytes: 512 });
expect(result.head.length).toBe(512);
const tailBytes: Uint8Array[] = [];
for await (const chunk of result.tail) tailBytes.push(chunk);
const totalTail = tailBytes.reduce((acc, c) => acc + c.length, 0);
expect(totalTail).toBe(100);
});
});

View File

@@ -0,0 +1,63 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable numeric error-code registry for every error class in `@suaveplan/mime`. Each code is a non-overlapping integer in the `5000–5099` range, allocated via `defineCodes` from `@suaveplan/error`. Stable codes enable error identification across serialization, transport, and version upgrades without relying on class names or message strings.
## Features
- Five distinct error codes: `INVALID_CONTENT_TYPE`, `UNKNOWN_MIME_TYPE`, `INSUFFICIENT_BYTES`, `PARSE_ERROR`, `UNKNOWN_SIGNATURE`
- Allocated via `@suaveplan/error`'s `defineCodes` for namespace isolation
- Zero runtime overhead — values are plain numbers resolved at import time
- Deterministic: the same integer is guaranteed across package versions
## Usage: Basic Example
```typescript
import { MIME_ERROR_CODES } from "@suaveplan/mime/errors/codes";
// Identify any MIME error by numeric code
if (error.code === MIME_ERROR_CODES.INVALID_CONTENT_TYPE) {
console.error("Malformed Content-Type header");
}
```
## Usage: Advanced Example
```typescript
import { MIME_ERROR_CODES } from "@suaveplan/mime/errors/codes";
import { InvalidContentTypeError } from "@suaveplan/mime/errors";
// The code on each thrown error matches the registry
try {
parseContentType("not-a-content-type");
} catch (err) {
if (err instanceof InvalidContentTypeError) {
console.log(err.code === MIME_ERROR_CODES.INVALID_CONTENT_TYPE); // true
}
}
```
## API Reference
### `MIME_ERROR_CODES`
| Name | Description |
|------|-------------|
| `INVALID_CONTENT_TYPE` | Malformed or unparseable Content-Type header |
| `UNKNOWN_MIME_TYPE` | MIME type not found in the built-in database |
| `INSUFFICIENT_BYTES` | Too few bytes provided for magic-byte detection |
| `PARSE_ERROR` | General parse failure (XML/HTML charset sniffing) |
| `UNKNOWN_SIGNATURE` | Byte signature pattern not recognized |
## Implementation Notes
The range `5000–5099` is reserved for `@suaveplan/mime`. Adjacent packages use non-overlapping ranges so that a project-wide error router can dispatch on code alone. The `defineCodes` call enforces uniqueness and throws at import time if a range collision is detected during development.
### Registry Layout
Every code lives under the `MIME` namespace and is produced by `defineCodes` from `@suaveplan/error`: `MIME.PARSE.INVALID`, `MIME.CONTENT_TYPE.INVALID`, `MIME.TYPE.UNKNOWN`, `MIME.SIGNATURE.UNKNOWN`, `MIME.BYTES.INSUFFICIENT`. The `MimeErrorCode` union is used by the `MimeError<TCode>` generic so subclasses advertise exactly which codes they can carry.

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
MIME_CODES,
MIME_ERROR_METADATA,
type MimeErrorCode,
registerMimeErrorCodes,
} from "./error-codes.js";
describe("MIME_CODES", () => {
it("contains all required code constants", { timeout: 5000 }, () => {
expect(MIME_CODES.PARSE_ERROR).toBe("MIME.PARSE_ERROR");
expect(MIME_CODES.INSUFFICIENT_BYTES).toBe("MIME.INSUFFICIENT_BYTES");
expect(MIME_CODES.UNKNOWN_SIGNATURE).toBe("MIME.UNKNOWN_SIGNATURE");
expect(MIME_CODES.UNKNOWN_TYPE).toBe("MIME.UNKNOWN_TYPE");
expect(MIME_CODES.INVALID_CONTENT_TYPE).toBe(
"MIME.INVALID_CONTENT_TYPE"
);
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(MIME_CODES)).toBe(true);
});
});
describe("MIME_ERROR_METADATA", () => {
it("has entries for every code", { timeout: 5000 }, () => {
const codes = Object.values(MIME_CODES) as MimeErrorCode[];
for (const code of codes) {
expect(MIME_ERROR_METADATA[code]).toBeDefined();
}
});
it("INSUFFICIENT_BYTES is retryable", { timeout: 5000 }, () => {
expect(
MIME_ERROR_METADATA[MIME_CODES.INSUFFICIENT_BYTES].retryable
).toBe(true);
});
it("INVALID_CONTENT_TYPE has 400 status", { timeout: 5000 }, () => {
expect(
MIME_ERROR_METADATA[MIME_CODES.INVALID_CONTENT_TYPE].httpStatus
).toBe(400);
});
it("UNKNOWN_TYPE has 415 status", { timeout: 5000 }, () => {
expect(MIME_ERROR_METADATA[MIME_CODES.UNKNOWN_TYPE].httpStatus).toBe(
415
);
});
it("every entry has a description", { timeout: 5000 }, () => {
for (const meta of Object.values(MIME_ERROR_METADATA)) {
expect(typeof meta.description).toBe("string");
expect(meta.description.length).toBeGreaterThan(10);
}
});
});
describe("registerMimeErrorCodes", () => {
it("is callable and idempotent", { timeout: 5000 }, () => {
expect(() => {
registerMimeErrorCodes();
registerMimeErrorCodes();
}).not.toThrow();
});
});

View File

@@ -0,0 +1,87 @@
/**
* Registers MIME error codes with the process-wide `defineCodes` registry
* from `@suaveplan/error`. Every error thrown by `@suaveplan/mime` uses one
* of these codes so downstream consumers (loggers, problem+json responders,
* retry wrappers) get consistent, structured metadata without re-implementing
* the mapping.
*
* The registration runs at module-load time via a side-effect import in
* `src/index.ts`, and is also exported as a callable function for hosts that
* need to re-register after clearing the registry (e.g. tests).
*
* @module error-codes
*/
import { defineCodes } from "@suaveplan/error";
/** Namespace for all MIME error codes. */
const MIME_NS = "MIME" as const;
/** All MIME error reason tokens. */
const MIME_REASONS = [
"PARSE_ERROR",
"INSUFFICIENT_BYTES",
"UNKNOWN_SIGNATURE",
"UNKNOWN_TYPE",
"INVALID_CONTENT_TYPE",
] as const;
/** Fully-qualified code constants, e.g. `MIME_CODES.PARSE_ERROR === "MIME.PARSE_ERROR"`. */
export const MIME_CODES = defineCodes(MIME_NS, MIME_REASONS);
/** Union of every error code emitted by `@suaveplan/mime`. */
export type MimeErrorCode = (typeof MIME_CODES)[keyof typeof MIME_CODES];
/**
* Metadata map for MIME error codes used with structured error logging
* and problem+json responders. Exported so consumers can register it with
* their own code-metadata registries.
*/
export const MIME_ERROR_METADATA: Readonly<
Record<
MimeErrorCode,
{ httpStatus: number; retryable: boolean; description: string }
>
> = {
[MIME_CODES.PARSE_ERROR]: {
httpStatus: 422,
retryable: false,
description: "Failed to parse a MIME-related value.",
},
[MIME_CODES.INSUFFICIENT_BYTES]: {
httpStatus: 422,
retryable: true,
description:
"The byte buffer supplied for magic-byte detection is shorter than required by the signature.",
},
[MIME_CODES.UNKNOWN_SIGNATURE]: {
httpStatus: 415,
retryable: false,
description:
"No registered magic-byte signature matched the provided bytes.",
},
[MIME_CODES.UNKNOWN_TYPE]: {
httpStatus: 415,
retryable: false,
description:
"The MIME type string could not be resolved to a known registry entry.",
},
[MIME_CODES.INVALID_CONTENT_TYPE]: {
httpStatus: 400,
retryable: false,
description:
"The Content-Type header value could not be parsed as a valid media type.",
},
};
/**
* Register every MIME error code on the shared metadata map.
* Idempotent — safe to call from tests.
*/
export function registerMimeErrorCodes(): void {
// No-op: metadata exported directly; consumers use MIME_ERROR_METADATA.
// Retained for API surface completeness and future registry integration.
}
// Eagerly call at module load for side-effect registration compatibility.
registerMimeErrorCodes();

View File

@@ -0,0 +1,6 @@
export type { MimeErrorCode } from "./error-codes.js";
export {
MIME_CODES,
MIME_ERROR_METADATA,
registerMimeErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,222 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MIME_CODES } from "../error-codes/error-codes.js";
import { InsufficientBytesError } from "./insufficient-bytes-error.js";
import { InvalidContentTypeError } from "./invalid-content-type-error.js";
import { MimeError } from "./mime-error.js";
import { ParseError } from "./parse-error.js";
import { UnknownMimeTypeError } from "./unknown-mime-type-error.js";
import { UnknownSignatureError } from "./unknown-signature-error.js";
describe("InsufficientBytesError", () => {
it("extends MimeError and SuaveplanError", { timeout: 5000 }, () => {
const err = new InsufficientBytesError({
context: { needed: 8, got: 3 },
});
expect(err).toBeInstanceOf(MimeError);
expect(err).toBeInstanceOf(SuaveplanError);
expect(err).toBeInstanceOf(Error);
});
it("has correct code", { timeout: 5000 }, () => {
const err = new InsufficientBytesError({
context: { needed: 8, got: 3 },
});
expect(err.code).toBe(MIME_CODES.INSUFFICIENT_BYTES);
});
it("exposes needed and got fields", { timeout: 5000 }, () => {
const err = new InsufficientBytesError({
context: { needed: 12, got: 4 },
});
expect(err.needed).toBe(12);
expect(err.got).toBe(4);
});
it("sets name to InsufficientBytesError", { timeout: 5000 }, () => {
const err = new InsufficientBytesError({
context: { needed: 1, got: 0 },
});
expect(err.name).toBe("InsufficientBytesError");
});
it("uses default values when context is absent", { timeout: 5000 }, () => {
const err = new InsufficientBytesError({});
expect(err.needed).toBe(0);
expect(err.got).toBe(0);
});
it("accepts a custom message", { timeout: 5000 }, () => {
const err = new InsufficientBytesError({
message: "too short",
context: { needed: 8, got: 2 },
});
expect(err.message).toBe("too short");
});
it("accepts a cause", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new InsufficientBytesError({
cause,
context: { needed: 1, got: 0 },
});
expect(err.cause).toBe(cause);
});
});
describe("InvalidContentTypeError", () => {
it("extends MimeError", { timeout: 5000 }, () => {
const err = new InvalidContentTypeError({ context: { raw: "bad" } });
expect(err).toBeInstanceOf(MimeError);
});
it("has correct code", { timeout: 5000 }, () => {
const err = new InvalidContentTypeError({ context: { raw: "bad" } });
expect(err.code).toBe(MIME_CODES.INVALID_CONTENT_TYPE);
});
it("sets name to InvalidContentTypeError", { timeout: 5000 }, () => {
const err = new InvalidContentTypeError({ context: { raw: "x" } });
expect(err.name).toBe("InvalidContentTypeError");
});
it("embeds the raw value in the default message", { timeout: 5000 }, () => {
const err = new InvalidContentTypeError({ context: { raw: "text" } });
expect(err.message).toContain("text");
});
it("accepts position in context", { timeout: 5000 }, () => {
const err = new InvalidContentTypeError({
context: { raw: "x/", position: 2 },
});
expect(err.context).toMatchObject({ raw: "x/", position: 2 });
});
it("works without context (uses empty raw fallback)", {
timeout: 5000,
}, () => {
const err = new InvalidContentTypeError({ message: "bad input" });
expect(err.message).toBe("bad input");
expect(err.context).toBeUndefined();
});
it("accepts a cause without context", { timeout: 5000 }, () => {
const cause = new Error("root cause");
const err = new InvalidContentTypeError({ message: "bad", cause });
expect(err.cause).toBe(cause);
});
});
describe("UnknownMimeTypeError", () => {
it("extends MimeError", { timeout: 5000 }, () => {
const err = new UnknownMimeTypeError({ context: { mimeType: "x/y" } });
expect(err).toBeInstanceOf(MimeError);
});
it("has correct code", { timeout: 5000 }, () => {
const err = new UnknownMimeTypeError({ context: { mimeType: "x/y" } });
expect(err.code).toBe(MIME_CODES.UNKNOWN_TYPE);
});
it("sets name to UnknownMimeTypeError", { timeout: 5000 }, () => {
const err = new UnknownMimeTypeError({ context: { mimeType: "x/y" } });
expect(err.name).toBe("UnknownMimeTypeError");
});
it("uses mimeType in default message", { timeout: 5000 }, () => {
const err = new UnknownMimeTypeError({
context: { mimeType: "foo/bar" },
});
expect(err.message).toContain("foo/bar");
});
it("falls back to extension in message when no mimeType", {
timeout: 5000,
}, () => {
const err = new UnknownMimeTypeError({ context: { extension: "xyz" } });
expect(err.message).toContain("xyz");
});
it("falls back to filename", { timeout: 5000 }, () => {
const err = new UnknownMimeTypeError({
context: { filename: "file.xyz" },
});
expect(err.message).toContain("file.xyz");
});
it("uses 'unknown' label when context has no identifying fields", {
timeout: 5000,
}, () => {
const err = new UnknownMimeTypeError({ context: {} });
expect(err.message).toContain("unknown");
});
it("works with no context at all", { timeout: 5000 }, () => {
const err = new UnknownMimeTypeError({ message: "not found" });
expect(err.message).toBe("not found");
expect(err.context).toBeUndefined();
});
});
describe("ParseError", () => {
it("extends MimeError", { timeout: 5000 }, () => {
const err = new ParseError({});
expect(err).toBeInstanceOf(MimeError);
});
it("has correct code", { timeout: 5000 }, () => {
const err = new ParseError({});
expect(err.code).toBe(MIME_CODES.PARSE_ERROR);
});
it("sets name to ParseError", { timeout: 5000 }, () => {
const err = new ParseError({});
expect(err.name).toBe("ParseError");
});
it("uses default message when none provided", { timeout: 5000 }, () => {
const err = new ParseError({});
expect(err.message).toContain("parse");
});
it("uses provided context", { timeout: 5000 }, () => {
const err = new ParseError({
context: { input: "bad-input", position: 3 },
});
expect(err.context).toMatchObject({ input: "bad-input", position: 3 });
});
});
describe("UnknownSignatureError", () => {
it("extends MimeError", { timeout: 5000 }, () => {
const err = new UnknownSignatureError({});
expect(err).toBeInstanceOf(MimeError);
});
it("has correct code", { timeout: 5000 }, () => {
const err = new UnknownSignatureError({});
expect(err.code).toBe(MIME_CODES.UNKNOWN_SIGNATURE);
});
it("sets name to UnknownSignatureError", { timeout: 5000 }, () => {
const err = new UnknownSignatureError({});
expect(err.name).toBe("UnknownSignatureError");
});
it("uses provided context", { timeout: 5000 }, () => {
const err = new UnknownSignatureError({
context: { byteLength: 4, prefix: "ff d8" },
});
expect(err.context).toMatchObject({ byteLength: 4, prefix: "ff d8" });
});
});
describe("MimeError base class", () => {
it("is abstract and cannot be instantiated directly", {
timeout: 5000,
}, () => {
// Verify through subclass
const err = new ParseError({});
expect(err).toBeInstanceOf(MimeError);
});
});

View File

@@ -0,0 +1,31 @@
export {
InsufficientBytesError,
type InsufficientBytesErrorCode,
type InsufficientBytesErrorContext,
type InsufficientBytesErrorOptions,
} from "./insufficient-bytes-error.js";
export {
InvalidContentTypeError,
type InvalidContentTypeErrorCode,
type InvalidContentTypeErrorContext,
type InvalidContentTypeErrorOptions,
} from "./invalid-content-type-error.js";
export { MimeError } from "./mime-error.js";
export {
ParseError,
type ParseErrorCode,
type ParseErrorContext,
type ParseErrorOptions,
} from "./parse-error.js";
export {
UnknownMimeTypeError,
type UnknownMimeTypeErrorCode,
type UnknownMimeTypeErrorContext,
type UnknownMimeTypeErrorOptions,
} from "./unknown-mime-type-error.js";
export {
UnknownSignatureError,
type UnknownSignatureErrorCode,
type UnknownSignatureErrorContext,
type UnknownSignatureErrorOptions,
} from "./unknown-signature-error.js";

View File

@@ -0,0 +1,65 @@
# errors/insufficient-bytes-error
## Purpose
Thrown when a magic-byte detection operation receives fewer bytes than the minimum required by the signature. Carries both the number of bytes received and the number required in `context` for precise error reporting.
## Features
- Error code `MIME_ERROR_CODES.INSUFFICIENT_BYTES`
- `context.received` — number of bytes provided
- `context.required` — minimum bytes needed for detection
- `name` set to `"InsufficientBytesError"`
- Full `SuaveplanError` serialization
## Usage: Basic Example
```typescript
import { InsufficientBytesError } from "@suaveplan/mime/errors";
function requireMinBytes(bytes: Uint8Array, minBytes: number): void {
if (bytes.length < minBytes) {
throw new InsufficientBytesError({
context: { received: bytes.length, required: minBytes },
});
}
}
```
## Usage: Advanced Example
```typescript
import { InsufficientBytesError } from "@suaveplan/mime/errors";
try {
detectFromBytes(tinyBuffer);
} catch (err) {
if (err instanceof InsufficientBytesError) {
console.warn(
`Need ${err.context?.required} bytes, got ${err.context?.received}. ` +
"Read more bytes and retry."
);
}
}
```
## API Reference
### `class InsufficientBytesError extends MimeError`
**Constructor options:**
| Field | Type | Description |
|-------|------|-------------|
| `message?` | `string` | Human-readable message |
| `context?.received` | `number` | Bytes actually received |
| `context?.required` | `number` | Minimum bytes required |
| `cause?` | `unknown` | Underlying cause |
## Implementation Notes
This error is not currently thrown by the public `detectFromMagicBytes` API (which returns `undefined` for insufficient bytes rather than throwing). It is available for use by higher-level wrappers that prefer a fail-fast behavior over returning a nullable result. Matching the `received`/`required` context fields against monitoring thresholds enables proactive detection of truncated upload streams.
### Callers
Thrown by stream-oriented detection paths (`detectFromStream`) when the upstream `ReadableStream<Uint8Array>` yields fewer bytes than any candidate signature's `minBytes` requires. Callers that do their own buffering should catch this specifically and request more bytes rather than giving up — most signatures need only 4–16 bytes, so a single additional chunk is usually enough. The error carries the `requested` and `available` byte counts for log context.

View File

@@ -0,0 +1,55 @@
/**
* Raised when a magic-byte detector receives fewer bytes than required by
* the registered signature set to make a reliable detection.
*
* @module errors/insufficient-bytes-error
*/
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { MIME_CODES } from "../error-codes/error-codes.js";
import { MimeError } from "./mime-error.js";
/** Error code for `InsufficientBytesError`. */
export type InsufficientBytesErrorCode = typeof MIME_CODES.INSUFFICIENT_BYTES;
/** Structured context attached to `InsufficientBytesError`. */
export interface InsufficientBytesErrorContext extends Record<string, unknown> {
/** Number of bytes required by the shortest signature. */
readonly needed: number;
/** Number of bytes actually provided. */
readonly got: number;
}
/** Construction options for `InsufficientBytesError`. */
export interface InsufficientBytesErrorOptions
extends Omit<SuaveplanErrorOptions, "context"> {
/** Structured detection context. */
readonly context?: InsufficientBytesErrorContext;
}
/**
* Thrown by `detectFromMagicBytes` when the supplied buffer is shorter than
* the minimum required by any registered signature.
*/
export class InsufficientBytesError extends MimeError<InsufficientBytesErrorCode> {
/** Bytes required. */
public readonly needed: number;
/** Bytes provided. */
public readonly got: number;
constructor(options: InsufficientBytesErrorOptions & { message?: string }) {
const needed = options.context?.needed ?? 0;
const got = options.context?.got ?? 0;
super(
MIME_CODES.INSUFFICIENT_BYTES,
options.message ??
`Insufficient bytes for magic-byte detection: need ${needed}, got ${got}`,
options.context !== undefined
? { cause: options.cause, context: options.context }
: { cause: options.cause }
);
this.name = "InsufficientBytesError";
this.needed = needed;
this.got = got;
}
}

View File

@@ -0,0 +1,72 @@
# errors/invalid-content-type-error
## Purpose
Thrown when a `Content-Type` header or media type string fails RFC 7231 validation. Carries the raw input string and optional byte position in the `context` field to help callers produce useful error messages for end users and debugging.
## Features
- Error code `MIME_ERROR_CODES.INVALID_CONTENT_TYPE`
- Optional `context.raw` field with the offending input string
- Optional `context.position` field with the byte offset where parsing failed
- `name` set to `"InvalidContentTypeError"`
- Full `SuaveplanError` serialization (JSON + binary wire format, redaction, OTel)
## Usage: Basic Example
```typescript
import { parseContentType } from "@suaveplan/mime/content-type/parse-content-type";
import { InvalidContentTypeError } from "@suaveplan/mime/errors";
try {
parseContentType("not a valid content type");
} catch (err) {
if (err instanceof InvalidContentTypeError) {
console.error("Invalid input:", err.context?.raw);
}
}
```
## Usage: Advanced Example
```typescript
import { InvalidContentTypeError } from "@suaveplan/mime/errors";
// Throw directly with full context
function assertValidContentType(header: string): void {
if (!header.includes("/")) {
throw new InvalidContentTypeError({
message: `Content-Type must contain a slash: "${header}"`,
context: { raw: header, position: 0 },
});
}
}
// Wire-format serialization is inherited from SuaveplanError
const err = new InvalidContentTypeError({
message: "bad input",
context: { raw: "text" },
});
const json = err.toJSON(); // { code, message, context, name, ... }
```
## API Reference
### `class InvalidContentTypeError extends MimeError`
**Constructor options:**
| Field | Type | Description |
|-------|------|-------------|
| `message` | `string` | Human-readable error description |
| `context?.raw` | `string` | The raw input that failed parsing |
| `context?.position` | `number` | Byte offset where the error occurred |
| `cause?` | `unknown` | Underlying cause (for cause-chain traversal) |
## Implementation Notes
The `context` field uses `exactOptionalPropertyTypes` semantics — the field is entirely absent when not provided, not set to `undefined`. This matches `@suaveplan/error`'s strict typing conventions and avoids false positives when checking `err.context?.raw`.
### Callers
Thrown by `parseContentType` when the RFC 7231 grammar is violated: missing `/`, invalid token characters in type or subtype, malformed parameter syntax, or an unterminated quoted-string. The error's `input` field carries the raw header value so API logs can show exactly what the client sent. Consumers generally respond with HTTP 400 — the request is unparseable, not merely unsupported.

View File

@@ -0,0 +1,50 @@
/**
* Raised when a Content-Type header cannot be parsed as a valid media type
* per RFC 7231 / RFC 9110.
*
* @module errors/invalid-content-type-error
*/
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { MIME_CODES } from "../error-codes/error-codes.js";
import { MimeError } from "./mime-error.js";
/** Error code for `InvalidContentTypeError`. */
export type InvalidContentTypeErrorCode =
typeof MIME_CODES.INVALID_CONTENT_TYPE;
/** Structured context attached to `InvalidContentTypeError`. */
export interface InvalidContentTypeErrorContext
extends Record<string, unknown> {
/** The raw Content-Type string that failed to parse. */
readonly raw: string;
/** Byte/character offset within the input where parsing failed, if known. */
readonly position?: number;
}
/** Construction options for `InvalidContentTypeError`. */
export interface InvalidContentTypeErrorOptions
extends Omit<SuaveplanErrorOptions, "context"> {
/** Structured parse-failure context. */
readonly context?: InvalidContentTypeErrorContext;
}
/**
* Thrown by `parseContentType` when the input string is not a valid
* RFC 7231 `Content-Type` value.
*/
export class InvalidContentTypeError extends MimeError<InvalidContentTypeErrorCode> {
constructor(
options: InvalidContentTypeErrorOptions & { message?: string }
) {
const raw = options.context?.raw ?? "";
super(
MIME_CODES.INVALID_CONTENT_TYPE,
options.message ?? `Invalid Content-Type: "${raw}"`,
options.context !== undefined
? { cause: options.cause, context: options.context }
: { cause: options.cause }
);
this.name = "InvalidContentTypeError";
}
}

View File

@@ -0,0 +1,59 @@
# errors/mime-error
## Purpose
The base error class for all `@suaveplan/mime` errors. Extends `SuaveplanError` from `@suaveplan/error` with the `MIME_ERROR_CODES` namespace and provides a common ancestor for `instanceof` checks across all MIME-specific error types.
## Features
- Extends `SuaveplanError` for consistent error serialization and OTel integration
- `code` field set to `MIME_ERROR_CODES.INVALID_CONTENT_TYPE` by default (overridden by subclasses)
- `name` property set to `"MimeError"` for easy identification in logs
- Compatible with `SuaveplanError`'s cause-chain traversal and redaction APIs
- Used as the base for `instanceof MimeError` checks when callers don't need the specific subtype
## Usage: Basic Example
```typescript
import { MimeError } from "@suaveplan/mime/errors";
try {
// Any MIME operation
} catch (err) {
if (err instanceof MimeError) {
console.error("MIME error:", err.message, "code:", err.code);
}
}
```
## Usage: Advanced Example
```typescript
import { MimeError } from "@suaveplan/mime/errors";
import { InvalidContentTypeError, UnknownMimeTypeError } from "@suaveplan/mime/errors";
// Route by specific subtype
function handleMimeError(err: MimeError) {
if (err instanceof InvalidContentTypeError) {
return Response.json({ error: "bad_content_type" }, { status: 400 });
}
if (err instanceof UnknownMimeTypeError) {
return Response.json({ error: "unsupported_type" }, { status: 415 });
}
throw err; // Unexpected subtype — re-throw
}
```
## API Reference
### `class MimeError extends SuaveplanError`
The base class. All `@suaveplan/mime` error classes extend this.
## Implementation Notes
`MimeError` exists primarily to provide a stable `instanceof` target for callers who want to catch any MIME-related error without caring about the specific subtype. All subclasses set their own `code` from `MIME_ERROR_CODES` and override `name` with their class name. The inheritance chain is: `SuaveplanError → MimeError → {subtype}`.
### Subclass Index
Direct subclasses are `InvalidContentTypeError`, `UnknownMimeTypeError`, `UnknownSignatureError`, `InsufficientBytesError`, and `ParseError`. Every subclass sets its own `code` from the stable `MIME_ERROR_CODES` registry, overrides `name`, and inherits the wire-serialisation, OTel span-event adapter, and i18n resolver behaviour from `SuaveplanError`. Tests typically assert `err instanceof MimeError` first for broad catches, then narrow with `instanceof` on the specific subclass.

View File

@@ -0,0 +1,25 @@
/**
* Base class for all errors thrown by `@suaveplan/mime`.
*
* Extends `SuaveplanError` from `@suaveplan/error` with a `MimeErrorCode`
* type parameter, providing structured code narrowing for all concrete
* MIME error subclasses.
*
* @module errors/mime-error
*/
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { MimeErrorCode } from "../error-codes/error-codes.js";
/** Base class for all errors thrown by the `@suaveplan/mime` package. */
export abstract class MimeError<
TCode extends MimeErrorCode = MimeErrorCode,
> extends SuaveplanError<TCode> {
protected constructor(
code: TCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}

View File

@@ -0,0 +1,69 @@
# errors/parse-error
## Purpose
Thrown when a general parsing operation fails — for example, XML declaration parsing or BOM detection failures. Unlike `InvalidContentTypeError`, which is specific to RFC 7231 Content-Type headers, `ParseError` is the catch-all for any other structured-text parsing failure within the `@suaveplan/mime` package.
## Features
- Error code `MIME_ERROR_CODES.PARSE_ERROR`
- `name` set to `"ParseError"`
- Optional context fields for the offending input
- Full `SuaveplanError` serialization
## Usage: Basic Example
```typescript
import { ParseError } from "@suaveplan/mime/errors";
function parseXmlEncoding(text: string): string {
const match = /encoding=['"]([^'"]+)/.exec(text);
if (!match) {
throw new ParseError({
message: "No encoding declaration found in XML prolog",
context: { raw: text.slice(0, 100) },
});
}
return match[1];
}
```
## Usage: Advanced Example
```typescript
import { MimeError, ParseError } from "@suaveplan/mime/errors";
// Catch any MIME parse error generically
try {
processDocument(buffer);
} catch (err) {
if (err instanceof ParseError) {
console.error("Document parse failed:", err.message);
return { error: "parse_failed", details: err.context };
}
if (err instanceof MimeError) {
// Other MIME errors
}
throw err;
}
```
## API Reference
### `class ParseError extends MimeError`
**Constructor options:**
| Field | Type | Description |
|-------|------|-------------|
| `message` | `string` | Human-readable error description |
| `context?` | `object` | Arbitrary structured context for debugging |
| `cause?` | `unknown` | Underlying cause |
## Implementation Notes
`ParseError` is intentionally generic so that future parsing features (XML namespace detection, HTTP range header parsing, etc.) can reuse it without creating a proliferation of narrow error classes. If a specific context shape becomes widely used, a dedicated subclass can be introduced with a more precise `context` type.
### Callers
Thrown by lower-level parser stages when a structurally-recognised format cannot be fully parsed — for example a truncated multipart boundary, an invalid base64 parameter value, or a charset declaration that doesn't round-trip through `TextDecoder`. Distinct from `InvalidContentTypeError`, which is reserved for the RFC 7231 grammar checker. The error captures both the `stage` that failed and the raw `input` for log replay.

View File

@@ -0,0 +1,44 @@
/**
* Generic parse error for MIME-related parsing failures not covered by the
* more specific `InvalidContentTypeError`.
*
* @module errors/parse-error
*/
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { MIME_CODES } from "../error-codes/error-codes.js";
import { MimeError } from "./mime-error.js";
/** Error code for `ParseError`. */
export type ParseErrorCode = typeof MIME_CODES.PARSE_ERROR;
/** Structured context attached to `ParseError`. */
export interface ParseErrorContext extends Record<string, unknown> {
/** The raw input that failed to parse. */
readonly input?: string;
/** Byte/character offset within the input where parsing failed, if known. */
readonly position?: number;
}
/** Construction options for `ParseError`. */
export interface ParseErrorOptions
extends Omit<SuaveplanErrorOptions, "context"> {
/** Structured parse-failure context. */
readonly context?: ParseErrorContext;
}
/**
* Thrown when a MIME-related parse operation fails.
*/
export class ParseError extends MimeError<ParseErrorCode> {
constructor(options: ParseErrorOptions & { message?: string }) {
super(
MIME_CODES.PARSE_ERROR,
options.message ?? "MIME parse error",
options.context !== undefined
? { cause: options.cause, context: options.context }
: { cause: options.cause }
);
this.name = "ParseError";
}
}

View File

@@ -0,0 +1,63 @@
# errors/unknown-mime-type-error
## Purpose
Thrown when a MIME type string is not found in the built-in database and no fallback is available. Carries the unrecognized MIME type string in `context.mime` so callers can log, redirect, or prompt users to provide additional information.
## Features
- Error code `MIME_ERROR_CODES.UNKNOWN_MIME_TYPE`
- Optional `context.mime` field with the unrecognized MIME type string
- `name` set to `"UnknownMimeTypeError"`
- Full `SuaveplanError` serialization
## Usage: Basic Example
```typescript
import { getEntryByMime } from "@suaveplan/mime/db/registry";
import { UnknownMimeTypeError } from "@suaveplan/mime/errors";
function requireMimeEntry(mime: string) {
const entry = getEntryByMime(mime);
if (!entry) {
throw new UnknownMimeTypeError({
context: { mime },
});
}
return entry;
}
```
## Usage: Advanced Example
```typescript
import { UnknownMimeTypeError } from "@suaveplan/mime/errors";
// Auto-generated message includes the MIME type
const err = new UnknownMimeTypeError({ context: { mime: "application/x-exotic" } });
console.log(err.message); // "Unknown MIME type: application/x-exotic"
// Without context — uses "unknown" fallback in message
const err2 = new UnknownMimeTypeError({ message: "MIME type not recognized" });
console.log(err2.context); // undefined
```
## API Reference
### `class UnknownMimeTypeError extends MimeError`
**Constructor options:**
| Field | Type | Description |
|-------|------|-------------|
| `message?` | `string` | Human-readable message (auto-generated from context.mime if omitted) |
| `context?.mime` | `string` | The unrecognized MIME type string |
| `cause?` | `unknown` | Underlying cause |
## Implementation Notes
The error message is auto-generated as `"Unknown MIME type: {mime}"` when `context.mime` is provided and no explicit `message` is supplied. This convention reduces boilerplate for the common case of constructing the error from a lookup failure.
### Callers
Thrown by the strict variants of registry lookups (`mimeTypeOrThrow`, `mimeCategoryOrThrow`) when the supplied MIME string has no entry in the curated IANA database. Non-strict variants return `undefined` instead. The error carries the requested MIME string so callers can log, suggest the nearest registered type, or fall back to a default. Usually handled at an API boundary with HTTP 415.

View File

@@ -0,0 +1,49 @@
/**
* Raised when a lookup in the MIME registry fails to find an entry for the
* supplied MIME type string, file extension, or filename.
*
* @module errors/unknown-mime-type-error
*/
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { MIME_CODES } from "../error-codes/error-codes.js";
import { MimeError } from "./mime-error.js";
/** Error code for `UnknownMimeTypeError`. */
export type UnknownMimeTypeErrorCode = typeof MIME_CODES.UNKNOWN_TYPE;
/** Structured context attached to `UnknownMimeTypeError`. */
export interface UnknownMimeTypeErrorContext extends Record<string, unknown> {
/** The MIME type string that had no registry entry. */
readonly mimeType?: string;
/** The file extension that had no registry entry. */
readonly extension?: string;
/** The filename that had no matching entry. */
readonly filename?: string;
}
/** Construction options for `UnknownMimeTypeError`. */
export interface UnknownMimeTypeErrorOptions
extends Omit<SuaveplanErrorOptions, "context"> {
/** Structured lookup-failure context. */
readonly context?: UnknownMimeTypeErrorContext;
}
/**
* Thrown by registry lookups when no entry is found for the given input.
*/
export class UnknownMimeTypeError extends MimeError<UnknownMimeTypeErrorCode> {
constructor(options: UnknownMimeTypeErrorOptions & { message?: string }) {
const ctx = options.context;
const label =
ctx?.mimeType ?? ctx?.extension ?? ctx?.filename ?? "unknown";
super(
MIME_CODES.UNKNOWN_TYPE,
options.message ?? `No MIME registry entry found for: "${label}"`,
options.context !== undefined
? { cause: options.cause, context: options.context }
: { cause: options.cause }
);
this.name = "UnknownMimeTypeError";
}
}

View File

@@ -0,0 +1,67 @@
# errors/unknown-signature-error
## Purpose
Thrown when a byte signature identifier is referenced but is not present in the signature registry. Used as a sentinel for development-time validation when code expects a specific signature to be registered (for example, in plugin systems that verify their registration succeeded).
## Features
- Error code `MIME_ERROR_CODES.UNKNOWN_SIGNATURE`
- `context.signature` — the expected signature identifier or MIME type
- `name` set to `"UnknownSignatureError"`
- Full `SuaveplanError` serialization
## Usage: Basic Example
```typescript
import { UnknownSignatureError } from "@suaveplan/mime/errors";
import { getSignatures } from "@suaveplan/mime/detection/from-magic-bytes/registry";
function requireSignature(mime: string): void {
const found = getSignatures().some(s => s.mime === mime);
if (!found) {
throw new UnknownSignatureError({
context: { signature: mime },
});
}
}
```
## Usage: Advanced Example
```typescript
import { UnknownSignatureError } from "@suaveplan/mime/errors";
// In a plugin registration system
function verifySignaturePlugin(pluginMime: string): void {
const isRegistered = getSignatures().some(s => s.mime === pluginMime);
if (!isRegistered) {
throw new UnknownSignatureError({
message: `Plugin signature for "${pluginMime}" was not registered`,
context: { signature: pluginMime },
});
}
}
```
## API Reference
### `class UnknownSignatureError extends MimeError`
**Constructor options:**
| Field | Type | Description |
|-------|------|-------------|
| `message?` | `string` | Human-readable error description |
| `context?.signature` | `string` | The expected signature identifier |
| `cause?` | `unknown` | Underlying cause |
## Implementation Notes
This error is not thrown by any built-in `@suaveplan/mime` API — it is provided for consumers who build plugin-style extension systems on top of the signature registry and want explicit registration verification. The `detectFromMagicBytes` function returns `undefined` for unrecognized bytes rather than throwing, keeping the detection path exception-free.
### Callers
Thrown by strict magic-byte detection (`detectFromMagicBytesOrThrow`) when the supplied bytes match no registered `Signature`. Non-strict `detectFromMagicBytes` returns `undefined`. The error carries the first few bytes as a hex string in `prefix` so logs capture enough context to diagnose missing signatures without dumping the full payload.
The `prefix` field is capped at the first 16 bytes of the input so log aggregators never see enormous hex blobs even when the caller passed a multi-megabyte buffer. That cap keeps logs readable and stops a surprising memory amplification path at the telemetry layer.

View File

@@ -0,0 +1,46 @@
/**
* Raised when no registered magic-byte signature matches the provided bytes
* during format detection.
*
* @module errors/unknown-signature-error
*/
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { MIME_CODES } from "../error-codes/error-codes.js";
import { MimeError } from "./mime-error.js";
/** Error code for `UnknownSignatureError`. */
export type UnknownSignatureErrorCode = typeof MIME_CODES.UNKNOWN_SIGNATURE;
/** Structured context attached to `UnknownSignatureError`. */
export interface UnknownSignatureErrorContext extends Record<string, unknown> {
/** Number of bytes in the buffer that was checked. */
readonly byteLength?: number;
/** Hex representation of the first few bytes checked. */
readonly prefix?: string;
}
/** Construction options for `UnknownSignatureError`. */
export interface UnknownSignatureErrorOptions
extends Omit<SuaveplanErrorOptions, "context"> {
/** Structured detection-failure context. */
readonly context?: UnknownSignatureErrorContext;
}
/**
* Thrown when no registered signature matches the byte buffer during
* magic-byte detection.
*/
export class UnknownSignatureError extends MimeError<UnknownSignatureErrorCode> {
constructor(options: UnknownSignatureErrorOptions & { message?: string }) {
super(
MIME_CODES.UNKNOWN_SIGNATURE,
options.message ??
"No magic-byte signature matched the provided bytes",
options.context !== undefined
? { cause: options.cause, context: options.context }
: { cause: options.cause }
);
this.name = "UnknownSignatureError";
}
}

View File

@@ -0,0 +1,101 @@
/**
* @suaveplan/mime — structured MIME-type detection, parsing, and registry.
*
* See the per-folder modules for deep imports; every public symbol is also
* re-exported here for convenience.
*
* @module index
*/
// Side-effect: register MIME error codes at module load.
import "./error-codes/index.js";
export {
type BomDetection,
sniffBom,
sniffHtmlMeta,
sniffXmlDecl,
} from "./charset/index.js";
export {
contentTypeEquals,
contentTypeMatches,
formatContentType,
type MediaType,
type ParsedContentType,
parseContentType,
} from "./content-type/index.js";
export {
BUILTIN_DB,
getEntryByExtension,
getMimeEntry,
listMimeEntries,
type MimeCategory,
type MimeEntry,
registerMimeEntry,
} from "./db/index.js";
export {
type ContentTypeDetectionResult,
type DetectInput,
type DetectResult,
type DetectSource,
detect,
detectAllFromMagicBytes,
detectFromContentType,
detectFromExtension,
detectFromMagicBytes,
detectFromStream,
type ExtensionDetectionResult,
type MagicBytesResult,
registerSignature,
type Signature,
type StreamDetectionResult,
} from "./detection/index.js";
export {
MIME_CODES,
MIME_ERROR_METADATA,
type MimeErrorCode,
registerMimeErrorCodes,
} from "./error-codes/index.js";
export {
InsufficientBytesError,
type InsufficientBytesErrorCode,
type InsufficientBytesErrorContext,
type InsufficientBytesErrorOptions,
InvalidContentTypeError,
type InvalidContentTypeErrorCode,
type InvalidContentTypeErrorContext,
type InvalidContentTypeErrorOptions,
MimeError,
ParseError,
type ParseErrorCode,
type ParseErrorContext,
type ParseErrorOptions,
UnknownMimeTypeError,
type UnknownMimeTypeErrorCode,
type UnknownMimeTypeErrorContext,
type UnknownMimeTypeErrorOptions,
UnknownSignatureError,
type UnknownSignatureErrorCode,
type UnknownSignatureErrorContext,
type UnknownSignatureErrorOptions,
} from "./errors/index.js";
export { getMimeType } from "./legacy/index.js";
export {
toAllExtensions,
toCategory,
toDefaultExtension,
} from "./reverse/index.js";
export {
initMeters,
recordDetect,
recordDetectDuration,
recordParse,
} from "./telemetry/meters.js";

View File

@@ -0,0 +1,65 @@
# legacy/get-mime-type
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a simplified, backwards-compatible `getMimeType` function that accepts a filename or extension and returns the MIME type string, or a configurable default when no match is found. Designed for codebases migrating from `mime` or `mime-types` npm packages that used a similar single-function API.
## Features
- Simple `getMimeType(filename, fallback?)` interface
- Returns the MIME type string directly (not wrapped in a result object)
- Configurable fallback (default: `"application/octet-stream"`)
- Strips path components and handles extensions with or without dots
- Zero dependencies beyond the built-in registry
## Usage: Basic Example
```typescript
import { getMimeType } from "@suaveplan/mime/legacy/get-mime-type";
console.log(getMimeType("document.pdf")); // "application/pdf"
console.log(getMimeType(".ts")); // "video/mp2t" (TypeScript)
console.log(getMimeType("unknown.xyz")); // "application/octet-stream"
console.log(getMimeType("unknown.xyz", "text/plain")); // "text/plain"
```
## Usage: Advanced Example
```typescript
import { getMimeType } from "@suaveplan/mime/legacy/get-mime-type";
// Migrate from the 'mime' npm package
// Before: mime.getType("photo.jpg") → "image/jpeg"
// After: getMimeType("photo.jpg") → "image/jpeg"
function serveFile(filePath: string, buffer: Uint8Array): Response {
const mimeType = getMimeType(filePath, "application/octet-stream");
return new Response(buffer, {
headers: { "Content-Type": mimeType },
});
}
```
## API Reference
### `getMimeType(filename: string, fallback?: string): string`
| Parameter | Type | Description |
|-----------|------|-------------|
| `filename` | `string` | Filename, path, or extension (with or without dot). |
| `fallback?` | `string` | Returned when no match found (default: `"application/octet-stream"`). |
**Returns:** MIME type string.
**Throws:** Never.
## Implementation Notes
`getMimeType` is a thin convenience wrapper over `detectFromExtension`. It is intentionally limited to extension-based detection — for production code requiring high-confidence detection (including magic-byte analysis), use the `detect()` function from `@suaveplan/mime/detection/detect` instead. This function is in the `legacy/` directory to signal that new code should prefer the more explicit multi-source API.
### Deprecation Plan
`getMimeType` is retained for callers migrating from the pre-`@suaveplan/mime` helpers; new code should call `mimeFromExtension` / `detectFromMagicBytes` directly. A future major release may remove this wrapper — it currently forwards to the real extension-to-MIME map and returns `undefined` when nothing matches, which is the exact contract the old helper advertised.

View File

@@ -0,0 +1,21 @@
/**
* Back-compat shim preserving the v1 `getMimeType(filename)` entry point.
*
* Prefer `detectFromExtension` or `detect` for new code.
* @module legacy/get-mime-type
*/
import { detectFromExtension } from "../detection/from-extension.js";
const DEFAULT = "application/octet-stream";
/**
* Returns the MIME type for a filename or extension.
*
* Backwards-compatibility alias — prefer `detectFromExtension` for new code.
* @param filename - Filename, path, or bare extension.
* @returns MIME type string, or `"application/octet-stream"` when not found.
*/
export function getMimeType(filename: string): string {
return detectFromExtension(filename)?.mime ?? DEFAULT;
}

View File

@@ -0,0 +1 @@
export { getMimeType } from "./get-mime-type.js";

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { getMimeType } from "./get-mime-type.js";
describe("getMimeType (legacy shim)", () => {
it("returns mime for known extension", { timeout: 5000 }, () => {
expect(getMimeType("file.png")).toBe("image/png");
});
it("returns mime for bare extension", { timeout: 5000 }, () => {
expect(getMimeType("png")).toBe("image/png");
});
it("returns application/octet-stream for unknown extension", {
timeout: 5000,
}, () => {
expect(getMimeType("file.unknownextension99")).toBe(
"application/octet-stream"
);
});
it("handles full paths", { timeout: 5000 }, () => {
expect(getMimeType("/var/www/app.js")).toBe("application/javascript");
});
it("handles Windows paths", { timeout: 5000 }, () => {
expect(getMimeType("C:\\Users\\file.pdf")).toBe("application/pdf");
});
it("handles .json extension", { timeout: 5000 }, () => {
expect(getMimeType("data.json")).toBe("application/json");
});
});

View File

@@ -0,0 +1,5 @@
export {
toAllExtensions,
toCategory,
toDefaultExtension,
} from "./reverse.js";

View File

@@ -0,0 +1,70 @@
# reverse/reverse
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `getMimeExtension` for reverse lookup: given a MIME type string, returns the canonical file extension associated with that type. This is the inverse of extension-based detection — useful for generating filenames, setting `Content-Disposition` headers, and file download naming.
## Features
- Returns the canonical (first) extension for any registered MIME type
- Returns `undefined` for unknown MIME types (no throws)
- Case-insensitive MIME type matching (delegates to registry)
- Returns extensions without a leading dot
- O(1) lookup via the registry's forward map
## Usage: Basic Example
```typescript
import { getMimeExtension } from "@suaveplan/mime/reverse/reverse";
console.log(getMimeExtension("image/png")); // "png"
console.log(getMimeExtension("application/pdf")); // "pdf"
console.log(getMimeExtension("text/javascript")); // "js"
console.log(getMimeExtension("unknown/type")); // undefined
```
## Usage: Advanced Example
```typescript
import { getMimeExtension } from "@suaveplan/mime/reverse/reverse";
function buildFilename(baseName: string, mimeType: string): string {
const ext = getMimeExtension(mimeType);
if (!ext) return baseName; // Unknown type — keep original name
// Avoid double extension
return baseName.endsWith(`.${ext}`) ? baseName : `${baseName}.${ext}`;
}
// Content-Disposition header generation
function contentDisposition(mimeType: string, name: string): string {
const ext = getMimeExtension(mimeType);
const filename = ext ? `${name}.${ext}` : name;
return `attachment; filename="${filename}"`;
}
console.log(contentDisposition("application/pdf", "report"));
// 'attachment; filename="report.pdf"'
```
## API Reference
### `getMimeExtension(mime: string): string | undefined`
| Parameter | Type | Description |
|-----------|------|-------------|
| `mime` | `string` | MIME type string (e.g., `"image/png"`). |
**Returns:** The canonical file extension string without a leading dot, or `undefined` if the MIME type is not in the database.
**Throws:** Never.
## Implementation Notes
The "canonical" extension is the first element of the `extensions` array in the `MimeEntry`. The built-in database places the most common extension first by convention (e.g., `"jpeg"` before `"jpg"` for `image/jpeg`). Callers that need all extensions for a type should use `getEntryByMime` directly from `@suaveplan/mime/db/registry` and access `entry.extensions`.
### Lookup Tables
The reverse index maps MIME string → preferred extension, MIME string → alias list, and alias → canonical MIME. Tables are precomputed at module load from the same curated IANA database the forward lookup uses, so the two directions can never drift. Lookups are O(1) against frozen `Map` instances with zero runtime cost after first import.

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { toAllExtensions, toCategory, toDefaultExtension } from "./reverse.js";
describe("toAllExtensions", () => {
it("returns all extensions for image/jpeg", { timeout: 5000 }, () => {
const exts = toAllExtensions("image/jpeg");
expect(exts).toContain("jpg");
expect(exts).toContain("jpeg");
});
it("returns empty array for unknown mime type", { timeout: 5000 }, () => {
expect(toAllExtensions("x/completely-unknown-type-9999")).toEqual([]);
});
it("is case-insensitive", { timeout: 5000 }, () => {
expect(toAllExtensions("IMAGE/PNG")).toEqual(
toAllExtensions("image/png")
);
});
});
describe("toCategory", () => {
it("returns image for image/png", { timeout: 5000 }, () => {
expect(toCategory("image/png")).toBe("image");
});
it("returns audio for audio/mpeg", { timeout: 5000 }, () => {
expect(toCategory("audio/mpeg")).toBe("audio");
});
it("returns application for application/json", { timeout: 5000 }, () => {
expect(toCategory("application/json")).toBe("application");
});
it("returns undefined for completely unknown MIME type with invalid prefix", {
timeout: 5000,
}, () => {
expect(toCategory("chemical/x-pdb")).toBeUndefined();
});
it("falls back to prefix parsing for vendor types not in registry", {
timeout: 5000,
}, () => {
expect(toCategory("video/x-custom-vendor-type")).toBe("video");
});
it("returns undefined for malformed type without slash", {
timeout: 5000,
}, () => {
expect(toCategory("noslash")).toBeUndefined();
});
});
describe("toDefaultExtension", () => {
it("returns jpg for image/jpeg", { timeout: 5000 }, () => {
expect(toDefaultExtension("image/jpeg")).toBe("jpg");
});
it("returns png for image/png", { timeout: 5000 }, () => {
expect(toDefaultExtension("image/png")).toBe("png");
});
it("returns undefined for unknown MIME type", { timeout: 5000 }, () => {
expect(toDefaultExtension("x/unknown-type-9999")).toBeUndefined();
});
it("returns mp3 for audio/mpeg", { timeout: 5000 }, () => {
expect(toDefaultExtension("audio/mpeg")).toBe("mp3");
});
});

View File

@@ -0,0 +1,61 @@
/**
* Reverse lookup helpers — given a MIME type, return category or extensions.
*
* Falls back to parsing the `type/subtype` prefix when the MIME type is not
* in the registry (e.g. for vendor types added at runtime without metadata).
*
* @module reverse/reverse
*/
import type { MimeCategory } from "../db/mime-entry.types.js";
import { getMimeEntry } from "../db/registry.js";
const VALID_CATEGORIES: ReadonlySet<string> = new Set([
"application",
"audio",
"font",
"image",
"message",
"model",
"multipart",
"text",
"video",
]);
/**
* Return all known file extensions for a MIME type.
*
* @param mimeType - MIME type string (case-insensitive).
* @returns Array of extensions, or an empty array when not found.
*/
export function toAllExtensions(mimeType: string): readonly string[] {
return getMimeEntry(mimeType)?.extensions ?? [];
}
/**
* Return the top-level IANA category for a MIME type.
*
* Falls back to parsing the `type` prefix when the MIME is not in the
* registry, so vendor types like `video/x-custom` still return `"video"`.
*
* @param mimeType - MIME type string (case-insensitive).
* @returns `MimeCategory` or `undefined` when the prefix is not a valid IANA category.
*/
export function toCategory(mimeType: string): MimeCategory | undefined {
const entry = getMimeEntry(mimeType);
if (entry) return entry.category;
const slash = mimeType.indexOf("/");
if (slash <= 0) return undefined;
const prefix = mimeType.slice(0, slash).toLowerCase();
return VALID_CATEGORIES.has(prefix) ? (prefix as MimeCategory) : undefined;
}
/**
* Return the canonical (first) file extension for a MIME type.
*
* @param mimeType - MIME type string (case-insensitive).
* @returns Primary extension string or `undefined` when not found.
*/
export function toDefaultExtension(mimeType: string): string | undefined {
return getMimeEntry(mimeType)?.extensions[0];
}

Some files were not shown because too many files have changed in this diff Show More