Files
zeekayandHanzo Dev 739b868937 feat(compile): check and bundle with the project served by the host
One home for acting on source: is it correct, and can it be built. Two
interfaces because most checkers do not bundle, and a registry so that
supporting another language is a row rather than a route — GET on the
registry and running a checker are the same address read or run.

A project names files, never a disk path. The host answers ReadFile,
FileExists, DirExists, Entries and Realpath, so a checker reaches exactly
what it was handed: the repository lives in s3 and the cache is the host's
business. esbuild takes the whole project through onResolve and onLoad with
zero preopened directories; tsgo issues ordinary WASI reads, so its mount is
an fs.FS over the same callbacks, and a project with no tsconfig.json gets
one that exists only in the guest's view of the tree.

Diagnostics are one shape with 1-based lines and columns. esbuild counts
columns from zero; that translation happens once here instead of in every
caller.

tsgo is a command module, so a check is an instance: compile once into an
on-disk cache, instantiate many with a unique name each, es2022 with no
ambient types by default and GOMEMLIMIT per run. On evo that is 273ms for one
check and 541ms for eight at once, against 1.7s and 2.35GB with the default
library bundle.

esbuild registers as a Bundler and nothing else. It bundled
`const wrong: string = add(1,2)` and exited 0, and the registry is what keeps
that honest.

The modules are 20 and 49 MiB and are not committed: Blob names one by path
or URL and its sha256, and refuses a module with no digest.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-18 13:36:34 -07:00

83 lines
2.2 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
package compile
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strings"
)
// Blob names a wasm module by where to get it and what it must hash to. tsgo is
// 49 MiB and esbuild 20 MiB, so neither lives in this repository; the host
// fetches them and this type is why a fetch cannot substitute something else.
//
// Sum is required. A module with no declared digest is refused rather than
// trusted, because the one thing a compile host must never be is a way to run
// somebody else's code.
type Blob struct {
// Path is a local file. It wins when set.
Path string
// URL is fetched over http when Path is empty.
URL string
// Sum is the hex sha256 the bytes must have.
Sum string
}
// Bytes fetches the module and verifies it. A digest mismatch names both
// digests: the caller has either a corrupt download or the wrong artifact, and
// those need different fixes.
func (b Blob) Bytes(ctx context.Context) ([]byte, error) {
if b.Sum == "" {
return nil, fmt.Errorf("compile: blob has no sha256")
}
var (
raw []byte
err error
)
switch {
case b.Path != "":
raw, err = os.ReadFile(b.Path)
if err != nil {
return nil, fmt.Errorf("compile: read module: %w", err)
}
case b.URL != "":
raw, err = fetch(ctx, b.URL)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("compile: blob names neither a path nor a url")
}
sum := sha256.Sum256(raw)
if got := hex.EncodeToString(sum[:]); !strings.EqualFold(got, b.Sum) {
return nil, fmt.Errorf("compile: module digest is %s, declared %s", got, b.Sum)
}
return raw, nil
}
func fetch(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("compile: fetch module: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("compile: fetch module: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("compile: fetch module %s: %s", url, resp.Status)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("compile: fetch module %s: %w", url, err)
}
return raw, nil
}