@ttt-productions/media-processing-core 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/image/formats.d.ts +2 -2
- package/dist/image/formats.d.ts.map +1 -1
- package/dist/image/formats.js.map +1 -1
- package/dist/image/image-processor.d.ts.map +1 -1
- package/dist/image/image-processor.js.map +1 -1
- package/dist/image/resize.d.ts +4 -4
- package/dist/image/resize.d.ts.map +1 -1
- package/dist/image/resize.js.map +1 -1
- package/dist/server/r2-retry.d.ts +91 -0
- package/dist/server/r2-retry.d.ts.map +1 -0
- package/dist/server/r2-retry.js +250 -0
- package/dist/server/r2-retry.js.map +1 -0
- package/dist/server/storage-ops.d.ts +7 -0
- package/dist/server/storage-ops.d.ts.map +1 -1
- package/dist/server/storage-ops.js +75 -29
- package/dist/server/storage-ops.js.map +1 -1
- package/package.json +3 -3
package/dist/image/formats.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import type { Sharp } from "sharp";
|
|
2
2
|
/** Output image formats the processor can emit. */
|
|
3
3
|
export type ImageOutputFormat = "jpeg" | "png" | "webp" | "avif";
|
|
4
4
|
/**
|
|
@@ -16,5 +16,5 @@ export declare function mimeForImageFormat(fmt?: string): string | undefined;
|
|
|
16
16
|
* png is lossless so quality is ignored; jpeg/webp/avif honor quality when set,
|
|
17
17
|
* otherwise sharp's per-format defaults apply.
|
|
18
18
|
*/
|
|
19
|
-
export declare function applyImageFormat(pipeline:
|
|
19
|
+
export declare function applyImageFormat(pipeline: Sharp, format: ImageOutputFormat, quality?: number): Sharp;
|
|
20
20
|
//# sourceMappingURL=formats.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"formats.d.ts","sourceRoot":"","sources":["../../src/image/formats.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"formats.d.ts","sourceRoot":"","sources":["../../src/image/formats.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAEnC,mDAAmD;AACnD,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,MAAM,CAAC;AAEjE;;;GAGG;AACH,wBAAgB,UAAU,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,iBAAiB,CAAA;CAAE,CAanF;AAED,mDAAmD;AACnD,wBAAgB,kBAAkB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CASnE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,KAAK,EACf,MAAM,EAAE,iBAAiB,EACzB,OAAO,CAAC,EAAE,MAAM,GACf,KAAK,CAaP"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"formats.js","sourceRoot":"","sources":["../../src/image/formats.ts"],"names":[],"mappings":"AAMA;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAC,GAAY;IACrC,QAAQ,CAAC,GAAG,IAAI,MAAM,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC;QACtC,KAAK,KAAK;YACR,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;QACvC,KAAK,MAAM;YACT,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;QACzC,KAAK,MAAM;YACT,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;QACzC,KAAK,KAAK,CAAC;QACX,KAAK,MAAM,CAAC;QACZ;YACE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAC1C,CAAC;AACH,CAAC;AAED,mDAAmD;AACnD,MAAM,UAAU,kBAAkB,CAAC,GAAY;IAC7C,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAC;IAC3B,MAAM,CAAC,GAAG,GAAG,CAAC,WAAW,EAAE,CAAC;IAC5B,IAAI,CAAC,KAAK,KAAK;QAAE,OAAO,YAAY,CAAC;IACrC,IAAI,CAAC,KAAK,MAAM;QAAE,OAAO,YAAY,CAAC;IACtC,IAAI,CAAC,KAAK,KAAK;QAAE,OAAO,WAAW,CAAC;IACpC,IAAI,CAAC,KAAK,MAAM;QAAE,OAAO,YAAY,CAAC;IACtC,IAAI,CAAC,KAAK,MAAM;QAAE,OAAO,YAAY,CAAC;IACtC,OAAO,SAAS,CAAC,EAAE,CAAC;AACtB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAC9B,
|
|
1
|
+
{"version":3,"file":"formats.js","sourceRoot":"","sources":["../../src/image/formats.ts"],"names":[],"mappings":"AAMA;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAC,GAAY;IACrC,QAAQ,CAAC,GAAG,IAAI,MAAM,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC;QACtC,KAAK,KAAK;YACR,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;QACvC,KAAK,MAAM;YACT,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;QACzC,KAAK,MAAM;YACT,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;QACzC,KAAK,KAAK,CAAC;QACX,KAAK,MAAM,CAAC;QACZ;YACE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAC1C,CAAC;AACH,CAAC;AAED,mDAAmD;AACnD,MAAM,UAAU,kBAAkB,CAAC,GAAY;IAC7C,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAC;IAC3B,MAAM,CAAC,GAAG,GAAG,CAAC,WAAW,EAAE,CAAC;IAC5B,IAAI,CAAC,KAAK,KAAK;QAAE,OAAO,YAAY,CAAC;IACrC,IAAI,CAAC,KAAK,MAAM;QAAE,OAAO,YAAY,CAAC;IACtC,IAAI,CAAC,KAAK,KAAK;QAAE,OAAO,WAAW,CAAC;IACpC,IAAI,CAAC,KAAK,MAAM;QAAE,OAAO,YAAY,CAAC;IACtC,IAAI,CAAC,KAAK,MAAM;QAAE,OAAO,YAAY,CAAC;IACtC,OAAO,SAAS,CAAC,EAAE,CAAC;AACtB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAC9B,QAAe,EACf,MAAyB,EACzB,OAAgB;IAEhB,MAAM,CAAC,GAAG,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;IAC5D,QAAQ,MAAM,EAAE,CAAC;QACf,KAAK,KAAK;YACR,OAAO,QAAQ,CAAC,GAAG,EAAE,CAAC;QACxB,KAAK,MAAM;YACT,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;QACvD,KAAK,MAAM;YACT,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;QACvD,KAAK,MAAM,CAAC;QACZ;YACE,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IACzD,CAAC;AACH,CAAC","sourcesContent":["// shared image format / quality presets\nimport type { Sharp } from \"sharp\";\n\n/** Output image formats the processor can emit. */\nexport type ImageOutputFormat = \"jpeg\" | \"png\" | \"webp\" | \"avif\";\n\n/**\n * Map a requested format string (case-insensitive) to its sharp format id and\n * file extension. Unknown or missing formats fall back to jpeg.\n */\nexport function pickFormat(fmt?: string): { ext: string; format: ImageOutputFormat } {\n switch ((fmt ?? \"jpeg\").toLowerCase()) {\n case \"png\":\n return { ext: \"png\", format: \"png\" };\n case \"webp\":\n return { ext: \"webp\", format: \"webp\" };\n case \"avif\":\n return { ext: \"avif\", format: \"avif\" };\n case \"jpg\":\n case \"jpeg\":\n default:\n return { ext: \"jpg\", format: \"jpeg\" };\n }\n}\n\n/** Map an image format string to its MIME type. */\nexport function mimeForImageFormat(fmt?: string): string | undefined {\n if (!fmt) return undefined;\n const f = fmt.toLowerCase();\n if (f === \"jpg\") return \"image/jpeg\";\n if (f === \"jpeg\") return \"image/jpeg\";\n if (f === \"png\") return \"image/png\";\n if (f === \"webp\") return \"image/webp\";\n if (f === \"avif\") return \"image/avif\";\n return `image/${f}`;\n}\n\n/**\n * Apply the chosen output format (and optional quality) to a sharp pipeline.\n * png is lossless so quality is ignored; jpeg/webp/avif honor quality when set,\n * otherwise sharp's per-format defaults apply.\n */\nexport function applyImageFormat(\n pipeline: Sharp,\n format: ImageOutputFormat,\n quality?: number\n): Sharp {\n const q = typeof quality === \"number\" ? quality : undefined;\n switch (format) {\n case \"png\":\n return pipeline.png();\n case \"webp\":\n return pipeline.webp(q ? { quality: q } : undefined);\n case \"avif\":\n return pipeline.avif(q ? { quality: q } : undefined);\n case \"jpeg\":\n default:\n return pipeline.jpeg(q ? { quality: q } : undefined);\n }\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"image-processor.d.ts","sourceRoot":"","sources":["../../src/image/image-processor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqC,qBAAqB,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"image-processor.d.ts","sourceRoot":"","sources":["../../src/image/image-processor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqC,qBAAqB,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;AAMpI,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AA8LvD,wBAAsB,YAAY,CAChC,IAAI,EAAE,mBAAmB,EACzB,GAAG,EAAE;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;CACxB,EACD,IAAI,CAAC,EAAE,mBAAmB,GACzB,OAAO,CAAC,qBAAqB,CAAC,CA6FhC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"image-processor.js","sourceRoot":"","sources":["../../src/image/image-processor.ts"],"names":[],"mappings":"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","sourcesContent":["import type { MediaOutput, MediaProcessingError, MediaProcessingResult, MediaProcessingSpec } from \"@ttt-productions/media-schemas\";\nimport sharp from \"sharp\";\nimport { mkdir } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { safeOutputPathFor } from \"../utils/safe-path.js\";\nimport type { ProcessMediaOptions } from \"../types.js\";\nimport { applyImageFormat, mimeForImageFormat, pickFormat } from \"./formats.js\";\nimport {\n computeAspectCropBox,\n resizeCover,\n resizeInside,\n toSharpPosition,\n type Gravity,\n} from \"./resize.js\";\n\nfunction matchMime(accepted: string, actual: string): boolean {\n const a = accepted.trim().toLowerCase();\n const m = actual.trim().toLowerCase();\n if (!a) return true;\n if (a === \"*/*\") return true;\n if (a.endsWith(\"/*\")) return m.startsWith(a.slice(0, -1));\n return a === m;\n}\n\nfunction acceptAllowsImage(spec: MediaProcessingSpec): boolean {\n const kinds = spec.accept?.kinds?.filter(Boolean) ?? [];\n return kinds.length === 0 ? true : kinds.includes(\"image\");\n}\n\nfunction acceptAllowsMime(spec: MediaProcessingSpec, actualMime?: string): boolean {\n const mimes = spec.accept?.mimes?.filter(Boolean) ?? [];\n if (mimes.length === 0) return true; // empty => accept anything\n if (!actualMime) return false;\n return mimes.some((a) => matchMime(a, actualMime));\n}\n\nfunction outputPathFor(base: string, key: string, ext: string): string {\n return safeOutputPathFor(base, key, ext);\n}\n\nfunction aspect(width?: number, height?: number): number | undefined {\n if (!width || !height) return undefined;\n return width / height;\n}\n\nfunction aspectClose(a: number, b: number, tolerance: number): boolean {\n return Math.abs(a - b) <= tolerance;\n}\n\n/**\n * Build a \"normalized base\" pipeline that enforces spec-level uniformity:\n * - if imageCrop exists -> always crop to outputWidth/outputHeight\n * - else if requiredWidth/requiredHeight exist -> crop/resize to those\n * - else if requiredAspectRatio exists -> center-crop to that aspect (no scaling unless needed later)\n *\n * If the source violates constraints and we cannot/should not auto-fix, returns a rejection error.\n */\nasync function buildBase(\n spec: MediaProcessingSpec,\n inputPath: string\n): Promise<\n | { ok: true; base: sharp.Sharp; inputMeta: sharp.Metadata }\n | { ok: false; error: MediaProcessingError }\n> {\n const input = sharp(inputPath, { failOn: \"none\" });\n const meta = await input.metadata();\n\n if (!meta.width || !meta.height || meta.width <= 0 || meta.height <= 0) {\n return {\n ok: false,\n error: {\n code: \"processing_failed\",\n message: \"Unable to read valid image dimensions. File may be corrupt.\",\n details: { width: meta.width, height: meta.height, format: meta.format, path: inputPath },\n },\n };\n }\n \n if (!meta.format) {\n return {\n ok: false,\n error: {\n code: \"unsupported_format\",\n message: \"Unable to determine image format. File may be corrupt.\",\n details: { path: inputPath },\n },\n };\n }\n\n if (!acceptAllowsImage(spec)) {\n return {\n ok: false,\n error: {\n code: \"invalid_mime\",\n message: \"Spec does not allow image uploads.\",\n details: { accept: spec.accept },\n },\n };\n }\n \n const detectedMime = mimeForImageFormat(meta.format);\n if (!acceptAllowsMime(spec, detectedMime)) {\n return {\n ok: false,\n error: {\n code: \"invalid_mime\",\n message: \"Image mime type is not allowed.\",\n details: { detectedMime, accept: spec.accept },\n },\n };\n }\n\n const w = meta.width ?? undefined;\n const h = meta.height ?? undefined;\n\n if (!w || !h) {\n // Can't validate/crop reliably without dimensions.\n // We'll still proceed unless strict dims/aspect are required without crop/auto.\n if (spec.requiredWidth || spec.requiredHeight || spec.requiredAspectRatio) {\n if (!spec.allowAutoFormat && !spec.imageCrop) {\n return {\n ok: false,\n error: {\n code: \"dimensions_mismatch\",\n message: \"Unable to read image dimensions for validation.\",\n details: { width: w, height: h },\n },\n };\n }\n }\n }\n\n // Start from raw input each time; callers can clone as needed.\n let base = sharp(inputPath);\n\n // Keep metadata only if explicitly requested.\n if (spec.image?.stripMetadata === false) {\n base = base.withMetadata({ orientation: meta.orientation });\n }\n\n // 1) Explicit crop spec (most strict)\n const crop = spec.imageCrop;\n if (crop) {\n base = resizeCover(base, crop.outputWidth, crop.outputHeight);\n return { ok: true, base, inputMeta: meta };\n }\n\n // 2) Exact required size -> auto crop/resize only if allowAutoFormat OR the request is inherently \"uniform\"\n const reqW = spec.requiredWidth;\n const reqH = spec.requiredHeight;\n if (reqW && reqH) {\n const canFix = spec.allowAutoFormat === true;\n // If not allowed to auto-fix, require exact match\n if (!canFix && w && h && (w !== reqW || h !== reqH)) {\n return {\n ok: false,\n error: {\n code: \"dimensions_mismatch\",\n message: \"Image dimensions do not match the required format.\",\n details: { requiredWidth: reqW, requiredHeight: reqH, width: w, height: h },\n },\n };\n }\n\n // Auto-fix path (or exact already): enforce normalized base\n base = resizeCover(base, reqW, reqH);\n\n return { ok: true, base, inputMeta: meta };\n }\n\n // 3) Aspect ratio requirement (no exact size)\n const reqAspect = spec.requiredAspectRatio;\n if (reqAspect && w && h) {\n const a = aspect(w, h);\n const tol = spec.aspectRatioTolerance ?? 0.02;\n if (a && !aspectClose(a, reqAspect, tol)) {\n if (!spec.allowAutoFormat) {\n return {\n ok: false,\n error: {\n code: \"aspect_ratio_mismatch\",\n message: \"Image aspect ratio does not match the required format.\",\n details: { requiredAspectRatio: reqAspect, aspectRatio: a, width: w, height: h },\n },\n };\n }\n\n // Auto center-crop to required aspect ratio (no forced scale yet).\n base = base.extract(computeAspectCropBox(w, h, reqAspect));\n }\n }\n\n return { ok: true, base, inputMeta: meta };\n}\n\nexport async function processImage(\n spec: MediaProcessingSpec,\n ctx: {\n inputPath: string;\n outputBasePath: string;\n },\n opts?: ProcessMediaOptions\n): Promise<MediaProcessingResult> {\n const tempFiles: string[] = [];\n\n try {\n if (opts?.signal?.aborted) {\n return { ok: false, mediaType: \"image\", error: { code: \"processing_canceled\", message: \"Processing canceled.\" } };\n }\n\n const variants = spec.image?.variants?.length ? spec.image.variants : [{ key: \"original\" as const }];\n\n const outDir = path.dirname(ctx.outputBasePath);\n await mkdir(outDir, { recursive: true });\n\n const baseRes = await buildBase(spec, ctx.inputPath);\n if (!baseRes.ok) {\n return { ok: false, mediaType: \"image\", error: baseRes.error };\n }\n\n const { base, inputMeta } = baseRes;\n\n const outputs: MediaOutput[] = [];\n const totalVariants = variants.length || 1;\n let idx = 0;\n\n for (const v of variants) {\n idx += 1;\n\n if (opts?.signal?.aborted) {\n return { ok: false, mediaType: \"image\", error: { code: \"processing_canceled\", message: \"Processing canceled.\" } };\n }\n\n opts?.onProgress?.({\n phase: \"process\",\n percent: (idx - 1) / totalVariants,\n detail: { step: \"image_variant\", key: v.key },\n });\n\n const { ext, format } = pickFormat(v.format);\n const outPath = outputPathFor(ctx.outputBasePath, v.key, ext);\n tempFiles.push(outPath);\n\n let p = base.clone();\n\n if (v.crop) {\n p = resizeCover(p, v.crop.width, v.crop.height, toSharpPosition((v.crop.gravity as Gravity) ?? \"center\"));\n } else if (v.maxWidth || v.maxHeight) {\n p = resizeInside(p, v.maxWidth, v.maxHeight);\n }\n\n p = applyImageFormat(p, format, v.quality);\n\n const info = await p.toFile(outPath);\n\n outputs.push({\n key: v.key,\n url: `file://${outPath}`,\n path: outPath,\n mime: mimeForImageFormat(info.format),\n sizeBytes: info.size,\n width: info.width,\n height: info.height,\n });\n }\n\n // success: keep outputs\n tempFiles.length = 0;\n\n return {\n ok: true,\n mediaType: \"image\",\n outputs,\n meta: {\n mime: mimeForImageFormat(inputMeta.format),\n width: inputMeta.width,\n height: inputMeta.height,\n },\n };\n } catch (e: any) {\n return {\n ok: false,\n mediaType: \"image\",\n error: {\n code: \"processing_failed\",\n message: e?.message ? String(e.message) : \"Image processing failed\",\n details: { name: e?.name },\n },\n };\n } finally {\n if (tempFiles.length) {\n const { rm } = await import(\"node:fs/promises\");\n await Promise.all(tempFiles.map((f) => rm(f, { force: true }).catch(() => {})));\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"image-processor.js","sourceRoot":"","sources":["../../src/image/image-processor.ts"],"names":[],"mappings":"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","sourcesContent":["import type { MediaOutput, MediaProcessingError, MediaProcessingResult, MediaProcessingSpec } from \"@ttt-productions/media-schemas\";\nimport sharp from \"sharp\";\nimport type { Sharp, Metadata } from \"sharp\";\nimport { mkdir } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { safeOutputPathFor } from \"../utils/safe-path.js\";\nimport type { ProcessMediaOptions } from \"../types.js\";\nimport { applyImageFormat, mimeForImageFormat, pickFormat } from \"./formats.js\";\nimport {\n computeAspectCropBox,\n resizeCover,\n resizeInside,\n toSharpPosition,\n type Gravity,\n} from \"./resize.js\";\n\nfunction matchMime(accepted: string, actual: string): boolean {\n const a = accepted.trim().toLowerCase();\n const m = actual.trim().toLowerCase();\n if (!a) return true;\n if (a === \"*/*\") return true;\n if (a.endsWith(\"/*\")) return m.startsWith(a.slice(0, -1));\n return a === m;\n}\n\nfunction acceptAllowsImage(spec: MediaProcessingSpec): boolean {\n const kinds = spec.accept?.kinds?.filter(Boolean) ?? [];\n return kinds.length === 0 ? true : kinds.includes(\"image\");\n}\n\nfunction acceptAllowsMime(spec: MediaProcessingSpec, actualMime?: string): boolean {\n const mimes = spec.accept?.mimes?.filter(Boolean) ?? [];\n if (mimes.length === 0) return true; // empty => accept anything\n if (!actualMime) return false;\n return mimes.some((a) => matchMime(a, actualMime));\n}\n\nfunction outputPathFor(base: string, key: string, ext: string): string {\n return safeOutputPathFor(base, key, ext);\n}\n\nfunction aspect(width?: number, height?: number): number | undefined {\n if (!width || !height) return undefined;\n return width / height;\n}\n\nfunction aspectClose(a: number, b: number, tolerance: number): boolean {\n return Math.abs(a - b) <= tolerance;\n}\n\n/**\n * Build a \"normalized base\" pipeline that enforces spec-level uniformity:\n * - if imageCrop exists -> always crop to outputWidth/outputHeight\n * - else if requiredWidth/requiredHeight exist -> crop/resize to those\n * - else if requiredAspectRatio exists -> center-crop to that aspect (no scaling unless needed later)\n *\n * If the source violates constraints and we cannot/should not auto-fix, returns a rejection error.\n */\nasync function buildBase(\n spec: MediaProcessingSpec,\n inputPath: string\n): Promise<\n | { ok: true; base: Sharp; inputMeta: Metadata }\n | { ok: false; error: MediaProcessingError }\n> {\n const input = sharp(inputPath, { failOn: \"none\" });\n const meta = await input.metadata();\n\n if (!meta.width || !meta.height || meta.width <= 0 || meta.height <= 0) {\n return {\n ok: false,\n error: {\n code: \"processing_failed\",\n message: \"Unable to read valid image dimensions. File may be corrupt.\",\n details: { width: meta.width, height: meta.height, format: meta.format, path: inputPath },\n },\n };\n }\n \n if (!meta.format) {\n return {\n ok: false,\n error: {\n code: \"unsupported_format\",\n message: \"Unable to determine image format. File may be corrupt.\",\n details: { path: inputPath },\n },\n };\n }\n\n if (!acceptAllowsImage(spec)) {\n return {\n ok: false,\n error: {\n code: \"invalid_mime\",\n message: \"Spec does not allow image uploads.\",\n details: { accept: spec.accept },\n },\n };\n }\n \n const detectedMime = mimeForImageFormat(meta.format);\n if (!acceptAllowsMime(spec, detectedMime)) {\n return {\n ok: false,\n error: {\n code: \"invalid_mime\",\n message: \"Image mime type is not allowed.\",\n details: { detectedMime, accept: spec.accept },\n },\n };\n }\n\n const w = meta.width ?? undefined;\n const h = meta.height ?? undefined;\n\n if (!w || !h) {\n // Can't validate/crop reliably without dimensions.\n // We'll still proceed unless strict dims/aspect are required without crop/auto.\n if (spec.requiredWidth || spec.requiredHeight || spec.requiredAspectRatio) {\n if (!spec.allowAutoFormat && !spec.imageCrop) {\n return {\n ok: false,\n error: {\n code: \"dimensions_mismatch\",\n message: \"Unable to read image dimensions for validation.\",\n details: { width: w, height: h },\n },\n };\n }\n }\n }\n\n // Start from raw input each time; callers can clone as needed.\n let base = sharp(inputPath);\n\n // Keep metadata only if explicitly requested.\n if (spec.image?.stripMetadata === false) {\n base = base.withMetadata({ orientation: meta.orientation });\n }\n\n // 1) Explicit crop spec (most strict)\n const crop = spec.imageCrop;\n if (crop) {\n base = resizeCover(base, crop.outputWidth, crop.outputHeight);\n return { ok: true, base, inputMeta: meta };\n }\n\n // 2) Exact required size -> auto crop/resize only if allowAutoFormat OR the request is inherently \"uniform\"\n const reqW = spec.requiredWidth;\n const reqH = spec.requiredHeight;\n if (reqW && reqH) {\n const canFix = spec.allowAutoFormat === true;\n // If not allowed to auto-fix, require exact match\n if (!canFix && w && h && (w !== reqW || h !== reqH)) {\n return {\n ok: false,\n error: {\n code: \"dimensions_mismatch\",\n message: \"Image dimensions do not match the required format.\",\n details: { requiredWidth: reqW, requiredHeight: reqH, width: w, height: h },\n },\n };\n }\n\n // Auto-fix path (or exact already): enforce normalized base\n base = resizeCover(base, reqW, reqH);\n\n return { ok: true, base, inputMeta: meta };\n }\n\n // 3) Aspect ratio requirement (no exact size)\n const reqAspect = spec.requiredAspectRatio;\n if (reqAspect && w && h) {\n const a = aspect(w, h);\n const tol = spec.aspectRatioTolerance ?? 0.02;\n if (a && !aspectClose(a, reqAspect, tol)) {\n if (!spec.allowAutoFormat) {\n return {\n ok: false,\n error: {\n code: \"aspect_ratio_mismatch\",\n message: \"Image aspect ratio does not match the required format.\",\n details: { requiredAspectRatio: reqAspect, aspectRatio: a, width: w, height: h },\n },\n };\n }\n\n // Auto center-crop to required aspect ratio (no forced scale yet).\n base = base.extract(computeAspectCropBox(w, h, reqAspect));\n }\n }\n\n return { ok: true, base, inputMeta: meta };\n}\n\nexport async function processImage(\n spec: MediaProcessingSpec,\n ctx: {\n inputPath: string;\n outputBasePath: string;\n },\n opts?: ProcessMediaOptions\n): Promise<MediaProcessingResult> {\n const tempFiles: string[] = [];\n\n try {\n if (opts?.signal?.aborted) {\n return { ok: false, mediaType: \"image\", error: { code: \"processing_canceled\", message: \"Processing canceled.\" } };\n }\n\n const variants = spec.image?.variants?.length ? spec.image.variants : [{ key: \"original\" as const }];\n\n const outDir = path.dirname(ctx.outputBasePath);\n await mkdir(outDir, { recursive: true });\n\n const baseRes = await buildBase(spec, ctx.inputPath);\n if (!baseRes.ok) {\n return { ok: false, mediaType: \"image\", error: baseRes.error };\n }\n\n const { base, inputMeta } = baseRes;\n\n const outputs: MediaOutput[] = [];\n const totalVariants = variants.length || 1;\n let idx = 0;\n\n for (const v of variants) {\n idx += 1;\n\n if (opts?.signal?.aborted) {\n return { ok: false, mediaType: \"image\", error: { code: \"processing_canceled\", message: \"Processing canceled.\" } };\n }\n\n opts?.onProgress?.({\n phase: \"process\",\n percent: (idx - 1) / totalVariants,\n detail: { step: \"image_variant\", key: v.key },\n });\n\n const { ext, format } = pickFormat(v.format);\n const outPath = outputPathFor(ctx.outputBasePath, v.key, ext);\n tempFiles.push(outPath);\n\n let p = base.clone();\n\n if (v.crop) {\n p = resizeCover(p, v.crop.width, v.crop.height, toSharpPosition((v.crop.gravity as Gravity) ?? \"center\"));\n } else if (v.maxWidth || v.maxHeight) {\n p = resizeInside(p, v.maxWidth, v.maxHeight);\n }\n\n p = applyImageFormat(p, format, v.quality);\n\n const info = await p.toFile(outPath);\n\n outputs.push({\n key: v.key,\n url: `file://${outPath}`,\n path: outPath,\n mime: mimeForImageFormat(info.format),\n sizeBytes: info.size,\n width: info.width,\n height: info.height,\n });\n }\n\n // success: keep outputs\n tempFiles.length = 0;\n\n return {\n ok: true,\n mediaType: \"image\",\n outputs,\n meta: {\n mime: mimeForImageFormat(inputMeta.format),\n width: inputMeta.width,\n height: inputMeta.height,\n },\n };\n } catch (e: any) {\n return {\n ok: false,\n mediaType: \"image\",\n error: {\n code: \"processing_failed\",\n message: e?.message ? String(e.message) : \"Image processing failed\",\n details: { name: e?.name },\n },\n };\n } finally {\n if (tempFiles.length) {\n const { rm } = await import(\"node:fs/promises\");\n await Promise.all(tempFiles.map((f) => rm(f, { force: true }).catch(() => {})));\n }\n }\n}\n"]}
|
package/dist/image/resize.d.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import type { Sharp, Gravity as SharpGravity } from "sharp";
|
|
2
2
|
/** Logical crop/resize gravity, mapped to sharp positions by toSharpPosition. */
|
|
3
3
|
export type Gravity = "center" | "top" | "bottom" | "left" | "right";
|
|
4
4
|
/** Map a logical gravity to a sharp position string. Defaults to centre. */
|
|
5
|
-
export declare function toSharpPosition(g?: Gravity):
|
|
5
|
+
export declare function toSharpPosition(g?: Gravity): SharpGravity;
|
|
6
6
|
/**
|
|
7
7
|
* Cover-resize to an exact width/height (crop to fill), never enlarging beyond
|
|
8
8
|
* the source. Used for explicit crops and required-size normalization.
|
|
9
9
|
*/
|
|
10
|
-
export declare function resizeCover(pipeline:
|
|
10
|
+
export declare function resizeCover(pipeline: Sharp, width: number, height: number, position?: SharpGravity): Sharp;
|
|
11
11
|
/**
|
|
12
12
|
* Fit-inside resize bounded by maxWidth/maxHeight (preserves aspect ratio),
|
|
13
13
|
* never enlarging beyond the source. Either bound may be omitted.
|
|
14
14
|
*/
|
|
15
|
-
export declare function resizeInside(pipeline:
|
|
15
|
+
export declare function resizeInside(pipeline: Sharp, maxWidth?: number, maxHeight?: number): Sharp;
|
|
16
16
|
/**
|
|
17
17
|
* Compute a centered crop box that brings (width x height) to targetAspect,
|
|
18
18
|
* shrinking only the longer dimension. Returns sharp .extract() coordinates.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resize.d.ts","sourceRoot":"","sources":["../../src/image/resize.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"resize.d.ts","sourceRoot":"","sources":["../../src/image/resize.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,OAAO,CAAC;AAE5D,iFAAiF;AACjF,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;AAErE,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,CAAC,CAAC,EAAE,OAAO,GAAG,YAAY,CAczD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CACzB,QAAQ,EAAE,KAAK,EACf,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,QAAQ,GAAE,YAAuB,GAChC,KAAK,CAMP;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,KAAK,EACf,QAAQ,CAAC,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,KAAK,CAKP;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,MAAM,GACnB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAiB9D"}
|
package/dist/image/resize.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resize.js","sourceRoot":"","sources":["../../src/image/resize.ts"],"names":[],"mappings":"AAMA,4EAA4E;AAC5E,MAAM,UAAU,eAAe,CAAC,CAAW;IACzC,QAAQ,CAAC,EAAE,CAAC;QACV,KAAK,KAAK;YACR,OAAO,OAAO,CAAC;QACjB,KAAK,QAAQ;YACX,OAAO,OAAO,CAAC;QACjB,KAAK,MAAM;YACT,OAAO,MAAM,CAAC;QAChB,KAAK,OAAO;YACV,OAAO,MAAM,CAAC;QAChB,KAAK,QAAQ,CAAC;QACd;YACE,OAAO,QAAQ,CAAC;IACpB,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,WAAW,CACzB,
|
|
1
|
+
{"version":3,"file":"resize.js","sourceRoot":"","sources":["../../src/image/resize.ts"],"names":[],"mappings":"AAMA,4EAA4E;AAC5E,MAAM,UAAU,eAAe,CAAC,CAAW;IACzC,QAAQ,CAAC,EAAE,CAAC;QACV,KAAK,KAAK;YACR,OAAO,OAAO,CAAC;QACjB,KAAK,QAAQ;YACX,OAAO,OAAO,CAAC;QACjB,KAAK,MAAM;YACT,OAAO,MAAM,CAAC;QAChB,KAAK,OAAO;YACV,OAAO,MAAM,CAAC;QAChB,KAAK,QAAQ,CAAC;QACd;YACE,OAAO,QAAQ,CAAC;IACpB,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,WAAW,CACzB,QAAe,EACf,KAAa,EACb,MAAc,EACd,WAAyB,QAAQ;IAEjC,OAAO,QAAQ,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE;QACpC,GAAG,EAAE,OAAO;QACZ,QAAQ;QACR,kBAAkB,EAAE,IAAI;KACzB,CAAC,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,YAAY,CAC1B,QAAe,EACf,QAAiB,EACjB,SAAkB;IAElB,OAAO,QAAQ,CAAC,MAAM,CAAC,QAAQ,IAAI,IAAI,EAAE,SAAS,IAAI,IAAI,EAAE;QAC1D,GAAG,EAAE,QAAQ;QACb,kBAAkB,EAAE,IAAI;KACzB,CAAC,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAClC,KAAa,EACb,MAAc,EACd,YAAoB;IAEpB,MAAM,CAAC,GAAG,KAAK,GAAG,MAAM,CAAC;IACzB,IAAI,KAAK,GAAG,KAAK,CAAC;IAClB,IAAI,KAAK,GAAG,MAAM,CAAC;IAEnB,IAAI,CAAC,GAAG,YAAY,EAAE,CAAC;QACrB,2BAA2B;QAC3B,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,YAAY,CAAC,CAAC,CAAC;IACzD,CAAC;SAAM,CAAC;QACN,4BAA4B;QAC5B,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC;IACxD,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IAC1D,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IAE1D,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;AACpD,CAAC","sourcesContent":["// sharp resize helpers extracted from the image-processor\nimport type { Sharp, Gravity as SharpGravity } from \"sharp\";\n\n/** Logical crop/resize gravity, mapped to sharp positions by toSharpPosition. */\nexport type Gravity = \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\";\n\n/** Map a logical gravity to a sharp position string. Defaults to centre. */\nexport function toSharpPosition(g?: Gravity): SharpGravity {\n switch (g) {\n case \"top\":\n return \"north\";\n case \"bottom\":\n return \"south\";\n case \"left\":\n return \"west\";\n case \"right\":\n return \"east\";\n case \"center\":\n default:\n return \"centre\";\n }\n}\n\n/**\n * Cover-resize to an exact width/height (crop to fill), never enlarging beyond\n * the source. Used for explicit crops and required-size normalization.\n */\nexport function resizeCover(\n pipeline: Sharp,\n width: number,\n height: number,\n position: SharpGravity = \"centre\"\n): Sharp {\n return pipeline.resize(width, height, {\n fit: \"cover\",\n position,\n withoutEnlargement: true,\n });\n}\n\n/**\n * Fit-inside resize bounded by maxWidth/maxHeight (preserves aspect ratio),\n * never enlarging beyond the source. Either bound may be omitted.\n */\nexport function resizeInside(\n pipeline: Sharp,\n maxWidth?: number,\n maxHeight?: number\n): Sharp {\n return pipeline.resize(maxWidth ?? null, maxHeight ?? null, {\n fit: \"inside\",\n withoutEnlargement: true,\n });\n}\n\n/**\n * Compute a centered crop box that brings (width x height) to targetAspect,\n * shrinking only the longer dimension. Returns sharp .extract() coordinates.\n */\nexport function computeAspectCropBox(\n width: number,\n height: number,\n targetAspect: number\n): { left: number; top: number; width: number; height: number } {\n const a = width / height;\n let cropW = width;\n let cropH = height;\n\n if (a > targetAspect) {\n // too wide -> reduce width\n cropW = Math.max(1, Math.floor(height * targetAspect));\n } else {\n // too tall -> reduce height\n cropH = Math.max(1, Math.floor(width / targetAspect));\n }\n\n const left = Math.max(0, Math.floor((width - cropW) / 2));\n const top = Math.max(0, Math.floor((height - cropH) / 2));\n\n return { left, top, width: cropW, height: cropH };\n}\n"]}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** TOTAL attempts (initial + retries) — three totals means at most TWO delays. */
|
|
2
|
+
export declare const R2_REQUEST_MAX_ATTEMPTS = 3;
|
|
3
|
+
/** Base delay for full-jitter exponential backoff. */
|
|
4
|
+
export declare const R2_RETRY_BASE_DELAY_MS = 250;
|
|
5
|
+
/** Ceiling on the exponential backoff (before jitter). */
|
|
6
|
+
export declare const R2_RETRY_MAX_BACKOFF_MS = 5000;
|
|
7
|
+
/** Hard cap on an honored server `Retry-After` so one response can't eat the
|
|
8
|
+
* caller's whole timeout budget. */
|
|
9
|
+
export declare const R2_RETRY_AFTER_CAP_MS = 30000;
|
|
10
|
+
/** HTTP statuses treated as transient and safe to retry. */
|
|
11
|
+
export declare const R2_RETRYABLE_STATUSES: ReadonlySet<number>;
|
|
12
|
+
export type R2RetryableOperation = "putFile" | "copy" | "readToFile";
|
|
13
|
+
/** Deterministic seams for tests; production defaults are internal (see below). */
|
|
14
|
+
export interface R2RetrySeams {
|
|
15
|
+
/** Resolve after `ms` milliseconds. */
|
|
16
|
+
sleep: (ms: number) => Promise<void>;
|
|
17
|
+
/** Uniform random in [0, 1). */
|
|
18
|
+
random: () => number;
|
|
19
|
+
/** Epoch-millisecond clock — keeps HTTP-date `Retry-After` tests off wall-clock. */
|
|
20
|
+
now: () => number;
|
|
21
|
+
}
|
|
22
|
+
/** Production seam defaults. */
|
|
23
|
+
export declare function defaultR2RetrySeams(): R2RetrySeams;
|
|
24
|
+
/**
|
|
25
|
+
* True only for recognized transient transport failures. An explicit
|
|
26
|
+
* `AbortError` and deterministic local-filesystem errors always return false —
|
|
27
|
+
* an abort must never become another attempt.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isRetryableTransportError(err: unknown): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Parse a `Retry-After` header value into a non-negative millisecond delay.
|
|
32
|
+
* Supports both the delta-seconds form (`"120"`) and the HTTP-date form
|
|
33
|
+
* (`"Wed, 21 Oct 2015 07:28:00 GMT"`). Returns null when absent/unparseable.
|
|
34
|
+
* An HTTP-date in the past clamps to 0.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseRetryAfterMs(headerValue: string | null | undefined, nowMs: number): number | null;
|
|
37
|
+
/**
|
|
38
|
+
* Compute the pre-next-attempt delay. If the failed response carries a valid
|
|
39
|
+
* `Retry-After`, honor it (capped, no backoff added on top). Otherwise use
|
|
40
|
+
* full-jitter exponential backoff: `random(0, min(maxBackoff, base * 2^retryIndex))`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function computeR2DelayMs(params: {
|
|
43
|
+
response: Response | null;
|
|
44
|
+
/** 0-based index of the retry about to happen (0 for the first retry). */
|
|
45
|
+
retryIndex: number;
|
|
46
|
+
seams: R2RetrySeams;
|
|
47
|
+
}): number;
|
|
48
|
+
/**
|
|
49
|
+
* Thrown when an R2 operation exhausts its retries or hits a non-retryable
|
|
50
|
+
* response. Carries operation/bucket/key/attempt/status context and a
|
|
51
|
+
* bounded, sanitized response body — never credentials, authorization headers,
|
|
52
|
+
* or full signed URLs.
|
|
53
|
+
*/
|
|
54
|
+
export declare class R2StorageError extends Error {
|
|
55
|
+
readonly operation: R2RetryableOperation;
|
|
56
|
+
readonly bucket: string;
|
|
57
|
+
readonly key: string;
|
|
58
|
+
readonly attempts: number;
|
|
59
|
+
readonly status?: number;
|
|
60
|
+
readonly responseText?: string;
|
|
61
|
+
constructor(params: {
|
|
62
|
+
operation: R2RetryableOperation;
|
|
63
|
+
bucket: string;
|
|
64
|
+
key: string;
|
|
65
|
+
attempts: number;
|
|
66
|
+
status?: number;
|
|
67
|
+
responseText?: string;
|
|
68
|
+
cause?: unknown;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export type R2AttemptResult<T> = {
|
|
72
|
+
kind: "success";
|
|
73
|
+
value: T;
|
|
74
|
+
} | {
|
|
75
|
+
kind: "response";
|
|
76
|
+
response: Response;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Drive up to `R2_REQUEST_MAX_ATTEMPTS` attempts of a single storage operation.
|
|
80
|
+
* `runAttempt` builds a FRESH signed request each call and returns either a
|
|
81
|
+
* success value or the raw `Response` for the driver to classify. Thrown errors
|
|
82
|
+
* are classified: only recognized transient transport failures are retried.
|
|
83
|
+
*/
|
|
84
|
+
export declare function fetchWithR2Retry<T>(params: {
|
|
85
|
+
operation: R2RetryableOperation;
|
|
86
|
+
bucket: string;
|
|
87
|
+
key: string;
|
|
88
|
+
seams: R2RetrySeams;
|
|
89
|
+
runAttempt: (attemptNumber: number) => Promise<R2AttemptResult<T>>;
|
|
90
|
+
}): Promise<T>;
|
|
91
|
+
//# sourceMappingURL=r2-retry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"r2-retry.d.ts","sourceRoot":"","sources":["../../src/server/r2-retry.ts"],"names":[],"mappings":"AAgBA,kFAAkF;AAClF,eAAO,MAAM,uBAAuB,IAAI,CAAC;AACzC,sDAAsD;AACtD,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAC1C,0DAA0D;AAC1D,eAAO,MAAM,uBAAuB,OAAQ,CAAC;AAC7C;qCACqC;AACrC,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAE5C,4DAA4D;AAC5D,eAAO,MAAM,qBAAqB,EAAE,WAAW,CAAC,MAAM,CAA2C,CAAC;AAElG,MAAM,MAAM,oBAAoB,GAAG,SAAS,GAAG,MAAM,GAAG,YAAY,CAAC;AAErE,mFAAmF;AACnF,MAAM,WAAW,YAAY;IAC3B,uCAAuC;IACvC,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,gCAAgC;IAChC,MAAM,EAAE,MAAM,MAAM,CAAC;IACrB,oFAAoF;IACpF,GAAG,EAAE,MAAM,MAAM,CAAC;CACnB;AAED,gCAAgC;AAChC,wBAAgB,mBAAmB,IAAI,YAAY,CAMlD;AAsDD;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAwB/D;AAMD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiBtG;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE;IACvC,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC1B,0EAA0E;IAC1E,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,YAAY,CAAC;CACrB,GAAG,MAAM,CAYT;AAMD;;;;;GAKG;AACH,qBAAa,cAAe,SAAQ,KAAK;IACvC,QAAQ,CAAC,SAAS,EAAE,oBAAoB,CAAC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;gBAEnB,MAAM,EAAE;QAClB,SAAS,EAAE,oBAAoB,CAAC;QAChC,MAAM,EAAE,MAAM,CAAC;QACf,GAAG,EAAE,MAAM,CAAC;QACZ,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,KAAK,CAAC,EAAE,OAAO,CAAC;KACjB;CAeF;AAMD,MAAM,MAAM,eAAe,CAAC,CAAC,IACzB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC;AAyB7C;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,CAAC,EAAE,MAAM,EAAE;IAChD,SAAS,EAAE,oBAAoB,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,YAAY,CAAC;IACpB,UAAU,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC;CACpE,GAAG,OAAO,CAAC,CAAC,CAAC,CAoCb"}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Bounded transient-retry policy for the R2 object store.
|
|
2
|
+
//
|
|
3
|
+
// PRIVATE sibling of storage-ops.ts — imported ONLY by that module. It is
|
|
4
|
+
// deliberately NOT re-exported from the server barrel (`server/index.ts`); the
|
|
5
|
+
// only public surface is the `R2StorageError` type, which storage-ops re-exports
|
|
6
|
+
// so callers can recognize an exhausted storage operation.
|
|
7
|
+
//
|
|
8
|
+
// The policy applies to the idempotent-against-a-deterministic-key operations
|
|
9
|
+
// only (putFile, copy, readToFile). `delete` is intentionally one-shot and never
|
|
10
|
+
// routes through here. Signing/config failures, local filesystem errors, and
|
|
11
|
+
// deterministic HTTP 4xx responses are never retried.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Fixed internal policy — never exposed as tunables on the public factory.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/** TOTAL attempts (initial + retries) — three totals means at most TWO delays. */
|
|
16
|
+
export const R2_REQUEST_MAX_ATTEMPTS = 3;
|
|
17
|
+
/** Base delay for full-jitter exponential backoff. */
|
|
18
|
+
export const R2_RETRY_BASE_DELAY_MS = 250;
|
|
19
|
+
/** Ceiling on the exponential backoff (before jitter). */
|
|
20
|
+
export const R2_RETRY_MAX_BACKOFF_MS = 5_000;
|
|
21
|
+
/** Hard cap on an honored server `Retry-After` so one response can't eat the
|
|
22
|
+
* caller's whole timeout budget. */
|
|
23
|
+
export const R2_RETRY_AFTER_CAP_MS = 30_000;
|
|
24
|
+
/** HTTP statuses treated as transient and safe to retry. */
|
|
25
|
+
export const R2_RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
26
|
+
/** Production seam defaults. */
|
|
27
|
+
export function defaultR2RetrySeams() {
|
|
28
|
+
return {
|
|
29
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
30
|
+
random: Math.random,
|
|
31
|
+
now: Date.now,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Transient-error classification
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Recognized transient transport failure codes (undici/fetch surface these on
|
|
38
|
+
// the error or its `.cause`). A broken connection to R2 lands here.
|
|
39
|
+
const RETRYABLE_NETWORK_ERROR_CODES = new Set([
|
|
40
|
+
"ECONNRESET",
|
|
41
|
+
"ECONNREFUSED",
|
|
42
|
+
"ECONNABORTED",
|
|
43
|
+
"ETIMEDOUT",
|
|
44
|
+
"EPIPE",
|
|
45
|
+
"ENOTFOUND",
|
|
46
|
+
"EAI_AGAIN",
|
|
47
|
+
"ENETUNREACH",
|
|
48
|
+
"ENETDOWN",
|
|
49
|
+
"EHOSTUNREACH",
|
|
50
|
+
"EHOSTDOWN",
|
|
51
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
52
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
53
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
54
|
+
"UND_ERR_SOCKET",
|
|
55
|
+
]);
|
|
56
|
+
// Deterministic local filesystem errors are NEVER retried (disk full, perms,
|
|
57
|
+
// missing dir, etc.). This guard wins over any transport classification so a
|
|
58
|
+
// local write failure during readToFile piping can't be mistaken for transient.
|
|
59
|
+
const LOCAL_FS_ERROR_CODES = new Set([
|
|
60
|
+
"ENOSPC",
|
|
61
|
+
"EACCES",
|
|
62
|
+
"EISDIR",
|
|
63
|
+
"ENOENT",
|
|
64
|
+
"EROFS",
|
|
65
|
+
"EMFILE",
|
|
66
|
+
"ENFILE",
|
|
67
|
+
"EBADF",
|
|
68
|
+
"EEXIST",
|
|
69
|
+
"EPERM",
|
|
70
|
+
"ENAMETOOLONG",
|
|
71
|
+
"ELOOP",
|
|
72
|
+
"ENOTDIR",
|
|
73
|
+
"EDQUOT",
|
|
74
|
+
]);
|
|
75
|
+
function codeOf(err) {
|
|
76
|
+
if (err && typeof err === "object") {
|
|
77
|
+
const c = err.code;
|
|
78
|
+
if (typeof c === "string")
|
|
79
|
+
return c;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* True only for recognized transient transport failures. An explicit
|
|
85
|
+
* `AbortError` and deterministic local-filesystem errors always return false —
|
|
86
|
+
* an abort must never become another attempt.
|
|
87
|
+
*/
|
|
88
|
+
export function isRetryableTransportError(err) {
|
|
89
|
+
if (!err || typeof err !== "object")
|
|
90
|
+
return false;
|
|
91
|
+
const e = err;
|
|
92
|
+
// NEVER convert an explicit abort into another attempt.
|
|
93
|
+
if (e.name === "AbortError")
|
|
94
|
+
return false;
|
|
95
|
+
const selfCode = codeOf(err);
|
|
96
|
+
const causeCode = codeOf(e.cause);
|
|
97
|
+
// Deterministic local filesystem errors are not transient — decided first so a
|
|
98
|
+
// local write error can never be reclassified as a transport failure.
|
|
99
|
+
if (selfCode && LOCAL_FS_ERROR_CODES.has(selfCode))
|
|
100
|
+
return false;
|
|
101
|
+
if (causeCode && LOCAL_FS_ERROR_CODES.has(causeCode))
|
|
102
|
+
return false;
|
|
103
|
+
// Recognized transient network codes on the error or its cause.
|
|
104
|
+
if (selfCode && RETRYABLE_NETWORK_ERROR_CODES.has(selfCode))
|
|
105
|
+
return true;
|
|
106
|
+
if (causeCode && RETRYABLE_NETWORK_ERROR_CODES.has(causeCode))
|
|
107
|
+
return true;
|
|
108
|
+
// fetch()/undici surface transport failures as `TypeError: fetch failed`
|
|
109
|
+
// (often with an unrecognized or absent cause) — treat those as transient.
|
|
110
|
+
if (err instanceof TypeError || e.name === "TypeError")
|
|
111
|
+
return true;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Retry-After parsing + delay computation
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
/**
|
|
118
|
+
* Parse a `Retry-After` header value into a non-negative millisecond delay.
|
|
119
|
+
* Supports both the delta-seconds form (`"120"`) and the HTTP-date form
|
|
120
|
+
* (`"Wed, 21 Oct 2015 07:28:00 GMT"`). Returns null when absent/unparseable.
|
|
121
|
+
* An HTTP-date in the past clamps to 0.
|
|
122
|
+
*/
|
|
123
|
+
export function parseRetryAfterMs(headerValue, nowMs) {
|
|
124
|
+
if (headerValue == null)
|
|
125
|
+
return null;
|
|
126
|
+
const trimmed = headerValue.trim();
|
|
127
|
+
if (trimmed === "")
|
|
128
|
+
return null;
|
|
129
|
+
// delta-seconds form
|
|
130
|
+
if (/^\d+$/.test(trimmed)) {
|
|
131
|
+
const seconds = Number(trimmed);
|
|
132
|
+
if (!Number.isFinite(seconds) || seconds < 0)
|
|
133
|
+
return null;
|
|
134
|
+
return seconds * 1000;
|
|
135
|
+
}
|
|
136
|
+
// HTTP-date form
|
|
137
|
+
const dateMs = Date.parse(trimmed);
|
|
138
|
+
if (Number.isNaN(dateMs))
|
|
139
|
+
return null;
|
|
140
|
+
const delta = dateMs - nowMs;
|
|
141
|
+
return delta > 0 ? delta : 0;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Compute the pre-next-attempt delay. If the failed response carries a valid
|
|
145
|
+
* `Retry-After`, honor it (capped, no backoff added on top). Otherwise use
|
|
146
|
+
* full-jitter exponential backoff: `random(0, min(maxBackoff, base * 2^retryIndex))`.
|
|
147
|
+
*/
|
|
148
|
+
export function computeR2DelayMs(params) {
|
|
149
|
+
const { response, retryIndex, seams } = params;
|
|
150
|
+
if (response) {
|
|
151
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"), seams.now());
|
|
152
|
+
if (retryAfterMs !== null) {
|
|
153
|
+
return Math.min(retryAfterMs, R2_RETRY_AFTER_CAP_MS);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const ceiling = Math.min(R2_RETRY_MAX_BACKOFF_MS, R2_RETRY_BASE_DELAY_MS * 2 ** retryIndex);
|
|
157
|
+
return Math.floor(seams.random() * ceiling);
|
|
158
|
+
}
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Exhaustion error
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
/**
|
|
163
|
+
* Thrown when an R2 operation exhausts its retries or hits a non-retryable
|
|
164
|
+
* response. Carries operation/bucket/key/attempt/status context and a
|
|
165
|
+
* bounded, sanitized response body — never credentials, authorization headers,
|
|
166
|
+
* or full signed URLs.
|
|
167
|
+
*/
|
|
168
|
+
export class R2StorageError extends Error {
|
|
169
|
+
operation;
|
|
170
|
+
bucket;
|
|
171
|
+
key;
|
|
172
|
+
attempts;
|
|
173
|
+
status;
|
|
174
|
+
responseText;
|
|
175
|
+
constructor(params) {
|
|
176
|
+
const statusPart = params.status !== undefined ? ` status ${params.status}` : "";
|
|
177
|
+
const bodyPart = params.responseText ? ` ${params.responseText}` : "";
|
|
178
|
+
super(`R2 ${params.operation} failed for ${params.bucket}/${params.key} after ${params.attempts} attempt(s):${statusPart}${bodyPart}`.trim(), params.cause !== undefined ? { cause: params.cause } : undefined);
|
|
179
|
+
this.name = "R2StorageError";
|
|
180
|
+
this.operation = params.operation;
|
|
181
|
+
this.bucket = params.bucket;
|
|
182
|
+
this.key = params.key;
|
|
183
|
+
this.attempts = params.attempts;
|
|
184
|
+
this.status = params.status;
|
|
185
|
+
this.responseText = params.responseText;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Strip ASCII control characters (0x00-0x1F, 0x7F) and bound length. Done with a
|
|
189
|
+
// code-point filter rather than a control-character regex (which `no-control-regex`
|
|
190
|
+
// forbids and which would embed literal control bytes in source).
|
|
191
|
+
function sanitizeBodyText(text) {
|
|
192
|
+
let out = "";
|
|
193
|
+
for (const ch of text.slice(0, 1000)) {
|
|
194
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
195
|
+
out += code < 0x20 || code === 0x7f ? " " : ch;
|
|
196
|
+
}
|
|
197
|
+
return out.trim().slice(0, 500);
|
|
198
|
+
}
|
|
199
|
+
// Bounded, sanitized read of a failed response body. Also drains the body so
|
|
200
|
+
// the underlying socket can be reused before the next attempt. R2 error bodies
|
|
201
|
+
// are short XML and never contain our credentials; sanitize defensively regardless.
|
|
202
|
+
async function safeReadBoundedBody(res) {
|
|
203
|
+
try {
|
|
204
|
+
return sanitizeBodyText(await res.text());
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return "<no body>";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Drive up to `R2_REQUEST_MAX_ATTEMPTS` attempts of a single storage operation.
|
|
212
|
+
* `runAttempt` builds a FRESH signed request each call and returns either a
|
|
213
|
+
* success value or the raw `Response` for the driver to classify. Thrown errors
|
|
214
|
+
* are classified: only recognized transient transport failures are retried.
|
|
215
|
+
*/
|
|
216
|
+
export async function fetchWithR2Retry(params) {
|
|
217
|
+
const { operation, bucket, key, seams, runAttempt } = params;
|
|
218
|
+
for (let attempt = 1; attempt <= R2_REQUEST_MAX_ATTEMPTS; attempt++) {
|
|
219
|
+
let result;
|
|
220
|
+
try {
|
|
221
|
+
result = await runAttempt(attempt);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
// Signing failures, explicit aborts, and deterministic local-fs errors are
|
|
225
|
+
// never retried; only recognized transient transport failures are.
|
|
226
|
+
if (!isRetryableTransportError(err))
|
|
227
|
+
throw err;
|
|
228
|
+
if (attempt >= R2_REQUEST_MAX_ATTEMPTS) {
|
|
229
|
+
throw new R2StorageError({ operation, bucket, key, attempts: attempt, cause: err });
|
|
230
|
+
}
|
|
231
|
+
await seams.sleep(computeR2DelayMs({ response: null, retryIndex: attempt - 1, seams }));
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (result.kind === "success")
|
|
235
|
+
return result.value;
|
|
236
|
+
const response = result.response;
|
|
237
|
+
const status = response.status;
|
|
238
|
+
// Consume the failed body before the next attempt.
|
|
239
|
+
const responseText = await safeReadBoundedBody(response);
|
|
240
|
+
if (R2_RETRYABLE_STATUSES.has(status) && attempt < R2_REQUEST_MAX_ATTEMPTS) {
|
|
241
|
+
await seams.sleep(computeR2DelayMs({ response, retryIndex: attempt - 1, seams }));
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// Non-retryable status, or a retryable status with attempts exhausted.
|
|
245
|
+
throw new R2StorageError({ operation, bucket, key, attempts: attempt, status, responseText });
|
|
246
|
+
}
|
|
247
|
+
// Unreachable: the loop always returns or throws.
|
|
248
|
+
throw new R2StorageError({ operation, bucket, key, attempts: R2_REQUEST_MAX_ATTEMPTS });
|
|
249
|
+
}
|
|
250
|
+
//# sourceMappingURL=r2-retry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"r2-retry.js","sourceRoot":"","sources":["../../src/server/r2-retry.ts"],"names":[],"mappings":"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","sourcesContent":["// Bounded transient-retry policy for the R2 object store.\n//\n// PRIVATE sibling of storage-ops.ts — imported ONLY by that module. It is\n// deliberately NOT re-exported from the server barrel (`server/index.ts`); the\n// only public surface is the `R2StorageError` type, which storage-ops re-exports\n// so callers can recognize an exhausted storage operation.\n//\n// The policy applies to the idempotent-against-a-deterministic-key operations\n// only (putFile, copy, readToFile). `delete` is intentionally one-shot and never\n// routes through here. Signing/config failures, local filesystem errors, and\n// deterministic HTTP 4xx responses are never retried.\n\n// ---------------------------------------------------------------------------\n// Fixed internal policy — never exposed as tunables on the public factory.\n// ---------------------------------------------------------------------------\n\n/** TOTAL attempts (initial + retries) — three totals means at most TWO delays. */\nexport const R2_REQUEST_MAX_ATTEMPTS = 3;\n/** Base delay for full-jitter exponential backoff. */\nexport const R2_RETRY_BASE_DELAY_MS = 250;\n/** Ceiling on the exponential backoff (before jitter). */\nexport const R2_RETRY_MAX_BACKOFF_MS = 5_000;\n/** Hard cap on an honored server `Retry-After` so one response can't eat the\n * caller's whole timeout budget. */\nexport const R2_RETRY_AFTER_CAP_MS = 30_000;\n\n/** HTTP statuses treated as transient and safe to retry. */\nexport const R2_RETRYABLE_STATUSES: ReadonlySet<number> = new Set([408, 429, 500, 502, 503, 504]);\n\nexport type R2RetryableOperation = \"putFile\" | \"copy\" | \"readToFile\";\n\n/** Deterministic seams for tests; production defaults are internal (see below). */\nexport interface R2RetrySeams {\n /** Resolve after `ms` milliseconds. */\n sleep: (ms: number) => Promise<void>;\n /** Uniform random in [0, 1). */\n random: () => number;\n /** Epoch-millisecond clock — keeps HTTP-date `Retry-After` tests off wall-clock. */\n now: () => number;\n}\n\n/** Production seam defaults. */\nexport function defaultR2RetrySeams(): R2RetrySeams {\n return {\n sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),\n random: Math.random,\n now: Date.now,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Transient-error classification\n// ---------------------------------------------------------------------------\n\n// Recognized transient transport failure codes (undici/fetch surface these on\n// the error or its `.cause`). A broken connection to R2 lands here.\nconst RETRYABLE_NETWORK_ERROR_CODES: ReadonlySet<string> = new Set([\n \"ECONNRESET\",\n \"ECONNREFUSED\",\n \"ECONNABORTED\",\n \"ETIMEDOUT\",\n \"EPIPE\",\n \"ENOTFOUND\",\n \"EAI_AGAIN\",\n \"ENETUNREACH\",\n \"ENETDOWN\",\n \"EHOSTUNREACH\",\n \"EHOSTDOWN\",\n \"UND_ERR_CONNECT_TIMEOUT\",\n \"UND_ERR_HEADERS_TIMEOUT\",\n \"UND_ERR_BODY_TIMEOUT\",\n \"UND_ERR_SOCKET\",\n]);\n\n// Deterministic local filesystem errors are NEVER retried (disk full, perms,\n// missing dir, etc.). This guard wins over any transport classification so a\n// local write failure during readToFile piping can't be mistaken for transient.\nconst LOCAL_FS_ERROR_CODES: ReadonlySet<string> = new Set([\n \"ENOSPC\",\n \"EACCES\",\n \"EISDIR\",\n \"ENOENT\",\n \"EROFS\",\n \"EMFILE\",\n \"ENFILE\",\n \"EBADF\",\n \"EEXIST\",\n \"EPERM\",\n \"ENAMETOOLONG\",\n \"ELOOP\",\n \"ENOTDIR\",\n \"EDQUOT\",\n]);\n\nfunction codeOf(err: unknown): string | undefined {\n if (err && typeof err === \"object\") {\n const c = (err as { code?: unknown }).code;\n if (typeof c === \"string\") return c;\n }\n return undefined;\n}\n\n/**\n * True only for recognized transient transport failures. An explicit\n * `AbortError` and deterministic local-filesystem errors always return false —\n * an abort must never become another attempt.\n */\nexport function isRetryableTransportError(err: unknown): boolean {\n if (!err || typeof err !== \"object\") return false;\n const e = err as { name?: unknown; cause?: unknown };\n\n // NEVER convert an explicit abort into another attempt.\n if (e.name === \"AbortError\") return false;\n\n const selfCode = codeOf(err);\n const causeCode = codeOf(e.cause);\n\n // Deterministic local filesystem errors are not transient — decided first so a\n // local write error can never be reclassified as a transport failure.\n if (selfCode && LOCAL_FS_ERROR_CODES.has(selfCode)) return false;\n if (causeCode && LOCAL_FS_ERROR_CODES.has(causeCode)) return false;\n\n // Recognized transient network codes on the error or its cause.\n if (selfCode && RETRYABLE_NETWORK_ERROR_CODES.has(selfCode)) return true;\n if (causeCode && RETRYABLE_NETWORK_ERROR_CODES.has(causeCode)) return true;\n\n // fetch()/undici surface transport failures as `TypeError: fetch failed`\n // (often with an unrecognized or absent cause) — treat those as transient.\n if (err instanceof TypeError || e.name === \"TypeError\") return true;\n\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Retry-After parsing + delay computation\n// ---------------------------------------------------------------------------\n\n/**\n * Parse a `Retry-After` header value into a non-negative millisecond delay.\n * Supports both the delta-seconds form (`\"120\"`) and the HTTP-date form\n * (`\"Wed, 21 Oct 2015 07:28:00 GMT\"`). Returns null when absent/unparseable.\n * An HTTP-date in the past clamps to 0.\n */\nexport function parseRetryAfterMs(headerValue: string | null | undefined, nowMs: number): number | null {\n if (headerValue == null) return null;\n const trimmed = headerValue.trim();\n if (trimmed === \"\") return null;\n\n // delta-seconds form\n if (/^\\d+$/.test(trimmed)) {\n const seconds = Number(trimmed);\n if (!Number.isFinite(seconds) || seconds < 0) return null;\n return seconds * 1000;\n }\n\n // HTTP-date form\n const dateMs = Date.parse(trimmed);\n if (Number.isNaN(dateMs)) return null;\n const delta = dateMs - nowMs;\n return delta > 0 ? delta : 0;\n}\n\n/**\n * Compute the pre-next-attempt delay. If the failed response carries a valid\n * `Retry-After`, honor it (capped, no backoff added on top). Otherwise use\n * full-jitter exponential backoff: `random(0, min(maxBackoff, base * 2^retryIndex))`.\n */\nexport function computeR2DelayMs(params: {\n response: Response | null;\n /** 0-based index of the retry about to happen (0 for the first retry). */\n retryIndex: number;\n seams: R2RetrySeams;\n}): number {\n const { response, retryIndex, seams } = params;\n\n if (response) {\n const retryAfterMs = parseRetryAfterMs(response.headers.get(\"retry-after\"), seams.now());\n if (retryAfterMs !== null) {\n return Math.min(retryAfterMs, R2_RETRY_AFTER_CAP_MS);\n }\n }\n\n const ceiling = Math.min(R2_RETRY_MAX_BACKOFF_MS, R2_RETRY_BASE_DELAY_MS * 2 ** retryIndex);\n return Math.floor(seams.random() * ceiling);\n}\n\n// ---------------------------------------------------------------------------\n// Exhaustion error\n// ---------------------------------------------------------------------------\n\n/**\n * Thrown when an R2 operation exhausts its retries or hits a non-retryable\n * response. Carries operation/bucket/key/attempt/status context and a\n * bounded, sanitized response body — never credentials, authorization headers,\n * or full signed URLs.\n */\nexport class R2StorageError extends Error {\n readonly operation: R2RetryableOperation;\n readonly bucket: string;\n readonly key: string;\n readonly attempts: number;\n readonly status?: number;\n readonly responseText?: string;\n\n constructor(params: {\n operation: R2RetryableOperation;\n bucket: string;\n key: string;\n attempts: number;\n status?: number;\n responseText?: string;\n cause?: unknown;\n }) {\n const statusPart = params.status !== undefined ? ` status ${params.status}` : \"\";\n const bodyPart = params.responseText ? ` ${params.responseText}` : \"\";\n super(\n `R2 ${params.operation} failed for ${params.bucket}/${params.key} after ${params.attempts} attempt(s):${statusPart}${bodyPart}`.trim(),\n params.cause !== undefined ? { cause: params.cause } : undefined,\n );\n this.name = \"R2StorageError\";\n this.operation = params.operation;\n this.bucket = params.bucket;\n this.key = params.key;\n this.attempts = params.attempts;\n this.status = params.status;\n this.responseText = params.responseText;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Retry driver\n// ---------------------------------------------------------------------------\n\nexport type R2AttemptResult<T> =\n | { kind: \"success\"; value: T }\n | { kind: \"response\"; response: Response };\n\n// Strip ASCII control characters (0x00-0x1F, 0x7F) and bound length. Done with a\n// code-point filter rather than a control-character regex (which `no-control-regex`\n// forbids and which would embed literal control bytes in source).\nfunction sanitizeBodyText(text: string): string {\n let out = \"\";\n for (const ch of text.slice(0, 1000)) {\n const code = ch.codePointAt(0) ?? 0;\n out += code < 0x20 || code === 0x7f ? \" \" : ch;\n }\n return out.trim().slice(0, 500);\n}\n\n// Bounded, sanitized read of a failed response body. Also drains the body so\n// the underlying socket can be reused before the next attempt. R2 error bodies\n// are short XML and never contain our credentials; sanitize defensively regardless.\nasync function safeReadBoundedBody(res: Response): Promise<string> {\n try {\n return sanitizeBodyText(await res.text());\n } catch {\n return \"<no body>\";\n }\n}\n\n/**\n * Drive up to `R2_REQUEST_MAX_ATTEMPTS` attempts of a single storage operation.\n * `runAttempt` builds a FRESH signed request each call and returns either a\n * success value or the raw `Response` for the driver to classify. Thrown errors\n * are classified: only recognized transient transport failures are retried.\n */\nexport async function fetchWithR2Retry<T>(params: {\n operation: R2RetryableOperation;\n bucket: string;\n key: string;\n seams: R2RetrySeams;\n runAttempt: (attemptNumber: number) => Promise<R2AttemptResult<T>>;\n}): Promise<T> {\n const { operation, bucket, key, seams, runAttempt } = params;\n\n for (let attempt = 1; attempt <= R2_REQUEST_MAX_ATTEMPTS; attempt++) {\n let result: R2AttemptResult<T>;\n try {\n result = await runAttempt(attempt);\n } catch (err) {\n // Signing failures, explicit aborts, and deterministic local-fs errors are\n // never retried; only recognized transient transport failures are.\n if (!isRetryableTransportError(err)) throw err;\n if (attempt >= R2_REQUEST_MAX_ATTEMPTS) {\n throw new R2StorageError({ operation, bucket, key, attempts: attempt, cause: err });\n }\n await seams.sleep(computeR2DelayMs({ response: null, retryIndex: attempt - 1, seams }));\n continue;\n }\n\n if (result.kind === \"success\") return result.value;\n\n const response = result.response;\n const status = response.status;\n // Consume the failed body before the next attempt.\n const responseText = await safeReadBoundedBody(response);\n\n if (R2_RETRYABLE_STATUSES.has(status) && attempt < R2_REQUEST_MAX_ATTEMPTS) {\n await seams.sleep(computeR2DelayMs({ response, retryIndex: attempt - 1, seams }));\n continue;\n }\n\n // Non-retryable status, or a retryable status with attempts exhausted.\n throw new R2StorageError({ operation, bucket, key, attempts: attempt, status, responseText });\n }\n\n // Unreachable: the loop always returns or throws.\n throw new R2StorageError({ operation, bucket, key, attempts: R2_REQUEST_MAX_ATTEMPTS });\n}\n"]}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { getStorage } from "firebase-admin/storage";
|
|
2
|
+
export { R2StorageError } from "./r2-retry.js";
|
|
2
3
|
/** Firebase Admin Storage Bucket — inferred to avoid @google-cloud/storage dep. */
|
|
3
4
|
export type Bucket = ReturnType<ReturnType<typeof getStorage>["bucket"]>;
|
|
4
5
|
export interface ObjectWriteResult {
|
|
@@ -47,6 +48,12 @@ export interface CreateR2ObjectStoreArgs {
|
|
|
47
48
|
endpoint?: string;
|
|
48
49
|
/** Override fetch for tests. */
|
|
49
50
|
fetchImpl?: typeof fetch;
|
|
51
|
+
/** Test seam: override the retry sleep (defaults to real `setTimeout`). */
|
|
52
|
+
sleepImpl?: (ms: number) => Promise<void>;
|
|
53
|
+
/** Test seam: override the backoff jitter source (defaults to `Math.random`). */
|
|
54
|
+
randomImpl?: () => number;
|
|
55
|
+
/** Test seam: override the clock used for HTTP-date `Retry-After` (defaults to `Date.now`). */
|
|
56
|
+
nowImpl?: () => number;
|
|
50
57
|
}
|
|
51
58
|
export declare function createR2ObjectStore(args: CreateR2ObjectStoreArgs): MediaObjectStore;
|
|
52
59
|
export interface CreateEmulatorObjectStoreArgs {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage-ops.d.ts","sourceRoot":"","sources":["../../src/server/storage-ops.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"storage-ops.d.ts","sourceRoot":"","sources":["../../src/server/storage-ops.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AASzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAE/C,mFAAmF;AACnF,MAAM,MAAM,MAAM,GAAG,UAAU,CAAC,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;AAEzE,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,iDAAiD;IACjD,OAAO,CAAC,IAAI,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACpG,yCAAyC;IACzC,IAAI,CAAC,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACzE;;;;;;OAMG;IACH,UAAU,CAAC,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAChF,mEAAmE;IACnE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAQD,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gCAAgC;IAChC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C,iFAAiF;IACjF,UAAU,CAAC,EAAE,MAAM,MAAM,CAAC;IAC1B,+FAA+F;IAC/F,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC;CACxB;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,gBAAgB,CAqHnF;AAcD,MAAM,WAAW,6BAA6B;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf;6EACyE;IACzE,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,iCAAiC,CAAC,IAAI,EAAE,6BAA6B,GAAG,gBAAgB,CA2CvG;AAQD,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAsBlD"}
|
|
@@ -17,6 +17,10 @@ import { stat } from "node:fs/promises";
|
|
|
17
17
|
import { Readable } from "node:stream";
|
|
18
18
|
import { pipeline } from "node:stream/promises";
|
|
19
19
|
import { AwsClient } from "aws4fetch";
|
|
20
|
+
import { defaultR2RetrySeams, fetchWithR2Retry, } from "./r2-retry.js";
|
|
21
|
+
// Re-export the exhaustion error so callers can recognize an exhausted R2
|
|
22
|
+
// operation. The rest of the retry helper stays private to this module.
|
|
23
|
+
export { R2StorageError } from "./r2-retry.js";
|
|
20
24
|
const DEFAULT_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
21
25
|
export function createR2ObjectStore(args) {
|
|
22
26
|
const endpoint = (args.endpoint ?? `https://${args.accountId}.r2.cloudflarestorage.com`).replace(/\/$/, "");
|
|
@@ -27,6 +31,15 @@ export function createR2ObjectStore(args) {
|
|
|
27
31
|
region: "auto",
|
|
28
32
|
});
|
|
29
33
|
const doFetch = args.fetchImpl ?? fetch;
|
|
34
|
+
// Retry seams: production defaults are internal; tests inject deterministic
|
|
35
|
+
// sleep/random/now. putFile/copy/readToFile ride the bounded retry helper;
|
|
36
|
+
// delete stays one-shot (never routes through it — see below).
|
|
37
|
+
const defaults = defaultR2RetrySeams();
|
|
38
|
+
const seams = {
|
|
39
|
+
sleep: args.sleepImpl ?? defaults.sleep,
|
|
40
|
+
random: args.randomImpl ?? defaults.random,
|
|
41
|
+
now: args.nowImpl ?? defaults.now,
|
|
42
|
+
};
|
|
30
43
|
const objectUrl = (key) => `${endpoint}/${args.bucket}/${key.split("/").map(encodeURIComponent).join("/")}`;
|
|
31
44
|
async function signedFetch(url, init) {
|
|
32
45
|
const signed = await client.sign(url, { ...init, aws: { allHeaders: true } });
|
|
@@ -34,47 +47,80 @@ export function createR2ObjectStore(args) {
|
|
|
34
47
|
}
|
|
35
48
|
return {
|
|
36
49
|
async putFile({ localPath, key, contentType }) {
|
|
50
|
+
// stat() once, outside the retry loop: a missing input file is a
|
|
51
|
+
// deterministic local error and must fail immediately, never retry.
|
|
37
52
|
const { size } = await stat(localPath);
|
|
38
53
|
const ct = contentType ?? getMimeFromExt(extractExt(localPath));
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
return fetchWithR2Retry({
|
|
55
|
+
operation: "putFile",
|
|
56
|
+
bucket: args.bucket,
|
|
57
|
+
key,
|
|
58
|
+
seams,
|
|
59
|
+
// Fresh signed request AND a fresh read stream on every attempt — a
|
|
60
|
+
// consumed stream is never reused.
|
|
61
|
+
runAttempt: async () => {
|
|
62
|
+
const res = await signedFetch(objectUrl(key), {
|
|
63
|
+
method: "PUT",
|
|
64
|
+
headers: {
|
|
65
|
+
"content-type": ct,
|
|
66
|
+
"content-length": String(size),
|
|
67
|
+
"cache-control": DEFAULT_CACHE_CONTROL,
|
|
68
|
+
},
|
|
69
|
+
body: createReadStream(localPath),
|
|
70
|
+
});
|
|
71
|
+
if (res.ok) {
|
|
72
|
+
return { kind: "success", value: { key, sizeBytes: size, contentType: ct } };
|
|
73
|
+
}
|
|
74
|
+
return { kind: "response", response: res };
|
|
45
75
|
},
|
|
46
|
-
body: createReadStream(localPath),
|
|
47
76
|
});
|
|
48
|
-
if (!res.ok) {
|
|
49
|
-
throw new Error(`R2 putFile failed for ${key}: ${res.status} ${await safeText(res)}`);
|
|
50
|
-
}
|
|
51
|
-
return { key, sizeBytes: size, contentType: ct };
|
|
52
77
|
},
|
|
53
78
|
async copy({ fromKey, toKey }) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
79
|
+
// Preserve the exact x-amz-copy-source across every re-signed attempt.
|
|
80
|
+
const copySource = `/${args.bucket}/${fromKey.split("/").map(encodeURIComponent).join("/")}`;
|
|
81
|
+
return fetchWithR2Retry({
|
|
82
|
+
operation: "copy",
|
|
83
|
+
bucket: args.bucket,
|
|
84
|
+
key: toKey,
|
|
85
|
+
seams,
|
|
86
|
+
runAttempt: async () => {
|
|
87
|
+
const res = await signedFetch(objectUrl(toKey), {
|
|
88
|
+
method: "PUT",
|
|
89
|
+
headers: { "x-amz-copy-source": copySource },
|
|
90
|
+
});
|
|
91
|
+
if (res.ok) {
|
|
92
|
+
return { kind: "success", value: { key: toKey } };
|
|
93
|
+
}
|
|
94
|
+
return { kind: "response", response: res };
|
|
58
95
|
},
|
|
59
96
|
});
|
|
60
|
-
if (!res.ok) {
|
|
61
|
-
throw new Error(`R2 copy failed ${fromKey} -> ${toKey}: ${res.status} ${await safeText(res)}`);
|
|
62
|
-
}
|
|
63
|
-
return { key: toKey };
|
|
64
97
|
},
|
|
65
98
|
async readToFile({ key, localPath }) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
return fetchWithR2Retry({
|
|
100
|
+
operation: "readToFile",
|
|
101
|
+
bucket: args.bucket,
|
|
102
|
+
key,
|
|
103
|
+
seams,
|
|
104
|
+
runAttempt: async () => {
|
|
105
|
+
const res = await signedFetch(objectUrl(key), { method: "GET" });
|
|
106
|
+
if (!res.ok || !res.body) {
|
|
107
|
+
return { kind: "response", response: res };
|
|
108
|
+
}
|
|
109
|
+
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
|
|
110
|
+
// Pipe ONLY after a successful response, and open the destination in
|
|
111
|
+
// truncating mode ('w') on every attempt so a partial download from a
|
|
112
|
+
// prior attempt can never prefix/corrupt this one. res.body is a web
|
|
113
|
+
// ReadableStream; stream it to disk so large media never sits fully in
|
|
114
|
+
// memory. A transient body/network stream failure here throws and is
|
|
115
|
+
// retried by the helper; a deterministic local fs error is not.
|
|
116
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(localPath, { flags: "w" }));
|
|
117
|
+
const { size } = await stat(localPath);
|
|
118
|
+
return { kind: "success", value: { sizeBytes: size, contentType } };
|
|
119
|
+
},
|
|
120
|
+
});
|
|
76
121
|
},
|
|
77
122
|
async delete(key) {
|
|
123
|
+
// Intentionally one-shot: delete is never retried (see r2-retry.ts header).
|
|
78
124
|
const res = await signedFetch(objectUrl(key), { method: "DELETE" });
|
|
79
125
|
// S3 DELETE returns 204 even for missing keys; anything else non-ok is real.
|
|
80
126
|
if (!res.ok && res.status !== 404) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage-ops.js","sourceRoot":"","sources":["../../src/server/storage-ops.ts"],"names":[],"mappings":"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","sourcesContent":["// Final-media object-store chokepoint — every final-media write/copy/delete in\n// the system goes through a MediaObjectStore. Inline storage-provider calls for\n// FINAL media are forbidden outside this module. (Upload STAGING stays plain\n// Firebase Storage and is not this module's concern.)\n//\n// There are two implementations:\n// - R2 (S3-compatible) for deployed environments — the only place final user\n// media lives in prod/dev; bytes are served exclusively by the gateway Worker.\n// - Firebase Storage emulator for local dev/tests — objects get a FIXED\n// caller-supplied download token so deterministic emulator URLs work with\n// zero Cloudflare involvement. Production NEVER mints download tokens.\n//\n// Writes return `{ key }` — never URLs. Display URLs are built at render time\n// from asset refs (see the consuming app's media-asset-url helper).\n\nimport { createReadStream, createWriteStream } from \"node:fs\";\nimport { stat } from \"node:fs/promises\";\nimport { Readable } from \"node:stream\";\nimport { pipeline } from \"node:stream/promises\";\nimport { AwsClient } from \"aws4fetch\";\nimport type { getStorage } from \"firebase-admin/storage\";\n\n/** Firebase Admin Storage Bucket — inferred to avoid @google-cloud/storage dep. */\nexport type Bucket = ReturnType<ReturnType<typeof getStorage>[\"bucket\"]>;\n\nexport interface ObjectWriteResult {\n key: string;\n sizeBytes: number;\n contentType: string;\n}\n\nexport interface ObjectReadResult {\n sizeBytes: number;\n contentType: string;\n}\n\nexport interface MediaObjectStore {\n /** Upload a local file to the store at `key`. */\n putFile(args: { localPath: string; key: string; contentType?: string }): Promise<ObjectWriteResult>;\n /** Server-side copy within the store. */\n copy(args: { fromKey: string; toKey: string }): Promise<{ key: string }>;\n /**\n * Download an object's bytes from the store to a local file (streamed, so a\n * large video never sits fully in memory). Used by safety evidence capture to\n * pull prod media — which in deployed envs lives in R2 — before writing it into\n * the separate (GCS) evidence vault: capture is inherently read-here-then-write,\n * NOT a server-side copy, because source and vault are different backends.\n */\n readToFile(args: { key: string; localPath: string }): Promise<ObjectReadResult>;\n /** Delete an object. Missing objects are a no-op, not an error. */\n delete(key: string): Promise<void>;\n}\n\nconst DEFAULT_CACHE_CONTROL = \"public, max-age=31536000, immutable\";\n\n// ---------------------------------------------------------------------------\n// R2 (S3-compatible) store — deployed environments\n// ---------------------------------------------------------------------------\n\nexport interface CreateR2ObjectStoreArgs {\n accountId: string;\n accessKeyId: string;\n secretAccessKey: string;\n bucket: string;\n /** Override for tests; defaults to the real R2 S3 endpoint. */\n endpoint?: string;\n /** Override fetch for tests. */\n fetchImpl?: typeof fetch;\n}\n\nexport function createR2ObjectStore(args: CreateR2ObjectStoreArgs): MediaObjectStore {\n const endpoint = (args.endpoint ?? `https://${args.accountId}.r2.cloudflarestorage.com`).replace(/\\/$/, \"\");\n const client = new AwsClient({\n accessKeyId: args.accessKeyId,\n secretAccessKey: args.secretAccessKey,\n service: \"s3\",\n region: \"auto\",\n });\n const doFetch = args.fetchImpl ?? fetch;\n\n const objectUrl = (key: string) =>\n `${endpoint}/${args.bucket}/${key.split(\"/\").map(encodeURIComponent).join(\"/\")}`;\n\n async function signedFetch(url: string, init: RequestInit & { headers?: Record<string, string> }) {\n const signed = await client.sign(url, { ...init, aws: { allHeaders: true } } as any);\n return doFetch(signed, { ...(init.body ? { duplex: \"half\" } : {}) } as any);\n }\n\n return {\n async putFile({ localPath, key, contentType }) {\n const { size } = await stat(localPath);\n const ct = contentType ?? getMimeFromExt(extractExt(localPath));\n const res = await signedFetch(objectUrl(key), {\n method: \"PUT\",\n headers: {\n \"content-type\": ct,\n \"content-length\": String(size),\n \"cache-control\": DEFAULT_CACHE_CONTROL,\n },\n body: createReadStream(localPath) as unknown as BodyInit,\n });\n if (!res.ok) {\n throw new Error(`R2 putFile failed for ${key}: ${res.status} ${await safeText(res)}`);\n }\n return { key, sizeBytes: size, contentType: ct };\n },\n\n async copy({ fromKey, toKey }) {\n const res = await signedFetch(objectUrl(toKey), {\n method: \"PUT\",\n headers: {\n \"x-amz-copy-source\": `/${args.bucket}/${fromKey.split(\"/\").map(encodeURIComponent).join(\"/\")}`,\n },\n });\n if (!res.ok) {\n throw new Error(`R2 copy failed ${fromKey} -> ${toKey}: ${res.status} ${await safeText(res)}`);\n }\n return { key: toKey };\n },\n\n async readToFile({ key, localPath }) {\n const res = await signedFetch(objectUrl(key), { method: \"GET\" });\n if (!res.ok || !res.body) {\n throw new Error(`R2 readToFile failed for ${key}: ${res.status} ${await safeText(res)}`);\n }\n const contentType = res.headers.get(\"content-type\") ?? \"application/octet-stream\";\n // res.body is a web ReadableStream; stream it to disk so large media never\n // sits fully in memory.\n await pipeline(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]), createWriteStream(localPath));\n const { size } = await stat(localPath);\n return { sizeBytes: size, contentType };\n },\n\n async delete(key) {\n const res = await signedFetch(objectUrl(key), { method: \"DELETE\" });\n // S3 DELETE returns 204 even for missing keys; anything else non-ok is real.\n if (!res.ok && res.status !== 404) {\n throw new Error(`R2 delete failed for ${key}: ${res.status} ${await safeText(res)}`);\n }\n },\n };\n}\n\nasync function safeText(res: Response): Promise<string> {\n try {\n return (await res.text()).slice(0, 500);\n } catch {\n return \"<no body>\";\n }\n}\n\n// ---------------------------------------------------------------------------\n// Firebase Storage emulator store — local dev / tests only\n// ---------------------------------------------------------------------------\n\nexport interface CreateEmulatorObjectStoreArgs {\n bucket: Bucket;\n /** Fixed token attached to every object so deterministic emulator URLs work\n * (token URLs bypass rules in the emulator). Pass the app's constant. */\n downloadToken: string;\n}\n\nexport function createFirebaseEmulatorObjectStore(args: CreateEmulatorObjectStoreArgs): MediaObjectStore {\n const { bucket, downloadToken } = args;\n return {\n async putFile({ localPath, key, contentType }) {\n const { size } = await stat(localPath);\n const ct = contentType ?? getMimeFromExt(extractExt(localPath));\n await bucket.upload(localPath, {\n destination: key,\n metadata: {\n contentType: ct,\n cacheControl: DEFAULT_CACHE_CONTROL,\n metadata: { firebaseStorageDownloadTokens: downloadToken },\n },\n });\n return { key, sizeBytes: size, contentType: ct };\n },\n\n async copy({ fromKey, toKey }) {\n await bucket.file(fromKey).copy(bucket.file(toKey));\n await bucket.file(toKey).setMetadata({\n metadata: { firebaseStorageDownloadTokens: downloadToken },\n });\n return { key: toKey };\n },\n\n async readToFile({ key, localPath }) {\n const file = bucket.file(key);\n await file.download({ destination: localPath });\n const [meta] = await file.getMetadata();\n const { size } = await stat(localPath);\n const contentType = (meta.contentType as string | undefined) ?? \"application/octet-stream\";\n return { sizeBytes: size, contentType };\n },\n\n async delete(key) {\n try {\n await bucket.file(key).delete();\n } catch (e: any) {\n if (e?.code === 404 || /No such object/i.test(String(e?.message))) return;\n throw e;\n }\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n\nfunction extractExt(localPath: string): string {\n return localPath.split(\".\").pop() || \"\";\n}\n\nexport function getMimeFromExt(ext: string): string {\n switch (ext.toLowerCase()) {\n case \"jpg\":\n case \"jpeg\":\n return \"image/jpeg\";\n case \"png\":\n return \"image/png\";\n case \"webp\":\n return \"image/webp\";\n case \"mp4\":\n return \"video/mp4\";\n case \"webm\":\n return \"video/webm\";\n case \"m4a\":\n return \"audio/mp4\";\n case \"aac\":\n return \"audio/aac\";\n case \"mp3\":\n return \"audio/mpeg\";\n default:\n return \"application/octet-stream\";\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"storage-ops.js","sourceRoot":"","sources":["../../src/server/storage-ops.ts"],"names":[],"mappings":"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","sourcesContent":["// Final-media object-store chokepoint — every final-media write/copy/delete in\n// the system goes through a MediaObjectStore. Inline storage-provider calls for\n// FINAL media are forbidden outside this module. (Upload STAGING stays plain\n// Firebase Storage and is not this module's concern.)\n//\n// There are two implementations:\n// - R2 (S3-compatible) for deployed environments — the only place final user\n// media lives in prod/dev; bytes are served exclusively by the gateway Worker.\n// - Firebase Storage emulator for local dev/tests — objects get a FIXED\n// caller-supplied download token so deterministic emulator URLs work with\n// zero Cloudflare involvement. Production NEVER mints download tokens.\n//\n// Writes return `{ key }` — never URLs. Display URLs are built at render time\n// from asset refs (see the consuming app's media-asset-url helper).\n\nimport { createReadStream, createWriteStream } from \"node:fs\";\nimport { stat } from \"node:fs/promises\";\nimport { Readable } from \"node:stream\";\nimport { pipeline } from \"node:stream/promises\";\nimport { AwsClient } from \"aws4fetch\";\nimport type { getStorage } from \"firebase-admin/storage\";\nimport {\n defaultR2RetrySeams,\n fetchWithR2Retry,\n type R2RetrySeams,\n} from \"./r2-retry.js\";\n\n// Re-export the exhaustion error so callers can recognize an exhausted R2\n// operation. The rest of the retry helper stays private to this module.\nexport { R2StorageError } from \"./r2-retry.js\";\n\n/** Firebase Admin Storage Bucket — inferred to avoid @google-cloud/storage dep. */\nexport type Bucket = ReturnType<ReturnType<typeof getStorage>[\"bucket\"]>;\n\nexport interface ObjectWriteResult {\n key: string;\n sizeBytes: number;\n contentType: string;\n}\n\nexport interface ObjectReadResult {\n sizeBytes: number;\n contentType: string;\n}\n\nexport interface MediaObjectStore {\n /** Upload a local file to the store at `key`. */\n putFile(args: { localPath: string; key: string; contentType?: string }): Promise<ObjectWriteResult>;\n /** Server-side copy within the store. */\n copy(args: { fromKey: string; toKey: string }): Promise<{ key: string }>;\n /**\n * Download an object's bytes from the store to a local file (streamed, so a\n * large video never sits fully in memory). Used by safety evidence capture to\n * pull prod media — which in deployed envs lives in R2 — before writing it into\n * the separate (GCS) evidence vault: capture is inherently read-here-then-write,\n * NOT a server-side copy, because source and vault are different backends.\n */\n readToFile(args: { key: string; localPath: string }): Promise<ObjectReadResult>;\n /** Delete an object. Missing objects are a no-op, not an error. */\n delete(key: string): Promise<void>;\n}\n\nconst DEFAULT_CACHE_CONTROL = \"public, max-age=31536000, immutable\";\n\n// ---------------------------------------------------------------------------\n// R2 (S3-compatible) store — deployed environments\n// ---------------------------------------------------------------------------\n\nexport interface CreateR2ObjectStoreArgs {\n accountId: string;\n accessKeyId: string;\n secretAccessKey: string;\n bucket: string;\n /** Override for tests; defaults to the real R2 S3 endpoint. */\n endpoint?: string;\n /** Override fetch for tests. */\n fetchImpl?: typeof fetch;\n /** Test seam: override the retry sleep (defaults to real `setTimeout`). */\n sleepImpl?: (ms: number) => Promise<void>;\n /** Test seam: override the backoff jitter source (defaults to `Math.random`). */\n randomImpl?: () => number;\n /** Test seam: override the clock used for HTTP-date `Retry-After` (defaults to `Date.now`). */\n nowImpl?: () => number;\n}\n\nexport function createR2ObjectStore(args: CreateR2ObjectStoreArgs): MediaObjectStore {\n const endpoint = (args.endpoint ?? `https://${args.accountId}.r2.cloudflarestorage.com`).replace(/\\/$/, \"\");\n const client = new AwsClient({\n accessKeyId: args.accessKeyId,\n secretAccessKey: args.secretAccessKey,\n service: \"s3\",\n region: \"auto\",\n });\n const doFetch = args.fetchImpl ?? fetch;\n\n // Retry seams: production defaults are internal; tests inject deterministic\n // sleep/random/now. putFile/copy/readToFile ride the bounded retry helper;\n // delete stays one-shot (never routes through it — see below).\n const defaults = defaultR2RetrySeams();\n const seams: R2RetrySeams = {\n sleep: args.sleepImpl ?? defaults.sleep,\n random: args.randomImpl ?? defaults.random,\n now: args.nowImpl ?? defaults.now,\n };\n\n const objectUrl = (key: string) =>\n `${endpoint}/${args.bucket}/${key.split(\"/\").map(encodeURIComponent).join(\"/\")}`;\n\n async function signedFetch(url: string, init: RequestInit & { headers?: Record<string, string> }) {\n const signed = await client.sign(url, { ...init, aws: { allHeaders: true } } as any);\n return doFetch(signed, { ...(init.body ? { duplex: \"half\" } : {}) } as any);\n }\n\n return {\n async putFile({ localPath, key, contentType }) {\n // stat() once, outside the retry loop: a missing input file is a\n // deterministic local error and must fail immediately, never retry.\n const { size } = await stat(localPath);\n const ct = contentType ?? getMimeFromExt(extractExt(localPath));\n return fetchWithR2Retry({\n operation: \"putFile\",\n bucket: args.bucket,\n key,\n seams,\n // Fresh signed request AND a fresh read stream on every attempt — a\n // consumed stream is never reused.\n runAttempt: async () => {\n const res = await signedFetch(objectUrl(key), {\n method: \"PUT\",\n headers: {\n \"content-type\": ct,\n \"content-length\": String(size),\n \"cache-control\": DEFAULT_CACHE_CONTROL,\n },\n body: createReadStream(localPath) as unknown as BodyInit,\n });\n if (res.ok) {\n return { kind: \"success\" as const, value: { key, sizeBytes: size, contentType: ct } };\n }\n return { kind: \"response\" as const, response: res };\n },\n });\n },\n\n async copy({ fromKey, toKey }) {\n // Preserve the exact x-amz-copy-source across every re-signed attempt.\n const copySource = `/${args.bucket}/${fromKey.split(\"/\").map(encodeURIComponent).join(\"/\")}`;\n return fetchWithR2Retry({\n operation: \"copy\",\n bucket: args.bucket,\n key: toKey,\n seams,\n runAttempt: async () => {\n const res = await signedFetch(objectUrl(toKey), {\n method: \"PUT\",\n headers: { \"x-amz-copy-source\": copySource },\n });\n if (res.ok) {\n return { kind: \"success\" as const, value: { key: toKey } };\n }\n return { kind: \"response\" as const, response: res };\n },\n });\n },\n\n async readToFile({ key, localPath }) {\n return fetchWithR2Retry({\n operation: \"readToFile\",\n bucket: args.bucket,\n key,\n seams,\n runAttempt: async () => {\n const res = await signedFetch(objectUrl(key), { method: \"GET\" });\n if (!res.ok || !res.body) {\n return { kind: \"response\" as const, response: res };\n }\n const contentType = res.headers.get(\"content-type\") ?? \"application/octet-stream\";\n // Pipe ONLY after a successful response, and open the destination in\n // truncating mode ('w') on every attempt so a partial download from a\n // prior attempt can never prefix/corrupt this one. res.body is a web\n // ReadableStream; stream it to disk so large media never sits fully in\n // memory. A transient body/network stream failure here throws and is\n // retried by the helper; a deterministic local fs error is not.\n await pipeline(\n Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]),\n createWriteStream(localPath, { flags: \"w\" }),\n );\n const { size } = await stat(localPath);\n return { kind: \"success\" as const, value: { sizeBytes: size, contentType } };\n },\n });\n },\n\n async delete(key) {\n // Intentionally one-shot: delete is never retried (see r2-retry.ts header).\n const res = await signedFetch(objectUrl(key), { method: \"DELETE\" });\n // S3 DELETE returns 204 even for missing keys; anything else non-ok is real.\n if (!res.ok && res.status !== 404) {\n throw new Error(`R2 delete failed for ${key}: ${res.status} ${await safeText(res)}`);\n }\n },\n };\n}\n\nasync function safeText(res: Response): Promise<string> {\n try {\n return (await res.text()).slice(0, 500);\n } catch {\n return \"<no body>\";\n }\n}\n\n// ---------------------------------------------------------------------------\n// Firebase Storage emulator store — local dev / tests only\n// ---------------------------------------------------------------------------\n\nexport interface CreateEmulatorObjectStoreArgs {\n bucket: Bucket;\n /** Fixed token attached to every object so deterministic emulator URLs work\n * (token URLs bypass rules in the emulator). Pass the app's constant. */\n downloadToken: string;\n}\n\nexport function createFirebaseEmulatorObjectStore(args: CreateEmulatorObjectStoreArgs): MediaObjectStore {\n const { bucket, downloadToken } = args;\n return {\n async putFile({ localPath, key, contentType }) {\n const { size } = await stat(localPath);\n const ct = contentType ?? getMimeFromExt(extractExt(localPath));\n await bucket.upload(localPath, {\n destination: key,\n metadata: {\n contentType: ct,\n cacheControl: DEFAULT_CACHE_CONTROL,\n metadata: { firebaseStorageDownloadTokens: downloadToken },\n },\n });\n return { key, sizeBytes: size, contentType: ct };\n },\n\n async copy({ fromKey, toKey }) {\n await bucket.file(fromKey).copy(bucket.file(toKey));\n await bucket.file(toKey).setMetadata({\n metadata: { firebaseStorageDownloadTokens: downloadToken },\n });\n return { key: toKey };\n },\n\n async readToFile({ key, localPath }) {\n const file = bucket.file(key);\n await file.download({ destination: localPath });\n const [meta] = await file.getMetadata();\n const { size } = await stat(localPath);\n const contentType = (meta.contentType as string | undefined) ?? \"application/octet-stream\";\n return { sizeBytes: size, contentType };\n },\n\n async delete(key) {\n try {\n await bucket.file(key).delete();\n } catch (e: any) {\n if (e?.code === 404 || /No such object/i.test(String(e?.message))) return;\n throw e;\n }\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n\nfunction extractExt(localPath: string): string {\n return localPath.split(\".\").pop() || \"\";\n}\n\nexport function getMimeFromExt(ext: string): string {\n switch (ext.toLowerCase()) {\n case \"jpg\":\n case \"jpeg\":\n return \"image/jpeg\";\n case \"png\":\n return \"image/png\";\n case \"webp\":\n return \"image/webp\";\n case \"mp4\":\n return \"video/mp4\";\n case \"webm\":\n return \"video/webm\";\n case \"m4a\":\n return \"audio/mp4\";\n case \"aac\":\n return \"audio/aac\";\n case \"mp3\":\n return \"audio/mpeg\";\n default:\n return \"application/octet-stream\";\n }\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ttt-productions/media-processing-core",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/ttt-productions/ttt-packages.git",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"prepublishOnly": "npm run clean && npm run build"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ttt-productions/media-schemas": "^0.14.
|
|
34
|
+
"@ttt-productions/media-schemas": "^0.14.3",
|
|
35
35
|
"aws4fetch": "^1.0.20",
|
|
36
|
-
"sharp": "^0.
|
|
36
|
+
"sharp": "^0.35.3"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"firebase-admin": ">=10.0.0"
|