@rool-dev/app 0.3.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.
Files changed (59) hide show
  1. package/README.md +306 -0
  2. package/dist/cli/dev.d.ts +10 -0
  3. package/dist/cli/dev.d.ts.map +1 -0
  4. package/dist/cli/dev.js +241 -0
  5. package/dist/cli/index.d.ts +3 -0
  6. package/dist/cli/index.d.ts.map +1 -0
  7. package/dist/cli/index.js +22 -0
  8. package/dist/cli/init.d.ts +7 -0
  9. package/dist/cli/init.d.ts.map +1 -0
  10. package/dist/cli/init.js +108 -0
  11. package/dist/cli/publish.d.ts +9 -0
  12. package/dist/cli/publish.d.ts.map +1 -0
  13. package/dist/cli/publish.js +213 -0
  14. package/dist/cli/vite-utils.d.ts +22 -0
  15. package/dist/cli/vite-utils.d.ts.map +1 -0
  16. package/dist/cli/vite-utils.js +96 -0
  17. package/dist/client.d.ts +79 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +235 -0
  20. package/dist/dev/AppGrid.svelte +246 -0
  21. package/dist/dev/AppGrid.svelte.d.ts +14 -0
  22. package/dist/dev/AppGrid.svelte.d.ts.map +1 -0
  23. package/dist/dev/DevHostController.d.ts +86 -0
  24. package/dist/dev/DevHostController.d.ts.map +1 -0
  25. package/dist/dev/DevHostController.js +395 -0
  26. package/dist/dev/HostShell.svelte +110 -0
  27. package/dist/dev/HostShell.svelte.d.ts +11 -0
  28. package/dist/dev/HostShell.svelte.d.ts.map +1 -0
  29. package/dist/dev/Sidebar.svelte +223 -0
  30. package/dist/dev/Sidebar.svelte.d.ts +19 -0
  31. package/dist/dev/Sidebar.svelte.d.ts.map +1 -0
  32. package/dist/dev/TabBar.svelte +83 -0
  33. package/dist/dev/TabBar.svelte.d.ts +14 -0
  34. package/dist/dev/TabBar.svelte.d.ts.map +1 -0
  35. package/dist/dev/app.css +1 -0
  36. package/dist/dev/host-shell.d.ts +8 -0
  37. package/dist/dev/host-shell.d.ts.map +1 -0
  38. package/dist/dev/host-shell.js +14807 -0
  39. package/dist/dev/host-shell.js.map +1 -0
  40. package/dist/dev/vite-env.d.ts +4 -0
  41. package/dist/host.d.ts +54 -0
  42. package/dist/host.d.ts.map +1 -0
  43. package/dist/host.js +171 -0
  44. package/dist/index.d.ts +10 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +8 -0
  47. package/dist/manifest.d.ts +35 -0
  48. package/dist/manifest.d.ts.map +1 -0
  49. package/dist/manifest.js +10 -0
  50. package/dist/protocol.d.ts +46 -0
  51. package/dist/protocol.d.ts.map +1 -0
  52. package/dist/protocol.js +14 -0
  53. package/dist/reactive.svelte.d.ts +100 -0
  54. package/dist/reactive.svelte.d.ts.map +1 -0
  55. package/dist/reactive.svelte.js +267 -0
  56. package/dist/types.d.ts +119 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/dist/types.js +7 -0
  59. package/package.json +78 -0
@@ -0,0 +1,395 @@
1
+ /**
2
+ * DevHostController — business logic for the dev host shell.
3
+ *
4
+ * Owns the RoolClient lifecycle, space management, channel-per-app management,
5
+ * bridge hosting, and published app management. The Svelte component is a thin
6
+ * view layer that reads this controller's state and calls its methods.
7
+ *
8
+ * The controller is self-sufficient: it manages the full lifecycle including
9
+ * DOM flush (via injected tick) and bridge binding. Svelte components can call
10
+ * controller methods directly without needing wrappers.
11
+ */
12
+ import { RoolClient } from '@rool-dev/sdk';
13
+ import { createBridgeHost } from '../host.js';
14
+ import { ENV_URLS } from '../manifest.js';
15
+ // ---------------------------------------------------------------------------
16
+ // localStorage helpers
17
+ // ---------------------------------------------------------------------------
18
+ function storageGet(key) {
19
+ try {
20
+ return localStorage.getItem(key);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ function storageSet(key, value) {
27
+ try {
28
+ if (value === null)
29
+ localStorage.removeItem(key);
30
+ else
31
+ localStorage.setItem(key, value);
32
+ }
33
+ catch { /* ignore */ }
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // DevHostController
37
+ // ---------------------------------------------------------------------------
38
+ export class DevHostController {
39
+ // --- Config (immutable after construction) ---
40
+ channelId;
41
+ appUrl;
42
+ manifest;
43
+ manifestError;
44
+ // --- SDK client ---
45
+ client;
46
+ // --- Observable state (Svelte component mirrors these via $state) ---
47
+ spaces = [];
48
+ currentSpaceId = null;
49
+ statusText = 'Initializing...';
50
+ statusState = 'off';
51
+ placeholderText = 'Authenticating...';
52
+ env;
53
+ publishedApps = [];
54
+ installedAppIds = [];
55
+ sidebarCollapsed = false;
56
+ // --- Per-tab state (imperative, not rendered directly) ---
57
+ channels = {};
58
+ iframeEls = {};
59
+ bridgeHosts = {};
60
+ // --- Dependencies ---
61
+ _onChange;
62
+ _tick;
63
+ // --- Storage keys ---
64
+ _spaceKey;
65
+ constructor(options, onChange, tick) {
66
+ this.channelId = options.channelId;
67
+ this.appUrl = options.appUrl;
68
+ this.manifest = options.manifest;
69
+ this.manifestError = options.manifestError;
70
+ this._onChange = onChange;
71
+ this._tick = tick;
72
+ this._spaceKey = `rool-devhost:${options.channelId}:space`;
73
+ // Restore persisted state
74
+ this.env = this._getSavedEnv();
75
+ this.sidebarCollapsed = storageGet('rool-devhost:collapsed') === 'true';
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // Derived
79
+ // ---------------------------------------------------------------------------
80
+ get tabs() {
81
+ const localTab = {
82
+ id: 'local',
83
+ name: this.manifest?.name ?? 'Local',
84
+ url: this.appUrl,
85
+ isLocal: true,
86
+ };
87
+ const appTabs = this.installedAppIds
88
+ .map((id) => {
89
+ const app = this.publishedApps.find((a) => a.appId === id);
90
+ if (!app)
91
+ return null;
92
+ return {
93
+ id: app.appId,
94
+ name: app.name,
95
+ url: `https://${app.appId}.${ENV_URLS[this.env].appsDomain}`,
96
+ isLocal: false,
97
+ };
98
+ })
99
+ .filter((t) => t !== null);
100
+ return [localTab, ...appTabs];
101
+ }
102
+ // ---------------------------------------------------------------------------
103
+ // Bootstrap
104
+ // ---------------------------------------------------------------------------
105
+ async boot() {
106
+ const urls = ENV_URLS[this.env];
107
+ this.client = new RoolClient({ baseUrl: urls.baseUrl, authUrl: urls.authUrl });
108
+ const authenticated = await this.client.initialize();
109
+ if (!authenticated) {
110
+ this.placeholderText = 'Redirecting to login...';
111
+ this.statusText = 'Authenticating...';
112
+ this.statusState = 'loading';
113
+ this._onChange();
114
+ this.client.login('App Dev Host');
115
+ return;
116
+ }
117
+ this.placeholderText = 'Loading spaces...';
118
+ this.statusText = 'Loading spaces...';
119
+ this.statusState = 'loading';
120
+ this._onChange();
121
+ const [spaceList, appList] = await Promise.all([
122
+ this.client.listSpaces(),
123
+ this.client.listApps().catch(() => []),
124
+ ]);
125
+ this.spaces = spaceList;
126
+ this.publishedApps = appList;
127
+ this.client.on('spaceAdded', (space) => {
128
+ if (!this.spaces.some((s) => s.id === space.id)) {
129
+ this.spaces = [...this.spaces, space];
130
+ this._onChange();
131
+ }
132
+ });
133
+ this.client.on('spaceRemoved', (id) => {
134
+ this.spaces = this.spaces.filter((s) => s.id !== id);
135
+ if (this.currentSpaceId === id) {
136
+ this.currentSpaceId = null;
137
+ this.statusText = 'Disconnected';
138
+ this.statusState = 'off';
139
+ }
140
+ this._onChange();
141
+ });
142
+ this.client.on('spaceRenamed', (id, name) => {
143
+ this.spaces = this.spaces.map((s) => (s.id === id ? { ...s, name } : s));
144
+ this._onChange();
145
+ });
146
+ this.statusText = 'Ready';
147
+ this.statusState = 'off';
148
+ const savedSpace = storageGet(this._spaceKey);
149
+ if (savedSpace && this.spaces.some((s) => s.id === savedSpace)) {
150
+ await this.selectSpace(savedSpace);
151
+ }
152
+ else {
153
+ this.placeholderText = 'Select a space to load the app';
154
+ this._onChange();
155
+ }
156
+ }
157
+ // ---------------------------------------------------------------------------
158
+ // Space selection
159
+ // ---------------------------------------------------------------------------
160
+ async selectSpace(spaceId) {
161
+ this._destroyAllBridgesAndChannels();
162
+ this.currentSpaceId = spaceId;
163
+ storageSet(this._spaceKey, spaceId);
164
+ this.statusText = 'Opening channels...';
165
+ this.statusState = 'loading';
166
+ this.placeholderText = 'Opening channels...';
167
+ this._onChange();
168
+ try {
169
+ // Open the local app's channel
170
+ const localChannel = await this.client.openChannel(spaceId, this.channelId);
171
+ this.channels['local'] = localChannel;
172
+ // Apply manifest settings to the local channel
173
+ await this._syncManifest(localChannel, this.manifest);
174
+ // Discover installed apps: space channels whose ID matches a published app
175
+ const space = await this.client.openSpace(spaceId);
176
+ const spaceChannels = space.getChannels();
177
+ const publishedAppIds = new Set(this.publishedApps.map((a) => a.appId));
178
+ this.installedAppIds = spaceChannels
179
+ .map((ch) => ch.id)
180
+ .filter((id) => publishedAppIds.has(id) && id !== this.channelId);
181
+ // Open channels for each installed app and sync their manifests
182
+ for (const appId of this.installedAppIds) {
183
+ try {
184
+ const ch = await this.client.openChannel(spaceId, appId);
185
+ this.channels[appId] = ch;
186
+ const remoteManifest = await this._fetchRemoteManifest(appId);
187
+ if (remoteManifest) {
188
+ await this._syncManifest(ch, remoteManifest);
189
+ }
190
+ }
191
+ catch (e) {
192
+ console.error(`Failed to open channel for app ${appId}:`, e);
193
+ }
194
+ }
195
+ // Show iframes, wait for DOM to mount them, then bind bridges
196
+ this.placeholderText = null;
197
+ this._onChange();
198
+ await this._tick();
199
+ this._bindAllBridges();
200
+ const spaceName = this.spaces.find((s) => s.id === this.currentSpaceId)?.name ?? spaceId;
201
+ this.statusText = `Connected \u2014 ${spaceName}`;
202
+ this.statusState = 'ok';
203
+ this._onChange();
204
+ }
205
+ catch (e) {
206
+ console.error('Failed to open channel:', e);
207
+ this.placeholderText = `Error: ${e instanceof Error ? e.message : String(e)}`;
208
+ this.statusText = 'Error';
209
+ this.statusState = 'off';
210
+ this._onChange();
211
+ }
212
+ }
213
+ // ---------------------------------------------------------------------------
214
+ // App installation / removal
215
+ // ---------------------------------------------------------------------------
216
+ /**
217
+ * Install an app in the current space.
218
+ *
219
+ * Opens the channel first, THEN adds the tab. This ensures the channel
220
+ * exists when the iframe mounts so registerIframe → _bindBridge can
221
+ * connect the bridge before the app sends its init message.
222
+ */
223
+ async installApp(appId) {
224
+ if (!this.currentSpaceId)
225
+ return;
226
+ if (this.installedAppIds.includes(appId))
227
+ return;
228
+ try {
229
+ // Step 1: open channel (creates it on server == install)
230
+ const ch = await this.client.openChannel(this.currentSpaceId, appId);
231
+ this.channels[appId] = ch;
232
+ // Step 2: add the card, flush DOM, bind bridge
233
+ this.installedAppIds = [...this.installedAppIds, appId];
234
+ this._onChange();
235
+ await this._tick();
236
+ this._bindBridge(appId);
237
+ // Step 3: fetch and apply the published app's manifest
238
+ const remoteManifest = await this._fetchRemoteManifest(appId);
239
+ if (remoteManifest) {
240
+ await this._syncManifest(ch, remoteManifest);
241
+ }
242
+ }
243
+ catch (e) {
244
+ console.error(`Failed to install app ${appId}:`, e);
245
+ this.installedAppIds = this.installedAppIds.filter((id) => id !== appId);
246
+ this._onChange();
247
+ }
248
+ }
249
+ /**
250
+ * Uninstall an app from the current space.
251
+ * Deletes the channel and removes the card.
252
+ */
253
+ removeApp(appId) {
254
+ this._destroyTab(appId);
255
+ this.installedAppIds = this.installedAppIds.filter((id) => id !== appId);
256
+ this._onChange();
257
+ // Delete the channel in the background (fire-and-forget)
258
+ if (this.currentSpaceId) {
259
+ this.client.deleteChannel(this.currentSpaceId, appId).catch((e) => {
260
+ console.error(`Failed to delete channel for app ${appId}:`, e);
261
+ });
262
+ }
263
+ }
264
+ // ---------------------------------------------------------------------------
265
+ // Environment switching
266
+ // ---------------------------------------------------------------------------
267
+ async switchEnv(newEnv) {
268
+ if (newEnv === this.env)
269
+ return;
270
+ this.env = newEnv;
271
+ storageSet('rool-devhost:env', newEnv);
272
+ this._destroyAllBridgesAndChannels();
273
+ this.currentSpaceId = null;
274
+ this.spaces = [];
275
+ this.publishedApps = [];
276
+ this.installedAppIds = [];
277
+ this._onChange();
278
+ await this.boot();
279
+ }
280
+ // ---------------------------------------------------------------------------
281
+ // Sidebar
282
+ // ---------------------------------------------------------------------------
283
+ toggleSidebar() {
284
+ this.sidebarCollapsed = !this.sidebarCollapsed;
285
+ storageSet('rool-devhost:collapsed', String(this.sidebarCollapsed));
286
+ this._onChange();
287
+ }
288
+ // ---------------------------------------------------------------------------
289
+ // Iframe registration (called by Svelte action)
290
+ // ---------------------------------------------------------------------------
291
+ registerIframe(tabId, el) {
292
+ this.iframeEls[tabId] = el;
293
+ this._bindBridge(tabId);
294
+ }
295
+ unregisterIframe(tabId) {
296
+ delete this.iframeEls[tabId];
297
+ }
298
+ // ---------------------------------------------------------------------------
299
+ // Cleanup
300
+ // ---------------------------------------------------------------------------
301
+ logout() {
302
+ this.client.logout();
303
+ window.location.reload();
304
+ }
305
+ // ---------------------------------------------------------------------------
306
+ // Private helpers
307
+ // ---------------------------------------------------------------------------
308
+ _bindBridge(tabId) {
309
+ const el = this.iframeEls[tabId];
310
+ const ch = this.channels[tabId];
311
+ if (el && ch && !this.bridgeHosts[tabId]) {
312
+ this.bridgeHosts[tabId] = createBridgeHost({ channel: ch, iframe: el });
313
+ }
314
+ }
315
+ _bindAllBridges() {
316
+ for (const tab of this.tabs) {
317
+ this._bindBridge(tab.id);
318
+ }
319
+ }
320
+ _destroyTab(tabId) {
321
+ this.bridgeHosts[tabId]?.destroy();
322
+ delete this.bridgeHosts[tabId];
323
+ this.channels[tabId]?.close();
324
+ delete this.channels[tabId];
325
+ delete this.iframeEls[tabId];
326
+ }
327
+ _destroyAllBridgesAndChannels() {
328
+ for (const host of Object.values(this.bridgeHosts)) {
329
+ host.destroy();
330
+ }
331
+ for (const ch of Object.values(this.channels)) {
332
+ ch.close();
333
+ }
334
+ this.bridgeHosts = {};
335
+ this.channels = {};
336
+ this.iframeEls = {};
337
+ }
338
+ /**
339
+ * Fetch `rool-app.json` from a published app's URL.
340
+ * Returns null if the fetch fails (app might not have a manifest).
341
+ */
342
+ async _fetchRemoteManifest(appId) {
343
+ const domain = ENV_URLS[this.env].appsDomain;
344
+ const url = `https://${appId}.${domain}/rool-app.json`;
345
+ try {
346
+ const res = await fetch(url);
347
+ if (!res.ok)
348
+ return null;
349
+ return await res.json();
350
+ }
351
+ catch {
352
+ return null;
353
+ }
354
+ }
355
+ /**
356
+ * Idempotently sync a manifest's settings (name, system instruction, collections)
357
+ * onto a channel. Safe to call every time the app is opened.
358
+ */
359
+ async _syncManifest(channel, manifest) {
360
+ if (!manifest)
361
+ return;
362
+ if (channel.channelName !== manifest.name) {
363
+ await channel.rename(manifest.name);
364
+ }
365
+ const targetInstruction = manifest.systemInstruction ?? null;
366
+ const currentInstruction = channel.getSystemInstruction() ?? null;
367
+ if (currentInstruction !== targetInstruction) {
368
+ await channel.setSystemInstruction(targetInstruction);
369
+ }
370
+ if (manifest.collections) {
371
+ const currentSchema = channel.getSchema();
372
+ const syncCollections = async (colls) => {
373
+ for (const [name, fields] of Object.entries(colls)) {
374
+ if (name in currentSchema) {
375
+ await channel.alterCollection(name, fields);
376
+ }
377
+ else {
378
+ await channel.createCollection(name, fields);
379
+ }
380
+ }
381
+ };
382
+ const { write: w, read: r } = manifest.collections;
383
+ if (w && w !== '*')
384
+ await syncCollections(w);
385
+ if (r && r !== '*')
386
+ await syncCollections(r);
387
+ }
388
+ }
389
+ _getSavedEnv() {
390
+ const saved = storageGet('rool-devhost:env');
391
+ if (saved === 'dev' || saved === 'prod')
392
+ return saved;
393
+ return 'prod';
394
+ }
395
+ }
@@ -0,0 +1,110 @@
1
+ <script lang="ts">
2
+ import { onMount, tick } from 'svelte';
3
+ import { DevHostController } from './DevHostController.js';
4
+ import type { AppTab } from './DevHostController.js';
5
+ import type { AppManifest } from '../manifest.js';
6
+ import type { RoolSpaceInfo, PublishedAppInfo } from '@rool-dev/sdk';
7
+ import type { Environment } from '../manifest.js';
8
+ import Sidebar from './Sidebar.svelte';
9
+ import AppGrid from './AppGrid.svelte';
10
+
11
+ // Props injected from the mount entry
12
+ interface Props {
13
+ channelId: string;
14
+ appUrl: string;
15
+ manifest: AppManifest | null;
16
+ manifestError: string | null;
17
+ }
18
+
19
+ const props: Props = $props();
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Controller + reactive state mirror
23
+ // ---------------------------------------------------------------------------
24
+
25
+ let spaces: RoolSpaceInfo[] = $state([]);
26
+ let currentSpaceId: string | null = $state(null);
27
+ let statusText: string = $state('Initializing...');
28
+ let statusState: 'ok' | 'loading' | 'off' = $state('off');
29
+ let placeholderText: string | null = $state('Authenticating...');
30
+ let sidebarCollapsed: boolean = $state(false);
31
+ let env: Environment = $state('prod');
32
+ let publishedApps: PublishedAppInfo[] = $state([]);
33
+ let installedAppIds: string[] = $state([]);
34
+ let tabs: AppTab[] = $state([]);
35
+
36
+ // UI-only state (not in controller)
37
+ let dropdownOpen: boolean = $state(false);
38
+
39
+ const controller = new DevHostController(
40
+ props,
41
+ syncState,
42
+ tick,
43
+ );
44
+
45
+ function syncState() {
46
+ spaces = controller.spaces;
47
+ currentSpaceId = controller.currentSpaceId;
48
+ statusText = controller.statusText;
49
+ statusState = controller.statusState;
50
+ placeholderText = controller.placeholderText;
51
+ sidebarCollapsed = controller.sidebarCollapsed;
52
+ env = controller.env;
53
+ publishedApps = controller.publishedApps;
54
+ installedAppIds = controller.installedAppIds;
55
+ tabs = controller.tabs;
56
+ }
57
+
58
+ // Derived: published apps not yet installed (excluding the local dev app)
59
+ let uninstalledApps = $derived(
60
+ publishedApps.filter((app) => app.appId !== props.channelId && !installedAppIds.includes(app.appId)),
61
+ );
62
+
63
+ // Initial sync
64
+ syncState();
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Bootstrap
68
+ // ---------------------------------------------------------------------------
69
+
70
+ onMount(() => {
71
+ controller.boot();
72
+
73
+ function handleClickOutside(e: MouseEvent) {
74
+ if (dropdownOpen && !(e.target as Element)?.closest('[data-dropdown]')) {
75
+ dropdownOpen = false;
76
+ }
77
+ }
78
+ document.addEventListener('click', handleClickOutside);
79
+ return () => document.removeEventListener('click', handleClickOutside);
80
+ });
81
+ </script>
82
+
83
+ <!-- Sidebar -->
84
+ <Sidebar
85
+ {controller}
86
+ manifest={props.manifest}
87
+ manifestError={props.manifestError}
88
+ {spaces}
89
+ {currentSpaceId}
90
+ {env}
91
+ {statusText}
92
+ {statusState}
93
+ {sidebarCollapsed}
94
+ bind:dropdownOpen
95
+ />
96
+
97
+ <!-- Main area -->
98
+ <div class="flex-1 min-w-0 flex flex-col">
99
+ {#if placeholderText}
100
+ <div class="flex items-center justify-center h-full text-slate-400 text-sm">{placeholderText}</div>
101
+ {:else}
102
+ <AppGrid
103
+ {controller}
104
+ {tabs}
105
+ {uninstalledApps}
106
+ onInstallApp={(id) => controller.installApp(id)}
107
+ onRemoveApp={(id) => controller.removeApp(id)}
108
+ />
109
+ {/if}
110
+ </div>
@@ -0,0 +1,11 @@
1
+ import type { AppManifest } from '../manifest.js';
2
+ interface Props {
3
+ channelId: string;
4
+ appUrl: string;
5
+ manifest: AppManifest | null;
6
+ manifestError: string | null;
7
+ }
8
+ declare const HostShell: import("svelte").Component<Props, {}, "">;
9
+ type HostShell = ReturnType<typeof HostShell>;
10
+ export default HostShell;
11
+ //# sourceMappingURL=HostShell.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HostShell.svelte.d.ts","sourceRoot":"","sources":["../../src/dev/HostShell.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAQhD,UAAU,KAAK;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAC;IAC7B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AA0FH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}