import * as ts from "typescript"; import type { HydrationPriority } from "../spec/schema"; const DEFAULT_BOUNDARY_IMPORT = "@mandujs/core/internal/client-boundary"; const BOUNDARY_COMPONENT = "__ManduClientBoundary"; const CLIENT_MODULE_PATTERN = /\.(?:client|island)(?:\.[cm]?[jt]sx?)?$/; export type ClientBoundaryHydrateMode = HydrationPriority | "load" | "manual" | `media(${string})`; export interface ClientBoundaryTransformOptions { routeId: string; fileName?: string; hydrate?: ClientBoundaryHydrateMode; boundaryImport?: string; ordinalOffset?: number; boundaryReplay?: readonly ClientBoundaryReplayRecord[]; } export interface ClientBoundaryReplayRecord { id?: string; ordinal: number; } export interface ClientBoundaryRecord { id: string; routeId: string; module: string; importSpecifier: string; exportName: string; localName: string; hydrate: ClientBoundaryHydrateMode; ordinal: number; propsSource: "inline"; propsKeys: string[]; hasSpreadProps: boolean; source: { file: string; line: number; column: number; }; } export interface ClientBoundaryDiagnostic { code: | "MANDU_BOUNDARY_UNSUPPORTED_CHILDREN" | "MANDU_BOUNDARY_UNSUPPORTED_FUNCTION_PROP" | "MANDU_BOUNDARY_UNSUPPORTED_PROP_VALUE" | "MANDU_BOUNDARY_UNSUPPORTED_REF" | "MANDU_BOUNDARY_INVALID_HOST_CONTEXT" | "MANDU_BOUNDARY_SERVER_ONLY_IMPORT" | "MANDU_BOUNDARY_UNRESOLVED_EXPORT"; severity: "error"; message: string; suggestion: string; routeId: string; boundaryId?: string; module?: string; exportName?: string; source: { file: string; line: number; column: number; }; } export interface ClientBoundaryTransformResult { code: string; transformed: boolean; boundaries: ClientBoundaryRecord[]; diagnostics: ClientBoundaryDiagnostic[]; } export type ClientBoundaryExportValidationStatus = "found" | "missing" | "unknown"; const SERVER_ONLY_MODULE_SPECIFIERS = new Set([ "async_hooks", "assert", "buffer", "child_process", "cluster", "console", "constants", "crypto", "dgram", "diagnostics_channel", "dns", "domain", "events", "fs", "http", "http2", "https", "inspector", "module", "net", "os", "path", "perf_hooks", "process", "punycode", "querystring", "readline", "repl", "stream", "string_decoder", "timers", "tls", "tty", "url", "util", "v8", "vm", "wasi", "worker_threads", "zlib", ]); const INVALID_BOUNDARY_HOST_CONTEXTS = new Set([ "table", "thead", "tbody", "tfoot", "tr", "colgroup", "select", "optgroup", "option", "ul", "ol", "dl", "p", ]); interface ClientImportBinding { module: string; exportName: string; localName: string; } interface NamespaceImportBinding { module: string; namespace: string; } interface CollectedClientImports { importsByLocalName: Map; namespaceImports: Map; removableImports: Set; hasBoundaryImport: boolean; } export function transformClientBoundaries( source: string, options: ClientBoundaryTransformOptions, ): ClientBoundaryTransformResult { const fileName = options.fileName ?? "route.tsx"; const hydrate = options.hydrate ?? "visible"; const boundaryImport = options.boundaryImport ?? DEFAULT_BOUNDARY_IMPORT; const ordinalOffset = options.ordinalOffset ?? 0; const sourceFile = ts.createSourceFile( fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX, ); const collected = collectClientImports(sourceFile, boundaryImport); if (collected.importsByLocalName.size === 0 && collected.namespaceImports.size === 0) { return { code: source, transformed: false, boundaries: [], diagnostics: [] }; } const boundaries: ClientBoundaryRecord[] = []; const diagnostics: ClientBoundaryDiagnostic[] = []; const transformer: ts.TransformerFactory = (context) => { const visitor: ts.Visitor = (node) => { if (ts.isImportDeclaration(node) && collected.removableImports.has(node)) { return undefined; } if (ts.isJsxSelfClosingElement(node)) { const binding = resolveClientBinding(node.tagName, collected); if (binding) { pushInvalidHostContextDiagnostic({ element: node, binding, routeId: options.routeId, sourceFile, fileName, diagnostics, }); return createBoundaryElement({ element: node, attributes: node.attributes, binding, routeId: options.routeId, hydrate, ordinalOffset, boundaryReplay: options.boundaryReplay, fileName, sourceFile, boundaries, diagnostics, }); } } if (ts.isJsxElement(node)) { const binding = resolveClientBinding(node.openingElement.tagName, collected); if (binding) { pushInvalidHostContextDiagnostic({ element: node.openingElement, binding, routeId: options.routeId, sourceFile, fileName, diagnostics, }); const meaningfulChildren = node.children.filter(isMeaningfulJsxChild); if (meaningfulChildren.length > 0) { diagnostics.push(createDiagnostic( "MANDU_BOUNDARY_UNSUPPORTED_CHILDREN", "Client boundary transform currently supports self-closing client components only. Move children into serializable props or use an explicit island API.", "Remove the children from this client component and pass plain serializable data as props, or keep this case on the explicit island API until server slots are supported.", options.routeId, undefined, binding.module, binding.exportName, sourceFile, fileName, node, )); } return createBoundaryElement({ element: node.openingElement, attributes: node.openingElement.attributes, binding, routeId: options.routeId, hydrate, ordinalOffset, boundaryReplay: options.boundaryReplay, fileName, sourceFile, boundaries, diagnostics, }); } } return ts.visitEachChild(node, visitor, context); }; return (node) => ts.visitNode(node, visitor) as ts.SourceFile; }; const result = ts.transform(sourceFile, [transformer]); const transformedSource = result.transformed[0] as ts.SourceFile; const withBoundaryImport = boundaries.length > 0 && !collected.hasBoundaryImport ? addBoundaryImport(transformedSource, boundaryImport) : transformedSource; const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); const code = printer.printFile(withBoundaryImport); result.dispose(); return { code, transformed: boundaries.length > 0, boundaries, diagnostics, }; } function pushInvalidHostContextDiagnostic(args: { element: ts.Node; binding: ClientImportBinding; routeId: string; sourceFile: ts.SourceFile; fileName: string; diagnostics: ClientBoundaryDiagnostic[]; }): void { const invalidContext = findInvalidBoundaryHostContext(args.element); if (!invalidContext) return; args.diagnostics.push(createDiagnostic( "MANDU_BOUNDARY_INVALID_HOST_CONTEXT", `Client boundary transform cannot emit its placeholder inside <${invalidContext.tagName}> because the SSR marker uses sibling
and