/** * `.adopt()` / auto-register barrel generator — the disk-file alternative * to a virtual-module Bun plugin (investigated and rejected: `Bun.plugin()`'s * `onResolve` is never invoked for an unresolvable specifier during a plain * runtime `import()`, so `oke dev` and `oke build` would resolve a virtual * module differently — measured, not assumed). * * Two unit shapes: * - **Barrel** — only `index.ts` (+ skip-list) → `import * as notes from "./notes/index.ts"; export { notes }` * - **Tree** — `[param]` / `(group)` / extra route files → synthesized namespace * with `stampHttpPath` / `stampFlowName` after import * * Mixed barrel `index.ts` + tree files is a generate error (ambiguous: * `index` is also a reserved leaf). */ import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { importSpecifierFromWalked, isSkipFlowFile, nameFromFlowFile, pathFromFlowFile, toPosixPath, } from "./flow-path.ts"; /** Options for {@link generateAdoptBarrel}. */ export interface GenerateAdoptBarrelOptions { /** Project root (contains `src/flows`). */ readonly rootDir: string; /** Flows directory relative to {@link rootDir} (default `"src/flows"`). */ readonly flowsDir?: string; } /** Generated barrel source plus the unit names it covers. */ export interface AdoptBarrelResult { /** Full `.ts` source — write verbatim to the generated file. */ readonly source: string; /** Unit (folder) names included, sorted. */ readonly units: readonly string[]; /** Folders under `flowsDir` skipped (empty / invalid name / no routes). */ readonly skipped: readonly string[]; } const GENERATED_HEADER = "// AUTO-GENERATED by `oke dev` / `oke build` — do not edit by hand.\n" + "// Regenerated from every `src/flows//` folder (barrel or file tree).\n"; const CATCHALL_COMMENT = '// Catch-all param is "*" — call api.docs.get({ "*": "a/b/c" }), not { slug }.\n'; /** Filename of the on-disk `.adopt()` barrel under the flows directory. */ export const ADOPT_BARREL_FILE = "index.ts"; /** Previous barrel filename — unlinked after writing {@link ADOPT_BARREL_FILE}. */ export const PREV_ADOPT_BARREL_FILE = "generated.ts"; /** A folder name usable as an `export * as ` binding. */ const VALID_UNIT_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/; const EXPORT_CONST = /^export const ([A-Za-z_$][A-Za-z0-9_$]*)\b/gm; /** `export const onCreated = on(` — consumers colocated with `signal.once` in `signals.ts`. */ const EXPORT_ON = /^export const ([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*on\s*\(/gm; const EXPLICIT_FLOW_UNIT = /flow\(\s*["']([A-Za-z_$][A-Za-z0-9_$]*)\./g; /** Skip-list file that may still export `on()` consumers (not `signal.once` decls). */ const SIGNALS_FILE = "signals.ts"; /** * Generate-time failure (mixed barrel + tree, export collision, unit drift). */ export class GenerateAdoptError extends Error { /** Stable error name. */ override readonly name = "GenerateAdoptError"; /** * @param message - Why generation failed */ constructor(message: string) { super(message); } } /** One route file discovered under a unit folder. */ interface WalkedFlowFile { /** POSIX path relative to the unit folder (`[id]/get.ts`). */ readonly relFromUnit: string; /** File source (for export + drift scans). */ readonly source: string; /** `export const` names. */ readonly exports: readonly string[]; } /** * True when `rel` is the adopt barrel (or its `.tmp`) at the flows root — * not a unit `index.ts`. * * @param rel - Watcher or source path (POSIX or Windows) */ export function isAdoptBarrelRelPath(rel: string): boolean { const posix = toPosixPath(rel); const marker = "flows/"; const idx = posix.lastIndexOf(marker); const after = idx >= 0 ? posix.slice(idx + marker.length) : posix; return ( after === ADOPT_BARREL_FILE || after === `${ADOPT_BARREL_FILE}.tmp` || after === PREV_ADOPT_BARREL_FILE || after === `${PREV_ADOPT_BARREL_FILE}.tmp` ); } /** * Write the adopt barrel atomically: `index.ts.tmp` → rename to * `index.ts`. Skips the write when bytes are unchanged so the `src/` * watcher + bun `--hot` do not loop. Unlinks leftover `generated.ts`. * * @param rootDir - Project root * @param source - Full barrel source from {@link generateAdoptBarrel} * @param flowsDir - Flows directory relative to root (default `"src/flows"`) */ export async function writeAdoptBarrel( rootDir: string, source: string, flowsDir = "src/flows", ): Promise<{ written: boolean }> { const dir = join(rootDir, flowsDir); const target = join(dir, ADOPT_BARREL_FILE); const tmp = `${target}.tmp`; await mkdir(dir, { recursive: true }); let written = true; try { const existing = await readFile(target, "utf8"); if (existing === source) written = false; } catch { // missing — write } if (written) { await writeFile(tmp, source); try { await rename(tmp, target); } catch { // Windows cannot rename onto an existing path (EEXIST / EPERM). await writeFile(target, source); try { await unlink(tmp); } catch { // tmp already replaced or removed } } } await unlinkPrevAdoptBarrel(dir); return { written }; } /** * Remove leftover `generated.ts` after the barrel moved to `index.ts`. * * @param dir - Absolute flows directory */ async function unlinkPrevAdoptBarrel(dir: string): Promise { for (const name of [PREV_ADOPT_BARREL_FILE, `${PREV_ADOPT_BARREL_FILE}.tmp`]) { try { await unlink(join(dir, name)); } catch { // absent } } } /** * Scan `//*` and emit a barrel that registers every unit. * * Never throws for a missing/unreadable flows directory (empty barrel). * Mixed barrel + tree, export collisions, and unit-prefix drift throw * {@link GenerateAdoptError}. * * @param options - Root dir / flows dir override */ export async function generateAdoptBarrel( options: GenerateAdoptBarrelOptions, ): Promise { const flowsDir = options.flowsDir ?? "src/flows"; const abs = join(options.rootDir, flowsDir); const units: string[] = []; const skipped: string[] = []; const unitBlocks: string[] = []; const registerNames: string[] = []; const declareLines: string[] = []; let hasCatchAll = false; let needsStampImport = false; try { const dirents = await readdir(abs, { withFileTypes: true }); for (const d of dirents.sort((a, b) => a.name.localeCompare(b.name))) { if (!d.isDirectory()) continue; if (d.name.startsWith("_")) { skipped.push(d.name); continue; } if (!VALID_UNIT_NAME.test(d.name)) { skipped.push(d.name); continue; } const unitAbs = join(abs, d.name); const files = await listFlowFiles(unitAbs); const hasIndex = files.some((f) => f.relFromUnit === "index.ts"); const hasOther = files.some((f) => f.relFromUnit !== "index.ts"); if (hasIndex && hasOther) { throw new GenerateAdoptError( `Unit "${d.name}" mixes a barrel index.ts with tree route files. ` + `Use only index.ts (barrel), or move the collection path to route.ts ` + `and keep [id]/ beside it.`, ); } if (files.length === 0) { skipped.push(d.name); continue; } units.push(d.name); registerNames.push(d.name); declareLines.push(` readonly ${d.name}: typeof ${d.name};`); if (unitBlocks.length > 0) unitBlocks.push(""); if (hasIndex && !hasOther) { const spec = JSON.stringify(importSpecifierFromWalked(`${d.name}/index.ts`)); unitBlocks.push(`import * as ${d.name} from ${spec};`); unitBlocks.push(`export { ${d.name} };`); continue; } needsStampImport = true; const { lines, catchAll } = emitTreeUnit(d.name, files); if (catchAll) hasCatchAll = true; unitBlocks.push(...lines); } } catch (err) { if (err instanceof GenerateAdoptError) throw err; // Missing flows dir — empty barrel, not a throw (mirrors extractManifest). } const parts: string[] = [GENERATED_HEADER.trimEnd()]; if (hasCatchAll) parts.push(CATCHALL_COMMENT.trimEnd()); if (registerNames.length > 0) { const stamp = needsStampImport ? ", stampFlowName, stampHttpPath" : ""; parts.push(`import { registerFlowUnits${stamp} } from "okengine";`); } if (unitBlocks.length > 0) { parts.push(unitBlocks.join("\n")); } if (registerNames.length > 0) { parts.push(`registerFlowUnits({ ${registerNames.join(", ")} });`); parts.push('declare module "okengine" {'); parts.push(" interface RegisteredFlowUnits {"); parts.push(...declareLines); parts.push(" }"); parts.push("}"); } const source = `${parts.join("\n")}\n`; return { source, units, skipped }; } /** * Walk a unit folder for route files (skip-list applied). * `signals.ts` is included only for `export const … = on(` consumers. * * @param unitAbs - Absolute unit directory */ async function listFlowFiles(unitAbs: string): Promise { const out: WalkedFlowFile[] = []; const walk = async (dir: string, rel: string): Promise => { const dirents = await readdir(dir, { withFileTypes: true }); for (const d of dirents) { if (d.name.startsWith("_")) continue; const posixName = toPosixPath(d.name); if (d.isDirectory()) { if (posixName.startsWith("[[") && posixName.endsWith("]]")) { throw new GenerateAdoptError( `Optional catch-all "${posixName}" is not supported — use [...slug] (param is "*").`, ); } await walk(join(dir, d.name), rel ? `${rel}/${posixName}` : posixName); continue; } if (!posixName.endsWith(".ts") && !posixName.endsWith(".tsx")) continue; if (isSkipFlowFile(posixName) && posixName !== SIGNALS_FILE) continue; const relFile = rel ? `${rel}/${posixName}` : posixName; const source = await readFile(join(dir, d.name), "utf8"); const exports = posixName === SIGNALS_FILE ? listExportedOnConsts(source) : listExportedConsts(source); const relPosix = toPosixPath(relFile); // Barrel `index.ts` counts even with no `export const` (`export {}` / re-exports). if (exports.length === 0 && relPosix !== "index.ts") continue; out.push({ relFromUnit: relPosix, source, exports }); } }; try { await walk(unitAbs, ""); } catch (err) { if (err instanceof GenerateAdoptError) throw err; } return out.sort((a, b) => a.relFromUnit.localeCompare(b.relFromUnit)); } /** * Collect `export const` binding names. * * @param source - TypeScript source */ export function listExportedConsts(source: string): string[] { const names: string[] = []; EXPORT_CONST.lastIndex = 0; let match: RegExpExecArray | null; while ((match = EXPORT_CONST.exec(source))) { const name = match[1]; if (name) names.push(name); } return names; } /** * Collect `export const name = on(` bindings (skip `signal.once` decls). * * @param source - TypeScript source */ export function listExportedOnConsts(source: string): string[] { const names: string[] = []; EXPORT_ON.lastIndex = 0; let match: RegExpExecArray | null; while ((match = EXPORT_ON.exec(source))) { const name = match[1]; if (name) names.push(name); } return names; } /** * camelCase export → kebab filename (`onCreated` → `on-created`). * * @param name - `export const` binding */ function camelToKebab(name: string): string { return name.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`).replace(/^-/, ""); } /** * Emit import + stamped namespace for a tree unit. * * @param unit - Folder name * @param files - Walked route files */ function emitTreeUnit( unit: string, files: WalkedFlowFile[], ): { lines: string[]; catchAll: boolean } { const used = new Map(); let catchAll = false; const importLines: string[] = []; const fieldLines: string[] = []; for (const file of files) { if (file.relFromUnit.includes("[...")) catchAll = true; assertUnitPrefix(unit, file); const spec = importSpecifierFromWalked(`${unit}/${file.relFromUnit}`); if (spec.includes("\\")) { throw new GenerateAdoptError( `Import specifier for ${unit}/${file.relFromUnit} is not POSIX.`, ); } const alias = importAlias(unit, file.relFromUnit); importLines.push(`import * as ${alias} from ${JSON.stringify(spec)};`); for (const exp of file.exports) { const prev = used.get(exp); if (prev !== undefined) { throw new GenerateAdoptError( `Unit "${unit}" exports "${exp}" from both ${prev} and ${file.relFromUnit}.`, ); } used.set(exp, file.relFromUnit); const flowName = nameFromFlowFile(`${unit}/${file.relFromUnit}`, exp) ?? `${unit}.${exp}`; const pathLit = JSON.stringify(httpPathForExport(unit, file.relFromUnit, exp)); fieldLines.push( ` ${exp}: stampHttpPath(stampFlowName(${alias}.${exp}, ${JSON.stringify(flowName)}), ${pathLit}),`, ); } } const lines = [ ...importLines, "", `const ${unit} = {`, ...fieldLines, `};`, `export { ${unit} };`, ]; return { lines, catchAll }; } /** * HTTP path stamp for one export. * * Skip-list files (`signals.ts`) have no file-tree path — fall back to * `unit` + kebab export so `onCreated` in `links/signals.ts` stamps * `/links/on-created` like a dedicated `on-created.ts` would. * * @param unit - Folder name * @param relFromUnit - POSIX relative path * @param exp - `export const` binding */ function httpPathForExport(unit: string, relFromUnit: string, exp: string): string { return ( pathFromFlowFile(`${unit}/${relFromUnit}`) ?? pathFromFlowFile(`src/flows/${unit}/${relFromUnit}`) ?? pathFromFlowFile(`${unit}/${camelToKebab(exp)}.ts`) ?? "/" ); } /** * Fail when `flow("other.x")` lives under a different unit folder. * * @param unit - Folder name * @param file - Walked file */ function assertUnitPrefix(unit: string, file: WalkedFlowFile): void { EXPLICIT_FLOW_UNIT.lastIndex = 0; let match: RegExpExecArray | null; while ((match = EXPLICIT_FLOW_UNIT.exec(file.source))) { const prefix = match[1]; if (prefix && prefix !== unit) { throw new GenerateAdoptError( `flow("${prefix}.…") in ${unit}/${file.relFromUnit} does not match the folder "${unit}".`, ); } } } /** * Stable import alias for a walked file (`notes/[id]/get.ts` → `notes_$id$_get`). * * @param unit - Unit folder * @param relFromUnit - POSIX relative path */ export function importAlias(unit: string, relFromUnit: string): string { const posix = toPosixPath(relFromUnit).replace(/\.[^.]+$/, ""); const body = posix .replace(/\[/g, "$") .replace(/\]/g, "$") .replace(/[^\w$]/g, "_"); return `${unit}_${body}`; }