Dev server internals
@jxsuite/server
is the reference implementation of the
backend protocol
: a Bun-native dev server whose
/__studio/*
mount is the literal route table, plus the proxies and services that make documents behave in development as they do in production. This page is the architecture tour for backend implementers. For using the server day to day, see
the dev server
in the Framework section.
One handler, an ordered route chain
createDevServer()
stands up a single
Bun.serve
fetch handler that checks routes in a fixed order; the first claimant wins, and anything unclaimed falls through to static file serving:
/__reload: the SSE live-reload endpoint (skipped whenwatch: false).POST /__jx_resolve__: the$prototype/$srcresolution proxy.POST /__jx_server__: thetiming: "server"function proxy./_jx/*: extension server mounts./__studio/*is the Studio API (skipped whenstudio: false): the collab WebSocket upgrade,activate, then the AI, site-import, and code sub-APIs, thenhandleStudioApifor everything else.Custom
middleware, if the embedder passed one.Static files: project files at their natural URLs, the active project's tree,
public/at the site root, and bare npm specifiers resolved throughnode_modulesand bundled on demand.
Ordering is load-bearing: the privileged endpoints must be claimed before the static fallback can ever see their paths, and
middleware
deliberately sits after the protocol routes so an embedder cannot accidentally shadow them.
The Studio API mount and activation
handleStudioApi
(in
studio-api.ts
) implements the protocol's route table against the filesystem. Its central piece of state is the
active project root
:
POST /__studio/activate
stores an absolute path, and from then on file operations, static serving, and format dispatch resolve against it. This is what lets one server open projects that live outside its own
root
.
Format and extension behavior is served from a per-project
extension registry
, built by scanning the project's dependencies and cached against
project.json
's mtime. Edit the manifest and the next request rebuilds the registry. The
formats
,
format
, and
project-schemas
routes all answer from it.
A host embedding
handleStudioApi
directly passes
onFileMoved
alongside
allowedRoots
and
onProjectCreated
.
createDevServer
wires it to the collaboration registry, and a host running its own collab layer must do the same: a live room is keyed by path, so a rename moves the file out from under it and the room's shutdown flush would write its pre-rename content back, recreating the file the rename deleted. It is called with the OLD absolute path, and it is the same hook the file watcher already uses.
Which root a path is measured against
Two roots are in play once a project is activated: the root the server was started on, and the project Studio bound to it. The API's convention is one sentence, and it is worth reading twice because the two are usually the same and the difference only shows up when they are not:
A path you send is relative to the server root. A path you get back is relative to the active project.
So
?path=
,
?dir=
and a rename's
from
/
to
all resolve against the server root, while the file lists, the reference reports and the refactor reports all name files relative to the project. The refactor routes are where this bites: their sweep runs inside the project, so
/__studio/references
re-expresses your target into the project's space before scanning, and a target that lands outside the project comes back as a
400
. It is never a
200
carrying zero references, which would answer a question nobody asked.
The resolve proxies
resolve.ts
implements the two endpoints the runtime uses to run server-side code during development.
handleResolve
takes a
$prototype
/
$src
entry, imports the module server-side (
.js
directly;
.class.json
via its
$implementation
, or a class constructed from the schema), instantiates it with the config, and returns the resolved value.
handleServerFunction
imports a module and invokes a named export with an arguments object. Both do dynamic
import()
of project code. They are remote-code-execution surfaces by design, which is why the security model below exists.
code-api.ts
adds the code services behind the
codeService
platform member:
oxfmt
formatting,
oxlint
diagnostics, and
Bun.Transpiler
minification for the function-body editor.
Extension server mounts
jx-mounts.ts
dispatches
/_jx/*
to extension classes that declare a
server
block, the same fetch-style handlers the generated site worker mounts in production, so data-backed documents work identically in both environments. In development it adds conveniences:
env
is
process.env
merged under the project's
.dev.vars
plus
JX_PROJECT_ROOT
, connectors with a
local
provider get stood in locally (D1 becomes SQLite under
.jx/data/
), and table schemas sync additively on first touch. See
server mounts
for the extension-author side.
The security model
The dev server and the desktop's
createProjectServer
share one set of primitives (
packages/server/src/net-guard.ts
); the dev server applies all but the token:
Loopback bind (
127.0.0.1) by default is the primary control, because other local processes and LAN pages can't read a loopback page's location. Ahostnameoption (jx dev --host) can widen the bind for containers, but that removes the control and should only be used behind trusted isolation.Origin/Host gate guards every privileged surface: the RCE-capable
/__jx_resolve__//__jx_server__routes, the/_jx/mounts, and the whole/__studio/*API. A loopback (or absent) Origin passes; a cross-origin Origin or a rebinding Host is rejected. The browser Studio and the served site are same-origin, so they pass; a malicious external page does not. The dev server needs no token because it is same-origin; the desktop server, whose canvas iframe is cross-origin, adds a per-server token on top.Fetch Metadata is read on the same surfaces.
Sec-Fetch-Sitesays what a request intends , whichOrigincan't: a same-originGETsends noOriginat all, so that check has to accept an absent one.same-originandnonepass;cross-sitepasses only as a top-level document navigation (a person clicking a link);same-siteis rejected , because on loopback there's no "site" bigger than the origin, sosame-sitemeans another port on your machine . A request with noSec-Fetch-*at all passes, because curl, Bun's fetch and the desktop bridge don't send it and the threat here is a browser page, which always does.File containment (
containedPath) pairs a lexicalrelative()check with arealpathre-check, so a../or absolute path can't escape and a symlink can't point outside the project root. It guards static serving,assertAccessiblefor the filesystem API, and every$src/$base/$implementationresolved before a dynamicimport(). Over-encoded paths are rejected after a single decode.Two-root activation :
assertAccessible(filePath, root, activeProjectRoot)allows a path under the server root or the project root Studio bound viaPOST /__studio/activate. Activation itself accepts four kinds of root: one contained under the server root, an explicitallowedRootsentry, a project this server just created, or a project the account already owns (an absolute directory holding aproject.jsonunder the user's home directory). That last kind is what opens an existing project: projects normally live outside whatever tree the server happens to serve. Anything else is a403, and a refused activation is an error to show the user. The endpoints that take nodir(the git surface especially) fall back to the server's own root, so swallowing the refusal would quietly act on the wrong tree.
The resolve proxies import and execute arbitrary project code by design. Keep the loopback bind, and if you must widen it, put the server behind trusted isolation. The Origin/Host gate assumes the network itself is not hostile.
One route is exempt from the token, and only from the token: the OAuth callback
(
/__jx_oauth__/callback
) on the desktop's project server. An identity provider redirects the user's own browser there, and a page cannot append a secret to a URL it does not compose. A token gate would make sign-in impossible rather than safe. The
state
parameter does that job: unguessable, single-use, short-lived, compared in constant time. The Host and Fetch Metadata checks still apply, and an IdP redirect is exactly the one cross-site shape they admit, a top-level document navigation.
No response ever carries an
Access-Control-Allow-
header.
The entire model rests on the browser refusing cross-origin reads, so one CORS header would give that away. A check enforces the absence rather than trusting it; nothing in the code makes a load-bearing absence visible.
Related
The dev server : the user-level page, covering options, live reload, and the proxies from the document author's view
The backend protocol : the contract this server is the reference for
Protocol route reference : every
/__studio/*route it servesExtension security model : the trust boundaries extensions themselves live under