@devframes/hub 0.5.3 → 0.6.0-beta.1

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`,
@@ -267,7 +282,10 @@ var DevframeDocksHost = class {
267
282
  this.views.set(view.id, view);
268
283
  this.events.emit("dock:entry:updated", view);
269
284
  return { update: (patch) => {
270
- if (patch.id && patch.id !== view.id) throw diagnostics.DF8101();
285
+ if (patch.id && patch.id !== view.id) throw diagnostics.DF8101({
286
+ id: view.id,
287
+ attempted: patch.id
288
+ });
271
289
  this.update(Object.assign(this.views.get(view.id), patch));
272
290
  } };
273
291
  }
@@ -306,13 +324,6 @@ var DevframeDocksHost = class {
306
324
  //#region src/node/host-messages.ts
307
325
  const MAX_ENTRIES = 1e3;
308
326
  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
327
  var DevframeMessagesHost = class {
317
328
  context;
318
329
  entries = /* @__PURE__ */ new Map();
@@ -323,9 +334,25 @@ var DevframeMessagesHost = class {
323
334
  removals = [];
324
335
  _autoDeleteTimers = /* @__PURE__ */ new Map();
325
336
  _clock = 0;
337
+ /**
338
+ * The tick of the newest removal record dropped from the capped
339
+ * `removals` log — cursors older than this can't get a reliable delta
340
+ * and fall back to a full snapshot in {@link listSince}.
341
+ */
342
+ _removalsTrimmedAt = 0;
326
343
  _tick() {
327
344
  return ++this._clock;
328
345
  }
346
+ _recordRemoval(id, time) {
347
+ this.removals.push({
348
+ id,
349
+ time
350
+ });
351
+ if (this.removals.length > MAX_REMOVALS) {
352
+ const dropped = this.removals.splice(0, this.removals.length - MAX_REMOVALS);
353
+ this._removalsTrimmedAt = dropped[dropped.length - 1].time;
354
+ }
355
+ }
329
356
  constructor(context) {
330
357
  this.context = context;
331
358
  }
@@ -385,18 +412,75 @@ var DevframeMessagesHost = class {
385
412
  }
386
413
  this.entries.delete(id);
387
414
  this.lastModified.delete(id);
388
- recordRemoval(this.removals, id, this._tick());
415
+ this._recordRemoval(id, this._tick());
389
416
  this.events.emit("message:removed", id);
390
417
  }
418
+ info(message, extra) {
419
+ return this.add({
420
+ ...extra,
421
+ message,
422
+ level: "info"
423
+ });
424
+ }
425
+ warn(message, extra) {
426
+ return this.add({
427
+ ...extra,
428
+ message,
429
+ level: "warn"
430
+ });
431
+ }
432
+ error(message, extra) {
433
+ return this.add({
434
+ ...extra,
435
+ message,
436
+ level: "error"
437
+ });
438
+ }
439
+ success(message, extra) {
440
+ return this.add({
441
+ ...extra,
442
+ message,
443
+ level: "success"
444
+ });
445
+ }
446
+ debug(message, extra) {
447
+ return this.add({
448
+ ...extra,
449
+ message,
450
+ level: "debug"
451
+ });
452
+ }
391
453
  async clear() {
392
454
  for (const timer of this._autoDeleteTimers.values()) clearTimeout(timer);
393
455
  this._autoDeleteTimers.clear();
394
456
  const tick = this._tick();
395
- for (const id of this.entries.keys()) recordRemoval(this.removals, id, tick);
457
+ for (const id of this.entries.keys()) this._recordRemoval(id, tick);
396
458
  this.entries.clear();
397
459
  this.lastModified.clear();
398
460
  this.events.emit("message:cleared");
399
461
  }
462
+ listSince(since) {
463
+ const version = this._clock;
464
+ if (since == null || since < this._removalsTrimmedAt || since > version) return {
465
+ entries: Array.from(this.entries.values()),
466
+ removedIds: [],
467
+ version,
468
+ full: true
469
+ };
470
+ const entries = [];
471
+ for (const [id, entry] of this.entries) {
472
+ const mod = this.lastModified.get(id);
473
+ if (mod != null && mod > since) entries.push(entry);
474
+ }
475
+ const removedIds = [];
476
+ for (const removal of this.removals) if (removal.time > since) removedIds.push(removal.id);
477
+ return {
478
+ entries,
479
+ removedIds,
480
+ version,
481
+ full: false
482
+ };
483
+ }
400
484
  _createHandle(id) {
401
485
  const host = this;
402
486
  return {
@@ -419,6 +503,8 @@ var DevframeMessagesHost = class {
419
503
  */
420
504
  const TERMINAL_STREAM_CHANNEL = "devframe:terminals";
421
505
  const TERMINAL_REPLAY_WINDOW = 1e3;
506
+ /** TERM handed to spawned PTYs; also used to reject fallback process labels. */
507
+ const PTY_TERM_NAME = "xterm-256color";
422
508
  var DevframeTerminalsHost = class {
423
509
  context;
424
510
  sessions = /* @__PURE__ */ new Map();
@@ -576,10 +662,123 @@ var DevframeTerminalsHost = class {
576
662
  this.register(session);
577
663
  return Promise.resolve(session);
578
664
  }
665
+ async startPtySession(executeOptions, terminal) {
666
+ if (this.sessions.has(terminal.id)) throw diagnostics.DF8200({ id: terminal.id });
667
+ const { spawn } = await import("zigpty");
668
+ const cols = executeOptions.cols ?? 80;
669
+ const rows = executeOptions.rows ?? 24;
670
+ let controller;
671
+ let pty;
672
+ let runId = 0;
673
+ let streamClosed = false;
674
+ const closeStream = () => {
675
+ if (streamClosed) return;
676
+ streamClosed = true;
677
+ try {
678
+ controller?.close();
679
+ } catch {}
680
+ };
681
+ const errorStream = (error) => {
682
+ if (streamClosed) return;
683
+ streamClosed = true;
684
+ try {
685
+ controller?.error(error);
686
+ } catch {}
687
+ };
688
+ const stream = new ReadableStream({
689
+ start(_controller) {
690
+ controller = _controller;
691
+ },
692
+ cancel() {
693
+ pty?.kill();
694
+ pty = void 0;
695
+ closeStream();
696
+ }
697
+ });
698
+ const spawnPty = () => {
699
+ const currentRun = ++runId;
700
+ const proc = spawn(executeOptions.command, executeOptions.args ?? [], {
701
+ name: PTY_TERM_NAME,
702
+ cols,
703
+ rows,
704
+ cwd: executeOptions.cwd ?? process$1.cwd(),
705
+ env: {
706
+ TERM: PTY_TERM_NAME,
707
+ COLORTERM: "truecolor",
708
+ FORCE_COLOR: "1",
709
+ ...executeOptions.env ?? {}
710
+ }
711
+ });
712
+ proc.onData((data) => {
713
+ if (streamClosed || currentRun !== runId) return;
714
+ controller?.enqueue(typeof data === "string" ? data : data.toString("utf8"));
715
+ });
716
+ proc.onExit(() => {
717
+ if (currentRun === runId) closeStream();
718
+ });
719
+ return proc;
720
+ };
721
+ try {
722
+ pty = spawnPty();
723
+ } catch (error) {
724
+ errorStream(error);
725
+ throw diagnostics.DF8203({
726
+ command: executeOptions.command,
727
+ reason: error instanceof Error ? error.message : String(error)
728
+ });
729
+ }
730
+ const session = {
731
+ ...terminal,
732
+ status: "running",
733
+ interactive: true,
734
+ stream,
735
+ type: "pty",
736
+ executeOptions,
737
+ write: (data) => {
738
+ try {
739
+ pty?.write(data);
740
+ } catch {}
741
+ },
742
+ resize: (nextCols, nextRows) => {
743
+ try {
744
+ pty?.resize(Math.max(1, nextCols), Math.max(1, nextRows));
745
+ } catch {}
746
+ },
747
+ getProcessName: () => {
748
+ try {
749
+ const name = pty?.process;
750
+ return name && name !== PTY_TERM_NAME ? name : void 0;
751
+ } catch {
752
+ return;
753
+ }
754
+ },
755
+ terminate: async () => {
756
+ pty?.kill();
757
+ pty = void 0;
758
+ closeStream();
759
+ },
760
+ restart: async () => {
761
+ pty?.kill();
762
+ pty = spawnPty();
763
+ }
764
+ };
765
+ this.register(session);
766
+ return session;
767
+ }
579
768
  };
580
769
  //#endregion
581
770
  //#region src/node/rpc-builtins.ts
582
771
  /**
772
+ * Resolve an interactive (PTY) terminal session by id, or throw. Sessions
773
+ * spawned via `startChildProcess` are output-only and are rejected here.
774
+ */
775
+ function resolveInteractiveSession(sessions, id) {
776
+ const session = sessions.get(id);
777
+ if (!session) throw diagnostics.DF8201({ id });
778
+ if (typeof session.write !== "function") throw diagnostics.DF8202({ id });
779
+ return session;
780
+ }
781
+ /**
583
782
  * `hub:commands:execute` — Invoke a registered server command by id. The
584
783
  * arguments after `id` are forwarded to the command's `handler(...)`.
585
784
  * Returns whatever the handler returns.
@@ -595,12 +794,85 @@ const hubCommandsExecute = defineHubRpcFunction({
595
794
  } })
596
795
  });
597
796
  /**
797
+ * `hub:messages:add` — Add a message from a browser client into the hub's
798
+ * messages subsystem. Marked `from: 'browser'`. Returns the serializable
799
+ * entry (the mutation handle stays server-side).
800
+ *
801
+ * Pairs with the client-side {@link import('../client').createDevframeClientHost}
802
+ * context, whose `messages` client dispatches through these built-ins so a
803
+ * dock client script can report into the same feed the server writes to.
804
+ */
805
+ const hubMessagesAdd = defineHubRpcFunction({
806
+ name: "hub:messages:add",
807
+ type: "action",
808
+ jsonSerializable: true,
809
+ setup: (context) => ({ async handler(input) {
810
+ return (await context.messages.add({
811
+ ...input,
812
+ from: "browser"
813
+ })).entry;
814
+ } })
815
+ });
816
+ /** `hub:messages:update` — Patch a message by id; returns the updated entry (or `undefined`). */
817
+ const hubMessagesUpdate = defineHubRpcFunction({
818
+ name: "hub:messages:update",
819
+ type: "action",
820
+ jsonSerializable: true,
821
+ setup: (context) => ({ async handler(id, patch) {
822
+ return context.messages.update(id, patch);
823
+ } })
824
+ });
825
+ /** `hub:messages:remove` — Remove a message by id. */
826
+ const hubMessagesRemove = defineHubRpcFunction({
827
+ name: "hub:messages:remove",
828
+ type: "action",
829
+ setup: (context) => ({ async handler(id) {
830
+ await context.messages.remove(id);
831
+ } })
832
+ });
833
+ /** `hub:messages:clear` — Remove every message. */
834
+ const hubMessagesClear = defineHubRpcFunction({
835
+ name: "hub:messages:clear",
836
+ type: "action",
837
+ setup: (context) => ({ async handler() {
838
+ await context.messages.clear();
839
+ } })
840
+ });
841
+ /**
842
+ * `hub:terminals:write` — Send input to an interactive PTY session spawned
843
+ * via `ctx.terminals.startPtySession`. Lets a hub-aware terminal UI (e.g. the
844
+ * terminals plugin) drive a session owned by another plugin.
845
+ */
846
+ const hubTerminalsWrite = defineHubRpcFunction({
847
+ name: "hub:terminals:write",
848
+ type: "action",
849
+ setup: (context) => ({ async handler(id, data) {
850
+ resolveInteractiveSession(context.terminals.sessions, id).write(data);
851
+ } })
852
+ });
853
+ /** `hub:terminals:resize` — Resize an interactive PTY session by id. */
854
+ const hubTerminalsResize = defineHubRpcFunction({
855
+ name: "hub:terminals:resize",
856
+ type: "action",
857
+ setup: (context) => ({ async handler(id, cols, rows) {
858
+ resolveInteractiveSession(context.terminals.sessions, id).resize(cols, rows);
859
+ } })
860
+ });
861
+ /**
598
862
  * Framework-neutral RPC declarations auto-registered by
599
863
  * {@link createHubContext}. Provide additional RPCs by passing your own
600
864
  * array via `CreateHubContextOptions.builtinRpcDeclarations`; the hub's
601
865
  * list is prepended automatically.
602
866
  */
603
- const builtinHubRpcDeclarations = [hubCommandsExecute];
867
+ const builtinHubRpcDeclarations = [
868
+ hubCommandsExecute,
869
+ hubMessagesAdd,
870
+ hubMessagesUpdate,
871
+ hubMessagesRemove,
872
+ hubMessagesClear,
873
+ hubTerminalsWrite,
874
+ hubTerminalsResize
875
+ ];
604
876
  //#endregion
605
877
  //#region src/node/context.ts
606
878
  /**
@@ -680,6 +952,18 @@ async function createHubContext(options) {
680
952
  //#endregion
681
953
  //#region src/node/mount-devframe.ts
682
954
  /**
955
+ * Find the next free dock id derived from `baseId`. Returns `baseId`
956
+ * when it is unused, otherwise appends `-2`, `-3`, … until a free slot
957
+ * is found. Used by the `'duplicate'` strategy so co-existing instances
958
+ * never collide in the dock registry.
959
+ */
960
+ function nextAvailableDockId(views, baseId) {
961
+ if (!views.has(baseId)) return baseId;
962
+ let n = 2;
963
+ while (views.has(`${baseId}-${n}`)) n++;
964
+ return `${baseId}-${n}`;
965
+ }
966
+ /**
683
967
  * Framework-neutral primitive — mounts a {@link DevframeDefinition} as a
684
968
  * dock inside a hub-aware context: serves the devframe's SPA at the
685
969
  * resolved base path, synthesizes an iframe dock entry from the
@@ -690,10 +974,31 @@ async function createHubContext(options) {
690
974
  * Vite `Plugin` whose `devtools.setup` ultimately delegates here.
691
975
  */
692
976
  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));
977
+ const strategy = d.duplicationStrategy ?? "warn";
978
+ const isDuplicate = ctx.docks.views.has(d.id);
979
+ if (isDuplicate && strategy !== "duplicate") {
980
+ if (strategy === "throw") throw diagnostics.DF8105({
981
+ id: d.id,
982
+ name: d.name
983
+ });
984
+ if (strategy === "warn") diagnostics.DF8105({
985
+ id: d.id,
986
+ name: d.name
987
+ });
988
+ return;
989
+ }
990
+ const id = isDuplicate ? nextAvailableDockId(ctx.docks.views, d.id) : d.id;
991
+ const base = options.base ?? (id === d.id ? resolveBasePath(d, "hosted") : resolveBasePath({
992
+ ...d,
993
+ id,
994
+ basePath: void 0
995
+ }, "hosted"));
996
+ if (d.cli?.distDir) {
997
+ ctx.views.hostStatic(base, resolve(d.cli.distDir));
998
+ await ctx.host.mountConnectionMeta?.(base);
999
+ }
695
1000
  ctx.docks.register({
696
- id: d.id,
1001
+ id,
697
1002
  title: d.name,
698
1003
  icon: d.icon ?? "ph:plug-duotone",
699
1004
  ...options.dock,
@@ -6142,4 +6447,4 @@ function createSimpleClientScript(fn) {
6142
6447
  };
6143
6448
  }
6144
6449
  //#endregion
6145
- export { DevframeCommandsHost, DevframeDocksHost, DevframeMessagesHost, DevframeTerminalsHost, builtinHubRpcDeclarations, createHubContext, createSimpleClientScript, hubCommandsExecute, mountDevframe };
6450
+ export { DevframeCommandsHost, DevframeDocksHost, DevframeMessagesHost, DevframeTerminalsHost, builtinHubRpcDeclarations, createHubContext, createSimpleClientScript, hubCommandsExecute, hubMessagesAdd, hubMessagesClear, hubMessagesRemove, hubMessagesUpdate, hubTerminalsResize, hubTerminalsWrite, mountDevframe };