framer-dalton 0.0.18 → 0.0.21

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.
@@ -8,14 +8,14 @@ import http from 'http';
8
8
  import { createHTTPHandler } from '@trpc/server/adapters/standalone';
9
9
  import { initTRPC, TRPCError } from '@trpc/server';
10
10
  import { z } from 'zod';
11
- import crypto from 'crypto';
11
+ import { connect } from 'framer-api';
12
+ import crypto, { randomUUID } from 'crypto';
12
13
  import { createRequire } from 'module';
13
14
  import * as vm from 'vm';
14
- import { connect } from 'framer-api';
15
15
 
16
- /* @framer/ai relay server v0.0.18 */
16
+ /* @framer/ai relay server v0.0.21 */
17
17
  var __defProp = Object.defineProperty;
18
- var __knownSymbol = (name, symbol) => (symbol = Symbol[name]) ? symbol : /* @__PURE__ */ Symbol.for("Symbol." + name);
18
+ var __knownSymbol = (name2, symbol) => (symbol = Symbol[name2]) ? symbol : /* @__PURE__ */ Symbol.for("Symbol." + name2);
19
19
  var __typeError = (msg) => {
20
20
  throw TypeError(msg);
21
21
  };
@@ -57,6 +57,20 @@ var __callDispose = (stack, error, hasError) => {
57
57
  };
58
58
  return next();
59
59
  };
60
+
61
+ // package.json
62
+ var name = "framer-dalton";
63
+
64
+ // src/check-node-version.ts
65
+ var MINIMUM_NODE_VERSION = 22;
66
+ var currentMajor = Number(process.versions.node.split(".")[0]);
67
+ if (currentMajor < MINIMUM_NODE_VERSION) {
68
+ console.error(
69
+ `${name} requires Node.js >= ${MINIMUM_NODE_VERSION}. You are running Node.js ${process.versions.node}.
70
+ Please upgrade: https://nodejs.org/`
71
+ );
72
+ process.exit(1);
73
+ }
60
74
  function getLogPath() {
61
75
  if (process.env.XDG_STATE_HOME) {
62
76
  return path.join(process.env.XDG_STATE_HOME, "framer", "relay.log");
@@ -76,9 +90,7 @@ var initialized = false;
76
90
  function ensureLogDir() {
77
91
  if (initialized) return;
78
92
  const dir = path.dirname(logPath);
79
- if (!fs2.existsSync(dir)) {
80
- fs2.mkdirSync(dir, { recursive: true });
81
- }
93
+ fs2.mkdirSync(dir, { recursive: true });
82
94
  initialized = true;
83
95
  }
84
96
  __name(ensureLogDir, "ensureLogDir");
@@ -89,11 +101,15 @@ function log(message) {
89
101
  `);
90
102
  }
91
103
  __name(log, "log");
104
+ function debug(tag, message) {
105
+ return;
106
+ }
107
+ __name(debug, "debug");
92
108
 
93
109
  // src/version.ts
94
110
  var VERSION = (
95
111
  // typeof is used to ensure this can be used just via tsx or node etc. without build
96
- "0.0.18"
112
+ "0.0.21"
97
113
  );
98
114
 
99
115
  // src/relay-client.ts
@@ -381,7 +397,7 @@ async function sandboxedImport(scopedFs, specifier) {
381
397
  return import(specifier);
382
398
  }
383
399
  __name(sandboxedImport, "sandboxedImport");
384
- async function execute(session, framer, code, options = {}) {
400
+ async function execute(session, code, options = {}) {
385
401
  const { cwd } = options;
386
402
  const output = [];
387
403
  const customConsole = {
@@ -402,7 +418,7 @@ async function execute(session, framer, code, options = {}) {
402
418
  const sandboxedRequire = createSandboxedRequire(scopedFs);
403
419
  const vmContextObj = {
404
420
  // Framer API
405
- framer,
421
+ framer: session.connection.framer,
406
422
  state: session.state,
407
423
  // Console
408
424
  console: customConsole,
@@ -454,6 +470,9 @@ async function execute(session, framer, code, options = {}) {
454
470
  return { output };
455
471
  } catch (err) {
456
472
  const errorMessage = err instanceof Error ? err.message : String(err);
473
+ if (isConnectionError(errorMessage)) {
474
+ session.connection.markDisconnected();
475
+ }
457
476
  return {
458
477
  output,
459
478
  error: errorMessage
@@ -463,36 +482,44 @@ async function execute(session, framer, code, options = {}) {
463
482
  }
464
483
  }
465
484
  __name(execute, "execute");
466
- async function executeWithReconnect(session, framer, code, options, reconnect, execId) {
467
- const result = await tryExecute(session, framer, code, options);
485
+ async function executeWithReconnect(session, code, options, execId) {
486
+ if (!session.connection.isConnected()) {
487
+ log(
488
+ `exec.reconnect exec=${execId} session=${session.id} reason="idle disconnected"`
489
+ );
490
+ try {
491
+ await session.connection.reconnect();
492
+ } catch {
493
+ return {
494
+ output: [],
495
+ error: "Failed to get connection for session"
496
+ };
497
+ }
498
+ }
499
+ const result = await execute(session, code, options);
468
500
  if (!result.error || !isConnectionError(result.error)) {
469
501
  return result;
470
502
  }
471
- const reqId = framer.requestId;
472
- const tag = `exec=${execId} session=${session.id}${reqId ? ` req=${reqId}` : ""}`;
473
- log(`reconnect ${tag} project=${session.projectId} reason="${result.error}"`);
474
- const newFramer = await reconnect();
475
- if (!newFramer) {
476
- log(`reconnect.failed ${tag} error="no connection returned"`);
503
+ log(
504
+ `reconnect exec=${execId} session=${session.id} req=${session.connection.framer.requestId} reason="${result.error}"`
505
+ );
506
+ try {
507
+ await session.connection.reconnect();
508
+ } catch {
509
+ log(
510
+ `reconnect.failed exec=${execId} session=${session.id} req=${session.connection.framer.requestId} error="reconnect failed"`
511
+ );
477
512
  return {
478
513
  output: [],
479
514
  error: "Connection lost and failed to reconnect"
480
515
  };
481
516
  }
482
- const newReqId = newFramer.requestId;
483
- log(`reconnect.success ${tag}${newReqId ? ` new_req=${newReqId}` : ""}`);
484
- return tryExecute(session, newFramer, code, options);
517
+ log(
518
+ `reconnect.success exec=${execId} session=${session.id} req=${session.connection.framer.requestId}`
519
+ );
520
+ return execute(session, code, options);
485
521
  }
486
522
  __name(executeWithReconnect, "executeWithReconnect");
487
- async function tryExecute(session, framer, code, options) {
488
- try {
489
- return await execute(session, framer, code, options);
490
- } catch (err) {
491
- const error = err instanceof Error ? err.message : String(err);
492
- return { output: [], error };
493
- }
494
- }
495
- __name(tryExecute, "tryExecute");
496
523
  function formatValue(value) {
497
524
  if (value === null) return "null";
498
525
  if (value === void 0) return "undefined";
@@ -513,191 +540,477 @@ function formatValue(value) {
513
540
  }
514
541
  }
515
542
  __name(formatValue, "formatValue");
516
- var ConnectionPool = class {
543
+ function getConfigDir() {
544
+ if (process.env.XDG_CONFIG_HOME) {
545
+ return path.join(process.env.XDG_CONFIG_HOME, "framer");
546
+ }
547
+ if (process.platform === "win32") {
548
+ return path.join(process.env.APPDATA || os.homedir(), "framer");
549
+ }
550
+ return path.join(os.homedir(), ".config", "framer");
551
+ }
552
+ __name(getConfigDir, "getConfigDir");
553
+ function ensureConfigDir() {
554
+ const configDir = getConfigDir();
555
+ fs2.mkdirSync(configDir, { recursive: true, mode: 448 });
556
+ }
557
+ __name(ensureConfigDir, "ensureConfigDir");
558
+ var DEFAULT_FS_POLL_INTERVAL_MS = 1e3;
559
+ var fsPollIntervalMs = DEFAULT_FS_POLL_INTERVAL_MS;
560
+ var SettingsWatcher = class {
561
+ constructor(settingsPath) {
562
+ this.settingsPath = settingsPath;
563
+ }
564
+ settingsPath;
517
565
  static {
518
- __name(this, "ConnectionPool");
519
- }
520
- pool = /* @__PURE__ */ new Map();
521
- reconnectPromises = /* @__PURE__ */ new Map();
522
- /**
523
- * Acquire a connection for a session.
524
- * If a connection already exists for the project, the session is added to it.
525
- * Otherwise, a new connection is created.
526
- */
527
- async acquire(projectId, apiKey, session) {
528
- const entry = this.pool.get(projectId);
529
- if (entry) {
530
- entry.sessions.add(session);
531
- if (!entry.connected) {
532
- await entry.connection.reconnect();
533
- entry.connected = true;
566
+ __name(this, "SettingsWatcher");
567
+ }
568
+ start(callback) {
569
+ fs2.watchFile(
570
+ this.settingsPath,
571
+ { persistent: false, interval: fsPollIntervalMs },
572
+ (curr, prev) => {
573
+ if (
574
+ // File was modified
575
+ curr.mtimeMs !== prev.mtimeMs || // File was renamed and replaced by a new file with the same name
576
+ curr.ino !== prev.ino || // File changed without any of the above changing for some reason?
577
+ curr.size !== prev.size
578
+ ) {
579
+ callback();
580
+ }
534
581
  }
535
- return entry.connection;
536
- }
537
- const connection = await connect(projectId, apiKey, {
538
- clientId: `dalton/${VERSION}`
539
- });
540
- this.pool.set(projectId, {
541
- connection,
542
- sessions: /* @__PURE__ */ new Set([session]),
543
- connected: true
582
+ );
583
+ return this;
584
+ }
585
+ stop() {
586
+ fs2.unwatchFile(this.settingsPath);
587
+ return this;
588
+ }
589
+ };
590
+
591
+ // src/config/settings.ts
592
+ var DEFAULT_MACHINE_ID = "unknown-machine-id-this-should-never-be-persisted";
593
+ var DEFAULT_SETTINGS = {
594
+ machineId: DEFAULT_MACHINE_ID,
595
+ telemetryEnabled: true,
596
+ telemetryNoticeShown: false
597
+ };
598
+ var SettingsFileSchema = z.object({
599
+ machineId: z.string().optional(),
600
+ telemetryEnabled: z.boolean().optional(),
601
+ telemetryNoticeShown: z.boolean().optional()
602
+ });
603
+ function getSettingsPath() {
604
+ return path.join(getConfigDir(), "settings.json");
605
+ }
606
+ __name(getSettingsPath, "getSettingsPath");
607
+ var settings;
608
+ var settingsWatcher;
609
+ function getSettings() {
610
+ if (settings) return settings;
611
+ settings = readSettingsFile();
612
+ if (!settingsWatcher) {
613
+ settingsWatcher = new SettingsWatcher(getSettingsPath()).start(() => {
614
+ settings = void 0;
544
615
  });
545
- return connection;
546
616
  }
547
- /**
548
- * Get the connection for a project.
549
- */
550
- getConnection(projectId) {
551
- const entry = this.pool.get(projectId);
552
- return entry?.connection ?? null;
553
- }
554
- /**
555
- * Reconnect a project's connection (call after catching a connection error).
556
- * Concurrent callers for the same project share a single reconnect attempt.
557
- */
558
- async reconnect(projectId) {
559
- const existingPromise = this.reconnectPromises.get(projectId);
560
- if (existingPromise) return existingPromise;
561
- const promise = this.doReconnect(projectId).finally(() => {
562
- this.reconnectPromises.delete(projectId);
617
+ return settings;
618
+ }
619
+ __name(getSettings, "getSettings");
620
+ function readSettingsFile() {
621
+ const settingsPath = getSettingsPath();
622
+ try {
623
+ const raw = JSON.parse(fs2.readFileSync(settingsPath, "utf-8"));
624
+ const result = SettingsFileSchema.parse(raw);
625
+ return {
626
+ machineId: result.machineId ?? DEFAULT_SETTINGS.machineId,
627
+ telemetryEnabled: result.telemetryEnabled ?? DEFAULT_SETTINGS.telemetryEnabled,
628
+ telemetryNoticeShown: result.telemetryNoticeShown ?? DEFAULT_SETTINGS.telemetryNoticeShown
629
+ };
630
+ } catch {
631
+ return { ...DEFAULT_SETTINGS };
632
+ }
633
+ }
634
+ __name(readSettingsFile, "readSettingsFile");
635
+ function setSettings(newSettings) {
636
+ settings = newSettings;
637
+ writeSettingsFile(newSettings);
638
+ }
639
+ __name(setSettings, "setSettings");
640
+ function writeSettingsFile(settings2) {
641
+ ensureConfigDir();
642
+ fs2.writeFileSync(getSettingsPath(), JSON.stringify(settings2, null, " "), {
643
+ mode: 384
644
+ });
645
+ }
646
+ __name(writeSettingsFile, "writeSettingsFile");
647
+ function getMachineId() {
648
+ const settings2 = getSettings();
649
+ if (settings2.machineId === DEFAULT_MACHINE_ID) {
650
+ const machineId = crypto.randomUUID();
651
+ setSettings({ ...settings2, machineId });
652
+ }
653
+ return getSettings().machineId;
654
+ }
655
+ __name(getMachineId, "getMachineId");
656
+ function isTelemetryEnabled() {
657
+ return getSettings().telemetryEnabled;
658
+ }
659
+ __name(isTelemetryEnabled, "isTelemetryEnabled");
660
+ var trackingEndpoint = "https://events.framer.com/track";
661
+ var inProgressTrackings = /* @__PURE__ */ new Set();
662
+ function sharedFields() {
663
+ return {
664
+ machineId: getMachineId(),
665
+ cliVersion: VERSION
666
+ };
667
+ }
668
+ __name(sharedFields, "sharedFields");
669
+ function wrapEvent(event) {
670
+ return {
671
+ source: "framer-dalton",
672
+ timestamp: Date.now(),
673
+ type: "track",
674
+ uuid: randomUUID(),
675
+ data: event
676
+ };
677
+ }
678
+ __name(wrapEvent, "wrapEvent");
679
+ function postEvent(event) {
680
+ if (!isTelemetryEnabled()) return false;
681
+ if (process.env.NODE_ENV === "test" && true) return false;
682
+ const promise = fetch(trackingEndpoint, {
683
+ method: "POST",
684
+ headers: {
685
+ "Content-Type": "application/json",
686
+ "User-Agent": `framer-dalton/${VERSION}`
687
+ },
688
+ body: JSON.stringify([wrapEvent(event)]),
689
+ signal: AbortSignal.timeout(
690
+ 5e3
691
+ /* 5 seconds */
692
+ )
693
+ }).then((response) => {
694
+ if (!response.ok) {
695
+ debug(
696
+ "tracking",
697
+ `failed to send ${event.event}: HTTP ${response.status}`
698
+ );
699
+ }
700
+ }).catch((error) => {
701
+ debug(
702
+ "tracking",
703
+ `failed to send ${event.event}: ${error instanceof Error ? error.message : String(error)}`
704
+ );
705
+ }).finally(() => {
706
+ inProgressTrackings.delete(promise);
707
+ });
708
+ inProgressTrackings.add(promise);
709
+ return true;
710
+ }
711
+ __name(postEvent, "postEvent");
712
+ function waitForTrackingToFinish() {
713
+ return Promise.allSettled(Array.from(inProgressTrackings));
714
+ }
715
+ __name(waitForTrackingToFinish, "waitForTrackingToFinish");
716
+ function trackRelayStart() {
717
+ postEvent({
718
+ event: "local_agents_relay_start",
719
+ ...sharedFields()
720
+ });
721
+ }
722
+ __name(trackRelayStart, "trackRelayStart");
723
+ var relayShutdownTracked = false;
724
+ function trackRelayShutdown() {
725
+ if (relayShutdownTracked) return;
726
+ relayShutdownTracked = postEvent({
727
+ event: "local_agents_relay_shutdown",
728
+ ...sharedFields()
729
+ });
730
+ }
731
+ __name(trackRelayShutdown, "trackRelayShutdown");
732
+ function trackSessionCreate(payload) {
733
+ postEvent({
734
+ event: "local_agents_session_create",
735
+ ...sharedFields(),
736
+ ...payload
737
+ });
738
+ }
739
+ __name(trackSessionCreate, "trackSessionCreate");
740
+ function trackSessionDestroy(payload) {
741
+ postEvent({
742
+ event: "local_agents_session_destroy",
743
+ ...sharedFields(),
744
+ ...payload
745
+ });
746
+ }
747
+ __name(trackSessionDestroy, "trackSessionDestroy");
748
+ function trackExec(payload) {
749
+ postEvent({
750
+ event: "local_agents_exec",
751
+ ...sharedFields(),
752
+ ...payload
753
+ });
754
+ }
755
+ __name(trackExec, "trackExec");
756
+ function trackConnectionAcquire(payload) {
757
+ postEvent({
758
+ event: "local_agents_connection_acquire",
759
+ ...sharedFields(),
760
+ ...payload
761
+ });
762
+ }
763
+ __name(trackConnectionAcquire, "trackConnectionAcquire");
764
+ function trackConnectionReconnect(payload) {
765
+ postEvent({
766
+ event: "local_agents_connection_reconnect",
767
+ ...sharedFields(),
768
+ ...payload
769
+ });
770
+ }
771
+ __name(trackConnectionReconnect, "trackConnectionReconnect");
772
+ function trackConnectionIdleDisconnect(payload) {
773
+ postEvent({
774
+ event: "local_agents_connection_idle_disconnect",
775
+ ...sharedFields(),
776
+ ...payload
777
+ });
778
+ }
779
+ __name(trackConnectionIdleDisconnect, "trackConnectionIdleDisconnect");
780
+ function trackError(payload) {
781
+ postEvent({
782
+ event: "local_agents_error",
783
+ ...sharedFields(),
784
+ ...payload
785
+ });
786
+ }
787
+ __name(trackError, "trackError");
788
+
789
+ // src/session-manager.ts
790
+ var SESSION_IDLE_TIMEOUT_MS = 60 * 1e3;
791
+ var SESSION_IDLE_CHECK_INTERVAL_MS = 30 * 1e3;
792
+ var MISSING_SERVER_SESSION_ID = "missing-session-id-should-not-happen";
793
+ var Connection = class _Connection {
794
+ constructor(projectId, userId, apiKey, headlessServerUrl, framer) {
795
+ this.projectId = projectId;
796
+ this.userId = userId;
797
+ this.apiKey = apiKey;
798
+ this.headlessServerUrl = headlessServerUrl;
799
+ this.framer = framer;
800
+ }
801
+ projectId;
802
+ userId;
803
+ apiKey;
804
+ headlessServerUrl;
805
+ framer;
806
+ static {
807
+ __name(this, "Connection");
808
+ }
809
+ sessions = [];
810
+ status = "connected";
811
+ pendingReconnect;
812
+ static async open(projectId, userId, apiKey, headlessServerUrl) {
813
+ const framer = await connect(projectId, apiKey, {
814
+ clientId: `dalton/${VERSION}`,
815
+ serverUrl: headlessServerUrl
563
816
  });
564
- this.reconnectPromises.set(projectId, promise);
565
- return promise;
817
+ return new _Connection(projectId, userId, apiKey, headlessServerUrl, framer);
566
818
  }
567
- async doReconnect(projectId) {
568
- const entry = this.pool.get(projectId);
569
- if (!entry) return null;
570
- try {
571
- await entry.connection.reconnect();
572
- entry.connected = true;
573
- return entry.connection;
574
- } catch {
575
- return null;
819
+ isConnected() {
820
+ return this.status === "connected";
821
+ }
822
+ markDisconnected() {
823
+ this.status = "disconnected";
824
+ }
825
+ getServerSessionId() {
826
+ return this.framer.sessionId ?? MISSING_SERVER_SESSION_ID;
827
+ }
828
+ listSessionInfo() {
829
+ return this.sessions.map((session) => ({
830
+ id: session.id,
831
+ projectId: this.projectId,
832
+ stateKeys: Object.keys(session.state),
833
+ headlessServerUrl: this.headlessServerUrl
834
+ }));
835
+ }
836
+ createSession(id) {
837
+ const session = {
838
+ id,
839
+ state: {},
840
+ lastActivityAt: Date.now(),
841
+ inflight: 0,
842
+ connection: this
843
+ };
844
+ this.sessions.push(session);
845
+ return session;
846
+ }
847
+ findSession(id) {
848
+ return this.sessions.find((session) => session.id === id);
849
+ }
850
+ removeSession(id) {
851
+ const index = this.sessions.findIndex((session) => session.id === id);
852
+ if (index >= 0) {
853
+ this.sessions.splice(index, 1);
576
854
  }
577
855
  }
578
- /**
579
- * Disconnect a project's connection without removing sessions.
580
- * The next exec will trigger a reconnect via executeWithReconnect.
581
- */
582
- async disconnect(projectId) {
583
- const entry = this.pool.get(projectId);
584
- if (!entry || !entry.connected) return;
585
- entry.connected = false;
586
- await entry.connection.disconnect();
587
- }
588
- isConnected(projectId) {
589
- const entry = this.pool.get(projectId);
590
- return entry?.connected ?? false;
591
- }
592
- /**
593
- * Release a session from a connection.
594
- * If no sessions remain, the connection is disconnected and removed.
595
- */
596
- async release(projectId, session) {
597
- const entry = this.pool.get(projectId);
598
- if (!entry) {
856
+ hasSessions() {
857
+ return this.sessions.length > 0;
858
+ }
859
+ clearSessions() {
860
+ this.sessions.length = 0;
861
+ }
862
+ isIdle(now, timeoutMs) {
863
+ return this.sessions.every(
864
+ (session) => session.inflight === 0 && now - session.lastActivityAt >= timeoutMs
865
+ );
866
+ }
867
+ async reconnect() {
868
+ if (this.status === "connected") {
599
869
  return;
600
870
  }
601
- entry.sessions.delete(session);
602
- if (entry.sessions.size === 0) {
603
- await entry.connection.disconnect();
604
- this.pool.delete(projectId);
871
+ if (this.pendingReconnect) {
872
+ return this.pendingReconnect;
605
873
  }
874
+ this.status = "reconnecting";
875
+ this.pendingReconnect = this.framer.reconnect().then(() => {
876
+ this.status = "connected";
877
+ trackConnectionReconnect({
878
+ projectId: this.projectId,
879
+ userId: this.userId,
880
+ sessionId: this.getServerSessionId()
881
+ });
882
+ }).catch((error) => {
883
+ this.status = "disconnected";
884
+ throw error;
885
+ }).finally(() => {
886
+ this.pendingReconnect = void 0;
887
+ });
888
+ return this.pendingReconnect;
606
889
  }
607
- /**
608
- * Release all connections (for cleanup).
609
- */
610
- async releaseAll() {
611
- for (const [projectId, entry] of this.pool) {
612
- await entry.connection.disconnect();
613
- this.pool.delete(projectId);
890
+ async disconnect() {
891
+ if (this.pendingReconnect) {
892
+ try {
893
+ await this.pendingReconnect;
894
+ } catch {
895
+ }
896
+ }
897
+ if (this.status === "connected") {
898
+ await this.framer.disconnect();
614
899
  }
900
+ this.status = "disconnected";
901
+ this.pendingReconnect = void 0;
615
902
  }
616
903
  };
617
- var connectionPool = new ConnectionPool();
618
-
619
- // src/session-manager.ts
620
- var SESSION_IDLE_TIMEOUT_MS = 60 * 1e3;
621
- var SESSION_IDLE_CHECK_INTERVAL_MS = 30 * 1e3;
622
904
  var SessionManager = class {
623
905
  static {
624
906
  __name(this, "SessionManager");
625
907
  }
626
- sessions = /* @__PURE__ */ new Map();
908
+ connections = [];
627
909
  idleCheck = null;
628
- async create(projectId, apiKey) {
629
- let id = 1;
630
- while (this.sessions.has(String(id))) {
631
- id++;
632
- }
633
- const session = {
634
- id: String(id),
910
+ async create(projectId, userId, apiKey, headlessServerUrl) {
911
+ const connection = await this.findOrCreateConnection(
635
912
  projectId,
913
+ userId,
636
914
  apiKey,
637
- state: {},
638
- lastActivityAt: 0,
639
- inflight: 0
640
- };
915
+ headlessServerUrl
916
+ );
917
+ const session = connection.createSession(this.getNextSessionId());
641
918
  this.startIdleCheck();
642
- await connectionPool.acquire(projectId, apiKey, session);
643
- session.lastActivityAt = Date.now();
644
- this.sessions.set(String(id), session);
645
- return String(id);
919
+ return session;
646
920
  }
647
921
  list() {
648
- return Array.from(this.sessions.values()).map((session) => ({
649
- id: session.id,
650
- projectId: session.projectId,
651
- stateKeys: Object.keys(session.state)
652
- }));
922
+ return this.connections.flatMap(
923
+ (connection) => connection.listSessionInfo()
924
+ );
653
925
  }
654
926
  get(id) {
655
- return this.sessions.get(id);
927
+ return this.findSession(id);
656
928
  }
657
- getFramer(session) {
658
- return connectionPool.getConnection(session.projectId);
659
- }
660
- isConnected(session) {
661
- return connectionPool.isConnected(session.projectId);
929
+ async execute(id, code, options, execId) {
930
+ var _stack = [];
931
+ try {
932
+ const session = this.findSession(id);
933
+ if (!session) {
934
+ return void 0;
935
+ }
936
+ log(
937
+ `exec exec=${execId} session=${id} req=${session.connection.framer.requestId} code=${JSON.stringify(code).slice(0, 100)}`
938
+ );
939
+ const start = performance.now();
940
+ const _guard = __using(_stack, this.startExecution(session));
941
+ const result = await executeWithReconnect(session, code, options, execId);
942
+ const durationMs = Math.round(performance.now() - start);
943
+ trackExec({
944
+ projectId: session.connection.projectId,
945
+ userId: session.connection.userId,
946
+ sessionId: session.connection.getServerSessionId(),
947
+ durationMs,
948
+ hasError: result.error !== void 0,
949
+ ...result.error ? { errorMessage: result.error } : {}
950
+ });
951
+ if (result.error) {
952
+ log(
953
+ `exec.error exec=${execId} session=${id} req=${session.connection.framer.requestId} ${durationMs}ms error="${result.error}"`
954
+ );
955
+ } else {
956
+ log(
957
+ `exec.done exec=${execId} session=${id} req=${session.connection.framer.requestId} ${durationMs}ms`
958
+ );
959
+ }
960
+ return result;
961
+ } catch (_) {
962
+ var _error = _, _hasError = true;
963
+ } finally {
964
+ __callDispose(_stack, _error, _hasError);
965
+ }
662
966
  }
663
- async reconnect(session) {
664
- return connectionPool.reconnect(session.projectId);
967
+ exec(session) {
968
+ return this.startExecution(session);
665
969
  }
666
- /** Marks session as actively executing. Dispose to release. */
667
- exec(id) {
668
- const session = this.sessions.get(id);
669
- if (session) {
670
- session.inflight++;
671
- }
970
+ startExecution(session) {
971
+ session.inflight++;
672
972
  return {
673
973
  [Symbol.dispose]: () => {
674
- if (session) {
675
- session.inflight--;
676
- session.lastActivityAt = Date.now();
677
- }
974
+ session.inflight--;
975
+ session.lastActivityAt = Date.now();
678
976
  }
679
977
  };
680
978
  }
681
979
  async destroy(id) {
682
- const session = this.sessions.get(id);
980
+ const session = this.findSession(id);
683
981
  if (!session) {
684
982
  return;
685
983
  }
686
- await connectionPool.release(session.projectId, session);
687
- this.sessions.delete(id);
688
- if (this.sessions.size === 0) {
984
+ trackSessionDestroy({
985
+ projectId: session.connection.projectId,
986
+ userId: session.connection.userId,
987
+ sessionId: session.connection.getServerSessionId()
988
+ });
989
+ session.connection.removeSession(id);
990
+ if (!session.connection.hasSessions()) {
991
+ await session.connection.disconnect();
992
+ const index = this.connections.indexOf(session.connection);
993
+ if (index >= 0) {
994
+ this.connections.splice(index, 1);
995
+ }
996
+ }
997
+ if (this.connections.length === 0) {
689
998
  this.stopIdleCheck();
690
999
  }
691
1000
  }
692
1001
  async destroyAll() {
693
- for (const id of this.sessions.keys()) {
694
- await this.destroy(id);
1002
+ const connections = [...this.connections];
1003
+ this.connections = [];
1004
+ this.stopIdleCheck();
1005
+ for (const connection of connections) {
1006
+ connection.clearSessions();
1007
+ await connection.disconnect();
695
1008
  }
696
1009
  }
697
1010
  startIdleCheck() {
698
1011
  if (this.idleCheck) return;
699
1012
  this.idleCheck = setInterval(() => {
700
- this.reapIdleSessions().catch((err) => {
1013
+ this.reapIdleConnections().catch((err) => {
701
1014
  log(`reap error: ${err instanceof Error ? err.message : err}`);
702
1015
  });
703
1016
  }, SESSION_IDLE_CHECK_INTERVAL_MS);
@@ -709,36 +1022,94 @@ var SessionManager = class {
709
1022
  this.idleCheck = null;
710
1023
  }
711
1024
  }
712
- async reapIdleSessions() {
1025
+ async reapIdleConnections() {
713
1026
  const now = Date.now();
714
- const projectSessions = /* @__PURE__ */ new Map();
715
- for (const session of this.sessions.values()) {
716
- const existing = projectSessions.get(session.projectId);
717
- if (existing) existing.push(session);
718
- else projectSessions.set(session.projectId, [session]);
719
- }
720
1027
  const disconnects = [];
721
- for (const [projectId, sessions] of projectSessions) {
722
- if (!connectionPool.isConnected(projectId)) continue;
723
- const allIdle = sessions.every(
724
- (s) => s.inflight === 0 && now - s.lastActivityAt >= SESSION_IDLE_TIMEOUT_MS
725
- );
726
- if (!allIdle) continue;
727
- const reqId = connectionPool.getConnection(projectId)?.requestId;
1028
+ for (const connection of this.connections) {
1029
+ if (!connection.isConnected()) continue;
1030
+ if (!connection.isIdle(now, SESSION_IDLE_TIMEOUT_MS)) continue;
728
1031
  log(
729
- `idle disconnect project=${projectId}${reqId ? ` req=${reqId}` : ""}`
1032
+ `idle disconnect project=${connection.projectId} req=${connection.framer.requestId}`
730
1033
  );
731
- disconnects.push(connectionPool.disconnect(projectId));
1034
+ disconnects.push({
1035
+ projectId: connection.projectId,
1036
+ userId: connection.userId,
1037
+ sessionId: connection.getServerSessionId(),
1038
+ disconnectPromise: connection.disconnect()
1039
+ });
732
1040
  }
733
- const results = await Promise.allSettled(disconnects);
734
- for (const result of results) {
1041
+ const results = await Promise.allSettled(
1042
+ disconnects.map((d) => d.disconnectPromise)
1043
+ );
1044
+ for (const [index, result] of results.entries()) {
1045
+ const { projectId, sessionId, userId } = disconnects[index];
1046
+ if (result.status === "fulfilled") {
1047
+ trackConnectionIdleDisconnect({ projectId, sessionId, userId });
1048
+ continue;
1049
+ }
735
1050
  if (result.status === "rejected") {
736
- log(
737
- `disconnect error: ${result.reason instanceof Error ? result.reason.message : result.reason}`
738
- );
1051
+ const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason);
1052
+ log(`disconnect error: ${errorMessage}`);
1053
+ trackError({
1054
+ errorType: "connection_idle_disconnect_error",
1055
+ projectId,
1056
+ sessionId,
1057
+ userId,
1058
+ errorMessage
1059
+ });
739
1060
  }
740
1061
  }
741
1062
  }
1063
+ findConnection(projectId, apiKey, headlessServerUrl) {
1064
+ return this.connections.find(
1065
+ (connection) => connection.projectId === projectId && connection.apiKey === apiKey && connection.headlessServerUrl === headlessServerUrl
1066
+ );
1067
+ }
1068
+ async findOrCreateConnection(projectId, userId, apiKey, headlessServerUrl) {
1069
+ const existingConnection = this.findConnection(
1070
+ projectId,
1071
+ apiKey,
1072
+ headlessServerUrl
1073
+ );
1074
+ if (existingConnection) {
1075
+ await existingConnection.reconnect();
1076
+ trackConnectionAcquire({
1077
+ projectId,
1078
+ userId: existingConnection.userId,
1079
+ sessionId: existingConnection.getServerSessionId(),
1080
+ isReuse: true
1081
+ });
1082
+ return existingConnection;
1083
+ }
1084
+ const connection = await Connection.open(
1085
+ projectId,
1086
+ userId,
1087
+ apiKey,
1088
+ headlessServerUrl
1089
+ );
1090
+ trackConnectionAcquire({
1091
+ projectId,
1092
+ userId: connection.userId,
1093
+ sessionId: connection.getServerSessionId(),
1094
+ isReuse: false
1095
+ });
1096
+ this.connections.push(connection);
1097
+ return connection;
1098
+ }
1099
+ findSession(id) {
1100
+ for (const connection of this.connections) {
1101
+ const session = connection.findSession(id);
1102
+ if (session) return session;
1103
+ }
1104
+ return void 0;
1105
+ }
1106
+ getNextSessionId() {
1107
+ let id = 1;
1108
+ while (this.findSession(String(id))) {
1109
+ id++;
1110
+ }
1111
+ return String(id);
1112
+ }
742
1113
  };
743
1114
  var sessionManager = new SessionManager();
744
1115
 
@@ -752,13 +1123,40 @@ var appRouter = t.router({
752
1123
  listSessions: t.procedure.query(() => {
753
1124
  return sessionManager.list();
754
1125
  }),
755
- createSession: t.procedure.input(z.object({ projectId: z.string(), apiKey: z.string() })).mutation(async ({ input }) => {
1126
+ createSession: t.procedure.input(
1127
+ z.object({
1128
+ projectId: z.string(),
1129
+ apiKey: z.string(),
1130
+ userId: z.string().optional(),
1131
+ headlessServerUrl: z.string()
1132
+ })
1133
+ ).mutation(async ({ input }) => {
1134
+ const start = performance.now();
756
1135
  try {
757
- const id = await sessionManager.create(input.projectId, input.apiKey);
758
- log(`session.new id=${id} project=${input.projectId}`);
759
- return { id };
1136
+ const session = await sessionManager.create(
1137
+ input.projectId,
1138
+ input.userId,
1139
+ input.apiKey,
1140
+ input.headlessServerUrl
1141
+ );
1142
+ log(
1143
+ `session.new id=${session.id} project=${input.projectId} headlessServerUrl=${input.headlessServerUrl}`
1144
+ );
1145
+ trackSessionCreate({
1146
+ projectId: input.projectId,
1147
+ userId: session.connection.userId,
1148
+ sessionId: session.connection.getServerSessionId(),
1149
+ durationMs: Math.round(performance.now() - start)
1150
+ });
1151
+ return { id: session.id };
760
1152
  } catch (err) {
761
1153
  const message = err instanceof Error ? err.message : String(err);
1154
+ trackError({
1155
+ errorType: "session_create_error",
1156
+ projectId: input.projectId,
1157
+ userId: input.userId,
1158
+ errorMessage: message
1159
+ });
762
1160
  throw new TRPCError({
763
1161
  code: isAuthError(message) ? "UNAUTHORIZED" : "INTERNAL_SERVER_ERROR",
764
1162
  message
@@ -776,61 +1174,29 @@ var appRouter = t.router({
776
1174
  cwd: z.string().optional()
777
1175
  })
778
1176
  ).mutation(async ({ input }) => {
779
- var _stack = [];
780
- try {
781
- const { sessionId, code, cwd } = input;
782
- const session = sessionManager.get(sessionId);
783
- if (!session) {
784
- throw new TRPCError({
785
- code: "NOT_FOUND",
786
- message: `Session ${sessionId} not found`
787
- });
788
- }
789
- const _guard = __using(_stack, sessionManager.exec(sessionId));
790
- const execId = nextExecId++;
791
- let framer = sessionManager.getFramer(session);
792
- if (framer && !sessionManager.isConnected(session)) {
793
- log(
794
- `exec.reconnect exec=${execId} session=${sessionId} reason="idle disconnected"`
795
- );
796
- framer = await sessionManager.reconnect(session);
797
- }
798
- const reqId = framer?.requestId;
799
- const tag = `exec=${execId} session=${sessionId}${reqId ? ` req=${reqId}` : ""}`;
800
- log(`exec ${tag} code=${JSON.stringify(code).slice(0, 100)}`);
801
- if (!framer) {
802
- log(`exec.error ${tag} error="no connection"`);
803
- return {
804
- output: [],
805
- error: "Failed to get connection for session"
806
- };
807
- }
808
- const start = performance.now();
809
- const result = await executeWithReconnect(
810
- session,
811
- framer,
812
- code,
813
- { cwd },
814
- () => sessionManager.reconnect(session),
815
- execId
816
- );
817
- const elapsed = (performance.now() - start).toFixed(0);
818
- if (result.error) {
819
- log(`exec.error ${tag} ${elapsed}ms error="${result.error}"`);
820
- } else {
821
- log(`exec.done ${tag} ${elapsed}ms`);
822
- }
823
- return result;
824
- } catch (_) {
825
- var _error = _, _hasError = true;
826
- } finally {
827
- __callDispose(_stack, _error, _hasError);
1177
+ const { sessionId, code, cwd } = input;
1178
+ const execId = nextExecId++;
1179
+ const result = await sessionManager.execute(
1180
+ sessionId,
1181
+ code,
1182
+ { cwd },
1183
+ execId
1184
+ );
1185
+ if (!result) {
1186
+ throw new TRPCError({
1187
+ code: "BAD_REQUEST",
1188
+ message: `Session ${sessionId} is invalid or has expired`
1189
+ });
828
1190
  }
1191
+ return result;
829
1192
  }),
830
1193
  shutdown: t.procedure.mutation(() => {
831
1194
  log("shutdown requested");
1195
+ trackRelayShutdown();
832
1196
  setTimeout(() => {
833
- process.exit(0);
1197
+ waitForTrackingToFinish().finally(() => {
1198
+ process.exit(0);
1199
+ });
834
1200
  }, 100);
835
1201
  })
836
1202
  });
@@ -851,7 +1217,10 @@ async function startRelayServer(port = RELAY_PORT) {
851
1217
  log(`idle for ${Math.round(idleMs / 1e3)}s, shutting down`);
852
1218
  clearInterval(idleCheck);
853
1219
  server.close();
854
- process.exit(0);
1220
+ trackRelayShutdown();
1221
+ waitForTrackingToFinish().finally(() => {
1222
+ process.exit(0);
1223
+ });
855
1224
  }
856
1225
  }, IDLE_CHECK_INTERVAL_MS);
857
1226
  idleCheck.unref();
@@ -870,32 +1239,57 @@ process.title = "framer-relay-server";
870
1239
  process.on("uncaughtException", (err) => {
871
1240
  log(`uncaught exception: ${err.message}`);
872
1241
  console.error("Uncaught Exception:", err);
873
- process.exit(1);
1242
+ trackError({
1243
+ errorType: "relay_uncaught_exception",
1244
+ errorMessage: err.message
1245
+ });
1246
+ waitForTrackingToFinish().finally(() => {
1247
+ process.exit(1);
1248
+ });
874
1249
  });
875
1250
  process.on("unhandledRejection", (reason) => {
876
1251
  log(`unhandled rejection: ${reason}`);
877
1252
  console.error("Unhandled Rejection:", reason);
878
- process.exit(1);
1253
+ trackError({
1254
+ errorType: "relay_unhandled_rejection",
1255
+ errorMessage: reason instanceof Error ? reason.message : String(reason)
1256
+ });
1257
+ waitForTrackingToFinish().finally(() => {
1258
+ process.exit(1);
1259
+ });
879
1260
  });
880
1261
  async function main() {
881
1262
  const server = await startRelayServer(RELAY_PORT);
882
1263
  console.log(`Framer relay server v${VERSION} running on port ${RELAY_PORT}`);
1264
+ trackRelayStart();
883
1265
  process.on("SIGINT", () => {
884
1266
  log("shutdown SIGINT");
885
1267
  console.log("\nShutting down...");
886
1268
  server.close();
887
- process.exit(0);
1269
+ trackRelayShutdown();
1270
+ waitForTrackingToFinish().finally(() => {
1271
+ process.exit(0);
1272
+ });
888
1273
  });
889
1274
  process.on("SIGTERM", () => {
890
1275
  log("shutdown SIGTERM");
891
1276
  console.log("\nShutting down...");
892
1277
  server.close();
893
- process.exit(0);
1278
+ trackRelayShutdown();
1279
+ waitForTrackingToFinish().finally(() => {
1280
+ process.exit(0);
1281
+ });
894
1282
  });
895
1283
  }
896
1284
  __name(main, "main");
897
1285
  main().catch((err) => {
898
1286
  log(`startup failed: ${err.message}`);
899
1287
  console.error("Failed to start relay server:", err);
900
- process.exit(1);
1288
+ trackError({
1289
+ errorType: "relay_startup_error",
1290
+ errorMessage: err.message
1291
+ });
1292
+ waitForTrackingToFinish().finally(() => {
1293
+ process.exit(1);
1294
+ });
901
1295
  });