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

Note

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.

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.

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.

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 ), 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 __jxCloud was 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:

  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.

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