@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.
package/docs/ai-quickstart.md
CHANGED
|
@@ -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
|
|
287
|
-
|
|
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.
|
package/docs/rate-limits.md
CHANGED
|
@@ -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:
|
|
77
|
+
## The pattern: drafts in memory, explicit saves
|
|
78
78
|
|
|
79
|
-
Keep every intermediate state in
|
|
80
|
-
|
|
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
|
|
91
|
+
const drafts = new Map<string, { state: unknown; at: number }>();
|
|
84
92
|
|
|
85
|
-
function onChange(state: unknown) {
|
|
86
|
-
|
|
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
|
-
|
|
100
|
+
drafts.delete(fileId);
|
|
93
101
|
}
|
|
94
102
|
```
|
|
95
103
|
|
|
96
|
-
|
|
97
|
-
|
|
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**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
package/resources/AGENTS.md
CHANGED
|
@@ -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
|
|
44
|
-
|
|
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
|
+
}
|