From 5a7d6f2119f4acbe7e4be11b77d9ca075dbe7fe1 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. 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 | 53 ++++- 5 files changed, 496 insertions(+), 3 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 b36e6a4..7541627 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..41a61af 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,13 +72,62 @@ 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]) } } +// 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 ( + 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 +} + // 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