Initial Commit
This commit is contained in:
108
packages/foundation/flow/streams-metadata/src/errors/errors.md
Normal file
108
packages/foundation/flow/streams-metadata/src/errors/errors.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# `errors`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error registry for the `@suaveplan/streams-metadata` package. Every
|
||||
failure surface produced by `MimeTransform`, `ExifTransform`, or
|
||||
`MetadataTransform` raises a subclass of `StreamsMetadataError` carrying one
|
||||
of the codes registered through `defineCodes("STREAMS_METADATA", [...])`.
|
||||
Callers `instanceof` test the typed subclasses (or compare `error.code`) to
|
||||
distinguish failure modes without parsing message strings.
|
||||
|
||||
The registry guarantees a closed code set: every code below is reachable
|
||||
from a real call site in `src/`, so OpenSpec conformance does not flag
|
||||
orphan codes and downstream consumers can build exhaustive error-handling
|
||||
switches that the compiler verifies.
|
||||
|
||||
## Features
|
||||
|
||||
- `STREAMS_METADATA_CODES` registered via `defineCodes`, frozen at module
|
||||
load with stable string codes prefixed `STREAMS_METADATA.`.
|
||||
- `StreamsMetadataErrorCode` discriminated string-union derived from the
|
||||
registry.
|
||||
- `StreamsMetadataError` base class extending `SuaveplanError`; one factory
|
||||
subclass per code (`MimeDetectionFailedError`, `ExifFailedError`,
|
||||
`InvalidPayloadError`, `MetadataAbortedError`).
|
||||
- `cause` chain preserved when wrapping foreign throws (e.g. an
|
||||
`@suaveplan/exif::ExifError`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ExifFailedError,
|
||||
STREAMS_METADATA_CODES,
|
||||
} from "@suaveplan/streams-metadata";
|
||||
|
||||
try {
|
||||
// call ExifTransform.process(...)
|
||||
} catch (err) {
|
||||
if (err instanceof ExifFailedError) {
|
||||
console.error(err.code, err.cause);
|
||||
// err.code === STREAMS_METADATA_CODES.EXIF_FAILED
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
STREAMS_METADATA_CODES,
|
||||
StreamsMetadataError,
|
||||
type StreamsMetadataErrorCode,
|
||||
} from "@suaveplan/streams-metadata";
|
||||
|
||||
function describe(err: StreamsMetadataError): string {
|
||||
const code: StreamsMetadataErrorCode = err.code;
|
||||
switch (code) {
|
||||
case STREAMS_METADATA_CODES.MIME_FAILED:
|
||||
return "MIME detection engine error";
|
||||
case STREAMS_METADATA_CODES.EXIF_FAILED:
|
||||
return "EXIF reader rejected the bytes";
|
||||
case STREAMS_METADATA_CODES.INVALID_PAYLOAD:
|
||||
return "Caller handed in a payload without bytes";
|
||||
case STREAMS_METADATA_CODES.ABORTED:
|
||||
return "Operation cancelled via AbortSignal";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `STREAMS_METADATA_CODES`: frozen mapping with keys `MIME_FAILED`,
|
||||
`EXIF_FAILED`, `INVALID_PAYLOAD`, `ABORTED`; values are the namespaced
|
||||
strings (`"STREAMS_METADATA.MIME_FAILED"`, etc.).
|
||||
- `StreamsMetadataErrorCode`: union of every value in
|
||||
`STREAMS_METADATA_CODES`.
|
||||
- `class StreamsMetadataError extends SuaveplanError<StreamsMetadataErrorCode>`:
|
||||
base; never thrown directly.
|
||||
- `class MimeDetectionFailedError(opts?: { cause?; message? })`: reserved
|
||||
for engine-level failures inside `MimeTransform`. The documented
|
||||
`application/octet-stream` fallback does NOT raise this.
|
||||
- `class ExifFailedError(opts?: { cause?; message? })`: raised by
|
||||
`ExifTransform` whenever `@suaveplan/exif::readMetadata` throws. The
|
||||
original `ExifError` is preserved on `cause`.
|
||||
- `class InvalidPayloadError(opts?: { message? })`: raised when a transform
|
||||
receives a payload missing the mandatory `bytes` field.
|
||||
- `class MetadataAbortedError(opts?: { cause?; message? })`: optional
|
||||
alternative to `@suaveplan/streams::StreamAbortedError` used by callers
|
||||
who want every error in the streams-metadata namespace.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Every factory subclass forwards `cause` only when it is an `Error`
|
||||
instance — non-Error throws are dropped from the `cause` slot but the
|
||||
caller can still observe them through `metadata:error` listener payloads
|
||||
on `MetadataTransform`.
|
||||
- Codes are reachable from real call sites: `EXIF_FAILED` via
|
||||
`src/exif/exif.ts`, `INVALID_PAYLOAD` via `src/mime/mime.ts` and
|
||||
`src/exif/exif.ts`, `ABORTED` via `MetadataAbortedError`, and
|
||||
`MIME_FAILED` via the dedicated subclass exposed to downstream callers
|
||||
who wrap their own MIME engines.
|
||||
- `defineCodes` freezes the returned object, so the registry is safe to
|
||||
share across realms.
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ExifFailedError,
|
||||
InvalidPayloadError,
|
||||
MetadataAbortedError,
|
||||
MimeDetectionFailedError,
|
||||
STREAMS_METADATA_CODES,
|
||||
StreamsMetadataError,
|
||||
} from "./errors.js";
|
||||
|
||||
describe("STREAMS_METADATA_CODES registry", () => {
|
||||
it("registers every code via defineCodes", { timeout: 5000 }, () => {
|
||||
expect(STREAMS_METADATA_CODES.MIME_FAILED).toBe(
|
||||
"STREAMS_METADATA.MIME_FAILED"
|
||||
);
|
||||
expect(STREAMS_METADATA_CODES.EXIF_FAILED).toBe(
|
||||
"STREAMS_METADATA.EXIF_FAILED"
|
||||
);
|
||||
expect(STREAMS_METADATA_CODES.INVALID_PAYLOAD).toBe(
|
||||
"STREAMS_METADATA.INVALID_PAYLOAD"
|
||||
);
|
||||
expect(STREAMS_METADATA_CODES.ABORTED).toBe("STREAMS_METADATA.ABORTED");
|
||||
});
|
||||
|
||||
it("freezes the registry", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(STREAMS_METADATA_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("factory subclasses", () => {
|
||||
it("MimeDetectionFailedError carries MIME_FAILED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new MimeDetectionFailedError();
|
||||
expect(err).toBeInstanceOf(StreamsMetadataError);
|
||||
expect(err.code).toBe(STREAMS_METADATA_CODES.MIME_FAILED);
|
||||
expect(err.message).toContain("MIME detection failed");
|
||||
});
|
||||
|
||||
it("MimeDetectionFailedError honours custom message and cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new MimeDetectionFailedError({
|
||||
message: "custom",
|
||||
cause,
|
||||
});
|
||||
expect(err.message).toBe("custom");
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("ExifFailedError preserves Error cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("upstream");
|
||||
const err = new ExifFailedError({ cause });
|
||||
expect(err.code).toBe(STREAMS_METADATA_CODES.EXIF_FAILED);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("ExifFailedError drops non-Error cause", { timeout: 5000 }, () => {
|
||||
const err = new ExifFailedError({ cause: "not an error" });
|
||||
expect(err.cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ExifFailedError supports custom message", { timeout: 5000 }, () => {
|
||||
const err = new ExifFailedError({ message: "boom" });
|
||||
expect(err.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("InvalidPayloadError uses documented default message", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new InvalidPayloadError();
|
||||
expect(err.code).toBe(STREAMS_METADATA_CODES.INVALID_PAYLOAD);
|
||||
expect(err.message).toContain("missing required `bytes`");
|
||||
});
|
||||
|
||||
it("InvalidPayloadError accepts an override message", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new InvalidPayloadError({ message: "boom" });
|
||||
expect(err.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("MetadataAbortedError preserves cause chain", { timeout: 5000 }, () => {
|
||||
const cause = new Error("aborted");
|
||||
const err = new MetadataAbortedError({ cause });
|
||||
expect(err.code).toBe(STREAMS_METADATA_CODES.ABORTED);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("MetadataAbortedError supports message + missing cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new MetadataAbortedError({ message: "halt" });
|
||||
expect(err.message).toBe("halt");
|
||||
expect(err.cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("MetadataAbortedError default message", { timeout: 5000 }, () => {
|
||||
const err = new MetadataAbortedError();
|
||||
expect(err.message).toContain("aborted");
|
||||
});
|
||||
|
||||
it("MimeDetectionFailedError default message", { timeout: 5000 }, () => {
|
||||
const err = new MimeDetectionFailedError();
|
||||
expect(err.message).toContain("MIME detection failed");
|
||||
});
|
||||
|
||||
it("ExifFailedError default message", { timeout: 5000 }, () => {
|
||||
const err = new ExifFailedError();
|
||||
expect(err.message).toContain("EXIF extraction failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StreamsMetadataError base", () => {
|
||||
it("is an Error and StreamsMetadataError descendant", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new ExifFailedError();
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(StreamsMetadataError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error codes for the `@suaveplan/streams-metadata` package. Every
|
||||
* code is reachable from a real call site in `src/`.
|
||||
*/
|
||||
export const STREAMS_METADATA_CODES = defineCodes("STREAMS_METADATA", [
|
||||
"MIME_FAILED",
|
||||
"EXIF_FAILED",
|
||||
"INVALID_PAYLOAD",
|
||||
"ABORTED",
|
||||
] as const);
|
||||
|
||||
export type StreamsMetadataErrorCode =
|
||||
(typeof STREAMS_METADATA_CODES)[keyof typeof STREAMS_METADATA_CODES];
|
||||
|
||||
interface ErrorOpts {
|
||||
cause?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function asErrorCause(c: unknown): Error | undefined {
|
||||
return c instanceof Error ? c : undefined;
|
||||
}
|
||||
|
||||
/** Base error class for every `@suaveplan/streams-metadata` failure. */
|
||||
export class StreamsMetadataError extends SuaveplanError<StreamsMetadataErrorCode> {}
|
||||
|
||||
/**
|
||||
* Thrown by `MimeTransform` when MIME detection encounters an internal
|
||||
* exception. The documented `application/octet-stream` fallback does NOT
|
||||
* raise this — it is reserved for engine-level failures.
|
||||
*/
|
||||
export class MimeDetectionFailedError extends StreamsMetadataError {
|
||||
constructor(opts: ErrorOpts = {}) {
|
||||
super(
|
||||
STREAMS_METADATA_CODES.MIME_FAILED,
|
||||
opts.message ?? "MIME detection failed",
|
||||
{ cause: asErrorCause(opts.cause) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by `ExifTransform` when `@suaveplan/exif::readMetadata` raises.
|
||||
* Preserves the underlying `ExifError` on `cause`.
|
||||
*/
|
||||
export class ExifFailedError extends StreamsMetadataError {
|
||||
constructor(opts: ErrorOpts = {}) {
|
||||
super(
|
||||
STREAMS_METADATA_CODES.EXIF_FAILED,
|
||||
opts.message ?? "EXIF extraction failed",
|
||||
{ cause: asErrorCause(opts.cause) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a transform receives a payload missing the mandatory `bytes`
|
||||
* field.
|
||||
*/
|
||||
export class InvalidPayloadError extends StreamsMetadataError {
|
||||
constructor(opts: ErrorOpts = {}) {
|
||||
super(
|
||||
STREAMS_METADATA_CODES.INVALID_PAYLOAD,
|
||||
opts.message ??
|
||||
"Streams-metadata payload is missing required `bytes` Uint8Array"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a transform is invoked with an already-aborted `AbortSignal`
|
||||
* by callers that want every error in the streams-metadata namespace.
|
||||
* Delegated to from `BaseTransform.assertNotAborted` indirectly when callers
|
||||
* opt in to the typed wrapping.
|
||||
*/
|
||||
export class MetadataAbortedError extends StreamsMetadataError {
|
||||
constructor(opts: ErrorOpts = {}) {
|
||||
super(
|
||||
STREAMS_METADATA_CODES.ABORTED,
|
||||
opts.message ?? "Streams-metadata operation aborted",
|
||||
{ cause: asErrorCause(opts.cause) }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type { StreamsMetadataErrorCode } from "./errors.js";
|
||||
export {
|
||||
ExifFailedError,
|
||||
InvalidPayloadError,
|
||||
MetadataAbortedError,
|
||||
MimeDetectionFailedError,
|
||||
STREAMS_METADATA_CODES,
|
||||
StreamsMetadataError,
|
||||
} from "./errors.js";
|
||||
@@ -0,0 +1,92 @@
|
||||
# `events`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the typed `StreamsMetadataEvents` map consumed by every transform
|
||||
in this package. `BaseTransform` from `@suaveplan/streams` already
|
||||
carries a generic event API parameterised by an event-map type, so this
|
||||
package does not need (and does not expose) a separate `EventEmitter`
|
||||
property: every transform subclasses
|
||||
`BaseTransform<{}, StreamsMetadataEvents>` and inherits typed
|
||||
`on(name, fn)` / `off(name, fn)` methods.
|
||||
|
||||
The event map is the public observability contract — listeners attach to
|
||||
the transform directly and receive strongly typed payloads, with
|
||||
TypeScript rejecting accesses to fields that do not exist on the declared
|
||||
payload.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-source map of event name to payload type, consumed by all three
|
||||
transforms and re-exported from the package root.
|
||||
- Compatible with the `EventMap` constraint declared by
|
||||
`@suaveplan/streams::BaseTransform`.
|
||||
- Four events covering both child stages (`mime:detected`, `exif:detected`)
|
||||
and the wrapper's lifecycle (`metadata:complete`, `metadata:error`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MetadataTransform } from "@suaveplan/streams-metadata";
|
||||
|
||||
const transform = new MetadataTransform();
|
||||
|
||||
transform.on("mime:detected", (r) => {
|
||||
console.log("MIME:", r.mimeType, "source:", r.source);
|
||||
});
|
||||
|
||||
transform.on("metadata:complete", (p) => {
|
||||
console.log("Done:", p.mime?.mimeType, p.exif?.format);
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MetadataTransform,
|
||||
type StreamsMetadataEvents,
|
||||
} from "@suaveplan/streams-metadata";
|
||||
|
||||
const transform = new MetadataTransform();
|
||||
|
||||
const handlers: {
|
||||
[K in keyof StreamsMetadataEvents]: (payload: StreamsMetadataEvents[K]) => void;
|
||||
} = {
|
||||
"mime:detected": (r) => console.log("MIME", r.priority, r.mimeType),
|
||||
"exif:detected": (m) => console.log("EXIF", m.format),
|
||||
"metadata:complete": (p) => console.log("OK", p.bytes.byteLength, "bytes"),
|
||||
"metadata:error": (e) => console.error("FAIL", e),
|
||||
};
|
||||
|
||||
for (const name of Object.keys(handlers) as (keyof StreamsMetadataEvents)[]) {
|
||||
transform.on(name, handlers[name] as never);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type StreamsMetadataEvents`:
|
||||
- `"mime:detected": MimeDetectionResult` — fired by `MimeTransform` (and
|
||||
forwarded by `MetadataTransform`) after detection completes.
|
||||
- `"exif:detected": ImageMetadata` — fired by `ExifTransform` (and
|
||||
forwarded by `MetadataTransform`) after the underlying
|
||||
`@suaveplan/exif::readMetadata` returns.
|
||||
- `"metadata:complete": EnrichedPayload` — fired only by
|
||||
`MetadataTransform`, once both children succeed.
|
||||
- `"metadata:error": unknown` — fired only by `MetadataTransform` when
|
||||
either child throws. The payload is the original thrown value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The map is declared as a `type` alias (not an `interface`) so it
|
||||
satisfies the structural `Readonly<Record<string, unknown>>` constraint
|
||||
imposed by `@suaveplan/streams::EventMap` without interface widening.
|
||||
- Field order is fixed for stable JSDoc generation: child events first,
|
||||
wrapper events last.
|
||||
- Adding a new event is a breaking change to the public API — bump the
|
||||
package version and document it in the next archived spec change.
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ImageMetadata } from "@suaveplan/exif";
|
||||
import type { EnrichedPayload, MimeDetectionResult } from "../types/payload.js";
|
||||
|
||||
/**
|
||||
* Typed event map consumed by every transform in this package.
|
||||
*
|
||||
* Each transform extends `BaseTransform<{}, StreamsMetadataEvents>` from
|
||||
* `@suaveplan/streams`, inheriting typed `on(name, fn)` / `off(name, fn)`
|
||||
* methods. The upstream `EventEmitter<...>` field is replaced by the
|
||||
* built-in event surface so observability stays type-safe without a separate
|
||||
* emitter property.
|
||||
*/
|
||||
export type StreamsMetadataEvents = {
|
||||
/** Fired by `MimeTransform` after MIME detection completes. */
|
||||
readonly "mime:detected": MimeDetectionResult;
|
||||
/** Fired by `ExifTransform` after EXIF extraction completes. */
|
||||
readonly "exif:detected": ImageMetadata;
|
||||
/** Fired by `MetadataTransform` after both children complete. */
|
||||
readonly "metadata:complete": EnrichedPayload;
|
||||
/** Fired by `MetadataTransform` when either child throws. */
|
||||
readonly "metadata:error": unknown;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export type { StreamsMetadataEvents } from "./events.js";
|
||||
102
packages/foundation/flow/streams-metadata/src/exif/exif.md
Normal file
102
packages/foundation/flow/streams-metadata/src/exif/exif.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# `exif/exif`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ExifTransform` is a universal stream transform that extracts
|
||||
`ImageMetadata` via `@suaveplan/exif::readMetadata` and (in `enrich` mode)
|
||||
attaches the result to the payload under `exif`. Unlike `MimeTransform`,
|
||||
this stage does raise on unsupported bytes — the underlying reader throws
|
||||
`ExifError(EXIF.UNSUPPORTED_FORMAT)` for non-image input. `ExifTransform`
|
||||
re-wraps every reader exception as `ExifFailedError` so callers can
|
||||
`instanceof StreamsMetadataError` and inspect the original `cause` chain.
|
||||
|
||||
The class extends `@suaveplan/streams::BaseTransform`, so the same
|
||||
`on`/`off`/`toTransformStream`/`dispose` surface every streams consumer
|
||||
uses is available without any glue. `@suaveplan/exif`'s `ReadOptions`
|
||||
(e.g. `skipXmp`, `skipIptc`) are forwarded verbatim through the constructor.
|
||||
|
||||
## Features
|
||||
|
||||
- Synchronous EXIF / IPTC / XMP extraction via
|
||||
`@suaveplan/exif::readMetadata`.
|
||||
- `read` options pass-through (skip XMP, IPTC, thumbnails, etc.).
|
||||
- `exif:detected` event emitted via the inherited
|
||||
`BaseTransform.on`/`emit` API.
|
||||
- `enrich` mode (default) shallow-clones the payload with `exif` attached;
|
||||
`passthrough` mode returns the original input reference unchanged.
|
||||
- Wraps every reader exception as `ExifFailedError`
|
||||
(`STREAMS_METADATA.EXIF_FAILED`) with `cause` preserved.
|
||||
- Raises `InvalidPayloadError` when the payload is missing the mandatory
|
||||
`bytes: Uint8Array` field.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ExifTransform } from "@suaveplan/streams-metadata";
|
||||
|
||||
const transform = new ExifTransform();
|
||||
const result = await transform.process({ bytes: jpegBytes });
|
||||
console.log(result.exif?.format, result.exif?.width, result.exif?.height);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ExifFailedError,
|
||||
ExifTransform,
|
||||
STREAMS_METADATA_CODES,
|
||||
} from "@suaveplan/streams-metadata";
|
||||
|
||||
const transform = new ExifTransform({ read: { skipXmp: true } });
|
||||
|
||||
transform.on("exif:detected", (m) => {
|
||||
console.log("Format:", m.format, "Orientation:", m.exif?.orientation);
|
||||
});
|
||||
|
||||
try {
|
||||
await transform.process({ bytes: notAnImageBytes });
|
||||
} catch (err) {
|
||||
if (err instanceof ExifFailedError) {
|
||||
// err.code === STREAMS_METADATA_CODES.EXIF_FAILED
|
||||
console.error("EXIF reader rejected the bytes:", err.cause);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ExifTransform extends BaseTransform<{}, StreamsMetadataEvents>`:
|
||||
- `constructor(options?: ExifTransformOptions)` — accepts
|
||||
`{ mode?, read? }`.
|
||||
- `async process<T = unknown>(input: T, options?: ProcessOptions): Promise<T>`
|
||||
— runs the reader, emits `exif:detected`, returns the enriched payload
|
||||
(or original input reference in `passthrough` mode). Honours
|
||||
`options.signal` via `BaseTransform.assertNotAborted`. Throws
|
||||
`ExifFailedError` (`STREAMS_METADATA.EXIF_FAILED`) when the underlying
|
||||
reader raises, and `InvalidPayloadError`
|
||||
(`STREAMS_METADATA.INVALID_PAYLOAD`) when `input.bytes` is not a
|
||||
`Uint8Array`.
|
||||
- `name: "exif"`.
|
||||
- `interface ExifTransformOptions`:
|
||||
- `mode?: MetadataMode` — `"enrich"` (default) or `"passthrough"`.
|
||||
- `read?: ReadOptions` — forwarded directly to
|
||||
`@suaveplan/exif::readMetadata`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The reader runs synchronously; `process` is async only to honour the
|
||||
`BaseTransform` contract.
|
||||
- Every reader exception is caught and re-thrown as `ExifFailedError`. The
|
||||
original error is preserved on `cause` only when it is an `Error`
|
||||
instance — non-Error throws still raise an `ExifFailedError` but with
|
||||
`cause` left unset.
|
||||
- The `passthrough` branch returns the original `input` reference (not a
|
||||
clone) — useful for pipelines that need EXIF observation without
|
||||
mutating the payload contract.
|
||||
- Listener exceptions are caught by `BaseTransform.emit` and never bubble
|
||||
out of `process` — observability cannot break the pipeline.
|
||||
108
packages/foundation/flow/streams-metadata/src/exif/exif.test.ts
Normal file
108
packages/foundation/flow/streams-metadata/src/exif/exif.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { ImageMetadata } from "@suaveplan/exif";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildGibberish,
|
||||
buildMinimalJpeg,
|
||||
buildMinimalPng,
|
||||
} from "../../tests/fixtures.js";
|
||||
import {
|
||||
ExifFailedError,
|
||||
InvalidPayloadError,
|
||||
STREAMS_METADATA_CODES,
|
||||
} from "../errors/errors.js";
|
||||
import { ExifTransform } from "./exif.js";
|
||||
|
||||
describe("ExifTransform", () => {
|
||||
it("extracts metadata from a JPEG payload", { timeout: 5000 }, async () => {
|
||||
const transform = new ExifTransform();
|
||||
const result = (await transform.process({
|
||||
bytes: buildMinimalJpeg(),
|
||||
})) as { exif?: ImageMetadata };
|
||||
expect(result.exif?.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("extracts metadata from a PNG payload", { timeout: 5000 }, async () => {
|
||||
const transform = new ExifTransform();
|
||||
const result = (await transform.process({
|
||||
bytes: buildMinimalPng(),
|
||||
})) as { exif?: ImageMetadata };
|
||||
expect(result.exif?.format).toBe("png");
|
||||
expect(result.exif?.width).toBe(100);
|
||||
expect(result.exif?.height).toBe(80);
|
||||
});
|
||||
|
||||
it("emits exif:detected before resolving", { timeout: 5000 }, async () => {
|
||||
const transform = new ExifTransform();
|
||||
const seen: ImageMetadata[] = [];
|
||||
transform.on("exif:detected", (m) => {
|
||||
seen.push(m);
|
||||
});
|
||||
await transform.process({ bytes: buildMinimalJpeg() });
|
||||
expect(seen.length).toBe(1);
|
||||
expect(seen[0]?.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("wraps ExifError as StreamsMetadataError on unsupported bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new ExifTransform();
|
||||
let captured: unknown;
|
||||
try {
|
||||
await transform.process({ bytes: buildGibberish() });
|
||||
} catch (err) {
|
||||
captured = err;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(ExifFailedError);
|
||||
const ef = captured as ExifFailedError;
|
||||
expect(ef.code).toBe(STREAMS_METADATA_CODES.EXIF_FAILED);
|
||||
expect(ef.cause).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("forwards read options to @suaveplan/exif", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new ExifTransform({ read: { skipExif: true } });
|
||||
const result = (await transform.process({
|
||||
bytes: buildMinimalJpeg(),
|
||||
})) as { exif?: ImageMetadata };
|
||||
expect(result.exif?.exif).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passthrough mode returns the original input reference", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new ExifTransform({ mode: "passthrough" });
|
||||
const input = { bytes: buildMinimalJpeg() };
|
||||
const out = await transform.process(input);
|
||||
expect(out).toBe(input);
|
||||
expect((out as { exif?: unknown }).exif).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws InvalidPayloadError when bytes is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new ExifTransform();
|
||||
await expect(
|
||||
transform.process({ name: "x.jpg" })
|
||||
).rejects.toBeInstanceOf(InvalidPayloadError);
|
||||
});
|
||||
|
||||
it("throws when supplied signal is already aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new ExifTransform();
|
||||
const ac = new AbortController();
|
||||
ac.abort(new Error("halt"));
|
||||
await expect(
|
||||
transform.process(
|
||||
{ bytes: buildMinimalJpeg() },
|
||||
{ signal: ac.signal }
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("exposes the stable name 'exif'", { timeout: 5000 }, () => {
|
||||
const transform = new ExifTransform();
|
||||
expect(transform.name).toBe("exif");
|
||||
});
|
||||
});
|
||||
60
packages/foundation/flow/streams-metadata/src/exif/exif.ts
Normal file
60
packages/foundation/flow/streams-metadata/src/exif/exif.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
type ImageMetadata,
|
||||
type ReadOptions,
|
||||
readMetadata,
|
||||
} from "@suaveplan/exif";
|
||||
import { BaseTransform } from "@suaveplan/streams";
|
||||
import { ExifFailedError, InvalidPayloadError } from "../errors/errors.js";
|
||||
import type { StreamsMetadataEvents } from "../events/events.js";
|
||||
import type {
|
||||
EnrichedPayload,
|
||||
MetadataMode,
|
||||
MetadataPayload,
|
||||
} from "../types/payload.js";
|
||||
import type { ProcessOptions } from "../types/process-options.js";
|
||||
import type { ExifTransformOptions } from "./exif.types.js";
|
||||
|
||||
/**
|
||||
* Universal stream transform that extracts `ImageMetadata` via
|
||||
* `@suaveplan/exif::readMetadata` and (in `enrich` mode) attaches the result
|
||||
* to the payload under `exif`. Emits `exif:detected` before returning.
|
||||
*
|
||||
* Unlike {@link MimeTransform}, this transform raises on unsupported bytes —
|
||||
* the underlying reader throws `ExifError(EXIF.UNSUPPORTED_FORMAT)` for
|
||||
* non-image input. `ExifTransform` re-wraps every reader exception as
|
||||
* {@link ExifFailedError} so callers can `instanceof StreamsMetadataError`
|
||||
* without losing the original `cause` chain.
|
||||
*/
|
||||
export class ExifTransform extends BaseTransform<
|
||||
Record<string, never>,
|
||||
StreamsMetadataEvents
|
||||
> {
|
||||
protected readonly mode: MetadataMode;
|
||||
protected readonly readOptions: ReadOptions | undefined;
|
||||
|
||||
constructor(options: ExifTransformOptions = {}) {
|
||||
super("exif");
|
||||
this.mode = options.mode ?? "enrich";
|
||||
this.readOptions = options.read;
|
||||
}
|
||||
|
||||
async process<T = unknown>(input: T, options?: ProcessOptions): Promise<T> {
|
||||
this.assertNotAborted(options);
|
||||
const payload = input as unknown as MetadataPayload;
|
||||
if (!(payload?.bytes instanceof Uint8Array)) {
|
||||
throw new InvalidPayloadError();
|
||||
}
|
||||
let metadata: ImageMetadata;
|
||||
try {
|
||||
metadata = readMetadata(payload.bytes, this.readOptions);
|
||||
} catch (err) {
|
||||
throw new ExifFailedError({ cause: err });
|
||||
}
|
||||
this.emit("exif:detected", metadata, options);
|
||||
if (this.mode === "passthrough") {
|
||||
return input;
|
||||
}
|
||||
const enriched: EnrichedPayload = { ...payload, exif: metadata };
|
||||
return enriched as unknown as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReadOptions } from "@suaveplan/exif";
|
||||
import type { MetadataMode } from "../types/payload.js";
|
||||
|
||||
/**
|
||||
* Options for {@link ExifTransform}. `mode` controls payload mutation; `read`
|
||||
* is forwarded verbatim to `@suaveplan/exif::readMetadata` so callers can
|
||||
* skip XMP, IPTC, or thumbnail parsing on the underlying reader.
|
||||
*/
|
||||
export interface ExifTransformOptions {
|
||||
mode?: MetadataMode;
|
||||
/** Forwarded directly to `@suaveplan/exif::readMetadata`. */
|
||||
read?: ReadOptions;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ExifTransform } from "./exif.js";
|
||||
export type { ExifTransformOptions } from "./exif.types.js";
|
||||
23
packages/foundation/flow/streams-metadata/src/index.ts
Normal file
23
packages/foundation/flow/streams-metadata/src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
ExifFailedError,
|
||||
InvalidPayloadError,
|
||||
MetadataAbortedError,
|
||||
MimeDetectionFailedError,
|
||||
STREAMS_METADATA_CODES,
|
||||
StreamsMetadataError,
|
||||
type StreamsMetadataErrorCode,
|
||||
} from "./errors/errors.js";
|
||||
export type { StreamsMetadataEvents } from "./events/events.js";
|
||||
export { ExifTransform } from "./exif/exif.js";
|
||||
export type { ExifTransformOptions } from "./exif/exif.types.js";
|
||||
export { MetadataTransform } from "./metadata/metadata.js";
|
||||
export type { MetadataTransformOptions } from "./metadata/metadata.types.js";
|
||||
export { MimeTransform } from "./mime/mime.js";
|
||||
export type { MimeTransformOptions } from "./mime/mime.types.js";
|
||||
export type {
|
||||
EnrichedPayload,
|
||||
MetadataMode,
|
||||
MetadataPayload,
|
||||
MimeDetectionResult,
|
||||
} from "./types/payload.js";
|
||||
export type { ProcessOptions } from "./types/process-options.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MetadataTransform } from "./metadata.js";
|
||||
export type { MetadataTransformOptions } from "./metadata.types.js";
|
||||
@@ -0,0 +1,107 @@
|
||||
# `metadata/metadata`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MetadataTransform` is the high-level composing wrapper around
|
||||
`MimeTransform` and `ExifTransform`. A single instance runs MIME detection
|
||||
followed by EXIF extraction in sequence; the result is a fully enriched
|
||||
payload carrying both `mime` and `exif`. Child events are forwarded
|
||||
through the wrapper so a single `on('mime:detected', fn)` subscription on
|
||||
the wrapper observes the child emission — no need to wire per-transform
|
||||
listeners. If either child throws, the wrapper catches the exception,
|
||||
emits `metadata:error` with the thrown value, and resolves with the
|
||||
original input reference so the pipeline continues.
|
||||
|
||||
This is the entry point most callers should reach for; the individual
|
||||
transforms are exposed for manual composition or when only one stage is
|
||||
desired.
|
||||
|
||||
## Features
|
||||
|
||||
- Composes `MimeTransform` then `ExifTransform` with shared mode flag.
|
||||
- Forwards `mime:detected` and `exif:detected` events to the wrapper's
|
||||
listeners (no child subscriptions required).
|
||||
- Emits `metadata:complete` on success with the final enriched payload.
|
||||
- Emits `metadata:error` with the thrown value on child failure and
|
||||
returns the original input reference so a downstream pipeline stage can
|
||||
continue.
|
||||
- Overrides `dispose()` to release child transforms in addition to the
|
||||
wrapper's own listener / hook tables.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MetadataTransform } from "@suaveplan/streams-metadata";
|
||||
|
||||
const transform = new MetadataTransform();
|
||||
const result = await transform.process({ bytes: jpegBytes, name: "photo.jpg" });
|
||||
|
||||
console.log(result.mime?.mimeType); // "image/jpeg"
|
||||
console.log(result.exif?.format); // "jpeg"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MetadataTransform,
|
||||
type EnrichedPayload,
|
||||
} from "@suaveplan/streams-metadata";
|
||||
|
||||
const transform = new MetadataTransform({
|
||||
mode: "enrich",
|
||||
read: { skipXmp: true },
|
||||
});
|
||||
|
||||
const events: string[] = [];
|
||||
transform.on("mime:detected", (r) => events.push(`mime:${r.source}`));
|
||||
transform.on("exif:detected", (m) => events.push(`exif:${m.format}`));
|
||||
transform.on("metadata:complete", (p: EnrichedPayload) =>
|
||||
events.push(`complete:${p.bytes.byteLength}`),
|
||||
);
|
||||
transform.on("metadata:error", (err) => {
|
||||
console.error("metadata pipeline failed", err);
|
||||
});
|
||||
|
||||
await transform.process({ bytes: jpegBytes, name: "photo.jpg" });
|
||||
// events => ["mime:magic-bytes", "exif:jpeg", "complete:..."]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class MetadataTransform extends BaseTransform<{}, StreamsMetadataEvents>`:
|
||||
- `constructor(options?: MetadataTransformOptions)` — accepts
|
||||
`{ mode?, read? }`. `mode` is shared across both children; `read` is
|
||||
forwarded to the inner `ExifTransform`.
|
||||
- `async process<T = unknown>(input: T, options?: ProcessOptions): Promise<T>`
|
||||
— runs `mime.process(input) → exif.process(...)`. On success emits
|
||||
`metadata:complete` and returns the final enriched payload. On any
|
||||
child throw emits `metadata:error` with the thrown value and returns
|
||||
the original `input` reference unchanged.
|
||||
- `dispose(): void` — releases both child transforms and the wrapper's
|
||||
own listener / hook tables. Idempotent.
|
||||
- `name: "metadata"`.
|
||||
- `type MetadataTransformOptions = MimeTransformOptions & ExifTransformOptions`
|
||||
— shared `mode` flag plus EXIF-specific `read` options.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Child events are wired in the constructor via `this.mime.on(...)` and
|
||||
`this.exif.on(...)`; the lambdas call `this.emit(...)` on the wrapper to
|
||||
re-emit. The `Unsubscribe` returns are dropped because the child
|
||||
transforms' listener tables are released through `this.mime.dispose()` /
|
||||
`this.exif.dispose()` in the override.
|
||||
- Error containment: a child throw never propagates out of `process` —
|
||||
observability via `metadata:error` is the canonical failure channel.
|
||||
The original `input` reference is returned (not a clone) so
|
||||
referentially-stable downstream stages keep working.
|
||||
- Subclasses overriding `dispose()` MUST call `super.dispose()` (per the
|
||||
base contract). This class follows that rule by chaining
|
||||
`super.dispose()` after disposing children.
|
||||
- The constructor honours `exactOptionalPropertyTypes` by only forwarding
|
||||
`mode` / `read` when explicitly supplied, so the children use their own
|
||||
defaults when the wrapper's options bundle omits the field.
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ImageMetadata } from "@suaveplan/exif";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildGibberish, buildMinimalJpeg } from "../../tests/fixtures.js";
|
||||
import { ExifFailedError } from "../errors/errors.js";
|
||||
import type { EnrichedPayload, MimeDetectionResult } from "../types/payload.js";
|
||||
import { MetadataTransform } from "./metadata.js";
|
||||
|
||||
describe("MetadataTransform", () => {
|
||||
it("composes mime and exif into an enriched payload", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MetadataTransform();
|
||||
const result = (await transform.process({
|
||||
bytes: buildMinimalJpeg(),
|
||||
name: "photo.jpg",
|
||||
})) as EnrichedPayload;
|
||||
expect(result.mime?.mimeType).toBe("image/jpeg");
|
||||
expect(result.exif?.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("emits mime:detected, exif:detected, and metadata:complete in order", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MetadataTransform();
|
||||
const events: string[] = [];
|
||||
transform.on("mime:detected", () => events.push("mime"));
|
||||
transform.on("exif:detected", () => events.push("exif"));
|
||||
transform.on("metadata:complete", () => events.push("complete"));
|
||||
await transform.process({
|
||||
bytes: buildMinimalJpeg(),
|
||||
name: "photo.jpg",
|
||||
});
|
||||
expect(events).toEqual(["mime", "exif", "complete"]);
|
||||
});
|
||||
|
||||
it("forwards child mime:detected and exif:detected events", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MetadataTransform();
|
||||
const mimeSeen: MimeDetectionResult[] = [];
|
||||
const exifSeen: ImageMetadata[] = [];
|
||||
transform.on("mime:detected", (r) => mimeSeen.push(r));
|
||||
transform.on("exif:detected", (m) => exifSeen.push(m));
|
||||
await transform.process({ bytes: buildMinimalJpeg() });
|
||||
expect(mimeSeen.length).toBe(1);
|
||||
expect(mimeSeen[0]?.source).toBe("magic-bytes");
|
||||
expect(exifSeen.length).toBe(1);
|
||||
expect(exifSeen[0]?.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("emits metadata:error and returns the original input when a child throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MetadataTransform();
|
||||
const errors: unknown[] = [];
|
||||
let completeFired = false;
|
||||
transform.on("metadata:error", (e) => errors.push(e));
|
||||
transform.on("metadata:complete", () => {
|
||||
completeFired = true;
|
||||
});
|
||||
const input = { bytes: buildGibberish() };
|
||||
const out = await transform.process(input);
|
||||
expect(out).toBe(input);
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors[0]).toBeInstanceOf(ExifFailedError);
|
||||
expect(completeFired).toBe(false);
|
||||
});
|
||||
|
||||
it("passthrough mode does not mutate the payload", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MetadataTransform({ mode: "passthrough" });
|
||||
const input = { bytes: buildMinimalJpeg(), name: "photo.jpg" };
|
||||
const out = (await transform.process(input)) as Record<string, unknown>;
|
||||
expect(out).toBe(input);
|
||||
expect(out.mime).toBeUndefined();
|
||||
expect(out.exif).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards read options to the inner ExifTransform", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MetadataTransform({ read: { skipExif: true } });
|
||||
const result = (await transform.process({
|
||||
bytes: buildMinimalJpeg(),
|
||||
})) as EnrichedPayload;
|
||||
expect(result.exif?.exif).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws when invoked with an already-aborted signal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MetadataTransform();
|
||||
const ac = new AbortController();
|
||||
ac.abort(new Error("halt"));
|
||||
await expect(
|
||||
transform.process(
|
||||
{ bytes: buildMinimalJpeg() },
|
||||
{ signal: ac.signal }
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("dispose releases child transforms idempotently", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const transform = new MetadataTransform();
|
||||
transform.dispose();
|
||||
transform.dispose();
|
||||
});
|
||||
|
||||
it("exposes the stable name 'metadata'", { timeout: 5000 }, () => {
|
||||
const transform = new MetadataTransform();
|
||||
expect(transform.name).toBe("metadata");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { BaseTransform } from "@suaveplan/streams";
|
||||
import type { StreamsMetadataEvents } from "../events/events.js";
|
||||
import { ExifTransform } from "../exif/exif.js";
|
||||
import { MimeTransform } from "../mime/mime.js";
|
||||
import type { EnrichedPayload, MetadataPayload } from "../types/payload.js";
|
||||
import type { ProcessOptions } from "../types/process-options.js";
|
||||
import type { MetadataTransformOptions } from "./metadata.types.js";
|
||||
|
||||
/**
|
||||
* Composing wrapper around {@link MimeTransform} and {@link ExifTransform}.
|
||||
*
|
||||
* In `enrich` mode (default) both children mutate the payload so the final
|
||||
* value carries `mime` and `exif`. In `passthrough` mode neither child
|
||||
* mutates the payload; child events still fire and are forwarded through
|
||||
* this wrapper for observability.
|
||||
*
|
||||
* Error handling: if either child throws, `MetadataTransform` catches the
|
||||
* exception, emits `metadata:error` with the thrown value, and returns the
|
||||
* **original** input reference so downstream pipeline stages can continue.
|
||||
*/
|
||||
export class MetadataTransform extends BaseTransform<
|
||||
Record<string, never>,
|
||||
StreamsMetadataEvents
|
||||
> {
|
||||
protected readonly mime: MimeTransform;
|
||||
protected readonly exif: ExifTransform;
|
||||
|
||||
constructor(options: MetadataTransformOptions = {}) {
|
||||
super("metadata");
|
||||
this.mime = new MimeTransform(
|
||||
options.mode === undefined ? {} : { mode: options.mode }
|
||||
);
|
||||
this.exif = new ExifTransform({
|
||||
...(options.mode !== undefined && { mode: options.mode }),
|
||||
...(options.read !== undefined && { read: options.read }),
|
||||
});
|
||||
this.mime.on("mime:detected", (r) => {
|
||||
this.emit("mime:detected", r);
|
||||
});
|
||||
this.exif.on("exif:detected", (m) => {
|
||||
this.emit("exif:detected", m);
|
||||
});
|
||||
}
|
||||
|
||||
async process<T = unknown>(input: T, options?: ProcessOptions): Promise<T> {
|
||||
this.assertNotAborted(options);
|
||||
const payload = input as unknown as MetadataPayload;
|
||||
try {
|
||||
const afterMime = await this.mime.process(payload, options);
|
||||
const afterExif = await this.exif.process(afterMime, options);
|
||||
this.emit(
|
||||
"metadata:complete",
|
||||
afterExif as unknown as EnrichedPayload,
|
||||
options
|
||||
);
|
||||
return afterExif as unknown as T;
|
||||
} catch (error) {
|
||||
this.emit("metadata:error", error, options);
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
this.mime.dispose();
|
||||
this.exif.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ExifTransformOptions } from "../exif/exif.types.js";
|
||||
import type { MimeTransformOptions } from "../mime/mime.types.js";
|
||||
|
||||
/**
|
||||
* Options for {@link MetadataTransform}. Both child transforms share the
|
||||
* `mode` flag; `read` is forwarded to `@suaveplan/exif::readMetadata` via
|
||||
* the inner {@link ExifTransform}.
|
||||
*/
|
||||
export type MetadataTransformOptions = MimeTransformOptions &
|
||||
ExifTransformOptions;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MimeTransform } from "./mime.js";
|
||||
export type { MimeTransformOptions } from "./mime.types.js";
|
||||
101
packages/foundation/flow/streams-metadata/src/mime/mime.md
Normal file
101
packages/foundation/flow/streams-metadata/src/mime/mime.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# `mime/mime`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MimeTransform` is a universal stream transform that decides the MIME
|
||||
type of a buffered binary payload and (in `enrich` mode) attaches the
|
||||
detection result to the payload under `mime`. It delegates to
|
||||
`@suaveplan/mime` for both magic-bytes and extension-based detection,
|
||||
layering a single composite path on top: magic bytes win when they match,
|
||||
then the caller's `payload.name` is consulted for an extension match, then
|
||||
`application/octet-stream` is the final fallback. The transform never
|
||||
throws for unknown bytes — the fallback is the documented success path —
|
||||
and emits a typed `mime:detected` event in every mode.
|
||||
|
||||
The class extends `@suaveplan/streams::BaseTransform`, so the same
|
||||
`on`/`off`/`toTransformStream`/`dispose` surface every streams consumer
|
||||
uses is available without any glue.
|
||||
|
||||
## Features
|
||||
|
||||
- Magic-bytes detection via `@suaveplan/mime::detectFromMagicBytes`, with
|
||||
the underlying priority preserved on the result.
|
||||
- Extension fallback via `@suaveplan/mime::detectFromExtension` (only
|
||||
consulted when magic bytes return `null` and `payload.name` is a string).
|
||||
- `application/octet-stream` final fallback with `priority: 0` and
|
||||
`source: "fallback"`.
|
||||
- `mime:detected` event emitted via the inherited
|
||||
`BaseTransform.on`/`emit` API.
|
||||
- `enrich` mode (default) shallow-clones the payload with `mime` attached;
|
||||
`passthrough` mode returns the original input reference unchanged.
|
||||
- Raises `InvalidPayloadError` when the payload is missing the mandatory
|
||||
`bytes: Uint8Array` field.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MimeTransform } from "@suaveplan/streams-metadata";
|
||||
|
||||
const transform = new MimeTransform();
|
||||
const result = await transform.process({
|
||||
bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
name: "image.png",
|
||||
});
|
||||
console.log(result.mime?.mimeType); // "image/png"
|
||||
console.log(result.mime?.source); // "magic-bytes"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MimeTransform } from "@suaveplan/streams-metadata";
|
||||
|
||||
const transform = new MimeTransform({ mode: "passthrough" });
|
||||
|
||||
transform.on("mime:detected", (r) => {
|
||||
if (r.source === "fallback") {
|
||||
console.warn("Unknown MIME — defaulting to application/octet-stream");
|
||||
} else {
|
||||
console.log(`MIME: ${r.mimeType} (priority ${r.priority}, via ${r.source})`);
|
||||
}
|
||||
});
|
||||
|
||||
const input = { bytes: new Uint8Array(), name: "song.mp3" };
|
||||
const out = await transform.process(input);
|
||||
console.log(out === input); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class MimeTransform extends BaseTransform<{}, StreamsMetadataEvents>`:
|
||||
- `constructor(options?: MimeTransformOptions)` — accepts `{ mode? }`,
|
||||
defaulting to `"enrich"`.
|
||||
- `async process<T = unknown>(input: T, options?: ProcessOptions): Promise<T>`
|
||||
— runs detection, emits `mime:detected`, returns the enriched payload
|
||||
(or original input reference in `passthrough` mode). Honours
|
||||
`options.signal` via `BaseTransform.assertNotAborted`. Throws
|
||||
`InvalidPayloadError` (`STREAMS_METADATA.INVALID_PAYLOAD`) when
|
||||
`input.bytes` is not a `Uint8Array`.
|
||||
- `name: "mime"` — stable name carried by every instance.
|
||||
- Inherits `on` / `off` / `dispose` / `toTransformStream` from
|
||||
`BaseTransform`.
|
||||
- `interface MimeTransformOptions`:
|
||||
- `mode?: MetadataMode` — `"enrich"` (default) or `"passthrough"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Detection precedence is exactly: magic bytes → extension → fallback. The
|
||||
contract guarantees every call returns a `MimeDetectionResult`, so
|
||||
consumers never need to handle a `null` / `undefined` return.
|
||||
- The magic-bytes branch consults `detectFromExtension(payload.name)` only
|
||||
to recover the extension string; the MIME type itself always comes from
|
||||
the magic-bytes layer (which has higher confidence than extension).
|
||||
- `passthrough` mode is reference-stable — the returned value is `===` the
|
||||
input. Useful for pipelines that need MIME observation without mutating
|
||||
the byte payload contract.
|
||||
- Listener exceptions are caught by `BaseTransform.emit` and never bubble
|
||||
out of `process` — observability cannot break the pipeline.
|
||||
154
packages/foundation/flow/streams-metadata/src/mime/mime.test.ts
Normal file
154
packages/foundation/flow/streams-metadata/src/mime/mime.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildGibberish,
|
||||
buildMinimalJpeg,
|
||||
buildMinimalPng,
|
||||
} from "../../tests/fixtures.js";
|
||||
import { InvalidPayloadError } from "../errors/errors.js";
|
||||
import type { MimeDetectionResult } from "../types/payload.js";
|
||||
import { MimeTransform } from "./mime.js";
|
||||
|
||||
describe("MimeTransform", () => {
|
||||
it("prefers magic bytes over extension", { timeout: 5000 }, async () => {
|
||||
const transform = new MimeTransform();
|
||||
const result = (await transform.process({
|
||||
bytes: buildMinimalPng(),
|
||||
name: "x.jpg",
|
||||
})) as { mime?: MimeDetectionResult };
|
||||
expect(result.mime?.mimeType).toBe("image/png");
|
||||
expect(result.mime?.source).toBe("magic-bytes");
|
||||
expect(result.mime?.priority).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("recovers extension from filename when magic-bytes match", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MimeTransform();
|
||||
const result = (await transform.process({
|
||||
bytes: buildMinimalPng(),
|
||||
name: "image.png",
|
||||
})) as { mime?: MimeDetectionResult };
|
||||
expect(result.mime?.extension).toBe("png");
|
||||
});
|
||||
|
||||
it("magic-bytes result has no extension when name is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MimeTransform();
|
||||
const result = (await transform.process({
|
||||
bytes: buildMinimalPng(),
|
||||
})) as { mime?: MimeDetectionResult };
|
||||
expect(result.mime?.source).toBe("magic-bytes");
|
||||
expect(result.mime?.extension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("magic-bytes result has no extension when filename suffix is unknown", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MimeTransform();
|
||||
const result = (await transform.process({
|
||||
bytes: buildMinimalPng(),
|
||||
name: "file.zzzunknown",
|
||||
})) as { mime?: MimeDetectionResult };
|
||||
expect(result.mime?.source).toBe("magic-bytes");
|
||||
expect(result.mime?.extension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to extension when magic returns null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MimeTransform();
|
||||
const result = (await transform.process({
|
||||
bytes: buildGibberish(),
|
||||
name: "song.mp3",
|
||||
})) as { mime?: MimeDetectionResult };
|
||||
expect(result.mime?.source).toBe("extension");
|
||||
expect(result.mime?.extension).toBe("mp3");
|
||||
expect(result.mime?.mimeType).toContain("audio");
|
||||
});
|
||||
|
||||
it("falls back to application/octet-stream when nothing matches", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MimeTransform();
|
||||
const result = (await transform.process({
|
||||
bytes: buildGibberish(),
|
||||
})) as { mime?: MimeDetectionResult };
|
||||
expect(result.mime?.mimeType).toBe("application/octet-stream");
|
||||
expect(result.mime?.source).toBe("fallback");
|
||||
expect(result.mime?.priority).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back when name has no known extension and magic-bytes return null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MimeTransform();
|
||||
const result = (await transform.process({
|
||||
bytes: buildGibberish(),
|
||||
name: "file.zzzunknown",
|
||||
})) as { mime?: MimeDetectionResult };
|
||||
expect(result.mime?.source).toBe("fallback");
|
||||
});
|
||||
|
||||
it("emits mime:detected before resolving", { timeout: 5000 }, async () => {
|
||||
const transform = new MimeTransform();
|
||||
const seen: MimeDetectionResult[] = [];
|
||||
transform.on("mime:detected", (r) => {
|
||||
seen.push(r);
|
||||
});
|
||||
await transform.process({ bytes: buildMinimalJpeg() });
|
||||
expect(seen.length).toBe(1);
|
||||
expect(seen[0]?.mimeType).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("passthrough mode returns the original input reference", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MimeTransform({ mode: "passthrough" });
|
||||
const input = { bytes: buildMinimalJpeg() };
|
||||
const out = await transform.process(input);
|
||||
expect(out).toBe(input);
|
||||
expect((out as { mime?: unknown }).mime).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws InvalidPayloadError when bytes is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MimeTransform();
|
||||
await expect(
|
||||
transform.process({ name: "x.png" })
|
||||
).rejects.toBeInstanceOf(InvalidPayloadError);
|
||||
});
|
||||
|
||||
it("throws when supplied signal is already aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MimeTransform();
|
||||
const ac = new AbortController();
|
||||
ac.abort(new Error("halt"));
|
||||
await expect(
|
||||
transform.process(
|
||||
{ bytes: buildMinimalJpeg() },
|
||||
{ signal: ac.signal }
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("a throwing mime:detected listener does not break process", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const transform = new MimeTransform();
|
||||
transform.on("mime:detected", () => {
|
||||
throw new Error("listener boom");
|
||||
});
|
||||
const result = (await transform.process({
|
||||
bytes: buildMinimalJpeg(),
|
||||
})) as { mime?: MimeDetectionResult };
|
||||
expect(result.mime?.mimeType).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("exposes the stable name 'mime'", { timeout: 5000 }, () => {
|
||||
const transform = new MimeTransform();
|
||||
expect(transform.name).toBe("mime");
|
||||
});
|
||||
});
|
||||
88
packages/foundation/flow/streams-metadata/src/mime/mime.ts
Normal file
88
packages/foundation/flow/streams-metadata/src/mime/mime.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { detectFromExtension, detectFromMagicBytes } from "@suaveplan/mime";
|
||||
import { BaseTransform } from "@suaveplan/streams";
|
||||
import { InvalidPayloadError } from "../errors/errors.js";
|
||||
import type { StreamsMetadataEvents } from "../events/events.js";
|
||||
import type {
|
||||
EnrichedPayload,
|
||||
MetadataMode,
|
||||
MetadataPayload,
|
||||
MimeDetectionResult,
|
||||
} from "../types/payload.js";
|
||||
import type { ProcessOptions } from "../types/process-options.js";
|
||||
import type { MimeTransformOptions } from "./mime.types.js";
|
||||
|
||||
/**
|
||||
* Universal stream transform that detects the MIME type of a buffered
|
||||
* payload and (in `enrich` mode) attaches the result to the payload under
|
||||
* `mime`. Emits `mime:detected` via the genesis `BaseTransform` built-in
|
||||
* event surface — no separate emitter property is exposed.
|
||||
*
|
||||
* Detection precedence:
|
||||
* 1. Magic bytes via `@suaveplan/mime::detectFromMagicBytes` (priority preserved).
|
||||
* 2. File extension via `@suaveplan/mime::detectFromExtension`.
|
||||
* 3. Final fallback `application/octet-stream` with `priority: 0`.
|
||||
*
|
||||
* `MimeTransform` never throws for unknown payloads — the fallback is the
|
||||
* documented success path. It only raises {@link InvalidPayloadError} when
|
||||
* the payload is missing the mandatory `bytes` field.
|
||||
*/
|
||||
export class MimeTransform extends BaseTransform<
|
||||
Record<string, never>,
|
||||
StreamsMetadataEvents
|
||||
> {
|
||||
protected readonly mode: MetadataMode;
|
||||
|
||||
constructor(options: MimeTransformOptions = {}) {
|
||||
super("mime");
|
||||
this.mode = options.mode ?? "enrich";
|
||||
}
|
||||
|
||||
async process<T = unknown>(input: T, options?: ProcessOptions): Promise<T> {
|
||||
this.assertNotAborted(options);
|
||||
const payload = input as unknown as MetadataPayload;
|
||||
if (!(payload?.bytes instanceof Uint8Array)) {
|
||||
throw new InvalidPayloadError();
|
||||
}
|
||||
const result = this.detect(payload);
|
||||
this.emit("mime:detected", result, options);
|
||||
if (this.mode === "passthrough") {
|
||||
return input;
|
||||
}
|
||||
const enriched: EnrichedPayload = { ...payload, mime: result };
|
||||
return enriched as unknown as T;
|
||||
}
|
||||
|
||||
protected detect(payload: MetadataPayload): MimeDetectionResult {
|
||||
const magic = detectFromMagicBytes(payload.bytes);
|
||||
if (magic) {
|
||||
const result: MimeDetectionResult = {
|
||||
mimeType: magic.mime,
|
||||
priority: magic.priority,
|
||||
source: "magic-bytes",
|
||||
};
|
||||
if (typeof payload.name === "string") {
|
||||
const ext = detectFromExtension(payload.name)?.extension;
|
||||
if (ext !== undefined) {
|
||||
result.extension = ext;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (typeof payload.name === "string") {
|
||||
const ext = detectFromExtension(payload.name);
|
||||
if (ext) {
|
||||
return {
|
||||
mimeType: ext.mime,
|
||||
priority: 0,
|
||||
extension: ext.extension,
|
||||
source: "extension",
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
mimeType: "application/octet-stream",
|
||||
priority: 0,
|
||||
source: "fallback",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { MetadataMode } from "../types/payload.js";
|
||||
|
||||
/**
|
||||
* Options for {@link MimeTransform}. `mode` controls whether the payload is
|
||||
* mutated (`enrich`, default) or returned unchanged (`passthrough`); the
|
||||
* `mime:detected` event fires in both modes.
|
||||
*/
|
||||
export interface MimeTransformOptions {
|
||||
mode?: MetadataMode;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
EnrichedPayload,
|
||||
MetadataMode,
|
||||
MetadataPayload,
|
||||
MimeDetectionResult,
|
||||
} from "./payload.js";
|
||||
export type { ProcessOptions } from "./process-options.js";
|
||||
108
packages/foundation/flow/streams-metadata/src/types/payload.md
Normal file
108
packages/foundation/flow/streams-metadata/src/types/payload.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# `types/payload`
|
||||
|
||||
## Purpose
|
||||
|
||||
Public type contract shared by every `@suaveplan/streams-metadata` transform.
|
||||
Callers and library code agree on the shape of a payload flowing through the
|
||||
metadata pipeline through four types: the mode flag, the input payload, the
|
||||
MIME detection result, and the enriched output. Defining them once keeps
|
||||
transform implementations free of duplicated structural declarations and
|
||||
gives consumers a single import path
|
||||
(`@suaveplan/streams-metadata/types/payload`) for downstream typing of their
|
||||
own pipeline stages.
|
||||
|
||||
The contract is deliberately small: it owns only the cross-transform
|
||||
vocabulary; transform-specific options live next to their transform.
|
||||
|
||||
## Features
|
||||
|
||||
- `MetadataMode` — switches every transform between `enrich` (mutate the
|
||||
payload) and `passthrough` (return the original input reference).
|
||||
- `MetadataPayload` — minimum shape every transform accepts: `bytes`
|
||||
required, `name` optional, arbitrary excess fields preserved across stages.
|
||||
- `MimeDetectionResult` — full detection record carrying MIME type, magic
|
||||
priority, extension, and the detection source.
|
||||
- `EnrichedPayload` — `MetadataPayload` with optional `mime` and `exif`
|
||||
fields populated by their respective transforms.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MetadataPayload,
|
||||
MimeDetectionResult,
|
||||
} from "@suaveplan/streams-metadata";
|
||||
|
||||
function logDetection(result: MimeDetectionResult): void {
|
||||
console.log(`Detected ${result.mimeType} via ${result.source}`);
|
||||
}
|
||||
|
||||
const payload: MetadataPayload = {
|
||||
bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
|
||||
name: "image.png",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
EnrichedPayload,
|
||||
MetadataMode,
|
||||
} from "@suaveplan/streams-metadata";
|
||||
|
||||
function isFullyEnriched(p: EnrichedPayload): boolean {
|
||||
return p.mime !== undefined && p.exif !== undefined;
|
||||
}
|
||||
|
||||
// Excess fields survive the pipeline:
|
||||
const incoming: EnrichedPayload = {
|
||||
bytes: new Uint8Array(),
|
||||
name: "x.jpg",
|
||||
uploadId: "u-123",
|
||||
tenantId: "tenant-7",
|
||||
};
|
||||
|
||||
function pickMode(passthroughOnly: boolean): MetadataMode {
|
||||
return passthroughOnly ? "passthrough" : "enrich";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type MetadataMode = "passthrough" | "enrich"` — controls payload
|
||||
mutation. `"enrich"` (default everywhere) returns a shallow-cloned
|
||||
payload with the detection result attached; `"passthrough"` returns the
|
||||
original input reference unchanged. Events still fire in both modes.
|
||||
- `interface MetadataPayload`:
|
||||
- `bytes: Uint8Array` — mandatory binary payload to inspect.
|
||||
- `name?: string` — optional filename hint used for extension-based MIME
|
||||
fallback.
|
||||
- `[key: string]: unknown` — arbitrary excess fields preserved across
|
||||
transforms.
|
||||
- `interface MimeDetectionResult`:
|
||||
- `mimeType: string` — full MIME type, e.g. `"image/jpeg"`.
|
||||
- `priority: number` — magic-bytes detection priority (`0` for
|
||||
extension- and fallback-sourced results).
|
||||
- `extension?: string` — file extension without the leading dot.
|
||||
- `source: "magic-bytes" | "extension" | "fallback"` — discriminator for
|
||||
how the result was determined.
|
||||
- `interface EnrichedPayload extends MetadataPayload`:
|
||||
- `mime?: MimeDetectionResult` — present when a `MimeTransform` ran in
|
||||
`enrich` mode.
|
||||
- `exif?: ImageMetadata` — present when an `ExifTransform` ran in
|
||||
`enrich` mode. Type re-exported from `@suaveplan/exif`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `[key: string]: unknown` index signature on `MetadataPayload` lets
|
||||
downstream callers layer their own typed payload (e.g. a tenant-scoped
|
||||
upload context) on top via interface merging without forking this
|
||||
package.
|
||||
- `extension` is optional on `MimeDetectionResult` because magic-bytes
|
||||
detection alone does not always recover an extension; the field is only
|
||||
populated when the caller supplies `payload.name`.
|
||||
- `MetadataPayload.bytes` is intentionally typed as a concrete `Uint8Array`
|
||||
rather than `ArrayBufferView` — every consumer (`@suaveplan/mime`,
|
||||
`@suaveplan/exif`) requires a `Uint8Array` and we want a single boundary
|
||||
validation point inside each transform.
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ImageMetadata } from "@suaveplan/exif";
|
||||
|
||||
/**
|
||||
* Controls whether a transform modifies the payload (`enrich`, default) or
|
||||
* returns the original input reference unchanged (`passthrough`).
|
||||
*/
|
||||
export type MetadataMode = "passthrough" | "enrich";
|
||||
|
||||
/**
|
||||
* Minimum shape every `@suaveplan/streams-metadata` transform accepts.
|
||||
* `bytes` is mandatory; `name` is an optional hint used for extension-based
|
||||
* MIME fallback. Excess fields are preserved across the pipeline.
|
||||
*/
|
||||
export interface MetadataPayload {
|
||||
bytes: Uint8Array;
|
||||
/** Optional filename — used for extension-based MIME fallback. */
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* MIME detection result attached to the payload in `enrich` mode and emitted
|
||||
* via the `mime:detected` event.
|
||||
*/
|
||||
export interface MimeDetectionResult {
|
||||
/** Full MIME type string, e.g. `'image/jpeg'`. */
|
||||
mimeType: string;
|
||||
/** Magic-bytes detection priority (0 when sourced from extension / fallback). */
|
||||
priority: number;
|
||||
/** File extension without leading dot, e.g. `'jpg'`. */
|
||||
extension?: string;
|
||||
/** How the MIME type was determined. */
|
||||
source: "magic-bytes" | "extension" | "fallback";
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload shape after enrichment by `MimeTransform` and / or `ExifTransform`.
|
||||
* Either field is present only when its corresponding transform ran in
|
||||
* `enrich` mode.
|
||||
*/
|
||||
export interface EnrichedPayload extends MetadataPayload {
|
||||
mime?: MimeDetectionResult;
|
||||
exif?: ImageMetadata;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# `types/process-options`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `ProcessOptions` shape accepted by every
|
||||
`@suaveplan/streams-metadata` transform's `process(input, options?)` method.
|
||||
The shape mirrors the protected `TransformOptions` carried by
|
||||
`@suaveplan/streams::BaseTransform` (`signal`, `clock`, `logger`) and exists
|
||||
as a local declaration because the upstream type is not re-exported from
|
||||
`@suaveplan/streams`'s public surface. Structural compatibility with the
|
||||
base shape keeps the `process` method's override valid while letting
|
||||
downstream callers depend only on this package's public API.
|
||||
|
||||
## Features
|
||||
|
||||
- `signal?: AbortSignal` — cancellation hook honoured by every transform
|
||||
via `BaseTransform.assertNotAborted`.
|
||||
- `clock?: Clock` — injectable monotonic clock from `@suaveplan/types`,
|
||||
reserved for future timing-sensitive transforms.
|
||||
- `logger?: LoggerLike` — structured logger from `@suaveplan/types`;
|
||||
receives swallowed listener errors per the base contract.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MimeTransform } from "@suaveplan/streams-metadata";
|
||||
|
||||
const transform = new MimeTransform();
|
||||
const controller = new AbortController();
|
||||
const result = await transform.process(
|
||||
{ bytes: imageBytes },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MetadataTransform,
|
||||
type ProcessOptions,
|
||||
} from "@suaveplan/streams-metadata";
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
|
||||
const clock: Clock = { now: () => 1700000000000 };
|
||||
const logger: LoggerLike = {
|
||||
error: (msg, ctx) => console.error("[metadata]", msg, ctx),
|
||||
};
|
||||
|
||||
const opts: ProcessOptions = {
|
||||
signal: new AbortController().signal,
|
||||
clock,
|
||||
logger,
|
||||
};
|
||||
|
||||
const transform = new MetadataTransform();
|
||||
const out = await transform.process({ bytes: jpegBytes, name: "x.jpg" }, opts);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ProcessOptions`:
|
||||
- `signal?: AbortSignal` — when fired before `process` yields,
|
||||
`BaseTransform.assertNotAborted` throws `StreamAbortedError` from
|
||||
`@suaveplan/streams`. The signal is rechecked at every yield point.
|
||||
- `clock?: Clock` — `@suaveplan/types::Clock` providing `now()`. The
|
||||
current package does not consume it but the slot is reserved to keep
|
||||
the surface stable with the base `TransformOptions`.
|
||||
- `logger?: LoggerLike` — `@suaveplan/types::LoggerLike` consumed by
|
||||
`BaseTransform.emit` when a registered listener throws; the throw is
|
||||
forwarded to `logger.error(...)` and never propagated to the `process`
|
||||
caller.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The interface is `readonly` on every field — callers building a
|
||||
long-lived options bundle should treat it as immutable.
|
||||
- Structural compatibility: this type matches the base shape with no extra
|
||||
fields, so passing a `ProcessOptions` into `BaseTransform.emit` and
|
||||
`BaseTransform.assertNotAborted` is type-safe.
|
||||
- `clock` is reserved for forward-compatibility; the current MIME and EXIF
|
||||
transforms are synchronous on the timing axis and do not read the
|
||||
clock. Listing it here ensures a future timing-sensitive transform can
|
||||
land without a breaking API change.
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Runtime options accepted by every transform's `process(input, options?)`
|
||||
* call. Mirrors the protected base shape carried by
|
||||
* `@suaveplan/streams::BaseTransform`. Defined locally because the upstream
|
||||
* type is not re-exported from `@suaveplan/streams`; structural compatibility
|
||||
* keeps the override valid.
|
||||
*/
|
||||
export interface ProcessOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly clock?: Clock;
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
Reference in New Issue
Block a user