feat: add Cloudflare Workers deployment for example app#92
Conversation
Add Cloudflare Workers support to the example app using the @cloudflare/vite-plugin. Includes a deploy script that stages a temp copy, rewires workspace:* to the published npm version, builds, and deploys via wrangler. - Add wrangler.jsonc with nodejs_compat and TanStack server entry - Add @cloudflare/vite-plugin to vite config (works for local dev too) - Add deploy script (scripts/deploy-example-cloudflare.ts) run via Node's native --experimental-strip-types - Add deploy:example and deploy:example:secrets npm scripts at root - Add .dev.vars.example for Cloudflare local secrets - Update .gitignore for .wrangler/ and .dev.vars - Update README with Cloudflare deployment instructions
Greptile SummaryThis PR adds Cloudflare Workers deployment support to the example app via
Confidence Score: 4/5The core Cloudflare Workers setup and authkit-loader changes are solid, but the deploy script contamination guard may not work reliably across all pnpm hoisting configurations. The authkit-loader dynamic-import refactor and the Vite/wrangler configuration are straightforward and correct. The deploy script contamination guard calls readlink on stagedExampleDir/node_modules which pnpm v9 may not populate, causing a cryptic ENOENT on a clean npm install. scripts/deploy-example-cloudflare.ts — contamination guard symlink path and readlink reliability Important Files Changed
|
| "test:coverage": "vitest run --coverage" | ||
| "test:coverage": "vitest run --coverage", | ||
| "deploy:example": "node --experimental-strip-types scripts/deploy-example-cloudflare.ts", | ||
| "deploy:example:secrets": "wrangler secret bulk example/.env --config example/wrangler.jsonc" |
There was a problem hiding this comment.
wrangler is only a devDependency of example/, not the root package. In a pnpm workspace without shameful hoisting, binaries from child-workspace packages are not placed in the root node_modules/.bin, so running pnpm run deploy:example:secrets from the repo root will fail with "command not found: wrangler". The fix is to delegate to the example workspace, where wrangler is available.
| "deploy:example:secrets": "wrangler secret bulk example/.env --config example/wrangler.jsonc" | |
| "deploy:example:secrets": "pnpm --filter example exec wrangler secret bulk .env --config wrangler.jsonc" |
| const dependencyLink = join(stagedExampleDir, 'node_modules', '@workos', 'authkit-tanstack-react-start'); | ||
| const dependencyTarget = resolve(dirname(dependencyLink), await readlink(dependencyLink)); | ||
| if (dependencyTarget === stagingRoot) { | ||
| throw new Error(`Staged deploy resolved ${sdkName} to the local workspace instead of npm.`); | ||
| } |
There was a problem hiding this comment.
The workspace-contamination guard calls
readlink (one hop) and then manually resolves the relative link target. pnpm typically creates a single-level symlink for workspace packages (pointing directly to the package root), so this works today. However, if readlink rejects — e.g. ENOENT because pnpm skipped creating a top-level symlink for the package (possible in some hoisting modes) — the error will bubble out as a cryptic OS error rather than a diagnostic message. Using fs.promises.realpath would both follow all symlink levels and surface a clearer failure mode. The startsWith check also guards against resolved paths that are nested under the staging root but are not the root itself.
| const dependencyLink = join(stagedExampleDir, 'node_modules', '@workos', 'authkit-tanstack-react-start'); | |
| const dependencyTarget = resolve(dirname(dependencyLink), await readlink(dependencyLink)); | |
| if (dependencyTarget === stagingRoot) { | |
| throw new Error(`Staged deploy resolved ${sdkName} to the local workspace instead of npm.`); | |
| } | |
| const dependencyLink = join(stagedExampleDir, 'node_modules', '@workos', 'authkit-tanstack-react-start'); | |
| const { realpath } = await import('node:fs/promises'); | |
| const dependencyTarget = await realpath(dependencyLink); | |
| if (dependencyTarget.startsWith(stagingRoot)) { | |
| throw new Error(`Staged deploy resolved ${sdkName} to the local workspace instead of npm.`); | |
| } |
| await cp(rootPackagePath, join(stagingRoot, 'package.json')); | ||
| await cp(join(repoRoot, 'pnpm-lock.yaml'), join(stagingRoot, 'pnpm-lock.yaml')); | ||
| await cp(join(repoRoot, 'pnpm-workspace.yaml'), join(stagingRoot, 'pnpm-workspace.yaml')); |
There was a problem hiding this comment.
🔴 Deploy script always fails by default because pnpm resolves SDK to the copied workspace instead of npm
The deploy script copies pnpm-workspace.yaml as-is (scripts/deploy-example-cloudflare.ts:61), which declares both . (the SDK root) and example/ as workspace packages. It also copies the root package.json (scripts/deploy-example-cloudflare.ts:59) containing @workos/authkit-tanstack-react-start@0.8.3. The example's dependency is rewritten from workspace:* to sdkSpec (line 71), which defaults to sdkPackage.version ("0.8.3") when WORKOS_AUTHKIT_TANSTACK_START_VERSION is unset. Since pnpm's link-workspace-packages defaults to true (and no .npmrc overrides this), pnpm will always link the workspace package when the version matches — which it does by default. The readlink check on line 83 catches this and throws, meaning pnpm run deploy (as documented in the README) always fails without setting an undocumented env var.
Staged workspace structure that causes the issue
The staging dir ends up as:
stagingRoot/package.json→ SDK (name:@workos/authkit-tanstack-react-start, version:0.8.3)stagingRoot/pnpm-workspace.yaml→packages: ['.', 'example/']stagingRoot/example/package.json→ depends on@workos/authkit-tanstack-react-start: "0.8.3"
pnpm sees the workspace package at . satisfies the dependency and links instead of downloading from npm.
Prompt for agents
The deploy script copies pnpm-workspace.yaml from the repo root, which includes '.' as a workspace package. Since the root package.json (the SDK) is also copied with its version, pnpm's link-workspace-packages (default: true) will always resolve the example's dependency to the workspace package instead of downloading from npm.
The fix is to write a modified pnpm-workspace.yaml to the staging root that only includes 'example/' (not '.'). This way the SDK's package.json at the staging root is not a workspace member, and pnpm will resolve @workos/authkit-tanstack-react-start from the npm registry.
In scripts/deploy-example-cloudflare.ts around line 61, instead of:
await cp(join(repoRoot, 'pnpm-workspace.yaml'), join(stagingRoot, 'pnpm-workspace.yaml'));
Write a workspace yaml that excludes the root:
await writeFile(join(stagingRoot, 'pnpm-workspace.yaml'), 'packages:\n - example/\n');
This ensures pnpm does not treat the staged root package.json as a workspace member and resolves the SDK from npm instead. The readlink safety check on lines 81-85 can be kept as an additional guard.
Was this helpful? React with 👍 or 👎 to provide feedback.
| "test:coverage": "vitest run --coverage" | ||
| "test:coverage": "vitest run --coverage", | ||
| "deploy:example": "node --experimental-strip-types scripts/deploy-example-cloudflare.ts", | ||
| "deploy:example:secrets": "wrangler secret bulk example/.env --config example/wrangler.jsonc" |
There was a problem hiding this comment.
🟡 deploy:example:secrets script fails because wrangler is not in root devDependencies
The root package.json defines "deploy:example:secrets": "wrangler secret bulk example/.env --config example/wrangler.jsonc". However, wrangler is only listed in example/devDependencies (example/package.json:42), not in the root's devDependencies. In a pnpm workspace, each package has isolated node_modules/.bin/ — binaries from example/ are not available to root-level scripts. Running pnpm run deploy:example:secrets from the repo root will fail with wrangler: command not found.
| "deploy:example:secrets": "wrangler secret bulk example/.env --config example/wrangler.jsonc" | |
| "deploy:example:secrets": "pnpm --filter example exec wrangler secret bulk .env --config wrangler.jsonc" |
Was this helpful? React with 👍 or 👎 to provide feedback.
authkit-loader.ts statically imported @workos/authkit-session, which pulled @workos-inc/node → eventemitter3 into the client module graph via the barrel re-export in server/index.ts. Convert to dynamic await import() matching the lazy pattern in middleware.ts, actions.ts, and server-functions.ts. Functions were already async — no API change. Refs #82
Summary
@cloudflare/vite-pluginscripts/deploy-example-cloudflare.ts) that stages a temp copy, rewiresworkspace:*to the published npm version, and deploys viawranglerworkerd) and production runtimesChanges
example/vite.config.ts— add@cloudflare/vite-pluginexample/wrangler.jsonc— worker config withnodejs_compatscripts/deploy-example-cloudflare.ts— deploy script (runs via Node's native--experimental-strip-types)scripts/tsconfig.json— tsconfig for scripts directorypackage.json— adddeploy:exampleanddeploy:example:secretsscripts.gitignore— ignore.wrangler/and.dev.varsexample/.dev.vars.example— template for Cloudflare local secretsexample/README.md— Cloudflare deployment instructionsDeploy
Test plan
pnpm devworks locally (runs onworkerdvia Cloudflare plugin)pnpm run deploy:examplebuilds and deploys successfullyCloses #90