mcp-tenant-lib 0.3.7 → 0.4.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.
package/dist/http.js CHANGED
@@ -152,23 +152,21 @@ export function createHttpServer({ port, staticDir, initialSchema, initialValues
152
152
  if (pathname.startsWith('/t/')) {
153
153
  pathname = '/';
154
154
  }
155
- const mountEntry = Object.entries(extraStaticMounts).find(([prefix]) => pathname === prefix || pathname.startsWith(`${prefix}/`));
155
+ // A single-file mount (e.g. "/main.js") only ever matches its own exact
156
+ // path — checked first so a directory mount rooted at "/" (see below)
157
+ // can't shadow it. A directory mount (no extension on its prefix, e.g.
158
+ // "/") matches its prefix or anything nested under it.
159
+ const singleFileMatch = Object.entries(extraStaticMounts).find(([prefix]) => path.extname(prefix) && pathname === prefix);
160
+ const dirMatch = Object.entries(extraStaticMounts).find(([prefix]) => !path.extname(prefix) && (pathname === prefix || pathname.startsWith(`${prefix}/`)));
156
161
  let filePath;
157
- if (mountEntry) {
158
- const [prefix, dir] = mountEntry;
159
- if (path.extname(prefix)) {
160
- // A prefix with a file extension (e.g. "/main.js") is a single-file
161
- // mount `dir` is that file's parent directory, and any request
162
- // exactly matching `prefix` resolves straight to it, no index.html
163
- // fallback. Lets a consumer serve one fixed-URL asset (e.g. an
164
- // embed script referenced by other pages as "<server>/main.js")
165
- // from a build directory that also happens to hold other files.
166
- filePath = path.join(dir, path.basename(prefix));
167
- }
168
- else {
169
- const rest = pathname.slice(prefix.length);
170
- filePath = path.join(dir, rest === '' || rest === '/' ? '/index.html' : rest);
171
- }
162
+ if (singleFileMatch) {
163
+ const [prefix, dir] = singleFileMatch;
164
+ filePath = path.join(dir, path.basename(prefix));
165
+ }
166
+ else if (dirMatch) {
167
+ const [prefix, dir] = dirMatch;
168
+ const rest = pathname.slice(prefix.length);
169
+ filePath = path.join(dir, rest === '' || rest === '/' ? '/index.html' : rest);
172
170
  }
173
171
  else {
174
172
  filePath = path.join(staticDir, pathname === '/' ? '/index.html' : pathname);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName, sanitizeChannelName, dashboardEvents } from './tenant.js';
1
+ export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep, startEmptySweep, isValidChannelName, sanitizeChannelName, dashboardEvents } from './tenant.js';
2
2
  export { buildMcpServer, type RegisterToolsFn, type McpServerIdentity } from './mcp.js';
3
3
  export { createHttpServer, type CreateHttpServerOptions } from './http.js';
4
4
  export { attachWebSocketServer } from './ws.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName, sanitizeChannelName, dashboardEvents } from './tenant.js';
1
+ export { Store, Tenant, tenants, getOrCreateTenant, disposeTenant, startIdleSweep, startEmptySweep, isValidChannelName, sanitizeChannelName, dashboardEvents } from './tenant.js';
2
2
  export { buildMcpServer } from './mcp.js';
3
3
  export { createHttpServer } from './http.js';
4
4
  export { attachWebSocketServer } from './ws.js';
@@ -94,5 +94,15 @@ export function enablePersistence(filePath) {
94
94
  writeToDisk();
95
95
  }, WRITE_DEBOUNCE_MS);
96
96
  pollInterval.unref();
97
+ // A change followed by a kill within WRITE_DEBOUNCE_MS never reaches the
98
+ // poll loop above, so the process's last bit of state would silently be
99
+ // lost on an otherwise-ordinary Ctrl+C. Force one final synchronous write
100
+ // before actually exiting so a quick restart doesn't drop it.
101
+ for (const signal of ['SIGINT', 'SIGTERM']) {
102
+ process.on(signal, () => {
103
+ writeToDisk();
104
+ process.exit(0);
105
+ });
106
+ }
97
107
  return { seededIds };
98
108
  }
package/dist/tenant.d.ts CHANGED
@@ -79,6 +79,17 @@ export declare class Tenant<TSchema, TValues> {
79
79
  lastStateChangeAt: number;
80
80
  wsClients: Set<WebSocket>;
81
81
  connections: Map<string, TenantConnection>;
82
+ /**
83
+ * Timestamp this tenant's connection count last dropped to zero, or
84
+ * `undefined` while it has at least one live connection. Distinct from
85
+ * `lastActivityAt` (bumped by ordinary traffic, drives the multi-hour
86
+ * general idle sweep) — this backs a much shorter "orphaned channel"
87
+ * sweep (see startEmptySweep) aimed at the "closed the tab" case: a
88
+ * channel nobody is connected to anymore, as opposed to one that's just
89
+ * quiet. Cleared the moment any connection registers again, so a normal
90
+ * ~2s client reconnect never trips the short timeout.
91
+ */
92
+ emptyAt: number | undefined;
82
93
  lastActivityAt: number;
83
94
  pendingCalls: Map<string, {
84
95
  resolve: (v: unknown) => void;
@@ -212,4 +223,5 @@ declare const tenants: Map<string, Tenant<any, any>>;
212
223
  declare function getOrCreateTenant<TSchema, TValues>(id: string, initialSchema: TSchema, initialValues: TValues): Tenant<TSchema, TValues>;
213
224
  declare function disposeTenant(id: string): void;
214
225
  declare function startIdleSweep(onSweep: (id: string) => void): NodeJS.Timeout;
215
- export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName, sanitizeChannelName };
226
+ declare function startEmptySweep(onSweep: (id: string) => void): NodeJS.Timeout;
227
+ export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, startEmptySweep, isValidChannelName, sanitizeChannelName };
package/dist/tenant.js CHANGED
@@ -86,6 +86,17 @@ export class Tenant {
86
86
  lastStateChangeAt = 0;
87
87
  wsClients;
88
88
  connections = new Map();
89
+ /**
90
+ * Timestamp this tenant's connection count last dropped to zero, or
91
+ * `undefined` while it has at least one live connection. Distinct from
92
+ * `lastActivityAt` (bumped by ordinary traffic, drives the multi-hour
93
+ * general idle sweep) — this backs a much shorter "orphaned channel"
94
+ * sweep (see startEmptySweep) aimed at the "closed the tab" case: a
95
+ * channel nobody is connected to anymore, as opposed to one that's just
96
+ * quiet. Cleared the moment any connection registers again, so a normal
97
+ * ~2s client reconnect never trips the short timeout.
98
+ */
99
+ emptyAt;
89
100
  #legacyManifest;
90
101
  #legacySummary;
91
102
  lastActivityAt;
@@ -159,6 +170,7 @@ export class Tenant {
159
170
  registerConnection(id, socket) {
160
171
  this.connections.set(id, { id, socket, manifest: [], summary: undefined, label: undefined });
161
172
  this.wsClients.add(socket);
173
+ this.emptyAt = undefined;
162
174
  notifyDashboard();
163
175
  }
164
176
  updateConnectionManifest(id, manifest, summary, label) {
@@ -189,6 +201,8 @@ export class Tenant {
189
201
  if (conn)
190
202
  this.wsClients.delete(conn.socket);
191
203
  this.connections.delete(id);
204
+ if (this.connections.size === 0)
205
+ this.emptyAt ??= Date.now();
192
206
  this.syncManifestToolRegistries();
193
207
  notifyDashboard();
194
208
  }
@@ -470,4 +484,34 @@ function startIdleSweep(onSweep) {
470
484
  sweepInterval.unref();
471
485
  return sweepInterval;
472
486
  }
473
- export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, isValidChannelName, sanitizeChannelName };
487
+ /**
488
+ * Much shorter, separate sweep aimed at "the browser tab closed" rather
489
+ * than "the connection went quiet" (that's TENANT_IDLE_TIMEOUT_MS/
490
+ * startIdleSweep above, which looks at traffic on a tenant that still has a
491
+ * live connection). This one only fires on tenants that have had ZERO
492
+ * connections for more than TENANT_EMPTY_TIMEOUT_MS — see Tenant.emptyAt.
493
+ * The server has no direct "tab closed" signal (a clean close and a brief
494
+ * network drop look identical), so this grace window doubles as the
495
+ * reconnect-tolerance budget: the client's own retry loop
496
+ * (client-bridge.ts) reconnects within ~2s of a drop, comfortably under the
497
+ * default, so a real reconnect always beats the sweep; a channel that stays
498
+ * empty past it is treated as abandoned.
499
+ */
500
+ const TENANT_EMPTY_TIMEOUT_MS = envMs('TENANT_EMPTY_TIMEOUT_MS', 15_000);
501
+ const TENANT_EMPTY_SWEEP_INTERVAL_MS = envMs('TENANT_EMPTY_SWEEP_INTERVAL_MS', 5_000);
502
+ function startEmptySweep(onSweep) {
503
+ const sweepInterval = setInterval(() => {
504
+ const now = Date.now();
505
+ for (const [id, tenant] of tenants) {
506
+ if (id === 'default')
507
+ continue;
508
+ if (tenant.emptyAt !== undefined && now - tenant.emptyAt > TENANT_EMPTY_TIMEOUT_MS) {
509
+ onSweep(id);
510
+ disposeTenant(id);
511
+ }
512
+ }
513
+ }, TENANT_EMPTY_SWEEP_INTERVAL_MS);
514
+ sweepInterval.unref();
515
+ return sweepInterval;
516
+ }
517
+ export { tenants, getOrCreateTenant, disposeTenant, startIdleSweep, startEmptySweep, isValidChannelName, sanitizeChannelName };
package/dist/types.d.ts CHANGED
@@ -63,6 +63,20 @@ export interface SubmitMessage {
63
63
  export interface InterruptMessage {
64
64
  type: 'interrupt';
65
65
  }
66
+ /**
67
+ * Sent by a page that is intentionally switching this socket off its
68
+ * current channel (e.g. a connect-flow "rename to a different channel")
69
+ * before it opens a fresh socket on the new one — distinct from an ordinary
70
+ * unclean close (network drop, tab crash), which carries no such signal.
71
+ * Lets the server dispose the old tenant immediately once this was its last
72
+ * connection, instead of waiting out the empty-tenant grace window (see
73
+ * Tenant.emptyAt / startEmptySweep in tenant.ts) for a channel the page
74
+ * itself just told us it's done with. A no-op if other connections remain
75
+ * on the tenant, or if it has already been disposed.
76
+ */
77
+ export interface LeaveChannelMessage {
78
+ type: 'leave_channel';
79
+ }
66
80
  export interface ToolParamSpec {
67
81
  type: 'string' | 'number' | 'boolean';
68
82
  description?: string;
@@ -125,4 +139,4 @@ export interface RenameConnectionMessage {
125
139
  type: 'rename_connection';
126
140
  appLabel: string;
127
141
  }
128
- export type ClientMessage = SetMessage | SubmitMessage | InterruptMessage | RegisterToolsMessage | CallResultMessage | RenameConnectionMessage | ResyncMessage;
142
+ export type ClientMessage = SetMessage | SubmitMessage | InterruptMessage | RegisterToolsMessage | CallResultMessage | RenameConnectionMessage | ResyncMessage | LeaveChannelMessage;
package/dist/ws.js CHANGED
@@ -107,6 +107,18 @@ export function attachWebSocketServer(httpServer, port, initialSchema, initialVa
107
107
  ? `[ws] resync from connection=${connectionId}: restoring tenant "${tenantId}" state pushed back by the browser`
108
108
  : `[ws] resync from connection=${connectionId}: ignored — tenant "${tenantId}" already has state at least as recent`);
109
109
  }
110
+ if (msg.type === 'leave_channel') {
111
+ // The page told us — as opposed to just dropping — that it's done
112
+ // with this channel (e.g. switching to a different one). Remove
113
+ // this connection now rather than waiting for the socket's own
114
+ // 'close' event, so a tenant this was the last connection on
115
+ // becomes empty (and eligible for startEmptySweep) immediately;
116
+ // the socket is closed right after, which would otherwise fire the
117
+ // same removeConnection redundantly — guarded there by connections
118
+ // no longer having this id.
119
+ console.error(`[ws] connection ${connectionId} left tenant=${tenantId} (${t.connections.size - 1} connection(s) remaining)`);
120
+ t.removeConnection(connectionId);
121
+ }
110
122
  });
111
123
  ws.on('close', (code, reason) => {
112
124
  console.error(`[ws] connection closed: tenant=${tenantId} connection=${connectionId} code=${code} reason=${reason.toString() || '(none)'}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-tenant-lib",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Generic tenant/session bookkeeping + MCP wiring, reusable across projects.",
6
6
  "repository": {