import type { Handler, HandlerFactory, JSONSchema, Module, ModuleFactory, NodeRef, Opaque, OpaqueCell, OpaqueRef, Schema, SchemaWithoutCell, Stream, StripCell, toJSON, } from "./types.ts"; import { opaqueRef, stream } from "./opaque-ref.ts"; import { applyArgumentIfcToResult, connectInputAndOutputs, } from "./node-utils.ts"; import { moduleToJSON } from "./json-utils.ts"; import { getTopFrame } from "./recipe.ts"; import { generateHandlerSchema } from "../schema.ts"; export function createNodeFactory( moduleSpec: Module, ): ModuleFactory { // Attach source location and preview to function implementations for debugging if (typeof moduleSpec.implementation === "function") { const location = getExternalSourceLocation(); if (location) { Object.defineProperty(moduleSpec.implementation, "name", { value: location, configurable: true, }); // Also set .src as backup (name can be finicky) (moduleSpec.implementation as { src?: string }).src = location; } // Store function body preview for hover tooltips const fnStr = moduleSpec.implementation.toString(); (moduleSpec.implementation as { preview?: string }).preview = fnStr.slice( 0, 200, ); } const module: Module & toJSON = { ...moduleSpec, toJSON: () => moduleToJSON(module), }; // A module with ifc classification on its argument schema should have at least // that value on its result schema module.resultSchema = applyArgumentIfcToResult( module.argumentSchema, module.resultSchema, ); return Object.assign((inputs: Opaque): OpaqueRef => { const outputs = opaqueRef(); const node: NodeRef = { module, inputs, outputs, frame: getTopFrame() }; connectInputAndOutputs(node); (outputs as OpaqueCell).connect(node); return outputs; }, module); } /** Extract file path and location from a stack frame line * Handles formats like: * " at functionName (file:///path/to/file.ts:42:15)" * " at file:///path/to/file.ts:42:15" * " at functionName (http://localhost:8000/scripts/index.js:250239:17)" * " at Object.eval [as factory] (somehash.js:52:52)" * @internal Exported for testing */ export function parseStackFrame( line: string, ): { file: string; line: number; col: number } | null { // Try to match file path inside parentheses first (most common format) // Handles: "at functionName (file:///path:42:15)" or "(http://url:42:15)" let match = line.match(/\((.+):(\d+):(\d+)\)\s*$/); // If no match, try to match after "at " without parentheses // Handles: "at file:///path:42:15" or "at http://url:42:15" if (!match) { match = line.match(/at\s+(.+):(\d+):(\d+)\s*$/); } if (!match) return null; const [, filePath, lineNum, col] = match; return { file: filePath.replace(/^file:\/\//, ""), line: parseInt(lineNum, 10), col: parseInt(col, 10), }; } /** Extract the first source location from a stack trace that isn't from this file */ function getExternalSourceLocation(): string | null { const stack = new Error().stack; if (!stack) return null; const lines = stack.split("\n"); // Find this file from the first real stack frame let thisFile: string | null = null; for (const line of lines) { const frame = parseStackFrame(line); if (frame) { thisFile = frame.file; break; } } if (!thisFile) return null; // Find first frame not from this file for (const line of lines) { const frame = parseStackFrame(line); if (frame && frame.file !== thisFile) { return `${frame.file}:${frame.line}:${frame.col}`; } } return null; } /** Declare a module * * @param implementation A function that takes an input and returns a result * * @returns A module node factory that also serializes as module. */ export function lift< T extends JSONSchema = JSONSchema, R extends JSONSchema = JSONSchema, >( argumentSchema: T, resultSchema: R, implementation: (input: Schema) => Schema, ): ModuleFactory, SchemaWithoutCell>; export function lift( implementation: (input: T) => R, ): ModuleFactory; export function lift( implementation: (input: T) => any, ): ModuleFactory>; export function lift any>( implementation: T, ): ModuleFactory[0], ReturnType>; export function lift( argumentSchema?: JSONSchema | ((input: any) => any), resultSchema?: JSONSchema, implementation?: (input: T) => R, ): ModuleFactory { if (typeof argumentSchema === "function") { implementation = argumentSchema; argumentSchema = resultSchema = undefined; } return createNodeFactory({ type: "javascript", implementation, ...(argumentSchema !== undefined ? { argumentSchema } : {}), ...(resultSchema !== undefined ? { resultSchema } : {}), }); } export function byRef(ref: string): ModuleFactory { return createNodeFactory({ type: "ref", implementation: ref, }); } function handlerInternal( eventSchema: | JSONSchema | ((event: E, props: T) => any) | undefined, stateSchema?: JSONSchema | { proxy: true }, handler?: (event: E, props: T) => any, ): HandlerFactory { if (typeof eventSchema === "function") { if ( stateSchema && typeof stateSchema === "object" && "proxy" in stateSchema && stateSchema.proxy === true ) { handler = eventSchema; eventSchema = stateSchema = undefined; } else { throw new Error( "Handler requires schemas or CTS transformer\n" + "help: enable CTS with /// for automatic schema inference, or provide explicit schemas", ); } } // Attach source location and preview to handler function for debugging if (typeof handler === "function") { const location = getExternalSourceLocation(); if (location) { Object.defineProperty(handler, "name", { value: location, configurable: true, }); // Also set .src as backup (name can be finicky) (handler as { src?: string }).src = location; } // Store function body preview for hover tooltips const fnStr = handler.toString(); (handler as { preview?: string }).preview = fnStr.slice(0, 200); } const schema = generateHandlerSchema( eventSchema, stateSchema as JSONSchema | undefined, ); const module: Handler & toJSON & { bind: (inputs: Opaque>) => Stream; } = { type: "javascript", implementation: handler, wrapper: "handler", with: (inputs: Opaque>) => factory(inputs), // Overriding the default `bind` method on functions. The wrapper will bind // the actual inputs, so they'll be available as `this` bind: (inputs: Opaque>) => factory(inputs), toJSON: () => moduleToJSON(module), ...(schema !== undefined ? { argumentSchema: schema } : {}), }; const factory = Object.assign((props: Opaque>): Stream => { const eventStream = stream(eventSchema); // Set stream marker (cast to E as stream is typed for the events it accepts) const node: NodeRef = { module, inputs: { $ctx: props, $event: eventStream }, outputs: {}, frame: getTopFrame(), }; connectInputAndOutputs(node); return eventStream; }, module); return factory; } export function handler< E extends JSONSchema = JSONSchema, T extends JSONSchema = JSONSchema, >( eventSchema: E, stateSchema: T, handler: (event: Schema, props: Schema) => any, ): HandlerFactory, SchemaWithoutCell>; export function handler( eventSchema: JSONSchema, stateSchema: JSONSchema, handler: (event: E, props: T) => any, ): HandlerFactory; export function handler( handler: (Event: E, props: T) => any, options: { proxy: true }, ): HandlerFactory; export function handler( handler: (event: E, props: T) => any, ): HandlerFactory; export function handler( eventSchema: | JSONSchema | ((event: E, props: T) => any) | undefined, stateSchema?: JSONSchema | { proxy: true }, handler?: (event: E, props: T) => any, ): HandlerFactory { return handlerInternal(eventSchema, stateSchema, handler); } export function derive< InputSchema extends JSONSchema = JSONSchema, ResultSchema extends JSONSchema = JSONSchema, >( argumentSchema: InputSchema, resultSchema: ResultSchema, input: Opaque>, f: ( input: Schema, ) => Schema, ): OpaqueRef>; export function derive( input: Opaque, f: (input: In) => Out, ): OpaqueRef; export function derive(...args: any[]): OpaqueRef { if (args.length === 4) { const [argumentSchema, resultSchema, input, f] = args as [ JSONSchema, JSONSchema, Opaque>, (input: Schema) => Schema, ]; return lift( argumentSchema, resultSchema, f as (input: Schema) => Schema, )(input); } const [input, f] = args as [ Opaque, (input: In) => Out, ]; return lift(f)(input); } // unsafe closures: like derive, but doesn't need any arguments export const computed: (fn: () => T) => OpaqueRef = (fn: () => T) => lift(fn)(undefined); /** * action: Creates a handler that doesn't use the state parameter. * * This is to handler as computed is to lift/derive: * - User writes: action((e) => count.set(e.data)) * - Transformer rewrites to: handler((e, { count }) => count.set(e.data))({ count }) * * The transformer extracts closures and makes them explicit, just like how * computed(() => expr) becomes derive({}, () => expr) with closure extraction. * * NOTE: This function should never be called directly at runtime because the * CTS transformer rewrites action() calls to handler() calls. If this function * is reached, it means CTS is not enabled. * * @param _event - A function that receives an event and performs side effects * @throws Error if called directly (CTS must be enabled for action() to work) */ export function action( _event: (event: T) => void, ): HandlerFactory; export function action( _event: (event: T) => void, ): HandlerFactory { throw new Error( "action() must be used with CTS enabled - add /// to your file", ); }