@sequenceholdings/artifact-studio 0.7.1 → 0.8.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.
Files changed (44) hide show
  1. package/README.md +87 -3
  2. package/dist/build.js +4 -2
  3. package/dist/gateway/authorization-cache.js +1 -1
  4. package/dist/gateway/control.d.ts +13 -1
  5. package/dist/gateway/control.js +3 -0
  6. package/dist/gateway/datadog-startup.d.ts +13 -0
  7. package/dist/gateway/datadog-startup.js +51 -0
  8. package/dist/gateway/hosted-proxy.d.ts +2 -0
  9. package/dist/gateway/hosted-proxy.js +9 -11
  10. package/dist/gateway/ingress-handler.js +2 -0
  11. package/dist/gateway/server.d.ts +1 -1
  12. package/dist/gateway/server.js +51 -20
  13. package/dist/gateway/session.d.ts +14 -0
  14. package/dist/gateway/session.js +31 -2
  15. package/dist/gateway-main.js +5 -3
  16. package/dist/manifest-next.d.ts +9 -0
  17. package/dist/manifest-next.js +5 -0
  18. package/dist/manifest.d.ts +3 -0
  19. package/dist/server/agent-responses.d.ts +425 -0
  20. package/dist/server/agent-responses.js +154 -0
  21. package/dist/server/broker-contract.d.ts +17 -0
  22. package/dist/server/broker-contract.js +17 -2
  23. package/dist/server/client.d.ts +5 -0
  24. package/dist/server/client.js +12 -2
  25. package/dist/server/groups.d.ts +32 -0
  26. package/dist/server/groups.js +28 -0
  27. package/dist/server/processes.d.ts +11 -0
  28. package/dist/server/request-authentication.d.ts +42 -0
  29. package/dist/server/request-authentication.js +93 -0
  30. package/dist/server/request-context.d.ts +1 -0
  31. package/dist/server/request-context.js +1 -0
  32. package/dist/server.d.ts +4 -3
  33. package/dist/server.js +2 -2
  34. package/dist/source-boundary.js +13 -5
  35. package/dist/templates/react-vite/package.json +1 -1
  36. package/dist/tunnel/broker.d.ts +14 -1
  37. package/dist/tunnel/broker.js +94 -10
  38. package/dist/tunnel/client.js +6 -0
  39. package/dist/tunnel/redis-types.d.ts +5 -1
  40. package/dist/tunnel/redis.d.ts +12 -0
  41. package/dist/tunnel/redis.js +102 -4
  42. package/dist/tunnel/transport.d.ts +3 -0
  43. package/package.json +2 -2
  44. package/templates/react-vite/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { WebSocketServer } from 'ws';
2
2
  import { TunnelPeer, tunnelLimits } from './peer.js';
3
3
  import { assertSafeRequest, encodeTunnelFrame, isSharedCacheDirective, normalizePublicOrigin, sanitizeRequestHeaders, sanitizeResponseHeaders, SHARED_CACHE_REFUSAL, TUNNEL_PROTOCOL } from './protocol.js';
4
+ import { appendArtifactAuthenticationHeader } from '../server/request-authentication.js';
4
5
  import { forwardWebSocketMessages, MessageBudget, permittedCloseCode, sendWebSocket, WebSocketMessage, writeChunk } from './io.js';
5
6
  import { declaredLength, isExternalFailure } from './external.js';
6
7
  const UPGRADE_REASONS = { 403: 'Forbidden', 429: 'Too Many Requests', 503: 'Service Unavailable' };
@@ -65,6 +66,10 @@ export class ArtifactTunnelBroker {
65
66
  pendingConnections = new Map();
66
67
  ownerTimers = new Set();
67
68
  closing = false;
69
+ draining = false;
70
+ drainWaiters = new Set();
71
+ pendingReceipts = new Set();
72
+ relayClosed;
68
73
  constructor(options) {
69
74
  this.options = options;
70
75
  if (!options.nodeId || !options.routing || !options.authorizeTunnel || !options.authorizeRequest || !options.validateLease) {
@@ -80,7 +85,7 @@ export class ArtifactTunnelBroker {
80
85
  let lease = null;
81
86
  let claimed = false;
82
87
  try {
83
- if (this.closing) {
88
+ if (this.draining || this.closing) {
84
89
  rejectUpgrade(socket, 503);
85
90
  return;
86
91
  }
@@ -99,19 +104,28 @@ export class ArtifactTunnelBroker {
99
104
  rejectUpgrade(socket, 403);
100
105
  return;
101
106
  }
107
+ if (this.draining || this.closing) {
108
+ rejectUpgrade(socket, 503);
109
+ return;
110
+ }
102
111
  claimed = await this.options.routing.claim(lease, this.options.nodeId);
103
112
  const owner = await this.options.routing.lookup(lease.sessionId);
104
113
  if (!claimed || owner?.nodeId !== this.options.nodeId || owner.epoch !== lease.epoch || socket.destroyed) {
105
114
  rejectUpgrade(socket, 403);
106
115
  return;
107
116
  }
117
+ if (this.draining || this.closing) {
118
+ await this.options.routing.release(lease.sessionId, lease.epoch, this.options.nodeId).catch(() => undefined);
119
+ rejectUpgrade(socket, 503);
120
+ return;
121
+ }
108
122
  const authorizedLease = lease;
109
123
  this.tunnels.handleUpgrade(request, socket, head, (websocket) => {
110
124
  const ready = () => websocket.send(encodeTunnelFrame({ type: 'ready', id: authorizedLease.sessionId, epoch: authorizedLease.epoch }));
111
125
  websocket.once('close', () => this.options.onTunnelClosed?.(authorizedLease));
112
126
  if (this.options.relay) {
113
127
  void this.options.relay.attach(authorizedLease, websocket).then(() => {
114
- if (this.closing) {
128
+ if (this.draining || this.closing) {
115
129
  websocket.terminate();
116
130
  return;
117
131
  }
@@ -283,7 +297,15 @@ export class ArtifactTunnelBroker {
283
297
  async handleWebSocketUpgrade(request, socket, head, sessionId) {
284
298
  socket.pause();
285
299
  try {
300
+ if (this.draining || this.closing) {
301
+ rejectUpgrade(socket, 503);
302
+ return;
303
+ }
286
304
  const authorized = await this.authorize(request, sessionId);
305
+ if (this.draining || this.closing) {
306
+ rejectUpgrade(socket, 503);
307
+ return;
308
+ }
287
309
  if (!authorized) {
288
310
  rejectUpgrade(socket, 403);
289
311
  return;
@@ -317,12 +339,9 @@ export class ArtifactTunnelBroker {
317
339
  return null;
318
340
  if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method ?? 'GET') && request.headers.origin !== origin)
319
341
  return null;
320
- const headers = sanitizeRequestHeaders(request.headers, origin);
321
- if (authorization.requestContext) {
322
- if (/[\r\n]/.test(authorization.requestContext))
323
- return null;
324
- headers['x-sequence-artifact-context'] = authorization.requestContext;
325
- }
342
+ const headers = this.requestHeaders({ request, origin, authorization });
343
+ if (!headers)
344
+ return null;
326
345
  let connection = this.connections.get(sessionId);
327
346
  if (this.options.relay && this.options.resolveLease) {
328
347
  const lease = await this.options.resolveLease(sessionId);
@@ -341,6 +360,21 @@ export class ArtifactTunnelBroker {
341
360
  return null;
342
361
  return { connection, headers };
343
362
  }
363
+ requestHeaders({ request, origin, authorization }) {
364
+ const headers = sanitizeRequestHeaders(request.headers, origin);
365
+ if (authorization.requestContext) {
366
+ if (/[\r\n]/.test(authorization.requestContext))
367
+ return null;
368
+ headers['x-sequence-artifact-context'] = authorization.requestContext;
369
+ }
370
+ try {
371
+ appendArtifactAuthenticationHeader({ headers, token: authorization.requestAuthentication, contextToken: authorization.requestContext });
372
+ }
373
+ catch {
374
+ return null;
375
+ }
376
+ return headers;
377
+ }
344
378
  /**
345
379
  * The live tunnel for an external request: the session's current lease, for the granted project
346
380
  * and environment, still valid. Null otherwise. No browser owner is compared: Atlas's grant
@@ -459,7 +493,22 @@ export class ArtifactTunnelBroker {
459
493
  return;
460
494
  }
461
495
  if (frame.type === 'end') {
496
+ if (!this.options.relay) {
497
+ this.finish(connection, frame.id);
498
+ return;
499
+ }
500
+ // The existing cancel frame releases an already-completed stream. Redis owners that
501
+ // negotiated receipts retain the route until this proves the final response was consumed.
502
+ const receipt = connection.peer.send({ type: 'cancel', id: frame.id }).catch(() => connection.peer.close());
503
+ this.pendingReceipts.add(receipt);
462
504
  this.finish(connection, frame.id);
505
+ try {
506
+ await receipt;
507
+ }
508
+ finally {
509
+ this.pendingReceipts.delete(receipt);
510
+ this.notifyDrained();
511
+ }
463
512
  return;
464
513
  }
465
514
  if (frame.type === 'cancel') {
@@ -575,6 +624,7 @@ export class ArtifactTunnelBroker {
575
624
  stream.socket.close(status ? 1011 : 1000);
576
625
  else if (stream.upgrade)
577
626
  rejectUpgrade(stream.upgrade.socket, status ?? 503);
627
+ this.notifyDrained();
578
628
  }
579
629
  abort(connection, id, status = 502) {
580
630
  if (!connection.streams.has(id))
@@ -582,20 +632,54 @@ export class ArtifactTunnelBroker {
582
632
  this.finish(connection, id, status);
583
633
  void connection.peer.send({ type: 'cancel', id }).catch(() => undefined);
584
634
  }
585
- /** Immediate revocation and graceful service shutdown hook. */
635
+ /** Stop new upgrades and remote-owner admission while admitted HTTP handlers finish. */
636
+ beginDrain() {
637
+ if (this.draining)
638
+ return;
639
+ this.draining = true;
640
+ this.options.relay?.beginDrain?.();
641
+ for (const connection of this.connections.values()) {
642
+ for (const [id, stream] of connection.streams)
643
+ if (stream.upgrade)
644
+ this.abort(connection, id, 503);
645
+ }
646
+ }
647
+ notifyDrained() {
648
+ if (!this.drainWaiters.size || this.pendingReceipts.size)
649
+ return;
650
+ for (const connection of this.connections.values())
651
+ if (connection.streams.size)
652
+ return;
653
+ for (const resolve of this.drainWaiters)
654
+ resolve();
655
+ this.drainWaiters.clear();
656
+ }
657
+ /** The gateway first joins admitted handlers and response accounting. Its outer deadline
658
+ * bounds finite streams; lease validation and revocation remain active until cleanup. */
659
+ async drain() {
660
+ this.beginDrain();
661
+ await new Promise((resolve) => { this.drainWaiters.add(resolve); this.notifyDrained(); });
662
+ await this.options.relay?.drain?.();
663
+ this.close();
664
+ await this.relayClosed;
665
+ }
666
+ /** Immediate security revocation, never a graceful drain. */
586
667
  disconnect(sessionId) {
587
668
  this.connections.get(sessionId)?.peer.close();
588
669
  this.options.relay?.disconnect(sessionId);
589
670
  }
590
671
  close() {
672
+ if (this.closing)
673
+ return;
591
674
  this.closing = true;
675
+ this.draining = true;
592
676
  for (const timer of this.ownerTimers)
593
677
  clearInterval(timer);
594
678
  this.ownerTimers.clear();
595
679
  for (const connection of this.connections.values())
596
680
  connection.peer.close();
597
681
  this.tunnels.close();
598
- void this.options.relay?.close().catch(() => undefined);
682
+ this.relayClosed = this.options.relay?.close().catch(() => undefined);
599
683
  }
600
684
  }
601
685
  /** For one-process local development/tests only. Production must provide a shared
@@ -6,6 +6,7 @@ import { forwardWebSocketMessages, MessageBudget, permittedCloseCode, sendWebSoc
6
6
  import { declaredLength } from './external.js';
7
7
  import { isLoopbackHostname } from '../loopback.js';
8
8
  import { ARTIFACT_EXTERNAL_CONTEXT_KID_PREFIX } from '../server/internal/signed-token.js';
9
+ import { ARTIFACT_REQUEST_AUTHENTICATION_HEADER, assertArtifactRequestHeaderBudget, validatedArtifactAuthenticationHeader } from '../server/request-authentication.js';
9
10
  /** One connection/epoch only. The caller obtains a fresh lease to reconnect;
10
11
  * no HTTP request or mutation is ever retained or replayed. */
11
12
  export async function connectLocalTunnel(options) {
@@ -59,6 +60,11 @@ export async function connectLocalTunnel(options) {
59
60
  const context = frame.headers['x-sequence-artifact-context'];
60
61
  if (typeof context === 'string')
61
62
  headers['x-sequence-artifact-context'] = context;
63
+ // This frame came from the authenticated gateway connection, not a browser header map.
64
+ const authentication = validatedArtifactAuthenticationHeader({ token: frame.headers[ARTIFACT_REQUEST_AUTHENTICATION_HEADER], contextToken: context });
65
+ if (authentication !== undefined)
66
+ headers[ARTIFACT_REQUEST_AUTHENTICATION_HEADER] = authentication;
67
+ assertArtifactRequestHeaderBudget({ headers });
62
68
  const timer = setTimeout(() => { void fail(frame.id); }, limits.requestTimeoutMs);
63
69
  timer.unref();
64
70
  const forward = { message: new WebSocketMessage(limits.maxWebSocketMessageBytes, messageBudget), bytes: 0, timer };
@@ -40,12 +40,16 @@ export type Remote = {
40
40
  nodeId: string;
41
41
  connection: string;
42
42
  lastSeen: number;
43
+ receipts?: boolean;
43
44
  };
44
45
  export type Attached = {
45
46
  lease: TunnelLease;
46
47
  socket: TunnelSocket;
47
48
  remotes: Map<string, Remote>;
48
- streams: Map<string, Remote>;
49
+ streams: Map<string, Remote & {
50
+ websocket: boolean;
51
+ ended: boolean;
52
+ }>;
49
53
  timer: NodeJS.Timeout;
50
54
  queuedBytes: number;
51
55
  receiving: Promise<void>;
@@ -12,6 +12,10 @@ export declare class RedisTunnelRelay implements TunnelRelay {
12
12
  private readonly options;
13
13
  private started;
14
14
  private closed;
15
+ private draining;
16
+ private drainSealed;
17
+ private drainWork;
18
+ private readonly drainWaiters;
15
19
  private readonly attached;
16
20
  private readonly connected;
17
21
  private readonly frames;
@@ -52,5 +56,13 @@ export declare class RedisTunnelRelay implements TunnelRelay {
52
56
  private closeOwner;
53
57
  private closeConnection;
54
58
  disconnect(sessionId: string): void;
59
+ /** Reject new remote requests, but allow same-node work already admitted by the HTTP
60
+ * gateway until drain() seals it. Security checks and ownership timers stay live. */
61
+ beginDrain(): void;
62
+ private notifyDrained;
63
+ /** Updated requesters acknowledge consumed final responses with cancel. Keep those bounded
64
+ * routes even before drain starts to cover an end-vs-shutdown race. Legacy requesters retain
65
+ * the previous publish-only completion behavior; they never acquire a new receipt wait. */
66
+ drain(): Promise<void>;
55
67
  close(): Promise<void>;
56
68
  }
@@ -4,6 +4,9 @@ import { redisIdentifier as identifier, redisNamespaceSchema as namespaceSchema,
4
4
  import { RelaySocket } from './redis-socket.js';
5
5
  import { deriveRelayFrameKey, RelayFrameAuthenticator } from './redis-auth.js';
6
6
  export { RedisTunnelOwnership } from './redis-ownership.js';
7
+ // Broker stream IDs are UUIDs. This authenticated, otherwise-unused ACK is ignored by old
8
+ // owners and never forwarded to the developer. It negotiates cancel-as-completion-receipt.
9
+ const DRAIN_CAPABILITY_ID = 'relay-drain-v1';
7
10
  /** Internal Redis channels contain authorized application traffic. Deploy them
8
11
  * on the environment's private, authenticated Redis and disable command replay.
9
12
  * Every message is authenticated with a gateway-only key: other Redis clients
@@ -12,6 +15,10 @@ export class RedisTunnelRelay {
12
15
  options;
13
16
  started = false;
14
17
  closed = false;
18
+ draining = false;
19
+ drainSealed = false;
20
+ drainWork = Promise.resolve();
21
+ drainWaiters = new Set();
15
22
  attached = new Map();
16
23
  connected = new Map();
17
24
  frames;
@@ -165,10 +172,14 @@ export class RedisTunnelRelay {
165
172
  }
166
173
  async attachOwned(input, socket) {
167
174
  this.assertStarted();
175
+ if (this.draining)
176
+ throw new Error('Tunnel relay is draining');
168
177
  const lease = await this.verifiedLease(input);
169
178
  if (!(await this.current(lease, this.options.nodeId)) || socket.readyState !== 1)
170
179
  throw new Error('Tunnel is owned by another node');
171
180
  this.assertStarted();
181
+ if (this.draining)
182
+ throw new Error('Tunnel relay is draining');
172
183
  const previous = this.attached.get(lease.sessionId);
173
184
  if (previous && previous.lease.epoch >= lease.epoch)
174
185
  throw new Error('Duplicate tunnel owner');
@@ -242,6 +253,10 @@ export class RedisTunnelRelay {
242
253
  }
243
254
  const remote = owner.remotes.get(message.connection);
244
255
  if (message.type === 'open') {
256
+ if (this.draining && (this.drainSealed || message.source !== this.options.nodeId)) {
257
+ await this.publish(message.source, this.envelope(owner.lease, message.connection, 'close', 'requester'));
258
+ return;
259
+ }
245
260
  if (remote || owner.remotes.size >= DEFAULT_TUNNEL_LIMITS.maxStreams)
246
261
  throw new Error('Duplicate or excessive tunnel connections');
247
262
  owner.remotes.set(message.connection, { nodeId: message.source, connection: message.connection, lastSeen: Date.now() });
@@ -272,6 +287,11 @@ export class RedisTunnelRelay {
272
287
  if (message.type === 'ready') {
273
288
  if (connection.socket.readyState !== 0)
274
289
  throw new Error('Duplicate tunnel ready');
290
+ // Publish before exposing the socket, preserving marker-before-request ordering even
291
+ // when this node is also the owner. Old owners ignore ACKs for unknown stream IDs.
292
+ await this.publish(connection.ownerNode, this.envelope(connection.lease, message.connection, 'frame', 'owner', JSON.stringify({ type: 'ack', id: DRAIN_CAPABILITY_ID, bytes: 1 })));
293
+ if (this.connected.get(message.connection) !== connection || connection.socket.readyState !== 0)
294
+ return;
275
295
  clearTimeout(connection.timer);
276
296
  connection.socket.readyState = 1;
277
297
  connection.resolve(connection.socket);
@@ -287,6 +307,11 @@ export class RedisTunnelRelay {
287
307
  const frame = parseTunnelFrame(raw);
288
308
  if (frame.type === 'ready' || frame.type === 'response' || frame.type === 'ws-open')
289
309
  throw new Error('Invalid requester frame direction');
310
+ if (frame.id === DRAIN_CAPABILITY_ID) {
311
+ if (frame.type === 'ack' && frame.bytes === 1)
312
+ remote.receipts = true;
313
+ return;
314
+ }
290
315
  const route = owner.streams.get(frame.id);
291
316
  if (frame.type === 'request') {
292
317
  if (route || owner.streams.size >= DEFAULT_TUNNEL_LIMITS.maxStreams) {
@@ -294,13 +319,37 @@ export class RedisTunnelRelay {
294
319
  await this.publish(remote.nodeId, this.envelope(owner.lease, remote.connection, 'close', 'requester'));
295
320
  return;
296
321
  }
297
- owner.streams.set(frame.id, remote);
322
+ const refused = this.draining && (this.drainSealed || remote.nodeId !== this.options.nodeId || frame.websocket);
323
+ if (refused && frame.websocket) {
324
+ // Upgrades cannot consume HTTP response frames. Cancel only this stream; no
325
+ // route or completion receipt is needed because it never reached the developer.
326
+ await this.publish(remote.nodeId, this.envelope(owner.lease, remote.connection, 'frame', 'requester', JSON.stringify({ type: 'cancel', id: frame.id })));
327
+ return;
328
+ }
329
+ owner.streams.set(frame.id, { ...remote, websocket: frame.websocket, ended: refused });
330
+ if (refused) {
331
+ await this.publish(remote.nodeId, this.envelope(owner.lease, remote.connection, 'frame', 'requester', JSON.stringify({ type: 'response', id: frame.id, status: 503, headers: { 'cache-control': 'no-store' } })));
332
+ await this.publish(remote.nodeId, this.envelope(owner.lease, remote.connection, 'frame', 'requester', JSON.stringify({ type: 'end', id: frame.id })));
333
+ if (!remote.receipts)
334
+ owner.streams.delete(frame.id);
335
+ this.notifyDrained();
336
+ return;
337
+ }
298
338
  }
299
339
  else if (!route || route.connection !== remote.connection || route.nodeId !== remote.nodeId)
300
340
  return;
301
- if (frame.type === 'cancel' || frame.type === 'ws-close')
302
- owner.streams.delete(frame.id);
341
+ if (route?.ended) {
342
+ if (frame.type === 'cancel' || frame.type === 'ws-close') {
343
+ owner.streams.delete(frame.id);
344
+ this.notifyDrained();
345
+ }
346
+ return;
347
+ }
303
348
  await this.writeDeveloper(owner, raw);
349
+ if (frame.type === 'cancel' || frame.type === 'ws-close') {
350
+ owner.streams.delete(frame.id);
351
+ this.notifyDrained();
352
+ }
304
353
  }
305
354
  writeDeveloper(owner, raw) {
306
355
  if (owner.stopped || owner.socket.readyState !== 1 || owner.socket.bufferedAmount + Buffer.byteLength(raw) > DEFAULT_TUNNEL_LIMITS.maxBufferedBytes) {
@@ -336,9 +385,15 @@ export class RedisTunnelRelay {
336
385
  const remote = owner.streams.get(frame.id);
337
386
  if (!remote)
338
387
  return;
388
+ if (remote.ended)
389
+ return;
390
+ const retainReceipt = frame.type === 'end' && !remote.websocket && remote.receipts;
391
+ if (retainReceipt)
392
+ remote.ended = true;
339
393
  await this.publish(remote.nodeId, this.envelope(owner.lease, remote.connection, 'frame', 'requester', raw));
340
- if (frame.type === 'end' || frame.type === 'cancel' || frame.type === 'ws-close')
394
+ if (!retainReceipt && (frame.type === 'end' || frame.type === 'cancel' || frame.type === 'ws-close'))
341
395
  owner.streams.delete(frame.id);
396
+ this.notifyDrained();
342
397
  }).catch(() => this.closeOwner(owner)).finally(() => { owner.queuedBytes -= bytes; });
343
398
  }
344
399
  closeRemote(owner, remote) {
@@ -349,6 +404,7 @@ export class RedisTunnelRelay {
349
404
  owner.streams.delete(id);
350
405
  void this.writeDeveloper(owner, JSON.stringify({ type: 'cancel', id })).catch(() => this.closeOwner(owner));
351
406
  }
407
+ this.notifyDrained();
352
408
  }
353
409
  closeOwner(owner) {
354
410
  if (owner.stopped)
@@ -362,6 +418,7 @@ export class RedisTunnelRelay {
362
418
  void this.publish(remote.nodeId, this.envelope(owner.lease, remote.connection, 'close', 'requester')).catch(() => undefined);
363
419
  owner.remotes.clear();
364
420
  owner.streams.clear();
421
+ this.notifyDrained();
365
422
  void this.options.ownership.release(owner.lease.sessionId, owner.lease.epoch, this.options.nodeId).catch(() => undefined);
366
423
  }
367
424
  closeConnection(id, connection) {
@@ -387,6 +444,47 @@ export class RedisTunnelRelay {
387
444
  return this.publish(remote.nodeId, { type: 'revoke', destination: 'owner', source: this.options.nodeId, connection: randomUUID(), sessionId, epoch: remote.epoch });
388
445
  }).catch(() => undefined);
389
446
  }
447
+ /** Reject new remote requests, but allow same-node work already admitted by the HTTP
448
+ * gateway until drain() seals it. Security checks and ownership timers stay live. */
449
+ beginDrain() {
450
+ if (this.draining)
451
+ return;
452
+ this.draining = true;
453
+ const cancellations = [];
454
+ for (const owner of this.attached.values())
455
+ for (const [id, route] of owner.streams) {
456
+ if (!route.websocket)
457
+ continue;
458
+ route.ended = true;
459
+ const frame = JSON.stringify({ type: 'cancel', id });
460
+ cancellations.push(Promise.all([
461
+ this.writeDeveloper(owner, frame),
462
+ this.publish(route.nodeId, this.envelope(owner.lease, route.connection, 'frame', 'requester', frame)),
463
+ ]).then(() => { owner.streams.delete(id); this.notifyDrained(); }).catch(() => this.closeOwner(owner)));
464
+ }
465
+ this.drainWork = Promise.all(cancellations).then(() => undefined);
466
+ }
467
+ notifyDrained() {
468
+ if (!this.drainWaiters.size)
469
+ return;
470
+ for (const owner of this.attached.values())
471
+ if (owner.streams.size)
472
+ return;
473
+ for (const resolve of this.drainWaiters)
474
+ resolve();
475
+ this.drainWaiters.clear();
476
+ }
477
+ /** Updated requesters acknowledge consumed final responses with cancel. Keep those bounded
478
+ * routes even before drain starts to cover an end-vs-shutdown race. Legacy requesters retain
479
+ * the previous publish-only completion behavior; they never acquire a new receipt wait. */
480
+ async drain() {
481
+ this.beginDrain();
482
+ this.drainSealed = true;
483
+ await this.receiving;
484
+ await this.drainWork;
485
+ await new Promise((resolve) => { this.drainWaiters.add(resolve); this.notifyDrained(); });
486
+ await this.publishing;
487
+ }
390
488
  async close() {
391
489
  if (this.closed)
392
490
  return;
@@ -13,5 +13,8 @@ export interface TunnelRelay {
13
13
  attach(lease: TunnelLease, socket: TunnelSocket): Promise<void>;
14
14
  connect(lease: TunnelLease): Promise<TunnelSocket>;
15
15
  disconnect(sessionId: string): void;
16
+ /** Optional for custom adapters; built-in Redis retains owned finite streams during drain. */
17
+ beginDrain?(): void;
18
+ drain?(): Promise<void>;
16
19
  close(): Promise<void>;
17
20
  }
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "publishConfig": {
10
10
  "access": "public"
11
11
  },
12
- "version": "0.7.1",
12
+ "version": "0.8.1",
13
13
  "description": "SDK types and CLI library for building Artifact Studio apps. Driven via `seq-studio artifact <sub>` (@sequenceholdings/studio-cli), which runs the `./cli` runCli export.",
14
14
  "type": "module",
15
15
  "exports": {
@@ -169,7 +169,7 @@
169
169
  "parse5": "7.3.0",
170
170
  "@sequenceholdings/lattice-form-renderer": "0.2.0",
171
171
  "@sequenceholdings/storage": "0.1.1",
172
- "@sequenceholdings/orm": "2.6.1"
172
+ "@sequenceholdings/orm": "2.6.2"
173
173
  },
174
174
  "devDependencies": {
175
175
  "@types/js-yaml": "^4.0.9",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {},
12
12
  "devDependencies": {
13
- "@sequenceholdings/artifact-studio": "^0.7.0",
13
+ "@sequenceholdings/artifact-studio": "^0.8.0",
14
14
  "@sequenceholdings/atlas-ui": "^3.1.1",
15
15
  "@tailwindcss/postcss": "^4.2.2",
16
16
  "@tailwindcss/vite": "^4.2.2",