@thatopen/services 0.16.0 → 0.17.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.
@@ -149,28 +149,33 @@ document.body.style.margin = "0";
149
149
  gotcha below), then appended as the toolbar's light-DOM content:
150
150
 
151
151
  ```ts
152
+ import { html, render } from "lit";
153
+
152
154
  const headlessBar = document.createElement("top-viewer-toolbar");
153
155
  headlessBar.toggleAttribute("headless", true);
154
156
 
155
- const customButtons = document.createElement("bim-toolbar") as unknown as HTMLElement;
156
- customButtons.setAttribute("style", "pointer-events: auto;");
157
+ // A plain container — the `<bim-toolbar>` itself is rendered inside it.
158
+ const toolbarHost = document.createElement("div");
159
+ toolbarHost.style.pointerEvents = "auto";
157
160
  render(
158
161
  html`
159
- <bim-toolbar-section label-hidden style="background: transparent;">
160
- <top-viewer-mode-toggle-button></top-viewer-mode-toggle-button>
161
- <top-viewer-hide-button></top-viewer-hide-button>
162
- <top-viewer-select-button></top-viewer-select-button>
163
- </bim-toolbar-section>
164
- <bim-toolbar-section label-hidden style="background: transparent;">
165
- <bim-button
166
- icon="mdi:fit-to-page-outline"
167
- @click=${() => myOwnFitToViewLogic()}
168
- ><bim-tooltip placement="top">Zoom to fit</bim-tooltip></bim-button>
169
- </bim-toolbar-section>
162
+ <bim-toolbar>
163
+ <bim-toolbar-section label-hidden style="background: transparent;">
164
+ <top-viewer-mode-toggle-button></top-viewer-mode-toggle-button>
165
+ <top-viewer-hide-button></top-viewer-hide-button>
166
+ <top-viewer-select-button></top-viewer-select-button>
167
+ </bim-toolbar-section>
168
+ <bim-toolbar-section label-hidden style="background: transparent;">
169
+ <bim-button
170
+ icon="mdi:fit-to-page-outline"
171
+ @click=${() => myOwnFitToViewLogic()}
172
+ ><bim-tooltip placement="top">Zoom to fit</bim-tooltip></bim-button>
173
+ </bim-toolbar-section>
174
+ </bim-toolbar>
170
175
  `,
171
- customButtons,
176
+ toolbarHost,
172
177
  );
173
- headlessBar.appendChild(customButtons);
178
+ headlessBar.appendChild(toolbarHost);
174
179
  // headlessBar can now be appended anywhere inside <top-viewer>.
175
180
  ```
176
181
 
@@ -208,7 +213,7 @@ document.body.style.margin = "0";
208
213
  Custom Elements v1 forbids for elements created imperatively —
209
214
  `document.createElement("bim-button")` throws `NotSupportedError`.
210
215
  Render them with lit's `render(html\`...\`, container)` instead, exactly
211
- like the `customButtons` bar above — never build them by hand.
216
+ like the `toolbarHost` container above — never build them by hand.
212
217
  - **`bim-button` sizes itself** — square when it's icon-only, a consistent
213
218
  height when it has a label. Don't set `width`/`height`/`min-width`
214
219
  inline on it; that only fights its own layout and produces buttons that
@@ -225,7 +230,13 @@ document.body.style.margin = "0";
225
230
  ### 🧩 Standalone buttons reference
226
231
 
227
232
  Every button the default bar uses is independently importable/usable as
228
- its own custom element:
233
+ its own custom element. They render icon-only with a tooltip; set the
234
+ optional `label` attribute on any of them to show text next to the icon
235
+ (the tooltip stays as it is):
236
+
237
+ ```html
238
+ <top-viewer-hide-button label="Hide"></top-viewer-hide-button>
239
+ ```
229
240
 
230
241
  | Element | Does |
231
242
  |---|---|
@@ -237,16 +248,105 @@ document.body.style.margin = "0";
237
248
  | `<top-viewer-focus-button>` | Frames the camera on the target set |
238
249
  | `<top-viewer-reset-button>` | Restores full visibility + opacity (global) |
239
250
  | `<top-viewer-select-button>` | Activates the default Select tool |
240
- | `<top-viewer-clip-button>` | Activates the Clip tool |
251
+ | `<top-viewer-clip-button>` | Clipping planes: face-placing mode by default, or axis planes / a mode menu via `modes` (see below) |
241
252
  | `<top-viewer-measure-length-button>` | Activates length measurement |
242
253
  | `<top-viewer-measure-area-button>` | Activates area measurement |
243
254
  | `<top-viewer-measure-angle-button>` | Activates angle measurement |
244
255
  | `<top-viewer-measure-edge-button>` | Activates edge measurement |
245
256
  | `<top-viewer-measure-face-button>` | Activates face measurement |
246
257
  | `<top-viewer-measure-volume-button>` | Activates volume measurement |
258
+ | `<top-viewer-measure-button>` | All measurement modes in one button (a menu by default), or a chosen subset via `modes` (see below) |
247
259
  | `<top-viewer-projection-toggle-button>` | Toggles Perspective ⇄ Orthographic |
248
260
  | `<top-viewer-walkthrough-button>` | Toggles first-person walkthrough |
261
+ | `<top-viewer-navigation-button>` | Switches between orbit and first-person navigation (a menu by default), or a single chosen mode via `modes` (see below) |
249
262
 
250
263
  Each one self-wires from context — drop it anywhere under `<top-viewer>`
251
264
  and it works, with no props to pass in.
265
+
266
+ ### ✂️ Clip button modes
267
+
268
+ Without any attribute, `<top-viewer-clip-button>` enters face-placing mode:
269
+ double-click a face in the model and a clip plane is dropped on it. The
270
+ optional `modes` attribute changes what the button offers — a whitespace-
271
+ (or comma-) separated list of `x`, `y`, `z` and `face` (case-insensitive;
272
+ unknown tokens are ignored, duplicates dropped, the order you write is the
273
+ order shown):
274
+
275
+ ```html
276
+ <!-- a menu with four entries: X axis, Y axis, Z axis, Face -->
277
+ <top-viewer-clip-button modes="x y z face"></top-viewer-clip-button>
278
+
279
+ <!-- one mode = no menu; a click creates a horizontal cut right away -->
280
+ <top-viewer-clip-button modes="y"></top-viewer-clip-button>
281
+
282
+ <!-- default: face-placing mode -->
283
+ <top-viewer-clip-button></top-viewer-clip-button>
284
+ ```
285
+
286
+ - **One mode** — the button runs it directly on click. `face` is the
287
+ default face-placing behavior.
288
+ - **Two or more** — clicking opens a menu with one entry per mode ("X
289
+ axis", "Y axis", "Z axis", "Face"); picking one runs it and closes the
290
+ menu.
291
+ - **Axis modes** create a single plane through the center of the bounding
292
+ box of all loaded models (nothing happens if no model is loaded). Axes
293
+ follow the three.js convention — **Y is vertical**, so `y` is a
294
+ horizontal cut. The plane's normal is the *negative* axis — `(-1, 0, 0)`,
295
+ `(0, -1, 0)`, `(0, 0, -1)` — so the plane clips what lies on the
296
+ positive side of the axis (`y` removes everything above the middle of
297
+ the model). Axis planes behave exactly like face-placed ones: same
298
+ section styling, listed in the objects outliner, draggable by their
299
+ gizmo, removable with Delete.
300
+ - The button shows as active only while face-placing mode is on.
301
+
302
+ ### 📏 Measure button modes
303
+
304
+ `<top-viewer-measure-button>` puts the measurement tools behind a single
305
+ button. Without any attribute it offers all six modes — `length`, `area`,
306
+ `angle`, `edge`, `face`, `volume` — in a menu. The optional `modes`
307
+ attribute picks a subset (whitespace- or comma-separated, case-insensitive;
308
+ unknown tokens are ignored, duplicates dropped, your order is kept):
309
+
310
+ ```html
311
+ <!-- default: a menu with all six modes -->
312
+ <top-viewer-measure-button></top-viewer-measure-button>
313
+
314
+ <!-- a menu with just these three, in this order -->
315
+ <top-viewer-measure-button modes="length area volume"></top-viewer-measure-button>
316
+
317
+ <!-- one mode = no menu; a click starts that measurement right away -->
318
+ <top-viewer-measure-button modes="length"></top-viewer-measure-button>
319
+ ```
320
+
321
+ - **One mode** — a click activates it directly, exactly like the matching
322
+ standalone button (`length` and `area` measure freely; `edge` and `face`
323
+ snap to edges and faces).
324
+ - **Two or more** — a click opens a menu with one text-labelled entry per
325
+ mode ("Length", "Area", "Angle", "Edge", "Face", "Volume"); picking one
326
+ activates it and closes the menu.
327
+ - The button shows as active while any of its modes is the active
328
+ measurement mode, and its icon switches to that mode's icon (a ruler
329
+ otherwise). In the menu, the active mode's entry is marked.
330
+
331
+ ### 🧭 Navigation button modes
332
+
333
+ `<top-viewer-navigation-button>` switches how the camera moves. Without any
334
+ attribute it offers both modes — `orbit` and `first-person` — in a menu.
335
+ The optional `modes` attribute narrows or reorders them (whitespace- or
336
+ comma-separated, case-insensitive; unknown tokens are ignored, duplicates
337
+ dropped, your order is kept):
338
+
339
+ ```html
340
+ <!-- default: a menu with "Orbit" and "First person" -->
341
+ <top-viewer-navigation-button></top-viewer-navigation-button>
342
+
343
+ <!-- one mode = no menu; a click switches straight to it -->
344
+ <top-viewer-navigation-button modes="orbit"></top-viewer-navigation-button>
345
+ ```
346
+
347
+ - `orbit` turns first-person navigation off (nothing happens if it already
348
+ is); `first-person` turns it on (nothing happens if it already is) — the
349
+ same first-person navigation `<top-viewer-walkthrough-button>` toggles.
350
+ - The button's icon always shows the current mode, and the button is active
351
+ while first-person is on. In the menu, the current mode's entry is marked.
252
352
  */
@@ -40,6 +40,20 @@ async function main() {
40
40
  );
41
41
  components.get(UIManager).init();
42
42
 
43
+ // The platform channel, pre-wired (see ./setups/channel.ts): external tools
44
+ // of this account (an MCP server, a CLI, a plugin) can command this app, and
45
+ // other open tabs of it are reachable for collaboration. Joined HERE, before
46
+ // any await the boot can park on (the world, project data): a stalled fetch
47
+ // further down must not leave the app unreachable — and silently so, since a
48
+ // join that is never requested also never gets to time out loudly. It only
49
+ // exists inside the platform iframe, so a failure is a warning, never a boot
50
+ // error.
51
+ try {
52
+ setupChannel(client, components);
53
+ } catch (error) {
54
+ console.warn("[channel] unavailable outside the platform:", error);
55
+ }
56
+
43
57
  // One STABLE top-viewer node, returned by reference so re-rendering top-app
44
58
  // (when we add the panels below) reuses it instead of disposing/recreating
45
59
  // its world. No <top-viewer-tools>: the bim-viewer mounts its own tabbed
@@ -97,22 +111,24 @@ async function main() {
97
111
  const projectId: string | undefined = client?.context?.projectId;
98
112
  let projectData;
99
113
  try {
100
- if (projectId) projectData = await client.getProjectData(projectId);
114
+ // Bounded: this request carries no AbortSignal, so a stalled response
115
+ // parked everything below it for good (that is how the app once ended up
116
+ // booted but with no panels and, before the channel moved up top, deaf to
117
+ // external tools). Past the deadline the app boots without project data —
118
+ // the same degraded path the catch below already provides.
119
+ if (projectId) {
120
+ projectData = await Promise.race([
121
+ client.getProjectData(projectId),
122
+ new Promise<undefined>((resolve) => {
123
+ setTimeout(() => resolve(undefined), 20_000);
124
+ }),
125
+ ]);
126
+ }
101
127
  } catch {
102
128
  /* dev/no-project → consumers degrade gracefully */
103
129
  }
104
130
  setAppContext(client, projectData);
105
131
 
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
-
116
132
  // Pluggable loaders for <top-models-list>. The built-in ships the lightweight
117
133
  // defaults (.frag load, IFC→fragments convert); heavy/app-specific loaders are
118
134
  // registered here so they stay OUT of the built-in's bundle. The reality-capture
@@ -0,0 +1,7 @@
1
+ ACCESS_TOKEN=your-access-token-here
2
+ API_URL=https://api.thatopen.com
3
+ PROJECT_ID=your-project-id-here
4
+ # Required only for permissions.ts — obtain from DevTools → Network → any /api request → Authorization header (without "Bearer ")
5
+ JWT=
6
+ # Required only for execution.ts — obtain from listComponents() or the platform dashboard
7
+ COMPONENT_ID=
@@ -0,0 +1,67 @@
1
+ // description: "App lifecycle — list, get with versions, create, download bundle, and archive."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { EngineServicesClient } from '../client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
9
+ const API_URL = process.env.API_URL;
10
+ const PROJECT_ID = process.env.PROJECT_ID;
11
+
12
+ if (!ACCESS_TOKEN || !API_URL) {
13
+ throw new Error('ACCESS_TOKEN and API_URL are required in src/core/examples/.env');
14
+ }
15
+
16
+ async function main() {
17
+ const client = new EngineServicesClient(ACCESS_TOKEN!, API_URL!);
18
+
19
+ // --- List ---
20
+ const apps = await client.listApps();
21
+ console.log(`\nFound ${apps.length} app(s):`);
22
+ for (const app of apps) {
23
+ console.log(` [${app._id}] ${app.name}`);
24
+ }
25
+
26
+ // --- Get ---
27
+ if (apps.length > 0) {
28
+ const detail = await client.listApps({ ShowVersions: true });
29
+ console.log('\nFirst app detail (with versions):');
30
+ console.log(JSON.stringify(detail[0], null, 2));
31
+ }
32
+
33
+ if (!PROJECT_ID) {
34
+ console.log('\nSkipping create/download/archive — set PROJECT_ID in .env to run the full cycle.');
35
+ return;
36
+ }
37
+
38
+ // --- Create ---
39
+ // The file must be a valid ZIP produced by the thatopen CLI toolchain (`thatopen publish`).
40
+ // Uploading an invalid or empty ZIP causes the server to close the connection — use a real built app ZIP here.
41
+ // appProps is optional; omitting it uses platform defaults.
42
+ const bundle = new Blob([''], { type: 'application/zip' });
43
+ const appName = `__example-test__-${Date.now()}`;
44
+ const created = await client.createApp({
45
+ file: bundle,
46
+ name: appName,
47
+ versionTag: 'v1',
48
+ projectId: PROJECT_ID,
49
+ });
50
+ console.log(`\nCreated app: [${created.item._id}] ${created.item.name}`);
51
+
52
+ const appId = created.item._id;
53
+
54
+ // --- Download bundle ---
55
+ // downloadAppBundle extracts only the JS bundle from the ZIP — not the full archive.
56
+ const bundleResponse = await client.downloadAppBundle(appId);
57
+ const bundleText = await bundleResponse.text();
58
+ console.log(`\nDownloaded bundle (latest): ${bundleText.slice(0, 80)}...`);
59
+
60
+ // --- Archive ---
61
+ // archiveApp is a soft-delete. Unlike files and components, there is no recoverApp method —
62
+ // archived apps can only be managed via the platform UI.
63
+ await client.archiveApp(appId);
64
+ console.log(`\nArchived app [${appId}] — account is clean.`);
65
+ }
66
+
67
+ main().catch(console.error);
@@ -0,0 +1,99 @@
1
+ // description: "Batch reads — sign many hidden files, list versions and metadata for many files, and resolve many folders in one request each."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { EngineServicesClient } from '../client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
9
+ const API_URL = process.env.API_URL;
10
+ const PROJECT_ID = process.env.PROJECT_ID;
11
+
12
+ if (!ACCESS_TOKEN || !API_URL) {
13
+ throw new Error('ACCESS_TOKEN and API_URL are required in src/core/examples/.env');
14
+ }
15
+
16
+ async function main() {
17
+ const client = new EngineServicesClient(ACCESS_TOKEN!, API_URL!);
18
+
19
+ if (!PROJECT_ID) {
20
+ console.log('Set PROJECT_ID in .env to run this example.');
21
+ return;
22
+ }
23
+
24
+ const files = await client.listFiles({ projectId: PROJECT_ID });
25
+ if (!files.length) {
26
+ console.log('No files in this project — nothing to batch.');
27
+ return;
28
+ }
29
+ const fileIds = files.map((file) => String(file._id));
30
+
31
+ // --- Versions for a whole page of files ---
32
+ // One request instead of one per file. Entries come back in request order,
33
+ // and a file the token cannot read carries an `error` instead of `versions`.
34
+ const versionEntries = await client.listVersionsBatch(fileIds);
35
+ console.log('\nVersions per file:');
36
+ for (const entry of versionEntries) {
37
+ if (entry.error) {
38
+ console.log(` ${entry.itemId} → ${entry.error.status} ${entry.error.message}`);
39
+ continue;
40
+ }
41
+ console.log(` ${entry.itemId} → ${entry.versions?.length ?? 0} version(s)`);
42
+ }
43
+
44
+ // --- Metadata for specific (file, version) pairs ---
45
+ // The versions above already carry their metadata, so this is only needed
46
+ // when the version list itself is not wanted.
47
+ const pairs = versionEntries
48
+ .filter((entry) => entry.versions?.length)
49
+ .map((entry) => ({
50
+ itemId: entry.itemId,
51
+ versionTag: entry.versions![0].tag,
52
+ }));
53
+ const metadataEntries = await client.getFileVersionMetadataBatch(pairs);
54
+ console.log('\nLatest-version metadata:');
55
+ for (const entry of metadataEntries) {
56
+ console.log(
57
+ ` ${entry.itemId}@${entry.versionTag} → ${JSON.stringify(entry.metadata ?? entry.error)}`,
58
+ );
59
+ }
60
+
61
+ // --- Folders by id ---
62
+ // Use listFolders({ projectId }) for the whole tree; this is for a known set
63
+ // of ids, such as the parents behind a breadcrumb.
64
+ const folderIds = [
65
+ ...new Set(files.map((file) => file.folderId).filter(Boolean).map(String)),
66
+ ];
67
+ if (folderIds.length) {
68
+ const folderEntries = await client.getFoldersBatch(folderIds);
69
+ console.log('\nFolders:');
70
+ for (const entry of folderEntries) {
71
+ console.log(` ${entry.folderId} → ${entry.folder?.name ?? entry.error?.message}`);
72
+ }
73
+ }
74
+
75
+ // --- Signed URLs for many hidden files ---
76
+ // This is the call tile-based viewers (splats, point clouds) should use.
77
+ // Minting one URL per tile as the camera moves is what pushes a single
78
+ // session past the rate limit; one request covers up to 100 tiles, and
79
+ // longer lists are split into several requests automatically.
80
+ const hiddenFiles = await client.getHiddenFilesByParent(fileIds[0]);
81
+ if (!hiddenFiles.length) {
82
+ console.log('\nNo hidden files on the first file — skipping signed URLs.');
83
+ return;
84
+ }
85
+ const signedEntries = await client.getHiddenFileSignedUrlsBatch(
86
+ hiddenFiles.map((hiddenFile) => String(hiddenFile._id)),
87
+ 3600,
88
+ );
89
+ console.log('\nSigned hidden file URLs:');
90
+ for (const entry of signedEntries) {
91
+ if (entry.error) {
92
+ console.log(` ${entry.hiddenFileId} → ${entry.error.status} ${entry.error.message}`);
93
+ continue;
94
+ }
95
+ console.log(` ${entry.hiddenFileId} → expires ${entry.expiresAt}`);
96
+ }
97
+ }
98
+
99
+ main().catch(console.error);
@@ -0,0 +1,65 @@
1
+ // description: "Platform channel (app-side, runs inside the platform iframe) — a collaboration room with presence, and typed commands from external tools (MCP, CLI, Revit, etc) with automatic replies."
2
+ import { PlatformClient } from '../platform-client';
3
+
4
+ // Unlike the other examples this is not a Node script: `client.channel` only
5
+ // exists inside an app running in the platform's iframe, and throws anywhere
6
+ // else. Copy `setupChannel` into your app.
7
+
8
+ // The contract with an external tool, written once. Each command names its
9
+ // payload and, if the caller waits for an answer, its reply. Use `type`, and
10
+ // list only what the app handles.
11
+ type McpCommands = {
12
+ 'select-by-category': {
13
+ payload: { category: string };
14
+ reply: { count: number };
15
+ };
16
+ 'get-loaded-models': { payload: undefined; reply: { models: string[] } };
17
+ };
18
+
19
+ // What tabs of this app send each other. No replies: these are events.
20
+ type CollabEvents = {
21
+ 'cursor-position': { userName: string; x: number; y: number; z: number };
22
+ };
23
+
24
+ export async function setupChannel(client: PlatformClient) {
25
+ // Commands from this account's MCP server only. `external()` with no kind
26
+ // hears every kind of external tool (MCP, CLI, Revit plugin, etc...).
27
+ const mcp = client.channel.external<McpCommands>('mcp');
28
+
29
+ // What you return is sent back to whoever asked. Throw and it gets an error
30
+ // instead of waiting for a timeout. A command that was sent without waiting
31
+ // for an answer (a notification) has its return value ignored.
32
+ mcp.on('select-by-category', async ({ category }) => {
33
+ const count = await selectByCategory(category);
34
+ return { count };
35
+ });
36
+ mcp.on('get-loaded-models', () => ({ models: listModels() }));
37
+
38
+ // Joining is opt-in. Nothing reaches this app until it joins, so wire it to
39
+ // a button, or join at startup if the app should always listen.
40
+ await mcp.join();
41
+
42
+ // Collaboration: other users' tabs of this same app, across accounts.
43
+ const collab = client.channel.collab<CollabEvents>();
44
+ collab.on('cursor-position', (cursor, { from }) => moveAvatar(from, cursor));
45
+ // `from` identifies a tab, so a departure removes exactly its avatar.
46
+ collab.onPeerLeft(({ from }) => removeAvatar(from));
47
+ await collab.join();
48
+
49
+ // Sending does nothing until the platform has confirmed the join.
50
+ collab.send('cursor-position', { userName: 'Ada', x: 0, y: 0, z: 0 });
51
+
52
+ // Being in a room says this tab is listening, not that anyone is there.
53
+ // `peers` is who the platform reports on the other end right now.
54
+ mcp.onChange(() => {
55
+ console.log(`MCP: ${mcp.joined ? 'listening' : 'off'}, ${mcp.peers.length} connected`);
56
+ });
57
+
58
+ return { mcp, collab };
59
+ }
60
+
61
+ // Stand-ins for the app's own logic.
62
+ declare function selectByCategory(category: string): Promise<number>;
63
+ declare function listModels(): string[];
64
+ declare function moveAvatar(id: string, cursor: unknown): void;
65
+ declare function removeAvatar(id: string): void;
@@ -0,0 +1,91 @@
1
+ // description: "Cloud component lifecycle — list, get with versions, create, update, download bundle, archive, and recover."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { EngineServicesClient } from '../client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
9
+ const API_URL = process.env.API_URL;
10
+ const PROJECT_ID = process.env.PROJECT_ID;
11
+
12
+ if (!ACCESS_TOKEN || !API_URL) {
13
+ throw new Error('ACCESS_TOKEN and API_URL are required in src/core/examples/.env');
14
+ }
15
+
16
+ async function main() {
17
+ const client = new EngineServicesClient(ACCESS_TOKEN!, API_URL!);
18
+
19
+ // --- List ---
20
+ const components = await client.listComponents();
21
+ console.log(`\nFound ${components.length} component(s):`);
22
+ for (const c of components) {
23
+ console.log(` [${c._id}] ${c.name}`);
24
+ }
25
+
26
+ // --- Get ---
27
+ if (components.length > 0) {
28
+ const detail = await client.getComponent(components[0]._id, { showVersions: true });
29
+ console.log('\nFirst component detail (with versions):');
30
+ console.log(JSON.stringify(detail, null, 2));
31
+ }
32
+
33
+ if (!PROJECT_ID) {
34
+ console.log('\nSkipping create/update/download/archive — set PROJECT_ID in .env to run the full cycle.');
35
+ return;
36
+ }
37
+
38
+ // --- Create ---
39
+ // componentProps is required for TOOL items — the backend rejects creation without it.
40
+ // The file must be a valid ZIP produced by the thatopen CLI toolchain (`thatopen publish`).
41
+ // Uploading a raw JS blob returns 422 — use a real built component ZIP here.
42
+ const bundle = new Blob(['export async function main() { return { type: "SUCCESS", message: "ok" }; }'], { type: 'application/javascript' });
43
+ const componentName = `__example-test__-${Date.now()}`;
44
+ const created = await client.createComponent({
45
+ file: bundle,
46
+ name: componentName,
47
+ versionTag: 'v1',
48
+ projectId: PROJECT_ID,
49
+ componentProps: { type: 'CLOUD', tier: 'FREE', isPublic: false },
50
+ });
51
+ console.log(`\nCreated component: [${created.item._id}] ${created.item.name}`);
52
+
53
+ const componentId = created.item._id;
54
+
55
+ // --- Update (rename) ---
56
+ const renamed = await client.updateComponent(componentId, { name: `${componentName}-renamed` });
57
+ console.log(`\nRenamed to: ${renamed.item?.name}`);
58
+
59
+ // --- Update (new version) ---
60
+ const bundleV2 = new Blob(['export async function main() { return { type: "SUCCESS", message: "v2" }; }'], { type: 'application/javascript' });
61
+ const versioned = await client.updateComponent(componentId, {
62
+ file: bundleV2,
63
+ versionTag: 'v2',
64
+ componentProps: { type: 'CLOUD', tier: 'FREE', isPublic: false },
65
+ });
66
+ console.log(`\nUploaded new version: ${versioned.version?.tag}`);
67
+
68
+ // --- Download bundle ---
69
+ // downloadComponentBundle extracts only the JS bundle from the ZIP — not the full archive.
70
+ const bundleResponse = await client.downloadComponentBundle(componentId);
71
+ const bundleText = await bundleResponse.text();
72
+ console.log(`\nDownloaded bundle (latest): ${bundleText.slice(0, 80)}...`);
73
+
74
+ const bundleV1Response = await client.downloadComponentBundle(componentId, { versionTag: 'v1' });
75
+ const bundleV1Text = await bundleV1Response.text();
76
+ console.log(`Downloaded bundle (v1): ${bundleV1Text.slice(0, 80)}...`);
77
+
78
+ // --- Archive / Recover ---
79
+ // archiveComponent is a soft-delete — recoverable with recoverComponent.
80
+ await client.archiveComponent(componentId);
81
+ console.log(`\nArchived component [${componentId}]`);
82
+
83
+ await client.recoverComponent(componentId);
84
+ console.log('Recovered component.');
85
+
86
+ // Archive again to clean up.
87
+ await client.archiveComponent(componentId);
88
+ console.log(`Re-archived component — account is clean.`);
89
+ }
90
+
91
+ main().catch(console.error);
@@ -0,0 +1,75 @@
1
+ // description: "Cloud component execution — trigger, track progress via WebSocket, list, get, and abort executions."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { EngineServicesClient } from '../client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
9
+ const API_URL = process.env.API_URL;
10
+ const PROJECT_ID = process.env.PROJECT_ID;
11
+
12
+ // Required: a published cloud component ID.
13
+ // Obtain it from `listComponents()` or from the platform dashboard.
14
+ // Set it as COMPONENT_ID in src/core/examples/.env.
15
+ const COMPONENT_ID = process.env.COMPONENT_ID;
16
+
17
+ if (!ACCESS_TOKEN || !API_URL) {
18
+ throw new Error('ACCESS_TOKEN and API_URL are required in src/core/examples/.env');
19
+ }
20
+
21
+ if (!COMPONENT_ID) {
22
+ throw new Error(
23
+ 'COMPONENT_ID is required to run execution.ts.\n' +
24
+ 'Get it from `npx tsx src/core/examples/components.ts` once you have a published component,\n' +
25
+ 'or from the platform dashboard. Set it as COMPONENT_ID in src/core/examples/.env.',
26
+ );
27
+ }
28
+
29
+ async function main() {
30
+ const client = new EngineServicesClient(ACCESS_TOKEN!, API_URL!);
31
+
32
+ // --- Execute ---
33
+ // Pass projectId in executionParams to scope the execution to a project.
34
+ // The backend validates that the component is linked to that project — a foreign projectId returns 403.
35
+ // Omit projectId for personal (ownership) executions.
36
+ const { executionId } = await client.executeComponent(
37
+ COMPONENT_ID!,
38
+ { ...(PROJECT_ID && { projectId: PROJECT_ID }) },
39
+ );
40
+ console.log(`\nStarted execution: [${executionId}]`);
41
+
42
+ // --- Track progress via WebSocket ---
43
+ // onExecutionProgress fires on each progress event until the execution completes.
44
+ // The socket is not automatically closed — the callback receives a final event when done.
45
+ await new Promise<void>((resolve) => {
46
+ client.onExecutionProgress(executionId, (data) => {
47
+ if (data.progressUpdate) {
48
+ console.log(` progress: ${data.progressUpdate.progress}%`);
49
+ if (data.progressUpdate.result) {
50
+ console.log(` result: ${data.progressUpdate.result}`, data.progressUpdate.resultMessage ?? '');
51
+ resolve();
52
+ }
53
+ }
54
+ if (data.messageUpdate) {
55
+ console.log(` message: ${data.messageUpdate.content}`);
56
+ }
57
+ });
58
+ });
59
+
60
+ // --- Get execution detail ---
61
+ const execution = await client.getExecution(executionId);
62
+ console.log('\nExecution detail:');
63
+ console.log(JSON.stringify(execution, null, 2));
64
+
65
+ // --- List executions for the component ---
66
+ const executions = await client.listExecutions(COMPONENT_ID!);
67
+ console.log(`\nTotal executions for component: ${executions.length}`);
68
+
69
+ // --- Abort (demonstrates the API — only useful on long-running executions) ---
70
+ // abortExecution on an already-completed execution is a no-op on most backends.
71
+ // In production, call it before the WebSocket resolves to cancel a running execution.
72
+ console.log('\n(Skipping abortExecution — execution already completed.)');
73
+ }
74
+
75
+ main().catch(console.error);