stack/scripts/wait-for-dev-package-imports.ts
2026-05-22 16:25:57 -07:00

132 lines
4.5 KiB
TypeScript

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.
//
// In addition to workspace packages, the probe checks that the generated Prisma
// client exists. When `turbo run dev` starts the backend, `codegen-prisma:watch`
// (`prisma generate --watch`) performs an initial generation that briefly removes
// and recreates `src/generated/prisma/`. If `codegen-docs` runs during that
// window it fails with ERR_MODULE_NOT_FOUND for `@/generated/prisma/client`.
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');
const { existsSync, readdirSync } = await import('node:fs');
const { join } = await import('node:path');
const generatedDir = join(process.cwd(), 'src', 'generated', 'prisma');
if (!existsSync(generatedDir) || readdirSync(generatedDir).length === 0) {
const err = new Error('ERR_MODULE_NOT_FOUND: Generated Prisma client not yet available at ' + generatedDir);
throw err;
}
})().then(
() => undefined,
(error) => {
console.error(error);
process.exit(1);
},
);
`;
type ProbeResult = {
exitCode: number | null,
output: string,
};
function runProbe(): Promise<ProbeResult> {
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);
},
);