import { createHash } from 'node:crypto'; import { access, readdir, readFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; import { createRequire } from 'node:module'; import { setSpanAttributes, withActiveSpan, } from '@shared_libs/observability/tracing'; const runtimeRequire = createRequire(import.meta.url); const esbuild = runtimeRequire('esbuild') as { build: (options: Record) => Promise<{ outputFiles?: Array<{ text: string }>; metafile?: { inputs: Record; }; }>; }; type EsbuildOnResolveArgs = { importer: string; path: string; }; type EsbuildPluginBuild = { onResolve: ( options: { filter: RegExp }, callback: (args: EsbuildOnResolveArgs) => | { path?: string; errors?: Array<{ text: string }>; } | undefined, ) => void; }; /** * SECURITY: These are the only allowed module-scope caches in the play runner. * * They cache compiled runner CODE solely by source hash. They must never cache * org-scoped, workflow-scoped, credential-bearing, or otherwise run-derived * material. Future edits: do not thread execution config, play input, API * responses, sandbox ids, or customer data into this cache key or payload. */ let cachedRunnerBundle: Promise | null = null; let cachedRunnerSourceHash: string | null = null; async function readPrebuiltRunnerBundle(): Promise { const bundlePath = process.env.DEEPLINE_PLAY_RUNNER_BUNDLE_PATH?.trim(); if (!bundlePath) { return null; } try { await access(bundlePath); const bundle = await readFile(bundlePath, 'utf-8'); console.info('[plays.runner.bundle.prebuilt]', { bundlePath, bytes: bundle.length, }); return bundle; } catch (error) { console.warn('[plays.runner.bundle.prebuilt_missing]', { bundlePath, error: error instanceof Error ? error.message : String(error), }); return null; } } async function listFiles(dir: string): Promise { const entries = await readdir(dir, { withFileTypes: true }); const values = await Promise.all( entries.map(async (entry) => { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { return await listFiles(fullPath); } return entry.isFile() ? [fullPath] : []; }), ); return values.flat().sort(); } async function hashRunnerSources(rootDirs: string[]): Promise { const sourceFiles = ( await Promise.all(rootDirs.map(async (rootDir) => await listFiles(rootDir))) ) .flat() .sort(); const hash = createHash('sha256'); for (const filePath of sourceFiles) { hash.update(filePath); hash.update(await readFile(filePath)); } return hash.digest('hex'); } function isInsidePath(candidate: string, parent: string): boolean { const relativePath = relative(parent, candidate); return ( Boolean(relativePath) && !relativePath.startsWith('..') && !relativePath.startsWith('/') ); } function createRunnerBoundaryPlugin(projectRoot: string) { const forbiddenRoots = [ resolve(projectRoot, 'src'), resolve(projectRoot, 'convex'), ]; function rejectionForImport(args: EsbuildOnResolveArgs): string | null { if (args.path.startsWith('@/') || args.path.startsWith('@convex/')) { return `play-runner bundle cannot import ${args.path}; move shared runtime code to shared_libs or call the app over the runtime API`; } if (!args.path.startsWith('.') || !args.importer) { return null; } const resolvedImport = resolve(dirname(args.importer), args.path); const forbiddenRoot = forbiddenRoots.find( (root) => isInsidePath(resolvedImport, root) || resolvedImport === root, ); if (!forbiddenRoot) { return null; } const relativeForbiddenPath = relative(projectRoot, resolvedImport); return `play-runner bundle cannot import ${args.path} from ${args.importer}; it resolves into ${relativeForbiddenPath}`; } return { name: 'deepline-play-runner-boundary', setup(build: EsbuildPluginBuild) { build.onResolve({ filter: /.*/ }, (args) => { const rejection = rejectionForImport(args); if (!rejection) { return undefined; } return { errors: [{ text: rejection }], }; }); }, }; } function assertBundleInputsStayInsideRunnerBoundary( projectRoot: string, inputs: Record | undefined, ) { if (!inputs) { return; } const forbiddenInputs = Object.keys(inputs) .map((inputPath) => resolve(projectRoot, inputPath)) .filter( (inputPath) => isInsidePath(inputPath, resolve(projectRoot, 'src')) || isInsidePath(inputPath, resolve(projectRoot, 'convex')) || inputPath === resolve(projectRoot, 'src') || inputPath === resolve(projectRoot, 'convex'), ); if (forbiddenInputs.length === 0) { return; } throw new Error( [ 'play-runner bundle crossed into app/server source:', ...forbiddenInputs.map( (inputPath) => `- ${relative(projectRoot, inputPath)}`, ), ].join('\n'), ); } export async function buildPlayRunnerBundle(): Promise { return await withActiveSpan( 'plays.runner.bundle', { tracer: 'deepline.plays', }, async (span) => { const prebuiltBundle = await readPrebuiltRunnerBundle(); if (prebuiltBundle) { span.setAttribute('plays.bundle_prebuilt', true); setSpanAttributes(span, { 'plays.bundle_bytes': prebuiltBundle.length, }); return prebuiltBundle; } span.setAttribute('plays.bundle_prebuilt', false); const sourceRoots = [ resolve(process.cwd(), 'apps', 'play-runner', 'src'), resolve(process.cwd(), 'shared_libs'), ]; const sourceHash = await hashRunnerSources(sourceRoots); setSpanAttributes(span, { 'plays.runner_source_hash': sourceHash, }); if (cachedRunnerBundle && cachedRunnerSourceHash === sourceHash) { span.setAttribute('plays.bundle_cache_hit', true); const bundle = await cachedRunnerBundle; setSpanAttributes(span, { 'plays.bundle_bytes': bundle.length, }); return bundle; } span.setAttribute('plays.bundle_cache_hit', false); cachedRunnerSourceHash = sourceHash; cachedRunnerBundle = (async () => { const projectRoot = process.cwd(); const runnerRoot = resolve(process.cwd(), 'apps', 'play-runner', 'src'); return await withActiveSpan( 'plays.runner.bundle_build', { tracer: 'deepline.plays', attributes: { 'plays.runner_source_hash': sourceHash, }, }, async (buildSpan) => { const result = await esbuild.build({ entryPoints: [resolve(runnerRoot, 'entry.ts')], bundle: true, platform: 'node', format: 'cjs', target: ['node18'], write: false, sourcemap: false, minify: true, tsconfig: resolve( process.cwd(), 'tsconfig.apps.play-runner.json', ), metafile: true, plugins: [createRunnerBoundaryPlugin(projectRoot)], }); assertBundleInputsStayInsideRunnerBoundary( projectRoot, result.metafile?.inputs, ); const bundle = result.outputFiles?.[0]?.text ?? ''; setSpanAttributes(buildSpan, { 'plays.bundle_bytes': bundle.length, }); return bundle; }, ); })(); const bundle = await cachedRunnerBundle; setSpanAttributes(span, { 'plays.bundle_bytes': bundle.length, }); return bundle; }, ); }