From 048b869b746aeab7ca51f3583eaabd098a1189cd Mon Sep 17 00:00:00 2001 From: Vasek - Tom C Date: Tue, 11 Aug 2026 14:16:58 +0200 Subject: [PATCH] feat(codegen): render the module entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the entrypoint renderer, ported with the client codegen but never reachable, to a subcommand. Unlike the binding generators it never sees the schema: it works from the typedef JSON the SDK introspector emits by scanning the user's own source, which is what carries the per-declaration locations the dispatcher imports classes from. The golden is the introspector's real output, not hand-written JSON — captured by scanning a module that exercises what dispatch has to handle: a constructor with a defaulted argument, exposed fields (including an object one, which round-trips through an ID), an optional argument, an async method and a void return. Upstream has no test for this renderer at all, here or in dagger/dagger, so this is the first thing pinning it. Producing that fixture surfaced two constraints on how the compiler reaches the scanner, both now recorded in the design: it must be the full package, since a trimmed one loses lib/lib.*.d.ts and with it every global type (any module returning Promise fails, confusingly, as "could not resolve type reference for string"); and it must sit next to introspector.js, since bare imports resolve from the importing file rather than the working directory. The third schema-driven mode makes the repetition worth removing: library, module and client each read the schema, build the generator, render and write an overlay, differing only in the config they contribute and the method they call. Factored into generateFromSchema, with each mode passing its generator method as a method expression. Flag parsing and validation stay per-mode. Signed-off-by: Tom Chauveau --- design/module-gen.md | 22 +- .../generator/typescript/entrypoint_test.go | 59 ++++++ .../testdata/entrypoint_smoke_want.ts | 166 +++++++++++++++ .../typescript/testdata/typedef_smoke.json | 199 ++++++++++++++++++ helpers/codegen/main.go | 158 +++++++++----- 5 files changed, 544 insertions(+), 60 deletions(-) create mode 100644 helpers/codegen/generator/typescript/entrypoint_test.go create mode 100644 helpers/codegen/generator/typescript/testdata/entrypoint_smoke_want.ts create mode 100644 helpers/codegen/generator/typescript/testdata/typedef_smoke.json diff --git a/design/module-gen.md b/design/module-gen.md index 8e2bf2a..9e4ff88 100644 --- a/design/module-gen.md +++ b/design/module-gen.md @@ -243,8 +243,26 @@ Two deliberate differences from upstream: Both halves were validated end to end before anything depended on them: the plain `bun build` scanner, run over a fixture module with our own `core.js` and a module-style `client.gen.ts`, produced a `typedef.json` carrying the -per-declaration `location` data the entrypoint renderer needs — first with the -compiler copied in, then again with it installed from the pinned version. +per-declaration `location` data the entrypoint renderer needs. + +**Two constraints on how the compiler is provided — both found the hard way, +and both must hold in the Phase 2 codegen container:** + +1. **It must be the full package, not a trimmed one.** An earlier revision of + this design proposed shipping `package.json` + `lib/typescript.js` (9.1 MB of + the package's 24 MB). That loads, and scans trivial signatures, but the + checker silently loses every *global* type: `lib/lib.*.d.ts` is missing, so + `Promise`, `Array` and friends do not resolve. A module with + `async foo(): Promise` — i.e. most modules — fails with the + misleading `could not resolve type reference for string`. Installing the + package (§4.1) avoids this by construction. +2. **It must be resolvable from `introspector.js`'s own directory**, not the + module's. Bare-import resolution walks up from the *importing file*, so + putting the compiler in the module's `node_modules` does nothing for a + scanner that lives elsewhere. Upstream sidesteps this by bundling the + compiler into its `--compile`d binary and mounting the package only for the + `lib.*.d.ts` files; we keep the compiler external, so the scanner and its + `node_modules/typescript` have to sit together. ### 4.2 The library sources: vendored in-tree diff --git a/helpers/codegen/generator/typescript/entrypoint_test.go b/helpers/codegen/generator/typescript/entrypoint_test.go new file mode 100644 index 0000000..47d7e48 --- /dev/null +++ b/helpers/codegen/generator/typescript/entrypoint_test.go @@ -0,0 +1,59 @@ +package typescriptgenerator + +import ( + "context" + "flag" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "codegen/generator" +) + +var updateFixtures = flag.Bool("test.update-fixtures", false, "update the test fixtures") + +// TestGenerateEntrypoint renders the static dispatch entrypoint from a typedef +// captured by running the real SDK introspector over a module exercising the +// shapes dispatch has to handle: a constructor with a defaulted argument, +// exposed fields (including an object one, which round-trips through an ID), an +// optional argument, an async method, and a void return. +// +// The fixture is the introspector's own output rather than hand-written JSON, +// so this pins the renderer against the contract it actually receives. +func TestGenerateEntrypoint(t *testing.T) { + gen := &TypeScriptGenerator{Config: generator.Config{ + EntrypointConfig: &generator.EntrypointGeneratorConfig{ + TypedefJSONPath: "testdata/typedef_smoke.json", + ModuleRoot: "/work", + SDKImportPath: "@dagger.io/dagger", + SourceDir: "src", + }, + }} + + state, err := gen.GenerateEntrypoint(context.Background()) + require.NoError(t, err) + + got := readOverlay(t, state, DefaultEntrypointFile) + + const goldenPath = "testdata/entrypoint_smoke_want.ts" + if *updateFixtures { + require.NoError(t, os.WriteFile(goldenPath, []byte(got), 0o600)) + } + want, err := os.ReadFile(goldenPath) + require.NoError(t, err) + + require.Equal(t, string(want), got) +} + +// TestGenerateEntrypoint_RequiresTypedef guards the one input the renderer +// cannot do without: unlike the binding generators it never sees the schema, so +// a missing typedef leaves it with nothing to dispatch. +func TestGenerateEntrypoint_RequiresTypedef(t *testing.T) { + gen := &TypeScriptGenerator{Config: generator.Config{ + EntrypointConfig: &generator.EntrypointGeneratorConfig{}, + }} + + _, err := gen.GenerateEntrypoint(context.Background()) + require.ErrorContains(t, err, "TypedefJSONPath is required") +} diff --git a/helpers/codegen/generator/typescript/testdata/entrypoint_smoke_want.ts b/helpers/codegen/generator/typescript/testdata/entrypoint_smoke_want.ts new file mode 100644 index 0000000..de5bbf5 --- /dev/null +++ b/helpers/codegen/generator/typescript/testdata/entrypoint_smoke_want.ts @@ -0,0 +1,166 @@ +// AUTO-GENERATED — DO NOT EDIT. +// Generated by Dagger TypeScript SDK introspector (cmd/codegen generate-entrypoint). + +import { Context, Error as DaggerError, FunctionCachePolicy, TypeDefKind, connection, dag, getRegisteredClass } from "@dagger.io/dagger" +import * as __dagger from "@dagger.io/dagger" +import * as telemetry from "@dagger.io/dagger/telemetry" +import { Smoke } from "./src/index" + +// Load a core/dependency object from its ID via node(id:) and wrap it in the +// matching generated client class. Mirrors the SDK runtime loader; replaces the +// retired loadFromID API (removed in #12041). Some core type names +// collide with JS builtins and get a trailing "_" (e.g. "Module" -> Module_). +function __loadCoreObject(id: string, typeName: string): any { + const cls = + (__dagger as any)[typeName] ?? (__dagger as any)[typeName + "_"] + if (!cls) { + throw new Error(`generated client class not found for core type: ${typeName}`) + } + return new cls(new Context().selectNode(id, typeName)) +} + +function formatError(e: unknown): DaggerError { + if (e instanceof Error) { + let error = dag.error(e.message) + const ext = (e as { extensions?: Record }).extensions + if (ext) { + for (const [k, v] of Object.entries(ext)) { + if (v !== "" && v !== undefined && v !== null) { + error = error.withValue( + k, + JSON.stringify(v) as string & { __JSON: never }, + ) + } + } + } + return error + } + try { + return dag.error(JSON.stringify(e)) + } catch { + return dag.error(String(e)) + } +} + +function rebuildSmoke(state: any): Smoke { + const __obj = Object.assign(Object.create(Smoke.prototype), state ?? {}) + if (state) { + if (state["source"] !== undefined && state["source"] !== null) { + __obj["source"] = __loadCoreObject(state["source"], "Directory") + } + } + return __obj +} + +async function serializeSmoke(__obj: Smoke): Promise { + if (__obj === null || __obj === undefined) return __obj + const __state: any = { ...__obj } + if ((__obj as any)["source"] !== undefined && (__obj as any)["source"] !== null) { + __state["source"] = await ((__obj as any)["source"]).id() + } + return __state +} + + + +async function register(): Promise { + let mod = dag.module_() + let obj_Smoke = dag.typeDef().withObject("Smoke", { description: "A module covering the shapes the entrypoint has to dispatch." }) + obj_Smoke = obj_Smoke.withFunction(dag.function_("ctr", dag.typeDef().withObject("Container"))) + obj_Smoke = obj_Smoke.withFunction(dag.function_("greet", dag.typeDef().withKind(TypeDefKind.StringKind)).withDescription("Greet someone.").withArg("name", dag.typeDef().withKind(TypeDefKind.StringKind)).withArg("loud", dag.typeDef().withKind(TypeDefKind.BooleanKind).withOptional(true))) + obj_Smoke = obj_Smoke.withFunction(dag.function_("nothing", dag.typeDef().withKind(TypeDefKind.VoidKind).withOptional(true))) + obj_Smoke = obj_Smoke.withFunction(dag.function_("read", dag.typeDef().withKind(TypeDefKind.StringKind)).withArg("path", dag.typeDef().withKind(TypeDefKind.StringKind))) + obj_Smoke = obj_Smoke.withField("greeting", dag.typeDef().withKind(TypeDefKind.StringKind)) + obj_Smoke = obj_Smoke.withField("source", dag.typeDef().withObject("Directory"), { description: "Where the source lives." }) + obj_Smoke = obj_Smoke.withConstructor(dag.function_("", obj_Smoke).withArg("source", dag.typeDef().withObject("Directory")).withArg("greeting", dag.typeDef().withKind(TypeDefKind.StringKind), { defaultValue: JSON.stringify("hello") as string & { __JSON: never } })) + mod = mod.withObject(obj_Smoke) + return await mod.id() +} + +async function invoke( + parentName: string, + fnName: string, + parentJson: any, + args: Record, +): Promise { + switch (parentName) { + case "Smoke": { + switch (fnName) { + + case "": { + const __arg_source = args["source"] === undefined || args["source"] === null ? args["source"] : __loadCoreObject(args["source"], "Directory") + const __arg_greeting = args["greeting"] + const __result = await new Smoke(__arg_source, __arg_greeting) as unknown as Smoke + return await serializeSmoke(__result) + } + + case "ctr": { + const __parent = rebuildSmoke(parentJson) + const __result = await __parent.ctr() + return await (__result).id() + } + + case "greet": { + const __parent = rebuildSmoke(parentJson) + const __arg_name = args["name"] === undefined || args["name"] === null ? args["name"] : args["name"] + const __arg_loud = args["loud"] === undefined || args["loud"] === null ? args["loud"] : args["loud"] + const __result = await __parent.greet(__arg_name, __arg_loud) + return __result + } + + case "nothing": { + const __parent = rebuildSmoke(parentJson) + const __result = await __parent.nothing() + return __result + } + + case "read": { + const __parent = rebuildSmoke(parentJson) + const __arg_path = args["path"] === undefined || args["path"] === null ? args["path"] : args["path"] + const __result = await __parent.read(__arg_path) + return __result + } + default: + throw new Error(`unknown function ${fnName} on Smoke`) + } + } + default: + throw new Error(`unknown object ${parentName}`) + } +} + +async function dispatch() { + await connection(async () => { + const fnCall = dag.currentFunctionCall() + const parentName = await fnCall.parentName() + + if (parentName === "") { + const id = await register() + await fnCall.returnValue(JSON.stringify(id) as string & { __JSON: never }) + return + } + + const fnName = await fnCall.name() + const parentJson = JSON.parse(await fnCall.parent()) + const fnArgs = await fnCall.inputArgs() + + const args: Record = {} + for (const arg of fnArgs) { + args[await arg.name()] = JSON.parse(await arg.value()) + } + + try { + const result = await invoke(parentName, fnName, parentJson, args) + const out = result === undefined || result === null ? "null" : JSON.stringify(result) + await fnCall.returnValue(out as string & { __JSON: never }) + } catch (e: unknown) { + await fnCall.returnError(formatError(e)) + process.exit(1) + } + }, { LogOutput: process.stdout }) +} + +dispatch().catch((e) => { + console.error(e) + process.exit(2) +}) \ No newline at end of file diff --git a/helpers/codegen/generator/typescript/testdata/typedef_smoke.json b/helpers/codegen/generator/typescript/testdata/typedef_smoke.json new file mode 100644 index 0000000..7670d8c --- /dev/null +++ b/helpers/codegen/generator/typescript/testdata/typedef_smoke.json @@ -0,0 +1,199 @@ +{ + "enums": {}, + "interfaces": {}, + "name": "Smoke", + "objects": { + "Smoke": { + "constructor": { + "arguments": [ + { + "description": "", + "isNullable": false, + "isOptional": false, + "isVariadic": false, + "location": { + "column": 15, + "filepath": "src/index.ts", + "line": 17 + }, + "name": "source", + "type": { + "kind": "OBJECT_KIND", + "name": "Directory" + } + }, + { + "defaultValue": "hello", + "description": "", + "isNullable": false, + "isOptional": false, + "isVariadic": false, + "location": { + "column": 34, + "filepath": "src/index.ts", + "line": 17 + }, + "name": "greeting", + "type": { + "kind": "STRING_KIND" + } + } + ], + "name": "" + }, + "description": "A module covering the shapes the entrypoint has to dispatch.", + "isDefaultExport": false, + "isExported": true, + "kind": "class", + "location": { + "column": 14, + "filepath": "src/index.ts", + "line": 7 + }, + "methods": { + "ctr": { + "arguments": [], + "description": "", + "isCheck": false, + "isGenerator": false, + "isUp": false, + "location": { + "column": 3, + "filepath": "src/index.ts", + "line": 31 + }, + "name": "ctr", + "returnType": { + "kind": "OBJECT_KIND", + "name": "Container" + } + }, + "greet": { + "arguments": [ + { + "description": "", + "isNullable": false, + "isOptional": false, + "isVariadic": false, + "location": { + "column": 9, + "filepath": "src/index.ts", + "line": 26 + }, + "name": "name", + "type": { + "kind": "STRING_KIND" + } + }, + { + "description": "", + "isNullable": false, + "isOptional": true, + "isVariadic": false, + "location": { + "column": 23, + "filepath": "src/index.ts", + "line": 26 + }, + "name": "loud", + "type": { + "kind": "BOOLEAN_KIND" + } + } + ], + "description": "Greet someone.", + "isCheck": false, + "isGenerator": false, + "isUp": false, + "location": { + "column": 3, + "filepath": "src/index.ts", + "line": 26 + }, + "name": "greet", + "returnType": { + "kind": "STRING_KIND" + } + }, + "nothing": { + "arguments": [], + "description": "", + "isCheck": false, + "isGenerator": false, + "isUp": false, + "location": { + "column": 3, + "filepath": "src/index.ts", + "line": 41 + }, + "name": "nothing", + "returnType": { + "kind": "VOID_KIND" + } + }, + "read": { + "arguments": [ + { + "description": "", + "isNullable": false, + "isOptional": false, + "isVariadic": false, + "location": { + "column": 14, + "filepath": "src/index.ts", + "line": 36 + }, + "name": "path", + "type": { + "kind": "STRING_KIND" + } + } + ], + "description": "", + "isCheck": false, + "isGenerator": false, + "isUp": false, + "location": { + "column": 9, + "filepath": "src/index.ts", + "line": 36 + }, + "name": "read", + "returnType": { + "kind": "STRING_KIND" + } + } + }, + "name": "Smoke", + "properties": { + "greeting": { + "description": "", + "isExposed": true, + "location": { + "column": 3, + "filepath": "src/index.ts", + "line": 15 + }, + "name": "greeting", + "type": { + "kind": "STRING_KIND" + } + }, + "source": { + "description": "Where the source lives.", + "isExposed": true, + "location": { + "column": 3, + "filepath": "src/index.ts", + "line": 12 + }, + "name": "source", + "type": { + "kind": "OBJECT_KIND", + "name": "Directory" + } + } + } + } + } +} diff --git a/helpers/codegen/main.go b/helpers/codegen/main.go index 377b877..8b08178 100644 --- a/helpers/codegen/main.go +++ b/helpers/codegen/main.go @@ -9,6 +9,8 @@ // closure), importing @dagger.io/dagger. // codegen library — the SDK library's own bindings, importing the runtime they // ship alongside. +// codegen entrypoint — a module's static dispatch entrypoint, from the typedef +// JSON the SDK introspector emits. // // Generation is engine-free: the schema and the bound module's metadata are // supplied as files, so no session is opened. `codegen introspect` is the one @@ -70,48 +72,114 @@ func run(args []string) error { return runClient(args[1:]) case "library": return runLibrary(args[1:]) + case "entrypoint": + return runEntrypoint(args[1:]) case "introspect": return runIntrospect(args[1:]) default: - return fmt.Errorf("unknown command %q (want module, client, library or introspect)", args[0]) + return fmt.Errorf("unknown command %q (want module, client, library, entrypoint or introspect)", args[0]) } } -// runLibrary regenerates the SDK library's own bindings. They ship inside the -// library, so they reach the runtime by relative source path rather than -// through the bundle or the package name — the third import arm. The schema is -// the plain session schema (see `codegen introspect`): core only, unscrubbed. -func runLibrary(args []string) error { - fs := flag.NewFlagSet("library", flag.ExitOnError) +// runEntrypoint renders a module's static dispatch entrypoint. Unlike the +// binding generators it never sees the schema: it works from the typedef JSON +// the SDK introspector emits by scanning the user's own source, which is what +// carries the per-declaration source locations the dispatcher imports classes +// from. +func runEntrypoint(args []string) error { + fs := flag.NewFlagSet("entrypoint", flag.ExitOnError) var ( - introspectionPath = fs.String("introspection-json-path", "", "path to the introspection schema JSON") - outputDir = fs.String("output", ".", "output directory for the generated bindings") + typedefPath = fs.String("typedef-json-path", "", "path to the typedef JSON emitted by the SDK introspector") + outputDir = fs.String("output", ".", "output directory for the generated entrypoint") + outputFile = fs.String("output-file", typescriptgenerator.DefaultEntrypointFile, "filename to write within the output directory") + moduleRoot = fs.String("module-root", "", "absolute path of the module root, used to resolve source-import paths") + sdkImport = fs.String("sdk-import", "@dagger.io/dagger", "bare specifier the entrypoint imports runtime helpers from") + sourceDir = fs.String("source-dir", "src", "the module's source directory, relative to its root") ) if err := fs.Parse(args); err != nil { return err } + if *typedefPath == "" { + return fmt.Errorf("--typedef-json-path is required") + } + + cfg := generator.Config{ + OutputDir: *outputDir, + EntrypointConfig: &generator.EntrypointGeneratorConfig{ + TypedefJSONPath: *typedefPath, + OutputFile: *outputFile, + ModuleRoot: *moduleRoot, + SDKImportPath: *sdkImport, + SourceDir: *sourceDir, + }, + } + gen := &typescriptgenerator.TypeScriptGenerator{Config: cfg} + + ctx := context.Background() + state, err := gen.GenerateEntrypoint(ctx) + if err != nil { + return fmt.Errorf("generate entrypoint: %w", err) + } + + if err := generator.Overlay(ctx, state.Overlay, cfg.OutputDir); err != nil { + return fmt.Errorf("write generated entrypoint: %w", err) + } + + return nil +} + +// renderFunc is a generator method that turns a schema into files. The three +// schema-driven modes differ only in which one they pick, so they are passed as +// method expressions (see generateFromSchema). +type renderFunc func(*typescriptgenerator.TypeScriptGenerator, context.Context, *introspection.Schema, string) (*generator.GeneratedState, error) - schema, schemaVersion, err := loadSchema(*introspectionPath) +// generateFromSchema is the half the schema-driven modes share: read the schema, +// build the generator, render, write the result. What differs is the config each +// mode contributes and the method it renders with, so those come in as +// arguments. `kind` names the output in errors ("module bindings", "client"). +func generateFromSchema(kind, introspectionPath string, cfg generator.Config, render renderFunc) error { + schema, schemaVersion, err := loadSchema(introspectionPath) if err != nil { return err } - cfg := generator.Config{OutputDir: *outputDir} gen := &typescriptgenerator.TypeScriptGenerator{Config: cfg} ctx := context.Background() - state, err := gen.GenerateLibrary(ctx, schema, schemaVersion) + state, err := render(gen, ctx, schema, schemaVersion) if err != nil { - return fmt.Errorf("generate library: %w", err) + return fmt.Errorf("generate %s: %w", kind, err) } if err := generator.Overlay(ctx, state.Overlay, cfg.OutputDir); err != nil { - return fmt.Errorf("write generated library bindings: %w", err) + return fmt.Errorf("write generated %s: %w", kind, err) } return nil } +// runLibrary regenerates the SDK library's own bindings. They ship inside the +// library, so they reach the runtime by relative source path rather than +// through the bundle or the package name — the third import arm. The schema is +// the plain session schema (see `codegen introspect`): core only, unscrubbed. +func runLibrary(args []string) error { + fs := flag.NewFlagSet("library", flag.ExitOnError) + var ( + introspectionPath = fs.String("introspection-json-path", "", "path to the introspection schema JSON") + outputDir = fs.String("output", ".", "output directory for the generated bindings") + ) + if err := fs.Parse(args); err != nil { + return err + } + + return generateFromSchema( + "library bindings", + *introspectionPath, + generator.Config{OutputDir: *outputDir}, + (*typescriptgenerator.TypeScriptGenerator).GenerateLibrary, + ) +} + func runModule(args []string) error { fs := flag.NewFlagSet("module", flag.ExitOnError) var ( @@ -126,28 +194,15 @@ func runModule(args []string) error { return fmt.Errorf("--module-name is required") } - schema, schemaVersion, err := loadSchema(*introspectionPath) - if err != nil { - return err - } - - cfg := generator.Config{ - OutputDir: *outputDir, - ModuleConfig: &generator.ModuleGeneratorConfig{ModuleName: *moduleName}, - } - gen := &typescriptgenerator.TypeScriptGenerator{Config: cfg} - - ctx := context.Background() - state, err := gen.GenerateModule(ctx, schema, schemaVersion) - if err != nil { - return fmt.Errorf("generate module: %w", err) - } - - if err := generator.Overlay(ctx, state.Overlay, cfg.OutputDir); err != nil { - return fmt.Errorf("write generated module bindings: %w", err) - } - - return nil + return generateFromSchema( + "module bindings", + *introspectionPath, + generator.Config{ + OutputDir: *outputDir, + ModuleConfig: &generator.ModuleGeneratorConfig{ModuleName: *moduleName}, + }, + (*typescriptgenerator.TypeScriptGenerator).GenerateModule, + ) } func runClient(args []string) error { @@ -161,11 +216,6 @@ func runClient(args []string) error { return err } - schema, schemaVersion, err := loadSchema(*introspectionPath) - if err != nil { - return err - } - clientConfig := &generator.ClientGeneratorConfig{} if *clientMetaPath != "" { metaJSON, err := os.ReadFile(*clientMetaPath) @@ -185,23 +235,15 @@ func runClient(args []string) error { } } - cfg := generator.Config{ - OutputDir: *outputDir, - ClientConfig: clientConfig, - } - gen := &typescriptgenerator.TypeScriptGenerator{Config: cfg} - - ctx := context.Background() - state, err := gen.GenerateClient(ctx, schema, schemaVersion) - if err != nil { - return fmt.Errorf("generate client: %w", err) - } - - if err := generator.Overlay(ctx, state.Overlay, cfg.OutputDir); err != nil { - return fmt.Errorf("write generated client: %w", err) - } - - return nil + return generateFromSchema( + "client", + *introspectionPath, + generator.Config{ + OutputDir: *outputDir, + ClientConfig: clientConfig, + }, + (*typescriptgenerator.TypeScriptGenerator).GenerateClient, + ) } // loadSchema reads the introspection JSON and prepares it for rendering: the