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
14 changes: 14 additions & 0 deletions .dagger/modules/packager/dagger.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "packager",
"engineVersion": "v1.0.0-0",
"sdk": {
"source": "dang"
},
"dependencies": [
{
"name": "polyfill",
"source": "github.com/dagger/polyfill@main",
"pin": "16627066d1852106320bdc0cfa0e5f901efe5970"
}
]
}
59 changes: 59 additions & 0 deletions .dagger/modules/packager/main.dang
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""
Builds the TypeScript SDK library artifacts this repo ships.

The SDK module reads them straight off its own source at generate time, so
whatever this produces has to be committed. Everything lands under `library/`,
laid out the way the library source expects it, and is refreshed by
`dagger generate`.
"""
type Packager {
"""
Workspace-relative directory holding the library.
"""
let libraryPath: String! = "library"

"""
Directory holding the library's own generated bindings, relative to
libraryPath. Matches the layout of the library source they are compiled with,
where the runtime they import sits at ../common/context.js.
"""
let bindingsPath: String! = "src/api"

"""
Regenerate the TypeScript library's own bindings (client.gen.ts) from the
running engine's schema.

The schema comes from the session rather than from a module: a plain session
serves no modules, so introspecting it yields the core API alone — which is
exactly what a library binds. That one step is why this opens a nested
session; rendering the bindings from the resulting JSON does not.
"""
libraryBindings(ws: Workspace!): Changeset! @generate {
let bindings = codegen(ws)
.withExec(["codegen", "introspect", "--output", "/schema.json"], experimentalPrivilegedNesting: true)
.withExec(["codegen", "library", "--introspection-json-path", "/schema.json", "--output", "/out"])
.directory("/out")

polyfill.workspace(ws).fork
.withDirectory(libraryPath + "/" + bindingsPath, bindings)
.changes
}

"""
Container with the codegen helper compiled and on PATH at
/usr/local/bin/codegen. Read from the workspace rather than this module's own
source: the helper lives at the repo root, outside the module directory. The
path is workspace-root-absolute, so generating from a subdirectory still
finds it.
"""
let codegen(ws: Workspace!): Container! {
container
.from("golang:1.25-alpine")
.withoutEntrypoint
.withMountedCache("/go/pkg/mod", cacheVolume("go-mod"))
.withMountedCache("/root/.cache/go-build", cacheVolume("go-build"))
.withDirectory("/helper", ws.directory("/helpers/codegen"))
.withWorkdir("/helper")
.withExec(["go", "build", "-o", "/usr/local/bin/codegen", "."])
}
}
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/library/src/api/client.gen.ts linguist-generated
3 changes: 3 additions & 0 deletions dagger.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
[modules.e2e]
source = ".dagger/modules/e2e"

[modules.packager]
source = ".dagger/modules/packager"

[modules.typescript-sdk]
source = "."
check.skip = ["*"]
Expand Down
89 changes: 89 additions & 0 deletions helpers/codegen/introspect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package main

import (
"bytes"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"

"codegen/introspection"
)

// Env vars the engine injects into a nested exec, pointing at the session's
// GraphQL endpoint (engine/client/client.go).
const (
sessionPortEnv = "DAGGER_SESSION_PORT"
sessionTokenEnv = "DAGGER_SESSION_TOKEN"
)

// runIntrospect dumps the session's introspection schema.
//
// This is the only subcommand that talks to the engine, and it does so over
// plain HTTP against the nested session rather than through the Go SDK: the
// generation subcommands stay dependency-free and never open a session of their
// own. The schema a plain session serves is core-only — no module is installed
// in it — which is exactly what library bindings are generated from.
func runIntrospect(args []string) error {
fs := flag.NewFlagSet("introspect", flag.ExitOnError)
output := fs.String("output", "", "path to write the introspection schema JSON to (default stdout)")
if err := fs.Parse(args); err != nil {
return err
}

port, ok := os.LookupEnv(sessionPortEnv)
if !ok {
return fmt.Errorf("%s is not set: introspect must run in a nested exec (experimentalPrivilegedNesting)", sessionPortEnv)
}

body, err := json.Marshal(map[string]string{
"query": introspection.Query,
"operationName": "IntrospectionQuery",
})
if err != nil {
return fmt.Errorf("marshal introspection query: %w", err)
}

req, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:"+port+"/query", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build introspection request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(os.Getenv(sessionTokenEnv), "")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("introspection query: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("introspection query: unexpected status %s", resp.Status)
}

// The generators consume the payload under "data" — the same shape as the
// introspection JSON the engine hands to codegen elsewhere.
var envelope struct {
Data json.RawMessage `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return fmt.Errorf("decode introspection response: %w", err)
}
if len(envelope.Errors) > 0 {
return fmt.Errorf("introspection query: %s", envelope.Errors[0].Message)
}
if len(envelope.Data) == 0 {
return fmt.Errorf("introspection query returned no data")
}

if *output == "" {
_, err = os.Stdout.Write(envelope.Data)
return err
}

return os.WriteFile(*output, envelope.Data, 0o644)
}
49 changes: 46 additions & 3 deletions helpers/codegen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@
// codegen client — a standalone client (dagger.gen.ts for the core types, plus
// one <module>.gen.ts per module in the bound module's
// closure), importing @dagger.io/dagger.
// codegen library — the SDK library's own bindings, importing the runtime they
// ship alongside.
//
// It is intentionally engine-free: the schema and the bound module's metadata
// are supplied as files, so no nested engine session is opened.
// 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
// exception — it dumps the session schema the library bindings are generated
// from, over plain HTTP, and only runs in this repo's own generate step.
package main

import (
Expand Down Expand Up @@ -64,11 +68,50 @@ func run(args []string) error {
return runModule(args[1:])
case "client":
return runClient(args[1:])
case "library":
return runLibrary(args[1:])
case "introspect":
return runIntrospect(args[1:])
default:
return fmt.Errorf("unknown command %q (want module or client)", args[0])
return fmt.Errorf("unknown command %q (want module, client, library 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)
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
}

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)
if err != nil {
return fmt.Errorf("generate library: %w", err)
}

if err := generator.Overlay(ctx, state.Overlay, cfg.OutputDir); err != nil {
return fmt.Errorf("write generated library bindings: %w", err)
}

return nil
}

func runModule(args []string) error {
fs := flag.NewFlagSet("module", flag.ExitOnError)
var (
Expand Down
Loading