From 527e4f5651b8858f11a1b9bab4627a4ae5cc1bc0 Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:33:19 +0000 Subject: [PATCH 1/4] [explorer] Add DO /query endpoint that introspects DO sqlite (#12574) --- .changeset/ready-hotels-jump.md | 9 + packages/miniflare/scripts/filter-openapi.ts | 28 +- .../scripts/openapi-filter-config.ts | 153 ++++++ .../miniflare/src/plugins/core/constants.ts | 15 + .../miniflare/src/plugins/core/explorer.ts | 61 ++- packages/miniflare/src/plugins/core/index.ts | 26 +- packages/miniflare/src/plugins/index.ts | 1 + .../src/workers/core/do-wrapper.worker.ts | 70 +++ .../workers/local-explorer/explorer.worker.ts | 9 +- .../workers/local-explorer/generated/index.ts | 9 + .../local-explorer/generated/types.gen.ts | 85 ++++ .../local-explorer/generated/zod.gen.ts | 44 ++ .../workers/local-explorer/openapi.local.json | 157 ++++++ .../workers/local-explorer/resources/do.ts | 95 +++- .../plugins/local-explorer/do-wrapper.spec.ts | 114 +++++ .../test/plugins/local-explorer/do.spec.ts | 469 ++++++++++++++++++ 16 files changed, 1336 insertions(+), 9 deletions(-) create mode 100644 .changeset/ready-hotels-jump.md create mode 100644 packages/miniflare/src/workers/core/do-wrapper.worker.ts create mode 100644 packages/miniflare/test/plugins/local-explorer/do-wrapper.spec.ts diff --git a/.changeset/ready-hotels-jump.md b/.changeset/ready-hotels-jump.md new file mode 100644 index 000000000000..f0903bd9a5ef --- /dev/null +++ b/.changeset/ready-hotels-jump.md @@ -0,0 +1,9 @@ +--- +"miniflare": minor +--- + +Local explorer: add /query endpoint to introspect sqlite in DOs + +This required adding a wrapper that extends user DO classes and adds in an extra method to access `ctx.storage.sql`. This _shouldn't_ have any impact on user code, but is gated by the env var `X_LOCAL_EXPLORER`. + +This is for an experimental WIP feature. diff --git a/packages/miniflare/scripts/filter-openapi.ts b/packages/miniflare/scripts/filter-openapi.ts index 9af53ced5c8b..99333b79d074 100644 --- a/packages/miniflare/scripts/filter-openapi.ts +++ b/packages/miniflare/scripts/filter-openapi.ts @@ -74,6 +74,12 @@ writeFilteredOpenAPIFile(values.input, outputPath, config); export interface FilterConfig { endpoints: EndpointConfig[]; ignores?: IgnoresConfig; + extensions?: ExtensionsConfig; +} + +export interface ExtensionsConfig { + paths?: Record>; + schemas?: Record; } export interface EndpointConfig { path: string; @@ -98,16 +104,19 @@ export interface IgnoresConfig { schemaProperties?: Record; } interface OpenAPIOperation { - parameters?: Array<{ name: string }>; + parameters?: Array<{ name: string; [key: string]: unknown }>; requestBody?: { - content: Record; + content: Record; + [key: string]: unknown; }; security?: unknown; + [key: string]: unknown; } interface OpenAPISchema { - properties?: Record; + properties?: Record; required?: string[]; + [key: string]: unknown; } interface OpenAPIComponents { @@ -192,7 +201,18 @@ function filterOpenAPISpec( components: filteredComponents, }; - // 8. Strip all x-* extensions from the final spec (single pass) + // 8. Merge extensions (local-only paths and schemas not in upstream API) + if (config.extensions) { + if (config.extensions.paths) { + Object.assign(filteredSpec.paths, config.extensions.paths); + } + if (config.extensions.schemas) { + filteredSpec.components.schemas ??= {}; + Object.assign(filteredSpec.components.schemas, config.extensions.schemas); + } + } + + // 9. Strip all x-* extensions from the final spec (single pass) return stripExtensions(filteredSpec) as OpenAPISpec; } diff --git a/packages/miniflare/scripts/openapi-filter-config.ts b/packages/miniflare/scripts/openapi-filter-config.ts index 49f7312f56ff..b9efff923223 100644 --- a/packages/miniflare/scripts/openapi-filter-config.ts +++ b/packages/miniflare/scripts/openapi-filter-config.ts @@ -88,6 +88,159 @@ const config = { ], }, }, + + // Local-only extensions (not in upstream Cloudflare API) + extensions: { + paths: { + "/workers/durable_objects/namespaces/{namespace_id}/query": { + post: { + description: + "Execute SQL queries against a Durable Object's SQLite storage.", + operationId: "durable-objects-namespace-query-sqlite", + parameters: [ + { + in: "path", + name: "namespace_id", + required: true, + schema: { + $ref: "#/components/schemas/workers_schemas-id", + }, + }, + ], + requestBody: { + content: { + "application/json": { + schema: { + oneOf: [ + { $ref: "#/components/schemas/do_query-by-id" }, + { $ref: "#/components/schemas/do_query-by-name" }, + ], + }, + }, + }, + required: true, + }, + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + items: { + $ref: "#/components/schemas/do_raw-query-result", + }, + type: "array", + }, + }, + type: "object", + }, + ], + }, + }, + }, + description: "Query response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "Query response failure.", + }, + }, + summary: "Query Durable Object SQLite", + tags: ["Durable Objects Namespace"], + }, + }, + }, + schemas: { + "do_sql-with-params": { + type: "object", + required: ["sql"], + properties: { + sql: { + type: "string", + minLength: 1, + description: "SQL query to execute", + }, + params: { + type: "array", + items: {}, + description: "Optional parameters for the SQL query", + }, + }, + }, + "do_query-by-id": { + type: "object", + required: ["durable_object_id", "queries"], + properties: { + durable_object_id: { + type: "string", + minLength: 1, + description: "Hex string ID of the Durable Object", + }, + queries: { + type: "array", + items: { $ref: "#/components/schemas/do_sql-with-params" }, + description: "Array of SQL queries to execute", + }, + }, + }, + "do_query-by-name": { + type: "object", + required: ["durable_object_name", "queries"], + properties: { + durable_object_name: { + type: "string", + minLength: 1, + description: "Name to derive DO ID via idFromName()", + }, + queries: { + type: "array", + items: { $ref: "#/components/schemas/do_sql-with-params" }, + description: "Array of SQL queries to execute", + }, + }, + }, + "do_raw-query-result": { + type: "object", + properties: { + columns: { + type: "array", + items: { type: "string" }, + description: "Column names from the query result", + }, + rows: { + type: "array", + items: { type: "array", items: {} }, + description: "Array of row arrays containing query results", + }, + meta: { + type: "object", + properties: { + rows_read: { + type: "number", + description: "Number of rows read during query execution", + }, + rows_written: { + type: "number", + description: "Number of rows written during query execution", + }, + }, + }, + }, + }, + }, + }, } satisfies FilterConfig; export default config; diff --git a/packages/miniflare/src/plugins/core/constants.ts b/packages/miniflare/src/plugins/core/constants.ts index 97ddb3548233..90f2df513927 100644 --- a/packages/miniflare/src/plugins/core/constants.ts +++ b/packages/miniflare/src/plugins/core/constants.ts @@ -1,3 +1,8 @@ +import type { + DoRawQueryResult, + DoSqlWithParams, +} from "../../workers/local-explorer/generated"; + export const CORE_PLUGIN_NAME = "core"; // Service for HTTP socket entrypoint (for checking runtime ready, routing, etc) @@ -55,3 +60,13 @@ export function getCustomNodeServiceName( ) { return `${SERVICE_CUSTOM_NODE_PREFIX}:${workerIndex}:${kind}${bindingName}`; } + +/** + * Used by the local explorer worker. + * The method name injected into wrapped Durable Objects for SQLite introspection. + */ +export const INTROSPECT_SQLITE_METHOD = "__miniflare_introspectSqlite"; + +export type IntrospectSqliteMethod = ( + queries: DoSqlWithParams[] +) => Promise; diff --git a/packages/miniflare/src/plugins/core/explorer.ts b/packages/miniflare/src/plugins/core/explorer.ts index 0014f411944e..4e3b105e5b60 100644 --- a/packages/miniflare/src/plugins/core/explorer.ts +++ b/packages/miniflare/src/plugins/core/explorer.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; +import SCRIPT_DO_WRAPPER from "worker:core/do-wrapper"; import SCRIPT_LOCAL_EXPLORER from "worker:local-explorer/explorer"; -import { Service, Worker_Binding } from "../../runtime"; +import { Service, Worker_Binding, Worker_Module } from "../../runtime"; import { OUTBOUND_DO_PROXY_SERVICE_NAME } from "../../shared/external-service"; import { CoreBindings } from "../../workers"; import { @@ -158,3 +159,61 @@ export function constructExplorerBindingMap( return IDToBindingName; } + +/** + * We need to wrap and extend user Durable Object classes to inject an + * internal sqlite introspection method for the local explorer to use. + * + * This generates a new entry module that replaces the original user entry + * module, which re-exports everything with DO classes wrapped. + */ +function generateWrapperEntry( + userEntryName: string, + durableObjectClassNames: string[] +): string { + const lines = [ + `import { createDurableObjectWrapper } from "./__mf_do_wrapper.js";`, + `import * as __mf_original__ from "./${userEntryName}";`, + // Re-export everything from original module + `export * from "./${userEntryName}";`, + `export default __mf_original__.default;`, + ]; + + for (const className of durableObjectClassNames) { + lines.push( + `export const ${className} = createDurableObjectWrapper(__mf_original__.${className});` + ); + } + + return lines.join("\n"); +} + +/** + * Transforms worker modules to wrap Durable Object classes for the local explorer. + * + * This function modifies the modules array to: + * 1. Insert a new wrapper entry module at the front (workerd uses first module as entry) + * 2. Inject the wrapper helper module + * 3. Keep the original entry module with its original name (preserves source mapping) + */ +export function wrapDurableObjectModules( + modules: Worker_Module[], + durableObjectClassNames: string[] +): Worker_Module[] { + const entryModule = modules[0]; + + const wrapperEntry = generateWrapperEntry( + entryModule.name, + durableObjectClassNames + ); + + // Build new modules array: + // 1. New wrapper entry module (workerd uses first module as entry) + // 2. Wrapper helper module with createDurableObjectWrapper + // 3. All original modules unchanged (entry keeps its name for source mapping) + return [ + { name: "__mf_do_wrapper_entry.js", esModule: wrapperEntry }, + { name: "__mf_do_wrapper.js", esModule: SCRIPT_DO_WRAPPER() }, + ...modules, + ]; +} diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index d02e91b67b6a..92ed45905e1c 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -58,7 +58,11 @@ import { SERVICE_ENTRY, SERVICE_LOCAL_EXPLORER, } from "./constants"; -import { constructExplorerBindingMap, getExplorerServices } from "./explorer"; +import { + constructExplorerBindingMap, + getExplorerServices, + wrapDurableObjectModules, +} from "./explorer"; import { buildStringScriptPath, convertModuleDefinition, @@ -743,6 +747,26 @@ export const CORE_PLUGIN: Plugin< const classNames = durableObjectClassNames.get(serviceName); const classNamesEntries = Array.from(classNames ?? []); + // Wrap Durable Object classes for the local explorer + // This injects a method onto user defined DO classes to allow + // us to introspect the sqlite databases, since these are not + // available on the stub. + const sqliteClasses = classNamesEntries.filter( + ([, { enableSql }]) => enableSql + ); + if ( + sharedOptions.unsafeLocalExplorer && + // service-format workers are not supported + "modules" in workerScript && + sqliteClasses.length > 0 && + "esModule" in workerScript.modules[0] + ) { + workerScript.modules = wrapDurableObjectModules( + workerScript.modules, + sqliteClasses.map(([className]) => className) + ); + } + const compatibilityDate = validateCompatibilityDate( log, options.compatibilityDate ?? FALLBACK_COMPATIBILITY_DATE diff --git a/packages/miniflare/src/plugins/index.ts b/packages/miniflare/src/plugins/index.ts index 93c429443c93..3bfeb5a69a28 100644 --- a/packages/miniflare/src/plugins/index.ts +++ b/packages/miniflare/src/plugins/index.ts @@ -175,6 +175,7 @@ export { kCurrentWorker, getNodeCompat, WorkerdStructuredLogSchema as workerdStructuredLogSchema, + INTROSPECT_SQLITE_METHOD, } from "./core"; export type { CompiledModuleRule, diff --git a/packages/miniflare/src/workers/core/do-wrapper.worker.ts b/packages/miniflare/src/workers/core/do-wrapper.worker.ts new file mode 100644 index 000000000000..98c0e05908be --- /dev/null +++ b/packages/miniflare/src/workers/core/do-wrapper.worker.ts @@ -0,0 +1,70 @@ +import { DurableObject } from "cloudflare:workers"; +import { INTROSPECT_SQLITE_METHOD } from "../../plugins/core/constants"; +import type { + DoRawQueryResult, + DoSqlWithParams, +} from "../local-explorer/generated/types.gen"; + +interface DurableObjectConstructor { + new ( + ...args: ConstructorParameters> + ): DurableObject; +} + +/** + * Wraps a user Durable Object class to add an introspection method + * for querying SQLite storage from the local explorer. + */ +export function createDurableObjectWrapper( + UserClass: DurableObjectConstructor +) { + class Wrapper extends UserClass { + constructor(ctx: DurableObjectState, env: Cloudflare.Env) { + super(ctx, env); + } + + /** + * Execute SQL queries against the DO's SQLite storage. + * If multiple queries are provided, they run in a transaction. + */ + [INTROSPECT_SQLITE_METHOD](queries: DoSqlWithParams[]): DoRawQueryResult[] { + const sql: SqlStorage | undefined = this.ctx.storage.sql; + + if (!sql) { + throw new Error( + "This Durable Object does not have SQLite storage enabled" + ); + } + + const executeQuery = (query: DoSqlWithParams): DoRawQueryResult => { + const cursor = sql.exec(query.sql, ...(query.params ?? [])); + + return { + columns: cursor.columnNames, + rows: Array.from(cursor.raw()), + meta: { + rows_read: cursor.rowsRead, + rows_written: cursor.rowsWritten, + }, + }; + }; + + const results: DoRawQueryResult[] = []; + + if (queries.length > 1) { + this.ctx.storage.transactionSync(() => { + for (const query of queries) { + results.push(executeQuery(query)); + } + }); + } else { + results.push(executeQuery(queries[0])); + } + + return results; + } + } + + Object.defineProperty(Wrapper, "name", { value: UserClass.name }); + return Wrapper; +} diff --git a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts index 87ffd97ea331..fa11709944f7 100644 --- a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts +++ b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts @@ -14,12 +14,13 @@ import { zCloudflareD1RawDatabaseQueryData, zDurableObjectsNamespaceListNamespacesData, zDurableObjectsNamespaceListObjectsData, + zDurableObjectsNamespaceQuerySqliteData, zWorkersKvNamespaceGetMultipleKeyValuePairsData, zWorkersKvNamespaceListANamespaceSKeysData, zWorkersKvNamespaceListNamespacesData, } from "./generated/zod.gen"; import { listD1Databases, rawD1Database } from "./resources/d1"; -import { listDONamespaces, listDOObjects } from "./resources/do"; +import { listDONamespaces, listDOObjects, queryDOSqlite } from "./resources/do"; import { bulkGetKVValues, deleteKVValue, @@ -178,4 +179,10 @@ app.get( (c) => listDOObjects(c, c.req.param("namespace_id"), c.req.valid("query")) ); +app.post( + "/api/workers/durable_objects/namespaces/:namespace_id/query", + validateRequestBody(zDurableObjectsNamespaceQuerySqliteData.shape.body), + (c) => queryDOSqlite(c, c.req.param("namespace_id"), c.req.valid("json")) +); + export default app; diff --git a/packages/miniflare/src/workers/local-explorer/generated/index.ts b/packages/miniflare/src/workers/local-explorer/generated/index.ts index b23f5fc34fb2..98b5b17ea4fa 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/index.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/index.ts @@ -26,6 +26,10 @@ export type { D1RawResultResponse, D1SingleQuery, D1Sql, + DoQueryById, + DoQueryByName, + DoRawQueryResult, + DoSqlWithParams, DurableObjectsNamespaceListNamespacesData, DurableObjectsNamespaceListNamespacesError, DurableObjectsNamespaceListNamespacesErrors, @@ -36,6 +40,11 @@ export type { DurableObjectsNamespaceListObjectsErrors, DurableObjectsNamespaceListObjectsResponse, DurableObjectsNamespaceListObjectsResponses, + DurableObjectsNamespaceQuerySqliteData, + DurableObjectsNamespaceQuerySqliteError, + DurableObjectsNamespaceQuerySqliteErrors, + DurableObjectsNamespaceQuerySqliteResponse, + DurableObjectsNamespaceQuerySqliteResponses, WorkersApiResponseCollection, WorkersApiResponseCommon, WorkersApiResponseCommonFailure, diff --git a/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts b/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts index 1a4b9b47889c..d07a4009f2e9 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts @@ -366,6 +366,60 @@ export type WorkersKvResultInfo = { total_count?: number; }; +export type DoSqlWithParams = { + /** + * SQL query to execute + */ + sql: string; + /** + * Optional parameters for the SQL query + */ + params?: Array; +}; + +export type DoQueryById = { + /** + * Hex string ID of the Durable Object + */ + durable_object_id: string; + /** + * Array of SQL queries to execute + */ + queries: Array; +}; + +export type DoQueryByName = { + /** + * Name to derive DO ID via idFromName() + */ + durable_object_name: string; + /** + * Array of SQL queries to execute + */ + queries: Array; +}; + +export type DoRawQueryResult = { + /** + * Column names from the query result + */ + columns?: Array; + /** + * Array of row arrays containing query results + */ + rows?: Array>; + meta?: { + /** + * Number of rows read during query execution + */ + rows_read?: number; + /** + * Number of rows written during query execution + */ + rows_written?: number; + }; +}; + export type WorkersNamespaceWritable = { class?: string; name?: string; @@ -800,3 +854,34 @@ export type DurableObjectsNamespaceListObjectsResponses = { export type DurableObjectsNamespaceListObjectsResponse = DurableObjectsNamespaceListObjectsResponses[keyof DurableObjectsNamespaceListObjectsResponses]; + +export type DurableObjectsNamespaceQuerySqliteData = { + body: DoQueryById | DoQueryByName; + path: { + namespace_id: WorkersSchemasId; + }; + query?: never; + url: "/workers/durable_objects/namespaces/{namespace_id}/query"; +}; + +export type DurableObjectsNamespaceQuerySqliteErrors = { + /** + * Query response failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type DurableObjectsNamespaceQuerySqliteError = + DurableObjectsNamespaceQuerySqliteErrors[keyof DurableObjectsNamespaceQuerySqliteErrors]; + +export type DurableObjectsNamespaceQuerySqliteResponses = { + /** + * Query response. + */ + 200: WorkersApiResponseCommon & { + result?: Array; + }; +}; + +export type DurableObjectsNamespaceQuerySqliteResponse = + DurableObjectsNamespaceQuerySqliteResponses[keyof DurableObjectsNamespaceQuerySqliteResponses]; diff --git a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts index 8734d1cf343b..a0d21080689a 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts @@ -295,6 +295,32 @@ export const zWorkersKvApiResponseCollection = zWorkersKvApiResponseCommon.and( }) ); +export const zDoSqlWithParams = z.object({ + sql: z.string().min(1), + params: z.array(z.unknown()).optional(), +}); + +export const zDoQueryById = z.object({ + durable_object_id: z.string().min(1), + queries: z.array(zDoSqlWithParams), +}); + +export const zDoQueryByName = z.object({ + durable_object_name: z.string().min(1), + queries: z.array(zDoSqlWithParams), +}); + +export const zDoRawQueryResult = z.object({ + columns: z.array(z.string()).optional(), + rows: z.array(z.array(z.unknown())).optional(), + meta: z + .object({ + rows_read: z.number().optional(), + rows_written: z.number().optional(), + }) + .optional(), +}); + export const zWorkersNamespaceWritable = z.object({ class: z.string().optional(), name: z.string().optional(), @@ -537,3 +563,21 @@ export const zDurableObjectsNamespaceListObjectsResponse = .optional(), }) ); + +export const zDurableObjectsNamespaceQuerySqliteData = z.object({ + body: z.union([zDoQueryById, zDoQueryByName]), + path: z.object({ + namespace_id: zWorkersSchemasId, + }), + query: z.never().optional(), +}); + +/** + * Query response. + */ +export const zDurableObjectsNamespaceQuerySqliteResponse = + zWorkersApiResponseCommon.and( + z.object({ + result: z.array(zDoRawQueryResult).optional(), + }) + ); diff --git a/packages/miniflare/src/workers/local-explorer/openapi.local.json b/packages/miniflare/src/workers/local-explorer/openapi.local.json index f7c8bdea1a94..7dc219a6e4eb 100644 --- a/packages/miniflare/src/workers/local-explorer/openapi.local.json +++ b/packages/miniflare/src/workers/local-explorer/openapi.local.json @@ -818,6 +818,78 @@ "summary": "List Objects", "tags": ["Durable Objects Namespace"] } + }, + "/workers/durable_objects/namespaces/{namespace_id}/query": { + "post": { + "description": "Execute SQL queries against a Durable Object's SQLite storage.", + "operationId": "durable-objects-namespace-query-sqlite", + "parameters": [ + { + "in": "path", + "name": "namespace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/workers_schemas-id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/do_query-by-id" + }, + { + "$ref": "#/components/schemas/do_query-by-name" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "items": { + "$ref": "#/components/schemas/do_raw-query-result" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Query response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "Query response failure." + } + }, + "summary": "Query Durable Object SQLite", + "tags": ["Durable Objects Namespace"] + } } }, "components": { @@ -1594,6 +1666,91 @@ } }, "type": "object" + }, + "do_sql-with-params": { + "type": "object", + "required": ["sql"], + "properties": { + "sql": { + "type": "string", + "minLength": 1, + "description": "SQL query to execute" + }, + "params": { + "type": "array", + "items": {}, + "description": "Optional parameters for the SQL query" + } + } + }, + "do_query-by-id": { + "type": "object", + "required": ["durable_object_id", "queries"], + "properties": { + "durable_object_id": { + "type": "string", + "minLength": 1, + "description": "Hex string ID of the Durable Object" + }, + "queries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/do_sql-with-params" + }, + "description": "Array of SQL queries to execute" + } + } + }, + "do_query-by-name": { + "type": "object", + "required": ["durable_object_name", "queries"], + "properties": { + "durable_object_name": { + "type": "string", + "minLength": 1, + "description": "Name to derive DO ID via idFromName()" + }, + "queries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/do_sql-with-params" + }, + "description": "Array of SQL queries to execute" + } + } + }, + "do_raw-query-result": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column names from the query result" + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + }, + "description": "Array of row arrays containing query results" + }, + "meta": { + "type": "object", + "properties": { + "rows_read": { + "type": "number", + "description": "Number of rows read during query execution" + }, + "rows_written": { + "type": "number", + "description": "Number of rows written during query execution" + } + } + } + } } } } diff --git a/packages/miniflare/src/workers/local-explorer/resources/do.ts b/packages/miniflare/src/workers/local-explorer/resources/do.ts index 0760f16d807e..35c04a46b0e2 100644 --- a/packages/miniflare/src/workers/local-explorer/resources/do.ts +++ b/packages/miniflare/src/workers/local-explorer/resources/do.ts @@ -1,9 +1,13 @@ +import { INTROSPECT_SQLITE_METHOD } from "../../../plugins/core/constants"; import { errorResponse, wrapResponse } from "../common"; import { zDurableObjectsNamespaceListNamespacesData, zDurableObjectsNamespaceListObjectsData, + zDurableObjectsNamespaceQuerySqliteData, } from "../generated/zod.gen"; +import type { IntrospectSqliteMethod } from "../../../plugins/core/constants"; import type { AppContext } from "../common"; +import type { Env } from "../explorer.worker"; import type { z } from "zod"; type ListNamespacesQuery = NonNullable< @@ -78,7 +82,7 @@ export async function listDOObjects( // No loopback service means we can't list DOs c.env.MINIFLARE_LOOPBACK === undefined ) { - return errorResponse(404, 10000, `Namespace not found: ${namespaceId}`); + return errorResponse(404, 10001, `Namespace not found: ${namespaceId}`); } // The DO storage structure is: //.sqlite @@ -103,7 +107,7 @@ export async function listDOObjects( } return errorResponse( 500, - 10000, + 10001, `Failed to read DO storage: ${response.statusText}` ); } @@ -150,3 +154,90 @@ export async function listDOObjects( }, }); } + +// ============================================================================ +// Query Durable Object SQLite +// ============================================================================ + +type QueryBody = z.output< + typeof zDurableObjectsNamespaceQuerySqliteData +>["body"]; + +interface IntrospectableDurableObject extends Rpc.DurableObjectBranded { + [INTROSPECT_SQLITE_METHOD]: IntrospectSqliteMethod; +} + +function getDOBinding( + env: Env, + namespaceId: string +): { + binding: DurableObjectNamespace; + useSQLite: boolean; +} | null { + const info = env.LOCAL_EXPLORER_BINDING_MAP.do[namespaceId]; + if (!info) return null; + return { + binding: env[ + info.binding + ] as DurableObjectNamespace, + useSQLite: info.useSQLite, + }; +} + +/** + * Query Durable Object SQLite storage + * + * Executes SQL queries against a specific Durable Object's SQLite storage + * using introspection method that is injected into user DO classes. + * + * The namespace ID is the uniqueKey: scriptName-className + */ +export async function queryDOSqlite( + c: AppContext, + namespaceId: string, + body: QueryBody +): Promise { + // Look up namespace in binding map + const ns = getDOBinding(c.env, namespaceId); + + if (!ns) { + return errorResponse(404, 10001, `Namespace not found: ${namespaceId}`); + } + + if (!ns.useSQLite) { + return errorResponse( + 400, + 10001, + `Namespace does not use SQLite storage: ${namespaceId}` + ); + } + + const binding = ns.binding; + // Get DO ID - either from hex string or from name + let doId: DurableObjectId; + try { + if ("durable_object_id" in body) { + doId = binding.idFromString(body.durable_object_id); + } else { + doId = binding.idFromName(body.durable_object_name); + } + } catch (error) { + const message = + error instanceof Error ? error.message : "Invalid Durable Object ID"; + return errorResponse(400, 10001, message); + } + + if (body.queries.length === 0) { + return errorResponse(400, 10001, "No queries provided"); + } + + const stub = binding.get(doId); + + try { + const results = await stub[INTROSPECT_SQLITE_METHOD](body.queries); + return c.json(wrapResponse(results)); + } catch (error) { + const message = error instanceof Error ? error.message : "Query failed"; + return errorResponse(400, 10001, message); + } +} diff --git a/packages/miniflare/test/plugins/local-explorer/do-wrapper.spec.ts b/packages/miniflare/test/plugins/local-explorer/do-wrapper.spec.ts new file mode 100644 index 000000000000..623d3b4ab81b --- /dev/null +++ b/packages/miniflare/test/plugins/local-explorer/do-wrapper.spec.ts @@ -0,0 +1,114 @@ +import { Miniflare } from "miniflare"; +import { afterAll, beforeAll, describe, test } from "vitest"; +import { disposeWithRetry } from "../../test-shared"; + +const TEST_SCRIPT = ` +import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; + +export class TestDO extends DurableObject { + count = 0; + + async fetch(request) { + return new Response("fetch-ok"); + } + + increment(by = 1) { + this.count += by; + return this.count; + } + + get currentCount() { + return this.count; + } + + async storeAndRetrieve(key, value) { + await this.ctx.storage.put(key, value); + return await this.ctx.storage.get(key); + } + + getClassName() { + return this.constructor.name; + } +} + +export class TestEntrypoint extends WorkerEntrypoint { + async fetch(request) { + const url = new URL(request.url); + const action = url.pathname.slice(1); + const stub = this.env.TEST_DO.get(this.env.TEST_DO.idFromName("test")); + + switch (action) { + case "fetch": + return stub.fetch(request); + case "increment": + return Response.json({ result: await stub.increment(5) }); + case "getter": + return Response.json({ result: await stub.currentCount }); + case "storage": + return Response.json({ result: await stub.storeAndRetrieve("key", "value") }); + case "class-name": + return Response.json({ result: await stub.getClassName() }); + default: + return new Response("not found", { status: 404 }); + } + } +} + +export default TestEntrypoint; +`; + +describe("Durable Object Wrapper", () => { + // Run the same tests with wrapper enabled and disabled + // to prove the wrapper doesn't change observable DO behavior + describe.each([ + { localExplorer: true, label: "enabled" }, + { localExplorer: false, label: "disabled" }, + ])("with unsafeLocalExplorer $label", ({ localExplorer }) => { + let mf: Miniflare; + + beforeAll(async () => { + mf = new Miniflare({ + compatibilityDate: "2024-04-03", + compatibilityFlags: ["nodejs_compat"], + modules: true, + script: TEST_SCRIPT, + unsafeLocalExplorer: localExplorer, + durableObjects: { + TEST_DO: "TestDO", + }, + }); + }); + + afterAll(() => disposeWithRetry(mf)); + + test("fetch handler works", async ({ expect }) => { + const res = await mf.dispatchFetch("http://localhost/fetch"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("fetch-ok"); + }); + + test("RPC method works", async ({ expect }) => { + const res = await mf.dispatchFetch("http://localhost/increment"); + const data = (await res.json()) as { result: number }; + expect(data.result).toBe(5); + }); + + test("RPC getter works", async ({ expect }) => { + const res = await mf.dispatchFetch("http://localhost/getter"); + const data = (await res.json()) as { result: number }; + expect(data.result).toBe(5); + }); + + test("storage operations work", async ({ expect }) => { + const res = await mf.dispatchFetch("http://localhost/storage"); + const data = (await res.json()) as { result: string }; + expect(data.result).toBe("value"); + }); + + test("class name is preserved", async ({ expect }) => { + const res = await mf.dispatchFetch("http://localhost/class-name"); + const data = (await res.json()) as { result: string }; + expect(data.result).toBe("TestDO"); + }); + }); +}); diff --git a/packages/miniflare/test/plugins/local-explorer/do.spec.ts b/packages/miniflare/test/plugins/local-explorer/do.spec.ts index f89ecc7a2978..5a5d761f9749 100644 --- a/packages/miniflare/test/plugins/local-explorer/do.spec.ts +++ b/packages/miniflare/test/plugins/local-explorer/do.spec.ts @@ -385,6 +385,7 @@ describe("Durable Objects API", () => { script: ` export class BoundDO {} export class UnboundDO {} + export class UnboundSQLiteDO {} export default { fetch() { return new Response("ok"); } } `, unsafeLocalExplorer: true, @@ -426,4 +427,472 @@ describe("Durable Objects API", () => { } }); }); + + describe("POST /workers/durable_objects/namespaces/:namespace_id/query", () => { + let mf: Miniflare; + const namespaceId = "query-worker-SqliteDO"; + + beforeAll(async () => { + mf = new Miniflare({ + name: "query-worker", + inspectorPort: 0, + compatibilityDate: "2026-01-01", + compatibilityFlags: ["nodejs_compat"], + modules: true, + script: ` + import { DurableObject } from "cloudflare:workers"; + + export class SqliteDO extends DurableObject { + async fetch(request) { + const url = new URL(request.url); + const action = url.pathname.slice(1); + + if (action === "setup") { + // Create a table and insert test data + // The data can be customized via query params + const dataset = url.searchParams.get("dataset") || "default"; + + this.ctx.storage.sql.exec(\` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL + ) + \`); + + if (dataset === "alice") { + this.ctx.storage.sql.exec(\` + INSERT INTO users (id, name, email) VALUES + (1, 'Alice', 'alice@example.com') + \`); + } else { + // Default dataset with all users + this.ctx.storage.sql.exec(\` + INSERT INTO users (id, name, email) VALUES + (1, 'Alice', 'alice@example.com'), + (2, 'Bob', 'bob@example.com'), + (3, 'Charlie', 'charlie@example.com') + \`); + } + return new Response("setup complete"); + } + + return new Response("ok"); + } + } + + export class NonSqliteDO extends DurableObject { + async fetch(request) { + return new Response("ok"); + } + } + + export default { + async fetch(request, env) { + const url = new URL(request.url); + const name = url.searchParams.get("name") || "test"; + const id = env.SQLITE_DO.idFromName(name); + const stub = env.SQLITE_DO.get(id); + return stub.fetch(request); + } + } + `, + unsafeLocalExplorer: true, + durableObjects: { + SQLITE_DO: { className: "SqliteDO", useSQLite: true }, + NON_SQLITE_DO: { className: "NonSqliteDO", useSQLite: false }, + }, + }); + + // Initialize a DO instance and set up test data + const res = await mf.dispatchFetch( + "http://localhost/setup?name=test-object" + ); + await res.text(); + }); + + afterAll(async () => { + await disposeWithRetry(mf); + }); + + test("queries by name - reads data written by user code", async ({ + expect, + }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces/${namespaceId}/query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + durable_object_name: "test-object", + queries: [{ sql: "SELECT * FROM users ORDER BY id" }], + }), + } + ); + + expect(response.status).toBe(200); + const data = (await response.json()) as QueryResponse; + expect(data.result).toMatchInlineSnapshot(` + [ + { + "columns": [ + "id", + "name", + "email", + ], + "meta": { + "rows_read": 3, + "rows_written": 0, + }, + "rows": [ + [ + 1, + "Alice", + "alice@example.com", + ], + [ + 2, + "Bob", + "bob@example.com", + ], + [ + 3, + "Charlie", + "charlie@example.com", + ], + ], + }, + ] + `); + }); + + test("queries by ID - reads data written by user code", async ({ + expect, + }) => { + // Get the DO ID from the list endpoint + const listResponse = await mf.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces/${namespaceId}/objects` + ); + const listData = (await listResponse.json()) as ListObjectsResponse; + const objectId = listData.result[0]?.id; + + expect(objectId).toBeDefined(); + + const response = await mf.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces/${namespaceId}/query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + durable_object_id: objectId, + queries: [{ sql: "SELECT name, email FROM users WHERE id = 2" }], + }), + } + ); + + expect(response.status).toBe(200); + const data = (await response.json()) as QueryResponse; + expect(data.result).toMatchInlineSnapshot(` + [ + { + "columns": [ + "name", + "email", + ], + "meta": { + "rows_read": 1, + "rows_written": 0, + }, + "rows": [ + [ + "Bob", + "bob@example.com", + ], + ], + }, + ] + `); + }); + + test("can make multiple queries", async ({ expect }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces/${namespaceId}/query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + durable_object_name: "test-object", + queries: [ + { sql: "SELECT COUNT(*) as count FROM users" }, + { sql: "SELECT name FROM users WHERE id = 1" }, + { sql: "SELECT email FROM users WHERE id = 3" }, + ], + }), + } + ); + + expect(response.status).toBe(200); + const data = (await response.json()) as QueryResponse; + expect(data.result).toMatchInlineSnapshot(` + [ + { + "columns": [ + "count", + ], + "meta": { + "rows_read": 3, + "rows_written": 0, + }, + "rows": [ + [ + 3, + ], + ], + }, + { + "columns": [ + "name", + ], + "meta": { + "rows_read": 1, + "rows_written": 0, + }, + "rows": [ + [ + "Alice", + ], + ], + }, + { + "columns": [ + "email", + ], + "meta": { + "rows_read": 1, + "rows_written": 0, + }, + "rows": [ + [ + "charlie@example.com", + ], + ], + }, + ] + `); + }); + + test("parameterized query with user data", async ({ expect }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces/${namespaceId}/query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + durable_object_name: "test-object", + queries: [ + { + sql: "SELECT name, email FROM users WHERE id = ?", + params: [2], + }, + ], + }), + } + ); + + expect(response.status).toBe(200); + const data = (await response.json()) as QueryResponse; + expect(data.result).toMatchInlineSnapshot(` + [ + { + "columns": [ + "name", + "email", + ], + "meta": { + "rows_read": 1, + "rows_written": 0, + }, + "rows": [ + [ + "Bob", + "bob@example.com", + ], + ], + }, + ] + `); + }); + + test("each DO instance has isolated data", async ({ expect }) => { + const setupAlice = await mf.dispatchFetch( + "http://localhost/setup?name=alice-do&dataset=alice" + ); + await setupAlice.text(); + + // Query Alice's DO - should only have Alice + const aliceResponse = await mf.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces/${namespaceId}/query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + durable_object_name: "alice-do", + queries: [{ sql: "SELECT name FROM users" }], + }), + } + ); + const aliceData = (await aliceResponse.json()) as QueryResponse; + expect(aliceData.result).toMatchInlineSnapshot(` + [ + { + "columns": [ + "name", + ], + "meta": { + "rows_read": 1, + "rows_written": 0, + }, + "rows": [ + [ + "Alice", + ], + ], + }, + ] + `); + + // Verify the original test-object DO still has all 3 users + const testResponse = await mf.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces/${namespaceId}/query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + durable_object_name: "test-object", + queries: [{ sql: "SELECT COUNT(*) as count FROM users" }], + }), + } + ); + const testData = (await testResponse.json()) as QueryResponse; + expect(testData.result).toMatchInlineSnapshot(` + [ + { + "columns": [ + "count", + ], + "meta": { + "rows_read": 3, + "rows_written": 0, + }, + "rows": [ + [ + 3, + ], + ], + }, + ] + `); + }); + + test("returns 404 for non-existent namespace", async ({ expect }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces/non-existent/query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + durable_object_name: "test", + queries: [{ sql: "SELECT 1" }], + }), + } + ); + + expect(response.status).toBe(404); + const data = (await response.json()) as ErrorResponse; + + expect(data.success).toBe(false); + expect(data.errors[0].message).toContain("Namespace not found"); + }); + + test("returns 400 for non-SQLite namespace", async ({ expect }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces/query-worker-NonSqliteDO/query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + durable_object_name: "test", + queries: [{ sql: "SELECT 1" }], + }), + } + ); + + expect(response.status).toBe(400); + const data = (await response.json()) as ErrorResponse; + + expect(data.success).toBe(false); + expect(data.errors[0].message).toContain("does not use SQLite"); + }); + + test("returns 400 for invalid DO ID format", async ({ expect }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces/${namespaceId}/query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + durable_object_id: "not-a-valid-hex-id", + queries: [{ sql: "SELECT 1" }], + }), + } + ); + + expect(response.status).toBe(400); + const data = (await response.json()) as ErrorResponse; + + expect(data.success).toBe(false); + }); + + test("returns 400 for SQL syntax error", async ({ expect }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces/${namespaceId}/query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + durable_object_name: "test-object", + queries: [{ sql: "INVALID SQL SYNTAX" }], + }), + } + ); + + expect(response.status).toBe(400); + const data = (await response.json()) as ErrorResponse; + + expect(data.success).toBe(false); + }); + }); }); + +interface QueryResult { + columns: string[]; + rows: unknown[][]; + meta: { + rows_read: number; + rows_written: number; + }; +} + +interface QueryResponse { + success: boolean; + result: QueryResult[]; + errors: Array<{ code: number; message: string }>; + messages: Array<{ code: number; message: string }>; +} + +interface ErrorResponse { + success: boolean; + result: null; + errors: Array<{ code: number; message: string }>; + messages: Array<{ code: number; message: string }>; +} From b7841dbbdba9d8d419d9205e165583dac79a17da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Wed, 18 Feb 2026 16:54:33 +0000 Subject: [PATCH 2/4] Migrate tests from unstable_dev() to unstable_startWorker() (#12053) --- .gitignore | 2 +- .../tests/get-platform-proxy.env.test.ts | 70 ++-- .../local-mode-tests/tests/logging.test.ts | 28 +- .../local-mode-tests/tests/module.test.ts | 62 +-- fixtures/local-mode-tests/tests/ports.test.ts | 42 +- .../tests/specified-port.test.ts | 30 +- fixtures/local-mode-tests/tests/sw.test.ts | 22 +- fixtures/no-bundle-import/src/index.test.ts | 39 +- fixtures/unstable_dev/package.json | 17 - fixtures/unstable_dev/src/index.js | 5 - fixtures/unstable_dev/src/wrangler-dev.mjs | 11 - fixtures/unstable_dev/wrangler.jsonc | 8 - .../package.json | 2 + .../tests/index.test.ts | 295 +++++--------- .../tests/tsconfig.json | 5 +- .../tsconfig.json | 2 +- .../vitest.config.mts | 32 +- packages/wrangler/e2e/dev-registry.test.ts | 123 +----- .../wrangler/src/__tests__/api-dev.test.ts | 269 ------------- .../__tests__/middleware.scheduled.test.ts | 142 +++---- .../wrangler/src/__tests__/middleware.test.ts | 378 +++++++++--------- packages/wrangler/src/api/dev.ts | 1 - .../init-tests/test-jest-new-worker.js | 23 -- .../init-tests/test-vitest-new-worker.js | 24 -- .../init-tests/test-vitest-new-worker.ts | 25 -- packages/wrangler/templates/tsconfig.json | 1 - pnpm-lock.yaml | 16 +- .../auto-assign-issues.ts | 1 - 28 files changed, 551 insertions(+), 1124 deletions(-) delete mode 100644 fixtures/unstable_dev/package.json delete mode 100644 fixtures/unstable_dev/src/index.js delete mode 100644 fixtures/unstable_dev/src/wrangler-dev.mjs delete mode 100644 fixtures/unstable_dev/wrangler.jsonc delete mode 100644 packages/wrangler/src/__tests__/api-dev.test.ts delete mode 100644 packages/wrangler/templates/init-tests/test-jest-new-worker.js delete mode 100644 packages/wrangler/templates/init-tests/test-vitest-new-worker.js delete mode 100644 packages/wrangler/templates/init-tests/test-vitest-new-worker.ts diff --git a/.gitignore b/.gitignore index 941e123cc83e..c902ed872863 100644 --- a/.gitignore +++ b/.gitignore @@ -235,4 +235,4 @@ dist/** .node-cache/ AGENTS.local.md -.opencode/plans/ \ No newline at end of file +.opencode/plans/ diff --git a/fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts b/fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts index a693406b9320..f0e59b0025d7 100644 --- a/fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts +++ b/fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts @@ -1,17 +1,7 @@ import path from "path"; import { D1Database, R2Bucket } from "@cloudflare/workers-types"; import { toMatchImageSnapshot } from "jest-image-snapshot"; -/* eslint-disable workers-sdk/no-vitest-import-expect -- uses expect throughout tests */ -import { - afterEach, - beforeEach, - describe, - expect, - it, - MockInstance, - vi, -} from "vitest"; -/* eslint-enable workers-sdk/no-vitest-import-expect */ +import { beforeEach, describe, it, MockInstance, vi } from "vitest"; import { getPlatformProxy } from "./shared"; import type { Fetcher, @@ -20,7 +10,6 @@ import type { KVNamespace, Workflow, } from "@cloudflare/workers-types"; -import type { Unstable_DevWorker } from "wrangler"; type Env = { MY_VAR: string; @@ -41,7 +30,6 @@ type Env = { const wranglerConfigFilePath = path.join(__dirname, "..", "wrangler.jsonc"); describe("getPlatformProxy - env", () => { - let devWorkers: Unstable_DevWorker[]; let warn = {} as MockInstance; beforeEach(() => { @@ -52,7 +40,9 @@ describe("getPlatformProxy - env", () => { }); describe("var bindings", () => { - it("correctly obtains var bindings from both wrangler config and .dev.vars", async () => { + it("correctly obtains var bindings from both wrangler config and .dev.vars", async ({ + expect, + }) => { const { env, dispose } = await getPlatformProxy({ configPath: wranglerConfigFilePath, }); @@ -68,7 +58,9 @@ describe("getPlatformProxy - env", () => { } }); - it("correctly makes vars from .dev.vars override the ones in wrangler config", async () => { + it("correctly makes vars from .dev.vars override the ones in wrangler config", async ({ + expect, + }) => { const { env, dispose } = await getPlatformProxy({ configPath: wranglerConfigFilePath, }); @@ -81,7 +73,9 @@ describe("getPlatformProxy - env", () => { } }); - it("correctly makes vars from .dev.vars not override bindings of the same name from wrangler config", async () => { + it("correctly makes vars from .dev.vars not override bindings of the same name from wrangler config", async ({ + expect, + }) => { const { env, dispose } = await getPlatformProxy({ configPath: wranglerConfigFilePath, }); @@ -99,7 +93,9 @@ describe("getPlatformProxy - env", () => { } }); - it("correctly reads a toml from a custom path alongside with its .dev.vars", async () => { + it("correctly reads a toml from a custom path alongside with its .dev.vars", async ({ + expect, + }) => { const { env, dispose } = await getPlatformProxy({ configPath: path.join( __dirname, @@ -123,7 +119,7 @@ describe("getPlatformProxy - env", () => { }); }); - it("correctly reads a json config file", async () => { + it("correctly reads a json config file", async ({ expect }) => { const { env, dispose } = await getPlatformProxy({ configPath: path.join(__dirname, "..", "wrangler.json"), }); @@ -139,7 +135,7 @@ describe("getPlatformProxy - env", () => { } }); - it("correctly obtains functioning ASSETS bindings", async () => { + it("correctly obtains functioning ASSETS bindings", async ({ expect }) => { const { env, dispose } = await getPlatformProxy({ configPath: wranglerConfigFilePath, }); @@ -149,7 +145,7 @@ describe("getPlatformProxy - env", () => { await dispose(); }); - it("correctly obtains functioning KV bindings", async () => { + it("correctly obtains functioning KV bindings", async ({ expect }) => { const { env, dispose } = await getPlatformProxy({ configPath: wranglerConfigFilePath, }); @@ -164,7 +160,7 @@ describe("getPlatformProxy - env", () => { await dispose(); }); - it("correctly obtains functioning R2 bindings", async () => { + it("correctly obtains functioning R2 bindings", async ({ expect }) => { const { env, dispose } = await getPlatformProxy({ configPath: wranglerConfigFilePath, }); @@ -182,7 +178,7 @@ describe("getPlatformProxy - env", () => { } }); - it("correctly obtains functioning D1 bindings", async () => { + it("correctly obtains functioning D1 bindings", async ({ expect }) => { const { env, dispose } = await getPlatformProxy({ configPath: wranglerConfigFilePath, }); @@ -210,7 +206,7 @@ describe("getPlatformProxy - env", () => { } }); - it("correctly obtains functioning Image bindings", async () => { + it("correctly obtains functioning Image bindings", async ({ expect }) => { expect.extend({ toMatchImageSnapshot }); const { env, dispose } = await getPlatformProxy({ @@ -249,7 +245,9 @@ describe("getPlatformProxy - env", () => { // Important: the hyperdrive values are passthrough ones since the workerd specific hyperdrive values only make sense inside // workerd itself and would simply not work in a node.js process - it("correctly obtains passthrough Hyperdrive bindings", async () => { + it("correctly obtains passthrough Hyperdrive bindings", async ({ + expect, + }) => { const { env, dispose } = await getPlatformProxy({ configPath: wranglerConfigFilePath, }); @@ -269,7 +267,7 @@ describe("getPlatformProxy - env", () => { }); describe("DO warnings", () => { - it("warns about internal DOs and doesn't crash", async () => { + it("warns about internal DOs and doesn't crash", async ({ expect }) => { await getPlatformProxy({ configPath: path.join(__dirname, "..", "wrangler_internal_do.jsonc"), }); @@ -285,14 +283,16 @@ describe("getPlatformProxy - env", () => { `); }); - it("doesn't warn about external DOs and doesn't crash", async () => { + it("doesn't warn about external DOs and doesn't crash", async ({ + expect, + }) => { await getPlatformProxy({ configPath: path.join(__dirname, "..", "wrangler_external_do.jsonc"), }); expect(warn).not.toHaveBeenCalled(); }); - it("warns about Workflows and doesn't crash", async () => { + it("warns about Workflows and doesn't crash", async ({ expect }) => { await getPlatformProxy({ configPath: path.join(__dirname, "..", "wrangler_workflow.jsonc"), }); @@ -308,7 +308,9 @@ describe("getPlatformProxy - env", () => { }); describe("with a target environment", () => { - it("should provide bindings targeting a specified environment and also inherit top-level ones", async () => { + it("should provide bindings targeting a specified environment and also inherit top-level ones", async ({ + expect, + }) => { const { env, dispose } = await getPlatformProxy({ configPath: wranglerConfigFilePath, environment: "production", @@ -325,7 +327,9 @@ describe("getPlatformProxy - env", () => { } }); - it("should not provide bindings targeting an environment when none was specified", async () => { + it("should not provide bindings targeting an environment when none was specified", async ({ + expect, + }) => { const { env, dispose } = await getPlatformProxy({ configPath: wranglerConfigFilePath, }); @@ -341,7 +345,9 @@ describe("getPlatformProxy - env", () => { } }); - it("should provide secrets targeting a specified environment", async () => { + it("should provide secrets targeting a specified environment", async ({ + expect, + }) => { const { env, dispose } = await getPlatformProxy({ configPath: wranglerConfigFilePath, environment: "production", @@ -355,7 +361,9 @@ describe("getPlatformProxy - env", () => { } }); - it("should error if a non-existent environment is provided", async () => { + it("should error if a non-existent environment is provided", async ({ + expect, + }) => { await expect( getPlatformProxy({ configPath: wranglerConfigFilePath, diff --git a/fixtures/local-mode-tests/tests/logging.test.ts b/fixtures/local-mode-tests/tests/logging.test.ts index 45b6c5d84926..71248dfa1587 100644 --- a/fixtures/local-mode-tests/tests/logging.test.ts +++ b/fixtures/local-mode-tests/tests/logging.test.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { setTimeout } from "node:timers/promises"; import util from "node:util"; import { afterEach, beforeEach, it, Mock, vi } from "vitest"; -import { unstable_dev } from "wrangler"; +import { unstable_startWorker } from "wrangler"; let output = ""; function spyOnConsoleMethod(name: keyof typeof console) { @@ -25,21 +25,17 @@ afterEach(() => { it("logs startup errors", async ({ expect }) => { let caughtError: unknown; try { - const worker = await unstable_dev( - path.resolve(__dirname, "..", "src", "nodejs-compat.ts"), - { - config: path.resolve(__dirname, "..", "wrangler.logging.jsonc"), - // Intentionally omitting `compatibilityFlags: ["nodejs_compat"]` - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - devEnv: true, - }, - } - ); - await worker.stop(); - expect.fail("Expected unstable_dev() to fail"); + const worker = await unstable_startWorker({ + entrypoint: path.resolve(__dirname, "../src/nodejs-compat.ts"), + config: path.resolve(__dirname, "../wrangler.logging.jsonc"), + // Intentionally omitting `compatibilityFlags: ["nodejs_compat"]` + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, + }, + }); + await worker.dispose(); + expect.fail("Expected unstable_startWorker() to fail"); } catch (e) { caughtError = e; } diff --git a/fixtures/local-mode-tests/tests/module.test.ts b/fixtures/local-mode-tests/tests/module.test.ts index b225a50ade7a..a9b7fe04fd7e 100644 --- a/fixtures/local-mode-tests/tests/module.test.ts +++ b/fixtures/local-mode-tests/tests/module.test.ts @@ -1,10 +1,9 @@ import path from "path"; import { afterAll, beforeAll, describe, it } from "vitest"; -import { unstable_dev } from "wrangler"; -import type { Unstable_DevWorker } from "wrangler"; +import { unstable_startWorker } from "wrangler"; describe("module worker", () => { - let worker: Unstable_DevWorker; + let worker: Awaited>; let originalNodeEnv: string | undefined; @@ -14,25 +13,22 @@ describe("module worker", () => { process.env.NODE_ENV = "local-testing"; - worker = await unstable_dev( - path.resolve(__dirname, "..", "src", "module.ts"), - { - config: path.resolve(__dirname, "..", "wrangler.module.jsonc"), - vars: { VAR4: "https://google.com" }, - ip: "127.0.0.1", - port: 0, - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - devEnv: true, - }, - } - ); + worker = await unstable_startWorker({ + entrypoint: path.resolve(__dirname, "../src/module.ts"), + config: path.resolve(__dirname, "../wrangler.module.jsonc"), + bindings: { + VAR4: { type: "plain_text", value: "https://google.com" }, + }, + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, + }, + }); }); afterAll(async () => { try { - await worker.stop(); + await worker.dispose(); } catch (e) { console.error("Failed to stop worker", e); } @@ -40,7 +36,7 @@ describe("module worker", () => { }); it("renders variables", async ({ expect }) => { - const resp = await worker.fetch("/vars"); + const resp = await worker.fetch("http://example.com/vars"); expect(resp).not.toBe(undefined); const text = await resp.text(); @@ -60,13 +56,15 @@ describe("module worker", () => { }); it("should return Hi by default", async ({ expect }) => { - const resp = await worker.fetch("/"); + const resp = await worker.fetch("http://example.com/"); expect(resp).not.toBe(undefined); const respJson = await resp.text(); expect(respJson).toBe(JSON.stringify({ greeting: "Hi!" })); }); it("should return Bonjour when French", async ({ expect }) => { - const resp = await worker.fetch("/", { headers: { lang: "fr-FR" } }); + const resp = await worker.fetch("http://example.com/", { + headers: { lang: "fr-FR" }, + }); expect(resp).not.toBe(undefined); if (resp) { const respJson = await resp.text(); @@ -75,7 +73,9 @@ describe("module worker", () => { }); it("should return G'day when Australian", async ({ expect }) => { - const resp = await worker.fetch("/", { headers: { lang: "en-AU" } }); + const resp = await worker.fetch("http://example.com/", { + headers: { lang: "en-AU" }, + }); expect(resp).not.toBe(undefined); if (resp) { const respJson = await resp.text(); @@ -84,7 +84,9 @@ describe("module worker", () => { }); it("should return Good day when British", async ({ expect }) => { - const resp = await worker.fetch("/", { headers: { lang: "en-GB" } }); + const resp = await worker.fetch("http://example.com/", { + headers: { lang: "en-GB" }, + }); expect(resp).not.toBe(undefined); if (resp) { const respJson = await resp.text(); @@ -93,7 +95,9 @@ describe("module worker", () => { }); it("should return Howdy when Texan", async ({ expect }) => { - const resp = await worker.fetch("/", { headers: { lang: "en-TX" } }); + const resp = await worker.fetch("http://example.com/", { + headers: { lang: "en-TX" }, + }); expect(resp).not.toBe(undefined); if (resp) { const respJson = await resp.text(); @@ -102,7 +106,9 @@ describe("module worker", () => { }); it("should return Hello when American", async ({ expect }) => { - const resp = await worker.fetch("/", { headers: { lang: "en-US" } }); + const resp = await worker.fetch("http://example.com/", { + headers: { lang: "en-US" }, + }); expect(resp).not.toBe(undefined); if (resp) { const respJson = await resp.text(); @@ -111,7 +117,9 @@ describe("module worker", () => { }); it("should return Hola when Spanish", async ({ expect }) => { - const resp = await worker.fetch("/", { headers: { lang: "es-ES" } }); + const resp = await worker.fetch("http://example.com/", { + headers: { lang: "es-ES" }, + }); expect(resp).not.toBe(undefined); if (resp) { const respJson = await resp.text(); @@ -120,7 +128,7 @@ describe("module worker", () => { }); it("returns hex string", async ({ expect }) => { - const resp = await worker.fetch("/buffer"); + const resp = await worker.fetch("http://example.com/buffer"); expect(resp).not.toBe(undefined); const text = await resp.text(); diff --git a/fixtures/local-mode-tests/tests/ports.test.ts b/fixtures/local-mode-tests/tests/ports.test.ts index a808285426ed..e44060f5b22b 100644 --- a/fixtures/local-mode-tests/tests/ports.test.ts +++ b/fixtures/local-mode-tests/tests/ports.test.ts @@ -1,49 +1,45 @@ import path from "path"; import { afterAll, beforeAll, describe, it } from "vitest"; -import { unstable_dev } from "wrangler"; -import type { Unstable_DevWorker } from "wrangler"; +import { unstable_startWorker } from "wrangler"; describe("multiple workers", () => { - let workers: Unstable_DevWorker[]; + let workers: Awaited>[]; beforeAll(async () => { //since the script is invoked from the directory above, need to specify index.js is in src/ workers = await Promise.all([ - unstable_dev(path.resolve(__dirname, "..", "src", "module.ts"), { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - devEnv: true, + unstable_startWorker({ + entrypoint: path.resolve(__dirname, "../src/module.ts"), + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }), - unstable_dev(path.resolve(__dirname, "..", "src", "module.ts"), { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - devEnv: true, + unstable_startWorker({ + entrypoint: path.resolve(__dirname, "../src/module.ts"), + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }), - unstable_dev(path.resolve(__dirname, "..", "src", "module.ts"), { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - devEnv: true, + unstable_startWorker({ + entrypoint: path.resolve(__dirname, "../src/module.ts"), + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }), ]); }); afterAll(async () => { - await Promise.all(workers.map(async (worker) => await worker.stop())); + await Promise.all(workers.map(async (worker) => await worker.dispose())); }); it.concurrent("all workers should be fetchable", async ({ expect }) => { const responses = await Promise.all( - workers.map(async (worker) => await worker.fetch()) + workers.map(async (worker) => await worker.fetch("http://example.com/")) ); const parsedResponses = await Promise.all( responses.map(async (resp) => await resp.text()) diff --git a/fixtures/local-mode-tests/tests/specified-port.test.ts b/fixtures/local-mode-tests/tests/specified-port.test.ts index 21c02841f714..086fcb5ae3b0 100644 --- a/fixtures/local-mode-tests/tests/specified-port.test.ts +++ b/fixtures/local-mode-tests/tests/specified-port.test.ts @@ -1,8 +1,7 @@ import nodeNet from "node:net"; import path from "path"; import { afterAll, assert, beforeAll, describe, it } from "vitest"; -import { unstable_dev } from "wrangler"; -import type { Unstable_DevWorker } from "wrangler"; +import { unstable_startWorker } from "wrangler"; function getPort() { return new Promise((resolve, reject) => { @@ -19,30 +18,25 @@ function getPort() { } describe("specific port", () => { - let worker: Unstable_DevWorker; + let worker: Awaited>; beforeAll(async () => { - worker = await unstable_dev( - path.resolve(__dirname, "..", "src", "module.ts"), - { - config: path.resolve(__dirname, "..", "wrangler.module.jsonc"), - port: await getPort(), - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - devEnv: true, - }, - } - ); + worker = await unstable_startWorker({ + entrypoint: path.resolve(__dirname, "../src/module.ts"), + config: path.resolve(__dirname, "../wrangler.module.jsonc"), + dev: { + server: { hostname: "127.0.0.1", port: await getPort() }, + inspector: false, + }, + }); }); afterAll(async () => { - await worker?.stop(); + await worker?.dispose(); }); it("fetches worker", async ({ expect }) => { - const resp = await worker.fetch("/"); + const resp = await worker.fetch("http://example.com/"); expect(resp.status).toBe(200); }); }); diff --git a/fixtures/local-mode-tests/tests/sw.test.ts b/fixtures/local-mode-tests/tests/sw.test.ts index 6866b2bc8e65..96cfd278e5de 100644 --- a/fixtures/local-mode-tests/tests/sw.test.ts +++ b/fixtures/local-mode-tests/tests/sw.test.ts @@ -1,10 +1,9 @@ import path from "path"; import { afterAll, beforeAll, describe, it } from "vitest"; -import { unstable_dev } from "wrangler"; -import type { Unstable_DevWorker } from "wrangler"; +import { unstable_startWorker } from "wrangler"; describe("service worker", () => { - let worker: Unstable_DevWorker; + let worker: Awaited>; let originalNodeEnv: string | undefined; @@ -14,24 +13,23 @@ describe("service worker", () => { process.env.NODE_ENV = "local-testing"; //since the script is invoked from the directory above, need to specify index.js is in src/ - worker = await unstable_dev(path.resolve(__dirname, "..", "src", "sw.ts"), { - config: path.resolve(__dirname, "..", "wrangler.sw.jsonc"), - ip: "127.0.0.1", - experimental: { - disableDevRegistry: true, - disableExperimentalWarning: true, - devEnv: true, + worker = await unstable_startWorker({ + entrypoint: path.resolve(__dirname, "../src/sw.ts"), + config: path.resolve(__dirname, "../wrangler.sw.jsonc"), + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); }); afterAll(async () => { - await worker.stop(); + await worker.dispose(); process.env.NODE_ENV = originalNodeEnv; }); it("renders", async ({ expect }) => { - const resp = await worker.fetch(); + const resp = await worker.fetch("http://example.com/"); expect(resp).not.toBe(undefined); const text = await resp.text(); diff --git a/fixtures/no-bundle-import/src/index.test.ts b/fixtures/no-bundle-import/src/index.test.ts index 5d3a6bb6af57..17a640f5ffbe 100644 --- a/fixtures/no-bundle-import/src/index.test.ts +++ b/fixtures/no-bundle-import/src/index.test.ts @@ -1,26 +1,25 @@ import path from "path"; import { afterAll, beforeAll, describe, test } from "vitest"; -import { unstable_dev } from "wrangler"; -import type { Unstable_DevWorker } from "wrangler"; +import { unstable_startWorker } from "wrangler"; describe("Worker", () => { - let worker: Unstable_DevWorker; + let worker: Awaited>; beforeAll(async () => { - worker = await unstable_dev(path.resolve(__dirname, "index.js"), { - logLevel: "none", - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - devEnv: true, + worker = await unstable_startWorker({ + entrypoint: path.resolve(__dirname, "index.js"), + dev: { + logLevel: "none", + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); }, 30_000); - afterAll(() => worker.stop()); + afterAll(() => worker.dispose()); test("module traversal results in correct response", async ({ expect }) => { - const resp = await worker.fetch(); + const resp = await worker.fetch("http://example.com/"); const text = await resp.text(); expect(text).toMatchInlineSnapshot( `"Hello Jane Smith and Hello John Smith"` @@ -30,7 +29,7 @@ describe("Worker", () => { test("module traversal results in correct response for CommonJS", async ({ expect, }) => { - const resp = await worker.fetch("/cjs"); + const resp = await worker.fetch("http://example.com/cjs"); const text = await resp.text(); expect(text).toMatchInlineSnapshot( `"CJS: Hello Jane Smith and Hello John Smith"` @@ -40,43 +39,43 @@ describe("Worker", () => { test("correct response for CommonJS which imports ESM", async ({ expect, }) => { - const resp = await worker.fetch("/cjs-loop"); + const resp = await worker.fetch("http://example.com/cjs-loop"); const text = await resp.text(); expect(text).toMatchInlineSnapshot('"CJS: cjs-string"'); }); test("support for dynamic imports", async ({ expect }) => { - const resp = await worker.fetch("/dynamic"); + const resp = await worker.fetch("http://example.com/dynamic"); const text = await resp.text(); expect(text).toMatchInlineSnapshot(`"dynamic"`); }); test("basic wasm support", async ({ expect }) => { - const resp = await worker.fetch("/wasm"); + const resp = await worker.fetch("http://example.com/wasm"); const text = await resp.text(); expect(text).toMatchInlineSnapshot('"42"'); }); test("resolves wasm import paths relative to root", async ({ expect }) => { - const resp = await worker.fetch("/wasm-nested"); + const resp = await worker.fetch("http://example.com/wasm-nested"); const text = await resp.text(); expect(text).toMatchInlineSnapshot('"nested42"'); }); test("wasm can be imported from a dynamic import", async ({ expect }) => { - const resp = await worker.fetch("/wasm-dynamic"); + const resp = await worker.fetch("http://example.com/wasm-dynamic"); const text = await resp.text(); expect(text).toMatchInlineSnapshot('"sibling42subdirectory42"'); }); test("text data can be imported", async ({ expect }) => { - const resp = await worker.fetch("/txt"); + const resp = await worker.fetch("http://example.com/txt"); const text = await resp.text(); expect(text).toMatchInlineSnapshot('"TEST DATA"'); }); test("binary data can be imported", async ({ expect }) => { - const resp = await worker.fetch("/bin"); + const resp = await worker.fetch("http://example.com/bin"); const bin = await resp.arrayBuffer(); const expected = new Uint8Array(new ArrayBuffer(4)); expected.set([0, 1, 2, 10]); @@ -86,7 +85,7 @@ describe("Worker", () => { test("actual dynamic import (that cannot be inlined by an esbuild run)", async ({ expect, }) => { - const resp = await worker.fetch("/lang/fr.json"); + const resp = await worker.fetch("http://example.com/lang/fr.json"); const text = await resp.text(); expect(text).toMatchInlineSnapshot('"Bonjour"'); }); diff --git a/fixtures/unstable_dev/package.json b/fixtures/unstable_dev/package.json deleted file mode 100644 index 307b7250b5b3..000000000000 --- a/fixtures/unstable_dev/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@fixture/unstable-dev", - "private": true, - "description": "", - "license": "ISC", - "author": "", - "scripts": { - "dev": "node --no-warnings ./src/wrangler-dev.mjs", - "test:ci": "npm run dev" - }, - "devDependencies": { - "wrangler": "workspace:*" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/fixtures/unstable_dev/src/index.js b/fixtures/unstable_dev/src/index.js deleted file mode 100644 index 2a8547782586..000000000000 --- a/fixtures/unstable_dev/src/index.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - async fetch(request) { - return new Response(`${request.url} ${new Date()}`); - }, -}; diff --git a/fixtures/unstable_dev/src/wrangler-dev.mjs b/fixtures/unstable_dev/src/wrangler-dev.mjs deleted file mode 100644 index 8ae981ce8cdc..000000000000 --- a/fixtures/unstable_dev/src/wrangler-dev.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import { unstable_dev } from "wrangler"; - -//since the script is invoked from the directory above, need to specify index.js is in src/ -const worker = await unstable_dev("dist/out.js", { config: "wrangler.jsonc" }); - -const resp = await worker.fetch("http://localhost:8787/"); -const text = await resp.text(); - -console.log("Invoked worker: ", text); - -await worker.stop(); diff --git a/fixtures/unstable_dev/wrangler.jsonc b/fixtures/unstable_dev/wrangler.jsonc deleted file mode 100644 index bc93fcbd9608..000000000000 --- a/fixtures/unstable_dev/wrangler.jsonc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "unstable_dev", - "main": "dist/out.js", - "compatibility_date": "2022-12-08", - "build": { - "command": "npx esbuild src/index.js --outfile=dist/out.js", - }, -} diff --git a/packages/edge-preview-authenticated-proxy/package.json b/packages/edge-preview-authenticated-proxy/package.json index ac48969d1bb7..2b96493011f1 100644 --- a/packages/edge-preview-authenticated-proxy/package.json +++ b/packages/edge-preview-authenticated-proxy/package.json @@ -12,12 +12,14 @@ }, "devDependencies": { "@cloudflare/eslint-config-shared": "workspace:*", + "@cloudflare/vitest-pool-workers": "catalog:default", "@cloudflare/workers-types": "catalog:default", "@types/cookie": "^0.6.0", "cookie": "^0.7.2", "eslint": "catalog:default", "promjs": "^0.4.2", "toucan-js": "4.0.0", + "vitest": "catalog:default", "wrangler": "workspace:*" }, "volta": { diff --git a/packages/edge-preview-authenticated-proxy/tests/index.test.ts b/packages/edge-preview-authenticated-proxy/tests/index.test.ts index a9344492a9f5..1b40a8e4aaab 100644 --- a/packages/edge-preview-authenticated-proxy/tests/index.test.ts +++ b/packages/edge-preview-authenticated-proxy/tests/index.test.ts @@ -1,10 +1,9 @@ import { randomBytes } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, describe, it, vi } from "vitest"; -import { unstable_dev } from "wrangler"; -import type { Unstable_DevWorker } from "wrangler"; +import { SELF } from "cloudflare:test"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; + +// Mock URL for the remote worker - all outbound fetches will be intercepted +const MOCK_REMOTE_URL = "http://mock-remote.test"; function removeUUID(str: string) { return str.replace( @@ -13,88 +12,80 @@ function removeUUID(str: string) { ); } -describe("Preview Worker", () => { - let worker: Unstable_DevWorker; - let remote: Unstable_DevWorker; - let tmpDir: string; - - beforeAll(async () => { - worker = await unstable_dev(path.join(__dirname, "../src/index.ts"), { - config: path.join(__dirname, "../wrangler.jsonc"), - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - }, - logLevel: "none", - }); +// Mock implementation for the remote worker +function createMockFetchImplementation() { + return async (input: Request | string | URL, init?: RequestInit) => { + const request = new Request(input, init); + const url = new URL(request.url); - tmpDir = await fs.realpath( - await fs.mkdtemp(path.join(os.tmpdir(), "preview-tests")) - ); - - await fs.writeFile( - path.join(tmpDir, "remote.js"), - /*javascript*/ ` - export default { - fetch(request) { - const url = new URL(request.url) - if(url.pathname === "/exchange") { - return Response.json({ - token: "TEST_TOKEN", - prewarm: "TEST_PREWARM" - }) - } - if(url.pathname === "/redirect") { - return Response.redirect("https://example.com", 302) - } - if(url.pathname === "/method") { - return new Response(request.method) - } - if(url.pathname === "/status") { - return new Response(407) - } - if(url.pathname === "/header") { - return new Response(request.headers.get("X-Custom-Header")) - } - return Response.json({ - url: request.url, - headers: [...request.headers.entries()] - }) - } - } - `.trim() - ); + // Only intercept requests to our mock remote URL + if (url.origin !== MOCK_REMOTE_URL) { + return new Response("OK", { status: 200 }); + } - await fs.writeFile( - path.join(tmpDir, "wrangler.toml"), - /*toml*/ ` -name = "remote-worker" -compatibility_date = "2023-01-01" - `.trim() - ); + if (url.pathname === "/exchange") { + return Response.json({ + token: "TEST_TOKEN", + prewarm: "TEST_PREWARM", + }); + } + if (url.pathname === "/redirect") { + // Use manual redirect to avoid trailing slash being added + return new Response(null, { + status: 302, + headers: { Location: "https://example.com" }, + }); + } + if (url.pathname === "/method") { + // HEAD requests should return empty body + const body = request.method === "HEAD" ? null : request.method; + return new Response(body, { + headers: { "Test-Http-Method": request.method }, + }); + } + if (url.pathname === "/status") { + return new Response("407"); + } + if (url.pathname === "/header") { + return new Response(request.headers.get("X-Custom-Header")); + } + if (url.pathname === "/cookies") { + const headers = new Headers(); + headers.append("Set-Cookie", "foo=1"); + headers.append("Set-Cookie", "bar=2"); + return new Response(undefined, { headers }); + } + if (url.pathname === "/prewarm") { + return new Response("OK"); + } - remote = await unstable_dev(path.join(tmpDir, "remote.js"), { - config: path.join(tmpDir, "wrangler.toml"), - ip: "127.0.0.1", - experimental: { disableExperimentalWarning: true }, - }); - }); + return Response.json( + { + url: request.url, + headers: [...request.headers.entries()], + }, + { headers: { "Content-Encoding": "identity" } } + ); + }; +} - afterAll(async () => { - await worker.stop(); - await remote.stop(); +beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockImplementation( + createMockFetchImplementation() + ); +}); - try { - await fs.rm(tmpDir, { recursive: true }); - } catch {} - }); +afterEach(() => { + vi.restoreAllMocks(); +}); +describe("Preview Worker", () => { let tokenId: string | null = null; it("should obtain token from exchange_url", async ({ expect }) => { - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://preview.devprod.cloudflare.dev/exchange?exchange_url=${encodeURIComponent( - `http://127.0.0.1:${remote.port}/exchange` + `${MOCK_REMOTE_URL}/exchange` )}`, { method: "POST", @@ -112,7 +103,7 @@ compatibility_date = "2023-01-01" }); it("should reject invalid exchange_url", async ({ expect }) => { vi.spyOn(console, "error").mockImplementation(() => {}); - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://preview.devprod.cloudflare.dev/exchange?exchange_url=not_an_exchange_url`, { method: "POST" } ); @@ -126,13 +117,13 @@ compatibility_date = "2023-01-01" const token = randomBytes(4096).toString("hex"); expect(token.length).toBe(8192); - let resp = await worker.fetch( + let resp = await SELF.fetch( `https://random-data.preview.devprod.cloudflare.dev/.update-preview-token?token=${encodeURIComponent( token )}&prewarm=${encodeURIComponent( - `http://127.0.0.1:${remote.port}/prewarm` + `${MOCK_REMOTE_URL}/prewarm` )}&remote=${encodeURIComponent( - `http://127.0.0.1:${remote.port}` + MOCK_REMOTE_URL )}&suffix=${encodeURIComponent("/hello?world")}`, { method: "GET", @@ -151,7 +142,7 @@ compatibility_date = "2023-01-01" tokenId = (resp.headers.get("set-cookie") ?? "") .split(";")[0] .split("=")[1]; - resp = await worker.fetch( + resp = await SELF.fetch( `https://random-data.preview.devprod.cloudflare.dev`, { method: "GET", @@ -165,16 +156,16 @@ compatibility_date = "2023-01-01" const json = (await resp.json()) as any; expect(json).toMatchObject({ - url: `http://127.0.0.1:${remote.port}/`, + url: `${MOCK_REMOTE_URL}/`, headers: expect.arrayContaining([["cf-workers-preview-token", token]]), }); }); it("should be redirected with cookie", async ({ expect }) => { - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://random-data.preview.devprod.cloudflare.dev/.update-preview-token?token=TEST_TOKEN&prewarm=${encodeURIComponent( - `http://127.0.0.1:${remote.port}/prewarm` + `${MOCK_REMOTE_URL}/prewarm` )}&remote=${encodeURIComponent( - `http://127.0.0.1:${remote.port}` + MOCK_REMOTE_URL )}&suffix=${encodeURIComponent("/hello?world")}`, { method: "GET", @@ -195,9 +186,9 @@ compatibility_date = "2023-01-01" }); it("should reject invalid prewarm url", async ({ expect }) => { vi.spyOn(console, "error").mockImplementation(() => {}); - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://random-data.preview.devprod.cloudflare.dev/.update-preview-token?token=TEST_TOKEN&prewarm=not_a_prewarm_url&remote=${encodeURIComponent( - `http://127.0.0.1:${remote.port}` + MOCK_REMOTE_URL )}&suffix=${encodeURIComponent("/hello?world")}` ); expect(resp.status).toBe(400); @@ -207,9 +198,9 @@ compatibility_date = "2023-01-01" }); it("should reject invalid remote url", async ({ expect }) => { vi.spyOn(console, "error").mockImplementation(() => {}); - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://random-data.preview.devprod.cloudflare.dev/.update-preview-token?token=TEST_TOKEN&prewarm=${encodeURIComponent( - `http://127.0.0.1:${remote.port}/prewarm` + `${MOCK_REMOTE_URL}/prewarm` )}&remote=not_a_remote_url&suffix=${encodeURIComponent("/hello?world")}` ); expect(resp.status).toBe(400); @@ -219,7 +210,7 @@ compatibility_date = "2023-01-01" }); it("should convert cookie to header", async ({ expect }) => { - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://random-data.preview.devprod.cloudflare.dev`, { method: "GET", @@ -231,14 +222,14 @@ compatibility_date = "2023-01-01" const json = (await resp.json()) as { headers: string[][]; url: string }; expect(json).toMatchObject({ - url: `http://127.0.0.1:${remote.port}/`, + url: `${MOCK_REMOTE_URL}/`, headers: expect.arrayContaining([ ["cf-workers-preview-token", "TEST_TOKEN"], ]), }); }); it("should not follow redirects", async ({ expect }) => { - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://random-data.preview.devprod.cloudflare.dev/redirect`, { method: "GET", @@ -256,7 +247,7 @@ compatibility_date = "2023-01-01" expect(await resp.text()).toMatchInlineSnapshot('""'); }); it("should return method", async ({ expect }) => { - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://random-data.preview.devprod.cloudflare.dev/method`, { method: "PUT", @@ -270,7 +261,7 @@ compatibility_date = "2023-01-01" expect(await resp.text()).toMatchInlineSnapshot('"PUT"'); }); it("should return header", async ({ expect }) => { - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://random-data.preview.devprod.cloudflare.dev/header`, { method: "PUT", @@ -285,7 +276,7 @@ compatibility_date = "2023-01-01" expect(await resp.text()).toMatchInlineSnapshot('"custom"'); }); it("should return status", async ({ expect }) => { - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://random-data.preview.devprod.cloudflare.dev/status`, { method: "PUT", @@ -301,92 +292,10 @@ compatibility_date = "2023-01-01" }); describe("Raw HTTP preview", () => { - let worker: Unstable_DevWorker; - let remote: Unstable_DevWorker; - let tmpDir: string; - - beforeAll(async () => { - worker = await unstable_dev(path.join(__dirname, "../src/index.ts"), { - // @ts-expect-error TODO: figure out the right way to get the server to accept host from the request - host: "0000.rawhttp.devprod.cloudflare.dev", - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - }, - }); - - tmpDir = await fs.realpath( - await fs.mkdtemp(path.join(os.tmpdir(), "preview-tests")) - ); - - await fs.writeFile( - path.join(tmpDir, "remote.js"), - /*javascript*/ ` - export default { - fetch(request) { - const url = new URL(request.url) - if(url.pathname === "/exchange") { - return Response.json({ - token: "TEST_TOKEN", - prewarm: "TEST_PREWARM" - }) - } - if(url.pathname === "/redirect") { - return Response.redirect("https://example.com", 302) - } - if(url.pathname === "/method") { - return new Response(request.method, { - headers: { "Test-Http-Method": request.method }, - }) - } - if(url.pathname === "/status") { - return new Response(407) - } - if(url.pathname === "/header") { - return new Response(request.headers.get("X-Custom-Header")) - } - if(url.pathname === "/cookies") { - const headers = new Headers(); - - headers.append("Set-Cookie", "foo=1"); - headers.append("Set-Cookie", "bar=2"); - - return new Response(undefined, { - headers, - }); - } - return Response.json({ - url: request.url, - headers: [...request.headers.entries()] - }, { headers: { "Content-Encoding": "identity" } }) - } - } - `.trim() - ); - - await fs.writeFile( - path.join(tmpDir, "wrangler.toml"), - /*toml*/ ` -name = "remote-worker" -compatibility_date = "2023-01-01" - `.trim() - ); - - remote = await unstable_dev(path.join(tmpDir, "remote.js"), { - config: path.join(tmpDir, "wrangler.toml"), - ip: "127.0.0.1", - experimental: { disableExperimentalWarning: true }, - }); - }); - - afterAll(async () => { - await worker.stop(); - }); - it("should allow arbitrary headers in cross-origin requests", async ({ expect, }) => { - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://0000.rawhttp.devprod.cloudflare.dev`, { method: "OPTIONS", @@ -403,7 +312,7 @@ compatibility_date = "2023-01-01" it("should allow arbitrary methods in cross-origin requests", async ({ expect, }) => { - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://0000.rawhttp.devprod.cloudflare.dev`, { method: "OPTIONS", @@ -419,7 +328,7 @@ compatibility_date = "2023-01-01" it("should preserve multiple cookies", async ({ expect }) => { const token = randomBytes(4096).toString("hex"); - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://0000.rawhttp.devprod.cloudflare.dev/cookies`, { method: "GET", @@ -427,7 +336,7 @@ compatibility_date = "2023-01-01" "Access-Control-Request-Method": "GET", origin: "https://cloudflare.dev", "X-CF-Token": token, - "X-CF-Remote": `http://127.0.0.1:${remote.port}`, + "X-CF-Remote": MOCK_REMOTE_URL, }, } ); @@ -439,7 +348,7 @@ compatibility_date = "2023-01-01" it("should pass headers to the user-worker", async ({ expect }) => { const token = randomBytes(4096).toString("hex"); - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://0000.rawhttp.devprod.cloudflare.dev/`, { method: "GET", @@ -447,7 +356,7 @@ compatibility_date = "2023-01-01" "Access-Control-Request-Method": "GET", origin: "https://cloudflare.dev", "X-CF-Token": token, - "X-CF-Remote": `http://127.0.0.1:${remote.port}`, + "X-CF-Remote": MOCK_REMOTE_URL, "Some-Custom-Header": "custom", Accept: "application/json", }, @@ -479,14 +388,14 @@ compatibility_date = "2023-01-01" expect, }) => { const token = randomBytes(4096).toString("hex"); - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://0000.rawhttp.devprod.cloudflare.dev/method`, { method: "POST", headers: { origin: "https://cloudflare.dev", "X-CF-Token": token, - "X-CF-Remote": `http://127.0.0.1:${remote.port}`, + "X-CF-Remote": MOCK_REMOTE_URL, "X-CF-Http-Method": "PUT", }, } @@ -499,14 +408,14 @@ compatibility_date = "2023-01-01" "should support %s method specified on the X-CF-Http-Method header", async (method, { expect }) => { const token = randomBytes(4096).toString("hex"); - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://0000.rawhttp.devprod.cloudflare.dev/method`, { method: "POST", headers: { origin: "https://cloudflare.dev", "X-CF-Token": token, - "X-CF-Remote": `http://127.0.0.1:${remote.port}`, + "X-CF-Remote": MOCK_REMOTE_URL, "X-CF-Http-Method": method, }, } @@ -523,14 +432,14 @@ compatibility_date = "2023-01-01" expect, }) => { const token = randomBytes(4096).toString("hex"); - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://0000.rawhttp.devprod.cloudflare.dev/method`, { method: "PUT", headers: { origin: "https://cloudflare.dev", "X-CF-Token": token, - "X-CF-Remote": `http://127.0.0.1:${remote.port}`, + "X-CF-Remote": MOCK_REMOTE_URL, }, } ); @@ -542,7 +451,7 @@ compatibility_date = "2023-01-01" expect, }) => { const token = randomBytes(4096).toString("hex"); - const resp = await worker.fetch( + const resp = await SELF.fetch( `https://0000.rawhttp.devprod.cloudflare.dev/`, { method: "GET", @@ -550,7 +459,7 @@ compatibility_date = "2023-01-01" "Access-Control-Request-Method": "GET", origin: "https://cloudflare.dev", "X-CF-Token": token, - "X-CF-Remote": `http://127.0.0.1:${remote.port}`, + "X-CF-Remote": MOCK_REMOTE_URL, "cf-ew-raw-Some-Custom-Header": "custom", "cf-ew-raw-Accept": "application/json", }, diff --git a/packages/edge-preview-authenticated-proxy/tests/tsconfig.json b/packages/edge-preview-authenticated-proxy/tests/tsconfig.json index 03c9d7b91aec..1ba476ce3ea9 100644 --- a/packages/edge-preview-authenticated-proxy/tests/tsconfig.json +++ b/packages/edge-preview-authenticated-proxy/tests/tsconfig.json @@ -2,8 +2,9 @@ "compilerOptions": { "target": "esnext", "lib": ["esnext"], - "module": "es2022", + "module": "esnext", "moduleResolution": "bundler", + "types": ["@cloudflare/vitest-pool-workers"], "allowJs": true, "checkJs": false, "noEmit": true, @@ -13,5 +14,5 @@ "strict": true, "skipLibCheck": true }, - "include": ["**/*.test.ts"] + "include": ["**/*.test.ts", "../src/env.d.ts"] } diff --git a/packages/edge-preview-authenticated-proxy/tsconfig.json b/packages/edge-preview-authenticated-proxy/tsconfig.json index c0b1b1eb4d43..63a233790772 100644 --- a/packages/edge-preview-authenticated-proxy/tsconfig.json +++ b/packages/edge-preview-authenticated-proxy/tsconfig.json @@ -14,5 +14,5 @@ "strict": true, "skipLibCheck": true }, - "exclude": ["**/*.test.ts"] + "exclude": ["**/*.test.ts", "vitest.config.mts"] } diff --git a/packages/edge-preview-authenticated-proxy/vitest.config.mts b/packages/edge-preview-authenticated-proxy/vitest.config.mts index 846cddc41995..d5e9dbec6b78 100644 --- a/packages/edge-preview-authenticated-proxy/vitest.config.mts +++ b/packages/edge-preview-authenticated-proxy/vitest.config.mts @@ -1,9 +1,25 @@ -import { defineProject, mergeConfig } from "vitest/config"; -import configShared from "../../vitest.shared"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; -export default mergeConfig( - configShared, - defineProject({ - test: {}, - }) -); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineWorkersConfig({ + test: { + poolOptions: { + workers: { + singleWorker: true, + isolatedStorage: false, + wrangler: { + configPath: "./wrangler.jsonc", + }, + }, + }, + }, + resolve: { + // promjs has broken package.json (main points to lib/index.js but files are at root) + alias: { + promjs: path.resolve(__dirname, "node_modules/promjs/index.js"), + }, + }, +}); diff --git a/packages/wrangler/e2e/dev-registry.test.ts b/packages/wrangler/e2e/dev-registry.test.ts index 648cbd63e2e1..0eaad3a8fb3f 100644 --- a/packages/wrangler/e2e/dev-registry.test.ts +++ b/packages/wrangler/e2e/dev-registry.test.ts @@ -1,4 +1,3 @@ -import { execSync } from "node:child_process"; import getPort from "get-port"; import dedent from "ts-dedent"; import { fetch, Request } from "undici"; @@ -7,8 +6,7 @@ import { WranglerE2ETestHelper } from "./helpers/e2e-wrangler-test"; import { fetchText } from "./helpers/fetch-text"; import { generateResourceName } from "./helpers/generate-resource-name"; import { normalizeOutput } from "./helpers/normalize"; -import { seed as baseSeed, makeRoot, seed } from "./helpers/setup"; -import { WRANGLER_IMPORT } from "./helpers/wrangler"; +import { seed as baseSeed, makeRoot } from "./helpers/setup"; import type { RequestInit } from "undici"; async function fetchJson(url: string, info?: RequestInit): Promise { @@ -34,125 +32,6 @@ async function fetchJson(url: string, info?: RequestInit): Promise { ); } -describe("unstable_dev()", () => { - let parent: string; - let child: string; - let workerName: string; - let registryPath: string; - - beforeEach(async () => { - workerName = generateResourceName("worker"); - - registryPath = makeRoot(); - - parent = makeRoot(); - - await seed(parent, { - "wrangler.toml": dedent` - name = "app" - compatibility_date = "2023-01-01" - compatibility_flags = ["nodejs_compat"] - - [[services]] - binding = "WORKER" - service = '${workerName}' - `, - "src/index.ts": dedent/* javascript */ ` - export default { - async fetch(req, env) { - return new Response("Hello from Parent!" + await env.WORKER.fetch(req).then(r => r.text())) - }, - }; - `, - "package.json": dedent` - { - "name": "app", - "version": "0.0.0", - "private": true - } - `, - }); - - child = await makeRoot(); - await seed(child, { - "wrangler.toml": dedent` - name = "${workerName}" - main = "src/index.ts" - compatibility_date = "2023-01-01" - `, - "src/index.ts": dedent/* javascript */ ` - export default { - fetch(req, env) { - return new Response("Hello from Child!") - }, - }; - `, - "package.json": dedent` - { - "name": "${workerName}", - "version": "0.0.0", - "private": true - } - `, - }); - }); - - async function runInNode() { - await seed(parent, { - "index.mjs": dedent/*javascript*/ ` - import { unstable_dev } from "${WRANGLER_IMPORT}" - import { setTimeout } from "node:timers/promises"; - import { readdirSync } from "node:fs" - - const childWorker = await unstable_dev( - "${child.replaceAll("\\", "/")}/src/index.ts", - { - experimental: { - disableExperimentalWarning: true, - }, - } - ); - - for (const timeout of [1000, 2000, 4000, 8000, 16000]) { - if(readdirSync(process.env.WRANGLER_REGISTRY_PATH).includes("${workerName}")) { - break - } - await setTimeout(timeout) - } - - const parentWorker = await unstable_dev( - "src/index.ts", - { - experimental: { - disableExperimentalWarning: true, - }, - } - ); - - console.log(await parentWorker.fetch("/").then(r => r.text())) - - process.exit(0); - `, - }); - const stdout = execSync(`node index.mjs`, { - cwd: parent, - encoding: "utf-8", - env: { - ...process.env, - WRANGLER_REGISTRY_PATH: registryPath, - }, - }); - return stdout; - } - - it("can fetch child", async () => { - await expect(runInNode()).resolves.toMatchInlineSnapshot(` - "Hello from Parent!Hello from Child! - " - `); - }); -}); - describe.each([{ cmd: "wrangler dev" }])("dev registry $cmd", ({ cmd }) => { let workerName: string; let workerName2: string; diff --git a/packages/wrangler/src/__tests__/api-dev.test.ts b/packages/wrangler/src/__tests__/api-dev.test.ts deleted file mode 100644 index 6604b425edfd..000000000000 --- a/packages/wrangler/src/__tests__/api-dev.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import * as fs from "node:fs"; -import { Request } from "undici"; -import { describe, it, vi } from "vitest"; -import { unstable_dev } from "../api"; -import { mockConsoleMethods } from "./helpers/mock-console"; -import { runInTempDir } from "./helpers/run-in-tmp"; - -vi.unmock("child_process"); -vi.unmock("undici"); - -describe("unstable_dev", () => { - runInTempDir(); - mockConsoleMethods(); - it("should return Hello World", async ({ expect }) => { - writeHelloWorldWorker(); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - }, - }); - const resp = await worker.fetch(); - if (resp) { - const text = await resp.text(); - expect(text).toMatchInlineSnapshot(`"Hello World!"`); - } - await worker.stop(); - }); - - it("should return the port that the server started on (1)", async ({ - expect, - }) => { - writeHelloWorldWorker(); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - }, - }); - expect(worker.port).toBeGreaterThan(0); - await worker.stop(); - }); - - it("should return the port that the server started on (2)", async ({ - expect, - }) => { - writeHelloWorldWorker(); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - }, - }); - expect(worker.port).not.toBe(0); - await worker.stop(); - }); -}); - -describe("unstable dev fetch input protocol", () => { - runInTempDir(); - - it("should use http localProtocol", async ({ expect }) => { - writeHelloWorldWorker(); - const worker = await unstable_dev("index.js", { - localProtocol: "http", - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - }, - }); - const res = await worker.fetch(); - if (res) { - const text = await res.text(); - expect(text).toMatchInlineSnapshot(`"Hello World!"`); - } - await worker.stop(); - }); - - it("should use undefined localProtocol", async ({ expect }) => { - writeHelloWorldWorker(); - const worker = await unstable_dev("index.js", { - localProtocol: undefined, - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - }, - }); - const res = await worker.fetch(); - if (res) { - const text = await res.text(); - expect(text).toMatchInlineSnapshot(`"Hello World!"`); - } - await worker.stop(); - }); -}); - -describe("unstable dev fetch input parsing", () => { - runInTempDir(); - - it("should pass in a request object unchanged", async ({ expect }) => { - const scriptContent = ` - export default { - fetch(request, env, ctx) { - const url = new URL(request.url); - if (url.pathname === "/test") { - if (request.method === "POST") { - return new Response("requestPOST"); - } - return new Response("requestGET"); - } - return new Response('Hello world'); - } - }; - `; - fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - }, - }); - const req = new Request(`http://127.0.0.1:${worker.port}/test`, { - method: "POST", - }); - const resp = await worker.fetch(req); - let text; - if (resp) { - text = await resp.text(); - } - expect(text).toMatchInlineSnapshot(`"requestPOST"`); - await worker.stop(); - }); - - it("should strip back to pathname for URL objects", async ({ expect }) => { - const scriptContent = ` - export default { - fetch(request, env, ctx) { - const url = new URL(request.url); - if (url.pathname === "/test") { - return new Response("request"); - } - return new Response('Hello world'); - } - }; - `; - fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - }, - }); - const url = new URL("http://localhost:80/test"); - const resp = await worker.fetch(url); - let text; - if (resp) { - text = await resp.text(); - } - expect(text).toMatchInlineSnapshot(`"request"`); - await worker.stop(); - }); - - it("should allow full url passed in string, and stripped back to pathname", async ({ - expect, - }) => { - const scriptContent = ` - export default { - fetch(request, env, ctx) { - const url = new URL(request.url); - if (url.pathname === "/test") { - return new Response("request"); - } - return new Response('Hello world'); - } - }; - `; - fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - }, - }); - const resp = await worker.fetch("http://example.com/test"); - let text; - if (resp) { - text = await resp.text(); - } - expect(text).toMatchInlineSnapshot(`"request"`); - await worker.stop(); - }); - - it("should allow pathname to be passed in", async ({ expect }) => { - const scriptContent = ` - export default { - fetch(request, env, ctx) { - const url = new URL(request.url); - if (url.pathname === "/test") { - return new Response("request"); - } - return new Response('Hello world'); - } - }; - `; - fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - }, - }); - const resp = await worker.fetch("/test"); - let text; - if (resp) { - text = await resp.text(); - } - expect(text).toMatchInlineSnapshot(`"request"`); - await worker.stop(); - }); - - it("should allow no input be passed in", async ({ expect }) => { - const scriptContent = ` - export default { - fetch(request, env, ctx) { - const url = new URL(request.url); - if (url.pathname === "/test") { - return new Response("request"); - } - return new Response('Hello world'); - } - }; - `; - fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, - }, - }); - const resp = await worker.fetch(""); - let text; - if (resp) { - text = await resp.text(); - } - expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.stop(); - }); -}); - -const writeHelloWorldWorker = () => { - const scriptContent = ` - export default { - fetch(request, env, ctx) { - return new Response("Hello World!"); - }, - }; - `; - fs.writeFileSync("index.js", scriptContent); -}; diff --git a/packages/wrangler/src/__tests__/middleware.scheduled.test.ts b/packages/wrangler/src/__tests__/middleware.scheduled.test.ts index 2cb476858cc5..78e9223fb61c 100644 --- a/packages/wrangler/src/__tests__/middleware.scheduled.test.ts +++ b/packages/wrangler/src/__tests__/middleware.scheduled.test.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs"; import { beforeEach, describe, it, vi } from "vitest"; -import { unstable_dev } from "../api"; +import { startWorker } from "../api/startDevWorker"; import { runInTempDir } from "./helpers/run-in-tmp"; vi.unmock("child_process"); @@ -40,102 +40,102 @@ describe("run scheduled events with middleware", () => { it("should not intercept when middleware is not enabled", async ({ expect, }) => { - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch("/__scheduled"); + const resp = await worker.fetch("http://dummy/__scheduled"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Fetch triggered at /__scheduled"`); - await worker.stop(); + await worker.dispose(); }); it("should intercept when middleware is enabled", async ({ expect }) => { - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, testScheduled: true, }, }); - const resp = await worker.fetch("/__scheduled"); + const resp = await worker.fetch("http://dummy/__scheduled"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Ran scheduled event"`); - await worker.stop(); + await worker.dispose(); }); it("should not trigger scheduled event on wrong route", async ({ expect, }) => { - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, testScheduled: true, }, }); - const resp = await worker.fetch("/test"); + const resp = await worker.fetch("http://dummy/test"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world!"`); - await worker.stop(); + await worker.dispose(); }); it("should respond with 404 for favicons", async ({ expect }) => { - const worker = await unstable_dev("only-scheduled.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "only-scheduled.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, testScheduled: true, }, }); - const resp = await worker.fetch("/favicon.ico", { + const resp = await worker.fetch("http://dummy/favicon.ico", { headers: { referer: "http://localhost/__scheduled", }, }); expect(resp.status).toEqual(404); - await worker.stop(); + await worker.dispose(); }); it("should not respond with 404 for favicons if user-worker has a response", async ({ expect, }) => { - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, testScheduled: true, }, }); - const resp = await worker.fetch("/favicon.ico", { + const resp = await worker.fetch("http://dummy/favicon.ico", { headers: { referer: "http://localhost/__scheduled", }, }); expect(resp.status).not.toEqual(404); - await worker.stop(); + await worker.dispose(); }); }); @@ -170,102 +170,102 @@ describe("run scheduled events with middleware", () => { it("should not intercept when middleware is not enabled", async ({ expect, }) => { - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch("/__scheduled"); + const resp = await worker.fetch("http://dummy/__scheduled"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Fetch triggered at /__scheduled"`); - await worker.stop(); + await worker.dispose(); }); it("should intercept when middleware is enabled", async ({ expect }) => { - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, testScheduled: true, }, }); - const resp = await worker.fetch("/__scheduled"); + const resp = await worker.fetch("http://dummy/__scheduled"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Ran scheduled event"`); - await worker.stop(); + await worker.dispose(); }); it("should not trigger scheduled event on wrong route", async ({ expect, }) => { - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, testScheduled: true, }, }); - const resp = await worker.fetch("/test"); + const resp = await worker.fetch("http://dummy/test"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world!"`); - await worker.stop(); + await worker.dispose(); }); it("should respond with 404 for favicons", async ({ expect }) => { - const worker = await unstable_dev("only-scheduled.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "only-scheduled.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, testScheduled: true, }, }); - const resp = await worker.fetch("/favicon.ico", { + const resp = await worker.fetch("http://dummy/favicon.ico", { headers: { referer: "http://localhost/__scheduled", }, }); expect(resp.status).toEqual(404); - await worker.stop(); + await worker.dispose(); }); it("should not respond with 404 for favicons if user-worker has a response", async ({ expect, }) => { - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, testScheduled: true, }, }); - const resp = await worker.fetch("/favicon.ico", { + const resp = await worker.fetch("http://dummy/favicon.ico", { headers: { referer: "http://localhost/__scheduled", }, }); expect(resp.status).not.toEqual(404); - await worker.stop(); + await worker.dispose(); }); }); }); diff --git a/packages/wrangler/src/__tests__/middleware.test.ts b/packages/wrangler/src/__tests__/middleware.test.ts index 7405cd0a8089..c230da721518 100644 --- a/packages/wrangler/src/__tests__/middleware.test.ts +++ b/packages/wrangler/src/__tests__/middleware.test.ts @@ -5,7 +5,7 @@ import dedent from "ts-dedent"; /* eslint-disable workers-sdk/no-vitest-import-expect -- large file >500 lines */ import { beforeEach, describe, expect, it, vi } from "vitest"; /* eslint-enable workers-sdk/no-vitest-import-expect */ -import { unstable_dev } from "../api"; +import { startWorker } from "../api/startDevWorker"; import { mockConsoleMethods } from "./helpers/mock-console"; import { runInTempDir } from "./helpers/run-in-tmp"; import { runWrangler } from "./helpers/run-wrangler"; @@ -54,21 +54,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.stop(); + await worker.dispose(); }); it("should be able to access scheduled workers from middleware", async () => { @@ -88,21 +88,21 @@ describe("middleware", () => { fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"OK"`); - await worker.stop(); + await worker.dispose(); }); it("should trigger an error in a scheduled work from middleware", async () => { @@ -125,21 +125,21 @@ describe("middleware", () => { fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Error in scheduled worker"`); - await worker.stop(); + await worker.dispose(); }); }); @@ -158,21 +158,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.stop(); + await worker.dispose(); }); it("should register a middleware and intercept using addMiddlewareInternal", async () => { @@ -189,21 +189,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.stop(); + await worker.dispose(); }); it("should be able to access scheduled workers from middleware", async () => { @@ -220,21 +220,21 @@ describe("middleware", () => { fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"OK"`); - await worker.stop(); + await worker.dispose(); }); it("should trigger an error in a scheduled work from middleware", async () => { @@ -254,21 +254,21 @@ describe("middleware", () => { fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Error in scheduled worker"`); - await worker.stop(); + await worker.dispose(); }); }); }); @@ -291,20 +291,20 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); if (resp) { const text = await resp.text(); expect(text).toMatchInlineSnapshot(`"Hello world"`); } - await worker.stop(); + await worker.dispose(); }); it("should return hello world with empty middleware array", async () => { @@ -319,21 +319,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.stop(); + await worker.dispose(); }); it("should return hello world passing through middleware", async () => { @@ -351,20 +351,20 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); if (resp) { const text = await resp.text(); expect(text).toMatchInlineSnapshot(`"Hello world"`); } - await worker.stop(); + await worker.dispose(); }); it("should return hello world with multiple middleware in array", async () => { @@ -385,21 +385,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.stop(); + await worker.dispose(); }); it("should leave response headers unchanged with middleware", async () => { @@ -417,15 +417,15 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); const status = resp?.status; let text; if (resp) { @@ -435,7 +435,7 @@ describe("middleware", () => { expect(status).toEqual(500); expect(text).toMatchInlineSnapshot(`"Hello world"`); expect(testHeader).toEqual("test"); - await worker.stop(); + await worker.dispose(); }); it("waitUntil should not block responses", async () => { @@ -462,21 +462,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world0"`); - await worker.stop(); + await worker.dispose(); }); }); @@ -489,20 +489,20 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); if (resp) { const text = await resp.text(); expect(text).toMatchInlineSnapshot(`"Hello world"`); } - await worker.stop(); + await worker.dispose(); }); it("should return hello world with empty middleware array", async () => { @@ -514,21 +514,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.stop(); + await worker.dispose(); }); it("should return hello world passing through middleware", async () => { @@ -543,20 +543,20 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); if (resp) { const text = await resp.text(); expect(text).toMatchInlineSnapshot(`"Hello world"`); } - await worker.stop(); + await worker.dispose(); }); it("should return hello world with addMiddleware function called multiple times", async () => { @@ -575,21 +575,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.stop(); + await worker.dispose(); }); it("should return hello world with addMiddleware function called with array of middleware", async () => { @@ -607,21 +607,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.stop(); + await worker.dispose(); }); it("should return hello world with addMiddlewareInternal function called multiple times", async () => { @@ -640,21 +640,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.stop(); + await worker.dispose(); }); it("should return hello world with addMiddlewareInternal function called with array of middleware", async () => { @@ -672,21 +672,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.stop(); + await worker.dispose(); }); it("should return hello world with both addMiddleware and addMiddlewareInternal called", async () => { @@ -705,21 +705,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world"`); - await worker.stop(); + await worker.dispose(); }); it("should leave response headers unchanged with middleware", async () => { @@ -733,15 +733,15 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); const status = resp?.status; let text; if (resp) { @@ -751,7 +751,7 @@ describe("middleware", () => { expect(status).toEqual(500); expect(text).toMatchInlineSnapshot(`"Hello world"`); expect(testHeader).toEqual("test"); - await worker.stop(); + await worker.dispose(); }); it("should allow multiple addEventListeners for fetch", async () => { @@ -766,21 +766,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world1"`); - await worker.stop(); + await worker.dispose(); }); it("waitUntil should not block responses", async () => { @@ -799,21 +799,21 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, }, }); - const resp = await worker.fetch(); + const resp = await worker.fetch("http://dummy"); let text; if (resp) { text = await resp.text(); } expect(text).toMatchInlineSnapshot(`"Hello world0"`); - await worker.stop(); + await worker.dispose(); }); }); }); @@ -1060,32 +1060,38 @@ describe("middleware", () => { `; fs.writeFileSync("index.js", scriptContent); - const worker = await unstable_dev("index.js", { - ip: "127.0.0.1", - experimental: { - disableExperimentalWarning: true, - disableDevRegistry: true, + const worker = await startWorker({ + entrypoint: "index.js", + dev: { + server: { hostname: "127.0.0.1", port: 0 }, + inspector: false, testScheduled: true, - d1Databases: [ - { - binding: "DB", - database_name: "db", - database_id: "00000000-0000-0000-0000-000000000000", - }, - ], + }, + bindings: { + DB: { + type: "d1", + database_name: "db", + database_id: "00000000-0000-0000-0000-000000000000", + }, }, }); try { - let res = await worker.fetch("http://localhost/setup"); + await worker.ready; + const url = await worker.url; + // TODO(#12596): worker.fetch() doesn't work correctly with paths when + // EXPERIMENTAL_MIDDLEWARE=true is set. The request URL pathname gets + // lost, causing the worker to not match routes like "/setup". + // We use native fetch() with the worker URL as a workaround. + let res = await fetch(new URL("/setup", url).href); expect(res.status).toBe(204); - res = await worker.fetch("http://localhost/__scheduled"); + res = await fetch(new URL("/__scheduled", url).href); expect(res.status).toBe(200); expect(await res.text()).toBe("Ran scheduled event"); - res = await worker.fetch("http://localhost/query"); + res = await fetch(new URL("/query", url).href); expect(res.status).toBe(200); expect(await res.json()).toEqual([{ id: 1, value: "one" }]); - res = await worker.fetch("http://localhost/bad"); + res = await fetch(new URL("/bad", url).href); expect(res.status).toBe(500); // TODO: in miniflare we don't have the `pretty-error` middleware implemented. // instead it uses `middleware-miniflare3-json-error`, which outputs JSON rather than text. @@ -1094,7 +1100,7 @@ describe("middleware", () => { // ); expect(await res.text()).toContain("Not found!"); } finally { - await worker.stop(); + await worker.dispose(); } }); }); diff --git a/packages/wrangler/src/api/dev.ts b/packages/wrangler/src/api/dev.ts index 9b1e41b859e7..040a156b61b1 100644 --- a/packages/wrangler/src/api/dev.ts +++ b/packages/wrangler/src/api/dev.ts @@ -80,7 +80,6 @@ export interface Unstable_DevOptions { testMode?: boolean; // This option shouldn't be used - We plan on removing it eventually testScheduled?: boolean; // Test scheduled events by visiting /__scheduled in browser watch?: boolean; // unstable_dev doesn't support watch-mode yet in testMode - devEnv?: boolean; fileBasedRegistry?: boolean; enableIpc?: boolean; enableContainers?: boolean; // Whether to build and connect to containers in dev mode. Defaults to true. diff --git a/packages/wrangler/templates/init-tests/test-jest-new-worker.js b/packages/wrangler/templates/init-tests/test-jest-new-worker.js deleted file mode 100644 index d33aa9159e30..000000000000 --- a/packages/wrangler/templates/init-tests/test-jest-new-worker.js +++ /dev/null @@ -1,23 +0,0 @@ -const { unstable_dev } = require("wrangler"); - -describe("Worker", () => { - let worker; - - beforeAll(async () => { - worker = await unstable_dev("src/index.js", { - experimental: { disableExperimentalWarning: true }, - }); - }); - - afterAll(async () => { - await worker.stop(); - }); - - it("should return Hello World", async () => { - const resp = await worker.fetch(); - if (resp) { - const text = await resp.text(); - expect(text).toMatchInlineSnapshot(`"Hello World!"`); - } - }); -}); diff --git a/packages/wrangler/templates/init-tests/test-vitest-new-worker.js b/packages/wrangler/templates/init-tests/test-vitest-new-worker.js deleted file mode 100644 index 3ff265e50f47..000000000000 --- a/packages/wrangler/templates/init-tests/test-vitest-new-worker.js +++ /dev/null @@ -1,24 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { unstable_dev } from "wrangler"; - -describe("Worker", () => { - let worker; - - beforeAll(async () => { - worker = await unstable_dev("src/index.js", { - experimental: { disableExperimentalWarning: true }, - }); - }); - - afterAll(async () => { - await worker.stop(); - }); - - it("should return Hello World", async () => { - const resp = await worker.fetch(); - if (resp) { - const text = await resp.text(); - expect(text).toMatchInlineSnapshot(`"Hello World!"`); - } - }); -}); diff --git a/packages/wrangler/templates/init-tests/test-vitest-new-worker.ts b/packages/wrangler/templates/init-tests/test-vitest-new-worker.ts deleted file mode 100644 index 2430ea5a5e03..000000000000 --- a/packages/wrangler/templates/init-tests/test-vitest-new-worker.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { unstable_dev } from "wrangler"; -import type { Unstable_DevWorker } from "wrangler"; - -describe("Worker", () => { - let worker: Unstable_DevWorker; - - beforeAll(async () => { - worker = await unstable_dev("src/index.ts", { - experimental: { disableExperimentalWarning: true }, - }); - }); - - afterAll(async () => { - await worker.stop(); - }); - - it("should return Hello World", async () => { - const resp = await worker.fetch(); - if (resp) { - const text = await resp.text(); - expect(text).toMatchInlineSnapshot(`"Hello World!"`); - } - }); -}); diff --git a/packages/wrangler/templates/tsconfig.json b/packages/wrangler/templates/tsconfig.json index 672ae3c4e8a7..0b51f60963f4 100644 --- a/packages/wrangler/templates/tsconfig.json +++ b/packages/wrangler/templates/tsconfig.json @@ -6,7 +6,6 @@ "include": ["**/*.ts"], "exclude": [ "__tests__", - "./init-tests/**", // Note: `startDevWorker` and `middleware` should also be included but some work is needed // for that first (see: https://github.com/cloudflare/workers-sdk/issues/8303) "startDevWorker", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f92f66924fb6..881155330c54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1286,12 +1286,6 @@ importers: specifier: 3.22.3 version: 3.22.3 - fixtures/unstable_dev: - devDependencies: - wrangler: - specifier: workspace:* - version: link:../../packages/wrangler - fixtures/vitest-pool-workers-examples: devDependencies: '@cloudflare/containers': @@ -1976,6 +1970,9 @@ importers: '@cloudflare/eslint-config-shared': specifier: workspace:* version: link:../eslint-config-shared + '@cloudflare/vitest-pool-workers': + specifier: catalog:default + version: 0.10.15(@cloudflare/workers-types@4.20260218.0)(@vitest/runner@3.2.3)(@vitest/snapshot@3.2.3)(vitest@3.2.3) '@cloudflare/workers-types': specifier: catalog:default version: 4.20260218.0 @@ -1994,6 +1991,9 @@ importers: toucan-js: specifier: 4.0.0 version: 4.0.0(patch_hash=qxsfpdzvzbhq2ecirbu5xq4vlq) + vitest: + specifier: catalog:default + version: 3.2.3(@types/debug@4.1.12)(@types/node@20.19.9)(@vitest/ui@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.0(@types/node@20.19.9)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.1) wrangler: specifier: workspace:* version: link:../wrangler @@ -16301,7 +16301,7 @@ snapshots: devalue: 5.3.2 miniflare: 4.20251210.0 semver: 7.7.3 - vitest: 3.2.3(@types/debug@4.1.12)(@types/node@20.19.9)(@vitest/ui@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.0(@types/node@20.19.9)(typescript@5.8.3))(supports-color@9.2.2)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.3(@types/debug@4.1.12)(@types/node@20.19.9)(@vitest/ui@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.0(@types/node@20.19.9)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.1) wrangler: 4.54.0(@cloudflare/workers-types@4.20260218.0) zod: 3.25.76 transitivePeerDependencies: @@ -25799,7 +25799,7 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.0 + esbuild: 0.27.3 get-tsconfig: 4.13.0 optionalDependencies: fsevents: 2.3.3 diff --git a/tools/github-workflow-helpers/auto-assign-issues.ts b/tools/github-workflow-helpers/auto-assign-issues.ts index 8a1c314ec3ae..6e8bfaf7c874 100644 --- a/tools/github-workflow-helpers/auto-assign-issues.ts +++ b/tools/github-workflow-helpers/auto-assign-issues.ts @@ -39,7 +39,6 @@ const TEAM_ASSIGNMENTS: { [label: string]: { [jobRole: string]: string } } = { types: { em: "lrapoport-cf", pm: "mattietk" }, "types-ai": { em: "jkipp-cloudflare" }, unenv: { em: "lrapoport-cf", pm: "mattietk" }, - unstable_dev: { em: "lrapoport-cf", pm: "mattietk" }, vectorize: { em: "sejoker", pm: "jonesphillip" }, "vite-plugin": { em: "lrapoport-cf", pm: "mattietk" }, vitest: { em: "lrapoport-cf", pm: "mattietk" }, From aa82c2b46be762adecca3f661a8f8228fb9c7c28 Mon Sep 17 00:00:00 2001 From: Cole Mackenzie Date: Wed, 18 Feb 2026 10:36:45 -0800 Subject: [PATCH 3/4] Generate typed pipeline bindings from stream schemas (#12395) Co-authored-by: Phillip Jones --- .changeset/shaggy-phones-stay.md | 27 + .../type-generation-pipeline-schema.test.ts | 328 ++++++++++++ .../src/__tests__/type-generation.test.ts | 499 +++++++++++++++++- packages/wrangler/src/pipelines/types.ts | 5 + .../wrangler/src/type-generation/index.ts | 330 ++++++++++-- .../src/type-generation/pipeline-schema.ts | 272 ++++++++++ 6 files changed, 1407 insertions(+), 54 deletions(-) create mode 100644 .changeset/shaggy-phones-stay.md create mode 100644 packages/wrangler/src/__tests__/type-generation-pipeline-schema.test.ts create mode 100644 packages/wrangler/src/type-generation/pipeline-schema.ts diff --git a/.changeset/shaggy-phones-stay.md b/.changeset/shaggy-phones-stay.md new file mode 100644 index 000000000000..ea670fa61c1f --- /dev/null +++ b/.changeset/shaggy-phones-stay.md @@ -0,0 +1,27 @@ +--- +"wrangler": minor +--- + +Generate typed pipeline bindings from stream schemas + +When running `wrangler types`, pipeline bindings now generate TypeScript types based on the stream's schema definition. This gives you full autocomplete and type checking when sending data to your pipelines. + +```jsonc +// wrangler.json +{ + "pipelines": [{ "binding": "ANALYTICS", "pipeline": "analytics-stream-id" }], +} +``` + +If your stream has a schema with fields like `user_id` (string) and `event_count` (int32), the generated types will be: + +```typescript +declare namespace Cloudflare { + type AnalyticsStreamRecord = { user_id: string; event_count: number }; + interface Env { + ANALYTICS: Pipeline; + } +} +``` + +For unstructured streams or when not authenticated, bindings fall back to the generic `Pipeline` type. diff --git a/packages/wrangler/src/__tests__/type-generation-pipeline-schema.test.ts b/packages/wrangler/src/__tests__/type-generation-pipeline-schema.test.ts new file mode 100644 index 000000000000..6f1bdbf2bd6e --- /dev/null +++ b/packages/wrangler/src/__tests__/type-generation-pipeline-schema.test.ts @@ -0,0 +1,328 @@ +import { describe, it } from "vitest"; +import { + generatePipelineTypeFromSchema, + GENERIC_PIPELINE_TYPE, + streamNameToTypeName, +} from "../type-generation/pipeline-schema"; + +describe("streamNameToTypeName", () => { + it("should convert snake_case to PascalCase with Record suffix", ({ + expect, + }) => { + expect(streamNameToTypeName("analytics_stream")).toBe( + "AnalyticsStreamRecord" + ); + }); + + it("should convert kebab-case to PascalCase with Record suffix", ({ + expect, + }) => { + expect(streamNameToTypeName("my-events")).toBe("MyEventsRecord"); + }); + + it("should handle single word", ({ expect }) => { + expect(streamNameToTypeName("events")).toBe("EventsRecord"); + }); +}); + +describe("generatePipelineTypeFromSchema", () => { + it("should return generic type for null schema", ({ expect }) => { + const result = generatePipelineTypeFromSchema(null); + expect(result.typeReference).toBe(GENERIC_PIPELINE_TYPE); + expect(result.typeDefinition).toBeNull(); + expect(result.typeName).toBeNull(); + }); + + it("should return generic type for schema with empty fields", ({ + expect, + }) => { + const result = generatePipelineTypeFromSchema({ fields: [] }); + expect(result.typeReference).toBe(GENERIC_PIPELINE_TYPE); + expect(result.typeDefinition).toBeNull(); + expect(result.typeName).toBeNull(); + }); + + it("should return generic type for schema with empty fields even when stream name is provided", ({ + expect, + }) => { + const result = generatePipelineTypeFromSchema({ fields: [] }, "my_stream"); + expect(result.typeReference).toBe(GENERIC_PIPELINE_TYPE); + expect(result.typeDefinition).toBeNull(); + expect(result.typeName).toBeNull(); + }); + + it("should return generic type for schema with non-array fields", ({ + expect, + }) => { + // @ts-expect-error Testing invalid input + const result = generatePipelineTypeFromSchema({ fields: "invalid" }); + expect(result.typeReference).toBe(GENERIC_PIPELINE_TYPE); + expect(result.typeDefinition).toBeNull(); + }); + + it("should generate named type when stream name is provided", ({ + expect, + }) => { + const schema = { + fields: [{ name: "user_id", type: "string" as const, required: true }], + }; + const result = generatePipelineTypeFromSchema(schema, "analytics_stream"); + expect(result.typeReference).toBe( + 'import("cloudflare:pipelines").Pipeline' + ); + expect(result.typeDefinition).toBe( + "type AnalyticsStreamRecord = { user_id: string };" + ); + expect(result.typeName).toBe("AnalyticsStreamRecord"); + }); + + it("should generate inline type when stream name is not provided", ({ + expect, + }) => { + const schema = { + fields: [{ name: "user_id", type: "string" as const, required: true }], + }; + const result = generatePipelineTypeFromSchema(schema); + expect(result.typeReference).toBe( + 'import("cloudflare:pipelines").Pipeline<{ user_id: string }>' + ); + expect(result.typeDefinition).toBeNull(); + expect(result.typeName).toBeNull(); + }); + + it("should generate type for optional field", ({ expect }) => { + const schema = { + fields: [{ name: "user_id", type: "string" as const, required: false }], + }; + const result = generatePipelineTypeFromSchema(schema, "my_stream"); + expect(result.typeDefinition).toBe( + "type MyStreamRecord = { user_id?: string };" + ); + }); + + it("should generate types for all primitive types", ({ expect }) => { + const schema = { + fields: [ + { name: "str", type: "string" as const, required: true }, + { name: "bool_val", type: "bool" as const, required: true }, + { name: "int32_val", type: "int32" as const, required: true }, + { name: "int64_val", type: "int64" as const, required: true }, + { name: "uint32_val", type: "uint32" as const, required: true }, + { name: "uint64_val", type: "uint64" as const, required: true }, + { name: "float32_val", type: "float32" as const, required: true }, + { name: "float64_val", type: "float64" as const, required: true }, + { name: "decimal128_val", type: "decimal128" as const, required: true }, + { name: "timestamp_val", type: "timestamp" as const, required: true }, + { name: "json_val", type: "json" as const, required: true }, + { name: "bytes_val", type: "bytes" as const, required: true }, + ], + }; + const result = generatePipelineTypeFromSchema(schema, "test_stream"); + expect(result.typeDefinition).toContain("str: string"); + expect(result.typeDefinition).toContain("bool_val: boolean"); + expect(result.typeDefinition).toContain("int32_val: number"); + expect(result.typeDefinition).toContain("int64_val: number"); + expect(result.typeDefinition).toContain("uint32_val: number"); + expect(result.typeDefinition).toContain("uint64_val: number"); + expect(result.typeDefinition).toContain("float32_val: number"); + expect(result.typeDefinition).toContain("float64_val: number"); + expect(result.typeDefinition).toContain("decimal128_val: number"); + expect(result.typeDefinition).toContain("timestamp_val: string | number"); + expect(result.typeDefinition).toContain( + "json_val: Record" + ); + // Binary data is base64-encoded when sent as JSON + expect(result.typeDefinition).toContain("bytes_val: string"); + }); + + it("should generate type for list field", ({ expect }) => { + const schema = { + fields: [ + { + name: "tags", + type: "list" as const, + required: true, + items: { name: "", type: "string" as const, required: true }, + }, + ], + }; + const result = generatePipelineTypeFromSchema(schema, "my_stream"); + expect(result.typeDefinition).toBe( + "type MyStreamRecord = { tags: Array };" + ); + }); + + it("should generate type for nested struct field", ({ expect }) => { + const schema = { + fields: [ + { + name: "metadata", + type: "struct" as const, + required: false, + fields: [ + { name: "source", type: "string" as const, required: false }, + { name: "priority", type: "int32" as const, required: true }, + ], + }, + ], + }; + const result = generatePipelineTypeFromSchema(schema, "my_stream"); + expect(result.typeDefinition).toContain("metadata?:"); + expect(result.typeDefinition).toContain("source?: string"); + expect(result.typeDefinition).toContain("priority: number"); + }); + + it("should generate type for complex schema with multiple field types", ({ + expect, + }) => { + const schema = { + fields: [ + { name: "user_id", type: "string" as const, required: true }, + { name: "amount", type: "float64" as const, required: false }, + { + name: "tags", + type: "list" as const, + required: false, + items: { name: "", type: "string" as const, required: true }, + }, + { + name: "metadata", + type: "struct" as const, + required: false, + fields: [ + { name: "source", type: "string" as const, required: false }, + { name: "priority", type: "int32" as const, required: false }, + ], + }, + ], + }; + const result = generatePipelineTypeFromSchema(schema, "my_stream"); + expect(result.typeDefinition).toContain("user_id: string"); + expect(result.typeDefinition).toContain("amount?: number"); + expect(result.typeDefinition).toContain("tags?: Array"); + expect(result.typeDefinition).toContain("metadata?:"); + }); + + it("should quote field names that are not valid identifiers", ({ + expect, + }) => { + const schema = { + fields: [ + { name: "valid_name", type: "string" as const, required: true }, + { name: "invalid-name", type: "string" as const, required: true }, + { name: "123invalid", type: "string" as const, required: true }, + ], + }; + const result = generatePipelineTypeFromSchema(schema, "my_stream"); + expect(result.typeDefinition).toContain("valid_name: string"); + expect(result.typeDefinition).toContain('"invalid-name": string'); + expect(result.typeDefinition).toContain('"123invalid": string'); + }); + + it("should escape special characters in field names", ({ expect }) => { + const schema = { + fields: [ + { + name: 'field"with"quotes', + type: "string" as const, + required: true, + }, + { + name: "field\nwith\nnewlines", + type: "string" as const, + required: true, + }, + { + name: "field\\with\\backslash", + type: "string" as const, + required: true, + }, + ], + }; + const result = generatePipelineTypeFromSchema(schema, "my_stream"); + // Should produce valid TypeScript without breaking the string + expect(result.typeDefinition).toContain('"field\\"with\\"quotes": string'); + expect(result.typeDefinition).toContain( + '"field\\nwith\\nnewlines": string' + ); + expect(result.typeDefinition).toContain( + '"field\\\\with\\\\backslash": string' + ); + }); + + it("should handle list without items definition", ({ expect }) => { + const schema = { + fields: [ + { + name: "unknown_list", + type: "list" as const, + required: true, + }, + ], + }; + const result = generatePipelineTypeFromSchema(schema, "my_stream"); + expect(result.typeDefinition).toContain("unknown_list: unknown[]"); + }); + + it("should handle struct without fields definition", ({ expect }) => { + const schema = { + fields: [ + { + name: "empty_struct", + type: "struct" as const, + required: true, + }, + ], + }; + const result = generatePipelineTypeFromSchema(schema, "my_stream"); + expect(result.typeDefinition).toContain( + "empty_struct: Record" + ); + }); + + it("should handle unknown field types gracefully", ({ expect }) => { + const schema = { + fields: [ + { + name: "mystery_field", + type: "unknown_type" as "string", + required: true, + }, + ], + }; + const result = generatePipelineTypeFromSchema(schema, "my_stream"); + expect(result.typeDefinition).toContain("mystery_field: unknown"); + }); + + it("should limit nesting depth to prevent stack overflow", ({ expect }) => { + // Create a deeply nested struct (more than 10 levels) + type NestedField = { + name: string; + type: "string" | "struct"; + required: boolean; + fields?: NestedField[]; + }; + + const createNestedStruct = (depth: number): NestedField => { + if (depth === 0) { + return { name: "leaf", type: "string", required: true }; + } + return { + name: `level_${depth}`, + type: "struct", + required: true, + fields: [createNestedStruct(depth - 1)], + }; + }; + + const schema = { + fields: [createNestedStruct(15)], // 15 levels deep + }; + + // Should not throw and should eventually return "unknown" for deeply nested + const result = generatePipelineTypeFromSchema(schema, "my_stream"); + expect(result.typeDefinition).toBeDefined(); + // At depth > 10, it should fall back to "unknown" + expect(result.typeDefinition).toContain("unknown"); + }); +}); diff --git a/packages/wrangler/src/__tests__/type-generation.test.ts b/packages/wrangler/src/__tests__/type-generation.test.ts index a00a9c7cfa7e..85191465a5f5 100644 --- a/packages/wrangler/src/__tests__/type-generation.test.ts +++ b/packages/wrangler/src/__tests__/type-generation.test.ts @@ -1,8 +1,10 @@ import * as fs from "node:fs"; +import { http, HttpResponse } from "msw"; import { afterAll, beforeAll, beforeEach, describe, it, vi } from "vitest"; import { constructTSModuleGlob, constructTypeKey, + escapeTypeScriptString, generateImportSpecifier, isValidIdentifier, } from "../type-generation"; @@ -17,7 +19,9 @@ import { } from "../type-generation/helpers"; import * as generateRuntime from "../type-generation/runtime"; import { dedent } from "../utils/dedent"; +import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; +import { createFetchResult, msw } from "./helpers/msw"; import { runInTempDir } from "./helpers/run-in-tmp"; import { runWrangler } from "./helpers/run-wrangler"; import type { EnvironmentNonInheritable } from "@cloudflare/workers-utils"; @@ -42,6 +46,20 @@ describe("isValidIdentifier", () => { }); }); +describe("escapeTypeScriptString", () => { + it("should escape special characters", ({ expect }) => { + expect(escapeTypeScriptString("normal")).toBe("normal"); + expect(escapeTypeScriptString('with"quotes')).toBe('with\\"quotes'); + expect(escapeTypeScriptString("with\\backslash")).toBe("with\\\\backslash"); + expect(escapeTypeScriptString("with\nnewline")).toBe("with\\nnewline"); + expect(escapeTypeScriptString("with\rcarriage")).toBe("with\\rcarriage"); + expect(escapeTypeScriptString("with\ttab")).toBe("with\\ttab"); + expect(escapeTypeScriptString('all"\\special\n\r\t')).toBe( + 'all\\"\\\\special\\n\\r\\t' + ); + }); +}); + describe("constructTypeKey", () => { it("should return a valid type key", ({ expect }) => { expect(constructTypeKey("valid")).toBe("valid"); @@ -57,6 +75,16 @@ describe("constructTypeKey", () => { expect(constructTypeKey("invalid-123")).toBe('"invalid-123"'); expect(constructTypeKey("invalid 123")).toBe('"invalid 123"'); }); + + it("should escape special characters in quoted keys", ({ expect }) => { + expect(constructTypeKey('key"with"quotes')).toBe('"key\\"with\\"quotes"'); + expect(constructTypeKey("key\nwith\nnewlines")).toBe( + '"key\\nwith\\nnewlines"' + ); + expect(constructTypeKey("key\\with\\backslash")).toBe( + '"key\\\\with\\\\backslash"' + ); + }); }); describe("constructTSModuleGlob() should return a valid TS glob ", () => { @@ -727,7 +755,6 @@ describe("generate types", () => { RATE_LIMITER: RateLimit; WORKER_LOADER_BINDING: WorkerLoader; VPC_SERVICE_BINDING: Fetcher; - PIPELINE: import("cloudflare:pipelines").Pipeline; LOGFWDR_SCHEMA: any; BROWSER_BINDING: Fetcher; AI_BINDING: Ai; @@ -735,6 +762,7 @@ describe("generate types", () => { MEDIA_BINDING: MediaBinding; VERSION_METADATA_BINDING: WorkerVersionMetadata; ASSETS_BINDING: Fetcher; + PIPELINE: import("cloudflare:pipelines").Pipeline; SOMETHING: "asdasdfasdf"; ANOTHER: "thing"; OBJECT_VAR: {"enterprise":"1701-D","activeDuty":true,"captain":"Picard"}; @@ -839,7 +867,6 @@ describe("generate types", () => { RATE_LIMITER: RateLimit; WORKER_LOADER_BINDING: WorkerLoader; VPC_SERVICE_BINDING: Fetcher; - PIPELINE: import("cloudflare:pipelines").Pipeline; LOGFWDR_SCHEMA: any; BROWSER_BINDING: Fetcher; AI_BINDING: Ai; @@ -847,6 +874,7 @@ describe("generate types", () => { MEDIA_BINDING: MediaBinding; VERSION_METADATA_BINDING: WorkerVersionMetadata; ASSETS_BINDING: Fetcher; + PIPELINE: import("cloudflare:pipelines").Pipeline; SOMETHING: "asdasdfasdf"; ANOTHER: "thing"; OBJECT_VAR: {"enterprise":"1701-D","activeDuty":true,"captain":"Picard"}; @@ -1014,7 +1042,6 @@ describe("generate types", () => { RATE_LIMITER: RateLimit; WORKER_LOADER_BINDING: WorkerLoader; VPC_SERVICE_BINDING: Fetcher; - PIPELINE: import("cloudflare:pipelines").Pipeline; LOGFWDR_SCHEMA: any; BROWSER_BINDING: Fetcher; AI_BINDING: Ai; @@ -1022,6 +1049,7 @@ describe("generate types", () => { MEDIA_BINDING: MediaBinding; VERSION_METADATA_BINDING: WorkerVersionMetadata; ASSETS_BINDING: Fetcher; + PIPELINE: import("cloudflare:pipelines").Pipeline; SOMETHING: "asdasdfasdf"; ANOTHER: "thing"; OBJECT_VAR: {"enterprise":"1701-D","activeDuty":true,"captain":"Picard"}; @@ -1668,9 +1696,7 @@ describe("generate types", () => { "var-a-b-": "/"a////b///"/""; true: true; false: false; - "multi - line - var": "this/nis/na/nmulti/nline/nvariable!"; + "multi/nline/nvar": "this/nis/na/nmulti/nline/nvariable!"; } } interface Env extends Cloudflare.Env {} @@ -2783,3 +2809,464 @@ describe("generate types", () => { `); }); }); + +describe("pipeline schema type generation", () => { + const std = mockConsoleMethods(); + const originalColumns = process.stdout.columns; + runInTempDir(); + mockAccountId(); + mockApiToken(); + + beforeAll(() => { + process.stdout.columns = 60; + }); + + afterAll(() => { + process.stdout.columns = originalColumns; + }); + + beforeEach(() => { + vi.spyOn(generateRuntime, "generateRuntimeTypes").mockImplementation( + async () => ({ + runtimeHeader: "// Runtime types generated with workerd@", + runtimeTypes: "", + }) + ); + }); + + it("should generate typed pipeline bindings when API returns schema", async ({ + expect, + }) => { + msw.use( + http.get( + "*/accounts/:accountId/pipelines/v1/streams/:streamId", + ({ params }) => { + const { streamId } = params; + if (streamId === "analytics-stream-id") { + return HttpResponse.json( + createFetchResult({ + id: "analytics-stream-id", + name: "analytics_stream", + version: 1, + created_at: "2026-02-02T02:57:23.583Z", + modified_at: "2026-02-02T02:57:23.583Z", + format: { type: "json" }, + schema: { + fields: [ + { + name: "session_duration_ms", + type: "int64", + required: true, + }, + { + name: "message_count", + type: "int32", + required: true, + }, + { + name: "had_cf_auth", + type: "bool", + required: true, + }, + { + name: "workflow_step_types", + type: "list", + items: { type: "string" }, + required: false, + }, + { + name: "timestamp", + type: "timestamp", + required: true, + }, + ], + }, + http: { enabled: false, authentication: false, cors: {} }, + worker_binding: { enabled: true }, + }) + ); + } + return HttpResponse.json( + { success: false, errors: [], messages: [], result: null }, + { status: 404 } + ); + } + ) + ); + + fs.writeFileSync( + "./wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "./index.ts", + compatibility_date: "2024-01-01", + pipelines: [{ binding: "ANALYTICS", pipeline: "analytics-stream-id" }], + }) + ); + fs.writeFileSync("./index.ts", "export default { fetch() {} }"); + + await runWrangler("types --include-runtime=false"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + Generating project types... + + declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + type AnalyticsStreamRecord = { session_duration_ms: number; message_count: number; had_cf_auth: boolean; workflow_step_types?: Array; timestamp: string | number }; + interface Env { + ANALYTICS: import("cloudflare:pipelines").Pipeline; + } + } + interface Env extends Cloudflare.Env {} + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.json file. + " + `); + }); + + it("should fall back to generic type when API returns unstructured stream", async ({ + expect, + }) => { + msw.use( + http.get("*/accounts/:accountId/pipelines/v1/streams/:streamId", () => { + return HttpResponse.json( + createFetchResult({ + id: "unstructured-stream-id", + name: "unstructured_stream", + version: 1, + format: { type: "json", unstructured: true }, + schema: null, + http: { enabled: false, authentication: false }, + worker_binding: { enabled: true }, + }) + ); + }) + ); + + fs.writeFileSync( + "./wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "./index.ts", + compatibility_date: "2024-01-01", + pipelines: [{ binding: "LOGS", pipeline: "unstructured-stream-id" }], + }) + ); + fs.writeFileSync("./index.ts", "export default { fetch() {} }"); + + await runWrangler("types --include-runtime=false"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + Generating project types... + + declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env { + LOGS: import("cloudflare:pipelines").Pipeline; + } + } + interface Env extends Cloudflare.Env {} + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.json file. + " + `); + }); + + it("should fall back to generic type when API call fails", async ({ + expect, + }) => { + msw.use( + http.get("*/accounts/:accountId/pipelines/v1/streams/:streamId", () => { + return HttpResponse.json( + { + success: false, + errors: [{ message: "Stream not found" }], + messages: [], + result: null, + }, + { status: 404 } + ); + }) + ); + + fs.writeFileSync( + "./wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "./index.ts", + compatibility_date: "2024-01-01", + pipelines: [{ binding: "MISSING", pipeline: "non-existent-stream" }], + }) + ); + fs.writeFileSync("./index.ts", "export default { fetch() {} }"); + + await runWrangler("types --include-runtime=false"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + Generating project types... + + declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env { + MISSING: import("cloudflare:pipelines").Pipeline; + } + } + interface Env extends Cloudflare.Env {} + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.json file. + " + `); + }); + + it("should handle multiple pipelines with different schemas", async ({ + expect, + }) => { + msw.use( + http.get( + "*/accounts/:accountId/pipelines/v1/streams/:streamId", + ({ params }) => { + const { streamId } = params; + if (streamId === "events-stream") { + return HttpResponse.json( + createFetchResult({ + id: "events-stream", + name: "events", + version: 1, + schema: { + fields: [ + { name: "event_type", type: "string", required: true }, + { name: "payload", type: "json", required: false }, + ], + }, + http: { enabled: false, authentication: false }, + worker_binding: { enabled: true }, + }) + ); + } + if (streamId === "metrics-stream") { + return HttpResponse.json( + createFetchResult({ + id: "metrics-stream", + name: "metrics", + version: 1, + schema: { + fields: [ + { name: "metric_name", type: "string", required: true }, + { name: "value", type: "float64", required: true }, + { + name: "tags", + type: "list", + items: { type: "string" }, + required: false, + }, + ], + }, + http: { enabled: false, authentication: false }, + worker_binding: { enabled: true }, + }) + ); + } + return HttpResponse.json( + { success: false, errors: [], messages: [], result: null }, + { status: 404 } + ); + } + ) + ); + + fs.writeFileSync( + "./wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "./index.ts", + compatibility_date: "2024-01-01", + pipelines: [ + { binding: "EVENTS", pipeline: "events-stream" }, + { binding: "METRICS", pipeline: "metrics-stream" }, + ], + }) + ); + fs.writeFileSync("./index.ts", "export default { fetch() {} }"); + + await runWrangler("types --include-runtime=false"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + Generating project types... + + declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + type EventsRecord = { event_type: string; payload?: Record }; + type MetricsRecord = { metric_name: string; value: number; tags?: Array }; + interface Env { + EVENTS: import("cloudflare:pipelines").Pipeline; + METRICS: import("cloudflare:pipelines").Pipeline; + } + } + interface Env extends Cloudflare.Env {} + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.json file. + " + `); + }); + + it("should handle nested struct types in schema", async ({ expect }) => { + msw.use( + http.get("*/accounts/:accountId/pipelines/v1/streams/:streamId", () => { + return HttpResponse.json( + createFetchResult({ + id: "nested-stream", + name: "nested", + version: 1, + schema: { + fields: [ + { name: "user_id", type: "string", required: true }, + { + name: "metadata", + type: "struct", + required: false, + fields: [ + { name: "source", type: "string", required: false }, + { name: "priority", type: "int32", required: true }, + ], + }, + ], + }, + http: { enabled: false, authentication: false }, + worker_binding: { enabled: true }, + }) + ); + }) + ); + + fs.writeFileSync( + "./wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "./index.ts", + compatibility_date: "2024-01-01", + pipelines: [{ binding: "NESTED", pipeline: "nested-stream" }], + }) + ); + fs.writeFileSync("./index.ts", "export default { fetch() {} }"); + + await runWrangler("types --include-runtime=false"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + Generating project types... + + declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + type NestedRecord = { user_id: string; metadata?: { source?: string; priority: number } }; + interface Env { + NESTED: import("cloudflare:pipelines").Pipeline; + } + } + interface Env extends Cloudflare.Env {} + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.json file. + " + `); + }); + + it("should generate pipeline types without Cloudflare namespace prefix for service-worker format", async ({ + expect, + }) => { + msw.use( + http.get("*/accounts/:accountId/pipelines/v1/streams/:streamId", () => { + return HttpResponse.json( + createFetchResult({ + id: "events-stream", + name: "events", + version: 1, + schema: { + fields: [ + { name: "event_type", type: "string", required: true }, + { name: "timestamp", type: "timestamp", required: true }, + ], + }, + http: { enabled: false, authentication: false }, + worker_binding: { enabled: true }, + }) + ); + }) + ); + + // Service worker format uses addEventListener instead of export default + fs.writeFileSync( + "./index.js", + `addEventListener("fetch", (event) => { event.respondWith(new Response("Hello")); });` + ); + fs.writeFileSync( + "./wrangler.json", + JSON.stringify({ + name: "test-worker", + main: "./index.js", + compatibility_date: "2024-01-01", + pipelines: [{ binding: "EVENTS", pipeline: "events-stream" }], + }) + ); + + await runWrangler("types --include-runtime=false"); + + // For service-worker format, the type should NOT have the Cloudflare prefix + // since the type definition is at the top level, not inside a namespace + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + Generating project types... + + type EventsRecord = { event_type: string; timestamp: string | number }; + export {}; + declare global { + const EVENTS: import("cloudflare:pipelines").Pipeline; + } + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.json file. + " + `); + }); +}); diff --git a/packages/wrangler/src/pipelines/types.ts b/packages/wrangler/src/pipelines/types.ts index 3e8e78af5242..54057175f6c6 100644 --- a/packages/wrangler/src/pipelines/types.ts +++ b/packages/wrangler/src/pipelines/types.ts @@ -137,8 +137,11 @@ export type SchemaField = { | "bool" | "int32" | "int64" + | "uint32" + | "uint64" | "float32" | "float64" + | "decimal128" | "string" | "timestamp" | "json" @@ -149,6 +152,8 @@ export type SchemaField = { fields?: SchemaField[]; items?: SchemaField; unit?: "second" | "millisecond" | "microsecond" | "nanosecond"; // For timestamp type + precision?: number; // For decimal128 type + scale?: number; // For decimal128 type }; export type Sink = { diff --git a/packages/wrangler/src/type-generation/index.ts b/packages/wrangler/src/type-generation/index.ts index 3427600f5d74..6aaf01507969 100644 --- a/packages/wrangler/src/type-generation/index.ts +++ b/packages/wrangler/src/type-generation/index.ts @@ -28,6 +28,7 @@ import { TOP_LEVEL_ENV_NAME, validateEnvInterfaceNames, } from "./helpers"; +import { fetchPipelineTypes } from "./pipeline-schema"; import { generateRuntimeTypes } from "./runtime"; import { logRuntimeTypesMessage } from "./runtime/log-runtime-types-message"; import type { Entry } from "../deployment-bundle/entry"; @@ -280,13 +281,20 @@ export function isValidIdentifier(key: string) { } /** - * Construct a type key, if it's not a valid identifier, wrap it in quotes + * Escapes special characters in a string for use in a TypeScript string literal. + */ +export function escapeTypeScriptString(str: string): string { + return JSON.stringify(str).slice(1, -1); +} + +/** + * Construct a type key, if it's not a valid identifier, wrap it in quotes with proper escaping */ export function constructTypeKey(key: string) { if (isValidIdentifier(key)) { return `${key}`; } - return `"${key}"`; + return `"${escapeTypeScriptString(key)}"`; } export function constructTSModuleGlob(glob: string) { @@ -448,6 +456,7 @@ async function generateSimpleEnvTypes( const collectedUnsafeBindings = collectAllUnsafeBindings(collectionArgs); const collectedVars = collectAllVars(collectionArgs); const collectedWorkflows = collectAllWorkflows(collectionArgs); + const collectedPipelines = collectAllPipelines(collectionArgs); const entrypointFormat = entrypoint?.format ?? "modules"; const fullOutputPath = resolve(outputPath); @@ -464,6 +473,28 @@ async function generateSimpleEnvTypes( }); } + // Track named type definitions (e.g., `type MyStreamRecord = {...}`) to be added to the Cloudflare namespace + const typeDefinitions: string[] = []; + + if (collectedPipelines.length > 0) { + const pipelineTypes = await fetchPipelineTypes(config, collectedPipelines); + for (const pipelineType of pipelineTypes) { + // For service-worker format, type definitions are at the top level (not in Cloudflare namespace) + // so we need to strip the "Cloudflare." prefix from the type reference + const typeRef = + entrypointFormat === "service-worker" + ? pipelineType.type.replace("Cloudflare.", "") + : pipelineType.type; + envTypeStructure.push({ + key: constructTypeKey(pipelineType.binding), + type: typeRef, + }); + if (pipelineType.typeDefinition) { + typeDefinitions.push(pipelineType.typeDefinition); + } + } + } + if (collectedVars) { // Note: vars get overridden by secrets, so should their types const vars = Object.entries(collectedVars).filter( @@ -670,7 +701,8 @@ async function generateSimpleEnvTypes( entrypoint ? generateImportSpecifier(fullOutputPath, entrypoint.file) : undefined, - [...getDurableObjectClassNameToUseSQLiteMap(config.migrations).keys()] + [...getDurableObjectClassNameToUseSQLiteMap(config.migrations).keys()], + typeDefinitions ); const hash = createHash("sha256") @@ -739,6 +771,7 @@ async function generatePerEnvironmentTypes( const servicesPerEnv = collectServicesPerEnvironment(collectionArgs); const workflowsPerEnv = collectWorkflowsPerEnvironment(collectionArgs); const unsafePerEnv = collectUnsafeBindingsPerEnvironment(collectionArgs); + const pipelinesPerEnv = collectPipelinesPerEnvironment(collectionArgs); // Track all binding names and their types across all environments for aggregation const aggregatedBindings = new Map< @@ -857,6 +890,8 @@ async function generatePerEnvironmentTypes( const perEnvInterfaces = new Array(); const stringKeys = new Array(); + // Track named type definitions (e.g., `type MyStreamRecord = {...}`) to be added to the Cloudflare namespace + const typeDefinitions = new Set(); for (const envName of envNames) { const interfaceName = toEnvInterfaceName(envName); @@ -927,6 +962,27 @@ async function generatePerEnvironmentTypes( trackBinding(unsafe.name, type, envName); } + const envPipelines = pipelinesPerEnv.get(envName) ?? []; + if (envPipelines.length > 0) { + const pipelineTypes = await fetchPipelineTypes(config, envPipelines); + for (const pipelineType of pipelineTypes) { + // For service-worker format, type definitions are at the top level (not in Cloudflare namespace) + // so we need to strip the "Cloudflare." prefix from the type reference + const typeRef = + entrypointFormat === "service-worker" + ? pipelineType.type.replace("Cloudflare.", "") + : pipelineType.type; + envBindings.push({ + key: constructTypeKey(pipelineType.binding), + value: typeRef, + }); + trackBinding(pipelineType.binding, typeRef, envName); + if (pipelineType.typeDefinition) { + typeDefinitions.add(pipelineType.typeDefinition); + } + } + } + if (envBindings.length > 0) { const bindingLines = envBindings .map(({ key, value }) => `\t\t${key}: ${value};`) @@ -982,6 +1038,23 @@ async function generatePerEnvironmentTypes( trackBinding(unsafe.name, type, TOP_LEVEL_ENV_NAME); } + const topLevelPipelines = pipelinesPerEnv.get(TOP_LEVEL_ENV_NAME) ?? []; + if (topLevelPipelines.length > 0) { + const pipelineTypes = await fetchPipelineTypes(config, topLevelPipelines); + for (const pipelineType of pipelineTypes) { + // For service-worker format, type definitions are at the top level (not in Cloudflare namespace) + // so we need to strip the "Cloudflare." prefix from the type reference + const typeRef = + entrypointFormat === "service-worker" + ? pipelineType.type.replace("Cloudflare.", "") + : pipelineType.type; + trackBinding(pipelineType.binding, typeRef, TOP_LEVEL_ENV_NAME); + if (pipelineType.typeDefinition) { + typeDefinitions.add(pipelineType.typeDefinition); + } + } + } + const aggregatedEnvBindings = new Array<{ key: string; required: boolean; @@ -1073,7 +1146,8 @@ async function generatePerEnvironmentTypes( entrypoint ? generateImportSpecifier(fullOutputPath, entrypoint.file) : undefined, - [...getDurableObjectClassNameToUseSQLiteMap(config.migrations).keys()] + [...getDurableObjectClassNameToUseSQLiteMap(config.migrations).keys()], + [...typeDefinitions] ); const hash = createHash("sha256") @@ -1121,11 +1195,18 @@ function generatePerEnvTypeStrings( compatibilityDate: string | undefined, compatibilityFlags: string[] | undefined, entrypointModule: string | undefined, - configuredDurableObjects: string[] + configuredDurableObjects: string[], + typeDefinitions: string[] = [] ): { fileContent: string; consoleOutput: string } { let baseContent = ""; let processEnv = ""; + // Named type definitions go inside the Cloudflare namespace + const typeDefsContent = + typeDefinitions.length > 0 + ? typeDefinitions.map((def) => `\t${def}`).join("\n") + : ""; + if (formatType === "modules") { if ( isProcessEnvPopulated(compatibilityDate, compatibilityFlags) && @@ -1144,13 +1225,15 @@ function generatePerEnvTypeStrings( ? `\n\tinterface GlobalProps {\n\t\tmainModule: typeof import("${entrypointModule}");${configuredDurableObjects.length > 0 ? `\n\t\tdurableNamespaces: ${configuredDurableObjects.map((d) => `"${d}"`).join(" | ")};` : ""}\n\t}` : ""; - baseContent = `declare namespace Cloudflare {${globalPropsContent}\n${perEnvContent}\n\tinterface Env {\n${envBindingLines}\n\t}\n}\ninterface ${envInterface} extends Cloudflare.Env {}${processEnv}`; + baseContent = `declare namespace Cloudflare {${globalPropsContent}${typeDefsContent ? `\n${typeDefsContent}` : ""}\n${perEnvContent}\n\tinterface Env {\n${envBindingLines}\n\t}\n}\ninterface ${envInterface} extends Cloudflare.Env {}${processEnv}`; } else { - // Service worker syntax - just output aggregated bindings as globals + // Service worker syntax - type definitions go at the top level since there's no namespace + const globalTypeDefsContent = + typeDefinitions.length > 0 ? typeDefinitions.join("\n") + "\n" : ""; const envBindingLines = aggregatedEnvBindings .map(({ key, type }) => `\tconst ${key}: ${type};`) .join("\n"); - baseContent = `export {};\ndeclare global {\n${envBindingLines}\n}`; + baseContent = `${globalTypeDefsContent}export {};\ndeclare global {\n${envBindingLines}\n}`; } const modulesContent = modulesTypeStructure.join("\n"); @@ -1219,7 +1302,8 @@ function generateTypeStrings( compatibilityDate: string | undefined, compatibilityFlags: string[] | undefined, entrypointModule: string | undefined, - configuredDurableObjects: string[] + configuredDurableObjects: string[], + typeDefinitions: string[] = [] ): { consoleOutput: string; fileContent: string; @@ -1227,6 +1311,12 @@ function generateTypeStrings( let baseContent = ""; let processEnv = ""; + // Type definitions (e.g., pipeline record types) go inside the Cloudflare namespace + const typeDefsContent = + typeDefinitions.length > 0 + ? typeDefinitions.map((def) => `\t${def}`).join("\n") + : ""; + if (formatType === "modules") { if ( isProcessEnvPopulated(compatibilityDate, compatibilityFlags) && @@ -1235,9 +1325,12 @@ function generateTypeStrings( // StringifyValues ensures that json vars are correctly types as strings, not objects on process.env processEnv = `\ntype StringifyValues> = {\n\t[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;\n};\ndeclare namespace NodeJS {\n\tinterface ProcessEnv extends StringifyValues `"${k}"`).join(" | ")}>> {}\n}`; } - baseContent = `declare namespace Cloudflare {${entrypointModule ? `\n\tinterface GlobalProps {\n\t\tmainModule: typeof import("${entrypointModule}");${configuredDurableObjects.length > 0 ? `\n\t\tdurableNamespaces: ${configuredDurableObjects.map((d) => `"${d}"`).join(" | ")};` : ""}\n\t}` : ""}\n\tinterface Env {${envTypeStructure.map((value) => `\n\t\t${value}`).join("")}\n\t}\n}\ninterface ${envInterface} extends Cloudflare.Env {}${processEnv}`; + baseContent = `declare namespace Cloudflare {${entrypointModule ? `\n\tinterface GlobalProps {\n\t\tmainModule: typeof import("${entrypointModule}");${configuredDurableObjects.length > 0 ? `\n\t\tdurableNamespaces: ${configuredDurableObjects.map((d) => `"${d}"`).join(" | ")};` : ""}\n\t}` : ""}${typeDefsContent ? `\n${typeDefsContent}` : ""}\n\tinterface Env {${envTypeStructure.map((value) => `\n\t\t${value}`).join("")}\n\t}\n}\ninterface ${envInterface} extends Cloudflare.Env {}${processEnv}`; } else { - baseContent = `export {};\ndeclare global {\n${envTypeStructure.map((value) => `\tconst ${value}`).join("\n")}\n}`; + // For service worker format, type definitions still go at the top level since there's no namespace + const globalTypeDefsContent = + typeDefinitions.length > 0 ? typeDefinitions.join("\n") + "\n" : ""; + baseContent = `${globalTypeDefsContent}export {};\ndeclare global {\n${envTypeStructure.map((value) => `\tconst ${value}`).join("\n")}\n}`; } const modulesContent = modulesTypeStructure.join("\n"); @@ -1708,25 +1801,7 @@ function collectCoreBindings( addBinding(vpcService.binding, "Fetcher", "vpc_services", envName); } - for (const [index, pipeline] of (env.pipelines ?? []).entries()) { - if (!pipeline.binding) { - throwMissingBindingError({ - binding: pipeline, - bindingType: "pipelines", - configPath: args.config, - envName, - fieldName: "binding", - index, - }); - } - - addBinding( - pipeline.binding, - 'import("cloudflare:pipelines").Pipeline', - "pipelines", - envName - ); - } + // Pipelines handled separately for async schema fetching if (env.logfwdr?.bindings?.length) { addBinding("LOGFWDR_SCHEMA", "any", "logfwdr", envName); @@ -2113,6 +2188,87 @@ function collectAllUnsafeBindings( return Array.from(unsafeMap.values()); } +/** + * Collects pipeline bindings across environments. + * + * This is separate from collectCoreBindings because pipelines need async + * schema fetching for typed bindings. + * + * @param args - All the CLI arguments passed to the `types` command + * + * @returns An array of collected pipeline bindings with their names and pipeline IDs. + */ +function collectAllPipelines( + args: Partial<(typeof typesCommand)["args"]> +): Array<{ + binding: string; + pipeline: string; +}> { + const pipelinesMap = new Map< + string, + { + binding: string; + pipeline: string; + } + >(); + + function collectEnvironmentPipelines( + env: RawEnvironment | undefined, + envName: string + ) { + if (!env?.pipelines) { + return; + } + + for (const [index, pipeline] of env.pipelines.entries()) { + if (!pipeline.binding) { + throwMissingBindingError({ + binding: pipeline, + bindingType: "pipelines", + configPath: args.config, + envName, + fieldName: "binding", + index, + }); + } + + if (!pipeline.pipeline) { + throwMissingBindingError({ + binding: pipeline, + bindingType: "pipelines", + configPath: args.config, + envName, + fieldName: "pipeline", + index, + }); + } + + if (pipelinesMap.has(pipeline.binding)) { + continue; + } + + pipelinesMap.set(pipeline.binding, { + binding: pipeline.binding, + pipeline: pipeline.pipeline, + }); + } + } + + const { rawConfig } = experimental_readRawConfig(args); + + if (args.env) { + const envConfig = getEnvConfig(args.env, rawConfig); + collectEnvironmentPipelines(envConfig, args.env); + } else { + collectEnvironmentPipelines(rawConfig, TOP_LEVEL_ENV_NAME); + for (const [envName, env] of Object.entries(rawConfig.env ?? {})) { + collectEnvironmentPipelines(env, envName); + } + } + + return Array.from(pipelinesMap.values()); +} + const logHorizontalRule = () => { const screenWidth = process.stdout.columns; logger.log(chalk.dim("─".repeat(Math.min(screenWidth, 60)))); @@ -2502,24 +2658,7 @@ function collectCoreBindingsPerEnvironment( }); } - for (const [index, pipeline] of (env.pipelines ?? []).entries()) { - if (!pipeline.binding) { - throwMissingBindingError({ - binding: pipeline, - bindingType: "pipelines", - configPath: args.config, - envName, - fieldName: "binding", - index, - }); - } - - bindings.push({ - bindingCategory: "pipelines", - name: pipeline.binding, - type: 'import("cloudflare:pipelines").Pipeline', - }); - } + // Pipelines handled separately for async schema fetching if (env.logfwdr?.bindings?.length) { bindings.push({ @@ -2986,3 +3125,98 @@ function collectUnsafeBindingsPerEnvironment( return result; } + +/** + * Collects pipeline bindings per environment. + * + * This is separate from collectCoreBindingsPerEnvironment because pipelines + * need async schema fetching for typed bindings. + * + * @param args - CLI arguments passed to the `types` command + * + * @returns A map of environment name to array of pipeline bindings + */ +function collectPipelinesPerEnvironment( + args: Partial<(typeof typesCommand)["args"]> +): Map< + string, + Array<{ + binding: string; + pipeline: string; + }> +> { + const result = new Map< + string, + Array<{ + binding: string; + pipeline: string; + }> + >(); + + function collectEnvironmentPipelines( + env: RawEnvironment | undefined, + envName: string + ): Array<{ + binding: string; + pipeline: string; + }> { + const pipelines = new Array<{ + binding: string; + pipeline: string; + }>(); + + if (!env?.pipelines) { + return pipelines; + } + + for (const [index, pipeline] of env.pipelines.entries()) { + if (!pipeline.binding) { + throwMissingBindingError({ + binding: pipeline, + bindingType: "pipelines", + configPath: args.config, + envName, + fieldName: "binding", + index, + }); + } + + if (!pipeline.pipeline) { + throwMissingBindingError({ + binding: pipeline, + bindingType: "pipelines", + configPath: args.config, + envName, + fieldName: "pipeline", + index, + }); + } + + pipelines.push({ + binding: pipeline.binding, + pipeline: pipeline.pipeline, + }); + } + + return pipelines; + } + + const { rawConfig } = experimental_readRawConfig(args); + + const topLevelPipelines = collectEnvironmentPipelines( + rawConfig, + TOP_LEVEL_ENV_NAME + ); + if (topLevelPipelines.length > 0) { + result.set(TOP_LEVEL_ENV_NAME, topLevelPipelines); + } + + for (const [envName, env] of Object.entries(rawConfig.env ?? {})) { + const envPipelines = collectEnvironmentPipelines(env, envName); + if (envPipelines.length > 0) { + result.set(envName, envPipelines); + } + } + + return result; +} diff --git a/packages/wrangler/src/type-generation/pipeline-schema.ts b/packages/wrangler/src/type-generation/pipeline-schema.ts new file mode 100644 index 000000000000..93dbd8ca7783 --- /dev/null +++ b/packages/wrangler/src/type-generation/pipeline-schema.ts @@ -0,0 +1,272 @@ +import { logger } from "../logger"; +import { getStream } from "../pipelines/client"; +import { getAPIToken } from "../user"; +import { toPascalCase } from "./helpers"; +import { escapeTypeScriptString, isValidIdentifier } from "./index"; +import type { SchemaField, Stream } from "../pipelines/types"; +import type { Config } from "@cloudflare/workers-utils"; + +/** + * Maximum nesting depth for struct/list types to prevent stack overflow + */ +const MAX_NESTING_DEPTH = 10; + +/** + * The default generic pipeline type when schema is unavailable + */ +export const GENERIC_PIPELINE_TYPE = + 'import("cloudflare:pipelines").Pipeline'; + +/** + * Converts a pipeline schema field type to its TypeScript equivalent + * + * @param field - The schema field to convert + * @param depth - Current nesting depth (for recursion protection) + * @returns The TypeScript type string + */ +function schemaFieldTypeToTypeScript(field: SchemaField, depth = 0): string { + if (depth > MAX_NESTING_DEPTH) { + logger.warn( + `Schema nesting depth exceeded ${MAX_NESTING_DEPTH} for field '${field.name}', using unknown type` + ); + return "unknown"; + } + + switch (field.type) { + case "string": + return "string"; + case "bool": + return "boolean"; + case "int32": + case "int64": + case "uint32": + case "uint64": + case "float32": + case "float64": + case "decimal128": + return "number"; + case "timestamp": + // Timestamps can be RFC 3339 strings or numeric Unix timestamps + return "string | number"; + case "json": + return "Record"; + case "bytes": + // Binary data is base64-encoded when sent as JSON + return "string"; + case "list": + if (field.items) { + const itemType = schemaFieldTypeToTypeScript(field.items, depth + 1); + return `Array<${itemType}>`; + } + return "unknown[]"; + case "struct": + if ( + field.fields && + Array.isArray(field.fields) && + field.fields.length > 0 + ) { + return schemaFieldsToTypeScript(field.fields, depth + 1); + } + return "Record"; + default: + return "unknown"; + } +} + +/** + * Converts an array of schema fields to a TypeScript object type string + * + * @param fields - The array of schema fields + * @param depth - Current nesting depth (for recursion protection) + * @returns TypeScript object type string + */ +function schemaFieldsToTypeScript(fields: SchemaField[], depth = 0): string { + if (fields.length === 0) { + return "Record"; + } + + const properties = fields.map((field) => { + const optional = field.required ? "" : "?"; + const fieldType = schemaFieldTypeToTypeScript(field, depth); + // Use quotes for field names that aren't valid identifiers, with proper escaping + const fieldName = isValidIdentifier(field.name) + ? field.name + : `"${escapeTypeScriptString(field.name)}"`; + return `${fieldName}${optional}: ${fieldType}`; + }); + + return `{ ${properties.join("; ")} }`; +} + +/** + * Converts a stream name to a TypeScript type name in PascalCase with "Record" suffix + * + * @example + * streamNameToTypeName("analytics_stream") // "AnalyticsStreamRecord" + * streamNameToTypeName("my-events") // "MyEventsRecord" + */ +export function streamNameToTypeName(streamName: string): string { + const pascalCase = toPascalCase(streamName); + return `${pascalCase}Record`; +} + +/** + * Result of generating pipeline type from schema + */ +export interface PipelineSchemaTypeResult { + /** The type reference to use in the binding (e.g., "Pipeline") */ + typeReference: string; + /** The type definition to include at the top of the file, or null if using generic type */ + typeDefinition: string | null; + /** The name of the generated type, or null if using generic type */ + typeName: string | null; +} + +/** + * Generates a TypeScript type for a pipeline binding based on its stream schema + * + * @param schema - The stream schema (or null for unstructured streams) + * @param streamName - The name of the stream (used for type naming) + * @returns Object containing the type reference and optional type definition + */ +export function generatePipelineTypeFromSchema( + schema: Stream["schema"], + streamName?: string +): PipelineSchemaTypeResult { + if (!schema || !Array.isArray(schema.fields) || schema.fields.length === 0) { + // Unstructured stream - use generic type + return { + typeReference: GENERIC_PIPELINE_TYPE, + typeDefinition: null, + typeName: null, + }; + } + + const recordType = schemaFieldsToTypeScript(schema.fields); + const typeName = streamName ? streamNameToTypeName(streamName) : null; + + if (typeName) { + // Type is inside Cloudflare namespace, so reference it with Cloudflare. prefix + return { + typeReference: `import("cloudflare:pipelines").Pipeline`, + typeDefinition: `type ${typeName} = ${recordType};`, + typeName, + }; + } + + // Fallback to inline type if no stream name available + return { + typeReference: `import("cloudflare:pipelines").Pipeline<${recordType}>`, + typeDefinition: null, + typeName: null, + }; +} + +/** + * Fetches the stream data from the API + * + * @param config - The wrangler config + * @param streamId - The stream/pipeline ID + * @returns The stream data, or null if unavailable + */ +export async function fetchStream( + config: Config, + streamId: string +): Promise { + try { + return await getStream(config, streamId); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.debug( + `Failed to fetch stream '${streamId}': ${errorMessage}. Using generic type.` + ); + return null; + } +} + +/** + * Checks if the user has an API token configured + */ +export function hasApiToken(): boolean { + return getAPIToken() !== undefined; +} + +/** + * Result of fetching pipeline types + */ +export interface PipelineTypeResult { + binding: string; + type: string; + typeDefinition: string | null; + typeName: string | null; +} + +/** + * Fetches pipeline types for all pipeline bindings in the config + * + * If authenticated, attempts to fetch schemas from the API. + * Falls back to generic types if not authenticated or on error. + * + * @param config - The wrangler config + * @param pipelines - Array of pipeline bindings from config + * @returns Array of pipeline type results + */ +export async function fetchPipelineTypes( + config: Config, + pipelines: Array<{ binding: string; pipeline: string }> +): Promise { + if (pipelines.length === 0) { + return []; + } + + if (!hasApiToken()) { + logger.warn( + "Not authenticated - using generic types for pipeline bindings. Run `wrangler login` to enable typed pipeline bindings." + ); + return pipelines.map((p) => ({ + binding: p.binding, + type: GENERIC_PIPELINE_TYPE, + typeDefinition: null, + typeName: null, + })); + } + + // Fetch all streams in parallel for better performance + const streams = await Promise.all( + pipelines.map((p) => fetchStream(config, p.pipeline)) + ); + + const results = pipelines.map((pipeline, i) => { + const stream = streams[i]; + if (stream) { + const schemaResult = generatePipelineTypeFromSchema( + stream.schema, + stream.name + ); + return { + binding: pipeline.binding, + type: schemaResult.typeReference, + typeDefinition: schemaResult.typeDefinition, + typeName: schemaResult.typeName, + }; + } + return { + binding: pipeline.binding, + type: GENERIC_PIPELINE_TYPE, + typeDefinition: null, + typeName: null, + }; + }); + + const fetchedCount = streams.filter(Boolean).length; + if (fetchedCount > 0) { + logger.debug(`Fetched schemas for ${fetchedCount} pipeline binding(s)`); + } + if (fetchedCount < pipelines.length) { + logger.warn( + `Could not fetch schemas for ${pipelines.length - fetchedCount} pipeline binding(s) - using generic types` + ); + } + + return results; +} From 0b171175f1fe8c885a54398ee84fd0aa35ca9cbe Mon Sep 17 00:00:00 2001 From: Sid Date: Wed, 18 Feb 2026 13:37:29 -0500 Subject: [PATCH 4/4] improve: bump up maximum allowed value for Queue delivery and retry delay to 24 hours (#12597) --- .changeset/slick-bananas-doubt.md | 6 ++++++ packages/miniflare/src/workers/queues/schemas.ts | 2 +- packages/wrangler/src/__tests__/queues/queues.test.ts | 8 ++++---- packages/wrangler/src/queues/cli/commands/create.ts | 3 +-- packages/wrangler/src/queues/cli/commands/update.ts | 3 +-- packages/wrangler/src/queues/constants.ts | 2 +- 6 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 .changeset/slick-bananas-doubt.md diff --git a/.changeset/slick-bananas-doubt.md b/.changeset/slick-bananas-doubt.md new file mode 100644 index 000000000000..0e56ee636f59 --- /dev/null +++ b/.changeset/slick-bananas-doubt.md @@ -0,0 +1,6 @@ +--- +"miniflare": patch +"wrangler": patch +--- + +The maximum allowed delivery and retry delays for Queues is now 24 hours diff --git a/packages/miniflare/src/workers/queues/schemas.ts b/packages/miniflare/src/workers/queues/schemas.ts index 0a3ead4e4135..d714a273ce1e 100644 --- a/packages/miniflare/src/workers/queues/schemas.ts +++ b/packages/miniflare/src/workers/queues/schemas.ts @@ -4,7 +4,7 @@ export const QueueMessageDelaySchema = z .number() .int() .min(0) - .max(43200) + .max(86400) .optional(); export const QueueProducerOptionsSchema = /* @__PURE__ */ z.object({ diff --git a/packages/wrangler/src/__tests__/queues/queues.test.ts b/packages/wrangler/src/__tests__/queues/queues.test.ts index 102f118f7665..96556bc19a3b 100644 --- a/packages/wrangler/src/__tests__/queues/queues.test.ts +++ b/packages/wrangler/src/__tests__/queues/queues.test.ts @@ -245,7 +245,7 @@ describe("wrangler", () => { -v, --version Show version number [boolean] OPTIONS - --delivery-delay-secs How long a published message should be delayed for, in seconds. Must be between 0 and 43200 [number] + --delivery-delay-secs How long a published message should be delayed for, in seconds. Must be between 0 and 86400 [number] --message-retention-period-secs How long to retain a message in the queue, in seconds. Must be between 60 and 86400 if on free tier, otherwise must be between 60 and 1209600 [number]" `); }); @@ -288,7 +288,7 @@ describe("wrangler", () => { await expect( runWrangler("queues create testQueue --delivery-delay-secs=99999") ).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: Invalid --delivery-delay-secs value: 99999. Must be between 0 and 43200]` + `[Error: Invalid --delivery-delay-secs value: 99999. Must be between 0 and 86400]` ); expect(requests.count).toEqual(0); @@ -469,7 +469,7 @@ describe("wrangler", () => { -v, --version Show version number [boolean] OPTIONS - --delivery-delay-secs How long a published message should be delayed for, in seconds. Must be between 0 and 43200 [number] + --delivery-delay-secs How long a published message should be delayed for, in seconds. Must be between 0 and 86400 [number] --message-retention-period-secs How long to retain a message in the queue, in seconds. Must be between 60 and 86400 if on free tier, otherwise must be between 60 and 1209600 [number]" `); }); @@ -556,7 +556,7 @@ describe("wrangler", () => { await expect( runWrangler("queues update testQueue --delivery-delay-secs=99999") ).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: Invalid --delivery-delay-secs value: 99999. Must be between 0 and 43200]` + `[Error: Invalid --delivery-delay-secs value: 99999. Must be between 0 and 86400]` ); expect(requests.count).toEqual(0); diff --git a/packages/wrangler/src/queues/cli/commands/create.ts b/packages/wrangler/src/queues/cli/commands/create.ts index 8e8cf7388158..f397a9385779 100644 --- a/packages/wrangler/src/queues/cli/commands/create.ts +++ b/packages/wrangler/src/queues/cli/commands/create.ts @@ -30,8 +30,7 @@ export const queuesCreateCommand = createCommand({ }, "delivery-delay-secs": { type: "number", - describe: - "How long a published message should be delayed for, in seconds. Must be between 0 and 43200", + describe: `How long a published message should be delayed for, in seconds. Must be between ${MIN_DELIVERY_DELAY_SECS} and ${MAX_DELIVERY_DELAY_SECS}`, }, "message-retention-period-secs": { type: "number", diff --git a/packages/wrangler/src/queues/cli/commands/update.ts b/packages/wrangler/src/queues/cli/commands/update.ts index 7c09106e2100..7efcbcbd323c 100644 --- a/packages/wrangler/src/queues/cli/commands/update.ts +++ b/packages/wrangler/src/queues/cli/commands/update.ts @@ -25,8 +25,7 @@ export const queuesUpdateCommand = createCommand({ }, "delivery-delay-secs": { type: "number", - describe: - "How long a published message should be delayed for, in seconds. Must be between 0 and 43200", + describe: `How long a published message should be delayed for, in seconds. Must be between ${MIN_DELIVERY_DELAY_SECS} and ${MAX_DELIVERY_DELAY_SECS}`, }, "message-retention-period-secs": { type: "number", diff --git a/packages/wrangler/src/queues/constants.ts b/packages/wrangler/src/queues/constants.ts index 5dda963160d9..a212d2bef297 100644 --- a/packages/wrangler/src/queues/constants.ts +++ b/packages/wrangler/src/queues/constants.ts @@ -2,7 +2,7 @@ export const INVALID_CONSUMER_SETTINGS_ERROR = 100127; export const INVALID_QUEUE_SETTINGS_ERROR = 100128; export const MIN_DELIVERY_DELAY_SECS = 0; -export const MAX_DELIVERY_DELAY_SECS = 43200; +export const MAX_DELIVERY_DELAY_SECS = 86400; export const MIN_MESSAGE_RETENTION_PERIOD_SECS = 60; export const MAX_MESSAGE_RETENTION_PERIOD_SECS = 1209600;