The Coline App SDK: apps, tools, file types and surfaces that run inside Coline workspaces, and public websites with Coline Sites. TypeScript, with an in-memory test workspace. MIT.
| 4 | import type { AppEndpointInput } from "../manifest/endpoints"; |
| 5 | import type { AppFileTypeV2Input } from "../manifest/file-type"; |
| 6 | import type { AppJobDefinitionInput } from "../manifest/jobs"; |
| 7 | import { colineAppManifestV2Schema } from "../manifest/manifest"; |
| 8 | import { extractManifestInput } from "./extraction"; |
| 9 | import type { |
| 10 | AnyColineJobDefinitionV2, |
| 11 | AnyColineToolDefinitionV2, |
| 12 | ColineAppConfigV2, |
| 13 | ColineAppV2, |
| 14 | ColineCommandDefinitionV2, |
| 15 | ColineEndpointDefinitionV2, |
| 16 | ColineEndpointHandler, |
| 17 | ColineFileTypeDefinitionV2, |
| 18 | ColineFileTypeRuntime, |
| 19 | ColineJobContextV2, |
| 20 | ColineJobDefinitionV2, |
| 21 | ColineJobOutcome, |
| 22 | ColineToolContext, |
| 23 | ColineToolDefinitionV2, |
| 24 | ColineToolResult, |
| 25 | } from "./types"; |
| 26 |
| 27 | /** |
| 28 | * `defineApp` and friends (plan §6.1). |
| 29 | * |
| 30 | * `defineApp` performs extraction and validates the manifest eagerly, so a |
| 31 | * mis-declared app fails at module evaluation in development and in the |
| 32 | * restricted build environment — never at install time. |
| 33 | */ |
| 34 |
| 35 | export class ColineAppDefinitionError extends Error { |
| 36 | readonly issues: readonly string[]; |
| 37 |
| 38 | constructor(appKey: string, issues: readonly string[]) { |
| 39 | super( |
| 40 | `App definition "${appKey}" is invalid:\n${issues |
| 41 | .map((issue) => ` - ${issue}`) |
| 42 | .join("\n")}`, |
| 43 | ); |
| 44 | this.name = "ColineAppDefinitionError"; |
| 45 | this.issues = issues; |
| 46 | } |
| 47 | } |
| 48 |
| 49 | export function defineTool<TInput, const TName extends string = string, TOutput = unknown>(definition: { |
| 50 | name: TName; |
| 51 | description: string; |
| 52 | input: z.ZodType<TInput>; |
| 53 | effect: ToolEffect; |
| 54 | execute: ( |
| 55 | input: TInput, |
| 56 | context: ColineToolContext, |
| 57 | ) => Promise<ColineToolResult<TOutput>> | ColineToolResult<TOutput>; |
| 58 | }): ColineToolDefinitionV2<TInput, TName, TOutput> { |
| 59 | return Object.freeze({ kind: "tool" as const, ...definition }); |
| 60 | } |
| 61 |
| 62 | export function defineFileType(definition: { |
| 63 | metadata: AppFileTypeV2Input; |
| 64 | runtime?: ColineFileTypeRuntime; |
| 65 | }): ColineFileTypeDefinitionV2 { |
| 66 | return Object.freeze({ |
| 67 | kind: "fileType" as const, |
| 68 | metadata: definition.metadata, |
| 69 | runtime: definition.runtime ?? {}, |
| 70 | }); |
| 71 | } |
| 72 |
| 73 | export function defineCommand(command: AppCommand): ColineCommandDefinitionV2 { |
| 74 | return Object.freeze({ kind: "command" as const, command }); |
| 75 | } |
| 76 |
| 77 | /** |
| 78 | * An inbound HTTP endpoint. The metadata becomes `manifest.endpoints[]`; |
| 79 | * `handle` runs after Coline verifies the request. |
| 80 | */ |
| 81 | export function defineEndpoint( |
| 82 | definition: AppEndpointInput & { handle: ColineEndpointHandler }, |
| 83 | ): ColineEndpointDefinitionV2 { |
| 84 | const { handle, ...metadata } = definition; |
| 85 | return Object.freeze({ kind: "endpoint" as const, metadata, handle }); |
| 86 | } |
| 87 |
| 88 | /** |
| 89 | * A background job. The metadata becomes `manifest.jobs[]`; `run` executes |
| 90 | * on Coline's job workers. Give `payload` a zod schema to validate what |
| 91 | * `coline.jobs.enqueue` passes before each attempt. |
| 92 | */ |
| 93 | export function defineJob<TPayload = Record<string, unknown>>( |
| 94 | definition: AppJobDefinitionInput & { |
| 95 | payload?: z.ZodType<TPayload>; |
| 96 | run: ( |
| 97 | payload: TPayload, |
| 98 | context: ColineJobContextV2, |
| 99 | ) => Promise<ColineJobOutcome> | ColineJobOutcome; |
| 100 | }, |
| 101 | ): ColineJobDefinitionV2<TPayload> { |
| 102 | const { run, payload, ...metadata } = definition; |
| 103 | return Object.freeze({ kind: "job" as const, metadata, payload: payload ?? null, run }); |
| 104 | } |
| 105 |
| 106 | export function defineApp<TConfig extends ColineAppConfigV2>(config: TConfig): ColineAppV2<TConfig> { |
| 107 | const manifestInput = extractManifestInput(config); |
| 108 | const parsed = colineAppManifestV2Schema.safeParse(manifestInput); |
| 109 | if (!parsed.success) { |
| 110 | throw new ColineAppDefinitionError( |
| 111 | typeof config.key === "string" ? config.key : "(unknown)", |
| 112 | parsed.error.issues.map( |
| 113 | (issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`, |
| 114 | ), |
| 115 | ); |
| 116 | } |
| 117 | const manifest = parsed.data; |
| 118 |
| 119 | const tools = new Map<string, AnyColineToolDefinitionV2>(); |
| 120 | for (const tool of config.tools ?? []) { |
| 121 | tools.set(tool.name, tool); |
| 122 | } |
| 123 |
| 124 | const fileTypes = new Map<string, ColineFileTypeRuntime>(); |
| 125 | const runtimeIssues: string[] = []; |
| 126 | for (const fileType of config.files ?? []) { |
| 127 | fileTypes.set(fileType.metadata.typeKey, fileType.runtime); |
| 128 | } |
| 129 |
| 130 | // Runtime completeness: every tree-tier surface needs its handler. |
| 131 | const manifestFilesByKey = new Map(manifest.files.map((file) => [file.typeKey, file])); |
| 132 | for (const [typeKey, runtime] of fileTypes) { |
| 133 | const declared = manifestFilesByKey.get(typeKey); |
| 134 | if (!declared) continue; |
| 135 | if (declared.surfaces.editor?.tier === "tree" && !runtime.renderEditor) { |
| 136 | runtimeIssues.push(`files.${typeKey}: tree editor surface declared without a renderEditor handler.`); |
| 137 | } |
| 138 | if (declared.surfaces.preview && !runtime.renderPreview) { |
| 139 | runtimeIssues.push(`files.${typeKey}: preview surface declared without a renderPreview handler.`); |
| 140 | } |
| 141 | if (declared.surfaces.inline && !runtime.renderInline) { |
| 142 | runtimeIssues.push(`files.${typeKey}: inline surface declared without a renderInline handler.`); |
| 143 | } |
| 144 | if (declared.indexable && declared.storage !== "external_pointer" && !runtime.index) { |
| 145 | runtimeIssues.push(`files.${typeKey}: indexable file types need index extractors (or set indexable: false).`); |
| 146 | } |
| 147 | } |
| 148 | if (manifest.surfaces.home?.tier === "tree" && !config.handlers?.renderHome) { |
| 149 | runtimeIssues.push("surfaces.home: tree home surface declared without a handlers.renderHome."); |
| 150 | } |
| 151 | if ( |
| 152 | manifest.triggers.schedules.length > 0 && |
| 153 | manifest.hosting.default === "coline" && |
| 154 | !config.handlers?.onSchedule |
| 155 | ) { |
| 156 | runtimeIssues.push( |
| 157 | "triggers.schedules: Coline-hosted schedules declared without a handlers.onSchedule.", |
| 158 | ); |
| 159 | } |
| 160 | const eventsHosted = (manifest.hosting.overrides["events"] ?? manifest.hosting.default) === "coline"; |
| 161 | if (manifest.triggers.webhooks.length > 0 && eventsHosted && !config.handlers?.onEvent) { |
| 162 | runtimeIssues.push( |
| 163 | "triggers.webhooks: Coline-hosted event subscriptions declared without a handlers.onEvent.", |
| 164 | ); |
| 165 | } |
| 166 |
| 167 | const endpoints = new Map<string, ColineEndpointHandler>(); |
| 168 | for (const endpoint of config.endpoints ?? []) { |
| 169 | const id = endpoint.metadata.id; |
| 170 | if (endpoints.has(id)) runtimeIssues.push(`endpoints.${id}: defined more than once.`); |
| 171 | endpoints.set(id, endpoint.handle); |
| 172 | } |
| 173 | const jobs = new Map<string, AnyColineJobDefinitionV2>(); |
| 174 | for (const job of config.jobs ?? []) { |
| 175 | if (jobs.has(job.metadata.name)) runtimeIssues.push(`jobs.${job.metadata.name}: defined more than once.`); |
| 176 | jobs.set(job.metadata.name, job); |
| 177 | } |
| 178 | if (runtimeIssues.length > 0) { |
| 179 | throw new ColineAppDefinitionError(manifest.key, runtimeIssues); |
| 180 | } |
| 181 |
| 182 | const collectionSchemas = new Map<string, z.ZodType<Record<string, unknown>>>(); |
| 183 | for (const [name, collection] of Object.entries(config.collections ?? {})) { |
| 184 | if (collection.schema) collectionSchemas.set(name, collection.schema); |
| 185 | } |
| 186 |
| 187 | return Object.freeze({ |
| 188 | kind: "app" as const, |
| 189 | manifest, |
| 190 | runtime: Object.freeze({ |
| 191 | tools, |
| 192 | fileTypes, |
| 193 | endpoints, |
| 194 | jobs, |
| 195 | collectionSchemas, |
| 196 | renderHome: config.handlers?.renderHome ?? null, |
| 197 | onSchedule: config.handlers?.onSchedule ?? null, |
| 198 | onEvent: config.handlers?.onEvent ?? null, |
| 199 | }), |
| 200 | }); |
| 201 | } |
| 202 |