@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 +11 -0
- package/README.md +72 -22
- package/bin/studio.mjs +3 -2
- package/package.json +2 -2
- package/scripts/connect-preview.mjs +124 -0
- package/scripts/connection-relay.mjs +97 -10
- package/scripts/connector-agent.mjs +4 -2
- package/scripts/connector.mjs +145 -104
- package/scripts/dev-channel.mjs +18 -11
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
|
-
|
|
254
|
+
Start your app's development server first. For Expo, use a separate terminal:
|
|
255
255
|
|
|
256
256
|
```sh
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
+
Once the app server is running and `STUDIO_CONNECT_TOKEN` is configured, connecting is one command:
|
|
267
263
|
|
|
268
264
|
```sh
|
|
269
|
-
npx
|
|
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` |
|
|
277
|
-
| `--
|
|
278
|
-
|
|
|
279
|
-
| `--
|
|
280
|
-
| `--native-
|
|
281
|
-
| `--
|
|
282
|
-
| `
|
|
283
|
-
| `--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 [--
|
|
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-
|
|
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-
|
|
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&®istered&&!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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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",
|
package/scripts/connector.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
244
|
+
while (!stopped && !signal?.aborted) {
|
|
197
245
|
try {
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
},
|
|
275
|
+
get expiresAt() { return leaseUntil; },
|
|
276
|
+
stop,
|
|
256
277
|
};
|
|
257
278
|
}
|
|
258
279
|
export async function connectorCli(args) {
|
|
259
|
-
const option = (name)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
}
|
package/scripts/dev-channel.mjs
CHANGED
|
@@ -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:
|
|
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:
|
|
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 !==
|
|
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 =
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
}
|