@letta-ai/letta-agent-sdk 0.3.2 → 0.3.3
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/AGENTS.md +47 -0
- package/README.md +17 -0
- package/dist/client-entry.js +800 -732
- package/dist/client-entry.js.map +8 -5
- package/dist/cloud-sandbox.d.ts +33 -0
- package/dist/cloud-sandbox.d.ts.map +1 -0
- package/dist/cloud-session.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +799 -732
- package/dist/index.js.map +9 -6
- package/dist/remote-client-session-core.d.ts +6 -93
- package/dist/remote-client-session-core.d.ts.map +1 -1
- package/dist/remote-session-protocol.d.ts +130 -0
- package/dist/remote-session-protocol.d.ts.map +1 -0
- package/dist/remote-turn-coordinator.d.ts +49 -0
- package/dist/remote-turn-coordinator.d.ts.map +1 -0
- package/dist/types.d.ts +2 -20
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/app-server-management.ts +641 -0
- package/src/app-server-session.ts +948 -0
- package/src/cli-resolver.ts +46 -0
- package/src/client-base.ts +482 -0
- package/src/client-entry.ts +31 -0
- package/src/client.ts +138 -0
- package/src/cloud-management.ts +360 -0
- package/src/cloud-sandbox.ts +117 -0
- package/src/cloud-session.ts +1313 -0
- package/src/index.ts +440 -0
- package/src/interactiveToolPolicy.ts +62 -0
- package/src/local-app-server-session.ts +39 -0
- package/src/local-app-server.ts +137 -0
- package/src/management-types.ts +133 -0
- package/src/management.ts +206 -0
- package/src/protocol.ts +249 -0
- package/src/remote-client-session-core.ts +786 -0
- package/src/remote-session-protocol.ts +660 -0
- package/src/remote-turn-coordinator.ts +505 -0
- package/src/remote.ts +177 -0
- package/src/repositories.ts +340 -0
- package/src/request-ids.ts +33 -0
- package/src/session.ts +1638 -0
- package/src/stream-events.ts +88 -0
- package/src/tool-helpers.ts +147 -0
- package/src/transport.ts +484 -0
- package/src/types.ts +1328 -0
- package/src/validation.ts +223 -0
- package/src/websocket.ts +22 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAppServerClient,
|
|
3
|
+
type AppServerClient,
|
|
4
|
+
type AppServerRequestCommandWithId,
|
|
5
|
+
type AppServerSocketConstructor,
|
|
6
|
+
type AppServerSocketLike,
|
|
7
|
+
} from "@letta-ai/letta-code/app-server-client";
|
|
8
|
+
import type {
|
|
9
|
+
ManagementQuery,
|
|
10
|
+
ManagementTransport,
|
|
11
|
+
} from "./management.js";
|
|
12
|
+
import { applyUniqueRequestIds } from "./request-ids.js";
|
|
13
|
+
import type {
|
|
14
|
+
ConversationMessagesResult,
|
|
15
|
+
LettaAgent,
|
|
16
|
+
LettaConversation,
|
|
17
|
+
} from "./management-types.js";
|
|
18
|
+
import type {
|
|
19
|
+
LettaCodeModelEntry,
|
|
20
|
+
LettaCodeRemoteClientOptions,
|
|
21
|
+
ListModelsResult,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
|
|
24
|
+
type OwnedConnection = { url: string; close(): void };
|
|
25
|
+
|
|
26
|
+
type ManagementResponse = {
|
|
27
|
+
type: string;
|
|
28
|
+
success: boolean;
|
|
29
|
+
error?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type AgentListResponse = ManagementResponse & {
|
|
33
|
+
agents: LettaAgent[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type AgentResponse = ManagementResponse & {
|
|
37
|
+
agent: LettaAgent | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type ConversationListResponse = ManagementResponse & {
|
|
41
|
+
conversations: LettaConversation[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ConversationResponse = ManagementResponse & {
|
|
45
|
+
conversation: LettaConversation | null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type ConversationMessagesResponse = ManagementResponse & {
|
|
49
|
+
messages: Record<string, unknown>[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type ListModelsResponse = ManagementResponse & {
|
|
53
|
+
entries?: unknown;
|
|
54
|
+
available_handles?: unknown;
|
|
55
|
+
byok_provider_aliases?: unknown;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type AppServerManagementOptions =
|
|
59
|
+
Partial<LettaCodeRemoteClientOptions> & {
|
|
60
|
+
url?: string;
|
|
61
|
+
connect?: () => Promise<OwnedConnection>;
|
|
62
|
+
/**
|
|
63
|
+
* How long (in milliseconds) an idle control connection lingers before it
|
|
64
|
+
* is released. Defaults to {@link DEFAULT_IDLE_LINGER_MS}.
|
|
65
|
+
*/
|
|
66
|
+
idleLingerMs?: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How long an idle control connection lingers before it is released.
|
|
71
|
+
*
|
|
72
|
+
* Long enough to batch a burst of management calls (for example a screen
|
|
73
|
+
* fetching a list plus a retrieve together) over one connection, short enough
|
|
74
|
+
* that the app-server's single control-client slot frees up quickly for
|
|
75
|
+
* sessions.
|
|
76
|
+
*/
|
|
77
|
+
const DEFAULT_IDLE_LINGER_MS = 250;
|
|
78
|
+
|
|
79
|
+
type ActiveConnection = {
|
|
80
|
+
client: AppServerClient;
|
|
81
|
+
ownedConnection: OwnedConnection | null;
|
|
82
|
+
detachDisconnect: () => void;
|
|
83
|
+
unregister: () => void;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type RegisteredManagementTransport = {
|
|
87
|
+
releaseIdleConnection(): Promise<void>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
type ManagementTransportRegistration = {
|
|
91
|
+
transport: RegisteredManagementTransport;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const registeredTransportsByUrl =
|
|
95
|
+
new Map<string, Set<ManagementTransportRegistration>>();
|
|
96
|
+
|
|
97
|
+
function appServerUrlKey(value: string): string {
|
|
98
|
+
try {
|
|
99
|
+
const url = new URL(value);
|
|
100
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
101
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
102
|
+
url.hash = "";
|
|
103
|
+
url.searchParams.delete("channel");
|
|
104
|
+
return url.toString();
|
|
105
|
+
} catch {
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function registerManagementTransport(
|
|
111
|
+
url: string,
|
|
112
|
+
transport: RegisteredManagementTransport,
|
|
113
|
+
): () => void {
|
|
114
|
+
const key = appServerUrlKey(url);
|
|
115
|
+
const registration = { transport };
|
|
116
|
+
const transports =
|
|
117
|
+
registeredTransportsByUrl.get(key) ??
|
|
118
|
+
new Set<ManagementTransportRegistration>();
|
|
119
|
+
transports.add(registration);
|
|
120
|
+
registeredTransportsByUrl.set(key, transports);
|
|
121
|
+
return () => {
|
|
122
|
+
transports.delete(registration);
|
|
123
|
+
if (transports.size === 0) {
|
|
124
|
+
registeredTransportsByUrl.delete(key);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Wait for every management transport targeting this app-server to release
|
|
131
|
+
* its control connection. The registry is process-wide so a session created
|
|
132
|
+
* by a different `LettaAgentClient` instance can still take the server's
|
|
133
|
+
* single control slot safely.
|
|
134
|
+
*/
|
|
135
|
+
export async function releaseAppServerManagementConnections(
|
|
136
|
+
url: string,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
const key = appServerUrlKey(url);
|
|
139
|
+
while (true) {
|
|
140
|
+
const transports = [...(registeredTransportsByUrl.get(key) ?? [])];
|
|
141
|
+
if (transports.length === 0) return;
|
|
142
|
+
await Promise.all(
|
|
143
|
+
transports.map(({ transport }) => transport.releaseIdleConnection()),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function ensureResponse<T>(
|
|
149
|
+
response: { success: boolean; error?: string },
|
|
150
|
+
value: T | null | undefined,
|
|
151
|
+
fallback: string,
|
|
152
|
+
): T {
|
|
153
|
+
if (!response.success || value == null) {
|
|
154
|
+
throw new Error(response.error ?? fallback);
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function modelEntries(value: unknown): LettaCodeModelEntry[] {
|
|
160
|
+
if (!Array.isArray(value)) return [];
|
|
161
|
+
return value.flatMap((entry) => {
|
|
162
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
|
|
163
|
+
const record = entry as Record<string, unknown>;
|
|
164
|
+
if (
|
|
165
|
+
typeof record.id !== "string" ||
|
|
166
|
+
typeof record.handle !== "string" ||
|
|
167
|
+
typeof record.label !== "string" ||
|
|
168
|
+
typeof record.description !== "string"
|
|
169
|
+
) {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
return [{
|
|
173
|
+
...record,
|
|
174
|
+
id: record.id,
|
|
175
|
+
handle: record.handle,
|
|
176
|
+
label: record.label,
|
|
177
|
+
description: record.description,
|
|
178
|
+
}];
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function stringRecord(value: unknown): Record<string, string> | undefined {
|
|
183
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
const entries = Object.entries(value as Record<string, unknown>).filter(
|
|
187
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
188
|
+
);
|
|
189
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Management transport that speaks the app-server control protocol.
|
|
194
|
+
*
|
|
195
|
+
* Connection lifecycle: the Letta Code app-server currently accepts a single
|
|
196
|
+
* control client at a time — while one control socket is attached, additional
|
|
197
|
+
* control sockets are rejected with close code 1008
|
|
198
|
+
* `"control channel already connected"` (see letta-ai/letta-code
|
|
199
|
+
* `src/websocket/app-server.ts`). Sessions (`AppServerSession`) need that same
|
|
200
|
+
* control slot, so a management transport that holds an idle connection
|
|
201
|
+
* starves any later `resumeSession()`/`createSession()` from the same process
|
|
202
|
+
* (live-reproduced in the letta-mobile reference app, SDK-FEEDBACK.md #00).
|
|
203
|
+
*
|
|
204
|
+
* To stay out of the way, this transport pools a single lazily-connected
|
|
205
|
+
* client while requests are in flight (bursts share one connection and one
|
|
206
|
+
* request-id counter, so responses correlate correctly), then releases the
|
|
207
|
+
* connection shortly after it goes idle ({@link DEFAULT_IDLE_LINGER_MS}) and
|
|
208
|
+
* reconnects lazily on the next request. Before a session connects, all
|
|
209
|
+
* management transports registered for the same app-server URL relinquish
|
|
210
|
+
* their connections; the handoff waits for in-flight work and for the control
|
|
211
|
+
* socket's close event. The inverse contention — a management request issued
|
|
212
|
+
* while a session holds the control slot — cannot be solved client-side and
|
|
213
|
+
* still fails until the app-server allows multiple control clients.
|
|
214
|
+
*/
|
|
215
|
+
export class AppServerManagementTransport
|
|
216
|
+
implements ManagementTransport
|
|
217
|
+
{
|
|
218
|
+
private connectionPromise: Promise<ActiveConnection> | null = null;
|
|
219
|
+
private releasePromise: Promise<void> | null = null;
|
|
220
|
+
private closingConnections = new Set<Promise<void>>();
|
|
221
|
+
private inFlightRequests = 0;
|
|
222
|
+
private idleWaiters = new Set<() => void>();
|
|
223
|
+
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
224
|
+
private readonly idleLingerMs: number;
|
|
225
|
+
|
|
226
|
+
constructor(private readonly options: AppServerManagementOptions) {
|
|
227
|
+
this.idleLingerMs = options.idleLingerMs ?? DEFAULT_IDLE_LINGER_MS;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async listAgents(query: ManagementQuery): Promise<LettaAgent[]> {
|
|
231
|
+
const response = await this.request<AgentListResponse>(
|
|
232
|
+
"agent_list",
|
|
233
|
+
{ query },
|
|
234
|
+
"agent_list_response",
|
|
235
|
+
);
|
|
236
|
+
if (!response.success) {
|
|
237
|
+
throw new Error(response.error ?? "Failed to list agents.");
|
|
238
|
+
}
|
|
239
|
+
return response.agents;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async retrieveAgent(agentId: string): Promise<LettaAgent> {
|
|
243
|
+
const response = await this.request<AgentResponse>(
|
|
244
|
+
"agent_retrieve",
|
|
245
|
+
{ agent_id: agentId },
|
|
246
|
+
"agent_retrieve_response",
|
|
247
|
+
);
|
|
248
|
+
return ensureResponse(
|
|
249
|
+
response,
|
|
250
|
+
response.agent,
|
|
251
|
+
`Failed to retrieve agent ${agentId}.`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async updateAgent(
|
|
256
|
+
agentId: string,
|
|
257
|
+
body: Record<string, unknown>,
|
|
258
|
+
): Promise<LettaAgent> {
|
|
259
|
+
const response = await this.request<AgentResponse>(
|
|
260
|
+
"agent_update",
|
|
261
|
+
{ agent_id: agentId, body },
|
|
262
|
+
"agent_update_response",
|
|
263
|
+
);
|
|
264
|
+
return ensureResponse(
|
|
265
|
+
response,
|
|
266
|
+
response.agent,
|
|
267
|
+
`Failed to update agent ${agentId}.`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async deleteAgent(agentId: string): Promise<void> {
|
|
272
|
+
const response = await this.request<ManagementResponse>(
|
|
273
|
+
"agent_delete",
|
|
274
|
+
{ agent_id: agentId },
|
|
275
|
+
"agent_delete_response",
|
|
276
|
+
);
|
|
277
|
+
if (!response.success) {
|
|
278
|
+
throw new Error(response.error ?? `Failed to delete agent ${agentId}.`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async listModels(): Promise<ListModelsResult> {
|
|
283
|
+
// A bare list_models command (no runtime scope) is answered on the
|
|
284
|
+
// control channel, so no session or conversation is required.
|
|
285
|
+
const response = await this.request<ListModelsResponse>(
|
|
286
|
+
"list_models",
|
|
287
|
+
{},
|
|
288
|
+
"list_models_response",
|
|
289
|
+
);
|
|
290
|
+
if (!response.success) {
|
|
291
|
+
throw new Error(response.error ?? "Failed to list models.");
|
|
292
|
+
}
|
|
293
|
+
const result: ListModelsResult = {
|
|
294
|
+
entries: modelEntries(response.entries),
|
|
295
|
+
};
|
|
296
|
+
if (response.available_handles === null) {
|
|
297
|
+
result.availableHandles = null;
|
|
298
|
+
} else if (Array.isArray(response.available_handles)) {
|
|
299
|
+
result.availableHandles = response.available_handles.filter(
|
|
300
|
+
(handle): handle is string => typeof handle === "string",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const aliases = stringRecord(response.byok_provider_aliases);
|
|
304
|
+
if (aliases) result.byokProviderAliases = aliases;
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async listConversations(
|
|
309
|
+
query: ManagementQuery,
|
|
310
|
+
): Promise<LettaConversation[]> {
|
|
311
|
+
const response = await this.request<ConversationListResponse>(
|
|
312
|
+
"conversation_list",
|
|
313
|
+
{ query },
|
|
314
|
+
"conversation_list_response",
|
|
315
|
+
);
|
|
316
|
+
if (!response.success) {
|
|
317
|
+
throw new Error(response.error ?? "Failed to list conversations.");
|
|
318
|
+
}
|
|
319
|
+
return response.conversations;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async retrieveConversation(
|
|
323
|
+
conversationId: string,
|
|
324
|
+
): Promise<LettaConversation> {
|
|
325
|
+
const response = await this.request<ConversationResponse>(
|
|
326
|
+
"conversation_retrieve",
|
|
327
|
+
{ conversation_id: conversationId },
|
|
328
|
+
"conversation_retrieve_response",
|
|
329
|
+
);
|
|
330
|
+
return ensureResponse(
|
|
331
|
+
response,
|
|
332
|
+
response.conversation,
|
|
333
|
+
`Failed to retrieve conversation ${conversationId}.`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async createConversation(
|
|
338
|
+
body: Record<string, unknown>,
|
|
339
|
+
): Promise<LettaConversation> {
|
|
340
|
+
const response = await this.request<ConversationResponse>(
|
|
341
|
+
"conversation_create",
|
|
342
|
+
{ body },
|
|
343
|
+
"conversation_create_response",
|
|
344
|
+
);
|
|
345
|
+
return ensureResponse(
|
|
346
|
+
response,
|
|
347
|
+
response.conversation,
|
|
348
|
+
"Failed to create conversation.",
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async updateConversation(
|
|
353
|
+
conversationId: string,
|
|
354
|
+
body: Record<string, unknown>,
|
|
355
|
+
): Promise<LettaConversation> {
|
|
356
|
+
const response = await this.request<ConversationResponse>(
|
|
357
|
+
"conversation_update",
|
|
358
|
+
{ conversation_id: conversationId, body },
|
|
359
|
+
"conversation_update_response",
|
|
360
|
+
);
|
|
361
|
+
return ensureResponse(
|
|
362
|
+
response,
|
|
363
|
+
response.conversation,
|
|
364
|
+
`Failed to update conversation ${conversationId}.`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async listConversationMessages(
|
|
369
|
+
conversationId: string,
|
|
370
|
+
query: ManagementQuery,
|
|
371
|
+
): Promise<ConversationMessagesResult> {
|
|
372
|
+
const response =
|
|
373
|
+
await this.request<ConversationMessagesResponse>(
|
|
374
|
+
"conversation_messages_list",
|
|
375
|
+
{ conversation_id: conversationId, query },
|
|
376
|
+
"conversation_messages_list_response",
|
|
377
|
+
);
|
|
378
|
+
if (!response.success) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
response.error ??
|
|
381
|
+
`Failed to list messages for conversation ${conversationId}.`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
return { messages: response.messages };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Release the pooled control connection instead of waiting out the idle
|
|
389
|
+
* linger. If requests are in flight, wait for them to settle first.
|
|
390
|
+
*
|
|
391
|
+
* The app-server accepts a single control client (see the class docs), so
|
|
392
|
+
* This is called before opening a session to hand the control slot over
|
|
393
|
+
* without a linger-sized or socket-close race window.
|
|
394
|
+
*/
|
|
395
|
+
releaseIdleConnection(): Promise<void> {
|
|
396
|
+
this.clearIdleTimer();
|
|
397
|
+
if (this.releasePromise) return this.releasePromise;
|
|
398
|
+
|
|
399
|
+
const release = this.releaseConnectionWhenIdle();
|
|
400
|
+
this.releasePromise = release;
|
|
401
|
+
void release.then(
|
|
402
|
+
() => {
|
|
403
|
+
if (this.releasePromise === release) {
|
|
404
|
+
this.releasePromise = null;
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
() => {
|
|
408
|
+
if (this.releasePromise === release) {
|
|
409
|
+
this.releasePromise = null;
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
return release;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private async request<TResponse extends { type: string }>(
|
|
417
|
+
type: string,
|
|
418
|
+
body: Record<string, unknown>,
|
|
419
|
+
responseType: string,
|
|
420
|
+
): Promise<TResponse> {
|
|
421
|
+
if (this.releasePromise) {
|
|
422
|
+
await this.releasePromise;
|
|
423
|
+
}
|
|
424
|
+
if (this.closingConnections.size > 0) {
|
|
425
|
+
await Promise.all([...this.closingConnections]);
|
|
426
|
+
}
|
|
427
|
+
this.clearIdleTimer();
|
|
428
|
+
this.inFlightRequests += 1;
|
|
429
|
+
try {
|
|
430
|
+
// Concurrent requests share the pooled connection (queueing behind the
|
|
431
|
+
// same connect promise) and its request-id counter, so correlation is
|
|
432
|
+
// stable across a burst and across reconnects: a fresh connection gets a
|
|
433
|
+
// fresh client whose pending map starts empty.
|
|
434
|
+
const { client } = await this.acquireConnection();
|
|
435
|
+
const command = {
|
|
436
|
+
type,
|
|
437
|
+
request_id: client.nextRequestId(type),
|
|
438
|
+
...body,
|
|
439
|
+
} as AppServerRequestCommandWithId;
|
|
440
|
+
const response = await client.request(command, {
|
|
441
|
+
predicate: (message): message is typeof message =>
|
|
442
|
+
message.type === responseType,
|
|
443
|
+
});
|
|
444
|
+
return response as unknown as TResponse;
|
|
445
|
+
} finally {
|
|
446
|
+
this.inFlightRequests -= 1;
|
|
447
|
+
if (this.inFlightRequests === 0) {
|
|
448
|
+
const waiters = [...this.idleWaiters];
|
|
449
|
+
this.idleWaiters.clear();
|
|
450
|
+
for (const resolve of waiters) resolve();
|
|
451
|
+
if (!this.releasePromise) {
|
|
452
|
+
this.scheduleIdleRelease();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private async releaseConnectionWhenIdle(): Promise<void> {
|
|
459
|
+
if (this.inFlightRequests > 0) {
|
|
460
|
+
await new Promise<void>((resolve) => {
|
|
461
|
+
this.idleWaiters.add(resolve);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
this.clearIdleTimer();
|
|
466
|
+
const promise = this.connectionPromise;
|
|
467
|
+
try {
|
|
468
|
+
if (promise) {
|
|
469
|
+
this.connectionPromise = null;
|
|
470
|
+
await this.trackClosingConnection(await promise);
|
|
471
|
+
}
|
|
472
|
+
if (this.closingConnections.size > 0) {
|
|
473
|
+
await Promise.all([...this.closingConnections]);
|
|
474
|
+
}
|
|
475
|
+
} catch {
|
|
476
|
+
// A failed connect already closes its partially-created resources.
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private acquireConnection(): Promise<ActiveConnection> {
|
|
481
|
+
if (this.connectionPromise) return this.connectionPromise;
|
|
482
|
+
const promise: Promise<ActiveConnection> = this.openConnection().then(
|
|
483
|
+
(connection) => {
|
|
484
|
+
// Unexpected disconnects (explicit closes do not notify) drop the
|
|
485
|
+
// pooled connection so the next request reconnects lazily.
|
|
486
|
+
connection.detachDisconnect = connection.client.onDisconnect(() => {
|
|
487
|
+
this.discardConnection(promise, connection);
|
|
488
|
+
});
|
|
489
|
+
return connection;
|
|
490
|
+
},
|
|
491
|
+
(error) => {
|
|
492
|
+
if (this.connectionPromise === promise) {
|
|
493
|
+
this.connectionPromise = null;
|
|
494
|
+
}
|
|
495
|
+
throw error;
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
this.connectionPromise = promise;
|
|
499
|
+
return promise;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private async openConnection(): Promise<ActiveConnection> {
|
|
503
|
+
const ownedConnection = this.options.url
|
|
504
|
+
? null
|
|
505
|
+
: ((await this.options.connect?.()) ?? null);
|
|
506
|
+
const url = this.options.url ?? ownedConnection?.url;
|
|
507
|
+
if (!url) {
|
|
508
|
+
throw new Error("App-server management requires a url or connect hook.");
|
|
509
|
+
}
|
|
510
|
+
const unregister = registerManagementTransport(url, this);
|
|
511
|
+
|
|
512
|
+
let client: AppServerClient | null = null;
|
|
513
|
+
try {
|
|
514
|
+
client = applyUniqueRequestIds(createAppServerClient({
|
|
515
|
+
url,
|
|
516
|
+
...(this.options.authToken !== undefined
|
|
517
|
+
? { authToken: this.options.authToken }
|
|
518
|
+
: {}),
|
|
519
|
+
...(this.options.WebSocket
|
|
520
|
+
? {
|
|
521
|
+
WebSocket:
|
|
522
|
+
this.options.WebSocket as AppServerSocketConstructor,
|
|
523
|
+
}
|
|
524
|
+
: {}),
|
|
525
|
+
...(this.options.requestTimeoutMs !== undefined
|
|
526
|
+
? { requestTimeoutMs: this.options.requestTimeoutMs }
|
|
527
|
+
: {}),
|
|
528
|
+
}));
|
|
529
|
+
await client.connect();
|
|
530
|
+
} catch (error) {
|
|
531
|
+
try {
|
|
532
|
+
if (client) {
|
|
533
|
+
const controlClosed = waitForSocketClose(client.control);
|
|
534
|
+
client.close();
|
|
535
|
+
ownedConnection?.close();
|
|
536
|
+
await controlClosed;
|
|
537
|
+
} else {
|
|
538
|
+
ownedConnection?.close();
|
|
539
|
+
}
|
|
540
|
+
} catch {
|
|
541
|
+
// Preserve the original connect error after best-effort cleanup.
|
|
542
|
+
} finally {
|
|
543
|
+
unregister();
|
|
544
|
+
}
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
client,
|
|
549
|
+
ownedConnection,
|
|
550
|
+
detachDisconnect: () => {},
|
|
551
|
+
unregister,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private discardConnection(
|
|
556
|
+
promise: Promise<ActiveConnection>,
|
|
557
|
+
connection: ActiveConnection,
|
|
558
|
+
): void {
|
|
559
|
+
if (this.connectionPromise === promise) {
|
|
560
|
+
this.connectionPromise = null;
|
|
561
|
+
this.clearIdleTimer();
|
|
562
|
+
}
|
|
563
|
+
void this.trackClosingConnection(connection);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private trackClosingConnection(
|
|
567
|
+
connection: ActiveConnection,
|
|
568
|
+
): Promise<void> {
|
|
569
|
+
const closing = closeConnection(connection);
|
|
570
|
+
this.closingConnections.add(closing);
|
|
571
|
+
void closing.then(
|
|
572
|
+
() => this.closingConnections.delete(closing),
|
|
573
|
+
() => this.closingConnections.delete(closing),
|
|
574
|
+
);
|
|
575
|
+
return closing;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private scheduleIdleRelease(): void {
|
|
579
|
+
if (!this.connectionPromise) return;
|
|
580
|
+
this.clearIdleTimer();
|
|
581
|
+
const timer = setTimeout(() => {
|
|
582
|
+
this.idleTimer = null;
|
|
583
|
+
this.releaseIdleConnection();
|
|
584
|
+
}, this.idleLingerMs);
|
|
585
|
+
this.idleTimer = timer;
|
|
586
|
+
// Do not keep a Node event loop alive just for the linger.
|
|
587
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private clearIdleTimer(): void {
|
|
591
|
+
if (this.idleTimer === null) return;
|
|
592
|
+
clearTimeout(this.idleTimer);
|
|
593
|
+
this.idleTimer = null;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function closeConnection(connection: ActiveConnection): Promise<void> {
|
|
598
|
+
connection.detachDisconnect();
|
|
599
|
+
const controlClosed = waitForSocketClose(connection.client.control);
|
|
600
|
+
try {
|
|
601
|
+
connection.client.close();
|
|
602
|
+
connection.ownedConnection?.close();
|
|
603
|
+
await controlClosed;
|
|
604
|
+
} finally {
|
|
605
|
+
connection.unregister();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function waitForSocketClose(socket: AppServerSocketLike): Promise<void> {
|
|
610
|
+
if (socket.readyState === 3) return Promise.resolve();
|
|
611
|
+
|
|
612
|
+
return new Promise((resolve) => {
|
|
613
|
+
let settled = false;
|
|
614
|
+
let detach = () => {};
|
|
615
|
+
const finish = () => {
|
|
616
|
+
if (settled) return;
|
|
617
|
+
settled = true;
|
|
618
|
+
clearTimeout(timeout);
|
|
619
|
+
detach();
|
|
620
|
+
resolve();
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
if (socket.addEventListener) {
|
|
624
|
+
socket.addEventListener("close", finish);
|
|
625
|
+
detach = () => socket.removeEventListener?.("close", finish);
|
|
626
|
+
} else if (socket.once) {
|
|
627
|
+
socket.once("close", finish);
|
|
628
|
+
detach = () => socket.off?.("close", finish);
|
|
629
|
+
} else if (socket.on) {
|
|
630
|
+
socket.on("close", finish);
|
|
631
|
+
detach = () => socket.off?.("close", finish);
|
|
632
|
+
} else {
|
|
633
|
+
resolve();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const timeout = setTimeout(finish, 1_000);
|
|
638
|
+
(timeout as unknown as { unref?: () => void }).unref?.();
|
|
639
|
+
if (socket.readyState === 3) finish();
|
|
640
|
+
});
|
|
641
|
+
}
|