Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions design/module-gen.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>` — 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

Expand Down
59 changes: 59 additions & 0 deletions helpers/codegen/generator/typescript/entrypoint_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
166 changes: 166 additions & 0 deletions helpers/codegen/generator/typescript/testdata/entrypoint_smoke_want.ts
Original file line number Diff line number Diff line change
@@ -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 load<Type>FromID 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<string, unknown> }).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<any> {
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<string> {
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<string, any>,
): Promise<any> {
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<string, any> = {}
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)
})
Loading