Host Goja HTTP routes with an Express-style JavaScript API
The express module exposes a small Express-style route registration API for Goja-hosted applications. It is intentionally Express-style, not full Express-compatible: it supports Go-owned planned auth routes, Go-backed handler mounts, static mounts, and response helpers, but not middleware stacks, routers, next(), template engines, or npm Express plugins.
express is runtime-scoped because it needs a configured gojahttp.Host and runtime owner. Install it with modules/express.NewRegistrar(host) rather than through the default native module registry.
host := gojahttp.NewHost(gojahttp.HostOptions{
Dev: true,
Renderer: uidsl.RenderAny,
})
factory, err := engine.NewRuntimeFactoryBuilder().
WithModules(
express.NewRegistrar(host),
uidsl.NewRegistrar(),
).
Build()
pkg/gojahttp owns the reusable HTTP host, route matching, request/response DTOs, body parsing, sessions, static mounts, and dispatch into the Goja runtime. The host is renderer-neutral; HostOptions.Renderer decides how res.html(value) renders non-string values.
const express = require("express");
const ui = require("ui.dsl");
const app = express.app();
app.get("/hello/:name")
.public()
.handle((ctx, res) => {
return ui.page(
{ title: "Hello" },
ui.h1("Hello " + ctx.params.name)
);
});
app.post("/api/echo")
.public()
.handle((ctx, res) => {
res.status(201).json({ body: ctx.body });
});
Handlers may call res.* explicitly. If a handler returns a string, the host sends it as text/HTML depending on content. If a handler returns any other non-null value, the host calls res.html(value) and uses the configured renderer. Planned handlers receive (ctx, res); the original request DTO is available as ctx.request.
app.get(pattern)
.public()
.handle(handler)
app.post(pattern)
.auth(express.user().required())
.csrf()
.allow(action)
.audit(event)
.handle(handler)
app.patch(pattern)
.auth(express.user().required())
.resource(express.resource(type).idFromParam(paramName))
.allow(action)
.audit(event)
.handle(handler)
app.route(method, pattern)
.public()
.handle(handler)
app.mount(prefix, mountableHandler, options?)
app.mountHandler(prefix, mountableHandler, options?)
app.static(prefix, directory)
app.staticFromAssetsModule(prefix, assetsModule, root)
app.get, app.post, app.put, app.patch, app.delete, and app.all are planned-route builders. They intentionally do not support the old app.get(pattern, handler) overload. Existing scripts must migrate public endpoints to app.get(pattern).public().handle(handler) and protected endpoints to an auth-aware chain.
app.mount(prefix, mountableHandler, options?) mounts a Go http.Handler that another native module exposed as a mountable JavaScript object. This is useful for Go-owned transports such as WebSocket servers. The mounted handler uses prefix matching and preserves the original request path by default.
const express = require("express");
const app = express.app();
// wsServer is a JavaScript object carrying a hidden Go http.Handler ref.
app.mount("/ws", wsServer);
// Use stripPrefix when the mounted handler expects paths relative to the mount.
app.mountHandler("/api", apiHandler, { stripPrefix: true });
Mount options:
type MountOptions = {
stripPrefix?: boolean;
excludePrefixes?: string[];
};
app.static(prefix, directory) serves a real host filesystem directory. Static helpers preserve their historical behavior and strip the mount prefix before the file server sees the request.
app.staticFromAssetsModule(prefix, assetsModule, root) serves files directly from a read-only embedded fs module, for example:
const express = require("express");
const assets = require("fs:assets");
const app = express.app();
app.staticFromAssetsModule("/static", assets, "/app/public");
Route patterns support exact paths, :params, and * wildcards. Parameter segments capture one path segment into req.params; for example /hello/:name exposes req.params.name. A * segment matches the remainder of the path, but currently does not expose a captured splat value. Handler mounts use prefix matching rather than route-pattern matching.
app.mount() is intentionally a prefix handoff to a Go http.Handler, not a second JavaScript route-pattern system for Go-owned transports. Use a stable mount prefix in JavaScript and let the mounted Go handler interpret the remaining path.
const express = require("express");
const app = express.app();
// Matches /ws and /ws/... . The Go handler receives the original path by default.
app.mount("/ws", sessionstream.webSocket.server(hub));
The mounted handler can route by inspecting r.URL.Path:
func websocketHandler(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/ws":
serveDefaultSocket(w, r)
case strings.HasPrefix(r.URL.Path, "/ws/rooms/"):
roomID := strings.TrimPrefix(r.URL.Path, "/ws/rooms/")
serveRoomSocket(w, r, roomID)
default:
http.NotFound(w, r)
}
}
Or it can use its own Go router, including the Go standard library http.ServeMux path-value patterns:
mux := http.NewServeMux()
mux.HandleFunc("GET /ws", func(w http.ResponseWriter, r *http.Request) {
serveDefaultSocket(w, r)
})
mux.HandleFunc("GET /ws/rooms/{roomID}", func(w http.ResponseWriter, r *http.Request) {
roomID := r.PathValue("roomID")
serveRoomSocket(w, r, roomID)
})
mux.HandleFunc("GET /ws/assets/{path...}", func(w http.ResponseWriter, r *http.Request) {
assetPath := r.PathValue("path")
serveAssetSocket(w, r, assetPath)
})
Use { stripPrefix: true } only when the mounted Go handler expects paths relative to the mount point. Leave it unset for handlers that route on their full public path.
All verb helpers now use planned routes. Planned routes use staged Go-backed builder objects: JavaScript gets a fluent API, but the security-critical route plan is compiled and validated by Go at registration time. app.route(method, pattern) remains available for dynamic or uncommon HTTP methods; prefer app.get(pattern), app.post(pattern), and the other verb helpers for normal routes.
A public planned route must explicitly call .public() before .handle(...):
const express = require("express");
const app = express.app();
app.get("/healthz")
.public()
.handle((_ctx, res) => res.json({ ok: true }));
An authenticated route declares its auth mode and permission action before the handler is registered:
app.get("/me")
.auth(express.user().required())
.allow("user.self.read")
.handle((ctx, res) => {
res.json({ id: ctx.actor.id });
});
A resource-bound route declares where the resource identity is extracted from the HTTP adapter layer. idFromParam("projectId") means “read the resource id from :projectId”, not “perform authorization in JavaScript”. The host's Go ResourceResolver and Authorizer still own resource loading and access control.
app.patch("/orgs/:orgId/projects/:projectId")
.auth(express.user().required())
.resource(
express.resource("project")
.idFromParam("projectId")
.tenantFromParam("orgId")
.mustExist()
)
.allow("project.update")
.handle((ctx, res) => {
const project = ctx.resource("project");
res.json({ project: project.id, tenant: project.tenantId });
});
The builder is intentionally strict:
.handle(...) is not available until a route calls .public() or .auth(...).allow(...)..auth(...) only accepts a Go-backed value returned by express.user()..resource(...) only accepts a Go-backed value returned by express.resource(type)..csrf() requires a host CSRFProtector on unsafe methods and rejects bad tokens before the handler runs..audit(event) emits host-owned audit events for allowed, denied, completed, and failed planned requests..idFromParam("id") on /projects/:projectId, fails at route registration time.Planned handlers receive (ctx, res):
type PlannedContext = {
request: Request;
actor: { id: string; kind: string; tenantIds?: string[]; claims?: Record<string, unknown> } | null;
body: unknown;
params: Record<string, string>;
resources: Record<string, ResourceRef>;
resource(name: string): ResourceRef | null;
action: string;
routeName: string;
};
Host applications configure planned auth through gojahttp.HostOptions.Auth:
host := gojahttp.NewHost(gojahttp.HostOptions{
RejectRawRoutes: true,
Auth: gojahttp.AuthOptions{
Authenticator: myAuthenticator,
Resources: myResourceResolver,
Authorizer: myAuthorizer,
CSRF: myCSRFProtector,
Audit: myAuditSink,
},
})
Missing auth services fail closed for authenticated planned routes. Missing CSRF services fail closed for unsafe routes that declare .csrf(). Missing credentials return 401, denied authorization and CSRF failures return 403, and resource lookup failures can return 404 via gojahttp.ErrNotFound. RejectRawRoutes rejects any matched low-level route that lacks a RoutePlan; enable it for production hosts that should only serve planned routes.
Planned handlers can inspect the original request DTO through ctx.request:
type Request = {
method: string;
url: string;
path: string;
query: Record<string, string | string[]>;
params: Record<string, string>;
headers: Record<string, string>;
cookies: Record<string, string>;
session: { id: string; isNew: boolean; cookieName: string } | null;
ip: string;
body: unknown;
rawBody: string;
};
JSON and form bodies are parsed automatically. Other request bodies are exposed as strings.
res.status(code)
res.set(name, value)
res.type(contentType)
res.json(value)
res.send(value)
res.html(value)
res.redirect(url)
res.redirect(status, url)
res.end()
res.html(value) requires a renderer in gojahttp.HostOptions. With modules/uidsl.RenderAny, route handlers can return or send ui.dsl nodes directly.
| Problem | Cause | Solution |
|---|---|---|
app.get(pattern, handler) was removed | The route uses the old raw handler overload. | Use app.get(pattern).public().handle(handler) or an auth-aware chain. |
.handle is not a function | The route has not declared .public() or completed .auth(...).allow(...). | Add the missing route-plan stage before .handle(...). |
| Authenticated route returns 500 | The Go host is missing auth services. | Configure gojahttp.HostOptions.Auth with an authenticator and authorizer. |
.csrf() route returns 500 | The route declares CSRF but the host has no CSRFProtector. | Configure Auth.CSRF or remove .csrf() from the route. |
Handler cannot read req.query or req.session | Planned handlers receive ctx, not raw req. | Use ctx.request.query or ctx.request.session. |
Raw route returns raw routes disabled | HostOptions.RejectRawRoutes is enabled and a low-level route without a plan matched. | Register the route through the planned Express API or Host.RegisterPlanned. |
app.get(path, handler) scripts.examples/xgoja/18-express-auth-host — Runnable planned auth route example.examples/xgoja/20-express-hello-world — Minimal no-auth public-route example.examples/xgoja/18-express-auth-host — Runnable Go host example for auth, resources, CSRF, audit, and strict mode.