Skip to content

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 one member that carries binary. 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 Library) 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.

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.

Warning

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.

Note

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.

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
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
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; PRs go via user token; 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
Desktop shell getAppInfo , openProjectInNewWindow , pickProject , 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.

Tip

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 .

Note

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 ). Omit the member when canPickDirectory() is false, so Studio hides the button. One that always returns null leaves a dead button on screen.

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 with 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:

  1. The user triggers Open Project ; Studio calls getPlatform().openProject() .

  2. 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 containing project.json .

  3. It returns { config, handle: { root, name, projectConfig } } , or null if the user cancelled (never throw for a cancel).

  4. 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.

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> 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(encodeURIComponent(dirHandle.name)}`);
  // …
}

Desktop ( packages/desktop/src/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 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 .

Desktop, Chromium build ( packages/desktop/src/chromium/platform.ts )

The NixOS build runs Studio in a Chromium --app window and talks to its launcher over a WebSocket instead of ElectroBun's bridge, so it is a second adapter over the same handler names , one request(method, params) helper per member. It is a useful thing to read if you are writing your own: it implements the same optional families as the ElectroBun adapter, over a completely different transport, and the two are checked against one declaration ( packages/desktop/tests/_rpc-parity.ts ).

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 buildSite over 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. This adapter deliberately omits 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.