diff --git a/bun.lock b/bun.lock index dac010cdf..42d962675 100644 --- a/bun.lock +++ b/bun.lock @@ -119,6 +119,7 @@ "dependencies": { "@nx/devkit": "^23.0.0", "@nx/js": "^23.0.0", + "jsonc-parser": "^3.3.0", "tslib": "^2.3.0", }, "peerDependencies": { @@ -2628,7 +2629,7 @@ "jsonc-eslint-parser": ["jsonc-eslint-parser@2.4.2", "", { "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", "espree": "^9.0.0", "semver": "^7.3.5" } }, "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA=="], - "jsonc-parser": ["jsonc-parser@3.2.1", "", {}, "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -3956,9 +3957,9 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@angular-devkit/schematics/@angular-devkit/core": ["@angular-devkit/core@21.2.14", "", { "dependencies": { "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.4", "rxjs": "7.8.2", "source-map": "0.7.6" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-RSOWXB9bFc2nwRWMxbIT0RbSNFUrwfBo4N5MNxbyQ69Ndc0gVm3h+3ArHv0qotH4d+pJYbm5ttXu8YqR2kc0CA=="], + "@angular-devkit/core/jsonc-parser": ["jsonc-parser@3.2.1", "", {}, "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA=="], - "@angular-devkit/schematics/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "@angular-devkit/schematics/@angular-devkit/core": ["@angular-devkit/core@21.2.14", "", { "dependencies": { "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.4", "rxjs": "7.8.2", "source-map": "0.7.6" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-RSOWXB9bFc2nwRWMxbIT0RbSNFUrwfBo4N5MNxbyQ69Ndc0gVm3h+3ArHv0qotH4d+pJYbm5ttXu8YqR2kc0CA=="], "@angular-devkit/schematics/ora": ["ora@9.3.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.1", "string-width": "^8.1.0" } }, "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw=="], @@ -4048,8 +4049,6 @@ "@nx/js/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@nx/js/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "@nx/js/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "@nx/node/kill-port": ["kill-port@1.6.1", "", { "dependencies": { "get-them-args": "1.3.2", "shell-exec": "1.0.2" }, "bin": { "kill-port": "cli.js" } }, "sha512-un0Y55cOM7JKGaLnGja28T38tDDop0AQ8N0KlAdyh+B1nmMoX8AnNmqPNZbS3mUMgiST51DCVqmbFT1gNJpVNw=="], @@ -4110,8 +4109,6 @@ "@schematics/angular/@angular-devkit/core": ["@angular-devkit/core@21.2.14", "", { "dependencies": { "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.4", "rxjs": "7.8.2", "source-map": "0.7.6" }, "peerDependencies": { "chokidar": "^5.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-RSOWXB9bFc2nwRWMxbIT0RbSNFUrwfBo4N5MNxbyQ69Ndc0gVm3h+3ArHv0qotH4d+pJYbm5ttXu8YqR2kc0CA=="], - "@schematics/angular/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "@svgr/core/camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], "@svgr/hast-util-to-babel-ast/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -4444,8 +4441,6 @@ "nx/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "nx/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "nx/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "nx/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], diff --git a/packages/nx-cloudflare-e2e/src/binding.spec.ts b/packages/nx-cloudflare-e2e/src/binding.spec.ts new file mode 100644 index 000000000..fbaf2da47 --- /dev/null +++ b/packages/nx-cloudflare-e2e/src/binding.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { uniq, fileExists, tmpProjPath } from '@nx/plugin/testing'; +import { createTestProject, cleanup, runCLI } from '@naxodev/e2e-utils'; +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; + +// Exercises the binding generator end-to-end: scaffolds a Worker via real C3, +// then runs `:binding` against the published tarball to verify config edits, +// code stubs, and migrations land correctly in a real consumer workspace. +describe('binding generator', () => { + let priorFlatConfig: string | undefined; + + beforeAll(() => { + priorFlatConfig = process.env.ESLINT_USE_FLAT_CONFIG; + process.env.ESLINT_USE_FLAT_CONFIG = 'false'; + createTestProject('nx-cloudflare'); + }, 300_000); + + afterAll(() => { + if (priorFlatConfig === undefined) { + delete process.env.ESLINT_USE_FLAT_CONFIG; + } else { + process.env.ESLINT_USE_FLAT_CONFIG = priorFlatConfig; + } + cleanup(); + }); + + it('adds a KV binding to an existing Worker', () => { + const app = uniq('bindingkv'); + + runCLI( + `generate @naxodev/nx-cloudflare:create-cloudflare --directory="apps/${app}" --type=hello-world --lang=ts --no-interactive`, + { env: { NX_ADD_PLUGINS: 'true' } } + ); + + runCLI( + `generate @naxodev/nx-cloudflare:binding --project="${app}" --type=kv --binding=MY_KV --id=test-id-123 --skipTypegen --no-interactive` + ); + + const root = join(tmpProjPath(), `apps/${app}`); + const wrangler = readFileSync(join(root, 'wrangler.jsonc'), 'utf-8'); + expect(wrangler).toContain('kv_namespaces'); + expect(wrangler).toContain('MY_KV'); + expect(wrangler).toContain('test-id-123'); + }, 300_000); + + it('adds a Durable Object binding with a migration and class stub', () => { + const app = uniq('bindingdo'); + + runCLI( + `generate @naxodev/nx-cloudflare:create-cloudflare --directory="apps/${app}" --type=hello-world --lang=ts --no-interactive`, + { env: { NX_ADD_PLUGINS: 'true' } } + ); + + runCLI( + `generate @naxodev/nx-cloudflare:binding --project="${app}" --type=do --binding=MY_DO --name=MyDurableObject --skipTypegen --no-interactive` + ); + + const root = join(tmpProjPath(), `apps/${app}`); + const wrangler = readFileSync(join(root, 'wrangler.jsonc'), 'utf-8'); + expect(wrangler).toContain('durable_objects'); + expect(wrangler).toContain('MyDurableObject'); + expect(wrangler).toContain('migrations'); + expect(wrangler).toContain('"v1"'); + + expect(fileExists(join(root, 'src/my-durable-object.ts'))).toBeTruthy(); + const stub = readFileSync(join(root, 'src/my-durable-object.ts'), 'utf-8'); + expect(stub).toContain('export class MyDurableObject'); + + const index = readFileSync(join(root, 'src/index.ts'), 'utf-8'); + expect(index).toContain("export * from './my-durable-object'"); + }, 300_000); + + it('adds a Queue binding with producer + consumer and a queue handler', () => { + const app = uniq('bindingqueue'); + + runCLI( + `generate @naxodev/nx-cloudflare:create-cloudflare --directory="apps/${app}" --type=hello-world --lang=ts --no-interactive`, + { env: { NX_ADD_PLUGINS: 'true' } } + ); + + runCLI( + `generate @naxodev/nx-cloudflare:binding --project="${app}" --type=queue --binding=MY_QUEUE --name=my-queue --skipTypegen --no-interactive` + ); + + const root = join(tmpProjPath(), `apps/${app}`); + const wrangler = readFileSync(join(root, 'wrangler.jsonc'), 'utf-8'); + expect(wrangler).toContain('queues'); + expect(wrangler).toContain('producers'); + expect(wrangler).toContain('consumers'); + expect(wrangler).toContain('my-queue'); + + const index = readFileSync(join(root, 'src/index.ts'), 'utf-8'); + expect(index).toContain('async queue('); + }, 300_000); +}); diff --git a/packages/nx-cloudflare/README.md b/packages/nx-cloudflare/README.md index 2742dbb13..ed4430e67 100644 --- a/packages/nx-cloudflare/README.md +++ b/packages/nx-cloudflare/README.md @@ -27,6 +27,7 @@ Nx plugin for Cloudflare. - ✅ Vitest tests support - ✅ Inferred `serve`, `deploy`, `typegen`, `version-upload`, and `tail` targets (via the `@naxodev/nx-cloudflare/plugin` Crystal plugin). - ✅ Generate Cloudflare Worker Library +- ✅ Add bindings to an existing Worker (KV, R2, D1, Durable Objects, Queues, Workflows, Service/RPC) ## Installation @@ -125,6 +126,78 @@ See the [`wrangler dev`](https://developers.cloudflare.com/workers/wrangler/comm `worker-configuration.d.ts` (your typed `Env` and runtime types) is the `typegen` target's declared output, so it is treated as a generated build artifact — the generator git-ignores it instead of committing it. Run `nx typegen ` to (re)generate it, and re-run after changing bindings or `compatibility_date` in your `wrangler` config. +### Bindings + +#### Adding a binding to an existing Worker + +```bash +nx g @naxodev/nx-cloudflare:binding --project=my-worker --type=kv --binding=MY_KV +``` + +The binding generator wires a binding into an existing Worker's `wrangler.jsonc`, stubs the required code (Durable Object / Workflow classes, queue consumer handler), adds Durable Object migrations, emits a matching Vitest spec, and refreshes `wrangler types` so the `Env` interface picks up the new binding. + +Supported types: + +| Type | Config key | Code stub | Provisionable via `--create` | +| ---------- | ----------------- | ---------------------------------------------------------- | --------------------------------- | +| `kv` | `kv_namespaces` | — (env binding typed by `wrangler types`) | ✅ `wrangler kv namespace create` | +| `r2` | `r2_buckets` | — | ✅ `wrangler r2 bucket create` | +| `d1` | `d1_databases` | — | ✅ `wrangler d1 create` | +| `do` | `durable_objects` | `export class X extends DurableObject {}` + migration | ❌ (code class, not a resource) | +| `queue` | `queues` | `queue()` handler injected into `src/index.ts` | ✅ `wrangler queues create` | +| `workflow` | `workflows` | `export class X extends WorkflowEntrypoint {}` | ❌ | +| `service` | `services` | — (env binding typed by `wrangler types`) | ❌ (reference to another Worker) | + +Available options: + +| Option | Type | Default | Description | +| ------------ | ------------------------------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------- | +| project | string | \*required | The target Worker project. | +| type | `kv` \| `r2` \| `d1` \| `do` \| `queue` \| `workflow` \| `service` | \*required | The kind of binding to add. | +| binding | string | \*required | The `Env` key name (SCREAMING_SNAKE_CASE), e.g. `MY_KV`. | +| name | string | — | Class name (`do`/`workflow`, PascalCase), queue name (`queue`), or service name (`service`). Required for those types. | +| id | string | — | Existing KV namespace id or D1 database id. Auto-captured when `--create` is used. | +| databaseName | string | — | D1 database name. Required for `d1` (or `--create`). | +| bucketName | string | — | R2 bucket name. Required for `r2` (or `--create`). | +| create | boolean | false | Provision the remote resource via Wrangler (KV/R2/D1/Queue only). Fills the id/name automatically. | +| skipTests | boolean | false | Skip generating a Vitest spec for the binding stub. | +| skipFormat | boolean | false | Skip formatting files. | +| skipTypegen | boolean | false | Skip auto-running `wrangler types` after writing the binding. | + +#### Examples + +Add a KV namespace with an existing id: + +```bash +nx g @naxodev/nx-cloudflare:binding --project=my-worker --type=kv --binding=MY_KV --id=abc123 +``` + +Provision a new KV namespace and wire it automatically: + +```bash +nx g @naxodev/nx-cloudflare:binding --project=my-worker --type=kv --binding=MY_KV --create +``` + +Add a Durable Object (writes the binding, a migration, a class stub, and a test): + +```bash +nx g @naxodev/nx-cloudflare:binding --project=my-worker --type=do --binding=MY_DO --name=MyDurableObject +``` + +Add a Queue (writes producer + consumer config and injects a `queue()` handler into `src/index.ts`): + +```bash +nx g @naxodev/nx-cloudflare:binding --project=my-worker --type=queue --binding=MY_QUEUE --name=my-queue +``` + +Add a Service/RPC binding: + +```bash +nx g @naxodev/nx-cloudflare:binding --project=my-worker --type=service --binding=AUTH_SERVICE --name=auth-worker +``` + +The generator preserves JSONC comments in `wrangler.jsonc` and errors if the binding name already exists — re-run with a different `--binding` or remove the existing entry first. + ### Cloudflare Worker Library #### Generating a new Cloudflare Worker Library diff --git a/packages/nx-cloudflare/generators.json b/packages/nx-cloudflare/generators.json index af7e25bad..e1c767176 100644 --- a/packages/nx-cloudflare/generators.json +++ b/packages/nx-cloudflare/generators.json @@ -16,6 +16,11 @@ "schema": "./src/generators/library/schema.json", "description": "library generator", "aliases": ["lib"] + }, + "binding": { + "factory": "./src/generators/binding/generator", + "schema": "./src/generators/binding/schema.json", + "description": "Add a binding (KV, R2, D1, Durable Object, Queue, Workflow, Service) to a Worker — edits wrangler.jsonc, stubs code + migrations, emits a test, and refreshes wrangler types" } } } diff --git a/packages/nx-cloudflare/package.json b/packages/nx-cloudflare/package.json index eb217b78b..e19df69a2 100644 --- a/packages/nx-cloudflare/package.json +++ b/packages/nx-cloudflare/package.json @@ -12,7 +12,8 @@ "dependencies": { "tslib": "^2.3.0", "@nx/devkit": "^23.0.0", - "@nx/js": "^23.0.0" + "@nx/js": "^23.0.0", + "jsonc-parser": "^3.3.0" }, "peerDependencies": { "wrangler": "^4.0.0" diff --git a/packages/nx-cloudflare/src/generators/binding/generator.spec.ts b/packages/nx-cloudflare/src/generators/binding/generator.spec.ts new file mode 100644 index 000000000..a5512b539 --- /dev/null +++ b/packages/nx-cloudflare/src/generators/binding/generator.spec.ts @@ -0,0 +1,598 @@ +import { describe, it, expect, beforeEach, mock, type Mock } from 'bun:test'; +import { Tree, joinPathFragments, updateJson } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { bindingGenerator } from './generator'; +import { runWranglerTypes } from '../../utils/run-wrangler-types'; +import { provisionResource } from '../../utils/provision'; + +// Mock the side-effecting modules so unit tests never shell out. Mirrors the +// create-cloudflare spec's run-c3 mock pattern. The callback returned by the +// generator calls these post-flush on the real fs — in tests we assert the +// callback was returned and invoke it to verify the mock was called. +mock.module('../../utils/run-wrangler-types', () => ({ + runWranglerTypes: mock(() => {}), +})); +mock.module('../../utils/provision', () => ({ + provisionResource: mock(() => {}), + PROVISION_SENTINEL: '__PENDING_CREATE__', +})); + +const runWranglerTypesMock = runWranglerTypes as unknown as Mock< + typeof runWranglerTypes +>; +const provisionResourceMock = provisionResource as unknown as Mock< + typeof provisionResource +>; + +const WRANGLER_JSONC = `{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "my-worker", + "main": "src/index.ts" +} +`; + +const INDEX_TS = `export default { + async fetch(request: Request, env: Env): Promise { + return new Response('Hello!'); + }, +}; +`; + +function seedWorker( + tree: Tree, + projectRoot: string, + projectName: string +): void { + updateJson(tree, 'package.json', (pkg) => ({ + ...pkg, + workspaces: [...(pkg.workspaces ?? []), `${projectRoot}/package.json`], + })); + tree.write( + joinPathFragments(projectRoot, 'package.json'), + JSON.stringify({ name: projectName }) + ); + tree.write(joinPathFragments(projectRoot, 'wrangler.jsonc'), WRANGLER_JSONC); + tree.write(joinPathFragments(projectRoot, 'src/index.ts'), INDEX_TS); +} + +describe('binding generator', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + runWranglerTypesMock.mockClear(); + provisionResourceMock.mockClear(); + }); + + describe('config editing', () => { + it('appends a KV namespace binding to wrangler.jsonc', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'kv', + binding: 'MY_KV', + id: 'abc123', + skipTypegen: true, + }); + + const config = tree.read('apps/w/wrangler.jsonc', 'utf-8')!; + expect(config).toContain('"kv_namespaces"'); + expect(config).toContain('"binding": "MY_KV"'); + expect(config).toContain('"id": "abc123"'); + }); + + it('appends an R2 bucket binding', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'r2', + binding: 'MY_BUCKET', + bucketName: 'my-bucket', + skipTypegen: true, + }); + + const config = tree.read('apps/w/wrangler.jsonc', 'utf-8')!; + expect(config).toContain('"r2_buckets"'); + expect(config).toContain('"bucket_name": "my-bucket"'); + }); + + it('appends a D1 database binding', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'd1', + binding: 'MY_DB', + databaseName: 'my-db', + id: 'd1-id-123', + skipTypegen: true, + }); + + const config = tree.read('apps/w/wrangler.jsonc', 'utf-8')!; + expect(config).toContain('"d1_databases"'); + expect(config).toContain('"database_name": "my-db"'); + expect(config).toContain('"database_id": "d1-id-123"'); + }); + + it('appends a Service binding', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'service', + binding: 'AUTH_SERVICE', + name: 'auth-worker', + skipTypegen: true, + }); + + const config = tree.read('apps/w/wrangler.jsonc', 'utf-8')!; + expect(config).toContain('"services"'); + expect(config).toContain('"service": "auth-worker"'); + }); + + it('writes a placeholder when no id/bucketName/databaseName is provided', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'kv', + binding: 'MY_KV', + skipTypegen: true, + }); + + const config = tree.read('apps/w/wrangler.jsonc', 'utf-8')!; + expect(config).toContain('PLACEHOLDER_FILL_ME_IN'); + }); + + it('preserves JSONC comments in wrangler.jsonc', async () => { + seedWorker(tree, 'apps/w', 'w'); + tree.write( + 'apps/w/wrangler.jsonc', + `// Top comment +{ + // Inner comment + "name": "my-worker", + "main": "src/index.ts" +} +` + ); + await bindingGenerator(tree, { + project: 'w', + type: 'kv', + binding: 'MY_KV', + id: 'abc', + skipTypegen: true, + }); + + const config = tree.read('apps/w/wrangler.jsonc', 'utf-8')!; + expect(config).toContain('// Top comment'); + expect(config).toContain('// Inner comment'); + expect(config).toContain('"kv_namespaces"'); + }); + }); + + describe('Durable Objects', () => { + it('writes a DO binding + migration + class stub + re-export', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'do', + binding: 'MY_DO', + name: 'MyDurableObject', + skipTypegen: true, + }); + + const config = tree.read('apps/w/wrangler.jsonc', 'utf-8')!; + expect(config).toContain('"durable_objects"'); + expect(config).toContain('"class_name": "MyDurableObject"'); + expect(config).toContain('"migrations"'); + expect(config).toContain('"tag": "v1"'); + expect(config).toContain('"new_sqlite_classes"'); + + expect(tree.exists('apps/w/src/my-durable-object.ts')).toBe(true); + const stub = tree.read('apps/w/src/my-durable-object.ts', 'utf-8')!; + // The base class is exported from cloudflare:workers (not a global), so + // the stub must import it or it won't type-check. + expect(stub).toContain( + "import { DurableObject } from 'cloudflare:workers'" + ); + expect(stub).toContain( + 'export class MyDurableObject extends DurableObject' + ); + + const index = tree.read('apps/w/src/index.ts', 'utf-8')!; + expect(index).toContain("export * from './my-durable-object'"); + }); + + it('increments the migration tag for subsequent DO bindings', async () => { + seedWorker(tree, 'apps/w', 'w'); + tree.write( + 'apps/w/wrangler.jsonc', + `{ + "name": "w", + "main": "src/index.ts", + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ExistingDO"] }] +}` + ); + await bindingGenerator(tree, { + project: 'w', + type: 'do', + binding: 'SECOND_DO', + name: 'SecondDurableObject', + skipTypegen: true, + }); + + const config = tree.read('apps/w/wrangler.jsonc', 'utf-8')!; + expect(config).toContain('"tag": "v2"'); + }); + }); + + describe('Workflows', () => { + it('writes a Workflow binding + class stub + re-export', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'workflow', + binding: 'MY_WORKFLOW', + name: 'MyWorkflow', + skipTypegen: true, + }); + + const config = tree.read('apps/w/wrangler.jsonc', 'utf-8')!; + expect(config).toContain('"workflows"'); + expect(config).toContain('"class_name": "MyWorkflow"'); + + expect(tree.exists('apps/w/src/my-workflow.ts')).toBe(true); + const stub = tree.read('apps/w/src/my-workflow.ts', 'utf-8')!; + expect(stub).toContain('WorkflowEntrypoint'); + expect(stub).toContain('export class MyWorkflow'); + + const index = tree.read('apps/w/src/index.ts', 'utf-8')!; + expect(index).toContain("export * from './my-workflow'"); + }); + }); + + describe('Queues', () => { + it('writes producer + consumer config and injects a queue handler', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'queue', + binding: 'MY_QUEUE', + name: 'my-queue', + skipTypegen: true, + }); + + const config = tree.read('apps/w/wrangler.jsonc', 'utf-8')!; + expect(config).toContain('"queues"'); + expect(config).toContain('"producers"'); + expect(config).toContain('"queue": "my-queue"'); + expect(config).toContain('"consumers"'); + + const index = tree.read('apps/w/src/index.ts', 'utf-8')!; + expect(index).toContain('async queue('); + expect(index).toContain('MessageBatch'); + expect(index).toContain('ctx: ExecutionContext'); + }); + + it('preserves the existing fetch handler when adding queue', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'queue', + binding: 'MY_QUEUE', + name: 'my-queue', + skipTypegen: true, + }); + + const updated = tree.read('apps/w/src/index.ts', 'utf-8')!; + expect(updated).toContain('async fetch'); + expect(updated).toContain('async queue'); + }); + + it('inserts before the real closing brace when a string literal contains "}"', async () => { + seedWorker(tree, 'apps/w', 'w'); + // A `}` inside the returned string must not be mistaken for the end of + // the default-export object by the brace matcher. + tree.write( + 'apps/w/src/index.ts', + `export default { + async fetch(request: Request, env: Env): Promise { + return new Response('}{ tricky }'); + }, +}; +` + ); + await bindingGenerator(tree, { + project: 'w', + type: 'queue', + binding: 'MY_QUEUE', + name: 'my-queue', + skipTypegen: true, + }); + + const index = tree.read('apps/w/src/index.ts', 'utf-8')!; + expect(index).toContain("return new Response('}{ tricky }');"); + expect(index).toContain('async queue('); + // The handler must land inside the object, before its final `}`. + expect(index.indexOf('async queue(')).toBeLessThan( + index.lastIndexOf('}') + ); + }); + + it('does not duplicate the queue handler on a second queue binding', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'queue', + binding: 'FIRST_QUEUE', + name: 'first-queue', + skipTypegen: true, + }); + await bindingGenerator(tree, { + project: 'w', + type: 'queue', + binding: 'SECOND_QUEUE', + name: 'second-queue', + skipTypegen: true, + }); + + const index = tree.read('apps/w/src/index.ts', 'utf-8')!; + const queueCount = (index.match(/async\s+queue\s*\(/g) ?? []).length; + expect(queueCount).toBe(1); + }); + }); + + describe('test stubs', () => { + it('emits a spec for DO bindings', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'do', + binding: 'MY_DO', + name: 'MyDurableObject', + skipTypegen: true, + }); + + expect(tree.exists('apps/w/src/my-durable-object.spec.ts')).toBe(true); + const spec = tree.read('apps/w/src/my-durable-object.spec.ts', 'utf-8')!; + expect(spec).toContain('import { MyDurableObject }'); + expect(spec).toContain('env.MY_DO'); + }); + + it('skips tests when --skipTests', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'do', + binding: 'MY_DO', + name: 'MyDurableObject', + skipTests: true, + skipTypegen: true, + }); + + expect(tree.exists('apps/w/src/my-durable-object.spec.ts')).toBe(false); + }); + + it('emits no spec for config-only types (KV/R2/D1/Service)', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'kv', + binding: 'MY_KV', + id: 'abc', + skipTypegen: true, + }); + + // No spec file should be created for a config-only binding + const files = tree.listChanges().map((c) => c.path); + expect(files.some((f) => f.endsWith('.spec.ts'))).toBe(false); + }); + }); + + describe('idempotency', () => { + it('errors on duplicate KV binding name', async () => { + seedWorker(tree, 'apps/w', 'w'); + tree.write( + 'apps/w/wrangler.jsonc', + `{ + "name": "w", + "main": "src/index.ts", + "kv_namespaces": [{ "binding": "MY_KV", "id": "existing" }] +}` + ); + + await expect( + bindingGenerator(tree, { + project: 'w', + type: 'kv', + binding: 'MY_KV', + id: 'new', + skipTypegen: true, + }) + ).rejects.toThrow('already exists'); + }); + + it('errors on duplicate DO binding name', async () => { + seedWorker(tree, 'apps/w', 'w'); + tree.write( + 'apps/w/wrangler.jsonc', + `{ + "name": "w", + "main": "src/index.ts", + "durable_objects": { + "bindings": [{ "name": "MY_DO", "class_name": "ExistingDO" }] + } +}` + ); + + await expect( + bindingGenerator(tree, { + project: 'w', + type: 'do', + binding: 'MY_DO', + name: 'NewDO', + skipTypegen: true, + }) + ).rejects.toThrow('already exists'); + }); + + it('errors when the DO class_name is already defined by a migration', async () => { + seedWorker(tree, 'apps/w', 'w'); + tree.write( + 'apps/w/wrangler.jsonc', + `{ + "name": "w", + "main": "src/index.ts", + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyDurableObject"] }] +}` + ); + + await expect( + bindingGenerator(tree, { + project: 'w', + type: 'do', + binding: 'ANOTHER_DO', + name: 'MyDurableObject', + skipTypegen: true, + }) + ).rejects.toThrow('already defined'); + }); + }); + + describe('validation', () => { + it('errors when --create is used with a non-provisionable type', async () => { + seedWorker(tree, 'apps/w', 'w'); + await expect( + bindingGenerator(tree, { + project: 'w', + type: 'do', + binding: 'MY_DO', + name: 'MyDO', + create: true, + skipTypegen: true, + }) + ).rejects.toThrow('`--create` is not supported'); + }); + + it('errors when --name is missing for DO', async () => { + seedWorker(tree, 'apps/w', 'w'); + await expect( + bindingGenerator(tree, { + project: 'w', + type: 'do', + binding: 'MY_DO', + skipTypegen: true, + } as never) + ).rejects.toThrow('`--name` is required'); + }); + + it('errors when --bucketName is missing for R2 without --create', async () => { + seedWorker(tree, 'apps/w', 'w'); + await expect( + bindingGenerator(tree, { + project: 'w', + type: 'r2', + binding: 'MY_BUCKET', + skipTypegen: true, + } as never) + ).rejects.toThrow('`--bucketName` is required'); + }); + + it('errors when the project does not exist', async () => { + await expect( + bindingGenerator(tree, { + project: 'no-such-project', + type: 'kv', + binding: 'MY_KV', + skipTypegen: true, + }) + ).rejects.toThrow('not found'); + }); + + it('errors when wrangler.toml is used (jsonc/json only)', async () => { + seedWorker(tree, 'apps/w', 'w'); + tree.delete('apps/w/wrangler.jsonc'); + tree.write('apps/w/wrangler.toml', 'name = "w"\nmain = "src/index.ts"\n'); + + await expect( + bindingGenerator(tree, { + project: 'w', + type: 'kv', + binding: 'MY_KV', + skipTypegen: true, + }) + ).rejects.toThrow('wrangler.jsonc'); + }); + }); + + describe('callback (post-flush)', () => { + it('auto-runs wrangler types by default', async () => { + seedWorker(tree, 'apps/w', 'w'); + const cb = await bindingGenerator(tree, { + project: 'w', + type: 'kv', + binding: 'MY_KV', + id: 'abc', + }); + await cb(); + expect(runWranglerTypesMock).toHaveBeenCalledTimes(1); + }); + + it('skips typegen when --skipTypegen', async () => { + seedWorker(tree, 'apps/w', 'w'); + const cb = await bindingGenerator(tree, { + project: 'w', + type: 'kv', + binding: 'MY_KV', + id: 'abc', + skipTypegen: true, + }); + await cb(); + expect(runWranglerTypesMock).not.toHaveBeenCalled(); + }); + + it('provisions the resource when --create is set', async () => { + seedWorker(tree, 'apps/w', 'w'); + const cb = await bindingGenerator(tree, { + project: 'w', + type: 'kv', + binding: 'MY_KV', + create: true, + skipTypegen: true, + }); + await cb(); + expect(provisionResourceMock).toHaveBeenCalledTimes(1); + }); + + it('writes the bucket name directly for R2 --create (no sentinel)', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'r2', + binding: 'MY_BUCKET', + create: true, + skipTypegen: true, + }); + + const config = tree.read('apps/w/wrangler.jsonc', 'utf-8')!; + expect(config).not.toContain('__PENDING_CREATE__'); + expect(config).toContain('"bucket_name": "my-bucket"'); + }); + + it('writes the database name directly for D1 --create (sentinel only in database_id)', async () => { + seedWorker(tree, 'apps/w', 'w'); + await bindingGenerator(tree, { + project: 'w', + type: 'd1', + binding: 'MY_DB', + create: true, + skipTypegen: true, + }); + + const config = tree.read('apps/w/wrangler.jsonc', 'utf-8')!; + expect(config).toContain('"database_name": "my-db"'); + expect(config).toContain('__PENDING_CREATE__'); + // The sentinel should appear only once (in database_id), not in database_name + const sentinelCount = (config.match(/__PENDING_CREATE__/g) ?? []).length; + expect(sentinelCount).toBe(1); + }); + }); +}); diff --git a/packages/nx-cloudflare/src/generators/binding/generator.ts b/packages/nx-cloudflare/src/generators/binding/generator.ts new file mode 100644 index 000000000..5af81d016 --- /dev/null +++ b/packages/nx-cloudflare/src/generators/binding/generator.ts @@ -0,0 +1,688 @@ +import { + formatFiles, + GeneratorCallback, + getProjects, + joinPathFragments, + logger, + names, + Tree, +} from '@nx/devkit'; +import { applyEdits, modify, parse, type JSONPath } from 'jsonc-parser'; +import type { NormalizedSchema, Schema } from './schema'; +import { + appendToArray, + assertJsoncConfig, + findInArray, + findWranglerConfig, + getMigrationCount, + migrationDefinesClass, + readWranglerConfig, +} from '../../utils/wrangler-config'; +import { runWranglerTypes } from '../../utils/run-wrangler-types'; +import { + PROVISION_SENTINEL, + provisionResource, + type ProvisionableType, +} from '../../utils/provision'; + +const FORMATTING = { + tabSize: 2, + insertSpaces: true, + insertFinalNewline: true, +} as const; + +const PROVISIONABLE_TYPES: ReadonlySet = new Set([ + 'kv', + 'r2', + 'd1', + 'queue', +]); + +export async function bindingGenerator( + tree: Tree, + schema: Schema +): Promise { + const options = normalizeOptions(tree, schema); + const config = readWranglerConfig(tree, options.configPath); + + assertNoDuplicateBinding(config, options); + + writeBindingConfig(tree, options); + writeCodeStubs(tree, options); + + if (!options.skipTests) { + writeTestStub(tree, options); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return async () => { + if (options.create) { + // Throws on failure (fail loud): --create was explicitly requested, so a + // failed provision must surface rather than leave a green run over a + // config that references a resource which was never created. + provisionResource({ + type: options.type as ProvisionableType, + binding: options.binding, + name: provisionName(options), + projectRoot: options.projectRoot, + configPath: options.configPath, + }); + } + if (!options.skipTypegen) { + try { + runWranglerTypes(options.projectRoot); + } catch (e) { + logger.warn( + `binding: \`wrangler types\` failed — run \`nx typegen ${options.project}\` manually. ` + + `Reason: ${e instanceof Error ? e.message : String(e)}` + ); + } + } + }; +} + +function normalizeOptions(tree: Tree, schema: Schema): NormalizedSchema { + const projects = getProjects(tree); + const project = projects.get(schema.project); + if (!project) { + throw new Error( + `Project "${schema.project}" not found. ` + + `Available projects: ${[...projects.keys()].join(', ')}` + ); + } + + const configPath = findWranglerConfig(tree, project.root); + if (!configPath) { + throw new Error( + `No wrangler.{jsonc,json,toml} found in ${project.root}. ` + + `The binding generator targets an existing Worker project. ` + + `Run \`nx g @naxodev/nx-cloudflare:application\` first.` + ); + } + assertJsoncConfig(configPath); + + validateTypeSpecificOptions(schema); + + const className = + schema.type === 'do' || schema.type === 'workflow' ? schema.name ?? '' : ''; + const fileName = + className.length > 0 + ? names(className).fileName + : schema.type === 'queue' + ? names(schema.name ?? '').fileName + : ''; + + return { + ...schema, + projectRoot: project.root, + configPath, + className, + fileName, + queueName: schema.type === 'queue' ? schema.name ?? '' : '', + serviceName: schema.type === 'service' ? schema.name ?? '' : '', + }; +} + +function validateTypeSpecificOptions(schema: Schema): void { + if ((schema.type === 'do' || schema.type === 'workflow') && !schema.name) { + throw new Error( + `\`--name\` is required for type "${schema.type}" ` + + `(the exported class name, PascalCase).` + ); + } + if (schema.type === 'queue' && !schema.name) { + throw new Error( + `\`--name\` is required for type "queue" (the queue name).` + ); + } + if (schema.type === 'service' && !schema.name) { + throw new Error( + `\`--name\` is required for type "service" (the target service name).` + ); + } + if (schema.type === 'r2' && !schema.create && !schema.bucketName) { + throw new Error( + `\`--bucketName\` is required for type "r2" (or pass --create to provision one).` + ); + } + if (schema.type === 'd1' && !schema.create && !schema.databaseName) { + throw new Error( + `\`--databaseName\` is required for type "d1" (or pass --create to provision one).` + ); + } + if ( + schema.create && + !PROVISIONABLE_TYPES.has(schema.type as ProvisionableType) + ) { + throw new Error( + `\`--create\` is not supported for type "${schema.type}". ` + + `Durable Objects, Workflows, and Service bindings are not provisioned resources — ` + + `they are code in your Worker (do/workflow) or a reference to another Worker (service). ` + + `Drop --create and re-run.` + ); + } +} + +function assertNoDuplicateBinding( + config: Record, + options: NormalizedSchema +): void { + const { type, binding, name } = options; + const checks: { path: JSONPath; field: string; value: string }[] = []; + + switch (type) { + case 'kv': + checks.push({ + path: ['kv_namespaces'], + field: 'binding', + value: binding, + }); + break; + case 'r2': + checks.push({ path: ['r2_buckets'], field: 'binding', value: binding }); + break; + case 'd1': + checks.push({ path: ['d1_databases'], field: 'binding', value: binding }); + break; + case 'workflow': + checks.push({ path: ['workflows'], field: 'binding', value: binding }); + break; + case 'service': + checks.push({ path: ['services'], field: 'binding', value: binding }); + break; + case 'do': { + const existing = config['durable_objects']; + const bindings = + existing && + typeof existing === 'object' && + Array.isArray((existing as Record)['bindings']) + ? ((existing as Record)['bindings'] as unknown[]) + : []; + const nameClash = bindings.some( + (b) => + typeof b === 'object' && + b !== null && + (b as Record)['name'] === binding + ); + if (nameClash) { + throw new Error( + `A Durable Object binding named "${binding}" already exists in wrangler.jsonc. ` + + `Use a different --binding or remove the existing entry.` + ); + } + // A class can only be introduced by a single migration; reusing a + // class_name that already exists (in a binding or a prior migration) + // would emit a duplicate `new_sqlite_classes` entry that wrangler rejects. + const classClash = + bindings.some( + (b) => + typeof b === 'object' && + b !== null && + (b as Record)['class_name'] === options.className + ) || migrationDefinesClass(config, options.className); + if (classClash) { + throw new Error( + `A Durable Object class "${options.className}" is already defined in wrangler.jsonc ` + + `(as a binding or in a migration). Use a different --name.` + ); + } + return; + } + case 'queue': { + const existing = config['queues']; + if (existing && typeof existing === 'object') { + const q = existing as Record; + if (Array.isArray(q['producers'])) { + const clash = (q['producers'] as unknown[]).some( + (b) => + typeof b === 'object' && + b !== null && + (b as Record)['binding'] === binding + ); + if (clash) { + throw new Error( + `A queue producer with binding "${binding}" already exists in wrangler.jsonc.` + ); + } + } + if (Array.isArray(q['consumers'])) { + const clash = (q['consumers'] as unknown[]).some( + (b) => + typeof b === 'object' && + b !== null && + (b as Record)['queue'] === name + ); + if (clash) { + throw new Error( + `A queue consumer for queue "${name}" already exists in wrangler.jsonc.` + ); + } + } + } + return; + } + } + + for (const { path, field, value } of checks) { + if (findInArray(config, path, field, value)) { + throw new Error( + `Binding "${value}" already exists in wrangler.jsonc ` + + `(${field} on ${path[0]}). Use a different --binding or remove the existing entry.` + ); + } + } +} + +function writeBindingConfig(tree: Tree, options: NormalizedSchema): void { + const { type, binding, configPath } = options; + + switch (type) { + case 'kv': + appendToArray(tree, configPath, ['kv_namespaces'], { + binding, + id: options.id ?? placeholderOrSentinel(options), + }); + break; + case 'r2': + appendToArray(tree, configPath, ['r2_buckets'], { + binding, + bucket_name: options.bucketName ?? nameOrPlaceholder(options), + }); + break; + case 'd1': + appendToArray(tree, configPath, ['d1_databases'], { + binding, + database_name: options.databaseName ?? nameOrPlaceholder(options), + database_id: options.id ?? placeholderOrSentinel(options), + }); + break; + case 'do': + appendDurableObjectBinding(tree, options); + appendDurableObjectMigration(tree, options); + break; + case 'queue': + appendQueueBinding(tree, options); + break; + case 'workflow': + appendToArray(tree, configPath, ['workflows'], { + binding, + name: kebabCase(options.className), + class_name: options.className, + }); + break; + case 'service': + appendToArray(tree, configPath, ['services'], { + binding, + service: options.serviceName, + }); + break; + } +} + +function placeholderOrSentinel(options: NormalizedSchema): string { + return options.create ? PROVISION_SENTINEL : 'PLACEHOLDER_FILL_ME_IN'; +} + +// For name-like fields (bucket_name, database_name) the value is known before +// provisioning — it is the binding name in lowercase-hyphen form, or a +// user-provided name. The sentinel is only for id fields that are captured +// from `wrangler create` stdout. +function nameOrPlaceholder(options: NormalizedSchema): string { + if (options.create) { + return provisionName(options); + } + return 'PLACEHOLDER_FILL_ME_IN'; +} + +// `durable_objects.bindings` is a nested array (object → array), so it needs +// `modify` with the full path rather than the top-level `appendToArray`. When +// the array doesn't exist yet, `modify` at index 0 with `isArrayInsertion` +// creates both the `durable_objects` object and its `bindings` array. +function appendDurableObjectBinding( + tree: Tree, + options: NormalizedSchema +): void { + const text = tree.read(options.configPath, 'utf-8'); + if (!text) { + throw new Error(`wrangler config not found: ${options.configPath}`); + } + const config = parse(text) as Record; + const existing = config['durable_objects']; + const bindings = + existing && + typeof existing === 'object' && + Array.isArray((existing as Record)['bindings']) + ? ((existing as Record)['bindings'] as unknown[]) + : null; + + const index = bindings === null ? 0 : bindings.length; + const entry = { name: options.binding, class_name: options.className }; + const edits = modify(text, ['durable_objects', 'bindings', index], entry, { + isArrayInsertion: true, + formattingOptions: FORMATTING, + }); + tree.write(options.configPath, applyEdits(text, edits)); +} + +function appendDurableObjectMigration( + tree: Tree, + options: NormalizedSchema +): void { + const config = readWranglerConfig(tree, options.configPath); + const count = getMigrationCount(config); + appendToArray(tree, options.configPath, ['migrations'], { + tag: `v${count + 1}`, + new_sqlite_classes: [options.className], + }); +} + +// `queues` is an object with `producers` and `consumers` arrays. Both are +// appended in a single pass: `modify` creates the `queues` object and the +// nested arrays when absent. +function appendQueueBinding(tree: Tree, options: NormalizedSchema): void { + const text = tree.read(options.configPath, 'utf-8'); + if (!text) { + throw new Error(`wrangler config not found: ${options.configPath}`); + } + const config = parse(text) as Record; + const existing = config['queues']; + const producers = + existing && + typeof existing === 'object' && + Array.isArray((existing as Record)['producers']) + ? ((existing as Record)['producers'] as unknown[]) + : null; + const consumers = + existing && + typeof existing === 'object' && + Array.isArray((existing as Record)['consumers']) + ? ((existing as Record)['consumers'] as unknown[]) + : null; + + const producerIndex = producers === null ? 0 : producers.length; + const consumerIndex = consumers === null ? 0 : consumers.length; + + let edits = modify( + text, + ['queues', 'producers', producerIndex], + { binding: options.binding, queue: options.queueName }, + { isArrayInsertion: true, formattingOptions: FORMATTING } + ); + const updated = applyEdits(text, edits); + edits = modify( + updated, + ['queues', 'consumers', consumerIndex], + { queue: options.queueName }, + { isArrayInsertion: true, formattingOptions: FORMATTING } + ); + tree.write(options.configPath, applyEdits(updated, edits)); +} + +function writeCodeStubs(tree: Tree, options: NormalizedSchema): void { + switch (options.type) { + case 'do': + writeDurableObjectStub(tree, options); + break; + case 'workflow': + writeWorkflowStub(tree, options); + break; + case 'queue': + writeQueueHandler(tree, options); + break; + default: + return; + } +} + +function writeDurableObjectStub(tree: Tree, options: NormalizedSchema): void { + const filePath = joinPathFragments( + options.projectRoot, + 'src', + `${options.fileName}.ts` + ); + tree.write( + filePath, + `import { DurableObject } from 'cloudflare:workers'; + +export class ${options.className} extends DurableObject { + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + } +} +` + ); + appendReExport(tree, options, `./${options.fileName}`); +} + +function writeWorkflowStub(tree: Tree, options: NormalizedSchema): void { + const filePath = joinPathFragments( + options.projectRoot, + 'src', + `${options.fileName}.ts` + ); + tree.write( + filePath, + `import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from 'cloudflare:workflows'; + +type Params = Record; + +export class ${options.className} extends WorkflowEntrypoint { + async run(event: WorkflowEvent, step: WorkflowStep) { + await step.do('first step', async () => { + return { started: true }; + }); + } +} +` + ); + appendReExport(tree, options, `./${options.fileName}`); +} + +// Insert the `queue(batch, env)` method into the existing default export +// object in `src/index.ts`. C3-scaffolded workers ship `export default { async +// fetch(...) {...} }`, so we find the matching closing brace by bracket-counting +// and insert before it. If no default export object exists, warn. If a queue +// handler already exists (from a prior binding), skip — a Worker has one +// queue() handler that receives from all queue bindings. +function writeQueueHandler(tree: Tree, options: NormalizedSchema): void { + const entryPath = joinPathFragments(options.projectRoot, 'src/index.ts'); + const existing = tree.exists(entryPath) + ? tree.read(entryPath, 'utf-8') ?? '' + : ''; + + if (/async\s+queue\s*\(/.test(existing)) { + logger.info( + `binding: a \`queue()\` handler already exists in ${entryPath} — leaving it untouched. ` + + `A Worker has a single queue() handler that receives batches from all queue ` + + `consumers; the new consumer is wired in wrangler.jsonc and will be delivered to it.` + ); + return; + } + + const method = ` async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext): Promise { + for (const message of batch.messages) { + // TODO: process message + message.ack(); + } + },`; + + if (existing.length === 0) { + tree.write( + entryPath, + `export default { +${method} +}; +` + ); + return; + } + + const match = existing.match(/export\s+default\s*\{/); + if (!match) { + logger.warn( + `binding: ${entryPath} has no \`export default { ... }\` object. ` + + `Add this method to your entrypoint manually:\n${method}\n` + ); + return; + } + + const matchStart = match.index ?? 0; + const openBraceIndex = existing.indexOf( + '{', + matchStart + match[0].length - 1 + ); + const closeBraceIndex = findMatchingBrace(existing, openBraceIndex); + if (closeBraceIndex === -1) { + logger.warn( + `binding: could not find the closing \`}\` of the default export in ${entryPath}. ` + + `Add this method manually:\n${method}\n` + ); + return; + } + + // Insert the method before the closing brace. Preserve whatever whitespace + // precedes the `}` — if the object is `export default {\n}` we insert on a + // new line; if it's `export default { ... }` we add a newline before the `}`. + const before = existing.slice(0, closeBraceIndex); + const after = existing.slice(closeBraceIndex); + const needsNewline = before.length > 0 && !before.endsWith('\n'); + const insertion = `${needsNewline ? '\n' : ''}${method}\n`; + tree.write(entryPath, `${before}${insertion}${after}`); +} + +// String/comment-aware bracket matcher. Skips braces inside string literals, +// template literals, line comments, and block comments so a `}` in a string +// like `"}}}";` doesn't short-circuit the depth counter and corrupt the file. +function findMatchingBrace(text: string, openIndex: number): number { + let depth = 0; + let i = openIndex; + while (i < text.length) { + const ch = text[i]; + const next = text[i + 1]; + + if (ch === '/' && next === '/') { + i = text.indexOf('\n', i); + if (i === -1) return -1; + continue; + } + if (ch === '/' && next === '*') { + const end = text.indexOf('*/', i + 2); + if (end === -1) return -1; + i = end + 2; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + i = skipString(text, i, ch); + if (i === -1) return -1; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + i++; + } + return -1; +} + +// Advance past a string/template literal starting at `start` (the opening +// quote). Returns the index after the closing quote, or -1 if unterminated. +function skipString(text: string, start: number, quote: string): number { + let i = start + 1; + while (i < text.length) { + if (text[i] === '\\') { + i += 2; + continue; + } + if (quote === '`' && text[i] === '$' && text[i + 1] === '{') { + // Template literal interpolation — skip to the matching `}` + let depth = 1; + i += 2; + while (i < text.length && depth > 0) { + if (text[i] === '{') depth++; + else if (text[i] === '}') depth--; + i++; + } + continue; + } + if (text[i] === quote) return i + 1; + i++; + } + return -1; +} + +function appendReExport( + tree: Tree, + options: NormalizedSchema, + relativePath: string +): void { + const entryPath = joinPathFragments(options.projectRoot, 'src/index.ts'); + const existing = tree.exists(entryPath) + ? tree.read(entryPath, 'utf-8') ?? '' + : ''; + const line = `export * from '${relativePath}';\n`; + if (existing.includes(`export * from '${relativePath}'`)) { + return; + } + const prefix = existing.length === 0 || existing.endsWith('\n') ? '' : '\n'; + tree.write(entryPath, `${existing}${prefix}${line}`); +} + +function writeTestStub(tree: Tree, options: NormalizedSchema): void { + if ( + options.type !== 'do' && + options.type !== 'workflow' && + options.type !== 'queue' + ) { + return; + } + + const srcRoot = joinPathFragments(options.projectRoot, 'src'); + const specPath = joinPathFragments(srcRoot, `${options.fileName}.spec.ts`); + + const importLine = + options.type === 'queue' + ? '' + : `import { ${options.className} } from './${options.fileName}';\n`; + + const content = `import { describe, it, expect } from 'vitest'; +import { env } from 'cloudflare:test'; +${importLine} +describe('${options.binding}', () => { + it('is bound in the test env', () => { + expect(env.${options.binding}).toBeDefined(); + }); +}); +`; + tree.write(specPath, content); +} + +function provisionName(options: NormalizedSchema): string { + switch (options.type) { + case 'r2': + return ( + options.bucketName ?? options.binding.toLowerCase().replace(/_/g, '-') + ); + case 'd1': + return ( + options.databaseName ?? options.binding.toLowerCase().replace(/_/g, '-') + ); + case 'queue': + return options.queueName; + default: + return options.binding; + } +} + +function kebabCase(s: string): string { + return s + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') + .toLowerCase(); +} + +export default bindingGenerator; diff --git a/packages/nx-cloudflare/src/generators/binding/schema.d.ts b/packages/nx-cloudflare/src/generators/binding/schema.d.ts new file mode 100644 index 000000000..584e2781b --- /dev/null +++ b/packages/nx-cloudflare/src/generators/binding/schema.d.ts @@ -0,0 +1,31 @@ +export type BindingType = + | 'kv' + | 'r2' + | 'd1' + | 'do' + | 'queue' + | 'workflow' + | 'service'; + +export interface Schema { + project: string; + type: BindingType; + binding: string; + name?: string; + id?: string; + databaseName?: string; + bucketName?: string; + create?: boolean; + skipTests?: boolean; + skipFormat?: boolean; + skipTypegen?: boolean; +} + +export interface NormalizedSchema extends Schema { + projectRoot: string; + className: string; + fileName: string; + queueName: string; + serviceName: string; + configPath: string; +} diff --git a/packages/nx-cloudflare/src/generators/binding/schema.json b/packages/nx-cloudflare/src/generators/binding/schema.json new file mode 100644 index 000000000..d481915bc --- /dev/null +++ b/packages/nx-cloudflare/src/generators/binding/schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxCloudflareBinding", + "title": "Add a Cloudflare binding to a Worker", + "description": "Wires a binding (KV, R2, D1, Durable Object, Queue, Workflow, or Service/RPC) into a Worker's wrangler.jsonc, stubs the required code (DO/Workflow classes, queue consumer) and migrations, emits a matching test, and refreshes `wrangler types`.", + "type": "object", + "properties": { + "project": { + "description": "The target Worker project to add the binding to.", + "type": "string", + "$default": { + "$source": "projectName", + "index": 0 + }, + "x-prompt": "Which project would you like to add the binding to?", + "x-priority": "important" + }, + "type": { + "description": "The kind of binding to add.", + "type": "string", + "enum": ["kv", "r2", "d1", "do", "queue", "workflow", "service"], + "x-prompt": "What type of binding do you want to add? (kv, r2, d1, do, queue, workflow, service)", + "x-priority": "important" + }, + "binding": { + "description": "The Env key name (SCREAMING_SNAKE_CASE) the Worker uses to access the binding, e.g. MY_KV.", + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "x-prompt": "What name should the binding have in `env`? (SCREAMING_SNAKE_CASE, e.g. MY_KV)", + "x-priority": "important" + }, + "name": { + "description": "Required for `do`/`workflow` (the exported class name, PascalCase), `queue` (the queue name), and `service` (the target service name). Not used for `kv`/`r2`/`d1`.", + "type": "string", + "x-prompt": "What is the class name (do/workflow), queue name (queue), or service name (service)?", + "x-priority": "important" + }, + "id": { + "description": "Existing KV namespace id or D1 database id. Skipped if `--create` is set (the provisioned id is captured automatically).", + "type": "string" + }, + "databaseName": { + "description": "D1 database name. Required when `--type=d1` and `--create` is not set.", + "type": "string" + }, + "bucketName": { + "description": "R2 bucket name. Required when `--type=r2` and `--create` is not set.", + "type": "string" + }, + "create": { + "description": "Provision the remote resource via the Wrangler CLI (KV/R2/D1/Queue only). Fills the id/name automatically. On provision failure, leaves a placeholder in the config for manual fill-in.", + "type": "boolean", + "default": false + }, + "skipTests": { + "description": "Skip generating a Vitest spec for the binding stub.", + "type": "boolean", + "default": false, + "x-priority": "internal" + }, + "skipFormat": { + "description": "Skip formatting files.", + "type": "boolean", + "default": false, + "x-priority": "internal" + }, + "skipTypegen": { + "description": "Skip auto-running `wrangler types` after writing the binding. The generated `Env` interface will not be refreshed.", + "type": "boolean", + "default": false, + "x-priority": "internal" + } + }, + "required": ["project", "type", "binding"] +} diff --git a/packages/nx-cloudflare/src/index.ts b/packages/nx-cloudflare/src/index.ts index 48266ce38..eebbb9e39 100644 --- a/packages/nx-cloudflare/src/index.ts +++ b/packages/nx-cloudflare/src/index.ts @@ -7,8 +7,10 @@ export { createCloudflareGenerator } from './generators/create-cloudflare/genera export { createCloudflareGenerator as applicationGenerator } from './generators/create-cloudflare/generator'; export { nxCloudflareWorkerLibraryGenerator as libraryGenerator } from './generators/library/generator'; export { initGenerator } from './generators/init/generator'; +export { bindingGenerator } from './generators/binding/generator'; export type { Schema as CreateCloudflareGeneratorSchema } from './generators/create-cloudflare/schema'; export type { Schema as CloudflareApplicationGeneratorSchema } from './generators/create-cloudflare/schema'; export type { NxCloudflareLibraryGeneratorSchema } from './generators/library/schema'; export type { InitGeneratorSchema } from './generators/init/schema'; +export type { Schema as BindingGeneratorSchema } from './generators/binding/schema'; diff --git a/packages/nx-cloudflare/src/utils/provision.spec.ts b/packages/nx-cloudflare/src/utils/provision.spec.ts new file mode 100644 index 000000000..4455a330b --- /dev/null +++ b/packages/nx-cloudflare/src/utils/provision.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'bun:test'; +import { + buildProvisionCommand, + parseProvisionOutput, + type ProvisionOptions, +} from './provision'; + +const baseOptions = ( + overrides: Partial = {} +): ProvisionOptions => ({ + type: 'kv', + binding: 'MY_KV', + name: 'my-kv', + projectRoot: 'apps/w', + configPath: 'apps/w/wrangler.jsonc', + ...overrides, +}); + +describe('buildProvisionCommand', () => { + it('creates a KV namespace by binding title', () => { + expect(buildProvisionCommand(baseOptions({ type: 'kv' }))).toEqual({ + command: 'wrangler', + args: ['kv', 'namespace', 'create', '--title', 'MY_KV'], + }); + }); + + it('creates R2/D1/Queue resources by name', () => { + expect( + buildProvisionCommand(baseOptions({ type: 'r2', name: 'my-bucket' })).args + ).toEqual(['r2', 'bucket', 'create', 'my-bucket']); + expect( + buildProvisionCommand(baseOptions({ type: 'd1', name: 'my-db' })).args + ).toEqual(['d1', 'create', 'my-db']); + expect( + buildProvisionCommand(baseOptions({ type: 'queue', name: 'my-queue' })) + .args + ).toEqual(['queues', 'create', 'my-queue']); + }); +}); + +describe('parseProvisionOutput', () => { + it('parses a KV id from a TOML-style line and strips quotes', () => { + const stdout = `🌀 Creating namespace with title "w-MY_KV" +✨ Success! +[[kv_namespaces]] +binding = "MY_KV" +id = "0f2ac74b498b48028cb68387c421e279"`; + expect(parseProvisionOutput('kv', stdout)).toEqual({ + id: '0f2ac74b498b48028cb68387c421e279', + }); + }); + + it('parses a KV id from a JSONC snippet', () => { + const stdout = `Add the following to your configuration file: +{ + "kv_namespaces": [ + { "binding": "MY_KV", "id": "abc123def456" } + ] +}`; + expect(parseProvisionOutput('kv', stdout)).toEqual({ id: 'abc123def456' }); + }); + + it('parses a D1 database_id (not the literal "id")', () => { + const stdout = `✅ Successfully created DB 'my-db' +[[d1_databases]] +binding = "MY_DB" +database_name = "my-db" +database_id = "62ab3e6d-1234-5678-9abc-def012345678"`; + expect(parseProvisionOutput('d1', stdout)).toEqual({ + id: '62ab3e6d-1234-5678-9abc-def012345678', + }); + }); + + it('returns no id for R2 and Queue (addressed by name)', () => { + expect(parseProvisionOutput('r2', 'bucket created')).toEqual({}); + expect(parseProvisionOutput('queue', 'queue created')).toEqual({}); + }); + + it('throws when no id can be parsed from KV/D1 output', () => { + expect(() => parseProvisionOutput('kv', 'unexpected output')).toThrow( + 'Could not parse resource id' + ); + }); +}); diff --git a/packages/nx-cloudflare/src/utils/provision.ts b/packages/nx-cloudflare/src/utils/provision.ts new file mode 100644 index 000000000..d2753bc42 --- /dev/null +++ b/packages/nx-cloudflare/src/utils/provision.ts @@ -0,0 +1,176 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { workspaceRoot } from '@nx/devkit'; +import { + applyEdits, + modify, + parse, + type FormattingOptions, +} from 'jsonc-parser'; + +export type ProvisionableType = 'kv' | 'r2' | 'd1' | 'queue'; + +export interface ProvisionResult { + id?: string; +} + +export interface ProvisionOptions { + type: ProvisionableType; + binding: string; + name: string; + projectRoot: string; + configPath: string; +} + +export const PROVISION_SENTINEL = '__PENDING_CREATE__'; + +const DEFAULT_FORMATTING: FormattingOptions = { + tabSize: 2, + insertSpaces: true, + insertFinalNewline: true, +}; + +// Where the provisioned id lives in the config, per type. Only KV and D1 carry +// a remote-generated id (written as the PROVISION_SENTINEL until capture); R2 +// buckets and Queues are addressed by their name, which is known up front. +const ID_LOCATION: Partial< + Record +> = { + kv: { arrayKey: 'kv_namespaces', idField: 'id' }, + d1: { arrayKey: 'd1_databases', idField: 'database_id' }, +}; + +// Pure: build the wrangler CLI command for each provisionable type. KV/R2/D1/ +// Queue are the only binding types with a `wrangler create` command. The +// captured id is parsed out of stdout by `parseProvisionOutput`. +export function buildProvisionCommand(options: ProvisionOptions): { + command: string; + args: string[]; +} { + switch (options.type) { + case 'kv': + return { + command: 'wrangler', + args: ['kv', 'namespace', 'create', '--title', options.binding], + }; + case 'r2': + return { + command: 'wrangler', + args: ['r2', 'bucket', 'create', options.name], + }; + case 'd1': + return { + command: 'wrangler', + args: ['d1', 'create', options.name], + }; + case 'queue': + return { + command: 'wrangler', + args: ['queues', 'create', options.name], + }; + } +} + +// Side-effecting provision invocation: shells out on the real filesystem after +// the generator's virtual Tree has been flushed. Kept separate so unit tests +// can mock it (mirroring run-c3.ts). On success it records the captured id back +// into the config. Throws on any failure — `--create` is an explicit request, +// so a failed provision must surface loudly rather than leave a green run over +// a config that points at a resource which was never created. +export function provisionResource(options: ProvisionOptions): void { + const { command, args } = buildProvisionCommand(options); + const cwd = join(workspaceRoot, options.projectRoot); + + let stdout: string; + try { + stdout = execFileSync(command, args, { + cwd, + // Capture stderr too so wrangler's diagnostic is attached to the thrown + // error instead of only streaming to the terminal. + stdio: ['inherit', 'pipe', 'pipe'], + encoding: 'utf-8', + }); + } catch (e) { + const stderr = + e && typeof e === 'object' && 'stderr' in e + ? String((e as { stderr?: unknown }).stderr ?? '').trim() + : ''; + const reason = stderr || (e instanceof Error ? e.message : String(e)); + throw new Error( + `\`${command} ${args.join(' ')}\` failed. The binding was written to ` + + `${options.configPath} but the resource was not provisioned` + + (ID_LOCATION[options.type] + ? ` (its id is still "${PROVISION_SENTINEL}")` + : '') + + `. Reason: ${reason}` + ); + } + + const { id } = parseProvisionOutput(options.type, stdout); + if (id) { + persistProvisionedId(options.configPath, options.type, options.binding, id); + } +} + +// Parse the resource id from `wrangler create` stdout. KV/D1 emit the id; +// R2/Queue are addressed by name and carry no id. The id appears as either a +// JSONC snippet (`"id": "abc"`) or a TOML-style line (`id = "abc"`) depending on +// the wrangler version, so accept both separators and strip surrounding quotes. +export function parseProvisionOutput( + type: ProvisionableType, + stdout: string +): ProvisionResult { + if (type === 'r2' || type === 'queue') { + return {}; + } + const idMatch = stdout.match( + /(?:database_)?id["']?\s*[:=]\s*["']?([^\s"',}\]]+)/i + ); + if (!idMatch) { + throw new Error( + `Could not parse resource id from wrangler output:\n${stdout}` + ); + } + return { id: idMatch[1] }; +} + +// Write the captured id into the specific binding entry on the real filesystem +// (post-flush). Scoped to the entry matching `binding` so a leftover sentinel +// from an unrelated prior run is never overwritten. If the entry can't be +// found, throw with the id so the user can record it manually — the remote +// resource already exists and its id must not be lost. +export function persistProvisionedId( + configPath: string, + type: ProvisionableType, + binding: string, + id: string +): void { + const location = ID_LOCATION[type]; + if (!location) { + return; + } + const abs = join(workspaceRoot, configPath); + const text = readFileSync(abs, 'utf-8'); + const config = parse(text) as Record; + const arr = config[location.arrayKey]; + const index = Array.isArray(arr) + ? arr.findIndex( + (entry) => + typeof entry === 'object' && + entry !== null && + (entry as Record)['binding'] === binding + ) + : -1; + if (index === -1) { + throw new Error( + `Provisioned ${type} resource (id "${id}") but could not find binding ` + + `"${binding}" in ${configPath} to record it. Set ${location.idField} ` + + `to "${id}" manually.` + ); + } + const edits = modify(text, [location.arrayKey, index, location.idField], id, { + formattingOptions: DEFAULT_FORMATTING, + }); + writeFileSync(abs, applyEdits(text, edits)); +} diff --git a/packages/nx-cloudflare/src/utils/run-wrangler-types.ts b/packages/nx-cloudflare/src/utils/run-wrangler-types.ts new file mode 100644 index 000000000..2f42e0664 --- /dev/null +++ b/packages/nx-cloudflare/src/utils/run-wrangler-types.ts @@ -0,0 +1,15 @@ +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { workspaceRoot } from '@nx/devkit'; + +// Side-effecting wrangler types invocation: shells out on the real filesystem +// after the generator's virtual Tree has been flushed. Kept in its own module +// (mirroring run-c3.ts) so generator unit tests can mock it without touching +// the pure generator logic. Verified by e2e. +export function runWranglerTypes(projectRoot: string): void { + const cwd = join(workspaceRoot, projectRoot); + execFileSync('wrangler', ['types'], { + cwd, + stdio: 'inherit', + }); +} diff --git a/packages/nx-cloudflare/src/utils/wrangler-config.ts b/packages/nx-cloudflare/src/utils/wrangler-config.ts new file mode 100644 index 000000000..61c5e9756 --- /dev/null +++ b/packages/nx-cloudflare/src/utils/wrangler-config.ts @@ -0,0 +1,138 @@ +import { joinPathFragments, Tree } from '@nx/devkit'; +import { + applyEdits, + modify, + parse, + type FormattingOptions, + type JSONPath, +} from 'jsonc-parser'; + +export const WRANGLER_CONFIG_FILES = [ + 'wrangler.jsonc', + 'wrangler.json', + 'wrangler.toml', +] as const; + +export const JSONC_CONFIG_EXTENSIONS = ['.jsonc', '.json']; + +const DEFAULT_FORMATTING: FormattingOptions = { + tabSize: 2, + insertSpaces: true, + insertFinalNewline: true, +}; + +export function findWranglerConfig( + tree: Tree, + projectRoot: string +): string | null { + for (const file of WRANGLER_CONFIG_FILES) { + const path = joinPathFragments(projectRoot, file); + if (tree.exists(path)) { + return path; + } + } + return null; +} + +export function isJsoncConfig(configPath: string): boolean { + return JSONC_CONFIG_EXTENSIONS.some((ext) => configPath.endsWith(ext)); +} + +export function assertJsoncConfig(configPath: string): void { + if (!isJsoncConfig(configPath)) { + throw new Error( + `The binding generator only supports wrangler.jsonc and wrangler.json. ` + + `Your project uses ${configPath.split('/').pop()}. ` + + `Convert it to wrangler.jsonc first, then re-run the generator.` + ); + } +} + +export function readWranglerConfig( + tree: Tree, + configPath: string +): Record { + const text = tree.read(configPath, 'utf-8'); + if (!text) { + throw new Error(`wrangler config not found or empty: ${configPath}`); + } + return parse(text) as Record; +} + +export function appendToArray( + tree: Tree, + configPath: string, + arrayPath: JSONPath, + entry: Record +): void { + const text = tree.read(configPath, 'utf-8'); + if (!text) { + throw new Error(`wrangler config not found or empty: ${configPath}`); + } + const config = parse(text) as Record; + const arr = getAtPath(config, arrayPath); + const index = Array.isArray(arr) ? arr.length : 0; + const edits = modify(text, [...arrayPath, index], entry, { + isArrayInsertion: true, + formattingOptions: DEFAULT_FORMATTING, + }); + tree.write(configPath, applyEdits(text, edits)); +} + +export function findInArray( + config: Record, + arrayPath: JSONPath, + field: string, + value: string +): boolean { + const arr = getAtPath(config, arrayPath); + if (!Array.isArray(arr)) { + return false; + } + return arr.some( + (entry) => + typeof entry === 'object' && + entry !== null && + (entry as Record)[field] === value + ); +} + +export function getMigrationCount(config: Record): number { + const migrations = config['migrations']; + return Array.isArray(migrations) ? migrations.length : 0; +} + +// Whether any migration already introduces `className` (via new_classes or +// new_sqlite_classes). A Durable Object class is introduced by exactly one +// migration, so this guards against emitting a duplicate that wrangler rejects. +export function migrationDefinesClass( + config: Record, + className: string +): boolean { + const migrations = config['migrations']; + if (!Array.isArray(migrations)) { + return false; + } + return migrations.some((m) => { + if (typeof m !== 'object' || m === null) { + return false; + } + const entry = m as Record; + return ['new_sqlite_classes', 'new_classes'].some( + (key) => + Array.isArray(entry[key]) && + (entry[key] as unknown[]).includes(className) + ); + }); +} + +function getAtPath(obj: unknown, path: JSONPath): unknown { + let current: unknown = obj; + for (const segment of path) { + if (current == null || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[segment as string]; + } + return current; +}