mcp-tenant-lib 0.3.8 → 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 +14 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/tenant.d.ts +13 -1
- package/dist/tenant.js +45 -1
- package/dist/types.d.ts +15 -1
- package/dist/ws.js +12 -0
- package/package.json +1 -1
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
|
-
|
|
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 (
|
|
158
|
-
const [prefix, dir] =
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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';
|
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
|
-
|
|
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
|
-
|
|
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)'}`);
|