diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index a4013491d..eb14f31c1 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -391,3 +391,6 @@ A: The `/api/v1/internal/metrics` response now intentionally includes `analytics ## Q: Why can environment config override writes fail with a product/product-line customer type warning after creating a preview project? A: The environment override endpoint validates the new environment override against the rendered branch config. Preview dummy payments data must therefore be internally coherent: products assigned to a product line need the same `customerType` as that product line, otherwise unrelated environment patches can fail with warnings like `Product "growth" has customer type "user" but its product line "workspace" has customer type "team"`. + +## Q: Why can `pnpm run dev` fail with `ERR_MODULE_NOT_FOUND` for `@stackframe/stack/dist/esm/index.js` during OpenAPI docs generation? +A: Root `dev` starts the OpenAPI docs watcher at the same time as package `dev` watchers. If a package `dev` script removes `dist` before `tsdown --watch` recreates it, the docs generator can import `apps/backend/src/stack.tsx` while `@stackframe/stack`'s ESM entrypoint is temporarily missing. Package watch scripts should update `dist` in place, and eager generators should wait for package imports to resolve before running. diff --git a/package.json b/package.json index bbf30ea56..b1557a610 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "generate-sdks": "pnpm exec tsx ./scripts/generate-sdks.ts", "generate-setup-prompt-docs": "pnpm exec tsx ./scripts/generate-setup-prompt-docs.ts", "generate-sdks:watch": "chokidar --silent -c 'pnpm run generate-sdks' './packages/template' --ignore './packages/template/package.json' --ignore '**/node_modules/**' --ignore '**/dist/**' --ignore '**/.turbo/**' --throttle 2000", - "generate-openapi-docs:watch": "pnpm run --filter=@stackframe/backend codegen-docs && chokidar --silent -c 'pnpm run --filter=@stackframe/backend codegen-docs' './apps/backend/src/app/api/latest/**/route.{js,jsx,ts,tsx}' './apps/backend/src/lib/openapi.ts' './packages/stack-shared/src/interface/webhooks.ts' --throttle 2000", + "generate-openapi-docs:watch": "pnpm exec tsx ./scripts/wait-for-dev-package-imports.ts && pnpm run --filter=@stackframe/backend codegen-docs && chokidar --silent -c 'pnpm run --filter=@stackframe/backend codegen-docs' './apps/backend/src/app/api/latest/**/route.{js,jsx,ts,tsx}' './apps/backend/src/lib/openapi.ts' './packages/stack-shared/src/interface/webhooks.ts' --throttle 2000", "generate-setup-prompt-docs:watch": "pnpm run generate-setup-prompt-docs && chokidar --silent -c 'pnpm run generate-setup-prompt-docs' './packages/stack-shared/src/ai/prompts.ts' './scripts/generate-setup-prompt-docs.ts' --throttle 2000" }, "devDependencies": { diff --git a/packages/js/package.json b/packages/js/package.json index ce7d1cdf4..cbc838838 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -41,7 +41,7 @@ "clean": "rimraf dist && rimraf node_modules", "lint": "eslint --ext .tsx,.ts .", "build": "rimraf dist && tsdown", - "dev": "rimraf dist && tsdown --watch" + "dev": "tsdown --watch" }, "files": [ "README.md", diff --git a/packages/react/package.json b/packages/react/package.json index a5cc7fe55..bc702a61a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -41,7 +41,7 @@ "clean": "rimraf dist && rimraf node_modules", "lint": "eslint --ext .tsx,.ts .", "build": "rimraf dist && pnpm run css && tsdown", - "dev": "rimraf dist && concurrently -n \"build,codegen\" -k \"tsdown --watch\" \"pnpm run codegen:watch\"", + "dev": "concurrently -n \"build,codegen\" -k \"tsdown --watch\" \"pnpm run codegen:watch\"", "codegen": "pnpm run css", "codegen:watch": "pnpm run css:watch", "css": "pnpm run css-tw && pnpm run css-sc", diff --git a/packages/stack/package.json b/packages/stack/package.json index a8ae93908..4766147f9 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -41,7 +41,7 @@ "clean": "rimraf dist && rimraf node_modules", "lint": "eslint --ext .tsx,.ts .", "build": "rimraf dist && pnpm run css && tsdown", - "dev": "rimraf dist && concurrently -n \"build,codegen\" -k \"tsdown --watch\" \"pnpm run codegen:watch\"", + "dev": "concurrently -n \"build,codegen\" -k \"tsdown --watch\" \"pnpm run codegen:watch\"", "codegen": "pnpm run css", "codegen:watch": "pnpm run css:watch", "css": "pnpm run css-tw && pnpm run css-sc", diff --git a/packages/template/package-template.json b/packages/template/package-template.json index d677db42f..daed224f6 100644 --- a/packages/template/package-template.json +++ b/packages/template/package-template.json @@ -53,12 +53,12 @@ "//": "IF_PLATFORM template react-like", "build": "rimraf dist && pnpm run css && tsdown", - "dev": "rimraf dist && concurrently -n \"build,codegen\" -k \"tsdown --watch\" \"pnpm run codegen:watch\"", + "dev": "concurrently -n \"build,codegen\" -k \"tsdown --watch\" \"pnpm run codegen:watch\"", "codegen": "pnpm run css", "codegen:watch": "pnpm run css:watch" ,"//": "ELSE_PLATFORM", "build": "rimraf dist && tsdown", - "dev": "rimraf dist && tsdown --watch" + "dev": "tsdown --watch" ,"//": "END_PLATFORM", "//": "IF_PLATFORM template react-like" diff --git a/packages/template/package.json b/packages/template/package.json index e088b299c..f065f2834 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -42,7 +42,7 @@ "clean": "rimraf dist && rimraf node_modules", "lint": "eslint --ext .tsx,.ts .", "build": "rimraf dist && pnpm run css && tsdown", - "dev": "rimraf dist && concurrently -n \"build,codegen\" -k \"tsdown --watch\" \"pnpm run codegen:watch\"", + "dev": "concurrently -n \"build,codegen\" -k \"tsdown --watch\" \"pnpm run codegen:watch\"", "codegen": "pnpm run css", "codegen:watch": "concurrently -n \"css\" -k \"pnpm run css:watch\"", "css": "pnpm run css-tw && pnpm run css-sc", diff --git a/scripts/wait-for-dev-package-imports.ts b/scripts/wait-for-dev-package-imports.ts new file mode 100644 index 000000000..c9791b6c2 --- /dev/null +++ b/scripts/wait-for-dev-package-imports.ts @@ -0,0 +1,118 @@ +import { spawn } from "child_process"; +import path from "path"; +import { setTimeout as sleep } from "timers/promises"; + +// Root `pnpm run dev` starts eager generators and package watch builds in +// parallel. `generate-openapi-docs:watch` intentionally runs `codegen-docs` +// once before starting chokidar, because chokidar only responds to future file +// changes. Without that initial run, dev docs could serve stale OpenAPI JSON +// from a previous branch, or no generated JSON at all after a clean checkout, +// until someone edits an API route. +// +// That eager OpenAPI generation imports backend modules, and some of those +// backend modules resolve workspace packages through their built `dist` +// entrypoints. Package watch scripts update those entrypoints with +// `tsdown --watch`, but on a cold checkout, after `pnpm clean`, or during the +// first package watcher build, the entrypoints may not exist yet even though +// `tsdown --watch` is about to create them. +// +// We keep this wait scoped to the eager generator rather than putting it in +// front of backend `dev`: the long-running Next dev server can tolerate package +// watchers warming up, while a one-shot generator exits immediately on a missing +// import and `concurrently -k` then tears down the whole dev command. Package +// watch scripts also avoid deleting `dist` in dev mode, which removes the +// common restart race; this probe covers the remaining cold-start case. +// +// This probe waits only for the package imports that the backend-side generator +// needs. It does not hide real runtime errors: we retry missing-module failures +// while package builds warm up, and fail immediately for other import failures. +const repoRoot = path.resolve(__dirname, ".."); +const backendDir = path.join(repoRoot, "apps/backend"); +const timeoutMs = 60_000; +const retryDelayMs = 1_000; + +const probeScript = ` +(async () => { + await import('@stackframe/stack'); + await import('@stackframe/stack-shared/dist/utils/env'); +})().then( + () => undefined, + (error) => { + console.error(error); + process.exit(1); + }, +); +`; + +type ProbeResult = { + exitCode: number | null, + output: string, +}; + +function runProbe(): Promise { + return new Promise((resolve, reject) => { + const child = spawn("pnpm", ["exec", "tsx", "-e", probeScript], { + cwd: backendDir, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let output = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + output += chunk; + }); + child.stderr.on("data", (chunk: string) => { + output += chunk; + }); + + child.on("error", reject); + child.on("close", (exitCode) => { + resolve({ exitCode, output }); + }); + }); +} + +function isMissingModuleError(output: string) { + return output.includes("ERR_MODULE_NOT_FOUND") || output.includes("MODULE_NOT_FOUND"); +} + +async function main() { + const start = performance.now(); + let lastOutput = ""; + let hasLoggedWait = false; + let isReady = false; + + while (performance.now() - start < timeoutMs) { + const result = await runProbe(); + if (result.exitCode === 0) { + isReady = true; + break; + } + + lastOutput = result.output; + if (!isMissingModuleError(result.output)) { + throw new Error(`Dev package import probe failed with a non-retryable error:\n${result.output}`); + } + + if (!hasLoggedWait) { + console.log("Waiting for dev package entrypoints to be generated..."); + hasLoggedWait = true; + } + await sleep(retryDelayMs); + } + + if (!isReady) { + throw new Error(`Timed out waiting for dev package imports to become available. Last probe output:\n${lastOutput}`); + } +} + +main().then( + () => undefined, + (error) => { + console.error(error); + process.exit(1); + }, +);