From 9e11bfa413d3f421aec6b5c5a927c2b2f350071b Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 1 Sep 2026 13:30:26 +0100 Subject: [PATCH 1/2] fix(storage-resize-images): degrade gracefully against a partial env The extension read process.env directly with fallbacks, so a missing variable degraded gracefully; the kit's params layer turned two of those cases into cold-start crashes (#2974's runtime-contract table). Only reachable outside a normal CLI deploy -- library consumers driving the params entry point, emulator runs, or a hand-rolled .env. - IMAGE_TYPE: ListParam.value() JSON-parses the raw var, so an unset value threw SyntaxError. configFromEnv now returns undefined when the var is absent (the resolver falls back to the default image type) and hands non-JSON input through as a raw string, which the resolver's comma-splitting toArray handles -- an extension-style `jpeg,webp` value now works instead of crashing. - FUNCTION_MEMORY: IntParam yields 0 when unset or non-numeric, and normalizeMemory passed "0" through as an invalid memory option. The 0 sentinel now maps to undefined, so the resolver applies its 1GiB default. Not changed: SHARP_OPTIONS ("" already degrades via `|| "{}"` in the resolver) and IS_ANIMATED / REGENERATE_TOKEN (unset reads false on both the extension and the kit). The suite's two crash-characterization tests are superseded by three graceful-degradation tests; the partial-env characterization test now documents the memory exception. --- kits/storage-resize-images/src/config.ts | 25 +++++++- .../tests/config.test.ts | 62 ++++++++++++------- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/kits/storage-resize-images/src/config.ts b/kits/storage-resize-images/src/config.ts index 9902641f79..4d1016035a 100644 --- a/kits/storage-resize-images/src/config.ts +++ b/kits/storage-resize-images/src/config.ts @@ -285,6 +285,24 @@ function optional(value: string): string | undefined { return value.length > 0 ? value : undefined; } +/** + * `ListParam.value()` JSON-parses the raw env var, so an unset IMAGE_TYPE (or + * an extension-style comma-separated list) throws at cold start. The extension + * read `process.env.IMAGE_TYPE` directly and degraded gracefully; do the same, + * handing non-JSON input to the resolver's comma-splitting `toArray`. + */ +function imageTypesFromEnv(): ReadonlyArray | string | undefined { + const raw = process.env.IMAGE_TYPE; + if (raw === undefined) { + return undefined; + } + try { + return params.imageTypes.value(); + } catch { + return raw; + } +} + export function configFromEnv(): ResizeImagesConfig { return { bucket: params.bucket.value(), @@ -296,11 +314,14 @@ export function configFromEnv(): ResizeImagesConfig { excludePathList: optional(params.excludePathList.value()), failedImagesPath: optional(params.failedImagesPath.value()), cacheControlHeader: optional(params.cacheControlHeader.value()), - imageTypes: params.imageTypes.value(), + imageTypes: imageTypesFromEnv(), outputOptions: optional(params.outputOptions.value()), sharpOptions: params.sharpOptions.value(), isAnimated: params.isAnimated.value(), - memory: params.memory.value(), + // IntParam yields 0 when FUNCTION_MEMORY is unset or non-numeric; the + // extension always supplied a value, so treat that as unset and let the + // resolver fall back to the default memory. + memory: params.memory.value() || undefined, regenerateToken: params.regenerateToken.value(), contentFilterLevel: params.contentFilterLevel.value() as ResizeImagesConfig["contentFilterLevel"], diff --git a/kits/storage-resize-images/tests/config.test.ts b/kits/storage-resize-images/tests/config.test.ts index 7ecaeef67d..dc53a6588e 100644 --- a/kits/storage-resize-images/tests/config.test.ts +++ b/kits/storage-resize-images/tests/config.test.ts @@ -102,6 +102,43 @@ describe("configFromEnv", () => { expect(config.projectId).toBe("extensions-testing"); }); + // The extension read process.env directly and degraded gracefully against a + // partial environment; the params layer must not turn that into a cold-start + // crash (ListParam JSON-parses IMAGE_TYPE, IntParam yields 0 for + // FUNCTION_MEMORY). + test("survives an unset IMAGE_TYPE", async () => { + delete process.env.IMAGE_TYPE; + const { configFromEnv } = await import("../src/config"); + const { resolveResizeImagesConfig } = await import("../src/export-config"); + + const config = configFromEnv(); + expect(config.imageTypes).toBeUndefined(); + expect(resolveResizeImagesConfig(config).imageTypes).toEqual(["false"]); + }); + + test("accepts an extension-style comma-separated IMAGE_TYPE", async () => { + process.env.IMAGE_TYPE = "jpeg,webp"; + const { configFromEnv } = await import("../src/config"); + const { resolveResizeImagesConfig } = await import("../src/export-config"); + + const config = configFromEnv(); + expect(config.imageTypes).toBe("jpeg,webp"); + expect(resolveResizeImagesConfig(config).imageTypes).toEqual([ + "jpeg", + "webp", + ]); + }); + + test("falls back to the default memory when FUNCTION_MEMORY is unset", async () => { + delete process.env.FUNCTION_MEMORY; + const { configFromEnv } = await import("../src/config"); + const { resolveResizeImagesConfig } = await import("../src/export-config"); + + const config = configFromEnv(); + expect(config.memory).toBeUndefined(); + expect(resolveResizeImagesConfig(config).memory).toBe("1GiB"); + }); + test("collapses unset optional strings to undefined", async () => { const { configFromEnv } = await import("../src/config"); const config = configFromEnv(); @@ -133,6 +170,8 @@ describe("configFromEnv", () => { // runtime contract. `.value()` reads only `process.env`; the declared // `default:` is written into the deployed `.env` by the CLI. A hand-rolled // or partial `.env` therefore yields these values, not the declared ones. + // (memory is the exception: configFromEnv maps IntParam's 0 sentinel to + // undefined so the resolver can apply its default.) delete process.env.IS_ANIMATED; delete process.env.REGENERATE_TOKEN; delete process.env.FUNCTION_MEMORY; @@ -143,31 +182,10 @@ describe("configFromEnv", () => { expect(config.isAnimated).toBe(false); expect(config.regenerateToken).toBe(false); - expect(config.memory).toBe(0); + expect(config.memory).toBeUndefined(); expect(config.sharpOptions).toBe(""); }); - test("a missing IMAGE_TYPE throws instead of falling back to its default", async () => { - // The list param JSON-parses the raw env var, so an absent IMAGE_TYPE is - // a cold-start crash — the kit's counterpart to the extension's - // `IMG_SIZES.split(",")` TypeError on a missing variable. - delete process.env.IMAGE_TYPE; - - const { configFromEnv } = await import("../src/config"); - expect(() => configFromEnv()).toThrow(SyntaxError); - }); - - test("IMAGE_TYPE is read as a JSON array, not a comma-separated string", async () => { - // The extension reads the same variable with `.split(",")`, so an - // extension-style value does not carry over. - process.env.IMAGE_TYPE = "jpeg,webp"; - const { configFromEnv } = await import("../src/config"); - expect(() => configFromEnv()).toThrow(SyntaxError); - - process.env.IMAGE_TYPE = '["jpeg","webp"]'; - expect(configFromEnv().imageTypes).toEqual(["jpeg", "webp"]); - }); - test("reads explicit values for every param", async () => { Object.assign(process.env, { IMG_SIZES: "200x200,400x400", From 44ae28778282b4daebe05e2a79f89a0b4a30acf7 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Mon, 7 Sep 2026 11:32:28 +0100 Subject: [PATCH 2/2] fix(storage-resize-images): preserve legacy image type --- kits/storage-resize-images/src/config.ts | 12 +++++++----- kits/storage-resize-images/tests/config.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/kits/storage-resize-images/src/config.ts b/kits/storage-resize-images/src/config.ts index 4d1016035a..b7850ed4d9 100644 --- a/kits/storage-resize-images/src/config.ts +++ b/kits/storage-resize-images/src/config.ts @@ -286,10 +286,11 @@ function optional(value: string): string | undefined { } /** - * `ListParam.value()` JSON-parses the raw env var, so an unset IMAGE_TYPE (or - * an extension-style comma-separated list) throws at cold start. The extension - * read `process.env.IMAGE_TYPE` directly and degraded gracefully; do the same, - * handing non-JSON input to the resolver's comma-splitting `toArray`. + * `ListParam.value()` JSON-parses the raw env var. Extension-style values may + * either throw (for example, `jpeg,webp`) or parse to a non-list and collapse + * to `[]` (notably the extension default, `false`). The extension read the raw + * value directly, so preserve it whenever the params layer cannot return a + * non-empty list and let the resolver's `toArray` apply legacy semantics. */ function imageTypesFromEnv(): ReadonlyArray | string | undefined { const raw = process.env.IMAGE_TYPE; @@ -297,7 +298,8 @@ function imageTypesFromEnv(): ReadonlyArray | string | undefined { return undefined; } try { - return params.imageTypes.value(); + const parsed = params.imageTypes.value(); + return parsed.length > 0 ? parsed : raw; } catch { return raw; } diff --git a/kits/storage-resize-images/tests/config.test.ts b/kits/storage-resize-images/tests/config.test.ts index dc53a6588e..966bf94961 100644 --- a/kits/storage-resize-images/tests/config.test.ts +++ b/kits/storage-resize-images/tests/config.test.ts @@ -116,6 +116,16 @@ describe("configFromEnv", () => { expect(resolveResizeImagesConfig(config).imageTypes).toEqual(["false"]); }); + test("accepts the extension-style IMAGE_TYPE=false default", async () => { + process.env.IMAGE_TYPE = "false"; + const { configFromEnv } = await import("../src/config"); + const { resolveResizeImagesConfig } = await import("../src/export-config"); + + const config = configFromEnv(); + expect(config.imageTypes).toBe("false"); + expect(resolveResizeImagesConfig(config).imageTypes).toEqual(["false"]); + }); + test("accepts an extension-style comma-separated IMAGE_TYPE", async () => { process.env.IMAGE_TYPE = "jpeg,webp"; const { configFromEnv } = await import("../src/config");