@byamoah/studio-core 0.1.0-beta.0 → 0.1.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @byamoah/studio-core
2
2
 
3
+ ## 0.1.0-next.1
4
+
5
+ ### Minor Changes
6
+
7
+ - 1253d94: Renew active development connections automatically and expire abandoned leases after inactivity. Add shared long-held editor updates and connector job waits to reduce repeated metadata, session and device requests. Make `studio connect` discover project configuration, check the running app, manage an HTTPS preview tunnel, open the private workspace and clean up owned processes on exit, with actionable startup errors.
8
+ - ecdd3db: Add an optional persistence interface to the connection relay so deployment adapters can restore active sessions after a process restart. The default Node relay remains in memory. In-flight connector jobs are not persisted.
9
+
10
+ ### Patch Changes
11
+
12
+ - @byamoah/studio-state@0.1.0-next.1
13
+
3
14
  ## 0.1.0-beta.0
4
15
 
5
16
  Initial public prerelease.
package/README.md CHANGED
@@ -251,42 +251,74 @@ There is not yet a single framework-neutral installation command that generates
251
251
 
252
252
  ## Connect a local project to hosted Studio
253
253
 
254
- Your laptop makes an outbound connection. The hosted platform does not need direct inbound access to the laptop's filesystem or LAN address.
254
+ Start your app's development server first. For Expo, use a separate terminal:
255
255
 
256
256
  ```sh
257
- # Set STUDIO_CONNECT_TOKEN in your shell using the provisioning token
258
- # supplied by the Studio host. Do not commit it.
259
- npx --no-install studio connect \
260
- --studio https://studio.example.com \
261
- --project ./studio.config.mjs
257
+ EXPO_PUBLIC_STUDIO_ORIGIN=https://studio.example.com npx expo start --localhost --port 8081
262
258
  ```
263
259
 
264
- Without `--local`, the command starts a loopback development agent, generates project metadata and watches supported source changes. It prints a private editor link. Keep the process running while editing and stop it with Ctrl+C when finished.
260
+ The environment variable configures the application's trusted web bridge origin. Restart Expo after changing it. Providers and inspection bridges still need the one-time integration described above.
265
261
 
266
- To reuse an existing local Studio development server:
262
+ Once the app server is running and `STUDIO_CONNECT_TOKEN` is configured, connecting is one command:
267
263
 
268
264
  ```sh
269
- npx --no-install studio connect \
270
- --studio https://studio.example.com \
271
- --local http://localhost:5173
265
+ npx studio connect --studio https://studio.example.com
272
266
  ```
273
267
 
268
+ The CLI finds the nearest `studio.config.mjs` or `studio.project.mjs`, including a parent configuration in a monorepo. Its watcher updates the normal `.studio/discovery.json` manifest (or your explicit native manifest path) alongside the hosted metadata; do not run a second `studio dev` watcher for the same project. It generates metadata, checks the app server, starts an HTTPS development tunnel, establishes the hosted connection and opens your private workspace in the browser. No local Studio website is required. It never launches a simulator unless `--simulator` is explicitly supplied.
269
+
270
+ The managed tunnel uses Cloudflare's official `cloudflared` tool. A compatible installed binary is reused; otherwise a pinned binary is downloaded once, verified against the official release checksum, and cached outside the project. There is no package-install hook that downloads it. No separate Cloudflare account is required for this temporary tunnel. The tool exposes the running app development server until you stop the command. Use an existing transport with `--preview-url` if you prefer another tunnel provider.
271
+
272
+ The managed startup flow requires the updated CLI. The initial `0.1.0-beta.0` CLI requires explicit transport URLs; use the updated release or a packed development version for the behavior described here.
273
+
274
+ ### Credentials and app URL
275
+
276
+ Your Studio host supplies a provisioning token. Set `STUDIO_CONNECT_TOKEN` in the shell, or put it in a private `.env.studio.local` file in the directory where you run the command:
277
+
278
+ ```dotenv
279
+ STUDIO_CONNECT_TOKEN=your-private-host-provisioning-token
280
+ ```
281
+
282
+ Ignore that file in Git. The token is not a public application environment variable. Account-based login is not implemented by this command.
283
+
284
+ Expo projects default to `http://127.0.0.1:8081/`. For another framework or port, configure the application server once:
285
+
286
+ ```js
287
+ connection: {
288
+ name: "My App",
289
+ developmentUrl: "http://localhost:3000/",
290
+ }
291
+ ```
292
+
293
+ The full adapter's `preview.developmentUrl` also works. `--app-url` overrides either setting. The command does not start or stop an app server that you own. Missing configuration, an unreachable app, a failed tunnel and a failed host connection produce terminal errors with a next action. A failed startup cleans up the tunnel and agent. Ctrl+C closes the session, stops the owned tunnel and stops discovery; your app server keeps running.
294
+
295
+ ### Options
296
+
274
297
  | Option/configuration | Purpose |
275
298
  | --- | --- |
276
- | `--project PATH` / `STUDIO_PROJECT` | Project config for the standalone agent |
277
- | `--local URL` / `STUDIO_LOCAL_ORIGIN` | Reuse an existing loopback Studio server |
278
- | `STUDIO_CONNECT_TOKEN` / `--token` | Hosted provisioning credential; prefer the environment variable |
279
- | `--preview-url URL` | Public web URL of the running app for iframe preview |
280
- | `--native-url URL` | Public native app launch URL, such as an Expo tunnel link |
281
- | `--simulator` | Open the local development link in an already booted Apple simulator |
282
- | `STUDIO_SIMULATOR_UDID` | Simulator identifier; defaults to `booted` |
283
- | `--allow-http` | Allow an HTTP loopback host for local testing |
299
+ | `--project PATH` / `STUDIO_PROJECT` | Override automatic project config lookup |
300
+ | `--app-url URL` | Loopback app server to check and tunnel |
301
+ | `--preview-url URL` | Use and check an existing public HTTPS web preview instead of creating a tunnel |
302
+ | `--native-url URL` | Override the native app launch URL; Expo projects with a managed tunnel get an `exps:` link |
303
+ | `--native-only` | Intentionally skip web-server/tunnel setup and use connected-device mode |
304
+ | `--no-open` | Print the private workspace link without opening a browser |
305
+ | `--local URL` / `STUDIO_LOCAL_ORIGIN` | Reuse an existing local Studio server; supply `--app-url` for its preview or `--preview-url` |
306
+ | `STUDIO_CONNECT_TOKEN` / `--token` | Provisioning credential; prefer the environment variable or private env file |
307
+ | `STUDIO_CLOUDFLARED_BIN` | Use an explicit compatible tunnel binary |
308
+ | `STUDIO_CACHE_DIR` | Override the tool download cache directory |
309
+ | `--simulator` | Explicitly open a connection in an already booted Apple simulator |
310
+ | `STUDIO_SIMULATOR_UDID` | Simulator identifier, defaulting to `booted` |
311
+ | `--allow-http` | Permit an HTTP loopback host/preview for local testing |
312
+
313
+ For a simulator-only workflow, add `--native-only --simulator` to the connection command. On the same laptop, its launch and content URLs remain loopback addresses. Other-network phones need a working public Expo/native transport and correct app bridge integration; a remote device cannot use laptop-local URLs. An externally supplied `--native-url` remains useful for custom development clients or an independently managed transport.
314
+
315
+ ### Renewal and traffic
284
316
 
285
- `--preview-url` and `--native-url` do not create tunnels. Supply URLs from a separately configured tunnel supporting your development server's HTTP and WebSocket traffic. Hosted HTTPS pages need a reachable HTTPS web preview. Configure the application bridge to trust the hosted Studio origin. React Native applications still run through their normal development server.
317
+ The session is a rolling lease, not a fixed development time limit. Authenticated connector activity extends it automatically. A browser tab alone cannot renew it. After an ungraceful disconnect, the session expires after the configured inactivity period, one hour by default. Ctrl+C explicitly revokes it immediately. Restarting a Node host without persistence ends sessions; a host with a session-storage adapter can restore them. Cloudflare's deployment adapter persists lease checkpoints.
286
318
 
287
- For a simulator on the laptop, use loopback addresses. For a phone on the same LAN, use the laptop's reachable network address. For devices on different networks, use publicly reachable app-development and Studio connection URLs. A remote phone cannot access the laptop through `localhost`.
319
+ Hosted Studio shares a single long-held `/updates` request across metadata, draft and device consumers. The server sends only changed sections (document, metadata or devices), and returns an empty response after up to 20 seconds of inactivity. The connector similarly waits up to ten seconds for jobs instead of requesting empty queues several times per second. Background browser tabs pause their update subscription; interrupted connections retry with backoff. Native telemetry can still cause legitimate updates when devices are active.
288
320
 
289
- Private connection links grant development access; share them only with intended participants. Current relay sessions are in memory, expire, and need reconnection after host restart. The connector is development infrastructure, not the installed application's permanent production content endpoint. Restart it after changing configuration or adapter modules that are loaded at startup.
321
+ Older connectors/hosts keep compatible polling behavior. Upgrade the CLI and platform together to get the shared stream and automatic renewal. Private pairing links grant development access; share them only with intended participants. The connector is not an installed application's permanent production content endpoint. Restart it after changing configuration modules loaded at startup.
290
322
 
291
323
  ## Validate and migrate content
292
324
 
@@ -356,6 +388,24 @@ The read endpoint is `/v1/projects/my-app/schemas/1/published.json`; clients sen
356
388
 
357
389
  The package contains runtime modules and declarations, but not a prebuilt Studio website. Running `/server` requires a separate platform build and project configuration. Hosted connection provisioning and project publication use separate credentials.
358
390
 
391
+ ### Optional relay session storage
392
+
393
+ `createConnectionRelay({ token, sessionStorage })` accepts an optional asynchronous storage adapter:
394
+
395
+ ```js
396
+ const sessionStorage = {
397
+ async load() { /* return an array of previously saved session records */ return []; },
398
+ async save(record) { /* durably replace the record identified by record.id */ },
399
+ async delete(id) { /* durably delete this session */ },
400
+ };
401
+ ```
402
+
403
+ An optional `renew(id, { expires, seen })` method can persist a small lease checkpoint without rewriting a large descriptor. Without it, the relay falls back to `save`. Device-only updates stay transient; restoration clears stale device telemetry and issues a fresh update cursor.
404
+
405
+ Omitting the adapter preserves in-memory Node behavior. The relay saves resumable session records when creating connections and updating snapshots; it deletes them on revocation and expiration. Restored records rebuild empty job queues. Storage errors reject the operation. In-flight requests and queued responses are not durable and may fail across restarts.
406
+
407
+ Session records include private capability credentials. Keep storage server-side, preserve records without altering them, and restrict access. The adapter owns its storage format, transactions and retention cleanup. No hosting-provider SDK is required by this interface.
408
+
359
409
  ## Troubleshooting
360
410
 
361
411
  - **`studio` is missing:** install the package in the project and run its local executable. Check the Node version.
package/bin/studio.mjs CHANGED
@@ -16,7 +16,7 @@ const option = (name) => {
16
16
  };
17
17
  if (["help", "--help", "-h"].includes(command)) {
18
18
  console.log(
19
- "Studio CLI\n studio connect --studio https://studio.example.com [--local http://localhost:5173] [--preview-url https://expo-tunnel] [--native-url exp://expo-tunnel]\n studio validate --url https://your-api/content [--project path | --config path]\n studio migrate --document reviewed.json [--project path | --config path]\n studio init\n studio discover [--project path | --config path]\n studio check [--project path | --config path]\n studio dev [--project path | --config path]\nNode 22.18+ is required. Local package installation supplies this executable; no registry publication is assumed.",
19
+ "Studio CLI\n studio connect --studio https://studio.example.com [--project path] [--app-url http://localhost:8081] [--preview-url https://existing-tunnel] [--native-only] [--no-open]\n studio validate --url https://your-api/content [--project path | --config path]\n studio migrate --document reviewed.json [--project path | --config path]\n studio init\n studio discover [--project path | --config path]\n studio check [--project path | --config path]\n studio dev [--project path | --config path]\nNode 22.18+ is required. Local package installation supplies this executable; no registry publication is assumed.",
20
20
  );
21
21
  process.exit(0);
22
22
  }
@@ -46,7 +46,8 @@ if (command === "init") {
46
46
  }
47
47
  if (command === "connect") {
48
48
  const {connectorCli}=await import("../scripts/connector.mjs");
49
- await connectorCli(args.slice(1));
49
+ try { await connectorCli(args.slice(1)); }
50
+ catch (error) { console.error(`Studio: ${error.message}`); process.exit(1); }
50
51
  process.exit(0);
51
52
  }
52
53
  if (!["discover", "check", "dev", "validate", "migrate"].includes(command))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byamoah/studio-core",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.1.0-next.1",
4
4
  "type": "module",
5
5
  "description": "Studio CLI, discovery compiler and portable inspection runtime",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "dependencies": {
27
27
  "@babel/parser": "^7.29.0",
28
28
  "@babel/traverse": "^7.29.0",
29
- "@byamoah/studio-state": "0.1.0-beta.0",
29
+ "@byamoah/studio-state": "0.1.0-next.1",
30
30
  "typescript": "^6.0.0",
31
31
  "@babel/core": "^7.29.13"
32
32
  },
@@ -0,0 +1,124 @@
1
+ import { spawn, execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { access, mkdir, readFile, writeFile, mkdtemp, rm, rename, chmod } from "node:fs/promises";
4
+ import { join, dirname, resolve } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { createHash } from "node:crypto";
7
+ const exec = promisify(execFile);
8
+
9
+ export async function findConnectorConfig(start = process.cwd()) {
10
+ let directory = resolve(start);
11
+ for (let depth = 0; depth < 8; depth++) {
12
+ for (const name of ["studio.config.mjs", "studio.project.mjs"]) {
13
+ const file = join(directory, name);
14
+ try { await access(file); return file; } catch {}
15
+ }
16
+ const parent = dirname(directory);
17
+ if (parent === directory) break;
18
+ directory = parent;
19
+ }
20
+ throw Error("No Studio project configuration found. Run npx studio init in your app, or pass --project /path/to/studio.project.mjs.");
21
+ }
22
+ export async function ensureTunnelBinary({ signal, cache = process.env.STUDIO_CACHE_DIR ?? join(homedir(), ".cache", "studio", "tools") } = {}) {
23
+ signal?.throwIfAborted();
24
+ const explicit = process.env.STUDIO_CLOUDFLARED_BIN;
25
+ try {
26
+ const result = await exec(explicit ?? "cloudflared", ["--version"], {timeout:5000, signal});
27
+ const version = result.stdout.match(/(\d{4})\.(\d+)\.(\d+)/)?.slice(1).map(Number);
28
+ if (!version || version[0] < 2026 || (version[0] === 2026 && version[1] < 10)) throw Error("outdated");
29
+ return explicit ?? "cloudflared";
30
+ }
31
+ catch { if (explicit) throw Error("STUDIO_CLOUDFLARED_BIN is not a compatible cloudflared binary (2026.10 or newer). Correct that path or remove the override to use automatic installation."); }
32
+ const version = "2026.10.7";
33
+ const architecture = { x64:"amd64", arm64:"arm64", arm:"arm" }[process.arch];
34
+ if (!architecture || !["darwin","linux","win32"].includes(process.platform))
35
+ throw Error("Automatic tunneling is unavailable on this platform. Supply --preview-url with your HTTPS development URL.");
36
+ const assetName = `cloudflared-${process.platform === "win32" ? "windows" : process.platform}-${architecture}` +
37
+ (process.platform === "darwin" ? ".tgz" : process.platform === "win32" ? ".exe" : "");
38
+ const directory = join(cache, `cloudflared-${version}-${process.platform}-${architecture}`);
39
+ const binary = join(directory, process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
40
+ try { await exec(binary,["--version"],{timeout:5000}); return binary; } catch {}
41
+ console.log("Downloading Cloudflare's tunnel client (one-time setup)…");
42
+ await mkdir(cache, {recursive:true});
43
+ const staging = await mkdtemp(join(cache,"download-"));
44
+ try {
45
+ const response = await fetch(`https://api.github.com/repos/cloudflare/cloudflared/releases/tags/${version}`, {signal:AbortSignal.any([...(signal?[signal]:[]),AbortSignal.timeout(20000)])});
46
+ if (!response.ok) throw Error(`Release metadata HTTP ${response.status}`);
47
+ const asset = (await response.json()).assets?.find(entry => entry.name === assetName);
48
+ if (!asset || !/^sha256:[a-f0-9]{64}$/.test(asset.digest ?? "")) throw Error("Official asset checksum unavailable");
49
+ const download = await fetch(asset.browser_download_url, {signal:AbortSignal.any([...(signal?[signal]:[]),AbortSignal.timeout(120000)])});
50
+ if (!download.ok) throw Error(`Download HTTP ${download.status}`);
51
+ const bytes = Buffer.from(await download.arrayBuffer());
52
+ if (`sha256:${createHash("sha256").update(bytes).digest("hex")}` !== asset.digest) throw Error("Tunnel binary checksum mismatch");
53
+ const target = join(staging, process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
54
+ if (process.platform === "darwin") {
55
+ const archive = join(staging,"archive.tgz");await writeFile(archive,bytes);
56
+ await exec("tar",["-xzf",archive,"-C",staging,"cloudflared"]);
57
+ await rm(archive);
58
+ } else await writeFile(target,bytes);
59
+ await chmod(target,0o700);await exec(target,["--version"],{timeout:5000});
60
+ try { await rename(staging,directory); }
61
+ catch (error) { try { await access(binary); } catch { throw error; } }
62
+ return binary;
63
+ } catch (error) {
64
+ throw Error(`Tunnel setup failed: ${error.message}. Check network access to github.com, install cloudflared separately, or use --preview-url with an existing HTTPS tunnel.`);
65
+ } finally { await rm(staging,{recursive:true,force:true}); }
66
+ }
67
+ export async function verifyAppServer(value, studio) {
68
+ const url = new URL(value);
69
+ if (!["http:","https:"].includes(url.protocol) || !["localhost","127.0.0.1","[::1]"].includes(url.hostname) || url.username || url.password)
70
+ throw Error("The app development URL must use loopback HTTP(S). Supply the public URL through --preview-url instead.");
71
+ try {
72
+ const response = await fetch(url,{redirect:"error",signal:AbortSignal.timeout(60000)});
73
+ if (!response.ok || !(response.headers.get("content-type")??"").includes("text/html")) throw Error(`HTTP ${response.status}; expected an app HTML page`);
74
+ } catch (error) {
75
+ throw Error(`App development server is unavailable at ${url.origin}: ${error.message}. Start the app first. For Expo: EXPO_PUBLIC_STUDIO_ORIGIN=${studio} npx expo start --localhost --port ${url.port || "8081"}. Then run studio connect again. For another framework, configure connection.developmentUrl or pass --app-url.`);
76
+ }
77
+ return url.href;
78
+ }
79
+ export async function startPreviewTunnel({ url, signal, binary, timeoutMs = 90000, probe = fetch } = {}) {
80
+ binary ??= await ensureTunnelBinary({ signal });
81
+ signal?.throwIfAborted();
82
+ const child = spawn(binary,["tunnel","--url",url,"--no-autoupdate","--no-prechecks","--protocol","http2"],{stdio:["ignore","pipe","pipe"]});
83
+ let address = "", registered = false, completed = false, buffer = "";
84
+ const exited = new Promise(resolve => child.once("exit", resolve));
85
+ const close = async () => {
86
+ if (!child.pid || child.exitCode !== null || child.signalCode) return;
87
+ await new Promise(resolve => {
88
+ const kill = setTimeout(()=>child.kill("SIGKILL"),3000);kill.unref();
89
+ child.once("exit",()=>{clearTimeout(kill);resolve();});child.kill("SIGTERM");
90
+ });
91
+ };
92
+ const abort = () => { void close(); };
93
+ signal?.addEventListener("abort",abort,{once:true});
94
+ if (signal?.aborted) { await close(); throw Error("Connection cancelled."); }
95
+ try {
96
+ await new Promise((resolve,reject) => {
97
+ const timer = setTimeout(()=>reject(Error("Tunnel startup timed out. Check your network/firewall or supply --preview-url.")),timeoutMs);
98
+ const fail = error => {clearTimeout(timer);reject(error);};
99
+ child.once("error",()=>fail(Error("Could not start cloudflared. Install it or set STUDIO_CLOUDFLARED_BIN.")));
100
+ child.once("exit",()=>{if(!completed)fail(Error("Preview tunnel stopped. Check your network or supply --preview-url."));});
101
+ const output = bytes => {
102
+ buffer = (buffer+bytes.toString()).slice(-12000);
103
+ address ||= buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/)?.[0] ?? "";
104
+ registered ||= buffer.includes("Registered tunnel connection");
105
+ if(address&&registered&&!completed){completed=true;clearTimeout(timer);resolve();}
106
+ };
107
+ child.stdout.on("data",output);child.stderr.on("data",output);
108
+ });
109
+ let ready = false;
110
+ for(let attempt=0;attempt<6&&!signal?.aborted;attempt++) {
111
+ try { const response=await probe(address,{signal:AbortSignal.any([...(signal?[signal]:[]),AbortSignal.timeout(20000)])});
112
+ if(response.ok){ready=true;break;} }catch{}
113
+ await new Promise(resolve=>setTimeout(resolve,500));
114
+ }
115
+ if(!ready)throw Error("The HTTPS tunnel is connected but the app is not reachable. Check the development server, then retry studio connect.");
116
+ return {url:address+"/",close:async()=>{signal?.removeEventListener("abort",abort);await close();},
117
+ exited};
118
+ }catch(error){signal?.removeEventListener("abort",abort);await close();throw error;}
119
+ }
120
+ export async function openStudioBrowser(url) {
121
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
122
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler",url] : [url];
123
+ await exec(command,args,{timeout:10000});
124
+ }
@@ -1,4 +1,4 @@
1
- import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  const hex = () => randomBytes(24).toString("hex");
3
3
  const same = (a, b) => {
4
4
  const x = Buffer.from(a ?? ""),
@@ -20,6 +20,7 @@ export function createConnectionRelay({
20
20
  ttlMs = 60 * 60 * 1000,
21
21
  timeoutMs = 15000,
22
22
  maxSessions = 8,
23
+ sessionStorage,
23
24
  } = {}) {
24
25
  const sessions = new Map();
25
26
  const close = (entry) => {
@@ -34,10 +35,13 @@ export function createConnectionRelay({
34
35
  });
35
36
  }
36
37
  entry.jobs.clear();
38
+ for (const wake of entry.updateWaiters ?? []) wake();
39
+ for (const wake of entry.pollWaiters ?? []) wake();
37
40
  };
38
- const clean = () => {
41
+ const clean = async () => {
39
42
  for (const [id, entry] of sessions)
40
43
  if (Date.now() > entry.expires) {
44
+ await sessionStorage?.delete(id);
41
45
  close(entry);
42
46
  sessions.delete(id);
43
47
  }
@@ -53,6 +57,49 @@ export function createConnectionRelay({
53
57
  typeof value.name === "string" &&
54
58
  value.session?.document &&
55
59
  typeof value.session?.revision === "string";
60
+ // Persist only resumable session data. In-flight jobs are intentionally ephemeral.
61
+ const snapshot = ({ id, editor, device, connector, descriptor, expires, seen, origin, version }) =>
62
+ ({ id, editor, device, connector, descriptor, expires, seen, origin, version });
63
+ let restored;
64
+ const restore = () => restored ??= (async () => {
65
+ if (!sessionStorage) return;
66
+ for (const entry of await sessionStorage.load()) {
67
+ if (!entry || !/^[a-f0-9-]{36}$/.test(entry.id) ||
68
+ ![entry.editor, entry.device, entry.connector].every(value => /^[a-f0-9]{48}$/.test(value)) ||
69
+ !Number.isFinite(entry.expires) || !Number.isFinite(entry.seen) ||
70
+ !validDescriptor(entry.descriptor) || typeof entry.origin !== "string")
71
+ throw Error("Invalid persisted relay session.");
72
+ const restoredEntry = { ...entry, version: randomUUID(), descriptor: { ...entry.descriptor, devices: [] }, persistedUntil: entry.expires, jobs: new Map(), updateWaiters: new Set(), pollWaiters: new Set(), history: new Map() };
73
+ remember(restoredEntry); sessions.set(entry.id, restoredEntry);
74
+ }
75
+ })().catch(error => { restored = undefined; throw error; });
76
+ const wait = (set, duration) => new Promise(resolve => {
77
+ const wake = () => { clearTimeout(timer); set.delete(wake); resolve(); };
78
+ const timer = setTimeout(wake, duration);
79
+ set.add(wake);
80
+ });
81
+ const capabilities = { updates: 1, longPoll: 1, renewal: 1 };
82
+ const digest = value => createHash("sha256").update(JSON.stringify(value ?? null)).digest("hex");
83
+ function remember(entry) {
84
+ entry.history.set(entry.version, {
85
+ metadata: entry.descriptor.manifest.buildId + digest(entry.descriptor.resourceUrls),
86
+ session: digest(entry.descriptor.session),
87
+ devices: digest(entry.descriptor.devices),
88
+ });
89
+ while (entry.history.size > 32) entry.history.delete(entry.history.keys().next().value);
90
+ }
91
+ function editorState(entry, after) {
92
+ const previous = entry.history.get(after), current = entry.history.get(entry.version);
93
+ if (previous) return {
94
+ projectId: entry.descriptor.projectId, version: entry.version, expiresAt: entry.expires,
95
+ ...(previous.metadata !== current.metadata ? { manifest: entry.descriptor.manifest, resourceUrls: entry.descriptor.resourceUrls } : {}),
96
+ ...(previous.session !== current.session ? { session: publicSession(entry, entry.origin) } : {}),
97
+ ...(previous.devices !== current.devices ? { devices: entry.descriptor.devices ?? [] } : {}),
98
+ };
99
+ return { ...entry.descriptor, version: entry.version,
100
+ capabilities: { ...capabilities, updates: entry.descriptor.capabilities?.updates === 1 ? 1 : 0 },
101
+ devices: entry.descriptor.devices ?? [], session: publicSession(entry, entry.origin), expiresAt: entry.expires };
102
+ }
56
103
  function publicSession(entry, origin) {
57
104
  const { descriptor: d } = entry,
58
105
  prefix = `/__studio/dev/${entry.editor}`,
@@ -113,6 +160,7 @@ export function createConnectionRelay({
113
160
  ),
114
161
  };
115
162
  entry.jobs.set(id, job);
163
+ for (const wake of entry.pollWaiters) wake();
116
164
  });
117
165
  }
118
166
  return async (req, res) => {
@@ -123,7 +171,8 @@ export function createConnectionRelay({
123
171
  !route.startsWith("/__studio/dev/")
124
172
  )
125
173
  return false;
126
- clean();
174
+ await restore();
175
+ await clean();
127
176
  const json = (status, value) => {
128
177
  res.writeHead(status, {
129
178
  "Content-Type": "application/json",
@@ -212,14 +261,22 @@ export function createConnectionRelay({
212
261
  expires: Date.now() + ttlMs,
213
262
  seen: Date.now(),
214
263
  jobs: new Map(),
264
+ updateWaiters: new Set(),
265
+ pollWaiters: new Set(),
266
+ version: randomUUID(),
267
+ history: new Map(),
268
+ persistedUntil: Date.now() + ttlMs,
215
269
  origin: publicOrigin,
216
270
  };
271
+ remember(entry);
272
+ await sessionStorage?.save(snapshot(entry));
217
273
  sessions.set(id, entry);
218
274
  return json(201, {
219
275
  id,
220
276
  connector: entry.connector,
221
277
  editorUrl: `${publicOrigin}/?connection=${id}#studio=${entry.editor}`,
222
278
  expiresAt: entry.expires,
279
+ capabilities,
223
280
  });
224
281
  }
225
282
  const control =
@@ -233,13 +290,26 @@ export function createConnectionRelay({
233
290
  !same(req.headers.authorization, `Bearer ${entry.connector}`)
234
291
  )
235
292
  return json(401, { error: "Connection unavailable." });
236
- entry.seen = Date.now();
293
+ const lease = { seen: Date.now(), expires: Date.now() + ttlMs };
294
+ if (lease.expires - entry.persistedUntil >= Math.min(60_000, ttlMs / 4)) {
295
+ if (sessionStorage?.renew) await sessionStorage.renew(entry.id, lease);
296
+ else await sessionStorage?.save(snapshot({ ...entry, ...lease }));
297
+ entry.persistedUntil = lease.expires;
298
+ }
299
+ Object.assign(entry, lease);
237
300
  if (!control[2] && req.method === "DELETE") {
301
+ await sessionStorage?.delete(entry.id);
238
302
  close(entry);
239
303
  sessions.delete(entry.id);
240
304
  return json(200, { closed: true });
241
305
  }
242
306
  if (control[2] === "poll" && req.method === "GET") {
307
+ const duration = Math.min(10000, Math.max(0, Number(url.searchParams.get("wait")) || 0));
308
+ if (duration && ![...entry.jobs.values()].some(job => !job.sent)) {
309
+ if (entry.pollWaiters.size >= 2) return json(429, { error: "Connector poll already pending." });
310
+ await wait(entry.pollWaiters, duration);
311
+ }
312
+ if (!sessions.has(entry.id)) return json(410, { error: "Connection closed." });
243
313
  const jobs = [...entry.jobs.values()]
244
314
  .filter((job) => !job.sent)
245
315
  .slice(0, 8);
@@ -263,7 +333,15 @@ export function createConnectionRelay({
263
333
  return json(422, {
264
334
  error: "Project identity cannot change within a connection.",
265
335
  });
336
+ const nextVersion = randomUUID();
337
+ const durableChanged = JSON.stringify({ ...value, devices: undefined }) !==
338
+ JSON.stringify({ ...entry.descriptor, devices: undefined });
339
+ if (durableChanged)
340
+ await sessionStorage?.save(snapshot({ ...entry, descriptor: value, version: nextVersion }));
266
341
  entry.descriptor = value;
342
+ entry.version = nextVersion;
343
+ remember(entry);
344
+ for (const wake of entry.updateWaiters) wake();
267
345
  return json(200, { saved: true });
268
346
  }
269
347
  if (control[2] === "results" && req.method === "POST") {
@@ -300,17 +378,26 @@ export function createConnectionRelay({
300
378
  (value) => same(value.editor, match[1]) || same(value.device, match[1]),
301
379
  );
302
380
  if (!entry)
303
- return ["/bootstrap", "/session", "/metadata.json"].includes(match[2])
381
+ return ["/bootstrap", "/session", "/metadata.json", "/updates"].includes(match[2])
304
382
  ? json(410, { error: "Connection expired. Reconnect from the laptop." })
305
383
  : false;
306
384
  const editor = same(entry.editor, match[1]),
307
385
  path = match[2];
308
386
  if (editor && req.method === "GET" && path === "/bootstrap")
309
- return json(200, {
310
- ...entry.descriptor,
311
- session: publicSession(entry, entry.origin),
312
- expiresAt: entry.expires,
313
- });
387
+ return json(200, editorState(entry));
388
+ if (editor && req.method === "GET" && path === "/updates") {
389
+ const duration = Math.min(20000, Math.max(0, Number(url.searchParams.get("wait")) || 0));
390
+ if (url.searchParams.get("after") === entry.version && duration) {
391
+ if (entry.updateWaiters.size >= 8) return json(429, { error: "Too many editor subscriptions." });
392
+ await wait(entry.updateWaiters, duration);
393
+ }
394
+ if (!sessions.has(entry.id) || Date.now() > entry.expires)
395
+ return json(410, { error: "Connection expired. Reconnect from the laptop." });
396
+ if (url.searchParams.get("after") === entry.version) {
397
+ res.writeHead(204, { "Cache-Control": "no-store" }); res.end(); return true;
398
+ }
399
+ return json(200, editorState(entry, url.searchParams.get("after")));
400
+ }
314
401
  if (editor && req.method === "GET" && path === "/session") {
315
402
  res.setHeader("ETag", `"${entry.descriptor.session.revision}"`);
316
403
  if (
@@ -35,6 +35,7 @@ export async function createConnectorAgent(configFile) {
35
35
  state: {
36
36
  ...original.state,
37
37
  directory,
38
+ nativeManifestFile: original.state?.nativeManifestFile ?? pathToFileURL(resolve(directory, "discovery.json")),
38
39
  authoring: original.state?.authoring ?? {
39
40
  snapshotFile: pathToFileURL(
40
41
  resolve(directory, "content.snapshot.json"),
@@ -144,9 +145,10 @@ export async function createConnectorAgent(configFile) {
144
145
  again = false;
145
146
  try {
146
147
  const before = await fingerprint();
147
- manifest = await generateDiscovery(project, publicDir);
148
- lastFingerprint = before;
148
+ const next = await generateDiscovery(project, publicDir);
149
149
  await channel.refresh();
150
+ manifest = next;
151
+ lastFingerprint = before;
150
152
  } catch {
151
153
  process.stderr.write(
152
154
  "Studio discovery needs attention. Previous local content is preserved.\n",
@@ -19,6 +19,8 @@ export async function startConnector({
19
19
  allowHttp = false,
20
20
  onConnected = () => {},
21
21
  signal,
22
+ syncIntervalMs = 1000,
23
+ pollWaitMs = 10000,
22
24
  } = {}) {
23
25
  if (!studio)
24
26
  throw Error("Use studio connect --studio https://studio.example.com.");
@@ -56,17 +58,20 @@ export async function startConnector({
56
58
  "Remote devices need a publicly reachable secure Expo tunnel.",
57
59
  );
58
60
  }
61
+ const connectionController = new AbortController();
59
62
  const request = async (url, options = {}) => {
60
63
  const response = await fetch(url, {
61
64
  ...options,
62
65
  redirect: "error",
63
66
  signal: AbortSignal.any([
64
67
  ...(signal ? [signal] : []),
65
- AbortSignal.timeout(20000),
68
+ ...(options.signal ? [options.signal] : []),
69
+ connectionController.signal,
70
+ AbortSignal.timeout(25000),
66
71
  ]),
67
72
  });
68
73
  if (!response.ok)
69
- throw Error(`Connector service returned ${response.status}.`);
74
+ throw Object.assign(Error(`Connector service returned ${response.status}.`), {status:response.status});
70
75
  return response.json();
71
76
  };
72
77
  let info,
@@ -101,6 +106,7 @@ export async function startConnector({
101
106
  }
102
107
  return {
103
108
  projectId: info.id,
109
+ capabilities: { updates: 1 },
104
110
  name: info.name,
105
111
  channel: info.channel,
106
112
  resourceUrls,
@@ -113,6 +119,10 @@ export async function startConnector({
113
119
  document: session.document,
114
120
  hasDraft: session.hasDraft,
115
121
  },
122
+ devices: await request(new URL(session.writeUrl.replace(/\/draft$/, "") + "/devices", origin))
123
+ .then(payload => (payload.devices ?? []).map(device => ({ ...device,
124
+ lastSeen: Math.floor((device.lastSeen ?? 0) / 10000) * 10000 })))
125
+ .catch(() => []),
116
126
  previewUrl: previewUrl ?? null,
117
127
  nativeUrl: nativeUrl ?? null,
118
128
  };
@@ -126,6 +136,13 @@ export async function startConnector({
126
136
  },
127
137
  body: JSON.stringify({ publicOrigin: remote.origin, descriptor }),
128
138
  });
139
+ let editor;
140
+ try { editor = new URL(created.editorUrl); } catch { throw Error("Studio returned an invalid workspace URL."); }
141
+ if (!/^[a-f0-9-]{36}$/.test(created.id) || !/^[a-f0-9]{48}$/.test(created.connector) ||
142
+ !Number.isFinite(created.expiresAt) || editor.origin !== remote.origin || editor.pathname !== "/" ||
143
+ editor.username || editor.password || editor.searchParams.get("connection") !== created.id ||
144
+ !/^[a-f0-9]{48}$/.test(new URLSearchParams(editor.hash.slice(1)).get("studio") ?? ""))
145
+ throw Error("Studio returned an invalid connection handshake. Check the host URL and retry.");
129
146
  const base = new URL(`/__studio/connect/${created.id}`, remote).href,
130
147
  headers = {
131
148
  Authorization: `Bearer ${created.connector}`,
@@ -175,7 +192,7 @@ export async function startConnector({
175
192
  headers: job.headers,
176
193
  ...(job.method === "POST" ? { body: job.body } : {}),
177
194
  redirect: "error",
178
- signal: AbortSignal.timeout(12000),
195
+ signal: AbortSignal.any([connectionController.signal, AbortSignal.timeout(12000)]),
179
196
  });
180
197
  const bytes = Buffer.from(await response.arrayBuffer());
181
198
  if (bytes.length > 7_000_000)
@@ -191,125 +208,149 @@ export async function startConnector({
191
208
  body: bytes.toString("base64"),
192
209
  };
193
210
  }
211
+ let leaseUntil = created.expiresAt;
212
+ const lifetime = connectionController;
213
+ const stop = () => { stopped = true; lifetime.abort(); };
214
+ signal?.addEventListener("abort", stop, { once: true });
215
+ const pause = (duration) => new Promise(resolve => {
216
+ if (lifetime.signal.aborted) return resolve();
217
+ const finish = () => { clearTimeout(timer); lifetime.signal.removeEventListener("abort", finish); resolve(); };
218
+ const timer = setTimeout(finish, duration);
219
+ lifetime.signal.addEventListener("abort", finish, {once:true});
220
+ });
221
+ let lastWarning = 0;
222
+ const warn = () => {
223
+ if (!allowHttp && Date.now() - lastWarning > 10000) {
224
+ process.stderr.write("Studio connection interrupted; retrying. Local development remains available.\n");
225
+ lastWarning = Date.now();
226
+ }
227
+ };
228
+ const sync = async () => {
229
+ while (!stopped && !signal?.aborted) {
230
+ try {
231
+ const serialized = JSON.stringify(await snapshot());
232
+ if (serialized !== last) {
233
+ await request(base + "/snapshot", {method:"POST",headers,body:serialized});
234
+ last = serialized;
235
+ }
236
+ } catch { if (!stopped) warn(); }
237
+ await pause(syncIntervalMs);
238
+ }
239
+ };
194
240
  const done = (async () => {
241
+ const updating = sync();
242
+ let failures = 0;
195
243
  try {
196
- while (!stopped && !signal?.aborted && Date.now() < created.expiresAt) {
244
+ while (!stopped && !signal?.aborted) {
197
245
  try {
198
- const next = await snapshot(),
199
- serialized = JSON.stringify(next);
200
- if (serialized !== last) {
201
- await request(base + "/snapshot", {
202
- method: "POST",
203
- headers,
204
- body: serialized,
205
- });
206
- last = serialized;
207
- }
208
- const { jobs } = await request(base + "/poll", { headers });
209
- await Promise.all(
210
- (jobs ?? []).map(async (job) => {
211
- let result;
212
- try {
213
- result = await perform(job);
214
- } catch {
215
- result = {
216
- id: job.id,
217
- status: 502,
218
- headers: { "content-type": "application/json" },
219
- body: Buffer.from(
220
- JSON.stringify({
221
- error: "Local development service unavailable.",
222
- }),
223
- ).toString("base64"),
224
- };
225
- }
226
- await request(base + "/results", {
227
- method: "POST",
228
- headers,
229
- body: JSON.stringify(result),
230
- });
231
- }),
232
- );
246
+ const result = await request(base + "/poll" +
247
+ (created.capabilities?.longPoll ? `?wait=${Math.min(pollWaitMs, 10000)}` : ""),
248
+ { headers, signal: lifetime.signal });
249
+ if (Number.isFinite(result.expiresAt)) leaseUntil = result.expiresAt;
250
+ if (Date.now() >= leaseUntil) throw Object.assign(Error("Connection expired."), {terminal:true});
251
+ failures = 0;
252
+ await Promise.all((result.jobs ?? []).map(async job => {
253
+ let response;
254
+ try { response = await perform(job); }
255
+ catch { response = {id:job.id,status:502,headers:{"content-type":"application/json"},body:Buffer.from(JSON.stringify({error:"Local development service unavailable."})).toString("base64")}; }
256
+ await request(base + "/results", {method:"POST",headers,body:JSON.stringify(response)});
257
+ }));
258
+ if (!created.capabilities?.longPoll || pollWaitMs === 0) await pause(400);
233
259
  } catch (error) {
234
- if (signal?.aborted || stopped) break;
235
- if (!allowHttp)
236
- process.stderr.write(
237
- "Studio connection interrupted; retrying. Local development remains available.\n",
238
- );
260
+ if (stopped || signal?.aborted) break;
261
+ if (error.terminal || error.status === 401 || error.status === 410) throw Error("Studio session expired or was revoked. Run studio connect again.");
262
+ warn();
263
+ await pause(Math.min(30000, 1000 * 2 ** Math.min(failures++, 5)));
239
264
  }
240
- await new Promise((resolve) => setTimeout(resolve, 400));
241
265
  }
242
266
  } finally {
243
- await fetch(base, {
244
- method: "DELETE",
245
- headers,
246
- signal: AbortSignal.timeout(2000),
247
- }).catch(() => {});
267
+ stop(); await updating;
268
+ signal?.removeEventListener("abort", stop);
269
+ await fetch(base, {method:"DELETE",headers,signal:AbortSignal.timeout(2000)}).catch(() => {});
248
270
  }
249
271
  })();
250
272
  return {
251
273
  ...created,
252
274
  done,
253
- stop: () => {
254
- stopped = true;
255
- },
275
+ get expiresAt() { return leaseUntil; },
276
+ stop,
256
277
  };
257
278
  }
258
279
  export async function connectorCli(args) {
259
- const option = (name) => {
260
- const index = args.indexOf(name);
261
- return index < 0 ? undefined : args[index + 1];
262
- };
263
- const controller = new AbortController();
264
- for (const name of ["SIGINT", "SIGTERM"])
265
- process.once(name, () => controller.abort());
266
- let agent;
267
- let local = option("--local") ?? process.env.STUDIO_LOCAL_ORIGIN;
268
- if (!local) {
269
- const { createConnectorAgent } = await import("./connector-agent.mjs");
270
- agent = await createConnectorAgent(
271
- option("--project") ?? process.env.STUDIO_PROJECT ?? "studio.config.mjs",
272
- );
273
- local = agent.url;
274
- }
280
+ const option = name => { const index=args.indexOf(name);return index<0?undefined:args[index+1]; };
281
+ try { process.loadEnvFile(".env.studio.local"); } catch(error) { if(error.code!=="ENOENT")throw error; }
282
+ const studio=option("--studio");
283
+ if(!studio)throw Error("Provide your host: npx studio connect --studio https://studio.example.com");
284
+ const host=new URL(studio);
285
+ if(host.username||host.password||host.search||host.hash||host.pathname!=="/"||
286
+ (host.protocol!=="https:"&&!(args.includes("--allow-http")&&host.protocol==="http:"&&["localhost","127.0.0.1","[::1]"].includes(host.hostname))))
287
+ throw Error("Use a credential-free HTTPS Studio origin.");
288
+ const token=option("--token")??process.env.STUDIO_CONNECT_TOKEN;
289
+ if(!token)throw Error("Studio credentials are missing. Set STUDIO_CONNECT_TOKEN in your shell or .env.studio.local using the provisioning token supplied by your Studio host. Then run this command again.");
290
+ const {findConnectorConfig,verifyAppServer,startPreviewTunnel,openStudioBrowser}=await import("./connect-preview.mjs");
291
+ const {readFile}=await import("node:fs/promises");
292
+ const {join}=await import("node:path");
293
+ const controller=new AbortController();
294
+ const interrupt=()=>controller.abort();
295
+ for(const name of ["SIGINT","SIGTERM"])process.once(name,interrupt);
296
+ let agent,tunnel;
297
+ let local=option("--local")??process.env.STUDIO_LOCAL_ORIGIN;
275
298
  try {
276
- const connection = await startConnector({
277
- studio: option("--studio"),
278
- token: option("--token") ?? process.env.STUDIO_CONNECT_TOKEN,
279
- local,
280
- previewUrl: option("--preview-url"),
281
- nativeUrl: option("--native-url"),
282
- allowHttp: args.includes("--allow-http"),
283
- signal: controller.signal,
284
- onConnected: async (created) => {
285
- console.log(
286
- `Open this private Studio pairing link:\n${created.editorUrl}\nConnection expires ${new Date(created.expiresAt).toISOString()}.`,
287
- );
288
- if (agent) {
289
- const localSession = await (
290
- await fetch(new URL("/__studio/dev/session", local))
291
- ).json();
292
- console.log(
293
- `Local simulator link (works without LAN):\n${localSession.simulatorUrl}`,
294
- );
295
- if (args.includes("--simulator")) {
296
- if (process.platform !== "darwin")
297
- throw Error("The iOS simulator requires macOS.");
298
- const { spawnSync } = await import("node:child_process");
299
- const opened = spawnSync("xcrun", [
300
- "simctl",
301
- "openurl",
302
- process.env.STUDIO_SIMULATOR_UDID ?? "booted",
303
- localSession.simulatorUrl,
304
- ]);
305
- if (opened.status !== 0)
306
- throw Error("Boot a simulator before using --simulator.");
307
- }
299
+ if(!local){
300
+ const {createConnectorAgent}=await import("./connector-agent.mjs");
301
+ const file=option("--project")??process.env.STUDIO_PROJECT??await findConnectorConfig();
302
+ console.log("Checking project and generating Studio metadata…");
303
+ agent=await createConnectorAgent(file);local=agent.url;
304
+ }
305
+ let previewUrl=option("--preview-url"),nativeUrl=option("--native-url");
306
+ if(!previewUrl&&!args.includes("--native-only")){
307
+ let appUrl=option("--app-url")??agent?.project.connection?.developmentUrl??agent?.project.preview?.developmentUrl;
308
+ let expo=false;
309
+ if(agent){
310
+ try{const pkg=JSON.parse(await readFile(join(agent.project.discovery.root,"package.json"),"utf8"));expo=!!(pkg.dependencies?.expo??pkg.devDependencies?.expo);}catch{}
311
+ }
312
+ if(!appUrl&&expo)appUrl="http://127.0.0.1:8081/";
313
+ if(!appUrl)throw Error("No app development URL is configured. Start your app server and set connection.developmentUrl in the Studio config, or pass --app-url http://localhost:PORT. Use --native-only only when you intentionally do not need a web preview.");
314
+ console.log("Checking app development server…");
315
+ const origin=await verifyAppServer(appUrl,host.origin);
316
+ console.log("Creating HTTPS preview tunnel…");
317
+ tunnel=await startPreviewTunnel({url:origin,signal:controller.signal});
318
+ previewUrl=tunnel.url;
319
+ if(!nativeUrl&&expo)nativeUrl=previewUrl.replace(/^https:/,"exps:")+"--/";
320
+ }
321
+ if(option("--preview-url")) {
322
+ const target=new URL(previewUrl);
323
+ if(target.username||target.password||(target.protocol!=="https:"&&!(args.includes("--allow-http")&&target.protocol==="http:"&&["localhost","127.0.0.1","[::1]"].includes(target.hostname))))
324
+ throw Error("Use a credential-free HTTPS --preview-url.");
325
+ try { const response=await fetch(target,{signal:AbortSignal.any([controller.signal,AbortSignal.timeout(20000)])});
326
+ if(!response.ok||!(response.headers.get("content-type")??"").includes("text/html"))throw Error(`HTTP ${response.status}`);
327
+ } catch(error) { throw Error(`Preview URL is unavailable: ${error.message}. Start your app/tunnel and retry.`); }
328
+ }
329
+ if(controller.signal.aborted)return;
330
+ const connection=await startConnector({studio:host.origin,token,local,previewUrl,nativeUrl,
331
+ allowHttp:args.includes("--allow-http"),signal:controller.signal,
332
+ onConnected:async created=>{
333
+ const editor=new URL(created.editorUrl),capability=new URLSearchParams(editor.hash.slice(1)).get("studio");
334
+ const ready=await fetch(new URL(`/__studio/dev/${capability}/bootstrap`,host),{signal:AbortSignal.any([controller.signal,AbortSignal.timeout(20000)])});
335
+ if(!ready.ok)throw Error("Hosted workspace did not become ready. Check the Studio host and reconnect.");
336
+ console.log(`Ready — Studio is connected${previewUrl?" with a live web preview":" in native-only mode"}.\nThe session renews while this connector runs. Press Ctrl+C to disconnect.\nPrivate workspace:\n${created.editorUrl}`);
337
+ if(!args.includes("--no-open")){
338
+ try{await openStudioBrowser(created.editorUrl);}catch{console.log("Could not open your browser automatically. Open the private workspace link above.");}
308
339
  }
309
- },
310
- });
340
+ if(args.includes("--simulator")){
341
+ if(!agent||process.platform!=="darwin")throw Error("--simulator requires a standalone agent on macOS and a booted simulator.");
342
+ const localSession=await(await fetch(new URL("/__studio/dev/session",local))).json();
343
+ const {spawnSync}=await import("node:child_process");
344
+ if(spawnSync("xcrun",["simctl","openurl",process.env.STUDIO_SIMULATOR_UDID??"booted",localSession.simulatorUrl]).status!==0)
345
+ throw Error("Boot a simulator before using --simulator.");
346
+ }
347
+ }});
348
+ let tunnelFailed=false;
349
+ void tunnel?.exited.then(()=>{if(!controller.signal.aborted){tunnelFailed=true;controller.abort();}});
311
350
  await connection.done;
351
+ if(tunnelFailed)throw Error("The preview tunnel stopped. Check network access and run studio connect again, or supply --preview-url.");
312
352
  } finally {
313
- await agent?.close();
353
+ controller.abort();await tunnel?.close();await agent?.close();
354
+ for(const name of ["SIGINT","SIGTERM"])process.removeListener(name,interrupt);
314
355
  }
315
356
  }
@@ -20,6 +20,7 @@ export async function createDevChannel(project, options = {}) {
20
20
  (await readDocument(project.state.authoring.snapshotFile));
21
21
  let document = mergeStudioDefaults(baseline, stored);
22
22
  if (project.content.validate(document).length) document = baseline;
23
+ let activeContractId = project.content.contractId;
23
24
  let revision = hash(document),
24
25
  writes = Promise.resolve();
25
26
  function hash(value) {
@@ -31,7 +32,7 @@ export async function createDevChannel(project, options = {}) {
31
32
  protocolVersion: 1,
32
33
  projectId: project.id,
33
34
  schemaVersion: project.content.schemaVersion,
34
- contractId: project.content.contractId,
35
+ contractId: activeContractId,
35
36
  revision,
36
37
  publishedAt: new Date().toISOString(),
37
38
  document,
@@ -39,6 +40,7 @@ export async function createDevChannel(project, options = {}) {
39
40
  const handler = async (req, res) => {
40
41
  const route = new URL(req.url, "http://localhost").pathname;
41
42
  if (!route.startsWith("/__studio/dev/")) return false;
43
+ if (project.content.contractId !== activeContractId) await handler.refresh();
42
44
  const origin = `http://${req.headers.host}`;
43
45
  const send = (code, body) => {
44
46
  res.writeHead(code, {
@@ -66,7 +68,7 @@ export async function createDevChannel(project, options = {}) {
66
68
  return send(200, {
67
69
  projectId: project.id,
68
70
  schemaVersion: project.content.schemaVersion,
69
- contractId: project.content.contractId,
71
+ contractId: activeContractId,
70
72
  endpoint,
71
73
  writeUrl: prefix + "/draft",
72
74
  nativeUrl,
@@ -81,7 +83,7 @@ export async function createDevChannel(project, options = {}) {
81
83
  return devices.handle(req, res, route.slice(prefix.length), send);
82
84
  if (route === prefix + "/content.json" && req.method === "GET") {
83
85
  const requested = new URL(req.url, origin).searchParams.get("contractId");
84
- if (requested && requested !== project.content.contractId)
86
+ if (requested && requested !== activeContractId)
85
87
  return send(409, {
86
88
  error:
87
89
  "No compatible development content. Regenerate Studio and reload the app.",
@@ -121,6 +123,7 @@ export async function createDevChannel(project, options = {}) {
121
123
  await writeDocument(draftFile, next);
122
124
  document = next;
123
125
  revision = nextRevision;
126
+ activeContractId = project.content.contractId;
124
127
  hasDraft = true;
125
128
  });
126
129
  writes = task.catch(() => {});
@@ -137,14 +140,18 @@ export async function createDevChannel(project, options = {}) {
137
140
  }
138
141
  return send(404, { error: "Development session is unavailable." });
139
142
  };
140
- handler.refresh = async () => {
141
- await writes;
142
- const latest = await readDocument(draftFile);
143
- const baseline = project.content.defaults ?? await readDocument(project.state.authoring.snapshotFile);
144
- const next = mergeStudioDefaults(baseline,latest);
145
- if(project.content.validate(next).length)return false;
146
- document=next;revision=hash(next);hasDraft=Object.keys(latest).length>0;
147
- return true;
143
+ handler.refresh = () => {
144
+ const task = writes.then(async () => {
145
+ const latest = await readDocument(draftFile);
146
+ const baseline = project.content.defaults ?? await readDocument(project.state.authoring.snapshotFile);
147
+ const next = mergeStudioDefaults(baseline, latest);
148
+ if (project.content.validate(next).length) return false;
149
+ document = next; revision = hash(next); hasDraft = Object.keys(latest).length > 0;
150
+ activeContractId = project.content.contractId;
151
+ return true;
152
+ });
153
+ writes = task.catch(() => {});
154
+ return task;
148
155
  };
149
156
  return handler;
150
157
  }