Writing a platform adapter
A platform adapter is a plain JavaScript object implementing the
StudioPlatform
interface, registered once before Studio boots. Every backend-touching operation in Studio goes through the registered adapter: file I/O, project loading, git, component discovery, the AI proxy. Studio itself never fetches a backend URL directly. Write one when your host can't simply serve the HTTP protocol (see the
embedding overview
for that decision).
The authoritative interface is
StudioPlatform
in
packages/studio/src/types.ts
. It is wider than the sketch in the desktop spec §3.1. The real interface adds git, packages, collaboration, the data surface, and publish members on top of the original file and project operations.
The interface surface
Core members are required on every adapter. Grouped by family:
| Family | Members |
|---|---|
| Identity | id
,
projectRoot
(get/set),
canvasUrl?
,
documentBaseUrl?
,
assetSpace?
,
assetCapabilities? |
| Session/project | activate
,
openProject
,
probeRootProject
,
createDestination
,
createProject
,
resolveSiteContext |
| Filesystem | listDirectory
,
readFile
,
writeFile
,
uploadFile
,
deleteFile
,
renameFile
,
createDirectory
,
locateFile
,
searchFiles
,
discoverComponents |
| Git | gitStatus
,
gitBranches
,
gitLog
,
gitStage
,
gitUnstage
,
gitCommit
,
gitPush
,
gitPull
,
gitFetch
,
gitCheckout
,
gitCreateBranch
,
gitDiff
,
gitShow
,
gitDiscard
,
gitInit
,
gitAddRemote |
| Packages | listPackages
,
addPackage
,
removePackage |
| Services | codeService
,
fetchPluginSchema
,
aiChatUrl |
All paths passed into adapter methods are project-relative, and so is every path an adapter method returns, including the ones inside a refactor report. Translating them to whatever the backend expects (a server-root prefix, an absolute path, a repo path) is the adapter's job, and it is a
one-directional
job: translate the request or translate the reply, not both. Doing both quietly assumes the backend echoes your own input space back at you, which is a property of one particular route rather than of the protocol. It is also wrong in a way nothing tests, because a strip whose prefix is the project root's own name eats the first segment of a path that legitimately starts with it. A core member may still answer "not available" through its return type (
codeService
resolves
null
on platforms without code tooling), but the member itself must exist.
uploadFile
is the member that carries binary INTO the project (
readFileBytes
, below, is the way back out). It takes
string | File | Blob | ArrayBuffer
, and every caller (the image field's Upload button, a file dropped on the canvas or the Files panel, the Media workspace) hands it whatever the browser gave them, usually a
File
. If your transport is HTTP you can post that body straight through.
If your transport serializes its arguments
(JSON over RPC or a WebSocket, as the desktop adapters do), a
File
becomes
{}
on the wire: base64-encode it in the adapter before the call and decode it in the backend.
@jxsuite/studio/base64
exports
toBase64
for exactly this, and it passes a
string
through untouched so callers that already hold base64 keep working.
It answers
UploadResult
(
{ path, size? }
), and
path
is the answer, not an echo
. Report where the bytes really landed: a store that de-duplicates by content hash, appends a collision suffix, or normalizes a name writes somewhere other than the path it was asked for, and the reference Studio puts in the document has to name the file that exists. A backend that writes exactly where it was told still reports it, so no caller has to know which kind of backend it is talking to.
readFileBytes
is the read side of that, and it is optional.
readFile
answers a string, which means UTF-8, so every byte that is not part of a valid UTF-8 sequence comes back as a replacement character: a JPEG through
readFile
is not a damaged JPEG, it is a run of U+FFFD that nothing can decode. An adapter that implements
readFileBytes
answers an
ArrayBuffer
instead, and Studio can then decode a project image. If your transport serializes its arguments, send base64 back and decode it with
base64ToBytes
from
@jxsuite/studio/base64
, which is the mirror of the
toBase64
above.
Serving the file at a URL is not a substitute. Studio can display an image from a URL, but reading its pixels back means drawing it into a canvas, and a canvas that has drawn a cross-origin image is tainted: reading it throws. The desktop adapters are exactly that case, because the canvas origin is a per-window loopback server while the shell document is not. CORS is not the escape hatch either, since the loopback security model depends on the browser refusing cross-origin reads. Bytes handed back through the adapter avoid the question entirely.
documentBaseUrl
is the other declaration worth knowing about. The canvas renders in an iframe and resolves a component
$ref
by fetching it (
readFile
is not reachable from that realm), so
project files have to exist at a URL
. The default base is
<canvas origin>/<projectRoot>/
, which is already correct for any backend that serves the project tree from its web root: the dev server does, and so does the desktop's loopback server.
Set
documentBaseUrl
when your
projectRoot
is an
identifier instead of a served path
. Jx Cloud's is
owner/repo@branch
, so the default addressed nothing and every
$ref
fetch missed. Point it at whatever route serves your project tree, ending in
/
; Studio appends the project-relative path to it.
If your host answers a missing file with a single-page fallback (the app shell at
HTTP 200
where a 404 belongs), a wrong base does not fail cleanly. The fetch succeeds, and the renderer reports
Unexpected token '<', "<!doctype "…
from the JSON parser. Studio now names that case explicitly, but the cure is a base that resolves.
assetSpace
says what your ORIGIN answers for a
site URL
. A host whose files are perfectly reachable through
readFile
can still need this, because what decides it is what answers
GET /hero.jpg
on the document the canvas is running in.
Leave it absent when that origin already serves the published site URL space. The dev server and the desktop loopback both do, so neither declares anything and
/hero.jpg
resolves natively.
Set it to
"repo"
when nothing does, and set
documentBaseUrl
with it.
"repo"
is inert on its own, because a host that says its site URLs are wrong without saying what is right has told Studio nothing it can act on. Studio then resolves every authored reference to the
project file
it names and addresses that file under your base:
/hero.jpg
is
public/hero.jpg
, and a content entry's
./images/hero.png
is
content/posts/images/hero.png
. Both are real repository paths, so you need no
public/
→root mapping, no asset-mount mapping, and no route beyond the one already serving project files.
A site URL is resolved the way a
build
would resolve it. The editing servers do it differently:
serveProjectFile
tries the project root before
public/
, so a file at
<root>/hero.jpg
loads at
/hero.jpg
in a dev preview and 404s on the deployed site. With no filesystem to probe, the canvas has to pick one answer, and the one that makes the preview agree with production is the build's.
A
listDirectory
entry may also carry
version
: an opaque token for the file's bytes, where two equal versions mean identical bytes. Set it only when your backend can vouch for those bytes, and leave it out for an uncommitted, dirty or just-uploaded file. In repo space Studio then requests that file as
<documentBaseUrl><path>?v=<version>
. Your route may answer it with an immutable cache lifetime only when
v
is a version you issued and you return exactly the bytes it names, so a page full of images repaints from the browser cache. A version names bytes, not a moment, so this holds for an older version too. Answer a
v
you do not recognize (one typed into a document by hand, say) and every unversioned URL exactly as you do today. Because the browser keeps an immutable answer for good, an old versioned URL goes on yielding the old bytes after the file changes, so keeping versions true is Studio's job: it forgets a version on every file event that names the file, and forgets them all when your event stream has a gap. So keep reporting writes through
subscribeFileEvents
, report a reopened stream as
onResync("reconnect")
, and report its first open as
onResync("open")
. A backend that sets no
version
loses nothing.
assetCapabilities
declares what your backend will accept as an upload:
maxUploadBytes
and an
accept
string in
<input accept>
syntax. Both are optional and absence means "no declared limit": Studio will not invent one, because a limit it made up is a file the user cannot upload for no reason anyone can name. Declare a limit and Studio refuses oversized files before spending the round trip, naming the number, and narrows the file picker to your
accept
. Nothing widens it.
createDestination
is a value your adapter declares: set it to
"path"
if your backend writes projects to a filesystem, or
"repo"
if a project is a remote repository. Studio uses it to decide which destination fields the New Project modal collects, and hands the answer back to
createProject
as
opts.destination
.
Your adapter must honor that destination and must not substitute one of its own
. A create with no usable destination is an error, not a cue to fall back to a default directory or account.
Optional members and degradation
Everything else on the interface is optional, and each optional member maps to an optional protocol route whose
degradation
field describes exactly what Studio does without it. Omit what your backend can't support:
| Family | Members | When absent |
|---|---|---|
| Binary reads | readFileBytes |
Studio can only read a project file as text, so any surface that must decode one stays hidden |
| Live sync | subscribeFileEvents
,
collab |
Sidebar is manual-refresh; editing is solo with file-level saves |
| Install pipeline | installDependencies
,
dependenciesNeedInstall
,
packageVersions
,
setPackageVersions |
Install/update affordances hide; the Packages table's Latest column stays empty; manifest-only edits still work |
| Catalogue/scaffold | listProjects
,
listStarters
,
importSite
,
pickDirectory
,
gitClone |
Welcome-screen catalogue, starters, import, and clone flows hide; without
pickDirectory
the New Project
Location
field is typed by hand |
| Formats/schemas | listFormats
,
listExtensions
,
fetchProjectSchemas
,
formatAction
,
resolveClass |
Only
.json
documents open; editors fall back to bundled schemas |
| Extension catalogue | listExtensionCatalog |
The Extensions section offers no catalogue, so an extension is added by typing its package name and installing it separately |
| Data + secrets | dataConnections
,
dataConnectionTest
,
dataPush
,
dataRows
, row CRUD,
listSecrets
,
setSecrets |
The Data grid and connection/push/secret controls hide |
| Publish | cfConnection
,
cfConnect
,
cfApi
,
cfAccounts
,
cfSelectAccount
,
cfDisconnect
,
createPullRequest |
Publish panel explains git-push publishing and Deploys lists nothing; without
createPullRequest
Send for review
is drawn disabled with the reason; without the account trio a brokered connection cannot be pointed at an account or revoked from Preferences |
| Site preview | previewSite
,
setPreviewOverlay
,
clearPreviewOverlay |
Open in Browser
falls back to
buildSite
; without the overlay pair a preview shows the last saved state |
| Site build | buildSite |
Build Site reports that this target cannot build, and Publish › Review changes offers no build check |
| Desktop shell | getAppInfo
,
openProjectInNewWindow
,
pickProject
,
newWindow
,
setWindowProject
,
getProjectRoot
, recents, settings |
Single-window; recents and settings persist in
localStorage |
| Cloud identity | getUser
,
getAccountStatus
,
listRepos
,
listRepoProjects
,
importProject |
No signed-in identity or repository picker; without
listRepoProjects
the picker lists no projects, and the reader names the project's folder (or leaves it for the root) |
One more field is a declaration rather than a member:
gitHosted: true
says this host's git is the remote's, so a commit lands on GitHub as it is made and each branch is a session of its own. The Publish workspace reads it to say what a commit and a branch switch will do, and asks before a switch reloads the window. Such a host should also report the repository's default branch as
defaultBranch
in what
gitBranches
returns:
Send for review
is refused on that branch, because a commit there is already live, and stays refused while the host has not said which branch it is. Leave
gitHosted
out for an ordinary working tree.
Studio always checks for presence before calling an optional member, so an omitted member is never an error. The protocol route reference is the complete degradation catalogue.
listExtensionCatalog
is worth a note, because it is the one member whose answer is about
your
backend rather than about the project. Not every host can run every extension: a Worker ships a fixed set of packages, and one it does not bundle is dropped from the registry before composition. So answer it yourself rather than serving a shipped list, and mark each entry with
bundled
when your host resolves the package without a project install and
installed
when the project resolves it. Offering an extension you cannot load gives the reader a switch that appears to work and does not.
previewSite
is what
Open in Browser
reaches first, and it does not build.
The contract is "serve the working tree as a site at real routes on an origin of your own, and name it in
url
". Compose each page from the project's files as it is asked for and let
@jxsuite/runtime
assemble it in the reader's browser. That is what makes it appear at once and show the author what they are looking at rather than what they last saved.
Honour
reused
.
Answer
true
when a client already holding this project's preview took the route you were given, and Studio opens nothing. A caller that opens a tab anyway leaves the author with two tabs on one project, which is the thing retargeting exists to prevent. Answer it only once a client has actually acknowledged: a frozen or back/forward-cached tab looks connected and will not act, and reporting
true
for one of those is a button that silently does nothing.
Take the overlay if you can hold it.
setPreviewOverlay(path, contents)
carries the bytes a save would write for a document the author has not saved, and a preview that reads them before the file is the whole difference between showing the canvas and showing the disk. Hold them in memory, prefer them at every read, and drop them on
clearPreviewOverlay
. An adapter that omits the pair still previews; it previews the last save.
buildSite
is now a separate action,
Build Site
, and still means "run the compiler". An adapter that cannot build at all (a hosted backend runs no project JS and has no bundler, image pipeline or filesystem) may answer it by rendering instead, reporting
mode: "live"
; omit
mode
, or send
"built"
, for compiler output. A live answer carries none of what the compiler emits: no prerendered HTML, no optimized images, no islands, no
sitemap.xml
or
_headers
.
collab
probes before it connects, and the probe decides more than availability.
Both bundled adapters GET the collab URL once and pass the
protocols
it lists to the wire client, which offers one as
Sec-WebSocket-Protocol
. An adapter that skips the probe and opens the socket directly must offer no subprotocol at all: a client whose offer goes unechoed fails the connection outright (
RFC 6455 §4.1
), so an unconditional offer breaks co-editing against every backend that predates negotiation. See
the backend protocol
.
Capabilities beyond the interface
Your host may be able to do things no other host can. Keep those
off
StudioPlatform
and let Studio feature-detect them on
globalThis.__jxPlatform
. That is how the desktop's
updater
and window controls work, and it is what lets the same Studio code run where they do not exist.
One consequence worth knowing before it bites you: annotating your factory's return type as
StudioPlatform
erases the extras from every caller, including your own tests. Let the type be inferred and assert conformance instead. That also stops the optional members you
did
implement from reading as possibly-absent:
export function createMyPlatform() {
const platform = {/* … interface members …, plus your extras */};
// On the identifier, not the object literal: a fresh literal gets excess property checks.
return platform satisfies StudioPlatform;
}
A browser-hosted adapter can still offer
pickDirectory
:
@jxsuite/studio/directory-picker
exports
canPickDirectory()
and
pickDirectoryPath(locate)
, which drive
showDirectoryPicker()
and hand you the picked folder's
name
plus the random id it wrote into a hidden
.jx-loc-id
there. Your
locate
callback resolves that pair to an absolute path (the dev server does it with
GET /__studio/locate-directory
), or
null
when nothing matches.
pickDirectoryPath
resolves
null
only when the user cancels. It rejects when it can't tag the folder or when
locate
answers
null
or throws, and Studio shows that message under
Location
. Hold your own
pickDirectory
to the same rule:
null
for a cancel, a thrown
Error
with a readable message for anything else. A
null
that means "failed" looks exactly like a cancel, so the user picks a folder and nothing happens. Omit the member when
canPickDirectory()
is false, so Studio hides the button.
Registration
Registration is a module-level setter backed by a global, so an adapter can be registered from a separate script bundle that loads before
studio.js
:
// packages/studio/src/platform.ts
const g = globalThis as unknown as { __jxPlatform?: StudioPlatform };
export function registerPlatform(platform: StudioPlatform) {
g.__jxPlatform = platform;
}
export function getPlatform() {
if (!g.__jxPlatform) {
throw new Error("No platform registered. Call registerPlatform() before starting Studio.");
}
return g.__jxPlatform;
}
The desktop app does this with a short init bundle loaded ahead of the Studio bundle. Its first import announces the launcher, and registration happens before anything asynchronous:
// packages/desktop/src/chromium/init.ts, loaded before studio.js
import { bootLauncher } from "./boot"; // first: publishes globalThis.__jxLauncher
import { hydrateGithubToken } from "@jxsuite/studio/github-auth-status";
import { createDesktopPlatform } from "./platform";
await bootLauncher({ create: createDesktopPlatform, hydrateGithubToken, launcher: "chromium" });
If nothing has registered by the time Studio boots, what happens depends on whether your page declared a boot module:
No boot module. Studio builds the cloud adapter when
__jxCloudwas published, and the dev-server adapter otherwise. An embedder that serves the HTTP protocol needs no registration code at all.A boot module, or an announced launcher. There is no fallback. If your init script throws before
registerPlatform, or finishes without registering, Studio shows a full-window boot-failure screen with the error and calls no backend. It used to fall back to the dev-server adapter, and a desktop window then reported "Failed to fetch" on Create Project while Browse… did nothing.
So register first, before any
await
, and call
announceLauncher()
from
@jxsuite/studio/platform
in your bundle's first import. Record a construction error on the signal it returns (
signal.error = { message, stack }
) so the failure screen can name it.
studioShellHtml({ boot })
writes the
jx-boot
marker that turns the fallback off, so you get this behavior by declaring
boot
.
The project-open flow
Opening a project is the one flow the adapter owns end to end, because the picking UI is inherently platform-specific:
The user triggers Open Project ; Studio calls
getPlatform().openProject().The adapter presents its own picker (a native file dialog on desktop,
showDirectoryPicker()in Chrome, a project list on cloud) and resolves the choice to a project root containingproject.json.It returns
{ config, handle: { root, name, projectConfig } }, ornullif the user cancelled (never throw for a cancel).Studio initializes project state from the handle: file tree, component registry, expanded directories.
On a host that can hold several windows, step 1 has a question in front of it. A window holds one project, so with one already open Studio asks
This Window
or
New Window
, but only when the adapter implements
pickProject
alongside
openProjectInNewWindow
.
openProject()
picks
and
binds: presenting the dialog re-roots the calling window's backend, which is right for This Window and fatal for New Window.
pickProject()
is the same picker with the binding left out: it resolves
{ root, name }
(or
null
for a cancel) and touches nothing, so Studio can hand the root to
openProjectInNewWindow(root)
and leave the asking window exactly as it was. Implement one without the other and Studio quietly stops asking, because it could not carry out the answer.
A host whose projects are repositories (
openProjectPicker: "repo-list"
) skips steps 1 to 3: Studio draws its own repository picker over
listRepos
, asks
listRepoProjects({ owner, name })
which folders of the chosen repository hold a
project.json
, and adopts the choice with
importProject({ owner, name, dir })
.
dir
is repository-relative with no leading or trailing slash, and absent for the repository root. Answer
listRepoProjects
with
{ branch, locations, truncated }
: each location is
{ dir, name? }
, the root first, and
truncated
says the repository was too large to list in full, which makes Studio tell the reader that a folder may be missing. Resolve
importProject
with a root key that names the folder, because the folder is part of the project's identity in Recent and in every later open.
Two supporting members round out the flow.
activate(root)
tells the backend which project root subsequent operations (and static file serving) should resolve against. The dev-server adapter calls it whenever
projectRoot
is set. It must
reject when the backend refuses the root
, rather than resolving quietly: operations that carry no explicit directory resolve against the backend's own root, so a swallowed refusal leaves the session reading and writing the wrong tree.
probeRootProject()
runs at startup to auto-detect whether the backend's root is itself a project, powering the zero-click open in dev mode.
Two real adapters
Dev server (
packages/studio/src/platforms/devserver.ts
)
The reference adapter is a stateless wrapper over
fetch
: every member maps 1
<1>1>
to a
/__studio/*
route, and its only state is the active project root, which it prefixes onto outgoing paths (
serverPath
). Whether a response needs the prefix stripped (
stripRoot
) depends on the route:
/__studio/files
echoes back the server-relative directory it was handed, while the refactor routes answer in the active project's own space and are passed through untouched. Its
openProject
shows how client-side picking meets a server-side backend. The browser picks a directory, then the adapter matches it to a server path:
// openProject, abbreviated: match the picked directory to a server-known project
const sitesRes = await fetch("/__studio/sites");
const sites = await readJson<SiteEntry[]>(sitesRes);
const match = sites.find((s: SiteEntry) => JSON.stringify(s.config) === JSON.stringify(config));
if (!match) {
// Project is outside dev server root — ask the server to find it by directory name
const findRes = await fetch(`/__studio/find-project?name=${encodeURIComponent(dirHandle.name)}`);
// …
}
Desktop (
packages/desktop/src/chromium/platform.ts
)
Settings are written as patches.
patchSettings({ set, remove })
changes only the keys it names and answers with the store as it then stands; a key named by neither must be left alone. That is a correctness rule and not an optimisation. A whole-map write means every writer implicitly claims the whole store, so a second window holding a different view of it silently overwrites the first. It also means a key your adapter has never heard of survives a write.
The desktop adapter translates the same interface into JSON-RPC over a WebSocket: each member is a one-line
request(method, params)
call into handlers that run beside the project, in a loopback project server of the window's own that only accepts its token. A native file dialog (
openProject()
) is the shell's to draw, so the project server asks the shell for it and never opens one itself. Beyond the mapping, the adapter implements the desktop-only families: multi-window, backend-persisted recents and settings,
getAppInfo
.
Studio runs in an Electron window (or, in a development checkout, a Chromium
--app
window) and talks to its project server over a WebSocket, so it is a good adapter to read if you are writing your own: one
request(method, params)
helper per member, the same optional families as any backend, and the handler names checked against one declaration (
packages/desktop/tests/_rpc-parity.ts
). Where Electron owns the window, a preload script exposes window controls as
globalThis.__jxShell
, and the adapter publishes them as
windowControls
when that bridge is present.
Two lessons from it generalize to any adapter:
A member you do not implement is a feature the user does not get, silently. Studio probes for methods and does not ask what kind of host you are, so the launcher answered
buildSiteover RPC for months while its adapter never exposed the method. Open in Browser reported that this backend could not preview the site. If you add a backend handler, add the member in the same change.Not every absent member is a gap. A build can deliberately omit the updater family (the system package manager owns updates, so there is no feed to report on) and
windowControls(the desktop environment decorates the window, so Studio must not draw its own buttons). Omission is how you say "not here"; the alternative is a control that does nothing.
Its transport also carries messages the launcher sends
unprompted
: a frame with a
method
and no request id. That is how
subscribeFileEvents
is fed, and how another window asks this one to come forward. If your host can push, a subscription member is a local handler plus a dispatch line. No polling needed.
Say when the stream has a gap.
subscribeFileEvents
takes a second argument,
{ onResync }
. Events are deltas, so if your transport can drop and come back (a WebSocket, an
EventSource
), whatever was sent while it was down is lost and no later event repairs it. Call
onResync("reconnect")
on every reconnect after the first open, and
onResync("commit")
when the backend changes the tree without sending per-file events. Studio then re-lists the folders it has loaded and, after a reconnect, re-reads the clean open documents, leaving any whose content did not change untouched. The Chromium shell above does this from its socket's
open
listener. A transport that cannot lose events, such as an in-process watcher, never needs to call it.
Related
Embedding overview : choosing between an adapter and the HTTP protocol
The backend protocol : the semantics your adapter must preserve
Protocol route reference : every route with optionality and degradation