@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.
- package/README.md +87 -3
- package/dist/build.js +4 -2
- package/dist/gateway/authorization-cache.js +1 -1
- package/dist/gateway/control.d.ts +13 -1
- package/dist/gateway/control.js +3 -0
- package/dist/gateway/datadog-startup.d.ts +13 -0
- package/dist/gateway/datadog-startup.js +51 -0
- package/dist/gateway/hosted-proxy.d.ts +2 -0
- package/dist/gateway/hosted-proxy.js +9 -11
- package/dist/gateway/ingress-handler.js +2 -0
- package/dist/gateway/server.d.ts +1 -1
- package/dist/gateway/server.js +51 -20
- package/dist/gateway/session.d.ts +14 -0
- package/dist/gateway/session.js +31 -2
- package/dist/gateway-main.js +5 -3
- package/dist/manifest-next.d.ts +9 -0
- package/dist/manifest-next.js +5 -0
- package/dist/manifest.d.ts +3 -0
- package/dist/server/agent-responses.d.ts +425 -0
- package/dist/server/agent-responses.js +154 -0
- package/dist/server/broker-contract.d.ts +17 -0
- package/dist/server/broker-contract.js +17 -2
- package/dist/server/client.d.ts +5 -0
- package/dist/server/client.js +12 -2
- package/dist/server/groups.d.ts +32 -0
- package/dist/server/groups.js +28 -0
- package/dist/server/processes.d.ts +11 -0
- package/dist/server/request-authentication.d.ts +42 -0
- package/dist/server/request-authentication.js +93 -0
- package/dist/server/request-context.d.ts +1 -0
- package/dist/server/request-context.js +1 -0
- package/dist/server.d.ts +4 -3
- package/dist/server.js +2 -2
- package/dist/source-boundary.js +13 -5
- package/dist/templates/react-vite/package.json +1 -1
- package/dist/tunnel/broker.d.ts +14 -1
- package/dist/tunnel/broker.js +94 -10
- package/dist/tunnel/client.js +6 -0
- package/dist/tunnel/redis-types.d.ts +5 -1
- package/dist/tunnel/redis.d.ts +12 -0
- package/dist/tunnel/redis.js +102 -4
- package/dist/tunnel/transport.d.ts +3 -0
- package/package.json +2 -2
- package/templates/react-vite/package.json +1 -1
package/dist/tunnel/broker.js
CHANGED
|
@@ -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 =
|
|
321
|
-
if (
|
|
322
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
package/dist/tunnel/client.js
CHANGED
|
@@ -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>;
|
package/dist/tunnel/redis.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/tunnel/redis.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
302
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|