From 99a1f49d7c037a25d4a19a3fe3054337e7201864 Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:04:47 +0100 Subject: [PATCH 1/3] Miniflare config followups (#15130) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .changeset/default-analytics-engine-name.md | 8 ++++ .changeset/inherit-r2-creds-config.md | 7 +++ .../remove-miniflare-vitest-assets-flag.md | 7 +++ .changeset/remove-workflow-remote-config.md | 8 ++++ .../reshape-miniflare-r2-s3-credentials.md | 7 +++ packages/config/src/__tests__/convert.test.ts | 28 +++++++++-- packages/config/src/bindings.ts | 10 +++- packages/config/src/convert.ts | 8 ++++ packages/config/src/index.ts | 1 + packages/config/src/schema.ts | 21 ++++++-- packages/config/src/worker-definition.ts | 1 - .../src/deploy/helpers/config-diffs.ts | 6 --- .../src/deploy/helpers/print-bindings.ts | 5 +- packages/miniflare/src/config/schema.ts | 48 ++++++++----------- packages/miniflare/src/config/v4-convert.ts | 9 +++- packages/miniflare/src/config/v4-schema.ts | 2 - packages/miniflare/src/plugins/r2/index.ts | 13 +++-- packages/miniflare/test/config/schema.spec.ts | 4 ++ .../miniflare/test/config/v4-convert.spec.ts | 8 ++-- packages/miniflare/test/plugins/r2/s3.spec.ts | 42 ++++++++++++---- .../vitest-pool-workers/src/pool/config.ts | 2 - .../src/config/binding-local-support.ts | 2 +- .../workers-utils/src/config/environment.ts | 2 - .../workers-utils/src/config/validation.ts | 10 ---- packages/workers-utils/src/worker.ts | 1 - .../normalize-and-validate-config.test.ts | 30 ++++++++++-- .../deploy/get-remote-config-diff.test.ts | 1 - .../wrangler/src/__tests__/workflows.test.ts | 1 - packages/wrangler/src/dev/miniflare/index.ts | 34 ++++--------- 29 files changed, 209 insertions(+), 117 deletions(-) create mode 100644 .changeset/default-analytics-engine-name.md create mode 100644 .changeset/inherit-r2-creds-config.md create mode 100644 .changeset/remove-miniflare-vitest-assets-flag.md create mode 100644 .changeset/remove-workflow-remote-config.md create mode 100644 .changeset/reshape-miniflare-r2-s3-credentials.md diff --git a/.changeset/default-analytics-engine-name.md b/.changeset/default-analytics-engine-name.md new file mode 100644 index 00000000000..8420786e288 --- /dev/null +++ b/.changeset/default-analytics-engine-name.md @@ -0,0 +1,8 @@ +--- +"@cloudflare/config": patch +"miniflare": patch +--- + +Default local Analytics Engine dataset names in Miniflare + +Analytics Engine dataset bindings without an explicit `name` now fallback to the worker and binding name as a default. diff --git a/.changeset/inherit-r2-creds-config.md b/.changeset/inherit-r2-creds-config.md new file mode 100644 index 00000000000..122e4b6c7f1 --- /dev/null +++ b/.changeset/inherit-r2-creds-config.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/config": minor +--- + +Add R2 local S3 credentials to the shared config binding shape + +R2 bindings now support `localDev.experimentalS3Credentials`, matching Wrangler's existing local S3 endpoint credentials configuration. diff --git a/.changeset/remove-miniflare-vitest-assets-flag.md b/.changeset/remove-miniflare-vitest-assets-flag.md new file mode 100644 index 00000000000..4dd292d4332 --- /dev/null +++ b/.changeset/remove-miniflare-vitest-assets-flag.md @@ -0,0 +1,7 @@ +--- +"miniflare": major +--- + +Remove the deprecated `hasAssetsAndIsVitest` option + +This internal assets testing option is no longer supported. diff --git a/.changeset/remove-workflow-remote-config.md b/.changeset/remove-workflow-remote-config.md new file mode 100644 index 00000000000..cf0e03b3e11 --- /dev/null +++ b/.changeset/remove-workflow-remote-config.md @@ -0,0 +1,8 @@ +--- +"wrangler": patch +"@cloudflare/config": patch +--- + +Remove unsupported `remote` configuration from Workflow bindings + +Workflow bindings no longer accept `remote` in configuration, as remote Workflow bindings have never actually been supported. diff --git a/.changeset/reshape-miniflare-r2-s3-credentials.md b/.changeset/reshape-miniflare-r2-s3-credentials.md new file mode 100644 index 00000000000..cedee802224 --- /dev/null +++ b/.changeset/reshape-miniflare-r2-s3-credentials.md @@ -0,0 +1,7 @@ +--- +"miniflare": major +--- + +Change R2 local S3 credentials configuration + +R2 bindings now use `localDev.experimentalS3Credentials` instead of `s3Credentials` for local S3 endpoint credentials. diff --git a/packages/config/src/__tests__/convert.test.ts b/packages/config/src/__tests__/convert.test.ts index 2b37c46b344..94f4f50541a 100644 --- a/packages/config/src/__tests__/convert.test.ts +++ b/packages/config/src/__tests__/convert.test.ts @@ -247,15 +247,37 @@ describe("convertToWranglerConfig", () => { ]); }); - it("maps r2 with name and jurisdiction", ({ expect }) => { + it("maps r2 with name, jurisdiction, and local S3 credentials", ({ + expect, + }) => { const result = convertToWranglerConfig({ ...baseConfig, env: { - MY_R2: { type: "r2", name: "my-bucket", jurisdiction: "eu" }, + MY_R2: { + type: "r2", + name: "my-bucket", + jurisdiction: "eu", + localDev: { + experimentalS3Credentials: { + accessKeyId: "access-key", + secretAccessKey: "secret-key", + }, + }, + }, }, }); expect(result.r2_buckets).toEqual([ - { binding: "MY_R2", bucket_name: "my-bucket", jurisdiction: "eu" }, + { + binding: "MY_R2", + bucket_name: "my-bucket", + jurisdiction: "eu", + local_dev: { + experimental_s3_credentials: { + accessKeyId: "access-key", + secretAccessKey: "secret-key", + }, + }, + }, ]); }); diff --git a/packages/config/src/bindings.ts b/packages/config/src/bindings.ts index 8881ddd6353..f6275c2e7c3 100644 --- a/packages/config/src/bindings.ts +++ b/packages/config/src/bindings.ts @@ -377,6 +377,14 @@ interface R2BindingOptions { jurisdiction?: string; /** Whether the R2 bucket should be remote or not in local development. */ remote?: boolean; + /** Settings that only apply to local development. */ + localDev?: { + /** EXPERIMENTAL: credentials for the local S3-compatible endpoint. */ + experimentalS3Credentials?: { + accessKeyId: string; + secretAccessKey: string; + }; + }; } /** @@ -610,8 +618,6 @@ interface WorkflowBindingOptions { workerName: string; /** The exported class name of the Workflow. */ exportName: string; - /** Whether the Workflow binding should be remote or not in local development. */ - remote?: boolean; } /** diff --git a/packages/config/src/convert.ts b/packages/config/src/convert.ts index 94df3c2a126..ba55783116d 100644 --- a/packages/config/src/convert.ts +++ b/packages/config/src/convert.ts @@ -445,12 +445,20 @@ function convertBindingsAndAssets( break; } case "r2": { + const experimentalS3Credentials = + binding.localDev?.experimentalS3Credentials; r2Buckets.push( omitUndefined({ binding: name, bucket_name: binding.name, jurisdiction: binding.jurisdiction, remote: binding.remote, + local_dev: + experimentalS3Credentials === undefined + ? undefined + : { + experimental_s3_credentials: experimentalS3Credentials, + }, }) ); break; diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 2e8f55208d2..9a7b530beb4 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,5 +1,6 @@ export * from "./public"; export { + AnalyticsEngineDatasetBindingSchema, AssetsSchema, BindingSchema, BrowserBindingSchema, diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 6bb18cd9b0f..9802721437d 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -56,6 +56,22 @@ export const R2BindingSchema = z.strictObject({ name: z.string().optional(), jurisdiction: z.string().optional(), remote: z.boolean().optional(), + localDev: z + .strictObject({ + experimentalS3Credentials: z + // AWS SDK may add additional keys as internal metadata like `$source`. + .object({ + accessKeyId: z.string(), + secretAccessKey: z.string(), + }) + .optional(), + }) + .optional(), +}); + +export const AnalyticsEngineDatasetBindingSchema = z.strictObject({ + type: z.literal("analytics-engine-dataset"), + name: z.string().optional(), }); export const FlagshipBindingSchema = z.strictObject({ @@ -87,10 +103,7 @@ export const KnownBindingSchema = z.discriminatedUnion("type", [ namespace: z.string(), remote: z.boolean().optional(), }), - z.strictObject({ - type: z.literal("analytics-engine-dataset"), - name: z.string().optional(), - }), + AnalyticsEngineDatasetBindingSchema, z.strictObject({ type: z.literal("artifacts"), namespace: z.string(), diff --git a/packages/config/src/worker-definition.ts b/packages/config/src/worker-definition.ts index 5b046939bc1..fac295a2e22 100644 --- a/packages/config/src/worker-definition.ts +++ b/packages/config/src/worker-definition.ts @@ -67,7 +67,6 @@ export interface TypedWorkerDefinition< // >(options: { // workerName: TWorkerName; // exportName: TExportName; - // remote?: boolean; // }): TypedWorkflowBinding; } diff --git a/packages/deploy-helpers/src/deploy/helpers/config-diffs.ts b/packages/deploy-helpers/src/deploy/helpers/config-diffs.ts index 6b6c68b761a..51aae35d5a3 100644 --- a/packages/deploy-helpers/src/deploy/helpers/config-diffs.ts +++ b/packages/deploy-helpers/src/deploy/helpers/config-diffs.ts @@ -183,12 +183,6 @@ function removeRemoteConfigFieldFromBindings(normalizedConfig: Config): void { ); } - if (normalizedConfig.workflows?.length) { - normalizedConfig.workflows = normalizedConfig.workflows.map( - ({ remote: _, ...binding }) => binding - ); - } - if (normalizedConfig.dispatch_namespaces?.length) { normalizedConfig.dispatch_namespaces = normalizedConfig.dispatch_namespaces.map( diff --git a/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts b/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts index 38adb86a46f..def7654c75d 100644 --- a/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts +++ b/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts @@ -198,7 +198,7 @@ export function printBindings( if (workflows.length > 0) { output.push( - ...workflows.map(({ class_name, script_name, binding, remote }) => { + ...workflows.map(({ class_name, script_name, binding }) => { let value = class_name; if (script_name) { value += ` (defined in ${script_name})`; @@ -209,8 +209,7 @@ export function printBindings( type: getBindingTypeFriendlyName("workflow"), value: value, mode: getMode({ - isSimulatedLocally: - script_name && !context.remoteBindingsDisabled ? !remote : true, + isSimulatedLocally: true, }), }; }) diff --git a/packages/miniflare/src/config/schema.ts b/packages/miniflare/src/config/schema.ts index eba32df8683..bcabc861946 100644 --- a/packages/miniflare/src/config/schema.ts +++ b/packages/miniflare/src/config/schema.ts @@ -1,25 +1,26 @@ import path from "node:path"; import { + AnalyticsEngineDatasetBindingSchema, AssetsSchema as RawAssetsConfigSchema, BrowserBindingSchema, + D1BindingSchema, DurableObjectCreatedExportSchema, DurableObjectDeletedExportSchema, DurableObjectExpectingTransferExportSchema, DurableObjectRenamedExportSchema, DurableObjectTransferredExportSchema, + FlagshipBindingSchema, + HyperdriveBindingSchema, KnownBindingSchema, + KVBindingSchema, ModuleTypeSchema, OutputWorkerSchema, + QueueBindingSchema, + R2BindingSchema, UnsafeBindingSchema, WorkerBindingSchema, WorkerEntrypointExportSchema, - D1BindingSchema, - FlagshipBindingSchema, - KVBindingSchema, - QueueBindingSchema, - R2BindingSchema, TailConsumerSchema, - HyperdriveBindingSchema, validateSingletonBindings, } from "@cloudflare/config"; import { z } from "zod"; @@ -40,7 +41,6 @@ import type { UnsafeUniqueKey } from "../plugins/shared/constants"; import type { Log } from "../shared"; import type { WorkerRegistry } from "../shared/dev-registry-types"; import type { Awaitable } from "../workers"; -import type { S3Credentials } from "../workers/r2/constants"; import type * as http from "node:http"; const AbsolutePathSchema = z @@ -179,23 +179,6 @@ const MiniflareBrowserBindingSchema = BrowserBindingSchema.extend({ headful: z.boolean().optional(), }); -/** - * `s3Credentials` is a local-dev-only field (used to expose the bucket via the - * S3-compatible endpoint), so it lives here rather than in the shared config - * schema. The credentials shape is inlined (rather than a named schema) to keep - * it out of the bundled public API surface; consumers derive the type from the - * R2 binding via `Extract`. - */ -const MiniflareR2BindingSchema = R2BindingSchema.extend({ - s3Credentials: z - // Allow internal source metadata used when checking duplicate credentials. - .object({ - accessKeyId: z.string(), - secretAccessKey: z.string(), - }) - .optional() satisfies z.ZodType, -}); - const MiniflareHyperdriveBindingSchema = HyperdriveBindingSchema.omit({ localConnectionString: true, }).extend({ localConnectionString: z.string() }); @@ -235,7 +218,6 @@ const MiniflareWorkflowBindingSchema = z.strictObject({ const OVERRIDDEN_BASE_BINDING_SCHEMAS = [ BrowserBindingSchema, WorkerBindingSchema, - R2BindingSchema, HyperdriveBindingSchema, ] as const; @@ -252,7 +234,6 @@ const PassthroughBindingSchemas = KnownBindingSchema.options.filter( const MiniflareKnownBindingSchema = z.discriminatedUnion("type", [ MiniflareBrowserBindingSchema, - MiniflareR2BindingSchema, MiniflareHyperdriveBindingSchema, MiniflareWorkerBindingSchema, FetcherBindingSchema, @@ -283,12 +264,19 @@ const ParsedMiniflareFlagshipBindingSchema = FlagshipBindingSchema.omit({ id: z.string(), }); -const ParsedMiniflareR2BindingSchema = MiniflareR2BindingSchema.omit({ +const ParsedMiniflareR2BindingSchema = R2BindingSchema.omit({ name: true, }).extend({ name: z.string(), }); +const ParsedMiniflareAnalyticsEngineDatasetBindingSchema = + AnalyticsEngineDatasetBindingSchema.omit({ + name: true, + }).extend({ + name: z.string(), + }); + const ParsedMiniflareQueueBindingSchema = QueueBindingSchema.omit({ name: true, }).extend({ @@ -299,7 +287,8 @@ const OVERRIDDEN_PARSED_BINDING_SCHEMAS = [ KVBindingSchema, D1BindingSchema, FlagshipBindingSchema, - MiniflareR2BindingSchema, + R2BindingSchema, + AnalyticsEngineDatasetBindingSchema, QueueBindingSchema, ] as const; @@ -319,6 +308,7 @@ export const ParsedMiniflareKnownBindingSchema = z.discriminatedUnion("type", [ ParsedMiniflareD1BindingSchema, ParsedMiniflareFlagshipBindingSchema, ParsedMiniflareR2BindingSchema, + ParsedMiniflareAnalyticsEngineDatasetBindingSchema, ParsedMiniflareQueueBindingSchema, ...ParsedPassthroughBindingSchemas, ]); @@ -501,6 +491,7 @@ function defaultBindingIdentifiers( { ...binding, id: binding.id ?? defaultIdentifier }, ]; case "r2": + case "analytics-engine-dataset": case "queue": return [ bindingName, @@ -612,7 +603,6 @@ export const DevConfigSchema = z.strictObject({ /** Whether this Worker is 'public' - whether should be advertised in the dev registry * and whether it should be included in local obs capture. Defaults to `true`. */ unsafeRegisterWorker: z.boolean().default(true), - hasAssetsAndIsVitest: z.boolean().optional(), // TODO(soon): remove in favour of per-object `unsafeUniqueKey: kEphemeralUniqueKey` unsafeEphemeralDurableObjects: z.boolean().optional(), // Strip the CF-Connecting-IP header from outbound fetches diff --git a/packages/miniflare/src/config/v4-convert.ts b/packages/miniflare/src/config/v4-convert.ts index 140c67e8d32..367782c9ec2 100644 --- a/packages/miniflare/src/config/v4-convert.ts +++ b/packages/miniflare/src/config/v4-convert.ts @@ -180,7 +180,6 @@ function convertWorkerOptions( dev.unsafeEvalBinding = worker.unsafeEvalBinding; dev.useModuleFallbackService = worker.unsafeUseModuleFallbackService; dev.unsafeRegisterWorker = worker.unsafeRegisterWorker ?? true; - dev.hasAssetsAndIsVitest = worker.hasAssetsAndIsVitest; dev.unsafeEphemeralDurableObjects = worker.unsafeEphemeralDurableObjects; dev.stripCfConnectingIp = worker.stripCfConnectingIp; dev.zone = worker.zone; @@ -382,7 +381,13 @@ function addR2Bindings( env[bindingName] = { type: "r2", name: bucket.id, - s3Credentials: bucket.s3Credentials, + ...(bucket.s3Credentials === undefined + ? {} + : { + localDev: { + experimentalS3Credentials: bucket.s3Credentials, + }, + }), remote: isRemote(bucket.remoteProxyConnectionString), }; } diff --git a/packages/miniflare/src/config/v4-schema.ts b/packages/miniflare/src/config/v4-schema.ts index 72324e5c8b6..9f9cb299c21 100644 --- a/packages/miniflare/src/config/v4-schema.ts +++ b/packages/miniflare/src/config/v4-schema.ts @@ -368,7 +368,6 @@ const V4WorkerOptionsShapeSchema = z.object({ unsafeEvalBinding: z.string().optional(), unsafeUseModuleFallbackService: z.boolean().optional(), unsafeRegisterWorker: z.boolean().optional(), - hasAssetsAndIsVitest: z.boolean().optional(), tails: z.array(V4ServiceDesignatorSchema).optional(), streamingTails: z.array(V4ServiceDesignatorSchema).optional(), stripCfConnectingIp: z.boolean().default(true), @@ -782,7 +781,6 @@ export type V4WorkerOptionsShape = { unsafeEvalBinding?: string; unsafeUseModuleFallbackService?: boolean; unsafeRegisterWorker?: boolean; - hasAssetsAndIsVitest?: boolean; tails?: V4ServiceDesignator[]; streamingTails?: V4ServiceDesignator[]; stripCfConnectingIp?: boolean; diff --git a/packages/miniflare/src/plugins/r2/index.ts b/packages/miniflare/src/plugins/r2/index.ts index bf3bc3861c5..663d373a99f 100644 --- a/packages/miniflare/src/plugins/r2/index.ts +++ b/packages/miniflare/src/plugins/r2/index.ts @@ -26,7 +26,9 @@ import type { MiniflareBinding, ParsedWorkerOptions, Plugin } from "../shared"; /** Local-dev S3 credentials, derived from the parsed R2 binding. */ type R2S3Credentials = NonNullable< - Extract["s3Credentials"] + NonNullable< + Extract["localDev"] + >["experimentalS3Credentials"] >; export const R2_PLUGIN_NAME = "r2"; @@ -83,9 +85,10 @@ export function getR2S3Service( const credentialsById: Record = {}; for (const worker of allWorkerOpts) { for (const [, bucket] of getEnvBindingsOfType(worker.config, "r2")) { + const s3Credentials = bucket.localDev?.experimentalS3Credentials; if ( getRemoteProxyConnectionString(bucket, worker.dev) !== undefined || - bucket.s3Credentials === undefined + s3Credentials === undefined ) { continue; } @@ -94,8 +97,8 @@ export function getR2S3Service( const existing = credentialsById[id]; if ( existing !== undefined && - (existing.accessKeyId !== bucket.s3Credentials.accessKeyId || - existing.secretAccessKey !== bucket.s3Credentials.secretAccessKey) + (existing.accessKeyId !== s3Credentials.accessKeyId || + existing.secretAccessKey !== s3Credentials.secretAccessKey) ) { throw new MiniflareCoreError( "ERR_DIFFERENT_S3_CREDENTIALS", @@ -103,7 +106,7 @@ export function getR2S3Service( ); } - credentialsById[id] = bucket.s3Credentials; + credentialsById[id] = s3Credentials; } } diff --git a/packages/miniflare/test/config/schema.spec.ts b/packages/miniflare/test/config/schema.spec.ts index 9e281fad991..aaaab528476 100644 --- a/packages/miniflare/test/config/schema.spec.ts +++ b/packages/miniflare/test/config/schema.spec.ts @@ -91,6 +91,7 @@ describe("MiniflareWorkerConfigSchema", () => { DB: { type: "d1" }, FLAGS: { type: "flagship" }, BUCKET: { type: "r2" }, + AE: { type: "analytics-engine-dataset" }, QUEUE: { type: "queue" }, }, }); @@ -100,6 +101,7 @@ describe("MiniflareWorkerConfigSchema", () => { DB: { type: "d1", id: "DB-api" }, FLAGS: { type: "flagship", id: "FLAGS-api" }, BUCKET: { type: "r2", name: "BUCKET-api" }, + AE: { type: "analytics-engine-dataset", name: "AE-api" }, QUEUE: { type: "queue", name: "QUEUE-api" }, }); }); @@ -170,6 +172,7 @@ describe("MiniflareWorkerConfigSchema", () => { DB: { type: "d1", id: "custom-db" }, FLAGS: { type: "flagship", id: "custom-flags" }, BUCKET: { type: "r2", name: "custom-bucket" }, + AE: { type: "analytics-engine-dataset", name: "custom-dataset" }, QUEUE: { type: "queue", name: "custom-queue" }, }, }); @@ -179,6 +182,7 @@ describe("MiniflareWorkerConfigSchema", () => { DB: { type: "d1", id: "custom-db" }, FLAGS: { type: "flagship", id: "custom-flags" }, BUCKET: { type: "r2", name: "custom-bucket" }, + AE: { type: "analytics-engine-dataset", name: "custom-dataset" }, QUEUE: { type: "queue", name: "custom-queue" }, }); }); diff --git a/packages/miniflare/test/config/v4-convert.spec.ts b/packages/miniflare/test/config/v4-convert.spec.ts index 5c5758034ca..1c747cf3532 100644 --- a/packages/miniflare/test/config/v4-convert.spec.ts +++ b/packages/miniflare/test/config/v4-convert.spec.ts @@ -129,9 +129,11 @@ describe("convertV4MiniflareOptions", () => { R2: { type: "r2", name: "bucket", - s3Credentials: { - accessKeyId: "access-key", - secretAccessKey: "secret-key", + localDev: { + experimentalS3Credentials: { + accessKeyId: "access-key", + secretAccessKey: "secret-key", + }, }, }, QUEUE: { type: "queue", name: "queue" }, diff --git a/packages/miniflare/test/plugins/r2/s3.spec.ts b/packages/miniflare/test/plugins/r2/s3.spec.ts index 67a62e09695..788770000e4 100644 --- a/packages/miniflare/test/plugins/r2/s3.spec.ts +++ b/packages/miniflare/test/plugins/r2/s3.spec.ts @@ -56,16 +56,26 @@ const ctx = miniflareTest<{ BUCKET: R2Bucket }, MiniflareTestContext>( name: "", compatibilityDate: "2025-05-01", env: { - BUCKET: { type: "r2", name: "bucket", s3Credentials: CREDENTIALS }, + BUCKET: { + type: "r2", + name: "bucket", + localDev: { + experimentalS3Credentials: CREDENTIALS, + }, + }, OTHER: { type: "r2", name: "other-bucket", - s3Credentials: CREDENTIALS, + localDev: { + experimentalS3Credentials: CREDENTIALS, + }, }, THIRD: { type: "r2", name: "third-bucket", - s3Credentials: THIRD_CREDENTIALS, + localDev: { + experimentalS3Credentials: THIRD_CREDENTIALS, + }, }, }, }, @@ -2110,7 +2120,7 @@ test("sets CORS headers on cross-origin responses", async ({ expect }) => { expect(res.headers.get("Access-Control-Expose-Headers")).toBe("*"); }); -test("rejects different s3Credentials for the same bucket", async ({ +test("rejects different S3 credentials for the same bucket", async ({ expect, }) => { const mf = new Miniflare({ @@ -2122,7 +2132,13 @@ test("rejects different s3Credentials for the same bucket", async ({ compatibilityDate: "2025-05-01", manifest: singleModuleManifest("export default {};"), env: { - BUCKET: { type: "r2", name: "shared", s3Credentials: CREDENTIALS }, + BUCKET: { + type: "r2", + name: "shared", + localDev: { + experimentalS3Credentials: CREDENTIALS, + }, + }, }, }, }, @@ -2136,9 +2152,11 @@ test("rejects different s3Credentials for the same bucket", async ({ BUCKET: { type: "r2", name: "shared", - s3Credentials: { - accessKeyId: "B".repeat(32), - secretAccessKey: "other-secret", + localDev: { + experimentalS3Credentials: { + accessKeyId: "B".repeat(32), + secretAccessKey: "other-secret", + }, }, }, }, @@ -2171,7 +2189,13 @@ test("verifies signatures against the original host when `upstream` is set", asy "export default { fetch: () => new Response(null, { status: 404 }) };" ), env: { - BUCKET: { type: "r2", name: "bucket", s3Credentials: CREDENTIALS }, + BUCKET: { + type: "r2", + name: "bucket", + localDev: { + experimentalS3Credentials: CREDENTIALS, + }, + }, }, }, }, diff --git a/packages/vitest-pool-workers/src/pool/config.ts b/packages/vitest-pool-workers/src/pool/config.ts index a547cd9f716..4ef2c7b6a9a 100644 --- a/packages/vitest-pool-workers/src/pool/config.ts +++ b/packages/vitest-pool-workers/src/pool/config.ts @@ -406,8 +406,6 @@ async function parseCustomPoolOptions( // Some assets plumbing that should be hidden from the end user if (options.miniflare?.assets) { - // (Used to set the SELF binding to point to the router worker instead) - options.miniflare.hasAssetsAndIsVitest = true; options.miniflare.assets.routerConfig ??= {}; options.miniflare.assets.routerConfig.has_user_worker = Boolean( options.main diff --git a/packages/workers-utils/src/config/binding-local-support.ts b/packages/workers-utils/src/config/binding-local-support.ts index 3d36d001e87..22222557a2b 100644 --- a/packages/workers-utils/src/config/binding-local-support.ts +++ b/packages/workers-utils/src/config/binding-local-support.ts @@ -45,7 +45,7 @@ const BINDING_LOCAL_SUPPORT: Record< kv_namespace: "local-and-remote", r2_bucket: "local-and-remote", d1: "local-and-remote", - workflow: "local-and-remote", + workflow: "local-only", browser: "local-and-remote", images: "local-and-remote", stream: "local-and-remote", diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index 49cc8820f5e..608710bb41e 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -873,8 +873,6 @@ export type WorkflowBinding = { class_name: string; /** The script where the Workflow is defined (if it's external to this Worker) */ script_name?: string; - /** Whether the Workflow should be remote or not in local development */ - remote?: boolean; /** Optional limits for the Workflow */ limits?: { /** Maximum number of steps a Workflow instance can execute */ diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index 6c5518dd4bc..f899d3b4ddb 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -2926,15 +2926,6 @@ const validateWorkflowBinding: ValidatorFn = (diagnostics, field, value) => { isValid = false; } - if (!isOptionalProperty(value, "remote", "boolean")) { - diagnostics.errors.push( - `"${field}" bindings should, optionally, have a boolean "remote" field but got ${JSON.stringify( - value - )}.` - ); - isValid = false; - } - if (hasProperty(value, "schedules") && value.schedules !== undefined) { if (typeof value.schedules === "string") { if (value.schedules.length === 0) { @@ -3020,7 +3011,6 @@ const validateWorkflowBinding: ValidatorFn = (diagnostics, field, value) => { "name", "class_name", "script_name", - "remote", "limits", "schedules", ]); diff --git a/packages/workers-utils/src/worker.ts b/packages/workers-utils/src/worker.ts index 0ced23b3bd3..306bd9a23f2 100644 --- a/packages/workers-utils/src/worker.ts +++ b/packages/workers-utils/src/worker.ts @@ -193,7 +193,6 @@ export interface CfWorkflow { class_name: string; binding: string; script_name?: string; - remote?: boolean; raw?: boolean; limits?: { steps?: number; diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts index b18493b9444..d5365514072 100644 --- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts +++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts @@ -7592,7 +7592,6 @@ describe("normalizeAndValidateConfig()", () => { name: "my-workflow", class_name: "MyWorkflow", script_name: "my-script", - remote: true, limits: { steps: 100 }, }, ], @@ -7715,7 +7714,6 @@ describe("normalizeAndValidateConfig()", () => { name: "my-workflow", class_name: "MyWorkflow", script_name: 123, - remote: "yes", }, ], } as unknown as RawConfig, @@ -7727,8 +7725,32 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.hasErrors()).toBe(true); expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` "Processing wrangler configuration: - - "workflows[0]" bindings should, optionally, have a string "script_name" field but got {"binding":"MY_WORKFLOW","name":"my-workflow","class_name":"MyWorkflow","script_name":123,"remote":"yes"}. - - "workflows[0]" bindings should, optionally, have a boolean "remote" field but got {"binding":"MY_WORKFLOW","name":"my-workflow","class_name":"MyWorkflow","script_name":123,"remote":"yes"}." + - "workflows[0]" bindings should, optionally, have a string "script_name" field but got {"binding":"MY_WORKFLOW","name":"my-workflow","class_name":"MyWorkflow","script_name":123}." + `); + }); + + it("should warn if remote is configured", ({ expect }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + workflows: [ + { + binding: "MY_WORKFLOW", + name: "my-workflow", + class_name: "MyWorkflow", + remote: true, + }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.hasWarnings()).toBe(true); + expect(diagnostics.renderWarnings()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - Unexpected fields found in workflows[0] field: "remote"" `); }); diff --git a/packages/wrangler/src/__tests__/deploy/get-remote-config-diff.test.ts b/packages/wrangler/src/__tests__/deploy/get-remote-config-diff.test.ts index 8d97a73279c..9089b82278b 100644 --- a/packages/wrangler/src/__tests__/deploy/get-remote-config-diff.test.ts +++ b/packages/wrangler/src/__tests__/deploy/get-remote-config-diff.test.ts @@ -509,7 +509,6 @@ describe("getRemoteConfigsDiff", () => { binding: "MY_WORKFLOW", name: "my-workflow", class_name: "MyWorkflow", - remote: false, }, ], vpc_services: [ diff --git a/packages/wrangler/src/__tests__/workflows.test.ts b/packages/wrangler/src/__tests__/workflows.test.ts index fa5b27b085f..89fdb23c676 100644 --- a/packages/wrangler/src/__tests__/workflows.test.ts +++ b/packages/wrangler/src/__tests__/workflows.test.ts @@ -1026,7 +1026,6 @@ describe("wrangler workflows", () => { name: "my-workflow", class_name: "MyWorkflow", script_name: "external-script", - remote: true, }, ], }); diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 3c63fbec111..39b32356da6 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -345,47 +345,29 @@ function pipelineEntry( function hyperdriveEntry(hyperdrive: CfHyperdrive): [string, string] { return [hyperdrive.binding, hyperdrive.localConnectionString ?? ""]; } -function workflowEntry( - { - binding, - name, - class_name: className, - script_name: scriptName, - remote, - limits, - }: CfWorkflow, - remoteProxyConnectionString?: RemoteProxyConnectionString -): [ +function workflowEntry({ + binding, + name, + class_name: className, + script_name: scriptName, + limits, +}: CfWorkflow): [ string, { name: string; className: string; scriptName?: string; - remoteProxyConnectionString?: RemoteProxyConnectionString; stepLimit?: number; }, ] { const stepLimit = limits?.steps; - if (!remoteProxyConnectionString || !remote) { - return [ - binding, - { - name, - className, - scriptName, - ...(stepLimit !== undefined && { stepLimit }), - }, - ]; - } - return [ binding, { name, className, scriptName, - remoteProxyConnectionString, ...(stepLimit !== undefined && { stepLimit }), }, ]; @@ -911,7 +893,7 @@ export function buildMiniflareBindingOptions( ); } } - return workflowEntry(workflow, remoteProxyConnectionString); + return workflowEntry(workflow); }) ), secretsStoreSecrets: Object.fromEntries( From ef73a28c1e7a208d730c6de64566bc96f683ca7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20=C3=9Alehla?= <106835858+ondraulehla@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:09:17 +0200 Subject: [PATCH 2/3] [wrangler] Fix kv bulk put corrupting binary values in local mode (#15192) --- .changeset/kv-bulk-put-local-base64-binary.md | 9 +++++++ .../wrangler/src/__tests__/kv/local.test.ts | 26 +++++++++++++++++++ packages/wrangler/src/kv/index.ts | 4 ++- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 .changeset/kv-bulk-put-local-base64-binary.md diff --git a/.changeset/kv-bulk-put-local-base64-binary.md b/.changeset/kv-bulk-put-local-base64-binary.md new file mode 100644 index 00000000000..82658594da4 --- /dev/null +++ b/.changeset/kv-bulk-put-local-base64-binary.md @@ -0,0 +1,9 @@ +--- +"wrangler": patch +--- + +Fixes `kv bulk put` corrupting binary values written to local KV + +Values marked `base64: true` were stored incorrectly whenever they contained bytes that do not form valid UTF-8, which covers images, compressed data and most other binary payloads. A Worker reading such a key back under `wrangler dev` got a different, longer value than the one that was written: a 12 byte PNG header came back as 20 bytes. + +`kv bulk put` writes to local KV by default, so the plain command was the affected one. Remote writes were never affected, and neither were entries without `base64` or values written with `kv key put`. diff --git a/packages/wrangler/src/__tests__/kv/local.test.ts b/packages/wrangler/src/__tests__/kv/local.test.ts index 58e4f087d0e..a7595f854d9 100644 --- a/packages/wrangler/src/__tests__/kv/local.test.ts +++ b/packages/wrangler/src/__tests__/kv/local.test.ts @@ -2,6 +2,7 @@ import { writeFileSync } from "node:fs"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { describe, it, vi } from "vitest"; import { mockConsoleMethods } from "../helpers/mock-console"; +import { mockProcess } from "../helpers/mock-process"; import { runWrangler } from "../helpers/run-wrangler"; vi.unmock("undici"); @@ -9,6 +10,7 @@ vi.unmock("undici"); describe("kv", () => { runInTempDir(); const std = mockConsoleMethods(); + const proc = mockProcess(); describe("local", () => { it("should put local kv storage", async ({ expect }) => { @@ -217,6 +219,30 @@ describe("kv", () => { `); }); + it("should put binary values from base64 in local bulk kv storage", async ({ + expect, + }) => { + // Bytes that are not valid UTF-8, so a UTF-8 round trip would replace + // them with U+FFFD. The first eight are the PNG signature. + const binary = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x80, 0xff, 0xfe, + ]); + const keyValues = [ + { + key: "binary", + value: binary.toString("base64"), + base64: true, + }, + ]; + writeFileSync("./binary-keys.json", JSON.stringify(keyValues)); + await runWrangler( + `kv bulk put binary-keys.json --namespace-id binary-namespace-id` + ); + + await runWrangler(`kv key get binary --namespace-id binary-namespace-id`); + expect(proc.write).toEqual(binary); + }); + it("should delete local bulk kv storage", async ({ expect }) => { const keyValues = [ { diff --git a/packages/wrangler/src/kv/index.ts b/packages/wrangler/src/kv/index.ts index 404df2d2b15..f15cf9b1401 100644 --- a/packages/wrangler/src/kv/index.ts +++ b/packages/wrangler/src/kv/index.ts @@ -1064,7 +1064,9 @@ export const kvBulkPutCommand = createCommand({ for (const value of content) { let data = value.value; if (value.base64) { - data = Buffer.from(data, "base64").toString(); + // Put the raw bytes in, not a string. The value has to stay + // binary here so nothing decodes it on the way to the store. + data = Buffer.from(data, "base64"); } await namespace.put(value.key, data, { expiration: value.expiration, From 59872c41d4417d9b8c2efddb4b35662453efcaae Mon Sep 17 00:00:00 2001 From: Thomas Rubini Date: Tue, 18 Aug 2026 18:20:54 +0100 Subject: [PATCH 3/3] [wrangler] add `connect` wrangler config section (#14995) --- .changeset/connect-handlers.md | 30 +++ packages/config/src/__tests__/convert.test.ts | 46 +++++ packages/config/src/__tests__/schema.test.ts | 51 +++++ packages/config/src/convert.ts | 18 +- packages/config/src/public.ts | 1 + packages/config/src/schema.ts | 6 + packages/config/src/triggers.ts | 34 ++- packages/config/src/types.ts | 11 +- packages/miniflare/src/config/v4-convert.ts | 5 + packages/miniflare/src/config/v4-schema.ts | 12 ++ packages/miniflare/src/index.ts | 32 +++ .../miniflare/src/plugins/shared/constants.ts | 17 ++ .../miniflare/src/runtime/config/workerd.ts | 6 +- packages/miniflare/test/index.spec.ts | 47 +++++ packages/workers-utils/src/config/config.ts | 1 + .../workers-utils/src/config/environment.ts | 23 +++ .../workers-utils/src/config/validation.ts | 150 ++++++++++++++ packages/workers-utils/src/types.ts | 8 +- .../normalize-and-validate-config.test.ts | 193 ++++++++++++++++++ .../src/__tests__/type-generation.test.ts | 1 + .../api/startDevWorker/ConfigController.ts | 8 +- .../startDevWorker/LocalRuntimeController.ts | 5 + packages/wrangler/src/dev/miniflare/index.ts | 2 + 23 files changed, 695 insertions(+), 12 deletions(-) create mode 100644 .changeset/connect-handlers.md diff --git a/.changeset/connect-handlers.md b/.changeset/connect-handlers.md new file mode 100644 index 00000000000..777ac38e65d --- /dev/null +++ b/.changeset/connect-handlers.md @@ -0,0 +1,30 @@ +--- +"wrangler": minor +"miniflare": minor +"@cloudflare/workers-utils": minor +"@cloudflare/config": minor +--- + +Add `connect` trigger for raw sockets + +You can now configure a Worker to receive raw socket connections during `wrangler dev`, delivered directly to the Worker's `connect(socket, env, ctx)` handler: + +```jsonc +{ + "connect": [{ "protocol": "tcp", "port": 5432 }], +} +``` + +Each entry opens a listening socket on `127.0.0.1` (or the given `address`) that forwards incoming connections straight to the Worker, bypassing the local dev HTTP entry point. This requires the `experimental` compatibility flag. Only `"tcp"` is supported at the moment. + +`@cloudflare/config` also supports declaring this trigger via `triggers.connect(...)`, which lowers to the `connect` field above: + +```ts +import { defineWorker, triggers } from "@cloudflare/config"; + +export default defineWorker({ + triggers: [ + triggers.connect({ protocol: "tcp", port: 5432, address: "127.0.0.1" }), + ], +}); +``` diff --git a/packages/config/src/__tests__/convert.test.ts b/packages/config/src/__tests__/convert.test.ts index 94f4f50541a..c49497ac1ed 100644 --- a/packages/config/src/__tests__/convert.test.ts +++ b/packages/config/src/__tests__/convert.test.ts @@ -1021,6 +1021,52 @@ describe("convertToWranglerConfig", () => { consumers: [{ queue: "c-queue" }], }); }); + + it("maps connect trigger to connect", ({ expect }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + triggers: [ + { + type: "connect", + protocol: "tcp", + port: 5432, + address: "127.0.0.1", + }, + ], + }); + expect(result.connect).toEqual([ + { protocol: "tcp", port: 5432, address: "127.0.0.1" }, + ]); + }); + + it("maps connect trigger without an address", ({ expect }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + triggers: [{ type: "connect", protocol: "tcp", port: 5432 }], + }); + expect(result.connect).toEqual([{ protocol: "tcp", port: 5432 }]); + }); + + it("collects multiple connect triggers into a single connect array", ({ + expect, + }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + triggers: [ + { type: "connect", protocol: "tcp", port: 5432 }, + { + type: "connect", + protocol: "tcp", + port: 6379, + address: "0.0.0.0", + }, + ], + }); + expect(result.connect).toEqual([ + { protocol: "tcp", port: 5432 }, + { protocol: "tcp", port: 6379, address: "0.0.0.0" }, + ]); + }); }); describe("domains", () => { diff --git a/packages/config/src/__tests__/schema.test.ts b/packages/config/src/__tests__/schema.test.ts index e9b2487f134..7c6179d8bfc 100644 --- a/packages/config/src/__tests__/schema.test.ts +++ b/packages/config/src/__tests__/schema.test.ts @@ -391,6 +391,57 @@ describe("InputWorkerSchema", () => { } }); + it("accepts a connect trigger", ({ expect }) => { + const result = InputWorkerSchema.safeParse({ + ...baseConfig, + triggers: [ + { + type: "connect", + protocol: "tcp", + port: 5432, + address: "127.0.0.1", + }, + ], + }); + + expect(result.success).toBe(true); + }); + + it("rejects a connect trigger with an invalid protocol", ({ expect }) => { + const result = InputWorkerSchema.safeParse({ + ...baseConfig, + triggers: [{ type: "connect", protocol: "ftp", port: 5432 }], + }); + + expect(result.success).toBe(false); + }); + + it("rejects unknown keys inside a connect trigger", ({ expect }) => { + const result = InputWorkerSchema.safeParse({ + ...baseConfig, + triggers: [ + { + type: "connect", + protocol: "tcp", + port: 5432, + hostname: "127.0.0.1", + }, + ], + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find( + (i) => i.code === "unrecognized_keys" + ); + expect(issue).toBeDefined(); + expect(issue?.path).toEqual(["triggers", 0]); + expect((issue as { keys?: string[] } | undefined)?.keys).toContain( + "hostname" + ); + } + }); + it("still accepts unknown keys on `unsafe:*` bindings (looseObject escape hatch)", ({ expect, }) => { diff --git a/packages/config/src/convert.ts b/packages/config/src/convert.ts index ba55783116d..6d244a13445 100644 --- a/packages/config/src/convert.ts +++ b/packages/config/src/convert.ts @@ -778,7 +778,7 @@ function convertExports( } // ═══════════════════════════════════════════════════════════════════════════ -// TRIGGERS (scheduled + fetch + queue consumer + email) +// TRIGGERS (scheduled + fetch + queue consumer + email + connect) // ═══════════════════════════════════════════════════════════════════════════ function convertTriggers( @@ -797,6 +797,9 @@ function convertTriggers( const queueConsumers: NonNullable< NonNullable["consumers"] > = result.queues?.consumers ? [...result.queues.consumers] : []; + const connectHandlers: NonNullable = result.connect + ? [...result.connect] + : []; let addresses: string[] | undefined; for (const trigger of triggers) { @@ -835,6 +838,16 @@ function convertTriggers( ); break; } + case "connect": { + connectHandlers.push( + omitUndefined({ + protocol: trigger.protocol, + port: trigger.port, + address: trigger.address, + }) + ); + break; + } } } @@ -847,6 +860,9 @@ function convertTriggers( if (queueConsumers.length) { result.queues = { ...(result.queues ?? {}), consumers: queueConsumers }; } + if (connectHandlers.length) { + result.connect = connectHandlers; + } // An empty array removes managed addresses; undefined means no email trigger. if (addresses !== undefined) { result.addresses = addresses; diff --git a/packages/config/src/public.ts b/packages/config/src/public.ts index e5cab52dcf3..b022faed23e 100644 --- a/packages/config/src/public.ts +++ b/packages/config/src/public.ts @@ -53,6 +53,7 @@ export type { export { bindings } from "./bindings"; export type { Triggers, + ConnectTrigger, EmailTrigger, FetchTrigger, QueueConsumerTrigger, diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 9802721437d..bb1abb53e99 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -477,6 +477,12 @@ const TriggerSchema = z.discriminatedUnion("type", [ type: z.literal("scheduled"), schedule: z.string(), }), + z.strictObject({ + type: z.literal("connect"), + protocol: z.enum(["tcp"]), + port: z.number(), + address: z.string().optional(), + }), ]); const UnsafeSchema = z.strictObject({ diff --git a/packages/config/src/triggers.ts b/packages/config/src/triggers.ts index 9d4103c7258..5d41c5da0e3 100644 --- a/packages/config/src/triggers.ts +++ b/packages/config/src/triggers.ts @@ -97,11 +97,29 @@ export interface EmailTrigger extends EmailTriggerOptions { type: "email"; } +interface ConnectTriggerOptions { + /** The transport protocol to listen for. */ + protocol: "tcp"; + /** The port to listen on. */ + port: number; + /** The address to bind to. Defaults to `127.0.0.1`. */ + address?: string; +} + +/** + * Connect trigger — invokes this Worker's `connect(socket, env, ctx)` + * handler for raw socket connections received on the configured + * protocol/port. + */ +export interface ConnectTrigger extends ConnectTriggerOptions { + type: "connect"; +} + /** - * Event triggers — fetch routes, queue consumers, cron schedules, and Email - * Routing addresses — that invoke this Worker. Construct entries with - * `triggers.fetch(...)`, `triggers.queue(...)`, `triggers.scheduled(...)`, or - * `triggers.email(...)`. + * Event triggers — fetch routes, queue consumers, cron schedules, Email + * Routing addresses, and raw sockets — that invoke this Worker. + * Construct entries with `triggers.fetch(...)`, `triggers.queue(...)`, + * `triggers.scheduled(...)`, `triggers.email(...)`, or `triggers.connect(...)`. * * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#triggers */ @@ -130,6 +148,12 @@ export interface Triggers { * addresses. */ email(options: EmailTriggerOptions): EmailTrigger; + /** + * Connect trigger — invokes this Worker's `connect(socket, env, ctx)` + * handler for raw socket connections received on the configured + * protocol/port. + */ + connect(options: ConnectTriggerOptions): ConnectTrigger; } /** @@ -146,6 +170,7 @@ export interface Triggers { * triggers.scheduled({ schedule: "0 * * * *" }), * triggers.scheduled({ schedule: "30 0 * * *" }), * triggers.email({ addresses: ["support@example.com"] }), + * triggers.connect({ protocol: "tcp", port: 5432 }), * ], * }); * ``` @@ -155,4 +180,5 @@ export const triggers: Triggers = { queue: (options) => ({ type: "queue", ...options }), scheduled: (options) => ({ type: "scheduled", ...options }), email: (options) => ({ type: "email", ...options }), + connect: (options) => ({ type: "connect", ...options }), }; diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 8417d04ff14..7adfdbe4ad1 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -56,6 +56,7 @@ import type { } from "./exports"; import type { WorkerModule } from "./inference"; import type { + ConnectTrigger, EmailTrigger, FetchTrigger, QueueConsumerTrigger, @@ -109,6 +110,7 @@ type Binding = * Union of all trigger definitions accepted in `triggers`. */ type Trigger = + | ConnectTrigger | EmailTrigger | FetchTrigger | QueueConsumerTrigger @@ -215,10 +217,11 @@ export interface WorkerConfig { domains?: string[]; /** - * Event triggers — fetch routes, queue consumers, cron schedules, and Email - * Routing addresses - * — that invoke this Worker. Construct entries with `triggers.fetch(...)`, - * `triggers.queue(...)`, `triggers.scheduled(...)`, or `triggers.email(...)`. + * Event triggers — fetch routes, queue consumers, cron schedules, Email + * Routing addresses, and raw sockets — that invoke this Worker. + * Construct entries with `triggers.fetch(...)`, `triggers.queue(...)`, + * `triggers.scheduled(...)`, `triggers.email(...)`, or + * `triggers.connect(...)`. * * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#triggers */ diff --git a/packages/miniflare/src/config/v4-convert.ts b/packages/miniflare/src/config/v4-convert.ts index 367782c9ec2..70e47a642e8 100644 --- a/packages/miniflare/src/config/v4-convert.ts +++ b/packages/miniflare/src/config/v4-convert.ts @@ -140,6 +140,11 @@ function convertWorkerOptions( config.triggers.push({ type: "fetch", pattern: route }); } + for (const connectHandler of worker.connectHandlers ?? []) { + config.triggers ??= []; + config.triggers.push({ type: "connect", ...connectHandler }); + } + addVariableBindings(env, worker.bindings); addNamespaceBindings(env, "kv", worker.kvNamespaces, isRemote); addNamespaceBindings(env, "d1", worker.d1Databases, isRemote); diff --git a/packages/miniflare/src/config/v4-schema.ts b/packages/miniflare/src/config/v4-schema.ts index 9f9cb299c21..05c558a017b 100644 --- a/packages/miniflare/src/config/v4-schema.ts +++ b/packages/miniflare/src/config/v4-schema.ts @@ -197,6 +197,12 @@ const V4UnsafeDirectSocketSchema = z.object({ proxy: z.boolean().optional(), }); +const V4ConnectHandlerSchema = z.object({ + protocol: z.enum(["tcp"]), + port: z.number(), + address: z.string().optional(), +}); + const V4IdEntrySchema = z.object({ id: z.string(), remoteProxyConnectionString: RemoteProxyConnectionStringSchema.optional(), @@ -364,6 +370,7 @@ const V4WorkerOptionsShapeSchema = z.object({ outboundService: V4ServiceDesignatorSchema.optional(), unsafeEphemeralDurableObjects: z.boolean().optional(), unsafeDirectSockets: z.array(V4UnsafeDirectSocketSchema).optional(), + connectHandlers: z.array(V4ConnectHandlerSchema).optional(), unsafeOverrideFetchWorker: z.string().optional(), unsafeEvalBinding: z.string().optional(), unsafeUseModuleFallbackService: z.boolean().optional(), @@ -777,6 +784,11 @@ export type V4WorkerOptionsShape = { entrypoint?: string; proxy?: boolean; }>; + connectHandlers?: Array<{ + protocol: "tcp"; + port: number; + address?: string; + }>; unsafeOverrideFetchWorker?: string; unsafeEvalBinding?: string; unsafeUseModuleFallbackService?: boolean; diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index e4a76052b2a..4b5f71494a3 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -37,6 +37,7 @@ import { D1_PLUGIN_NAME, DURABLE_OBJECTS_PLUGIN_NAME, FLAGSHIP_PLUGIN_NAME, + getConnectSocketName, getDirectSocketName, getDurableObjectUniqueKey, getEmailPathsToClean, @@ -1982,6 +1983,37 @@ export class Miniflare { }, }); } + + // Open raw listening sockets that deliver incoming connections + // to this Worker's `connect()`. + const connectHandlers = getTriggersOfType(workerOpts.config, "connect"); + for (let j = 0; j < connectHandlers.length; j++) { + const connectHandler = connectHandlers[j]; + // The socket's name already encodes the configured protocol/port, so + // we can pass `connectHandler.port` as both the current and + // "previous" port: `#getSocketAddress()` only compares these to + // detect the `port: 0` (OS-assigned) case, in which case it looks up + // the actual previously-assigned random port by this same `name`. + const name = getConnectSocketName( + workerIndex, + connectHandler.protocol, + connectHandler.port + ); + const address = this.#getSocketAddress( + name, + connectHandler.port, + connectHandler.address, + connectHandler.port, + reusePorts + ); + + sockets.push({ + name, + address, + service: { name: getUserServiceName(workerName) }, + tcp: {}, + }); + } } if ( diff --git a/packages/miniflare/src/plugins/shared/constants.ts b/packages/miniflare/src/plugins/shared/constants.ts index 0a4d6585516..a5a1dec14d9 100644 --- a/packages/miniflare/src/plugins/shared/constants.ts +++ b/packages/miniflare/src/plugins/shared/constants.ts @@ -13,11 +13,28 @@ export const SOCKET_ENTRY_LOCAL = "entry:local"; export const SOCKET_DEBUG_PORT = "debug-port"; export const SOCKET_DEV_REGISTRY = "dev-registry"; const SOCKET_DIRECT_PREFIX = "direct"; +const SOCKET_CONNECT_PREFIX = "connect"; export function getDirectSocketName(workerIndex: number, entrypoint: string) { return `${SOCKET_DIRECT_PREFIX}:${workerIndex}:${entrypoint}`; } +/** + * Utility to get the name for a raw socket listener implementing a worker's `connect` handler. + * + * @param workerIndex Index of the worker the listener belongs to + * @param protocol Protocol the listener accepts connections for + * @param port Port the listener accepts connections for + * @returns the name for the socket + */ +export function getConnectSocketName( + workerIndex: number, + protocol: "tcp" | "udp", + port: number +) { + return `${SOCKET_CONNECT_PREFIX}:${workerIndex}:${protocol}:${port}`; +} + // Service looping back to Miniflare's Node.js process (for storage, etc) export const SERVICE_LOOPBACK = "loopback"; diff --git a/packages/miniflare/src/runtime/config/workerd.ts b/packages/miniflare/src/runtime/config/workerd.ts index 9129b241468..56ddffc7615 100644 --- a/packages/miniflare/src/runtime/config/workerd.ts +++ b/packages/miniflare/src/runtime/config/workerd.ts @@ -28,13 +28,17 @@ export type Socket = { name?: string; address?: string; service?: ServiceDesignator; -} & ({ http?: HttpOptions } | { https?: Socket_Https }); +} & ({ http?: HttpOptions } | { https?: Socket_Https } | { tcp?: Socket_Tcp }); export interface Socket_Https { options?: HttpOptions; tlsOptions?: TlsOptions; } +export interface Socket_Tcp { + tlsOptions?: TlsOptions; +} + export type Service = { name?: string; } & ( diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index 1a0b9f213fe..708ad3886ae 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -15,6 +15,7 @@ import { _forceColour, NODEJS_COMPAT_DEFAULT_ON_DATE, } from "@cloudflare/workers-utils"; +import getPort from "get-port"; import { _transformsForContentEncodingAndContentType, DeferredPromise, @@ -3415,6 +3416,52 @@ test("Miniflare: allows direct access to workers", async ({ expect }) => { new TypeError('Direct access disabled in "d" worker for "three" entrypoint') ); }); + +test("Miniflare: connectHandlers deliver raw TCP connections to the Worker's connect() handler", async ({ + expect, + onTestFinished, +}) => { + const port = await getPort(); + const mf = new Miniflare({ + workers: [ + { + config: { + type: "worker", + name: "", + compatibilityDate: "2025-05-01", + compatibilityFlags: ["experimental"], + manifest: singleModuleManifest(` + export default { + async connect(socket) { + const reader = socket.readable.getReader(); + const writer = socket.writable.getWriter(); + const { value } = await reader.read(); + await writer.write(value); + await writer.close(); + }, + }; + `), + triggers: [{ type: "connect", protocol: "tcp", port }], + }, + }, + ], + }); + onTestFinished(() => mf.dispose()); + await mf.ready; + + const received = await new Promise((resolve, reject) => { + const socket = net.connect(port, "127.0.0.1", () => { + socket.write("hello"); + }); + const chunks: Buffer[] = []; + socket.on("data", (chunk) => chunks.push(chunk)); + socket.on("end", () => resolve(Buffer.concat(chunks))); + socket.on("error", reject); + }); + + expect(received.toString()).toBe("hello"); +}); + test("Miniflare: allows RPC between multiple instances", async ({ expect }) => { const mf1 = new Miniflare({ workers: [ diff --git a/packages/workers-utils/src/config/config.ts b/packages/workers-utils/src/config/config.ts index 17c0af4a115..d7511758540 100644 --- a/packages/workers-utils/src/config/config.ts +++ b/packages/workers-utils/src/config/config.ts @@ -368,6 +368,7 @@ export const defaultWranglerConfig: Config = { producers: [], consumers: [], // WORKERS SUPPORT ONLY!! }, + connect: [], r2_buckets: [], d1_databases: [], vectorize: [], diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index 608710bb41e..eae4b7c8f37 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -1092,6 +1092,29 @@ export interface EnvironmentNonInheritable { }[]; }; + /** + * Specifies raw sockets that this Worker should listen on. + * Each entry opens a listening socket on the + * given port that delivers incoming connections directly to the Worker's + * `connect(socket, env, ctx)` handler. + * + * NOTE: This field is not automatically inherited from the top level environment, + * and so must be specified in every named environment. + * + * @default [] + * @nonInheritable + */ + connect: { + /** The transport protocol to listen for. */ + protocol: "tcp"; + + /** The port to listen on. */ + port: number; + + /** The address to bind to. Defaults to `127.0.0.1`. */ + address?: string; + }[]; + /** * Specifies R2 buckets that are bound to this Worker environment. * diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index f899d3b4ddb..640530578a9 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -1782,6 +1782,16 @@ function normalizeAndValidateEnvironment( validateQueues(envName), { producers: [], consumers: [] } ), + connect: notInheritable( + diagnostics, + topLevelEnv, + rawConfig, + rawEnv, + envName, + "connect", + validateConnectHandlers(envName), + [] + ), r2_buckets: notInheritable( diagnostics, topLevelEnv, @@ -5238,6 +5248,146 @@ const validateConsumer: ValidatorFn = (diagnostics, field, value, _config) => { return isValid; }; +/** + * Validate that the field is an array of `connect` handler definitions, each with a + * unique protocol/port combination. + */ +function validateConnectHandlers(envName: string): ValidatorFn { + return (diagnostics, field, value, config) => { + if (value === undefined) { + return true; + } + + const fieldPath = + config === undefined ? `${field}` : `env.${envName}.${field}`; + + if (!Array.isArray(value)) { + diagnostics.errors.push( + `The field "${fieldPath}" should be an array but got ${JSON.stringify( + value + )}.` + ); + return false; + } + + let isValid = true; + for (let i = 0; i < value.length; i++) { + if ( + !validateConnectHandler( + diagnostics, + `${fieldPath}[${i}]`, + value[i], + config + ) + ) { + isValid = false; + } + } + + // Reject duplicate protocol+port combinations within the same worker. + const firstIndexByKey = new Map(); + for (let i = 0; i < value.length; i++) { + const handler = value[i]; + if ( + typeof handler !== "object" || + handler === null || + typeof (handler as { port?: unknown }).port !== "number" || + typeof (handler as { protocol?: unknown }).protocol !== "string" + ) { + // Already reported by `validateConnectHandler` above. + continue; + } + + const { protocol, port } = handler as { + protocol: string; + port: number; + }; + const key = `${protocol}:${port}`; + const firstIndex = firstIndexByKey.get(key); + if (firstIndex !== undefined) { + diagnostics.errors.push( + `"${fieldPath}[${i}]" has the same "protocol" (${protocol}) and "port" (${port}) as "${fieldPath}[${firstIndex}]". Each entry in "connect" must use a unique protocol/port combination.` + ); + isValid = false; + } else { + firstIndexByKey.set(key, i); + } + } + + return isValid; + }; +} + +/** + * Check that the given field is a valid "connect" handler object. + */ +const validateConnectHandler: ValidatorFn = (diagnostics, field, value) => { + if (typeof value !== "object" || value === null) { + diagnostics.errors.push( + `"${field}" should be an object, but got ${JSON.stringify(value)}` + ); + return false; + } + + let isValid = true; + if ( + !validateAdditionalProperties(diagnostics, field, Object.keys(value), [ + "protocol", + "port", + "address", + ]) + ) { + isValid = false; + } + + if ("protocol" in value && value.protocol !== "tcp") { + diagnostics.errors.push( + `"${field}" should have a "protocol" field of "tcp" but got ${JSON.stringify( + value.protocol + )}.` + ); + isValid = false; + } else if (!("protocol" in value)) { + diagnostics.errors.push( + `"${field}" should have a "protocol" field of "tcp" but got ${JSON.stringify( + value + )}.` + ); + isValid = false; + } + + if (!isRequiredProperty(value, "port", "number")) { + diagnostics.errors.push( + `"${field}" should have a number "port" field but got ${JSON.stringify( + value + )}.` + ); + isValid = false; + } else if ( + !Number.isInteger((value as { port: number }).port) || + (value as { port: number }).port < 1 || + (value as { port: number }).port > 65535 + ) { + diagnostics.errors.push( + `"${field}" should have an integer "port" field between 1 and 65535 but got ${JSON.stringify( + value + )}.` + ); + isValid = false; + } + + if (!isOptionalProperty(value, "address", "string")) { + diagnostics.errors.push( + `"${field}" should, optionally, have a string "address" field but got ${JSON.stringify( + value + )}.` + ); + isValid = false; + } + + return isValid; +}; + const validateCompatibilityDate: ValidatorFn = (diagnostics, field, value) => { if (value === undefined) { return true; diff --git a/packages/workers-utils/src/types.ts b/packages/workers-utils/src/types.ts index 13e26cdb367..98f469ce5a1 100644 --- a/packages/workers-utils/src/types.ts +++ b/packages/workers-utils/src/types.ts @@ -432,7 +432,13 @@ export type Trigger = | ({ type: "route" } & ZoneNameRoute) | ({ type: "route" } & CustomDomainRoute) | { type: "cron"; cron: string } - | ({ type: "queue-consumer" } & Omit); + | ({ type: "queue-consumer" } & Omit) + | { + type: "connect"; + protocol: "tcp"; + port: number; + address?: string; + }; type BindingOmit = Omit; type NameOmit = Omit; diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts index d5365514072..f772092232f 100644 --- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts +++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts @@ -75,6 +75,7 @@ describe("normalizeAndValidateConfig()", () => { consumers: [], producers: [], }, + connect: [], r2_buckets: [], secrets_store_secrets: [], artifacts: [], @@ -6003,6 +6004,198 @@ describe("normalizeAndValidateConfig()", () => { }); }); + describe("[connect]", () => { + it("should error if connect is not an array", ({ expect }) => { + const { config, diagnostics } = normalizeAndValidateConfig( + { connect: {} } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(config).toEqual( + expect.not.objectContaining({ connect: expect.anything }) + ); + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - The field "connect" should be an array but got {}." + `); + }); + + it("should error if a connect handler entry is missing a protocol", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + connect: [{ port: 8081 }], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "connect[0]" should have a "protocol" field of "tcp" but got {"port":8081}." + `); + }); + + it("should error if a connect handler entry has an invalid protocol", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + connect: [{ protocol: "ftp", port: 8081 }], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "connect[0]" should have a "protocol" field of "tcp" but got "ftp"." + `); + }); + + it("should error if a connect handler entry is missing a port", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + connect: [ + { protocol: "tcp" }, + { protocol: "tcp", address: "0.0.0.0" }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "connect[0]" should have a number "port" field but got {"protocol":"tcp"}. + - "connect[1]" should have a number "port" field but got {"protocol":"tcp","address":"0.0.0.0"}." + `); + }); + + it("should error if a connect handler entry's port is not an integer", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + connect: [ + { protocol: "tcp", port: 3.14 }, + { protocol: "tcp", port: Number.NaN }, + { protocol: "tcp", port: Number.POSITIVE_INFINITY }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "connect[0]" should have an integer "port" field between 1 and 65535 but got {"protocol":"tcp","port":3.14}. + - "connect[1]" should have an integer "port" field between 1 and 65535 but got {"protocol":"tcp","port":null}. + - "connect[2]" should have an integer "port" field between 1 and 65535 but got {"protocol":"tcp","port":null}." + `); + }); + + it("should error if a connect handler entry's port is out of range", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + connect: [ + { protocol: "tcp", port: 0 }, + { protocol: "tcp", port: -1 }, + { protocol: "tcp", port: 65536 }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "connect[0]" should have an integer "port" field between 1 and 65535 but got {"protocol":"tcp","port":0}. + - "connect[1]" should have an integer "port" field between 1 and 65535 but got {"protocol":"tcp","port":-1}. + - "connect[2]" should have an integer "port" field between 1 and 65535 but got {"protocol":"tcp","port":65536}." + `); + }); + + it("should error if a connect handler entry has unexpected fields", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + connect: [ + { protocol: "tcp", port: 8081, invalidField: "madeupValue" }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.renderWarnings()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - Unexpected fields found in connect[0] field: "invalidField"" + `); + }); + + it("should accept a valid connect config with multiple unique protocol/port combinations", ({ + expect, + }) => { + const { config, diagnostics } = normalizeAndValidateConfig( + { + connect: [ + { protocol: "tcp", port: 8081, address: "*" }, + { protocol: "tcp", port: 8082 }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(false); + expect(diagnostics.hasWarnings()).toBe(false); + expect(config.connect).toEqual([ + { protocol: "tcp", port: 8081, address: "*" }, + { protocol: "tcp", port: 8082 }, + ]); + }); + + it("should error if two connect handlers share the same protocol and port", ({ + expect, + }) => { + const { diagnostics } = normalizeAndValidateConfig( + { + connect: [ + { protocol: "tcp", port: 8081 }, + { protocol: "tcp", port: 8082 }, + { protocol: "tcp", port: 8081, address: "0.0.0.0" }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - "connect[2]" has the same "protocol" (tcp) and "port" (8081) as "connect[0]". Each entry in "connect" must use a unique protocol/port combination." + `); + }); + }); + describe("[r2_buckets]", () => { it("should error if r2_buckets is an object", ({ expect }) => { const { diagnostics } = normalizeAndValidateConfig( diff --git a/packages/wrangler/src/__tests__/type-generation.test.ts b/packages/wrangler/src/__tests__/type-generation.test.ts index bb66dc08485..11582c9114e 100644 --- a/packages/wrangler/src/__tests__/type-generation.test.ts +++ b/packages/wrangler/src/__tests__/type-generation.test.ts @@ -553,6 +553,7 @@ const bindingsConfigMock: Omit< ], vpc_networks: [], websearch: undefined, + connect: [], }; describe("generate types - CLI", () => { diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 57b42393810..9076fc859c5 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -329,7 +329,13 @@ async function resolveTriggers( type: "cron", })) ?? []; - return [...devRoutes, ...queueConsumers, ...crons]; + const connectHandlers = + config.connect?.map>((c) => ({ + ...c, + type: "connect", + })) ?? []; + + return [...devRoutes, ...queueConsumers, ...crons, ...connectHandlers]; } async function resolveConfig( diff --git a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts index 2944282ab9c..deee1db8897 100644 --- a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts @@ -119,6 +119,7 @@ export async function convertToConfigBundle( const crons = []; const routes = []; const queueConsumers = []; + const connectHandlers = []; for (const trigger of event.config.triggers ?? []) { if (trigger.type === "cron") { crons.push(trigger.cron); @@ -127,6 +128,9 @@ export async function convertToConfigBundle( } else if (trigger.type === "queue-consumer") { const { type: _, ...consumer } = trigger; queueConsumers.push(consumer); + } else if (trigger.type === "connect") { + const { type: _, ...connectHandler } = trigger; + connectHandlers.push(connectHandler); } } if (event.bundle.entry.format === "service-worker") { @@ -202,6 +206,7 @@ export async function convertToConfigBundle( crons, routes: event.config.dev.routeRequestsByRoutes ? routes : undefined, queueConsumers, + connectHandlers, outboundService: event.config.dev.outboundService, localProtocol: event.config.dev?.server?.secure ? "https" : "http", localUpstream: event.config.dev?.origin?.hostname, diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 39b32356da6..68d18457e4f 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -92,6 +92,7 @@ export interface ConfigBundle { crons: Config["triggers"]["crons"]; routes: string[] | undefined; queueConsumers: Config["queues"]["consumers"]; + connectHandlers: Config["connect"]; localProtocol: "http" | "https"; localUpstream: string | undefined; upstreamProtocol: "http" | "https"; @@ -1188,6 +1189,7 @@ export async function buildMiniflareOptions( outboundService: config.outboundService, zone: config.zone, access: config.access?.dev, + connectHandlers: config.connectHandlers, }, ...externalWorkers, ],