import { createHash } from 'node:crypto'; import { readFile, stat } from 'node:fs/promises'; import { basename, dirname, extname, isAbsolute, join, relative, resolve, } from 'node:path'; export interface PlayLocalFileReference { sourceFragment: string; logicalPath: string; absolutePath: string; bytes: number; contentHash: string; contentType: string; } export interface PlayLocalFileDiscoveryError { sourceFragment: string; message: string; } export interface PlayLocalFileDiscoveryResult { files: PlayLocalFileReference[]; unresolved: PlayLocalFileDiscoveryError[]; } export interface PlayStagedFileRef { storageKind: 'r2'; storageKey: string; logicalPath: string; fileName: string; contentHash: string; contentType: string; bytes: number; } type ConstMap = Map; const SOURCE_EXTENSIONS = [ '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json', ]; const TEXT_IMPORT_EXTENSIONS = new Set(['.md', '.txt']); function isTextImportFile(filePath: string): boolean { return TEXT_IMPORT_EXTENSIONS.has(extname(filePath)); } function sha256(buffer: Buffer): string { return createHash('sha256').update(buffer).digest('hex'); } function contentTypeForFile(filePath: string): string { const extension = extname(filePath).toLowerCase(); if (extension === '.csv') return 'text/csv'; if (extension === '.json') return 'application/json'; if (extension === '.txt') return 'text/plain'; return 'application/octet-stream'; } function stripCommentsToSpaces(source: string): string { return source .replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, ' ')) .replace( /(^|[^:])\/\/.*$/gm, (match, prefix: string) => prefix + ' '.repeat(Math.max(0, match.length - prefix.length)), ); } function unquoteStringLiteral(literal: string): string | null { const trimmed = literal.trim(); const quote = trimmed[0]; if ( (quote !== '"' && quote !== "'") || trimmed[trimmed.length - 1] !== quote ) { return null; } try { return JSON.parse( quote === '"' ? trimmed : `"${trimmed.slice(1, -1).replace(/"/g, '\\"')}"`, ); } catch { return trimmed.slice(1, -1); } } function splitTopLevelPlus(expression: string): string[] | null { const parts: string[] = []; let start = 0; let depth = 0; let quote: string | null = null; let escaped = false; for (let index = 0; index < expression.length; index += 1) { const char = expression[index]!; if (quote) { if (escaped) { escaped = false; } else if (char === '\\') { escaped = true; } else if (char === quote) { quote = null; } continue; } if (char === '"' || char === "'" || char === '`') { quote = char; continue; } if (char === '(' || char === '[' || char === '{') depth += 1; if (char === ')' || char === ']' || char === '}') depth -= 1; if (char === '+' && depth === 0) { parts.push(expression.slice(start, index)); start = index + 1; } } if (parts.length === 0) return null; parts.push(expression.slice(start)); return parts; } function stripOuterParens(expression: string): string { let value = expression.trim(); while (value.startsWith('(') && value.endsWith(')')) { value = value.slice(1, -1).trim(); } return value; } function isRuntimeInputExpression(expression: string): boolean { return /(^|[^\w$])input([^\w$]|$)/.test(expression); } function resolveStringExpression( expression: string, constants: ConstMap, ): string | null { const value = stripOuterParens(expression); if (/^(['"])(?:\\.|(?!\1)[\s\S])*\1$/.test(value)) { return unquoteStringLiteral(value); } if (/^`(?:\\.|[^`$]|\$(?!\{))*`$/.test(value)) { return value.slice(1, -1); } if (/^[A-Za-z_$][\w$]*$/.test(value)) { return constants.get(value) ?? null; } const parts = splitTopLevelPlus(value); if (parts) { const resolved = parts.map((part) => resolveStringExpression(part, constants), ); return resolved.every((part): part is string => part != null) ? resolved.join('') : null; } return null; } function collectTopLevelStringConstants(sourceCode: string): ConstMap { const constants: ConstMap = new Map(); const source = stripCommentsToSpaces(sourceCode); for (const match of source.matchAll( /(?:^|\n)\s*const\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+)/g, )) { const resolved = resolveStringExpression(match[2]!, constants); if (resolved != null) { constants.set(match[1]!, resolved); } } return constants; } function findMatchingGenericEnd(source: string, openIndex: number): number { let depth = 0; let quote: string | null = null; let escaped = false; for (let index = openIndex; index < source.length; index += 1) { const char = source[index]!; if (quote) { if (escaped) { escaped = false; } else if (char === '\\') { escaped = true; } else if (char === quote) { quote = null; } continue; } if (char === '"' || char === "'" || char === '`') { quote = char; continue; } if (char === '<') depth += 1; if (char === '>') { depth -= 1; if (depth === 0) return index; } } return -1; } function findCallOpenParen(source: string, afterCsvIndex: number): number { let index = afterCsvIndex; while (/\s/.test(source[index] ?? '')) index += 1; if (source[index] === '<') { const genericEnd = findMatchingGenericEnd(source, index); if (genericEnd < 0) return -1; index = genericEnd + 1; while (/\s/.test(source[index] ?? '')) index += 1; } return source[index] === '(' ? index : -1; } function firstCallArgument( source: string, openParen: number, ): { text: string; start: number; end: number } | null { let depth = 0; let quote: string | null = null; let escaped = false; const start = openParen + 1; for (let index = start; index < source.length; index += 1) { const char = source[index]!; if (quote) { if (escaped) { escaped = false; } else if (char === '\\') { escaped = true; } else if (char === quote) { quote = null; } continue; } if (char === '"' || char === "'" || char === '`') { quote = char; continue; } if (char === '(' || char === '[' || char === '{') depth += 1; if (char === ')' && depth === 0) { const text = source.slice(start, index).trim(); return text ? { text, start, end: index } : null; } if (char === ',' && depth === 0) { const text = source.slice(start, index).trim(); return text ? { text, start, end: index } : null; } if (char === ')' || char === ']' || char === '}') depth -= 1; } return null; } function localImportSpecifiers(sourceCode: string): string[] { const source = stripCommentsToSpaces(sourceCode); const specifiers: string[] = []; for (const match of source.matchAll( /\b(?:import|export)\s+(?!type\b)(?:[\s\S]*?\s+from\s*)?['"]([^'"]+)['"]/g, )) { if (match[1]?.startsWith('.')) specifiers.push(match[1]); } for (const match of source.matchAll( /\brequire\s*\(\s*(['"])(\.[^'"]*)\1\s*\)/g, )) { specifiers.push(match[2]!); } return specifiers; } async function fileExists(filePath: string): Promise { try { await stat(filePath); return true; } catch { return false; } } function isPathInsideDirectory(filePath: string, directory: string): boolean { const relativePath = relative(directory, filePath); return ( relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) ); } async function resolveLocalImport( fromFile: string, specifier: string, ): Promise { const base = isAbsolute(specifier) ? resolve(specifier) : resolve(dirname(fromFile), specifier); const candidates: string[] = [base]; const explicitExtension = extname(base).toLowerCase(); if (!explicitExtension) { candidates.push( ...SOURCE_EXTENSIONS.map((extension) => `${base}${extension}`), ); candidates.push( ...SOURCE_EXTENSIONS.map((extension) => join(base, `index${extension}`)), ); } else if (['.js', '.jsx', '.mjs', '.cjs'].includes(explicitExtension)) { const stem = base.slice(0, -explicitExtension.length); candidates.push( ...SOURCE_EXTENSIONS.map((extension) => `${stem}${extension}`), ); } for (const candidate of candidates) { if (await fileExists(candidate)) { return candidate; } } throw new Error( `Could not resolve local import "${specifier}" from ${fromFile}`, ); } export async function discoverPackagedLocalFiles( entryFile: string, ): Promise { const absoluteEntryFile = resolve(entryFile); const packagingRoot = dirname(absoluteEntryFile); const files = new Map(); const unresolved: PlayLocalFileDiscoveryError[] = []; const visitedFiles = new Set(); const visitSourceFile = async (filePath: string): Promise => { const absolutePath = resolve(filePath); if (visitedFiles.has(absolutePath)) { return; } visitedFiles.add(absolutePath); const sourceCode = await readFile(absolutePath, 'utf-8'); if ( extname(absolutePath).toLowerCase() === '.json' || isTextImportFile(absolutePath) ) { return; } const scanSource = stripCommentsToSpaces(sourceCode); const constants = collectTopLevelStringConstants(sourceCode); const childVisits: Promise[] = []; for (const match of scanSource.matchAll( /\b([A-Za-z_$][\w$]*)\s*\.\s*csv\b/g, )) { const target = match[1]!; if (target !== 'ctx' && !target.endsWith('Ctx')) { continue; } const openParen = findCallOpenParen( scanSource, match.index! + match[0].length, ); if (openParen < 0) { continue; } const argument = firstCallArgument(scanSource, openParen); if (!argument) { unresolved.push({ sourceFragment: 'ctx.csv()', message: 'ctx.csv() requires a file path string or input reference.', }); } else if (!isRuntimeInputExpression(argument.text)) { const resolvedPath = resolveStringExpression(argument.text, constants); if (resolvedPath == null) { unresolved.push({ sourceFragment: sourceCode .slice(argument.start, argument.end) .trim(), message: 'Could not resolve this ctx.csv(...) path at submit time. Use a string literal, a top-level const string, or pass a runtime input like input.file.', }); } else { const absoluteCsvPath = resolve(dirname(absolutePath), resolvedPath); if ( isAbsolute(resolvedPath) || !isPathInsideDirectory(absoluteCsvPath, packagingRoot) ) { unresolved.push({ sourceFragment: sourceCode .slice(argument.start, argument.end) .trim(), message: 'ctx.csv(...) packaged file paths must be relative paths inside the play directory. Pass external files at runtime with input.file instead.', }); continue; } const buffer = await readFile(absoluteCsvPath); const stats = await stat(absoluteCsvPath); files.set(absoluteCsvPath, { sourceFragment: sourceCode .slice(argument.start, argument.end) .trim(), logicalPath: resolvedPath, absolutePath: absoluteCsvPath, bytes: stats.size, contentHash: sha256(buffer), contentType: contentTypeForFile(absoluteCsvPath), }); } } } for (const specifier of localImportSpecifiers(sourceCode)) { childVisits.push( resolveLocalImport(absolutePath, specifier).then((resolvedImport) => visitSourceFile(resolvedImport), ), ); } await Promise.all(childVisits); }; await visitSourceFile(absoluteEntryFile); return { files: [...files.values()], unresolved, }; } export function buildPlayStorageKey(input: { orgId: string; contentHash: string; logicalPath: string; }): string { const fileName = basename(input.logicalPath); return `plays/v2/orgs/${input.orgId}/files/${input.contentHash}/${fileName}`; }