From ca69fcf683379eb05f5cae3d6fbd8c96f140948a Mon Sep 17 00:00:00 2001 From: Hemal Shah Date: Wed, 14 Oct 2026 13:42:02 +0100 Subject: [PATCH 1/3] feat: Add wrangler to pulumi worker config transformation --- .changeset/worker-wrangler-config.md | 5 + packages/cloudflare/package.json | 1 + .../src/construct/worker-site/main.ts | 121 +++++++++++++++++- .../src/construct/worker-site/types.ts | 7 + .../cloudflare/src/services/worker/types.ts | 8 ++ .../test/construct/worker-site.test.ts | 81 ++++++++++++ pnpm-lock.yaml | 3 + 7 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 .changeset/worker-wrangler-config.md diff --git a/.changeset/worker-wrangler-config.md b/.changeset/worker-wrangler-config.md new file mode 100644 index 000000000..5607b0113 --- /dev/null +++ b/.changeset/worker-wrangler-config.md @@ -0,0 +1,5 @@ +--- +'@gradientedge/cdk-utils-cloudflare': patch +--- + +Add reusable Worker asset path resolution, Wrangler base configuration translation, and opt-in workers.dev subdomain control to CloudflareWorkerSite. diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 114f0dcca..b35f2efcc 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -29,6 +29,7 @@ "@pulumi/std": "2.3.2", "@types/lodash": "4.17.25", "app-root-path": "3.1.0", + "jsonc-parser": "3.3.1", "lodash": "4.18.1" }, "keywords": [ diff --git a/packages/cloudflare/src/construct/worker-site/main.ts b/packages/cloudflare/src/construct/worker-site/main.ts index e03f1d355..ebef12954 100644 --- a/packages/cloudflare/src/construct/worker-site/main.ts +++ b/packages/cloudflare/src/construct/worker-site/main.ts @@ -3,10 +3,19 @@ import * as path from 'path' import * as aws from '@pulumi/aws' import * as azure from '@pulumi/azure-native' -import { GetZoneResult, Ruleset, WorkersCustomDomain, WorkersScript, Zone, ZoneSetting } from '@pulumi/cloudflare' +import { + GetZoneResult, + Ruleset, + WorkersCustomDomain, + WorkersScript, + WorkersScriptSubdomain, + Zone, + ZoneSetting, +} from '@pulumi/cloudflare' import { WorkersScriptBinding } from '@pulumi/cloudflare/types/input.js' import { ComponentResourceOptions, Output } from '@pulumi/pulumi' import * as std from '@pulumi/std' +import { parse, ParseError } from 'jsonc-parser' import { CommonCloudflareConstruct } from '../../common/index.js' @@ -38,6 +47,8 @@ export class CloudflareWorkerSite extends CommonCloudflareConstruct { siteWorkerScript: WorkersScript /** The Cloudflare Workers custom domain resource */ siteWorkerDomain: WorkersCustomDomain + /** Optional workers.dev subdomain configuration for the script */ + siteWorkerSubdomain: WorkersScriptSubdomain /** The Cloudflare ruleset resource for the worker site */ siteRuleSet: Ruleset /** The Cloudflare zone setting resource for the worker site */ @@ -64,10 +75,15 @@ export class CloudflareWorkerSite extends CommonCloudflareConstruct { * @summary Initialise and provision resources */ protected initResources() { + if (this.props.siteWranglerConfig || this.props.siteSource) this.translateWorkersBaseConfig() + this.resolveWorkerAssetPath() this.resolveZone() + this.createWorkerPlainTextBindingEnvironmentVariables() + this.createWorkerSecretTextBindingEnvironmentVariables() this.resolveEnvironmentVariables() this.createWorker() this.createWorkerDomain() + this.createWorkerSubdomain() this.createRuleset() this.createZoneSetting() } @@ -96,6 +112,96 @@ export class CloudflareWorkerSite extends CommonCloudflareConstruct { } } + protected resolveWorkerAssetPath() { + const cdkParentDir = path.resolve(process.cwd(), '..') + const fromCwd = path.resolve(cdkParentDir, this.props.siteWorkerAsset) + const fromDist = path.resolve(cdkParentDir, '..', this.props.siteWorkerAsset) + const workerAsset = [fromCwd, fromDist].find(candidate => fs.existsSync(candidate)) ?? fromCwd + this.props.siteWorkerAsset = path.relative(cdkParentDir, workerAsset) + } + + protected translateWorkersBaseConfig() { + const configSource = this.props.siteWranglerConfig ?? this.props.siteSource + if (!configSource) throw new Error('Worker Wrangler config path is required') + const configRelPath = configSource.replace(/^\.\.\//, '') + const fromCwd = path.resolve(process.cwd(), '..', configRelPath, 'wrangler.jsonc') + const fromDist = path.resolve(process.cwd(), '..', '..', configRelPath, 'wrangler.jsonc') + const configPath = fs.existsSync(fromCwd) ? fromCwd : fromDist + const configText = fs.readFileSync(configPath, 'utf8') + const parseErrors: ParseError[] = [] + const wranglerConfig = parse(configText, parseErrors) as { + name?: string + main?: string + compatibility_date?: string + compatibility_flags?: string[] + cache?: { enabled?: boolean } + observability?: { + enabled?: boolean + logs?: { enabled?: boolean; invocation_logs?: boolean; head_sampling_rate?: number } + traces?: { enabled?: boolean; head_sampling_rate?: number } + } + assets?: { directory?: string; binding?: string; not_found_handling?: string } + vars?: Record + } + + if (parseErrors.length > 0) { + throw new Error(`Failed to parse Wrangler base config at ${configPath}`) + } + + this.workerPlainTextBindingEnvironmentVariables = [ + ...Object.entries(wranglerConfig.vars ?? {}).map(([name, text]) => ({ name, text, type: 'plain_text' })), + ...(wranglerConfig.assets?.binding ? [{ name: wranglerConfig.assets.binding, type: 'assets' }] : []), + ] + this.props.siteWorkerScript = { + ...this.props.siteWorkerScript, + scriptName: this.props.siteWorkerScript.scriptName ?? wranglerConfig.name, + mainModule: wranglerConfig.main ?? this.props.siteWorkerScript?.mainModule, + compatibilityDate: wranglerConfig.compatibility_date ?? this.props.siteWorkerScript?.compatibilityDate, + compatibilityFlags: wranglerConfig.compatibility_flags ?? this.props.siteWorkerScript?.compatibilityFlags, + cacheOptions: wranglerConfig.cache + ? { enabled: wranglerConfig.cache.enabled } + : this.props.siteWorkerScript?.cacheOptions, + observability: wranglerConfig.observability + ? { + enabled: wranglerConfig.observability.enabled ?? false, + logs: wranglerConfig.observability.logs + ? { + enabled: wranglerConfig.observability.logs.enabled ?? wranglerConfig.observability.enabled ?? false, + invocationLogs: wranglerConfig.observability.logs.invocation_logs ?? true, + headSamplingRate: wranglerConfig.observability.logs.head_sampling_rate, + } + : undefined, + traces: wranglerConfig.observability.traces + ? { + enabled: wranglerConfig.observability.traces.enabled, + headSamplingRate: wranglerConfig.observability.traces.head_sampling_rate, + } + : undefined, + } + : this.props.siteWorkerScript?.observability, + assets: wranglerConfig.assets + ? { + directory: wranglerConfig.assets.directory + ? path.resolve(path.dirname(configPath), wranglerConfig.assets.directory) + : undefined, + config: { + notFoundHandling: wranglerConfig.assets.not_found_handling, + }, + } + : this.props.siteWorkerScript?.assets, + } + } + + /** + * @summary Create the plain text binding environment variables for the static site + */ + protected createWorkerPlainTextBindingEnvironmentVariables() {} + + /** + * @summary Create the secret text binding environment variables for the static site + */ + protected createWorkerSecretTextBindingEnvironmentVariables() {} + /** * @summary Create the worker */ @@ -126,6 +232,19 @@ export class CloudflareWorkerSite extends CommonCloudflareConstruct { ) } + protected createWorkerSubdomain() { + if (!this.props.siteWorkerSubdomain) return + this.siteWorkerSubdomain = new WorkersScriptSubdomain( + `${this.id}-worker-subdomain`, + { + ...this.props.siteWorkerSubdomain, + accountId: this.props.accountId, + scriptName: this.siteWorkerScript.scriptName, + }, + { parent: this, dependsOn: [this.siteWorkerScript] } + ) + } + /** * @summary Resolve secrets from AWS Secrets Manager * @param secretName the secret name diff --git a/packages/cloudflare/src/construct/worker-site/types.ts b/packages/cloudflare/src/construct/worker-site/types.ts index dd70209af..ecfc7182f 100644 --- a/packages/cloudflare/src/construct/worker-site/types.ts +++ b/packages/cloudflare/src/construct/worker-site/types.ts @@ -2,6 +2,7 @@ import { CommonCloudflareStackProps } from '../../common/index.js' import { WorkerDomainProps, WorkerScriptProps, + WorkerSubdomainProps, RulesetProps, ZoneProps, ZoneSettingProps, @@ -20,8 +21,14 @@ export interface CloudflareWorkerSiteProps extends CommonCloudflareStackProps { siteWorkerScript: WorkerScriptProps /** The Cloudflare Workers custom domain properties */ siteWorkerDomain: WorkerDomainProps + /** Optional workers.dev and preview URL settings */ + siteWorkerSubdomain?: WorkerSubdomainProps /** The relative path to the worker script asset file */ siteWorkerAsset: string + /** Optional path to the Worker project's base Wrangler configuration directory */ + siteWranglerConfig?: string + /** Legacy path to the Worker project's base Wrangler configuration directory */ + siteSource?: string /** The Cloudflare ruleset properties for the worker site */ siteRuleSet: RulesetProps /** The Cloudflare zone setting properties for the worker site */ diff --git a/packages/cloudflare/src/services/worker/types.ts b/packages/cloudflare/src/services/worker/types.ts index 97eeedd2d..5a40acb2b 100644 --- a/packages/cloudflare/src/services/worker/types.ts +++ b/packages/cloudflare/src/services/worker/types.ts @@ -5,6 +5,7 @@ import { WorkersKvNamespaceArgs, WorkersRouteArgs, WorkersScriptArgs, + WorkersScriptSubdomainArgs, } from '@pulumi/cloudflare' /** @@ -14,6 +15,13 @@ import { */ export interface WorkerDomainProps extends WorkersCustomDomainArgs {} +/** + * Properties for configuring a Cloudflare Worker's workers.dev subdomain + * @see [Pulumi Cloudflare WorkersScriptSubdomain]{@link https://www.pulumi.com/registry/packages/cloudflare/api-docs/workersscriptsubdomain/} + * @category Interface + */ +export interface WorkerSubdomainProps extends WorkersScriptSubdomainArgs {} + /** * Properties for creating a Cloudflare Workers Route * @see [Pulumi Cloudflare WorkersRoute]{@link https://www.pulumi.com/registry/packages/cloudflare/api-docs/workerroute/} diff --git a/packages/cloudflare/test/construct/worker-site.test.ts b/packages/cloudflare/test/construct/worker-site.test.ts index 7894b62c4..b8343f8a0 100644 --- a/packages/cloudflare/test/construct/worker-site.test.ts +++ b/packages/cloudflare/test/construct/worker-site.test.ts @@ -1,9 +1,12 @@ +import fs from 'fs' +import os from 'os' import * as path from 'path' import { fileURLToPath } from 'url' import { GetZoneResult, Zone } from '@pulumi/cloudflare' import * as pulumi from '@pulumi/pulumi' import { Output } from '@pulumi/pulumi' +import { vi } from 'vitest' import { CloudflareWorkerSite, CloudflareWorkerSiteProps, CommonCloudflareStack } from '../../src/index.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -112,6 +115,14 @@ class TestCommonConstruct extends CloudflareWorkerSite { super(name, props) this.initResources() } + + public testResolveWorkerAssetPath() { + this.resolveWorkerAssetPath() + } + + public testTranslateWorkersBaseConfig() { + this.translateWorkersBaseConfig() + } } class TestExistingZoneConstruct extends CloudflareWorkerSite { @@ -164,6 +175,76 @@ describe('TestCloudflareWorkerSite', () => { test('handles mis-configurations as expected', () => { expect(() => new TestInvalidCommonCloudflareStack('test-stack', testStackProps)).toThrow() }) + + test('only creates a workers.dev subdomain when configured', async () => { + expect(stack.construct.siteWorkerSubdomain).toBeUndefined() + const worker = new TestCommonConstruct('test-subdomain-worker', { + ...stack.construct.props, + siteWorkerSubdomain: { + accountId: 'test-account', + scriptName: 'test-script-dev', + enabled: false, + previewsEnabled: false, + }, + }) + const accountId = worker.siteWorkerSubdomain.accountId as Output & { + promise: (withUnknowns?: boolean) => Promise + } + const scriptName = worker.siteWorkerSubdomain.scriptName as Output & { + promise: (withUnknowns?: boolean) => Promise + } + const enabled = worker.siteWorkerSubdomain.enabled as Output & { + promise: (withUnknowns?: boolean) => Promise + } + const previewsEnabled = worker.siteWorkerSubdomain.previewsEnabled as Output & { + promise: (withUnknowns?: boolean) => Promise + } + expect(await accountId.promise(true)).toBe('123456789012') + expect(await scriptName.promise(true)).toBe('test-script-dev') + expect(await enabled.promise(true)).toBe(false) + expect(await previewsEnabled.promise(true)).toBe(false) + }) + + test('resolves the Worker asset from a dist working directory', () => { + const originalAsset = stack.construct.props.siteWorkerAsset + const workingDirectory = process.cwd() + const expectedAsset = path.relative(workingDirectory, path.resolve(workingDirectory, '..', originalAsset)) + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(path.join(workingDirectory, 'dist')) + try { + stack.construct.testResolveWorkerAssetPath() + expect(stack.construct.props.siteWorkerAsset).toEqual(expectedAsset) + } finally { + cwd.mockRestore() + stack.construct.props.siteWorkerAsset = originalAsset + } + }) + + test('translates a base Wrangler JSONC config', () => { + const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'worker-site-config-')) + const originalScript = stack.construct.props.siteWorkerScript + const originalBindings = stack.construct.workerPlainTextBindingEnvironmentVariables + fs.writeFileSync( + path.join(configDirectory, 'wrangler.jsonc'), + '{ // Worker base config\n "main": "worker.js", "compatibility_date": "2026-04-19", "vars": {"HELLO": "world"}, "assets": {"directory": "./public", "binding": "ASSETS", "not_found_handling": "404-page"} }' + ) + try { + stack.construct.props.siteWranglerConfig = configDirectory + stack.construct.testTranslateWorkersBaseConfig() + expect(stack.construct.props.siteWorkerScript.mainModule).toEqual('worker.js') + expect(stack.construct.props.siteWorkerScript.compatibilityDate).toEqual('2026-04-19') + const assets = stack.construct.props.siteWorkerScript.assets as { directory?: string } | undefined + expect(assets?.directory).toEqual(path.join(configDirectory, 'public')) + expect(stack.construct.workerPlainTextBindingEnvironmentVariables).toEqual([ + { name: 'HELLO', text: 'world', type: 'plain_text' }, + { name: 'ASSETS', type: 'assets' }, + ]) + } finally { + stack.construct.props.siteWranglerConfig = undefined + stack.construct.props.siteWorkerScript = originalScript + stack.construct.workerPlainTextBindingEnvironmentVariables = originalBindings + fs.rmSync(configDirectory, { recursive: true, force: true }) + } + }) }) describe('TestCloudflareWorkerSite', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a20f35704..c015dff3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,9 @@ importers: app-root-path: specifier: 3.1.0 version: 3.1.0 + jsonc-parser: + specifier: 3.3.1 + version: 3.3.1 lodash: specifier: 4.18.1 version: 4.18.1 From 0eca3789c40db8a5191640104c3689584bb297ce Mon Sep 17 00:00:00 2001 From: Hemal Shah Date: Wed, 14 Oct 2026 14:03:54 +0100 Subject: [PATCH 2/3] feat: Add wrangler to pulumi worker config transformation --- packages/cloudflare/src/construct/worker-site/main.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/construct/worker-site/main.ts b/packages/cloudflare/src/construct/worker-site/main.ts index ebef12954..417c6808f 100644 --- a/packages/cloudflare/src/construct/worker-site/main.ts +++ b/packages/cloudflare/src/construct/worker-site/main.ts @@ -75,7 +75,7 @@ export class CloudflareWorkerSite extends CommonCloudflareConstruct { * @summary Initialise and provision resources */ protected initResources() { - if (this.props.siteWranglerConfig || this.props.siteSource) this.translateWorkersBaseConfig() + this.translateWorkersBaseConfig() this.resolveWorkerAssetPath() this.resolveZone() this.createWorkerPlainTextBindingEnvironmentVariables() @@ -121,6 +121,8 @@ export class CloudflareWorkerSite extends CommonCloudflareConstruct { } protected translateWorkersBaseConfig() { + if (!this.props.siteWranglerConfig && !this.props.siteSource) return + const configSource = this.props.siteWranglerConfig ?? this.props.siteSource if (!configSource) throw new Error('Worker Wrangler config path is required') const configRelPath = configSource.replace(/^\.\.\//, '') From 8eed8c53ced98a5e0e62e7b60a1dc4c312d02c9b Mon Sep 17 00:00:00 2001 From: Hemal Shah Date: Wed, 14 Oct 2026 14:05:03 +0100 Subject: [PATCH 3/3] feat: Add wrangler to pulumi worker config transformation --- .../cloudflare/src/construct/worker-site/main.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/cloudflare/src/construct/worker-site/main.ts b/packages/cloudflare/src/construct/worker-site/main.ts index 417c6808f..48bd248b3 100644 --- a/packages/cloudflare/src/construct/worker-site/main.ts +++ b/packages/cloudflare/src/construct/worker-site/main.ts @@ -15,7 +15,7 @@ import { import { WorkersScriptBinding } from '@pulumi/cloudflare/types/input.js' import { ComponentResourceOptions, Output } from '@pulumi/pulumi' import * as std from '@pulumi/std' -import { parse, ParseError } from 'jsonc-parser' +import { parse } from 'jsonc-parser' import { CommonCloudflareConstruct } from '../../common/index.js' @@ -121,17 +121,14 @@ export class CloudflareWorkerSite extends CommonCloudflareConstruct { } protected translateWorkersBaseConfig() { - if (!this.props.siteWranglerConfig && !this.props.siteSource) return - - const configSource = this.props.siteWranglerConfig ?? this.props.siteSource - if (!configSource) throw new Error('Worker Wrangler config path is required') + const configSource = this.props.siteWranglerConfig || this.props.siteSource + if (!configSource) return const configRelPath = configSource.replace(/^\.\.\//, '') const fromCwd = path.resolve(process.cwd(), '..', configRelPath, 'wrangler.jsonc') const fromDist = path.resolve(process.cwd(), '..', '..', configRelPath, 'wrangler.jsonc') const configPath = fs.existsSync(fromCwd) ? fromCwd : fromDist const configText = fs.readFileSync(configPath, 'utf8') - const parseErrors: ParseError[] = [] - const wranglerConfig = parse(configText, parseErrors) as { + const wranglerConfig = parse(configText) as { name?: string main?: string compatibility_date?: string @@ -146,10 +143,6 @@ export class CloudflareWorkerSite extends CommonCloudflareConstruct { vars?: Record } - if (parseErrors.length > 0) { - throw new Error(`Failed to parse Wrangler base config at ${configPath}`) - } - this.workerPlainTextBindingEnvironmentVariables = [ ...Object.entries(wranglerConfig.vars ?? {}).map(([name, text]) => ({ name, text, type: 'plain_text' })), ...(wranglerConfig.assets?.binding ? [{ name: wranglerConfig.assets.binding, type: 'assets' }] : []),