Codelet logoCodelet

Live Share

A workspace shared with whoever opens a link — files, edits, carets, tabs and optionally terminals, with no server anywhere

live shares the workbench's workspace with whoever opens a link. One browser starts a session and hands out the link; whoever opens it reads the sharer's files, opens the same tabs, and sees everyone's caret where it is. There's no session on a server — the browser that started it hands out the seats, and frames pass between browsers over whatever transport you give it.

import { Workbench, FileSystem } from "codelet/workbench";
import { live } from "codelet/extensions/live";

const workbench = new Workbench({
  parent: document.getElementById("app")!,
  fs: new FileSystem({ "/README.md": "# Hello" }),
  extensions: [live()],
});

By default a session goes no further than the browser it started in: broadcastChannelGateway() reaches other tabs of the same origin and nothing else. That's the whole session working, between two of your own tabs, and it asks for nothing. To share with another machine, pass a gateway that leaves the page — see The Gateway seam.

Warning

Anyone with the link is in the session, and a guest can edit, create, rename and delete the sharer's files. There's no admission prompt and no read-only mode: the link is the whole of the access control, so treat it as a credential.

#Options

OptionTypeDefaultDescription
gateway() => GatewaybroadcastChannelGatewayWhat carries a session. Pass mqttGateway to share between machines. See Transports.
upgrade(bus: Gateway) => GatewayA bus over that bus, applied once the session is sealed. p2p() is the one that ships. See Connecting browsers directly.
userstringthe name the reader gaveThis reader's display name in the roster, decided by the page. Passing one skips the prompt too.
link(id: string) => stringthe page's own URL, no query, #live=idThe address a guest opens, built from the session's secret. See The link.
excludereadonly string[]Globs this browser never shares when it's the one sharing: never listed, never read, never mentioned in a change.
terminals"off" \| "read" \| "write""off"Whether the shells the terminal is showing cross too. See Sharing terminals.

#Starting and joining

The extension adds a Live Share entry to the activity bar. Its pane is where a session starts:

  • Start Session — begins one and shows the link, with a button to copy it and a button to open it in a second tab. A workspace with no files has nothing to admit anybody to, so the pane offers joining alone until there's a file.
  • Join Session — asks for a link in the workbench's own field. Paste a whole link, or the bare id out of one.

Five commands are in the palette: Live Share: Start Session, Live Share: Join Session, Live Share: Leave Session, Live Share: Change Display Name and Live Share: Focus Live Share. live.join is also how anything that already holds a link gets in — handed one it joins straight away instead of asking:

vscode.commands.executeCommand("live.join", "https://example.com/#live=…");

A tab opened on a link joins by itself, so handing out the link is usually the whole gesture. Sharing doesn't navigate the sharer anywhere — the link is handed out, not opened.

The status bar says which end this is and what it's running over — $(lock) Sharing (public mqtt, 3 peers) on the host, $(check) Joined (tab) on a guest, $(error) Live Share: <reason> for a session that ended. Clicking it opens the pane; while a session is up, the activity bar icon shows how many are in it. One button leaves either end: the sharer's Stop Session ends it for everybody, a guest's Leave Session gives up their own seat and goes back to their own files.

The first time this browser is in a session it asks for a display name, and remembers the answer in localStorage — that's what the roster, the caret's name tag and a file's badge in the explorer all say. Leave it empty and you're your seat instead: Host, Guest 2, Guest 3… To change it, click your own roster row and type over it, or run Live Share: Change Display Name. Passing user skips the question entirely: nothing prompts, and your row isn't a way to a name.

#What a guest sees

A guest's own workspace is left exactly as it was. The shared tree opens over it as a workspace of the session's own, so leaving is the whole way back — their own tabs, carets and unsaved text are still there.

Five things cross the session:

  • The files. The sharer's tree, browsable and searchable, filled lazily: the root level on joining, a directory's children when it's expanded, a file's text when it's opened. A guest can create, write, rename and delete too — those are asked of the end holding the files.
  • The documents. The file being read is one document in every tab: edits cross as edits, merge rather than overwrite, and land in each reader's own undo history.
  • The carets. Everyone's cursor and selection, drawn in the file they're in, in that peer's own colour. Clicking a roster row jumps to that peer's file at their caret and follows them from there, until you click the row again or they leave. A guest joins already following the sharer.
  • The open tabs. Whoever joins arrives on the files the sharer already has open, with the sharer's own tab showing. That's asked once, on arrival — opening a fifth file after that is reading for yourself.
  • The terminals, if the sharer asked for them. Off by default — see Sharing terminals.

Files a guest hasn't opened are read-only until they are: what the mirror holds of one is a placeholder, not the file. Opening it fetches the text and unlocks it.

The link carries a 128-bit secret in the fragment: https://your.app/#live=<secret>. That secret is the session's name on the bus, the admission, and — on any transport that leaves the page — the encryption key. Hand it out the way you'd hand out a password.

It's in the fragment rather than the query — a query is often the host's own page state, and a fragment is the one part of an address a browser never sends anywhere. A ?live= is read too, for a host that would rather write one; the query itself is dropped from the link, since it's the sharer's own state and none of it is a guest's.

Pass link to build the address yourself, for a page served somewhere other than where the workbench lives:

live({ link: (id) => `https://example.com/join?live=${id}` });

Whatever you build, put the id somewhere that survives being copied — it's the only thing that opens the session.

#Reloading

The session lives in the address and nowhere else — no storage is used. The sharing tab gets #share=<secret> written into its own fragment, so reloading either end rejoins the same session on the same side of it, and leaving the session takes it back off. Only one tab of a browser can be the one sharing: duplicating the sharing tab copies its address exactly, so the copy joins what its sibling is already sharing instead of opening a second session over the same link.

#The Gateway seam

A Gateway is what a session runs over: a topic, a string out, a string in. Two ship — broadcastChannelGateway (the default) and mqttGateway — and a third, p2p, upgrades whichever one is running to a direct connection where it can.

The room itself lives in the browser that starts it, not on a gateway: a gateway only delivers strings to a topic, and who is in the room and what admits them is handled above it, the same way whichever one answers. Anything that can deliver a string to everyone else on a topic is a gateway — your own WebSocket fan-out, most usefully:

import type { Gateway } from "codelet/extensions/live";

const socketGateway = (): Gateway => {
  let socket: WebSocket | undefined;
  let received: (data: string) => void = () => {};
  let closed: (reason: string) => void = () => {};
  return {
    name: "my server",
    // Says this leaves the page, so every frame is sealed before it's sent.
    remote: true,
    open: (topic) =>
      new Promise((resolve) => {
        socket = new WebSocket(`wss://example.com/live/${topic}`);
        socket.onmessage = (event) => received(String(event.data));
        socket.onclose = () => closed("The connection was lost");
        socket.onopen = () => resolve();
      }),
    send: (data) => socket?.send(data),
    onMessage: (handler) => (received = handler),
    onClose: (handler) => (closed = handler),
    close: () => socket?.close(),
  };
};

live({ gateway: socketGateway });

remote: true is what has every frame sealed before it reaches your server: encrypted with a key derived from the link's secret, so your server sees ciphertext addressed to a topic name and never the secret itself. A gateway may also implement carrying() — a name, whether it's up, a round trip — to draw its own row in the pane, and arm(data) to prepare the frame that has to go out on pagehide, where there's no time to await anything.

Warning

crypto.subtle only exists in a secure context. A page served over plain HTTP — a LAN address while testing, typically — can't seal anything: the pane warns and shows not encrypted, and a guest on https can't join an unencrypted host at all. broadcastChannelGateway is never sealed and doesn't need to be — it never leaves the browser.

Under Connection the pane draws four pictures derived from the link alone, computed at both ends and never sent. Matching pictures mean you're in the same session; they say nothing about who else holds the link.

#Transports

#Sharing between machines

mqttGateway() puts the session on public MQTT brokers, still with no server of yours anywhere — brokers carry sealed frames and know nothing about a session. It publishes to every broker in the list at once, so two browsers on different brokers still find each other and one broker going down mid-session costs nothing:

import { live, mqttGateway, p2p } from "codelet/extensions/live";

live({ gateway: mqttGateway, upgrade: p2p() });
OptionTypeDefaultDescription
brokersreadonly string[]publicBrokersWebSocket broker URLs. A login is user:pass@ on the address. Any list is joined whole.
live({ gateway: () => mqttGateway({ brokers: ["wss://mqtt.example.com:8084/mqtt"] }) });

Note

publicBrokers are open, rate-limited and nobody's to depend on. A workbench that matters should point at a broker of its own — no MQTT client is bundled, only the packet shapes a fan-out needs, so any standard broker works.

#Connecting browsers directly

p2p() sits over whichever gateway left the page and moves the payload onto WebRTC data channels between the browsers themselves. It's an upgrade, never a requirement — the session keeps running on the relay while a connection is negotiated, and a pair that never connects (a symmetric NAT, say) carries on exactly as it was. A page with no RTCPeerConnection gets the gateway back unchanged, so passing it is always safe.

OptionTypeDefaultDescription
iceServersRTCIceServer[]publicIceServersSTUN (and TURN, if you have one) servers. An empty list only ever finds a peer on the same LAN.

There's no TURN server here, because none would be free — a pair no STUN can get through stays on the relay. Pass your own if you have one:

live({
  gateway: mqttGateway,
  upgrade: p2p({ iceServers: [{ urls: "turn:turn.example.com", username: "u", credential: "p" }] }),
});

#What you have to provide

For two tabs of one browser, nothing — the default broadcastChannelGateway needs no host at all. To share between machines, you need either:

  • A broker. mqttGateway() works out of the box against public brokers, no signup required. Point brokers at your own for anything that matters.
  • Your own relay. Write a Gateway (see above) over any WebSocket fan-out you already run.

Nothing that reaches a gateway is ever plaintext except over broadcastChannelGateway, which never leaves the browser. What's never sent anywhere at all: the link's secret itself (only its hash and sealed frames cross a remote gateway), paths matched by exclude, and anything in an unopened file or an unexpanded directory.

#Excluding files

exclude is enforced by the browser doing the sharing, everywhere a path could get out — a listing, a read, a change notification, a rename, a caret's filename, the tab strip a guest arrives onto. An excluded path a guest asks for is refused, not just left out:

live({ exclude: ["node_modules", ".env*", "secrets/**"] });

Globs are written against the root of the workspace.

#Sharing terminals

Off unless it's asked for, and worth reading before it is:

live({ terminals: "read" });
  • "off" (default) — no shell crosses, and a guest draws no shared terminal at all.
  • "read" — the shells this browser has open appear as tabs in the guest's panel, output and all. Watching, not typing.
  • "write" — the guest's keystrokes reach the shell too.

Warning

exclude is a workspace rule, and none of it reaches a terminal. A shell is not a path — it's every path at once and the machine around it, so sharing one hands whoever holds the link a cat of any excluded file, whatever credentials are on that machine, and the network the browser is sitting in. There's no narrower version of this to offer.

This needs the terminal extension with a shell in it — what crosses is the sharer's own terminal tabs. A guest gets a tab per shell, named for whoever is sharing it; closing it is done reading, not the shell ending.

#What it does not do

  • No admission and no read-only guest. The link is the whole of the access control, and everyone holding it can edit.
  • No reconnecting. A gateway that drops ends the session; joining again is a fresh seat on the same link, as long as the sharer is still there.
  • No multi-root. The shared workspace shows in place of the reader's own, and a path is the sharer's own path at both ends.
  • The sharer is the session. There's nothing on a server to outlive the tab that started it: closing it ends the session for everybody.
Read more in Extensions > Terminal.