207 lines
7.1 KiB
TypeScript
207 lines
7.1 KiB
TypeScript
import { sha256 } from "@suaveplan/crypto-hash";
|
|
import { EventBus } from "@suaveplan/events";
|
|
import { getMimeType } from "@suaveplan/mime";
|
|
import type { MessageResolver } from "@suaveplan/types";
|
|
import { type PackerFeatureFlags, resolvePackerFlags } from "../flags/flags.js";
|
|
import { defaultPackerResolver } from "../i18n/messages.js";
|
|
import { PackIdempotencyGuard } from "../idempotency/idempotency.js";
|
|
import type { PackIdempotencyOptions } from "../idempotency/idempotency.types.js";
|
|
import {
|
|
createPackerObservability,
|
|
type PackerObservability,
|
|
} from "../observability/observability.js";
|
|
import { PluginPipeline } from "../plugins/plugins.js";
|
|
import { retryRead } from "../retry/retryable-read.js";
|
|
import type {
|
|
RetryConfig,
|
|
RetryReadOptions,
|
|
} from "../retry/retryable-read.types.js";
|
|
import { resolveRuntime } from "../runtime/runtime.js";
|
|
import type { PackerRuntime } from "../runtime/runtime.types.js";
|
|
import type {
|
|
PackedData,
|
|
PackedDataMetadata,
|
|
PackerEventMap,
|
|
PackerOptions,
|
|
} from "../types/packer.types.js";
|
|
import type { PackSource } from "../types/source.types.js";
|
|
|
|
/**
|
|
* Resolved (defaults-applied) options for {@link AbstractPacker}.
|
|
* Internal — derived from the optional `PackerOptions` once at construction.
|
|
*/
|
|
export interface ResolvedPackerOptions {
|
|
compressionLevel: number;
|
|
encryption: PackerOptions["encryption"];
|
|
signing: PackerOptions["signing"];
|
|
metadata: Record<string, unknown>;
|
|
messageResolver: MessageResolver;
|
|
observability: PackerObservability;
|
|
runtime: PackerRuntime;
|
|
idempotency: PackIdempotencyGuard | undefined;
|
|
retryConfig: RetryConfig | undefined;
|
|
flags: PackerFeatureFlags;
|
|
plugins: PluginPipeline;
|
|
}
|
|
|
|
/**
|
|
* Abstract base class for whole-file packer implementations. Carries the
|
|
* resolved options bundle, a typed `EventBus<PackerEventMap>` for observability
|
|
* events, and a few shared helpers (checksum, MIME detection).
|
|
*
|
|
* Subclasses implement `pack` (and optionally `packStream`).
|
|
*/
|
|
export abstract class AbstractPacker extends EventBus<PackerEventMap> {
|
|
protected readonly options: ResolvedPackerOptions;
|
|
|
|
public constructor(options: PackerOptions = {}) {
|
|
super();
|
|
const messageResolver =
|
|
options.messageResolver ?? defaultPackerResolver;
|
|
const obsOpts: Parameters<typeof createPackerObservability>[0] = {};
|
|
if (options.observability !== undefined)
|
|
obsOpts.observability = options.observability;
|
|
if (options.meter !== undefined) obsOpts.meter = options.meter;
|
|
if (options.tracer !== undefined) obsOpts.tracer = options.tracer;
|
|
const observability = createPackerObservability(obsOpts);
|
|
const runtime = resolveRuntime(options);
|
|
const idempotency = makeIdempotency(options.idempotency, runtime);
|
|
const flags = resolvePackerFlags(options.flags);
|
|
const plugins = new PluginPipeline(options.plugins, messageResolver);
|
|
this.options = {
|
|
compressionLevel: options.compressionLevel ?? 6,
|
|
encryption: options.encryption,
|
|
signing: options.signing,
|
|
metadata: { ...(options.metadata ?? {}) },
|
|
messageResolver,
|
|
observability,
|
|
runtime,
|
|
idempotency,
|
|
retryConfig: options.retryConfig,
|
|
flags,
|
|
plugins,
|
|
};
|
|
}
|
|
|
|
/** Pack `data` into a whole-file envelope. Subclasses define the pipeline. */
|
|
public abstract pack(
|
|
data: Uint8Array,
|
|
filenameOrOpts?: string | import("../types/packer.types.js").PackOptions
|
|
): Promise<PackedData>;
|
|
|
|
/**
|
|
* Pack a streaming source by collecting it into a buffer and delegating to
|
|
* {@link pack}. Whole-file mode is not streaming-native; for true-streaming
|
|
* use the chunked subpath.
|
|
*/
|
|
public async packStream(
|
|
source: PackSource,
|
|
filename?: string
|
|
): Promise<PackedData> {
|
|
const buffer = await collect(source, {
|
|
...(this.options.retryConfig !== undefined
|
|
? { retryConfig: this.options.retryConfig }
|
|
: {}),
|
|
});
|
|
return this.pack(buffer, filename);
|
|
}
|
|
|
|
/** SHA-256 hex of `data`. Used for the `checksum` field of metadata. */
|
|
protected checksum(data: Uint8Array): string {
|
|
return toHex(sha256(data));
|
|
}
|
|
|
|
/** Detect MIME type from an optional filename via `@suaveplan/mime`. */
|
|
protected mimeFor(filename?: string): string | undefined {
|
|
return filename ? getMimeType(filename) : undefined;
|
|
}
|
|
|
|
/** Build the metadata object common to all packed outputs. */
|
|
protected buildMetadata(input: {
|
|
originalSize: number;
|
|
compressedSize: number;
|
|
encrypted: boolean;
|
|
signed?: boolean;
|
|
mimeType?: string;
|
|
checksum: string;
|
|
}): PackedDataMetadata {
|
|
return {
|
|
originalSize: input.originalSize,
|
|
compressedSize: input.compressedSize,
|
|
encrypted: input.encrypted,
|
|
...(input.signed !== undefined ? { signed: input.signed } : {}),
|
|
...(input.mimeType !== undefined
|
|
? { mimeType: input.mimeType }
|
|
: {}),
|
|
timestamp: this.options.runtime.clock(),
|
|
checksum: input.checksum,
|
|
...(Object.keys(this.options.metadata).length > 0
|
|
? { custom: { ...this.options.metadata } }
|
|
: {}),
|
|
};
|
|
}
|
|
}
|
|
|
|
function makeIdempotency(
|
|
opts: PackIdempotencyOptions | undefined,
|
|
runtime: PackerRuntime
|
|
): PackIdempotencyGuard | undefined {
|
|
if (opts === undefined) return undefined;
|
|
return new PackIdempotencyGuard(opts, runtime.clock);
|
|
}
|
|
|
|
/** Collect a streaming source into a single `Uint8Array`. */
|
|
async function collect(
|
|
source: PackSource,
|
|
opts: RetryReadOptions
|
|
): Promise<Uint8Array> {
|
|
if (source instanceof Uint8Array) return source;
|
|
if (typeof Blob !== "undefined" && source instanceof Blob) {
|
|
return new Uint8Array(await source.arrayBuffer());
|
|
}
|
|
const parts: Uint8Array[] = [];
|
|
let total = 0;
|
|
if (
|
|
source !== null &&
|
|
typeof source === "object" &&
|
|
Symbol.asyncIterator in source
|
|
) {
|
|
for await (const part of source as AsyncIterable<Uint8Array>) {
|
|
parts.push(part);
|
|
total += part.byteLength;
|
|
}
|
|
} else {
|
|
const reader = (
|
|
source as unknown as ReadableStream<Uint8Array>
|
|
).getReader();
|
|
try {
|
|
for (;;) {
|
|
const { value, done } = await retryRead(reader, opts);
|
|
if (done) break;
|
|
if (value) {
|
|
parts.push(value);
|
|
total += value.byteLength;
|
|
}
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
const out = new Uint8Array(total);
|
|
let off = 0;
|
|
for (const p of parts) {
|
|
out.set(p, off);
|
|
off += p.byteLength;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function toHex(bytes: Uint8Array): string {
|
|
let s = "";
|
|
for (let i = 0; i < bytes.byteLength; i++) {
|
|
const b = bytes[i] ?? /* istanbul ignore next */ 0;
|
|
s += b.toString(16).padStart(2, "0");
|
|
}
|
|
return s;
|
|
}
|