@devframes/hub 0.5.4 → 0.6.0-beta.2

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.
@@ -1,9 +1,9 @@
1
- import { r as defineHubRpcFunction } from "../define-tHP6fX6A.mjs";
1
+ import { r as defineHubRpcFunction } from "../define-Dp-x6d09.mjs";
2
2
  import { DEFAULT_STATE_USER_SETTINGS } from "../constants.mjs";
3
3
  import { REMOTE_CONNECTION_KEY } from "devframe/constants";
4
+ import { createEventEmitter } from "devframe/utils/events";
4
5
  import { createHostContext, createStorage } from "devframe/node";
5
6
  import { debounce } from "perfect-debounce";
6
- import { createEventEmitter } from "devframe/utils/events";
7
7
  import { defineDiagnostics } from "nostics";
8
8
  import { colors } from "devframe/utils/colors";
9
9
  import { ansiFormatter } from "nostics/formatters/ansi";
@@ -33,8 +33,14 @@ const diagnostics = defineDiagnostics({
33
33
  why: (p) => `Dock with id "${p.id}" is already registered`,
34
34
  fix: "Use the `force` parameter to overwrite an existing registration."
35
35
  },
36
- DF8101: { why: "Cannot change the id of a dock. Use register() to add new docks." },
37
- DF8102: { why: (p) => `Dock with id "${p.id}" is not registered. Use register() to add new docks.` },
36
+ DF8101: {
37
+ why: (p) => `Cannot change the id of dock "${p.id}" to "${p.attempted}". Dock ids are immutable once registered`,
38
+ fix: (p) => `Remove \`id\` from the patch to keep updating "${p.id}", or call register() with the full entry to add "${p.attempted}" as a new dock.`
39
+ },
40
+ DF8102: {
41
+ why: (p) => `Dock with id "${p.id}" is not registered and cannot be updated`,
42
+ fix: (p) => `Call register() to add "${p.id}" as a new dock, or check the id for typos.`
43
+ },
38
44
  DF8103: {
39
45
  why: (p) => `Dock entry "${p.id}" cannot set groupId to its own id`,
40
46
  fix: "Point groupId at a different group entry, or omit it."
@@ -43,10 +49,19 @@ const diagnostics = defineDiagnostics({
43
49
  why: (p) => `Dock group "${p.id}" cannot itself belong to a group (nested groups are unsupported)`,
44
50
  fix: "Remove groupId from the group entry; nest members one level only."
45
51
  },
52
+ DF8105: {
53
+ why: (p) => `Devframe "${p.name}" (id "${p.id}") is already mounted on this hub`,
54
+ fix: "Each devframe is deduplicated by id. Set `duplicationStrategy: \"duplicate\"` on the definition to let instances coexist, `\"silent\"` to drop duplicates quietly, or `\"throw\"` to surface them as errors."
55
+ },
46
56
  DF8200: { why: (p) => `Terminal session with id "${p.id}" already registered` },
47
57
  DF8201: { why: (p) => `Terminal session with id "${p.id}" not registered` },
58
+ DF8202: {
59
+ why: (p) => `Terminal session "${p.id}" does not accept input`,
60
+ fix: "Spawn it via ctx.terminals.startPtySession() to get an interactive, writable session."
61
+ },
62
+ DF8203: { why: (p) => `Failed to spawn PTY session for "${p.command}": ${p.reason}` },
48
63
  DF8400: { why: (p) => `Command "${p.id}" is already registered` },
49
- DF8401: { why: "Cannot change the id of a command. Use register() to add new commands." },
64
+ DF8401: { why: "Cannot change the id of a command. Use register() to add new commands" },
50
65
  DF8402: { why: (p) => `Command "${p.id}" is not registered` },
51
66
  DF8403: {
52
67
  why: (p) => `Command id "${p.id}" is already used by another command or child command`,
@@ -192,13 +207,15 @@ function buildRemoteUrl(baseUrl, payload, transport) {
192
207
  }
193
208
  var DevframeDocksHost = class {
194
209
  context;
210
+ builtinDocks;
195
211
  views = /* @__PURE__ */ new Map();
196
212
  events = createEventEmitter();
197
213
  userSettings = void 0;
198
214
  /** Dock-id → allocated remote token + resolved options. */
199
215
  remoteDocks = /* @__PURE__ */ new Map();
200
- constructor(context) {
216
+ constructor(context, builtinDocks = {}) {
201
217
  this.context = context;
218
+ this.builtinDocks = builtinDocks;
202
219
  }
203
220
  async init() {
204
221
  this.userSettings = await this.context.rpc.sharedState.get("devframe:user-settings", { sharedState: createStorage({
@@ -208,36 +225,36 @@ var DevframeDocksHost = class {
208
225
  }
209
226
  values({ includeBuiltin = true } = {}) {
210
227
  const context = this.context;
211
- const builtinDocksEntries = [
212
- {
213
- type: "~builtin",
214
- id: "~terminals",
215
- title: "Terminals",
216
- icon: "ph:terminal-duotone",
217
- category: "~builtin",
218
- get when() {
219
- return context.terminals.sessions.size === 0 ? "false" : void 0;
220
- }
221
- },
222
- {
223
- type: "~builtin",
224
- id: "~messages",
225
- title: "Messages & Notifications",
226
- icon: "ph:notification-duotone",
227
- category: "~builtin",
228
- get badge() {
229
- const size = context.messages.entries.size;
230
- return size > 0 ? String(size) : void 0;
231
- }
232
- },
233
- {
234
- type: "~builtin",
235
- id: "~settings",
236
- title: "Settings",
237
- category: "~builtin",
238
- icon: "ph:gear-duotone"
228
+ const { terminals = true, messages = true, settings = true } = this.builtinDocks;
229
+ const builtinDocksEntries = [];
230
+ if (terminals) builtinDocksEntries.push({
231
+ type: "~builtin",
232
+ id: "~terminals",
233
+ title: "Terminals",
234
+ icon: "ph:terminal-duotone",
235
+ category: "~builtin",
236
+ get when() {
237
+ return context.terminals.sessions.size === 0 ? "false" : void 0;
238
+ }
239
+ });
240
+ if (messages) builtinDocksEntries.push({
241
+ type: "~builtin",
242
+ id: "~messages",
243
+ title: "Messages & Notifications",
244
+ icon: "ph:notification-duotone",
245
+ category: "~builtin",
246
+ get badge() {
247
+ const size = context.messages.entries.size;
248
+ return size > 0 ? String(size) : void 0;
239
249
  }
240
- ];
250
+ });
251
+ if (settings) builtinDocksEntries.push({
252
+ type: "~builtin",
253
+ id: "~settings",
254
+ title: "Settings",
255
+ category: "~builtin",
256
+ icon: "ph:gear-duotone"
257
+ });
241
258
  return [...Array.from(this.views.values(), (view) => this.projectView(view)), ...includeBuiltin ? builtinDocksEntries : []];
242
259
  }
243
260
  projectView(view) {
@@ -267,7 +284,10 @@ var DevframeDocksHost = class {
267
284
  this.views.set(view.id, view);
268
285
  this.events.emit("dock:entry:updated", view);
269
286
  return { update: (patch) => {
270
- if (patch.id && patch.id !== view.id) throw diagnostics.DF8101();
287
+ if (patch.id && patch.id !== view.id) throw diagnostics.DF8101({
288
+ id: view.id,
289
+ attempted: patch.id
290
+ });
271
291
  this.update(Object.assign(this.views.get(view.id), patch));
272
292
  } };
273
293
  }
@@ -306,13 +326,6 @@ var DevframeDocksHost = class {
306
326
  //#region src/node/host-messages.ts
307
327
  const MAX_ENTRIES = 1e3;
308
328
  const MAX_REMOVALS = 1e3;
309
- function recordRemoval(removals, id, time) {
310
- removals.push({
311
- id,
312
- time
313
- });
314
- if (removals.length > MAX_REMOVALS) removals.splice(0, removals.length - MAX_REMOVALS);
315
- }
316
329
  var DevframeMessagesHost = class {
317
330
  context;
318
331
  entries = /* @__PURE__ */ new Map();
@@ -323,9 +336,25 @@ var DevframeMessagesHost = class {
323
336
  removals = [];
324
337
  _autoDeleteTimers = /* @__PURE__ */ new Map();
325
338
  _clock = 0;
339
+ /**
340
+ * The tick of the newest removal record dropped from the capped
341
+ * `removals` log — cursors older than this can't get a reliable delta
342
+ * and fall back to a full snapshot in {@link listSince}.
343
+ */
344
+ _removalsTrimmedAt = 0;
326
345
  _tick() {
327
346
  return ++this._clock;
328
347
  }
348
+ _recordRemoval(id, time) {
349
+ this.removals.push({
350
+ id,
351
+ time
352
+ });
353
+ if (this.removals.length > MAX_REMOVALS) {
354
+ const dropped = this.removals.splice(0, this.removals.length - MAX_REMOVALS);
355
+ this._removalsTrimmedAt = dropped[dropped.length - 1].time;
356
+ }
357
+ }
329
358
  constructor(context) {
330
359
  this.context = context;
331
360
  }
@@ -385,18 +414,75 @@ var DevframeMessagesHost = class {
385
414
  }
386
415
  this.entries.delete(id);
387
416
  this.lastModified.delete(id);
388
- recordRemoval(this.removals, id, this._tick());
417
+ this._recordRemoval(id, this._tick());
389
418
  this.events.emit("message:removed", id);
390
419
  }
420
+ info(message, extra) {
421
+ return this.add({
422
+ ...extra,
423
+ message,
424
+ level: "info"
425
+ });
426
+ }
427
+ warn(message, extra) {
428
+ return this.add({
429
+ ...extra,
430
+ message,
431
+ level: "warn"
432
+ });
433
+ }
434
+ error(message, extra) {
435
+ return this.add({
436
+ ...extra,
437
+ message,
438
+ level: "error"
439
+ });
440
+ }
441
+ success(message, extra) {
442
+ return this.add({
443
+ ...extra,
444
+ message,
445
+ level: "success"
446
+ });
447
+ }
448
+ debug(message, extra) {
449
+ return this.add({
450
+ ...extra,
451
+ message,
452
+ level: "debug"
453
+ });
454
+ }
391
455
  async clear() {
392
456
  for (const timer of this._autoDeleteTimers.values()) clearTimeout(timer);
393
457
  this._autoDeleteTimers.clear();
394
458
  const tick = this._tick();
395
- for (const id of this.entries.keys()) recordRemoval(this.removals, id, tick);
459
+ for (const id of this.entries.keys()) this._recordRemoval(id, tick);
396
460
  this.entries.clear();
397
461
  this.lastModified.clear();
398
462
  this.events.emit("message:cleared");
399
463
  }
464
+ listSince(since) {
465
+ const version = this._clock;
466
+ if (since == null || since < this._removalsTrimmedAt || since > version) return {
467
+ entries: Array.from(this.entries.values()),
468
+ removedIds: [],
469
+ version,
470
+ full: true
471
+ };
472
+ const entries = [];
473
+ for (const [id, entry] of this.entries) {
474
+ const mod = this.lastModified.get(id);
475
+ if (mod != null && mod > since) entries.push(entry);
476
+ }
477
+ const removedIds = [];
478
+ for (const removal of this.removals) if (removal.time > since) removedIds.push(removal.id);
479
+ return {
480
+ entries,
481
+ removedIds,
482
+ version,
483
+ full: false
484
+ };
485
+ }
400
486
  _createHandle(id) {
401
487
  const host = this;
402
488
  return {
@@ -419,6 +505,8 @@ var DevframeMessagesHost = class {
419
505
  */
420
506
  const TERMINAL_STREAM_CHANNEL = "devframe:terminals";
421
507
  const TERMINAL_REPLAY_WINDOW = 1e3;
508
+ /** TERM handed to spawned PTYs; also used to reject fallback process labels. */
509
+ const PTY_TERM_NAME = "xterm-256color";
422
510
  var DevframeTerminalsHost = class {
423
511
  context;
424
512
  sessions = /* @__PURE__ */ new Map();
@@ -576,10 +664,123 @@ var DevframeTerminalsHost = class {
576
664
  this.register(session);
577
665
  return Promise.resolve(session);
578
666
  }
667
+ async startPtySession(executeOptions, terminal) {
668
+ if (this.sessions.has(terminal.id)) throw diagnostics.DF8200({ id: terminal.id });
669
+ const { spawn } = await import("zigpty");
670
+ const cols = executeOptions.cols ?? 80;
671
+ const rows = executeOptions.rows ?? 24;
672
+ let controller;
673
+ let pty;
674
+ let runId = 0;
675
+ let streamClosed = false;
676
+ const closeStream = () => {
677
+ if (streamClosed) return;
678
+ streamClosed = true;
679
+ try {
680
+ controller?.close();
681
+ } catch {}
682
+ };
683
+ const errorStream = (error) => {
684
+ if (streamClosed) return;
685
+ streamClosed = true;
686
+ try {
687
+ controller?.error(error);
688
+ } catch {}
689
+ };
690
+ const stream = new ReadableStream({
691
+ start(_controller) {
692
+ controller = _controller;
693
+ },
694
+ cancel() {
695
+ pty?.kill();
696
+ pty = void 0;
697
+ closeStream();
698
+ }
699
+ });
700
+ const spawnPty = () => {
701
+ const currentRun = ++runId;
702
+ const proc = spawn(executeOptions.command, executeOptions.args ?? [], {
703
+ name: PTY_TERM_NAME,
704
+ cols,
705
+ rows,
706
+ cwd: executeOptions.cwd ?? process$1.cwd(),
707
+ env: {
708
+ TERM: PTY_TERM_NAME,
709
+ COLORTERM: "truecolor",
710
+ FORCE_COLOR: "1",
711
+ ...executeOptions.env ?? {}
712
+ }
713
+ });
714
+ proc.onData((data) => {
715
+ if (streamClosed || currentRun !== runId) return;
716
+ controller?.enqueue(typeof data === "string" ? data : data.toString("utf8"));
717
+ });
718
+ proc.onExit(() => {
719
+ if (currentRun === runId) closeStream();
720
+ });
721
+ return proc;
722
+ };
723
+ try {
724
+ pty = spawnPty();
725
+ } catch (error) {
726
+ errorStream(error);
727
+ throw diagnostics.DF8203({
728
+ command: executeOptions.command,
729
+ reason: error instanceof Error ? error.message : String(error)
730
+ });
731
+ }
732
+ const session = {
733
+ ...terminal,
734
+ status: "running",
735
+ interactive: true,
736
+ stream,
737
+ type: "pty",
738
+ executeOptions,
739
+ write: (data) => {
740
+ try {
741
+ pty?.write(data);
742
+ } catch {}
743
+ },
744
+ resize: (nextCols, nextRows) => {
745
+ try {
746
+ pty?.resize(Math.max(1, nextCols), Math.max(1, nextRows));
747
+ } catch {}
748
+ },
749
+ getProcessName: () => {
750
+ try {
751
+ const name = pty?.process;
752
+ return name && name !== PTY_TERM_NAME ? name : void 0;
753
+ } catch {
754
+ return;
755
+ }
756
+ },
757
+ terminate: async () => {
758
+ pty?.kill();
759
+ pty = void 0;
760
+ closeStream();
761
+ },
762
+ restart: async () => {
763
+ pty?.kill();
764
+ pty = spawnPty();
765
+ }
766
+ };
767
+ this.register(session);
768
+ return session;
769
+ }
579
770
  };
580
771
  //#endregion
581
772
  //#region src/node/rpc-builtins.ts
582
773
  /**
774
+ * Resolve an interactive (PTY) terminal session by id, or throw. Sessions
775
+ * spawned via `startChildProcess` are output-only and are rejected here.
776
+ */
777
+ function resolveInteractiveSession(sessions, id) {
778
+ const session = sessions.get(id);
779
+ if (!session) throw diagnostics.DF8201({ id });
780
+ if (typeof session.write !== "function") throw diagnostics.DF8202({ id });
781
+ return session;
782
+ }
783
+ /**
583
784
  * `hub:commands:execute` — Invoke a registered server command by id. The
584
785
  * arguments after `id` are forwarded to the command's `handler(...)`.
585
786
  * Returns whatever the handler returns.
@@ -595,12 +796,85 @@ const hubCommandsExecute = defineHubRpcFunction({
595
796
  } })
596
797
  });
597
798
  /**
799
+ * `hub:messages:add` — Add a message from a browser client into the hub's
800
+ * messages subsystem. Marked `from: 'browser'`. Returns the serializable
801
+ * entry (the mutation handle stays server-side).
802
+ *
803
+ * Pairs with the client-side {@link import('../client').createDevframeClientHost}
804
+ * context, whose `messages` client dispatches through these built-ins so a
805
+ * dock client script can report into the same feed the server writes to.
806
+ */
807
+ const hubMessagesAdd = defineHubRpcFunction({
808
+ name: "hub:messages:add",
809
+ type: "action",
810
+ jsonSerializable: true,
811
+ setup: (context) => ({ async handler(input) {
812
+ return (await context.messages.add({
813
+ ...input,
814
+ from: "browser"
815
+ })).entry;
816
+ } })
817
+ });
818
+ /** `hub:messages:update` — Patch a message by id; returns the updated entry (or `undefined`). */
819
+ const hubMessagesUpdate = defineHubRpcFunction({
820
+ name: "hub:messages:update",
821
+ type: "action",
822
+ jsonSerializable: true,
823
+ setup: (context) => ({ async handler(id, patch) {
824
+ return context.messages.update(id, patch);
825
+ } })
826
+ });
827
+ /** `hub:messages:remove` — Remove a message by id. */
828
+ const hubMessagesRemove = defineHubRpcFunction({
829
+ name: "hub:messages:remove",
830
+ type: "action",
831
+ setup: (context) => ({ async handler(id) {
832
+ await context.messages.remove(id);
833
+ } })
834
+ });
835
+ /** `hub:messages:clear` — Remove every message. */
836
+ const hubMessagesClear = defineHubRpcFunction({
837
+ name: "hub:messages:clear",
838
+ type: "action",
839
+ setup: (context) => ({ async handler() {
840
+ await context.messages.clear();
841
+ } })
842
+ });
843
+ /**
844
+ * `hub:terminals:write` — Send input to an interactive PTY session spawned
845
+ * via `ctx.terminals.startPtySession`. Lets a hub-aware terminal UI (e.g. the
846
+ * terminals plugin) drive a session owned by another plugin.
847
+ */
848
+ const hubTerminalsWrite = defineHubRpcFunction({
849
+ name: "hub:terminals:write",
850
+ type: "action",
851
+ setup: (context) => ({ async handler(id, data) {
852
+ resolveInteractiveSession(context.terminals.sessions, id).write(data);
853
+ } })
854
+ });
855
+ /** `hub:terminals:resize` — Resize an interactive PTY session by id. */
856
+ const hubTerminalsResize = defineHubRpcFunction({
857
+ name: "hub:terminals:resize",
858
+ type: "action",
859
+ setup: (context) => ({ async handler(id, cols, rows) {
860
+ resolveInteractiveSession(context.terminals.sessions, id).resize(cols, rows);
861
+ } })
862
+ });
863
+ /**
598
864
  * Framework-neutral RPC declarations auto-registered by
599
865
  * {@link createHubContext}. Provide additional RPCs by passing your own
600
866
  * array via `CreateHubContextOptions.builtinRpcDeclarations`; the hub's
601
867
  * list is prepended automatically.
602
868
  */
603
- const builtinHubRpcDeclarations = [hubCommandsExecute];
869
+ const builtinHubRpcDeclarations = [
870
+ hubCommandsExecute,
871
+ hubMessagesAdd,
872
+ hubMessagesUpdate,
873
+ hubMessagesRemove,
874
+ hubMessagesClear,
875
+ hubTerminalsWrite,
876
+ hubTerminalsResize
877
+ ];
604
878
  //#endregion
605
879
  //#region src/node/context.ts
606
880
  /**
@@ -614,7 +888,7 @@ async function createHubContext(options) {
614
888
  ...options,
615
889
  builtinRpcDeclarations: [...builtinHubRpcDeclarations, ...options.builtinRpcDeclarations ?? []]
616
890
  });
617
- const docks = new DevframeDocksHost(context);
891
+ const docks = new DevframeDocksHost(context, options.builtinDocks);
618
892
  const terminals = new DevframeTerminalsHost(context);
619
893
  const messages = new DevframeMessagesHost(context);
620
894
  const commands = new DevframeCommandsHost(context);
@@ -680,6 +954,18 @@ async function createHubContext(options) {
680
954
  //#endregion
681
955
  //#region src/node/mount-devframe.ts
682
956
  /**
957
+ * Find the next free dock id derived from `baseId`. Returns `baseId`
958
+ * when it is unused, otherwise appends `-2`, `-3`, … until a free slot
959
+ * is found. Used by the `'duplicate'` strategy so co-existing instances
960
+ * never collide in the dock registry.
961
+ */
962
+ function nextAvailableDockId(views, baseId) {
963
+ if (!views.has(baseId)) return baseId;
964
+ let n = 2;
965
+ while (views.has(`${baseId}-${n}`)) n++;
966
+ return `${baseId}-${n}`;
967
+ }
968
+ /**
683
969
  * Framework-neutral primitive — mounts a {@link DevframeDefinition} as a
684
970
  * dock inside a hub-aware context: serves the devframe's SPA at the
685
971
  * resolved base path, synthesizes an iframe dock entry from the
@@ -690,10 +976,31 @@ async function createHubContext(options) {
690
976
  * Vite `Plugin` whose `devtools.setup` ultimately delegates here.
691
977
  */
692
978
  async function mountDevframe(ctx, d, options = {}) {
693
- const base = options.base ?? resolveBasePath(d, "hosted");
694
- if (d.cli?.distDir) ctx.views.hostStatic(base, resolve(d.cli.distDir));
979
+ const strategy = d.duplicationStrategy ?? "warn";
980
+ const isDuplicate = ctx.docks.views.has(d.id);
981
+ if (isDuplicate && strategy !== "duplicate") {
982
+ if (strategy === "throw") throw diagnostics.DF8105({
983
+ id: d.id,
984
+ name: d.name
985
+ });
986
+ if (strategy === "warn") diagnostics.DF8105({
987
+ id: d.id,
988
+ name: d.name
989
+ });
990
+ return;
991
+ }
992
+ const id = isDuplicate ? nextAvailableDockId(ctx.docks.views, d.id) : d.id;
993
+ const base = options.base ?? (id === d.id ? resolveBasePath(d, "hosted") : resolveBasePath({
994
+ ...d,
995
+ id,
996
+ basePath: void 0
997
+ }, "hosted"));
998
+ if (d.cli?.distDir) {
999
+ ctx.views.hostStatic(base, resolve(d.cli.distDir));
1000
+ await ctx.host.mountConnectionMeta?.(base);
1001
+ }
695
1002
  ctx.docks.register({
696
- id: d.id,
1003
+ id,
697
1004
  title: d.name,
698
1005
  icon: d.icon ?? "ph:plug-duotone",
699
1006
  ...options.dock,
@@ -6142,4 +6449,4 @@ function createSimpleClientScript(fn) {
6142
6449
  };
6143
6450
  }
6144
6451
  //#endregion
6145
- export { DevframeCommandsHost, DevframeDocksHost, DevframeMessagesHost, DevframeTerminalsHost, builtinHubRpcDeclarations, createHubContext, createSimpleClientScript, hubCommandsExecute, mountDevframe };
6452
+ export { DevframeCommandsHost, DevframeDocksHost, DevframeMessagesHost, DevframeTerminalsHost, builtinHubRpcDeclarations, createHubContext, createSimpleClientScript, hubCommandsExecute, hubMessagesAdd, hubMessagesClear, hubMessagesRemove, hubMessagesUpdate, hubTerminalsResize, hubTerminalsWrite, mountDevframe };