@thatopen/services 0.15.0 → 0.16.0

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.
@@ -127,6 +127,14 @@ machine lives at:
127
127
  https://platform.thatopen.com/dashboard/projects/<PROJECT_ID>/apps/local-app
128
128
  ```
129
129
 
130
+ > **If the platform shows "failed to load application" and the console says
131
+ > `Permission was denied for this request to access the `loopback` address space`**:
132
+ > current Chrome blocks an https page from fetching `http://localhost` until the
133
+ > user grants the **Local network access** permission. Tell the user: click the
134
+ > icon left of the URL → Site settings → **Local network access** → **Allow**,
135
+ > then reload. One-time, per browser profile. Verified 2026-09-22 — without the
136
+ > grant the dev loop does not work at all; with it, everything below does.
137
+
130
138
  Substitute the project id. That is the local app running inside the platform's own UI, against
131
139
  the project's real data. Send the user there rather than to a bare localhost port, which is the
132
140
  app without the platform around it.
@@ -257,6 +265,54 @@ engine / UI example sets. Load those indexes before writing code. For an **app**
257
265
  must be built with Lit + `@thatopen/ui` (`BUI`)** — consult the design system first. Run the
258
266
  scaffold first, then extend it; don't rebuild from scratch.
259
267
 
268
+ ## 5b. The channel: drive the running app from outside, and multiplayer
269
+
270
+ The scaffold is already listening. `src/setups/channel.ts` joins the platform
271
+ channel at boot with two typed commands (`ping`, `get-loaded-models`), and a
272
+ collaboration room shared by every open tab of the app. This is the bridge for
273
+ "an LLM on the desktop drives the app" and for multiplayer, and it is the same
274
+ system: the platform relays messages between app tabs and external tools.
275
+
276
+ To command the app from outside (a script, an MCP server, your own tool):
277
+
278
+ 1. The user creates a **platform token** with the **API** permission
279
+ (dashboard → Account → Tokens). Same secrecy rules as always.
280
+ 2. Connect with `socket.io-client` and subscribe, then publish:
281
+
282
+ ```bash
283
+ npm i socket.io-client # in the external tool's own project, not the app
284
+ ```
285
+
286
+ ```js
287
+ import { io } from "socket.io-client";
288
+
289
+ const socket = io(`https://platform.thatopen.com?accessToken=${process.env.THATOPEN_TOKEN}`);
290
+ // While iterating with `thatopen serve`, OMIT appId (the dev channel is keyed
291
+ // to the account); pass the appId once the app is published.
292
+ socket.emit("channelSubscribe", { projectId, appId, kind: "cli" });
293
+ socket.on("channelMessage", (msg) => {
294
+ if (msg.type === "channel:subscribed") {
295
+ socket.emit(
296
+ "channelPublish",
297
+ { type: "ping", requestId: crypto.randomUUID(), payload: { echo: "hi" } },
298
+ (ack) => console.log(ack), // { delivered: 0 } = no app tab is listening
299
+ );
300
+ } else {
301
+ console.log("app says:", msg);
302
+ }
303
+ });
304
+ ```
305
+
306
+ A reply to your `requestId` reaches only your socket — a second tool connected
307
+ at the same time never sees it. `{ delivered: 0 }` on the ack means no tab of
308
+ that account's app has the channel joined: open the app first.
309
+
310
+ To grow the app's command surface, extend `AppCommands` in
311
+ `src/setups/channel.ts` and handle the new command — the handler's return value
312
+ is the reply. The full walkthrough (typed contracts, collaboration events,
313
+ presence) is `node_modules/@thatopen/services/src/core/examples/channel.ts`,
314
+ indexed in the client API index you already loaded.
315
+
260
316
  ## 6. Publish (when ready)
261
317
 
262
318
  ```bash
@@ -283,8 +339,10 @@ component, once added to a project, is then triggered by an app or an automation
283
339
  four-point plan and waiting for approval to do what was just requested costs the user a whole turn
284
340
  and buys nothing. Stop and ask only when you are about to change files you did not create, when
285
341
  the request can be read two ways that mean different work, or when the next step is destructive.
286
- - **Never save to the platform on every change.** Drafts belong in `localStorage` /
287
- IndexedDB; the platform gets an explicit save. Writes are capped at 30 per minute and a
342
+ - **Never save to the platform on every change.** Drafts live in memory — the
343
+ production sandbox runs apps on an opaque origin where `localStorage`,
344
+ `sessionStorage`, IndexedDB and cookies all **throw** (they work in dev mode
345
+ and then break for every real user). The platform gets an explicit save. Writes are capped at 30 per minute and a
288
346
  `429` loses the write. See
289
347
  `node_modules/@thatopen/services/docs/rate-limits.md` before you write any save, sync or
290
348
  polling code.
@@ -74,27 +74,36 @@ try {
74
74
  copy of the change was in that request, it is gone — which is the real reason
75
75
  the local-draft pattern below matters.
76
76
 
77
- ## The pattern: local drafts, explicit saves
77
+ ## The pattern: drafts in memory, explicit saves
78
78
 
79
- Keep every intermediate state in the browser. Write to the platform only when
80
- the user asks for it.
79
+ Keep every intermediate state in memory. Write to the platform only when the
80
+ user asks for it, or on a slow timer.
81
+
82
+ > **Why not `localStorage`?** Platform apps run in a sandboxed iframe on an
83
+ > **opaque origin**, where `localStorage`, `sessionStorage`, IndexedDB and
84
+ > cookies don't return empty — they **throw, on the property access itself**.
85
+ > Dev mode grants `allow-same-origin`, so origin storage works on the machine
86
+ > of whoever writes the app and fails for every user. This has shipped broken
87
+ > at least once. Inside a platform app there are exactly two stores: memory,
88
+ > and the platform through the client API.
81
89
 
82
90
  ```ts
83
- const draftKey = `draft:${fileId}`;
91
+ const drafts = new Map<string, { state: unknown; at: number }>();
84
92
 
85
- function onChange(state: unknown) {
86
- localStorage.setItem(draftKey, JSON.stringify({ state, at: Date.now() }));
93
+ function onChange(fileId: string, state: unknown) {
94
+ drafts.set(fileId, { state, at: Date.now() });
87
95
  }
88
96
 
89
- async function onSave(state: unknown) {
97
+ async function onSave(fileId: string, state: unknown) {
90
98
  const blob = new Blob([JSON.stringify(state)], { type: 'application/json' });
91
99
  await client.updateFile(fileId, { file: blob, versionTag: `v${Date.now()}` });
92
- localStorage.removeItem(draftKey);
100
+ drafts.delete(fileId);
93
101
  }
94
102
  ```
95
103
 
96
- On load, if a draft exists for the file, offer to restore it. That gives crash
97
- recovery — the thing autosave was really for — at zero requests.
104
+ A draft in memory does not survive a reload. If surviving one matters, the
105
+ draft itself has to go to the platform — on the explicit save, or on the slow
106
+ timer below — because there is nowhere else to put it.
98
107
 
99
108
  Rules of thumb:
100
109
 
@@ -104,10 +113,11 @@ Rules of thumb:
104
113
  - **Never save while a save is in flight.** Keep a flag per file, and drop or
105
114
  queue the second save. Overlapping writes also create versions that are hard
106
115
  to reconcile.
107
- - **Big or binary work-in-progress** belongs in IndexedDB, not `localStorage`
108
- (about 5 MB per origin).
109
- - There is **no draft-write method in the client on purpose.** Local storage is
110
- the draft store; the platform stores versions the user chose to keep.
116
+ - **Big or binary work-in-progress** stays in memory too — there is no
117
+ IndexedDB fallback in the sandbox, so size is bounded by RAM, which is fine:
118
+ it is the same object the app is already editing.
119
+ - There is **no draft-write method in the client on purpose.** Memory is the
120
+ draft store; the platform stores versions the user chose to keep.
111
121
 
112
122
  ## Retries
113
123
 
package/package.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "access": "public"
16
16
  },
17
17
  "private": false,
18
- "version": "0.15.0",
18
+ "version": "0.16.0",
19
19
  "main": "dist/index.cjs.js",
20
20
  "module": "dist/index.es.js",
21
21
  "types": "dist/index.d.ts",
@@ -36,12 +36,19 @@ Once you have these, you know everything available on the platform. Only then fe
36
36
  **Run it first** (`npm run dev`) to see it work, *then* extend it — don't rebuild a viewer from scratch.
37
37
  - **Do the thing they asked for.** If the request already says what to build, build it. Stop and ask only when you are about to change files you did not create, when the request can be read two ways that mean different work, or when the next step is destructive.
38
38
  - If something already exists in the indexes, use it — don't reimplement it.
39
+ - **The scaffold already listens on the platform channel** (`src/setups/channel.ts`):
40
+ external tools (an MCP server, a CLI) command the app through `AppCommands`, and
41
+ tabs collaborate through `AppCollabEvents`. Extend those maps instead of opening
42
+ sockets — apps cannot hold their own connection. Walkthrough:
43
+ `node_modules/@thatopen/services/src/core/examples/channel.ts`.
39
44
 
40
45
  ## Hard rules (always apply)
41
46
 
42
47
  1. **Never write to the platform on every change.** No autosave per keystroke,
43
- per drag, or inside a render loop. Keep work in progress in `localStorage` /
44
- IndexedDB and call the platform on an **explicit user save** (or a timer no
48
+ per drag, or inside a render loop. Keep work in progress **in memory** — the
49
+ production sandbox has NO origin storage (`localStorage`, `sessionStorage`,
50
+ IndexedDB and cookies **throw** on its opaque origin; dev mode hides this) —
51
+ and call the platform on an **explicit user save** (or a timer no
45
52
  faster than once every 30 seconds). Writes are capped at **30 per minute**;
46
53
  crossing that returns `429` and the write is **lost**, not queued.
47
54
  If the user asks for autosave, build it against local storage and say so.
@@ -209,6 +209,39 @@ client.onExecutionProgress(executionId, (data) => {
209
209
  client.localServerUrl = "http://localhost:4001";
210
210
  ```
211
211
 
212
+ ## The channel: external control + multiplayer
213
+
214
+ This app ships listening on the platform channel (`src/setups/channel.ts`). Two
215
+ things work out of the box:
216
+
217
+ - **External tools can command it.** An MCP server, a CLI or a plugin
218
+ authenticated with a platform token of the same account can send the commands
219
+ declared in `AppCommands` (`ping`, `get-loaded-models`) and gets typed
220
+ replies back. This is how a desktop LLM drives the running app.
221
+ - **Tabs of this app see each other.** Every open tab joins a collaboration
222
+ room shared across accounts (`AppCollabEvents`) — the base for cursors,
223
+ presence and live sync.
224
+
225
+ To add a command, extend the map and handle it; whatever you return is the reply:
226
+
227
+ ```ts
228
+ export type AppCommands = {
229
+ // ...existing commands...
230
+ "select-by-category": { payload: { category: string }; reply: { count: number } };
231
+ };
232
+
233
+ external.on("select-by-category", async ({ category }) => {
234
+ const count = await selectByCategory(category);
235
+ return { count };
236
+ });
237
+ ```
238
+
239
+ The app never opens a socket: the platform shell owns the connection and relays
240
+ it, which is also the security boundary. Joining is opt-in per room — the
241
+ scaffold joins at boot; move the `join()` calls behind a button to make it a
242
+ user choice. Full walkthrough with a collab example:
243
+ `node_modules/@thatopen/services/src/core/examples/channel.ts`.
244
+
212
245
  ## Configuration
213
246
 
214
247
  - `.thatopen` — local config (gitignored). Created by `npm run login`; also holds
@@ -8,6 +8,7 @@ import * as BUI from "@thatopen/ui";
8
8
  import * as MARKERJS from "@markerjs/markerjs3";
9
9
  import { PlatformClient, UIManager } from "@thatopen/services";
10
10
  import { setAppContext } from "./app";
11
+ import { setupChannel } from "./setups/channel";
11
12
 
12
13
  // ─── A2 migration — PHASES 1+2: boot on UIManager + re-dock panels ───────────
13
14
  // Juan consolidated the old AppManager (layout) + ViewportsManager (viewport)
@@ -102,6 +103,16 @@ async function main() {
102
103
  }
103
104
  setAppContext(client, projectData);
104
105
 
106
+ // The platform channel, pre-wired (see ./setups/channel.ts): external tools
107
+ // of this account (an MCP server, a CLI, a plugin) can command this app, and
108
+ // other open tabs of it are reachable for collaboration. It only exists
109
+ // inside the platform iframe, so a failure is a warning, never a boot error.
110
+ try {
111
+ setupChannel(client, components);
112
+ } catch (error) {
113
+ console.warn("[channel] unavailable outside the platform:", error);
114
+ }
115
+
105
116
  // Pluggable loaders for <top-models-list>. The built-in ships the lightweight
106
117
  // defaults (.frag load, IFC→fragments convert); heavy/app-specific loaders are
107
118
  // registered here so they stay OUT of the built-in's bundle. The reality-capture
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The platform channel, pre-wired: typed commands that an external tool (an
3
+ * MCP server, a CLI, a desktop LLM, a plugin) can send this app while it runs,
4
+ * and a collaboration room where every open tab of this app hears the others.
5
+ *
6
+ * The app never opens a socket. The platform shell owns the one connection and
7
+ * relays over `postMessage`; `client.channel` is the typed layer on top. That
8
+ * also means this only works inside the platform iframe — `setupChannel` is
9
+ * called from a try/catch in `main.ts` so the app still boots anywhere else.
10
+ *
11
+ * To extend: add a command to {@link AppCommands} and handle it below. What a
12
+ * handler returns is sent back to whoever asked; throw and they get the error.
13
+ * Full walkthrough: `node_modules/@thatopen/services/src/core/examples/channel.ts`.
14
+ */
15
+ import * as OBC from "@thatopen/components";
16
+ import { PlatformClient } from "@thatopen/services";
17
+
18
+ /**
19
+ * The contract with external tools. Each command names its payload and, when
20
+ * the caller waits for an answer, its reply. A command without `reply` is a
21
+ * notification. This type is the whole API an MCP server sees: keep it honest.
22
+ */
23
+ export type AppCommands = {
24
+ /** Liveness check — answer whoever is probing that this app is listening. */
25
+ ping: { payload: { echo?: string } | undefined; reply: { pong: true; echo?: string } };
26
+ /** The ids of the models currently loaded in the viewer. */
27
+ "get-loaded-models": { payload: undefined; reply: { models: string[] } };
28
+ };
29
+
30
+ /** Events tabs of this app send each other. Events, so no replies. */
31
+ export type AppCollabEvents = {
32
+ /** Sent once on join, so other tabs can log or greet. Replace with your own. */
33
+ hello: { at: number };
34
+ };
35
+
36
+ /**
37
+ * Wires both rooms and joins them at boot. Joining is what makes the app
38
+ * reachable — until then, nothing arrives. If you'd rather listen only when
39
+ * the user opts in, move the `join()` calls behind a button.
40
+ */
41
+ export function setupChannel(
42
+ client: PlatformClient,
43
+ components: OBC.Components,
44
+ ) {
45
+ // Commands from every kind of external tool of this account. Narrow with
46
+ // `client.channel.external<AppCommands>("mcp")` to hear one kind only.
47
+ const external = client.channel.external<AppCommands>();
48
+
49
+ external.on("ping", (payload) => ({ pong: true as const, echo: payload?.echo }));
50
+
51
+ external.on("get-loaded-models", () => {
52
+ const fragments = components.get(OBC.FragmentsManager);
53
+ return { models: [...fragments.list.keys()] };
54
+ });
55
+
56
+ // Collaboration: other users' tabs of this same app, across accounts.
57
+ const collab = client.channel.collab<AppCollabEvents>();
58
+ collab.on("hello", (event, { from }) => {
59
+ console.log(`[channel] tab ${from} joined the collaboration`, event);
60
+ });
61
+ collab.onPeerLeft(({ from }) => {
62
+ console.log(`[channel] tab ${from} left the collaboration`);
63
+ });
64
+
65
+ // Fire-and-forget joins: the app is usable with or without the channel, so
66
+ // a failure is a warning, never a boot error.
67
+ external
68
+ .join()
69
+ .catch((error) => console.warn("[channel] external join failed:", error));
70
+ collab
71
+ .join()
72
+ .then(() => collab.send("hello", { at: Date.now() }))
73
+ .catch((error) => console.warn("[channel] collab join failed:", error));
74
+
75
+ return { external, collab };
76
+ }