goja

package
v1.801.464 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

Documentation

Overview

Package goja is the in-process JavaScript host: it runs a service's goja bundle (a self-contained, ESM-free JS file exposing globalThis.handle(req)) inside the cloud binary, with an optional tenant-bound Base/SQLite binding.

It is a LIBRARY, not a subsystem — it registers no route and has no manifest row.

It is the SHARED glue used by apps/plan and apps/pricing to host @hanzo/plans and @hanzo/pricing in-process via dop251/goja — the same engine base/plugins/gojavm uses. We do not import base's gojavm Runtime directly because that loader is manifest-driven (extension.json + a single exported `fn` over JSON-over-the-wire payloads); our services instead inject a catalog of JSON globals at VM init and call a richer handle({route,params,...}) entry. The VM-pool + compile-once + per-runtime ensureLoaded discipline here mirrors gojavm/runtime.go exactly so behavior and the pool semantics are identical.

Module boundary: the JS bundle + catalog data live in the service repos (hanzoai/plans, hanzoai/pricing) and are passed in by the caller. This package carries zero service logic — only the engine plumbing.

READ-WRITE variant: this package hosts bundles with a read-only catalog injected once at New (plans/pricing). Subsystems that need PERSISTENCE — a bundle that reads AND writes per-tenant Base/SQLite (captable #97, esign #100, dataroom #101) — use NewBase (base.go / basestore.go), the Base binding folded into THIS package: it builds on this engine (via DispatchWith) and injects a tenant-bound __db bridge per request. Reach for NewBase when your bundle stores data; reach for New directly only for a read-only bundle.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Body added in v1.801.433

func Body(fields map[string]BodyField) (any, error)

Body assembles the object the bundle validates from the caller's verbatim tokens. A field that was never on the wire contributes NO key — the `undefined` a partial update reads.

The result is a plain Go value, not bytes, because that is what crosses into goja: the same shape the untyped relay handed the host after decoding the caller's bytes into `any`. Key order is lost to the map on the way, and cannot matter — a bundle reads its fields by name and echoes no request body back.

func Envelope added in v1.801.433

func Envelope() zip.Handler

Envelope writes a BundleErr back to the client VERBATIM: the bundle's own status, its own bytes, under the bare `application/json` the untyped relay beside it sends. Anything else propagates unchanged.

It must be installed on the app's group BEFORE the ops it serves — fiber runs middleware in registration order, so one installed after its leaves never runs.

func TenantSegment

func TenantSegment(tenant string) string

TenantSegment maps a tenant/org key to an INJECTIVE, traversal-safe single OBJECT-STORE key segment: lowercased unpadded base32 of the RAW org bytes.

This is deliberately NOT a "slug": lowercasing + folding illegal bytes to '_' (the previous slugify) is NON-injective and collapses DISTINCT owners onto ONE physical prefix — a cross-tenant break. principal.Org returns the org VERBATIM for exactly that reason (folding "Acme"/"acme" or "a b"/"a_b" into one bucket is itself a tenant break); this encoder preserves that distinction all the way to the stored name. base32 of the raw bytes is a bijection with its output, so distinct orgs ALWAYS map to distinct segments: "Acme", "acme", "a b" and "a_b" each land on their OWN prefix (the previous slugify collapsed all four onto two).

The [a-z2-7] output contains no path separator and can never equal "." or ".." (those need chars outside the alphabet), so a segment can never traverse an object-store key prefix. Empty tenant → "" (callers reject it).

It does NOT name the per-tenant DATABASE. That name is namespace's — one slugger for every file cloud opens — and this encoder is what remains for the blob half, which has no namespace rendering of its own.

Types

type BaseConfig

type BaseConfig struct {
	// Name identifies the subsystem ("captable", "esign", "dataroom"). It names
	// the goja host AND the per-tenant database it opens.
	Name string
	// Bundle is the self-contained goja bundle exposing globalThis.handle.
	Bundle []byte
	// Schema is the per-tenant SQLite DDL, run (idempotently — use
	// CREATE TABLE IF NOT EXISTS) on every tenant DB when it first opens.
	Schema string
	// DataDir is the deployment data root; per-tenant files land where namespace
	// renders them, at {DataDir}/orgs/{slug}/{Name}.db.
	DataDir string
	// OnOpen is an optional per-tenant seed hook run ONCE after migration (e.g.
	// captable seeds the tenant's company row). It runs outside the per-request
	// transaction. May be nil.
	OnOpen func(ctx context.Context, tenant string, db *sql.DB) error
	// HostFns are OPTIONAL extra native host globals injected onto the runtime on
	// every Dispatch, ALONGSIDE __db/__newId/__now — for capabilities goja cannot
	// provide that a subsystem implements in Go (e.g. esign injects __pdf =
	// { stamp, sign } for PDF rendering + x509/PKCS#7 signing). Values are Go
	// funcs or map[string]any of Go funcs (goja exposes them as callable JS). They
	// are process-global (set once at NewBase), not per-tenant; the binding stays
	// domain-free. May be nil. A key MUST NOT collide with __db/__newId/__now/__blob.
	HostFns map[string]any
	// Blob is the OPTIONAL object-storage seam (see BlobStore). When set, the binding
	// injects globalThis.__blob = { put(key, b64), get(key) -> b64 } on every
	// Dispatch, bound to the tenant: keys are prefixed with {Name}/{TenantSegment}
	// so a bundle can NEVER address another tenant's blob. Payloads cross as base64
	// strings (goja-friendly); the binding decodes/encodes at the boundary so the
	// bundle never handles raw bytes. nil ⇒ no __blob is injected. This is the ONE
	// way a bundle keeps big binaries out of its per-tenant SQLite.
	Blob BlobStore
}

BaseConfig configures a BaseHost.

type BaseHost

type BaseHost struct {
	// contains filtered or unexported fields
}

BaseHost is a compiled bundle + its per-tenant Base stores. Safe for concurrent use.

func NewBase

func NewBase(cfg BaseConfig) (*BaseHost, error)

NewBase compiles the bundle (via the goja engine, New) and prepares the per-tenant store manager. It does NOT open any tenant DB — those open lazily on first Dispatch.

func (*BaseHost) Close

func (h *BaseHost) Close() error

Close closes every open tenant DB and drops the goja engine. Idempotent.

func (*BaseHost) Dispatch

func (h *BaseHost) Dispatch(ctx context.Context, tenant string, req BaseRequest) (*Response, error)

Dispatch resolves the tenant's Base/SQLite store, opens a per-request transaction, injects the RW-Base host globals bound to it, and calls globalThis.handle. It commits on a <400 non-throwing response and rolls back otherwise. tenant MUST be a validated principal's org (the caller resolves it, e.g. via clients/principal.Org) — the binding does not itself authenticate.

type BaseRequest

type BaseRequest struct {
	Route  string
	Params map[string]string
	Query  map[string]string
	Body   any
}

BaseRequest is the dispatch envelope. The binding adds the tenant (as orgId) and the Base bridge; the caller supplies route/params/query/body.

type BlobStore

type BlobStore interface {
	Put(ctx context.Context, key string, payload []byte) error
	Get(ctx context.Context, key string) ([]byte, error)
}

BlobStore is the ONE object-storage seam a bundle uses to persist large binary payloads OUTSIDE its per-tenant SQLite (e.g. sign's PDFs — a 32 MiB base64 blob in a TEXT column would bloat the tenant DB and get copied on every read). The cloud VFS/S3 data plane (deps.VFS) satisfies it, exactly as clients/dataroom already uses it for document bytes. Keys are opaque; the binding tenant-scopes them.

type BodyField added in v1.801.433

type BodyField interface {
	// contains filtered or unexported methods
}

BodyField is one field of an assembled bundle body: a Scalar for a single token, a ScalarList for a lenient array. The interface is CLOSED — its method is unexported — so those two are the whole vocabulary, and Body needs no default case for a kind that cannot exist.

type BundleErr added in v1.801.433

type BundleErr struct {
	// Status is the bundle's own HTTP status.
	Status int
	// Body is the bundle's own response bytes, written back verbatim.
	Body []byte
	// Msg is the human sentence for a caller that is not on the HTTP path.
	Msg string
}

BundleErr is the bundle's OWN non-2xx answer, carried as a Go error so a typed op can return it. A bundle authors envelopes cloud has no vocabulary for, and zip's error path renders {status,code,error}, which has nowhere to put them. So the op returns the bundle's status and its BYTES, and Envelope writes them back untouched.

It is not an escape from typing. The op still declares its In and its Out, so the document, the MCP tool, the CLI command and the SDK method all exist.

func (*BundleErr) Error added in v1.801.433

func (e *BundleErr) Error() string

func (*BundleErr) Unwrap added in v1.801.433

func (e *BundleErr) Unwrap() error

Unwrap gives the error a status and a message OFF the HTTP path, where there is no response to write bytes into: an MCP tools/call and an in-process CLI invoke run the op without passing through Envelope, so zip's own error handler renders this instead — the bundle's status and message in zip's envelope, rather than a blanket 500 that loses both.

type Config

type Config struct {
	// Name identifies the service for error messages ("plans", "pricing").
	Name string
	// Bundle is the goja bundle source (goja/bundle.js from the service repo).
	Bundle []byte
	// Globals are injected onto each runtime before the bundle runs, e.g.
	// {"__PLANS_DATA__": <catalog>}. Values are converted via goja.ToValue.
	// Pointers to the same Go value are shared read-only across runtimes; the
	// bundles never mutate injected globals.
	Globals map[string]any
}

Config configures a Host.

type Host

type Host struct {
	// contains filtered or unexported fields
}

Host is a compiled service bundle plus a pool of goja runtimes that have had the bundle + the injected globals evaluated. Safe for concurrent use.

func New

func New(cfg Config) (*Host, error)

New compiles the bundle and pre-warms the runtime pool. The bundle is compiled once (goja.Program is safe to share across runtimes); each pool runtime evaluates it lazily on first use.

func (*Host) Close

func (h *Host) Close() error

Close drops the pool.

func (*Host) Dispatch

func (h *Host) Dispatch(ctx context.Context, req Request) (*Response, error)

Dispatch calls globalThis.handle(req) on a pooled runtime and returns the JS-side {status, body}. ctx cancellation interrupts the call. Read-only bundles (plans/pricing) use this; their globals are the catalog injected once at New.

func (*Host) DispatchWith

func (h *Host) DispatchWith(ctx context.Context, req Request, hostGlobals map[string]any) (*Response, error)

DispatchWith is Dispatch plus a set of per-call NATIVE globals installed on the runtime immediately before handle() runs (left in place until the next dispatch on that slot overwrites them). This is the read-WRITE extension of the read-only plan/pricing pattern: the NewBase binding passes a tenant-bound __db bridge (+ __newId/__now) here so a bundle's SQL calls hit the right per-tenant Base. The slot is held exclusively for the whole call (withSlot serializes it), so installing globals on the shared runtime is race-free, and values are plain Go funcs/maps that goja converts to callable JS.

func (*Host) Eval

func (h *Host) Eval(ctx context.Context, fnName string, jsonArg []byte) ([]byte, error)

Eval runs an arbitrary JS expression against a pooled runtime (bundle already loaded) and returns the exported Go value. Used by callers that want to invoke a non-route helper the bundle exposes (e.g. applyMarkup).

func (*Host) SetGlobal

func (h *Host) SetGlobal(key string, value any)

SetGlobal updates an injected global and forces every pooled runtime to re-evaluate the bundle on next use (so the new value takes effect). Used by the pricing sync path to swap in freshly-synced data.

type Request

type Request struct {
	Route  string            `json:"route"`
	Params map[string]string `json:"params,omitempty"`
	Query  map[string]string `json:"query,omitempty"`
	Tenant string            `json:"tenant,omitempty"`

	// OrgID is the validated tenant for read-WRITE subsystems (captable). It is
	// passed to handle as req.orgId; the bundle uses it to scope every row.
	// Read-only bundles (plans/pricing) ignore it and read Tenant instead.
	OrgID string `json:"orgId,omitempty"`
	// Body is the decoded request body for mutations, passed to handle as
	// req.body. nil for reads. Whatever json.Unmarshal produced (map/slice/scalar)
	// is converted to a JS value by goja.
	Body any `json:"body,omitempty"`
}

Request is the dispatch envelope handed to globalThis.handle in JS.

type Response

type Response struct {
	Status int             `json:"status"`
	Body   json.RawMessage `json:"body"`
}

Response is what globalThis.handle returns: an HTTP status + an opaque body that the host serializes straight to JSON.

type Scalar added in v1.801.433

type Scalar string

Scalar is ONE JSON value carried from the caller to the bundle unchanged — the token exactly as it arrived, quotes and all: `"Acme"`, `123`, `null`, and a composite token too, which is how a lenient array field is carried per element as []Scalar.

It exists because a bundle's validators are LENIENT in a way no Go type is. A helper that calls String(v) stores `{"taxId":12345}` as "12345" today, and a *string field would refuse it with a 400 — making the route accept LESS. A helper that REFUSES a non-string answers in the bundle's own envelope, which a Go field refusing it first would replace with zip's. Carrying the token verbatim keeps both: the bundle sees what the caller sent and stays the only judge of it.

A Go string is the carrier because it is a string KIND, so every projection describes the field as `string` — which is what these fields are. The leniency is not in the schema; it is named in each field's own prose.

The zero value means ABSENT — no key was on the wire — which is why a field of this type is `omitempty` and never a pointer. A pointer would collapse the distinction a partial update depends on: encoding/json sets a pointer field to nil for an explicit `null` WITHOUT calling UnmarshalJSON, so `{"city":null}` and `{}` would arrive identically. A non-pointer Scalar records `null` as the four bytes `null`, and an empty JSON string as the two bytes `""`, so neither can be confused with absent.

func (Scalar) MarshalJSON added in v1.801.433

func (s Scalar) MarshalJSON() ([]byte, error)

MarshalJSON writes the carried token back exactly as it arrived. A value that did NOT come off the wire — a hand-built op input, a URL param bound by zip's setScalar — is not a JSON token but the string it spells, so it is quoted. That keeps the type total: every Scalar marshals to valid JSON.

func (*Scalar) UnmarshalJSON added in v1.801.433

func (s *Scalar) UnmarshalJSON(b []byte) error

UnmarshalJSON keeps the caller's bytes. It cannot fail: whatever the caller sent for this field is the bundle's to judge, so nothing is rejected here.

type ScalarList added in v1.801.433

type ScalarList []Scalar

ScalarList is a lenient ARRAY field: each element is carried verbatim, so a bundle that reads the elements itself stays the only judge of them.

It exists because Scalar would describe an array field as a `string`, and a wrong type in the schema is worse here than in prose: the agent, the SDK and the CLI all read it, and a bundle that tests Array.isArray silently substitutes an EMPTY list for anything that is not an array. So a caller told `string` sends one, the bundle discards it, and the call succeeds having ignored the field. Declaring the array is what makes that failure impossible to reach.

Leniency survives anyway, in both directions. A non-array body value is a decode error that Fill drops, leaving the field nil — ABSENT, which is exactly the `undefined` the bundle turns into its own empty list. And an element that is not a string is carried as the token it was, so the bundle sees what the caller sent.

type SizedIn added in v1.801.433

type SizedIn struct {
	// contains filtered or unexported fields
}

SizedIn is the request-size half of an input, embedded by every body-carrying typed op.

The relay capped a body and answered 413 AFTER resolving the tenant. A typed op never sees the request, so the size is recorded where the bytes are — the input's own UnmarshalJSON — and read back after the tenant is resolved, which is what keeps a 403 ahead of a 413 for the caller that has both problems.

The field is unexported, so it reaches no schema: zip's projector walks an untagged embedded struct and keeps only its EXPORTED fields, so embedding this adds nothing a caller could send.

func (*SizedIn) Fill added in v1.801.433

func (s *SizedIn) Fill(max int, b []byte, v any)

Fill decodes the caller's object into v, records whether it exceeded max, and NEVER refuses the body. It is the one decode path for a bundle-backed input.

A body that is not an object — an array, a bare scalar, `null` — leaves every field absent, which is exactly what the relay did: it decoded into `any` and the bundle turned anything that was not an object into `{}`. The bundle then answers, in its own envelope, the same "name is required" it always did. A type error on ONE key is saved and decoding continues, so a body that echoes `"id":123` back at a PATCH still delivers the fields beside it — as the relay did, since the URL carries the id and the bundle never read one from the body.

func (SizedIn) Oversize added in v1.801.433

func (s SizedIn) Oversize() bool

Oversize reports whether the body exceeded the cap passed to Fill. The op reads it after resolving the tenant, so the 403 stays ahead of the 413.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL