| 1 | /** |
| 2 | * Builds self-installing modules from ahead-of-time compiled Schema decoders. |
| 3 | * |
| 4 | * @stability unstable |
| 5 | * @since 4.0.0 |
| 6 | */ |
| 7 | import * as Data from "../../Data.ts" |
| 8 | import * as Effect from "../../Effect.ts" |
| 9 | import * as FileSystem from "../../FileSystem.ts" |
| 10 | import * as Path from "../../Path.ts" |
| 11 | import type * as PlatformError from "../../PlatformError.ts" |
| 12 | import * as Schema from "../../Schema.ts" |
| 13 | import * as SchemaAST from "../../SchemaAST.ts" |
| 14 | import * as SchemaAOTCompiler from "../SchemaAOTCompiler.ts" |
| 15 | |
| 16 | /** |
| 17 | * An operation whose root AST should be prepared by {@link build}. |
| 18 | * |
| 19 | * @stability unstable |
| 20 | * @category models |
| 21 | * @since 4.0.0 |
| 22 | */ |
| 23 | export type Operation = "decode" | "encode" | "is" | "make" |
| 24 | |
| 25 | /** |
| 26 | * Loads the exports of a schema module during a build. |
| 27 | * |
| 28 | * @stability unstable |
| 29 | * @category models |
| 30 | * @since 4.0.0 |
| 31 | */ |
| 32 | export interface ModuleLoader { |
| 33 | (): PromiseLike<unknown> |
| 34 | } |
| 35 | |
| 36 | /** |
| 37 | * Options for {@link build}. |
| 38 | * |
| 39 | * @stability unstable |
| 40 | * @category models |
| 41 | * @since 4.0.0 |
| 42 | */ |
| 43 | export interface BuildOptions { |
| 44 | /** |
| 45 | * Maps import specifiers relative to `baseUrl` to loaders for those same |
| 46 | * modules. Only directly exported Schema values are compiled. |
| 47 | */ |
| 48 | readonly modules: Readonly<Record<string, ModuleLoader>> |
| 49 | /** The URL against which relative module specifiers are resolved. */ |
| 50 | readonly baseUrl: string | URL |
| 51 | /** The file-system path of the generated module. */ |
| 52 | readonly outFile: string |
| 53 | /** The parser operations to prepare. Defaults to decoding. */ |
| 54 | readonly operations?: ReadonlyArray<Operation> | undefined |
| 55 | } |
| 56 | |
| 57 | /** |
| 58 | * A summary of a completed {@link build}. |
| 59 | * |
| 60 | * @stability unstable |
| 61 | * @category models |
| 62 | * @since 4.0.0 |
| 63 | */ |
| 64 | export interface BuildResult { |
| 65 | /** The absolute path written by the build. */ |
| 66 | readonly outFile: string |
| 67 | /** The number of loaded modules containing at least one Schema export. */ |
| 68 | readonly modules: number |
| 69 | /** The number of directly exported Schema values found in those modules. */ |
| 70 | readonly schemas: number |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * An error raised while loading modules or generating an AOT module. |
| 75 | * |
| 76 | * @stability unstable |
| 77 | * @category errors |
| 78 | * @since 4.0.0 |
| 79 | */ |
| 80 | export class BuildError extends Data.TaggedError("BuildError")<{ |
| 81 | readonly _tag: "BuildError" |
| 82 | readonly cause: unknown |
| 83 | readonly kind: "Generate" | "InvalidModule" | "LoadModule" | "ResolveModule" |
| 84 | readonly message: string |
| 85 | readonly module?: string | undefined |
| 86 | }> {} |
| 87 | |
| 88 | interface ExportedSchema { |
| 89 | readonly alias: string |
| 90 | readonly exportName: string |
| 91 | readonly schema: Schema.Top |
| 92 | } |
| 93 | |
| 94 | const operationOrder: ReadonlyArray<Operation> = ["decode", "encode", "is", "make"] |
| 95 | |
| 96 | const importPath = ( |
| 97 | path: Path.Path, |
| 98 | outFile: string, |
| 99 | baseUrl: string | URL, |
| 100 | specifier: string |
| 101 | ): Effect.Effect<string, BuildError> => |
| 102 | Effect.gen(function*() { |
| 103 | const url = yield* Effect.try({ |
| 104 | try: () => new URL(specifier, baseUrl), |
| 105 | catch: (cause) => |
| 106 | new BuildError({ |
| 107 | cause, |
| 108 | kind: "ResolveModule", |
| 109 | message: `Could not resolve schema module ${JSON.stringify(specifier)}`, |
| 110 | module: specifier |
| 111 | }) |
| 112 | }) |
| 113 | const sourcePath = yield* path.fromFileUrl(url).pipe( |
| 114 | Effect.mapError((cause) => |
| 115 | new BuildError({ |
| 116 | cause, |
| 117 | kind: "ResolveModule", |
| 118 | message: `Schema module ${JSON.stringify(specifier)} does not resolve to a file`, |
| 119 | module: specifier |
| 120 | }) |
| 121 | ) |
| 122 | ) |
| 123 | const relative = path.relative(path.dirname(outFile), sourcePath) |
| 124 | if (path.isAbsolute(relative)) { |
| 125 | return yield* new BuildError({ |
| 126 | cause: undefined, |
| 127 | kind: "ResolveModule", |
| 128 | message: `Schema module ${JSON.stringify(specifier)} cannot be imported relative to the output file`, |
| 129 | module: specifier |
| 130 | }) |
| 131 | } |
| 132 | const normalized = path.sep === "/" ? relative : relative.split(path.sep).join("/") |
| 133 | return normalized.startsWith(".") ? normalized : `./${normalized}` |
| 134 | }) |
| 135 | |
| 136 | const root = ( |
| 137 | exported: ExportedSchema, |
| 138 | operation: Operation |
| 139 | ): readonly [ |
| 140 | ast: SchemaAST.AST, |
| 141 | operation: SchemaAOTCompiler.Operation, |
| 142 | expression: string, |
| 143 | derived: boolean |
| 144 | ] => { |
| 145 | const expression = `${exported.alias}[${JSON.stringify(exported.exportName)}].ast` |
| 146 | switch (operation) { |
| 147 | case "decode": |
| 148 | return [exported.schema.ast, "decode", expression, false] |
| 149 | case "encode": { |
| 150 | const ast = SchemaAST.flip(exported.schema.ast) |
| 151 | return ast === exported.schema.ast |
| 152 | ? [ast, "decode", expression, false] |
| 153 | : [ast, "decode", `A.flip(${expression})`, true] |
| 154 | } |
| 155 | case "is": |
| 156 | case "make": { |
| 157 | const ast = SchemaAST.toType(exported.schema.ast) |
| 158 | return ast === exported.schema.ast |
| 159 | ? [ast, operation, expression, false] |
| 160 | : [ast, operation, `A.toType(${expression})`, true] |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | /** |
| 166 | * Writes a self-installing AOT module for Schema values exported by a set of |
| 167 | * modules. |
| 168 | * |
| 169 | * **When to use** |
| 170 | * |
| 171 | * Use when a build script should let an application install generated decoders |
| 172 | * by importing one generated module at startup. |
| 173 | * |
| 174 | * **Details** |
| 175 | * |
| 176 | * Module keys are import specifiers relative to `baseUrl`. Their loaders run |
| 177 | * sequentially during the build. The builder sorts module specifiers and export |
| 178 | * names, compiles direct Schema exports, and writes deterministic source using |
| 179 | * `FileSystem`. The generated module imports those exports and installs their |
| 180 | * decoders in the shared Schema parser registry as a module side effect. A lazy |
| 181 | * record returned by `import.meta.glob` can be passed as `modules` directly. |
| 182 | * |
| 183 | * Decoding is prepared by default. `is` and `make` prepare the type-side AST; |
| 184 | * `encode` prepares the flipped AST. Nested dependencies are discovered by the |
| 185 | * AOT compiler and do not need separate exports. |
| 186 | * |
| 187 | * **Gotchas** |
| 188 | * |
| 189 | * Loaders execute application modules during the build. Each loader must return |
| 190 | * the same module named by its key. The generated file must be rebuilt after a |
| 191 | * schema definition or Effect version changes. Unsupported operations retain |
| 192 | * the interpreter fallback. Build configurations that mark modules as |
| 193 | * side-effect free must retain the generated import. |
| 194 | * |
| 195 | * @stability unstable |
| 196 | * @category compilation |
| 197 | * @since 4.0.0 |
| 198 | */ |
| 199 | export const build: ( |
| 200 | options: BuildOptions |
| 201 | ) => Effect.Effect< |
| 202 | BuildResult, |
| 203 | BuildError | PlatformError.PlatformError, |
| 204 | FileSystem.FileSystem | Path.Path |
| 205 | > = Effect.fnUntraced(function*(options) { |
| 206 | const fs = yield* FileSystem.FileSystem |
| 207 | const path = yield* Path.Path |
| 208 | const outFile = path.resolve(options.outFile) |
| 209 | const requested = new Set(options.operations ?? ["decode"]) |
| 210 | const operations = operationOrder.filter((operation) => requested.has(operation)) |
| 211 | const imports: Array<string> = [] |
| 212 | const exportedSchemas: Array<ExportedSchema> = [] |
| 213 | |
| 214 | for (const specifier of Object.keys(options.modules).sort()) { |
| 215 | const loader = options.modules[specifier]! |
| 216 | const loaded = yield* Effect.tryPromise({ |
| 217 | try: () => loader(), |
| 218 | catch: (cause) => |
| 219 | new BuildError({ |
| 220 | cause, |
| 221 | kind: "LoadModule", |
| 222 | message: `Could not load schema module ${JSON.stringify(specifier)}`, |
| 223 | module: specifier |
| 224 | }) |
| 225 | }) |
| 226 | if (typeof loaded !== "object" || loaded === null) { |
| 227 | return yield* new BuildError({ |
| 228 | cause: loaded, |
| 229 | kind: "InvalidModule", |
| 230 | message: `Schema module ${JSON.stringify(specifier)} did not load a module namespace`, |
| 231 | module: specifier |
| 232 | }) |
| 233 | } |
| 234 | const namespace = loaded as Readonly<Record<string, unknown>> |
| 235 | const exports = Object.keys(namespace).filter((name) => Schema.isSchema(namespace[name])).sort() |
| 236 | if (exports.length === 0) continue |
| 237 | const alias = `m${imports.length}` |
| 238 | const specifierFromOutput = yield* importPath(path, outFile, options.baseUrl, specifier) |
| 239 | imports.push(`import * as ${alias} from ${JSON.stringify(specifierFromOutput)};`) |
| 240 | for (const exportName of exports) { |
| 241 | exportedSchemas.push({ |
| 242 | alias, |
| 243 | exportName, |
| 244 | schema: namespace[exportName] as Schema.Top |
| 245 | }) |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | const source = yield* Effect.try({ |
| 250 | try: () => { |
| 251 | const targets: Array<{ |
| 252 | readonly ast: SchemaAST.AST |
| 253 | readonly operations: Array<SchemaAOTCompiler.Operation> |
| 254 | readonly expression: string |
| 255 | }> = [] |
| 256 | const seen = new Map<SchemaAST.AST, number>() |
| 257 | let needsSchemaASTImport = false |
| 258 | for (const exported of exportedSchemas) { |
| 259 | for (const operation of operations) { |
| 260 | const [ast, targetOperation, expression, isDerived] = root(exported, operation) |
| 261 | const index = seen.get(ast) |
| 262 | if (index !== undefined) { |
| 263 | const targetOperations = targets[index].operations |
| 264 | if (!targetOperations.includes(targetOperation)) targetOperations.push(targetOperation) |
| 265 | continue |
| 266 | } |
| 267 | seen.set(ast, targets.length) |
| 268 | targets.push({ ast, operations: [targetOperation], expression }) |
| 269 | needsSchemaASTImport ||= isDerived |
| 270 | } |
| 271 | } |
| 272 | return [ |
| 273 | ...imports, |
| 274 | ...(needsSchemaASTImport ? ["import * as A from \"effect/SchemaAST\";"] : []), |
| 275 | SchemaAOTCompiler.compile(targets), |
| 276 | `install([${targets.map((target) => target.expression).join(",")}]);`, |
| 277 | "" |
| 278 | ].join("\n") |
| 279 | }, |
| 280 | catch: (cause) => |
| 281 | new BuildError({ |
| 282 | cause, |
| 283 | kind: "Generate", |
| 284 | message: "Could not generate the Schema AOT module" |
| 285 | }) |
| 286 | }) |
| 287 | |
| 288 | yield* fs.makeDirectory(path.dirname(outFile), { recursive: true }) |
| 289 | yield* fs.writeFileString(outFile, source) |
| 290 | return { |
| 291 | outFile, |
| 292 | modules: imports.length, |
| 293 | schemas: exportedSchemas.length |
| 294 | } |
| 295 | }) |