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 — file I/O, project loading, git, component discovery, the AI proxy — goes through the registered adapter; 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? |
| 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; translating them to whatever the backend expects (a server-root prefix, an absolute path, a repo path) is the adapter's job. 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.
createDestination
is a declaration, not a method: 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 |
|---|---|---|
| Live sync | subscribeFileEvents
,
collab |
Sidebar is manual-refresh; editing is solo with file-level saves |
| Install pipeline | installDependencies
,
dependenciesNeedInstall
,
outdatedPackages
,
setPackageVersions |
Install/update affordances hide; 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 |
| Data + secrets | dataConnections
,
dataConnectionTest
,
dataPush
,
dataRows
, row CRUD,
listSecrets
,
setSecrets |
The Data grid and connection/push/secret controls hide |
| Publish | cfConnection
,
cfConnect
,
cfApi
,
createPullRequest |
Publish panel explains git-push publishing; PRs go via user token |
| Desktop shell | getAppInfo
,
openProjectInNewWindow
,
newWindow
,
setWindowProject
,
getProjectRoot
, recents, settings |
Single-window; recents and settings persist in
localStorage |
| Cloud identity | getUser
,
getAccountStatus
,
listRepos
,
importProject |
No signed-in identity or repository picker |
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.
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
). Omit the member when
canPickDirectory()
is false rather than defining one that always returns null, so Studio hides the button instead of showing a dead one.
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 exactly this — a four-line init bundle injected ahead of the Studio bundle:
// packages/desktop/src/init.ts — loaded before studio.js
import { registerPlatform } from "@jxsuite/studio/platform";
import { createDesktopPlatform } from "./platform";
registerPlatform(createDesktopPlatform());
If nothing has registered by the time Studio boots, it self-registers the dev-server adapter:
if (!hasPlatform()) registerPlatform(createDevServerPlatform())
. So an embedder that serves the HTTP protocol needs no registration code at all, and one that doesn't must win the race by loading its init script first.
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.
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 and strips from responses (
serverPath
/
stripRoot
). 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(encodeURIComponent(dirHandle.name)}`);
// …
}
Desktop (
packages/desktop/src/platform.ts
)
The desktop adapter translates the same interface into ElectroBun RPC: each member is a one-line
rpc.request.*
call into Bun-side handlers (
openProject()
is literally
return await rpc.request.openProject()
, backed by a native file dialog in the Bun process). Beyond the mapping, it patches
window.fetch
so the runtime's dev-proxy endpoints (
/__jx_resolve__
,
/__jx_server__
) also ride the RPC bridge, and it implements the desktop-only families — multi-window, backend-persisted recents and settings,
getAppInfo
.
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