Generate Goja-native fluent builders for protobuf messages and recover concrete Go messages without JSON round trips.
Generated protobuf builders let JavaScript running inside Goja construct real Go protobuf messages through a fluent API. JavaScript gets ergonomic pb.Task.builder().title("...").build() calls, while Go receives a concrete proto.Message through protogoja.MessageFromValue instead of converting through JSON.stringify, protojson.Unmarshal, or schema-name lookup.
Use this guide when you own a .proto schema and want to expose it as a CommonJS module inside goja-repl, an engine-managed runtime, xgoja, or a custom host application.
The generator emits a Go companion file next to normal protoc-gen-go output. Normal protobuf generation gives Go structs; protoc-gen-goja-builder gives Goja module helpers, JavaScript builder namespaces, hidden message references, TypeScript declarations, and host integration functions.
For a file such as task.proto, generation produces:
proto/task.proto
proto/task.pb.go # normal protoc-gen-go output
proto/task_goja.pb.go # protoc-gen-goja-builder output
The generated companion file contains file-scoped helpers like:
func GojaBuilderFileTaskProtoTypeScriptModule(moduleName string) *spec.Module
func NewGojaBuilderFileTaskProtoLoader(moduleName string) require.ModuleLoader
func RegisterGojaBuilderFileTaskProtoModule(reg *require.Registry, moduleName string) error
func GojaBuilderFileTaskProtoModule(moduleName string) modules.NativeModule
func RegisterGojaBuilderFileTaskProtoMessageTypes(register func(string, proto.Message) error) error
Each protobuf message becomes a JavaScript namespace with builder(), from(value), is(value), and clone(value). The namespace is also a hidden prototype token, so Go code can recover the message type with protogoja.MessagePrototypeFromValue when it needs a schema handle rather than a payload.
typeNameGenerated protobuf modules expose two related JavaScript value shapes:
pb.Task is a message namespace. It is a type/schema token. Go should
consume it with protogoja.MessagePrototypeFromValue(value) when a host API
needs to register or inspect a protobuf type.pb.Task.builder().title("demo").build() is a built message value. It is
a payload. Go should consume it with protogoja.MessageFromValue(value) when
a host API needs the concrete proto.Message.Both shapes expose public typeName metadata so JavaScript can log, compare,
or display the protobuf full name. That public property is not the same thing as
the hidden Go-owned reference. A plain object such as
{ typeName: "examples.tasks.v1.Task" } is only ordinary JavaScript data; it is
not a generated namespace and does not carry a hidden prototype token.
Host integrations should therefore check the hidden-reference helpers before using generic JavaScript export paths:
if prototype, ok := protogoja.MessagePrototypeFromValue(value); ok {
// value is a generated namespace such as pb.Task.
_ = prototype.TypeName()
_ = prototype.NewMessage()
}
if msg, ok := protogoja.MessageFromValue(value); ok {
// value is a built payload such as pb.Task.builder().build().
_ = msg
}
Avoid calling value.Export(), vm.ExportTo(...), JSON.stringify, object
spread, or other copying/serialization mechanisms before these checks. Those
operations preserve only public JavaScript data such as typeName; they do not
preserve hidden protogoja references.
Use protoc when your project already has direct protobuf generation commands or go:generate lines. Install both generators, then invoke them in the same command so *.pb.go and *_goja.pb.go stay in sync.
Install the plugins:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install github.com/go-go-golems/go-go-goja/cmd/protoc-gen-goja-builder@latest
Generate Go protobuf code and Goja builders:
protoc \
--proto_path=. \
--go_out=. --go_opt=paths=source_relative \
--goja-builder_out=. \
--goja-builder_opt=paths=source_relative,module_name=examples.tasks.v1 \
proto/task.proto
Commit both generated files. The module_name option is the default JavaScript module name that examples, docs, and xgoja declarations should use, even though hosts can still pass a custom module name to the generated loader helpers.
Use go:generate when the schema belongs to one Go package and developers should be able to refresh generated files with one stable command.
package taskpb
//go:generate protoc -I . --go_out=. --go_opt=paths=source_relative --goja-builder_out=. --goja-builder_opt=paths=source_relative,module_name=examples.tasks.v1 task.proto
Then run:
go generate ./proto
go test ./proto ./pkg/that/consumes/protos -count=1
A package-local generation command is often the safest workflow because the protobuf schema, normal Go output, Goja companion output, and tests live close together.
Generated builders are fluent, mutable construction helpers. Built messages are stable Go-backed values that carry hidden protobuf references; scripts should build new messages or clone existing messages instead of mutating built objects as plain JavaScript records.
Given this schema:
message Task {
string id = 1;
string title = 2;
repeated string tags = 3;
map<string, string> labels = 4;
TaskPriority priority = 5;
google.protobuf.Timestamp due_at = 6;
google.protobuf.Struct metadata = 7;
}
JavaScript can construct a real protobuf message:
const pb = require("examples.tasks.v1")
const task = pb.Task.builder()
.id("task-1")
.title("Ship protobuf builders")
.addTags("protobuf")
.addTags("goja")
.putLabels("component", "runtime")
.priority(pb.TaskPriority.TASK_PRIORITY_HIGH)
.dueAt(new Date("2026-06-12T20:00:00Z"))
.metadata({ owner: "agent", reviewed: true })
.build()
The generated API includes these field-shape helpers:
| Field shape | Generated methods | Notes |
|---|---|---|
| Scalar / enum / message | <field>(value), clear<Field>() | Replaces the field value. |
| Repeated | <field>(values), add<Field>(value), clear<Field>() | add<Field> appends one element. |
| Map | <field>(objectOrMap), put<Field>(key, value), delete<Field>(key), clear<Field>() | Plain objects and JavaScript Map are accepted for map replacement. |
| Explicit presence | has<Field>(), clear<Field>() | Works for proto2 fields, optional, messages, and oneofs with protobuf presence. |
| Real oneof | which<Oneof>(), clear<Oneof>() | Synthetic proto3 optional backing oneofs are hidden. |
Enums are exported as JavaScript objects. Use generated enum constants rather than raw integers when readability matters:
.priority(pb.TaskPriority.TASK_PRIORITY_HIGH)
The important host-side rule is: do not ask JavaScript to serialize the message back to JSON just so Go can parse it again. Generated build() returns a Goja value with a hidden cloned protobuf reference, and protogoja.MessageFromValue recovers a fresh Go clone.
value := exports.Get("task")
msg, ok := protogoja.MessageFromValue(value)
if !ok {
return fmt.Errorf("expected generated protobuf message")
}
task, ok := msg.(*taskpb.Task)
if !ok {
return fmt.Errorf("expected *taskpb.Task, got %T", msg)
}
fmt.Println(task.GetTitle())
Use protogoja.MustMessageFromValue in tests when a mismatch should fail immediately. Use protogoja.TypeNameFromValue or protogoja.IsMessageValueOf when a generic module needs to inspect the message type before dispatching.
A raw Goja host can install a generated module with the generated registry helper. This is the smallest integration path and is useful in tests, CLIs, and custom embedders.
vm := goja.New()
reg := require.NewRegistry()
if err := taskpb.RegisterGojaBuilderFileTaskProtoModule(reg, "examples.tasks.v1"); err != nil {
return err
}
reg.Enable(vm)
_, err := vm.RunString(`
const pb = require("examples.tasks.v1")
globalThis.task = pb.Task.builder().title("demo").build()
`)
if err != nil {
return err
}
This path does not require xgoja or a runtime factory. It is the direct CommonJS module loader path.
Use engine.NativeModuleRegistrar when every runtime created by an engine.RuntimeFactory should have the protobuf module available.
factory, err := engine.NewRuntimeFactoryBuilder().
WithModules(engine.NativeModuleRegistrar{
ModuleName: "examples.tasks.v1",
Loader: taskpb.NewGojaBuilderFileTaskProtoLoader("examples.tasks.v1"),
}).
Build()
if err != nil {
return err
}
This is the right integration point for applications that already centralize runtime construction through pkg/engine.
Use provider registration when a module should be selected from xgoja.yaml, built into generated xgoja binaries, and included in generated TypeScript declarations.
func Register(registry *providerapi.ProviderRegistry) error {
return registry.Package("task-protos", providerapi.Module{
Name: "examples.tasks.v1",
Description: "Generated builders for task protobuf messages",
TypeScript: taskpb.GojaBuilderFileTaskProtoTypeScriptModule("examples.tasks.v1"),
NewModuleFactory: func(providerapi.ModuleSetupContext) (require.ModuleLoader, error) {
return taskpb.NewGojaBuilderFileTaskProtoLoader("examples.tasks.v1"), nil
},
})
}
Then select it in xgoja.yaml:
modules:
- package: task-protos
name: examples.tasks.v1
This gives JavaScript authors a typed require("examples.tasks.v1") module and lets xgoja gen-dts include the generated declarations.
Generated modules expose a RawDTS descriptor because protobuf builders need rich object, enum, map, repeated, and oneof types. Hosts do not have to hand-write declarations; use the generated TypeScriptModule helper.
mod := taskpb.GojaBuilderFileTaskProtoTypeScriptModule("examples.tasks.v1")
When the module is registered with xgoja, declaration generation can be run from the xgoja project:
xgoja gen-dts -f xgoja.yaml --out js/types/xgoja-modules.d.ts --strict
The resulting declarations include message interfaces, builder interfaces, enum objects, repeated helpers, map helpers, optional-presence helpers, and real-oneof helpers.
The runtime intentionally accepts only the inputs that preserve type meaning. This keeps protobuf construction predictable and avoids silently converting arbitrary JavaScript objects into arbitrary message graphs.
| Input target | Accepted JavaScript values |
|---|---|
| Ordinary message field | Generated protobuf message value or generated builder value. |
google.protobuf.Timestamp | JavaScript Date or RFC3339 string. |
google.protobuf.Duration | Go duration string such as "1h2m3s". |
google.protobuf.Struct | Plain JavaScript object. |
google.protobuf.Value | JSON-shaped JavaScript value. |
google.protobuf.ListValue | JavaScript array. |
| Wrapper types | Wrapped scalar value. |
google.protobuf.FieldMask | Comma-separated string or string array. |
google.protobuf.Any | Generated protobuf message value or builder value. |
| 64-bit integers | Safe JavaScript number or string; prefer string outside the safe integer range. |
Plain objects are not accepted for ordinary message fields. Build nested messages explicitly:
const task = pb.Task.builder().title("nested").build()
const envelope = pb.TaskEnvelope.builder().task(task).build()
or pass the builder directly when you want the parent setter to build it:
const envelope = pb.TaskEnvelope.builder()
.task(pb.Task.builder().title("nested"))
.build()
The repository contains a compiled provider example that exercises the full path from .proto to JavaScript and back to Go:
examples/xgoja/15-protobuf-builder-provider/
Run it with:
cd examples/xgoja/15-protobuf-builder-provider
make smoke
The example generates protobuf code, registers a provider module, runs JavaScript from scripts/build-task.js, and verifies concrete *taskpb.Task and *taskpb.TaskEnvelope extraction in Go tests.
| Problem | Cause | Solution |
|---|---|---|
protoc-gen-goja-builder: program not found | The plugin is not on PATH. | Run go install github.com/go-go-golems/go-go-goja/cmd/protoc-gen-goja-builder@latest or install from your local checkout. |
require("...") fails | The generated module loader was not registered with the runtime's require registry. | Call the generated RegisterGojaBuilderFile...Module, use engine.NativeModuleRegistrar, or register an xgoja provider module. |
| Go receives a plain object instead of a protobuf message | JavaScript code serialized or copied the object instead of passing the built value. | Pass the result of .build() directly and recover it with protogoja.MessageFromValue. |
| Ordinary nested message setter rejects a plain object | Plain objects are intentionally accepted only for JSON-shaped WKTs. | Build the nested message with its generated builder and pass the message or builder. |
Task_LabelsEntry or another map-entry symbol appears in generated API | The generator is too old and is emitting synthetic map-entry messages. | Regenerate with a version that skips Descriptor.IsMapEntry() messages. |
| TypeScript output misses the module | The provider did not expose TypeScript or xgoja.yaml did not select the module. | Use the generated GojaBuilderFile...TypeScriptModule(moduleName) helper in provider registration. |
cmd/protoc-gen-goja-builder/README.md — generator-specific command and host integration reference.examples/xgoja/15-protobuf-builder-provider/ — compiled end-to-end provider example.pkg/protogoja — runtime package for message refs, prototype refs, and builder refs.xgoja help tutorial-protobuf-builder-provider — xgoja-focused provider tutorial once the xgoja CLI docs are available.goja-repl help typescript-declaration-generator — shared TypeScript declaration generation guide.