@sequenceholdings/artifact-studio 0.2.6 → 0.3.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.
Files changed (181) hide show
  1. package/VERSION-PIN.md +8 -1
  2. package/dist/api.d.ts +11 -1
  3. package/dist/api.js +26 -9
  4. package/dist/build.d.ts +2 -2
  5. package/dist/build.js +4 -0
  6. package/dist/cli.d.ts +1 -0
  7. package/dist/cli.js +105 -10
  8. package/dist/dev-link.d.ts +1 -1
  9. package/dist/dev-link.js +2 -1
  10. package/dist/gateway/admission.d.ts +55 -0
  11. package/dist/gateway/admission.js +132 -0
  12. package/dist/gateway/control.d.ts +91 -0
  13. package/dist/gateway/control.js +113 -0
  14. package/dist/gateway/hosted-proxy.d.ts +36 -0
  15. package/dist/gateway/hosted-proxy.js +347 -0
  16. package/dist/gateway/release-admission.d.ts +23 -0
  17. package/dist/gateway/release-admission.js +259 -0
  18. package/dist/gateway/server.d.ts +29 -0
  19. package/dist/gateway/server.js +298 -0
  20. package/dist/gateway/session.d.ts +71 -0
  21. package/dist/gateway/session.js +143 -0
  22. package/dist/gateway/usage.d.ts +37 -0
  23. package/dist/gateway/usage.js +46 -0
  24. package/dist/gateway-main.d.ts +1 -0
  25. package/dist/gateway-main.js +84 -0
  26. package/dist/git-clone.d.ts +23 -1
  27. package/dist/git-clone.js +32 -15
  28. package/dist/manifest-next.d.ts +217 -0
  29. package/dist/manifest-next.js +120 -0
  30. package/dist/manifest-static.d.ts +76 -0
  31. package/dist/manifest-static.js +124 -0
  32. package/dist/manifest.d.ts +118 -13
  33. package/dist/manifest.js +12 -119
  34. package/dist/next/bridge.d.ts +47 -0
  35. package/dist/next/bridge.js +229 -0
  36. package/dist/next/browser-logs.d.ts +7 -0
  37. package/dist/next/browser-logs.js +51 -0
  38. package/dist/next/browser.d.ts +6 -0
  39. package/dist/next/browser.js +57 -0
  40. package/dist/next/protocol.d.ts +73 -0
  41. package/dist/next/protocol.js +56 -0
  42. package/dist/next/release.d.ts +15 -0
  43. package/dist/next/release.js +46 -0
  44. package/dist/next/requests.d.ts +15 -0
  45. package/dist/next/requests.js +59 -0
  46. package/dist/next/routes.d.ts +4 -0
  47. package/dist/next/routes.js +21 -0
  48. package/dist/next/streams.d.ts +16 -0
  49. package/dist/next/streams.js +132 -0
  50. package/dist/next/theme.d.ts +10 -0
  51. package/dist/next/theme.js +45 -0
  52. package/dist/next/transport.d.ts +15 -0
  53. package/dist/next/transport.js +13 -0
  54. package/dist/next-build.d.ts +29 -0
  55. package/dist/next-build.js +129 -0
  56. package/dist/next-cache/adapter-runtime.d.ts +51 -0
  57. package/dist/next-cache/adapter-runtime.js +73 -0
  58. package/dist/next-cache/component-handler.d.ts +3 -0
  59. package/dist/next-cache/component-handler.js +10 -0
  60. package/dist/next-cache/components.d.ts +20 -0
  61. package/dist/next-cache/components.js +113 -0
  62. package/dist/next-cache/config.d.ts +9 -0
  63. package/dist/next-cache/config.js +78 -0
  64. package/dist/next-cache/incremental-handler.d.ts +18 -0
  65. package/dist/next-cache/incremental-handler.js +17 -0
  66. package/dist/next-cache/incremental.d.ts +27 -0
  67. package/dist/next-cache/incremental.js +99 -0
  68. package/dist/next-cache/index.d.ts +18 -0
  69. package/dist/next-cache/index.js +16 -0
  70. package/dist/next-cache/memory-store.d.ts +27 -0
  71. package/dist/next-cache/memory-store.js +118 -0
  72. package/dist/next-cache/redis-scripts.d.ts +3 -0
  73. package/dist/next-cache/redis-scripts.js +85 -0
  74. package/dist/next-cache/redis-store.d.ts +18 -0
  75. package/dist/next-cache/redis-store.js +56 -0
  76. package/dist/next-cache/runtime.d.ts +29 -0
  77. package/dist/next-cache/runtime.js +63 -0
  78. package/dist/next-cache/serialization.d.ts +39 -0
  79. package/dist/next-cache/serialization.js +333 -0
  80. package/dist/next-cache/store.d.ts +54 -0
  81. package/dist/next-cache/store.js +45 -0
  82. package/dist/next-cache/valkey-transport.d.ts +52 -0
  83. package/dist/next-cache/valkey-transport.js +189 -0
  84. package/dist/next-dev-control.d.ts +48 -0
  85. package/dist/next-dev-control.js +63 -0
  86. package/dist/next-dev-process.d.ts +17 -0
  87. package/dist/next-dev-process.js +110 -0
  88. package/dist/next-dev.d.ts +12 -0
  89. package/dist/next-dev.js +267 -0
  90. package/dist/next-image-config.d.ts +9 -0
  91. package/dist/next-image-config.js +101 -0
  92. package/dist/next-project.d.ts +22 -0
  93. package/dist/next-project.js +64 -0
  94. package/dist/next-public-fonts-local.d.ts +7 -0
  95. package/dist/next-public-fonts-local.js +64 -0
  96. package/dist/next-public-fonts.d.ts +13 -0
  97. package/dist/next-public-fonts.js +49 -0
  98. package/dist/next-source-hash.d.ts +14 -0
  99. package/dist/next-source-hash.js +18 -0
  100. package/dist/next-source.d.ts +35 -0
  101. package/dist/next-source.js +155 -0
  102. package/dist/next.d.ts +9 -0
  103. package/dist/next.js +5 -0
  104. package/dist/project.d.ts +3 -0
  105. package/dist/project.js +9 -1
  106. package/dist/runtime-contracts.d.ts +229 -0
  107. package/dist/runtime-contracts.js +224 -0
  108. package/dist/sdk.d.ts +2 -2
  109. package/dist/server/broker-contract.d.ts +68 -0
  110. package/dist/server/broker-contract.js +33 -0
  111. package/dist/server/client.d.ts +67 -0
  112. package/dist/server/client.js +159 -0
  113. package/dist/server/logger.d.ts +26 -0
  114. package/dist/server/logger.js +24 -0
  115. package/dist/server/release-forms.d.ts +13 -0
  116. package/dist/server/release-forms.js +124 -0
  117. package/dist/server/request-context.d.ts +74 -0
  118. package/dist/server/request-context.js +103 -0
  119. package/dist/server/runtime-config.d.ts +53 -0
  120. package/dist/server/runtime-config.js +64 -0
  121. package/dist/server.d.ts +8 -0
  122. package/dist/server.js +4 -0
  123. package/dist/templates/nextjs/.artifactignore +14 -0
  124. package/dist/templates/nextjs/README.md +151 -0
  125. package/dist/templates/nextjs/app/about/page.tsx +26 -0
  126. package/dist/templates/nextjs/app/actions.ts +18 -0
  127. package/dist/templates/nextjs/app/api/health/route.ts +6 -0
  128. package/dist/templates/nextjs/app/components/counter.tsx +19 -0
  129. package/dist/templates/nextjs/app/components/greeting-form.tsx +38 -0
  130. package/dist/templates/nextjs/app/globals.css +4 -0
  131. package/dist/templates/nextjs/app/layout.tsx +27 -0
  132. package/dist/templates/nextjs/app/page.tsx +51 -0
  133. package/dist/templates/nextjs/app/providers.tsx +107 -0
  134. package/dist/templates/nextjs/artifact.bundle.yml +55 -0
  135. package/dist/templates/nextjs/next.config.mjs +27 -0
  136. package/dist/templates/nextjs/package.json +32 -0
  137. package/dist/templates/nextjs/pnpm-workspace.yaml +8 -0
  138. package/dist/templates/nextjs/postcss.config.mjs +5 -0
  139. package/dist/templates/nextjs/tsconfig.json +20 -0
  140. package/dist/templates/react-vite/package.json +1 -1
  141. package/dist/tunnel/broker.d.ts +75 -0
  142. package/dist/tunnel/broker.js +451 -0
  143. package/dist/tunnel/client.d.ts +22 -0
  144. package/dist/tunnel/client.js +177 -0
  145. package/dist/tunnel/index.d.ts +6 -0
  146. package/dist/tunnel/index.js +4 -0
  147. package/dist/tunnel/io.d.ts +33 -0
  148. package/dist/tunnel/io.js +108 -0
  149. package/dist/tunnel/peer.d.ts +25 -0
  150. package/dist/tunnel/peer.js +147 -0
  151. package/dist/tunnel/protocol.d.ts +69 -0
  152. package/dist/tunnel/protocol.js +196 -0
  153. package/dist/tunnel/redis-ownership.d.ts +38 -0
  154. package/dist/tunnel/redis-ownership.js +100 -0
  155. package/dist/tunnel/redis-socket.d.ts +13 -0
  156. package/dist/tunnel/redis-socket.js +48 -0
  157. package/dist/tunnel/redis-types.d.ts +70 -0
  158. package/dist/tunnel/redis-types.js +9 -0
  159. package/dist/tunnel/redis.d.ts +53 -0
  160. package/dist/tunnel/redis.js +391 -0
  161. package/dist/tunnel/transport.d.ts +17 -0
  162. package/dist/tunnel/transport.js +1 -0
  163. package/package.json +67 -4
  164. package/templates/nextjs/.artifactignore +14 -0
  165. package/templates/nextjs/README.md +151 -0
  166. package/templates/nextjs/app/about/page.tsx +26 -0
  167. package/templates/nextjs/app/actions.ts +18 -0
  168. package/templates/nextjs/app/api/health/route.ts +6 -0
  169. package/templates/nextjs/app/components/counter.tsx +19 -0
  170. package/templates/nextjs/app/components/greeting-form.tsx +38 -0
  171. package/templates/nextjs/app/globals.css +4 -0
  172. package/templates/nextjs/app/layout.tsx +27 -0
  173. package/templates/nextjs/app/page.tsx +51 -0
  174. package/templates/nextjs/app/providers.tsx +107 -0
  175. package/templates/nextjs/artifact.bundle.yml +55 -0
  176. package/templates/nextjs/next.config.mjs +27 -0
  177. package/templates/nextjs/package.json +32 -0
  178. package/templates/nextjs/pnpm-workspace.yaml +8 -0
  179. package/templates/nextjs/postcss.config.mjs +5 -0
  180. package/templates/nextjs/tsconfig.json +20 -0
  181. package/templates/react-vite/package.json +1 -1
@@ -0,0 +1,33 @@
1
+ import type { Writable } from 'node:stream';
2
+ import WebSocket, { type RawData } from 'ws';
3
+ import type { TunnelFrame } from './protocol.js';
4
+ export declare function writeChunk(target: Writable, bytes: Buffer): Promise<void>;
5
+ export declare function rawBuffer(data: RawData): Buffer;
6
+ export declare function sendWebSocket(socket: WebSocket, bytes: Buffer, binary: boolean): Promise<void>;
7
+ /** pause() alone does not stop messages already decoded from a single TCP read.
8
+ * Serialize those messages so fragments from two HMR messages cannot interleave. */
9
+ export declare function forwardWebSocketMessages(socket: WebSocket, forward: (bytes: Buffer, binary: boolean) => Promise<void>, maximumQueuedBytes: number, failed: () => void, budget: MessageBudget): void;
10
+ export declare class MessageBudget {
11
+ private readonly maximum;
12
+ private bytes;
13
+ constructor(maximum: number);
14
+ add(bytes: number): void;
15
+ release(bytes: number): void;
16
+ }
17
+ /** Reassembles only one bounded message, preserving HMR message boundaries. */
18
+ export declare class WebSocketMessage {
19
+ private readonly maximum;
20
+ private readonly budget;
21
+ private parts;
22
+ private size;
23
+ private binary;
24
+ constructor(maximum: number, budget: MessageBudget);
25
+ accept(frame: Extract<TunnelFrame, {
26
+ type: 'data';
27
+ }>): {
28
+ bytes: Buffer;
29
+ binary: boolean;
30
+ } | undefined;
31
+ reset(): void;
32
+ }
33
+ export declare function permittedCloseCode(code: number): number;
@@ -0,0 +1,108 @@
1
+ import WebSocket from 'ws';
2
+ export async function writeChunk(target, bytes) {
3
+ if (target.destroyed)
4
+ throw new Error('Destination disconnected');
5
+ if (!target.write(bytes)) {
6
+ // A destroyed HTTP destination may emit close without drain or error.
7
+ await new Promise((resolve, reject) => {
8
+ const cleanup = () => { target.off('drain', done); target.off('close', closed); target.off('error', failed); };
9
+ const done = () => { cleanup(); resolve(); };
10
+ const closed = () => { cleanup(); reject(new Error('Destination disconnected')); };
11
+ const failed = (error) => { cleanup(); reject(error); };
12
+ target.once('drain', done);
13
+ target.once('close', closed);
14
+ target.once('error', failed);
15
+ });
16
+ }
17
+ }
18
+ export function rawBuffer(data) {
19
+ return Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
20
+ }
21
+ export async function sendWebSocket(socket, bytes, binary) {
22
+ if (socket.readyState !== WebSocket.OPEN)
23
+ throw new Error('WebSocket disconnected');
24
+ await new Promise((resolve, reject) => {
25
+ socket.send(bytes, { binary }, (error) => error ? reject(error) : resolve());
26
+ });
27
+ }
28
+ /** pause() alone does not stop messages already decoded from a single TCP read.
29
+ * Serialize those messages so fragments from two HMR messages cannot interleave. */
30
+ export function forwardWebSocketMessages(socket, forward, maximumQueuedBytes, failed, budget) {
31
+ let queued = 0;
32
+ let pending = Promise.resolve();
33
+ socket.on('message', (data, binary) => {
34
+ const bytes = rawBuffer(data);
35
+ try {
36
+ budget.add(bytes.length);
37
+ }
38
+ catch {
39
+ failed();
40
+ return;
41
+ }
42
+ queued += bytes.length;
43
+ socket.pause();
44
+ if (queued > maximumQueuedBytes) {
45
+ budget.release(bytes.length);
46
+ failed();
47
+ return;
48
+ }
49
+ pending = pending.then(() => forward(bytes, binary)).finally(() => {
50
+ budget.release(bytes.length);
51
+ queued -= bytes.length;
52
+ if (queued === 0 && socket.readyState === WebSocket.OPEN)
53
+ socket.resume();
54
+ }).catch(failed);
55
+ });
56
+ }
57
+ export class MessageBudget {
58
+ maximum;
59
+ bytes = 0;
60
+ constructor(maximum) {
61
+ this.maximum = maximum;
62
+ }
63
+ add(bytes) {
64
+ if (this.bytes + bytes > this.maximum)
65
+ throw new Error('WebSocket buffer exceeded');
66
+ this.bytes += bytes;
67
+ }
68
+ release(bytes) { this.bytes -= bytes; }
69
+ }
70
+ /** Reassembles only one bounded message, preserving HMR message boundaries. */
71
+ export class WebSocketMessage {
72
+ maximum;
73
+ budget;
74
+ parts = [];
75
+ size = 0;
76
+ binary;
77
+ constructor(maximum, budget) {
78
+ this.maximum = maximum;
79
+ this.budget = budget;
80
+ }
81
+ accept(frame) {
82
+ const bytes = Buffer.from(frame.data, 'base64');
83
+ if (typeof frame.binary !== 'boolean' || typeof frame.final !== 'boolean')
84
+ throw new Error('Missing WebSocket framing');
85
+ if (this.binary !== undefined && this.binary !== frame.binary)
86
+ throw new Error('Changed message encoding');
87
+ this.binary = frame.binary;
88
+ if (this.size + bytes.length > this.maximum)
89
+ throw new Error('WebSocket message too large');
90
+ this.budget.add(bytes.length);
91
+ this.size += bytes.length;
92
+ this.parts.push(bytes);
93
+ if (!frame.final)
94
+ return;
95
+ const result = { bytes: Buffer.concat(this.parts, this.size), binary: this.binary };
96
+ return result;
97
+ }
98
+ reset() {
99
+ this.budget.release(this.size);
100
+ this.parts = [];
101
+ this.size = 0;
102
+ this.binary = undefined;
103
+ }
104
+ }
105
+ export function permittedCloseCode(code) {
106
+ return code === 1000 || (code >= 1001 && code <= 1014 && ![1004, 1005, 1006].includes(code)) || (code >= 3000 && code <= 4999)
107
+ ? code : 1000;
108
+ }
@@ -0,0 +1,25 @@
1
+ import type { TunnelSocket } from './transport.js';
2
+ import { DEFAULT_TUNNEL_LIMITS, type TunnelFrame } from './protocol.js';
3
+ export type TunnelLimits = typeof DEFAULT_TUNNEL_LIMITS;
4
+ /** Flow control is per direction and per stream. ACK means the receiving
5
+ * application consumed the bytes, not merely that the WebSocket received them. */
6
+ export declare class TunnelPeer {
7
+ readonly socket: TunnelSocket;
8
+ readonly limits: TunnelLimits;
9
+ private readonly receive;
10
+ private readonly disconnected;
11
+ private readonly acceptsRequests;
12
+ private readonly streams;
13
+ private queuedBytes;
14
+ private stopped;
15
+ constructor(socket: TunnelSocket, limits: TunnelLimits, receive: (frame: TunnelFrame) => Promise<void>, disconnected: () => void, acceptsRequests?: boolean);
16
+ open(id?: string): string;
17
+ forget(id: string): void;
18
+ send(frame: TunnelFrame): Promise<void>;
19
+ data(id: string, data: Buffer, websocket?: {
20
+ binary: boolean;
21
+ }): Promise<void>;
22
+ private credit;
23
+ close(): void;
24
+ }
25
+ export declare function tunnelLimits(overrides?: Partial<TunnelLimits>): TunnelLimits;
@@ -0,0 +1,147 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import WebSocket from 'ws';
3
+ import { DEFAULT_TUNNEL_LIMITS, encodeTunnelFrame, parseTunnelFrame, } from './protocol.js';
4
+ /** Flow control is per direction and per stream. ACK means the receiving
5
+ * application consumed the bytes, not merely that the WebSocket received them. */
6
+ export class TunnelPeer {
7
+ socket;
8
+ limits;
9
+ receive;
10
+ disconnected;
11
+ acceptsRequests;
12
+ streams = new Map();
13
+ queuedBytes = 0;
14
+ stopped = false;
15
+ constructor(socket, limits, receive, disconnected, acceptsRequests = false) {
16
+ this.socket = socket;
17
+ this.limits = limits;
18
+ this.receive = receive;
19
+ this.disconnected = disconnected;
20
+ this.acceptsRequests = acceptsRequests;
21
+ socket.on('message', (data, binary) => {
22
+ try {
23
+ if (binary)
24
+ throw new Error('Binary protocol frame');
25
+ const frame = parseTunnelFrame(data);
26
+ if (frame.type === 'ack') {
27
+ const stream = this.streams.get(frame.id);
28
+ if (!stream)
29
+ return; // A cancelled stream may have an ACK in flight.
30
+ if (frame.bytes > stream.outstanding)
31
+ throw new Error('Invalid credit');
32
+ stream.outstanding -= frame.bytes;
33
+ for (const wake of stream.waiters)
34
+ wake();
35
+ return;
36
+ }
37
+ if (frame.type === 'request') {
38
+ if (!this.acceptsRequests || this.streams.has(frame.id))
39
+ throw new Error('Unexpected request frame');
40
+ this.open(frame.id);
41
+ }
42
+ const stream = this.streams.get(frame.id);
43
+ if (!stream)
44
+ return; // Frames from an already cancelled request are discarded.
45
+ if (frame.type === 'cancel') {
46
+ void this.receive(frame).catch(() => this.close());
47
+ return;
48
+ }
49
+ const bytes = frame.type === 'data' ? Buffer.from(frame.data, 'base64').length : 0;
50
+ this.queuedBytes += bytes;
51
+ if (this.queuedBytes > limits.maxBufferedBytes)
52
+ throw new Error('Receive buffer exceeded');
53
+ stream.receive = stream.receive.then(async () => {
54
+ try {
55
+ if (!this.streams.has(frame.id))
56
+ return;
57
+ await this.receive(frame);
58
+ if (bytes > 0)
59
+ await this.send({ type: 'ack', id: frame.id, bytes });
60
+ }
61
+ finally {
62
+ this.queuedBytes -= bytes;
63
+ }
64
+ }).catch(() => this.close());
65
+ }
66
+ catch {
67
+ this.close();
68
+ }
69
+ });
70
+ socket.on('error', () => this.close());
71
+ socket.once('close', () => this.close());
72
+ }
73
+ open(id = randomUUID()) {
74
+ if (this.stopped || this.streams.has(id) || this.streams.size >= this.limits.maxStreams) {
75
+ throw new Error('Tunnel stream limit or duplicate stream');
76
+ }
77
+ this.streams.set(id, { outstanding: 0, waiters: new Set(), receive: Promise.resolve() });
78
+ return id;
79
+ }
80
+ forget(id) {
81
+ const stream = this.streams.get(id);
82
+ this.streams.delete(id);
83
+ if (stream)
84
+ for (const wake of stream.waiters)
85
+ wake();
86
+ }
87
+ async send(frame) {
88
+ if (this.stopped || this.socket.readyState !== WebSocket.OPEN)
89
+ throw new Error('Tunnel disconnected');
90
+ const payload = encodeTunnelFrame(frame);
91
+ if (this.socket.bufferedAmount + Buffer.byteLength(payload) > this.limits.maxBufferedBytes) {
92
+ this.close();
93
+ throw new Error('Tunnel send buffer exceeded');
94
+ }
95
+ await new Promise((resolve, reject) => {
96
+ this.socket.send(payload, (error) => error ? reject(error) : resolve());
97
+ });
98
+ }
99
+ async data(id, data, websocket) {
100
+ // An empty WebSocket message is meaningful; HTTP empty chunks are not.
101
+ for (let offset = 0; offset < Math.max(data.length, websocket ? 1 : 0); offset += this.limits.maxChunkBytes) {
102
+ const chunk = data.subarray(offset, offset + this.limits.maxChunkBytes);
103
+ await this.credit(id, chunk.length);
104
+ const stream = this.streams.get(id);
105
+ if (!stream)
106
+ throw new Error('Tunnel stream closed');
107
+ stream.outstanding += chunk.length;
108
+ await this.send({
109
+ type: 'data', id, data: chunk.toString('base64'),
110
+ ...(websocket ? { binary: websocket.binary, final: offset + chunk.length >= data.length } : {}),
111
+ });
112
+ }
113
+ }
114
+ async credit(id, bytes) {
115
+ while (true) {
116
+ const stream = this.streams.get(id);
117
+ if (!stream || this.stopped)
118
+ throw new Error('Tunnel stream closed');
119
+ if (stream.outstanding + bytes <= this.limits.maxInFlightBytes)
120
+ return;
121
+ await new Promise((resolve) => {
122
+ const wake = () => { stream.waiters.delete(wake); resolve(); };
123
+ stream.waiters.add(wake);
124
+ });
125
+ }
126
+ }
127
+ close() {
128
+ if (this.stopped)
129
+ return;
130
+ this.stopped = true;
131
+ for (const id of this.streams.keys())
132
+ this.forget(id);
133
+ this.socket.terminate();
134
+ this.disconnected();
135
+ }
136
+ }
137
+ export function tunnelLimits(overrides) {
138
+ const limits = { ...DEFAULT_TUNNEL_LIMITS, ...overrides };
139
+ for (const [name, value] of Object.entries(limits)) {
140
+ if (!Number.isSafeInteger(value) || value <= 0 || value > DEFAULT_TUNNEL_LIMITS[name]) {
141
+ throw new Error(`Invalid tunnel limit ${name}`);
142
+ }
143
+ }
144
+ if (limits.maxChunkBytes > limits.maxInFlightBytes)
145
+ throw new Error('Chunk exceeds stream window');
146
+ return limits;
147
+ }
@@ -0,0 +1,69 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { z } from 'zod';
3
+ /** Version 1 is negotiated by the tunnel connection, not by individual streams. */
4
+ export declare const TUNNEL_PROTOCOL = "artifact-tunnel.v1";
5
+ export declare const DEFAULT_TUNNEL_LIMITS: Readonly<{
6
+ maxFrameBytes: number;
7
+ maxChunkBytes: number;
8
+ maxStreams: 32;
9
+ maxBodyBytes: number;
10
+ maxInFlightBytes: number;
11
+ maxWebSocketMessageBytes: number;
12
+ maxBufferedBytes: number;
13
+ requestTimeoutMs: 60000;
14
+ }>;
15
+ export type TunnelHeaders = Record<string, string | string[]>;
16
+ type HeaderInput = Record<string, string | string[] | undefined>;
17
+ /** Enforce origin-form paths before a URL parser can reinterpret a destination. */
18
+ export declare function assertSafeRequest(method: string, path: string): void;
19
+ export declare const tunnelFrameSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
20
+ type: z.ZodLiteral<"ready">;
21
+ id: z.ZodString;
22
+ epoch: z.ZodNumber;
23
+ }, z.core.$strict>, z.ZodObject<{
24
+ type: z.ZodLiteral<"request">;
25
+ id: z.ZodString;
26
+ method: z.ZodString;
27
+ path: z.ZodString;
28
+ headers: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
29
+ websocket: z.ZodBoolean;
30
+ }, z.core.$strict>, z.ZodObject<{
31
+ type: z.ZodLiteral<"response">;
32
+ id: z.ZodString;
33
+ status: z.ZodNumber;
34
+ headers: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
35
+ }, z.core.$strict>, z.ZodObject<{
36
+ type: z.ZodLiteral<"data">;
37
+ id: z.ZodString;
38
+ data: z.ZodString;
39
+ binary: z.ZodOptional<z.ZodBoolean>;
40
+ final: z.ZodOptional<z.ZodBoolean>;
41
+ }, z.core.$strict>, z.ZodObject<{
42
+ type: z.ZodLiteral<"end">;
43
+ id: z.ZodString;
44
+ }, z.core.$strict>, z.ZodObject<{
45
+ type: z.ZodLiteral<"cancel">;
46
+ id: z.ZodString;
47
+ }, z.core.$strict>, z.ZodObject<{
48
+ type: z.ZodLiteral<"ws-open">;
49
+ id: z.ZodString;
50
+ protocol: z.ZodString;
51
+ }, z.core.$strict>, z.ZodObject<{
52
+ type: z.ZodLiteral<"ws-close">;
53
+ id: z.ZodString;
54
+ code: z.ZodNumber;
55
+ reason: z.ZodString;
56
+ }, z.core.$strict>, z.ZodObject<{
57
+ type: z.ZodLiteral<"ack">;
58
+ id: z.ZodString;
59
+ bytes: z.ZodNumber;
60
+ }, z.core.$strict>], "type">;
61
+ export type TunnelFrame = z.infer<typeof tunnelFrameSchema>;
62
+ /** Compatible with ws RawData without introducing a websocket dependency here. */
63
+ export declare function parseTunnelFrame(input: string | Buffer | ArrayBuffer | Buffer[]): TunnelFrame;
64
+ export declare function encodeTunnelFrame(frame: TunnelFrame): string;
65
+ export declare function normalizePublicOrigin(value: string): string;
66
+ /** Rebuild trusted routing headers; no Atlas bearer/session crosses into app code. */
67
+ export declare function sanitizeRequestHeaders(headers: HeaderInput, publicOrigin: string): TunnelHeaders;
68
+ export declare function sanitizeResponseHeaders(headers: HeaderInput): TunnelHeaders;
69
+ export {};
@@ -0,0 +1,196 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { z } from 'zod';
3
+ /** Version 1 is negotiated by the tunnel connection, not by individual streams. */
4
+ export const TUNNEL_PROTOCOL = 'artifact-tunnel.v1';
5
+ export const DEFAULT_TUNNEL_LIMITS = Object.freeze({
6
+ maxFrameBytes: 64 * 1024,
7
+ maxChunkBytes: 32 * 1024,
8
+ maxStreams: 32,
9
+ maxBodyBytes: 32 * 1024 * 1024,
10
+ maxInFlightBytes: 256 * 1024,
11
+ maxWebSocketMessageBytes: 4 * 1024 * 1024,
12
+ maxBufferedBytes: 8 * 1024 * 1024,
13
+ requestTimeoutMs: 60_000,
14
+ });
15
+ const HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i;
16
+ const HEADER_VALUE = /^[\t\x20-\x7e\x80-\xff]*$/;
17
+ const METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
18
+ const PLATFORM_COOKIE = '__Host-atlas-artifact-session';
19
+ function isPlatformCookie(name) {
20
+ const normalized = name.toLowerCase();
21
+ const reserved = PLATFORM_COOKIE.toLowerCase();
22
+ return normalized === reserved || normalized.startsWith(`${reserved}-`);
23
+ }
24
+ const HOP_HEADERS = new Set([
25
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
26
+ 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade',
27
+ 'content-length', 'expect',
28
+ ]);
29
+ const headerValueSchema = z.string().max(8192).regex(HEADER_VALUE);
30
+ const headersSchema = z.record(z.string().max(256).regex(HEADER_TOKEN), z.union([headerValueSchema, z.array(headerValueSchema).min(1).max(32)])).superRefine((headers, context) => {
31
+ const names = new Set();
32
+ let bytes = 0;
33
+ for (const [name, value] of Object.entries(headers)) {
34
+ if (names.has(name.toLowerCase())) {
35
+ context.addIssue({ code: 'custom', message: 'Duplicate case-insensitive header' });
36
+ }
37
+ names.add(name.toLowerCase());
38
+ bytes += Buffer.byteLength(name) + Buffer.byteLength(Array.isArray(value) ? value.join(',') : value);
39
+ }
40
+ if (names.size > 128 || bytes > 32 * 1024) {
41
+ context.addIssue({ code: 'custom', message: 'Tunnel headers exceed their size limit' });
42
+ }
43
+ });
44
+ /** Enforce origin-form paths before a URL parser can reinterpret a destination. */
45
+ export function assertSafeRequest(method, path) {
46
+ if (!METHODS.has(method))
47
+ throw new Error('Unsupported tunnel request method');
48
+ if (path.length > 16 * 1024 || !path.startsWith('/') || path.startsWith('//') ||
49
+ !/^[\x21-\x7e]+$/.test(path) || /[\\#]/.test(path) ||
50
+ /%(?![a-f0-9]{2})/i.test(path) || /%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/i.test(path)) {
51
+ throw new Error('Tunnel request requires a safe origin-form path');
52
+ }
53
+ }
54
+ const requestPathSchema = z.string().refine((path) => {
55
+ try {
56
+ assertSafeRequest('GET', path);
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }, 'Tunnel request requires a safe origin-form path');
63
+ const streamIdSchema = z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
64
+ const dataSchema = z.string()
65
+ .max(4 * Math.ceil(DEFAULT_TUNNEL_LIMITS.maxChunkBytes / 3))
66
+ .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/)
67
+ .refine((data) => {
68
+ const decoded = Buffer.from(data, 'base64');
69
+ return decoded.length <= DEFAULT_TUNNEL_LIMITS.maxChunkBytes && decoded.toString('base64') === data;
70
+ }, 'Tunnel data must be canonical, bounded base64');
71
+ export const tunnelFrameSchema = z.discriminatedUnion('type', [
72
+ z.object({ type: z.literal('ready'), id: streamIdSchema, epoch: z.number().int().positive() }).strict(),
73
+ z.object({
74
+ type: z.literal('request'), id: streamIdSchema,
75
+ method: z.string().refine((method) => METHODS.has(method)),
76
+ path: requestPathSchema, headers: headersSchema, websocket: z.boolean(),
77
+ }).strict(),
78
+ z.object({
79
+ type: z.literal('response'), id: streamIdSchema,
80
+ status: z.number().int().min(200).max(599), headers: headersSchema,
81
+ }).strict(),
82
+ z.object({
83
+ type: z.literal('data'), id: streamIdSchema, data: dataSchema,
84
+ binary: z.boolean().optional(), final: z.boolean().optional(),
85
+ }).strict(),
86
+ z.object({ type: z.literal('end'), id: streamIdSchema }).strict(),
87
+ z.object({ type: z.literal('cancel'), id: streamIdSchema }).strict(),
88
+ z.object({
89
+ type: z.literal('ws-open'), id: streamIdSchema,
90
+ protocol: z.string().max(256).refine((protocol) => protocol === '' || HEADER_TOKEN.test(protocol)),
91
+ }).strict(),
92
+ z.object({
93
+ type: z.literal('ws-close'), id: streamIdSchema,
94
+ code: z.number().int().refine((code) => (code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code)) ||
95
+ (code >= 3000 && code <= 4999)),
96
+ reason: z.string().refine((reason) => Buffer.byteLength(reason) <= 123),
97
+ }).strict(),
98
+ z.object({
99
+ type: z.literal('ack'), id: streamIdSchema,
100
+ bytes: z.number().int().min(1).max(DEFAULT_TUNNEL_LIMITS.maxInFlightBytes),
101
+ }).strict(),
102
+ ]);
103
+ /** Compatible with ws RawData without introducing a websocket dependency here. */
104
+ export function parseTunnelFrame(input) {
105
+ const bytes = typeof input === 'string' ? Buffer.byteLength(input)
106
+ : Array.isArray(input) ? input.reduce((total, part) => total + part.byteLength, 0)
107
+ : input.byteLength;
108
+ if (bytes > DEFAULT_TUNNEL_LIMITS.maxFrameBytes)
109
+ throw new Error('Tunnel frame exceeds its size limit');
110
+ const source = typeof input === 'string' ? input : new TextDecoder('utf-8', { fatal: true }).decode(Array.isArray(input) ? Buffer.concat(input) : input);
111
+ return tunnelFrameSchema.parse(JSON.parse(source));
112
+ }
113
+ export function encodeTunnelFrame(frame) {
114
+ const source = JSON.stringify(tunnelFrameSchema.parse(frame));
115
+ if (Buffer.byteLength(source) > DEFAULT_TUNNEL_LIMITS.maxFrameBytes) {
116
+ throw new Error('Tunnel frame exceeds its size limit');
117
+ }
118
+ return source;
119
+ }
120
+ export function normalizePublicOrigin(value) {
121
+ const url = new URL(value);
122
+ const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
123
+ if (!/^https?:\/\/[^/?#@\\\s]+\/?$/i.test(value) || url.username || url.password || url.search || url.hash ||
124
+ url.pathname !== '/' || (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)))
125
+ throw new Error('Tunnel public origin must be an HTTPS origin or HTTP loopback origin');
126
+ return url.origin;
127
+ }
128
+ function normalizedHeaders(headers) {
129
+ const present = Object.fromEntries(Object.entries(headers).filter(([, value]) => value !== undefined));
130
+ return new Map(Object.entries(headersSchema.parse(present)).map(([name, value]) => [name.toLowerCase(), value]));
131
+ }
132
+ function removePrivateHeaders(headers) {
133
+ const connection = headers.get('connection');
134
+ const nominated = new Set((Array.isArray(connection) ? connection.join(',') : connection ?? '')
135
+ .split(',').map((name) => name.trim().toLowerCase()));
136
+ for (const name of headers.keys()) {
137
+ if (HOP_HEADERS.has(name) || nominated.has(name) || name === 'forwarded' ||
138
+ name === 'authorization' || name === 'remote-user' || name === 'x-remote-user' ||
139
+ name === 'x-serverless-authorization' || name === 'x-goog-iap-jwt-assertion' ||
140
+ /^(?:x-forwarded-|x-sequence-|x-artifact-|x-atlas-|x-user-|x-auth-|x-authenticated-|x-goog-authenticated-|proxy-)/.test(name))
141
+ headers.delete(name);
142
+ }
143
+ }
144
+ /** Rebuild trusted routing headers; no Atlas bearer/session crosses into app code. */
145
+ export function sanitizeRequestHeaders(headers, publicOrigin) {
146
+ const origin = normalizePublicOrigin(publicOrigin);
147
+ const normalized = normalizedHeaders(headers);
148
+ const suppliedOrigin = normalized.get('origin');
149
+ if (suppliedOrigin !== undefined && suppliedOrigin !== origin)
150
+ throw new Error('Tunnel request origin mismatch');
151
+ removePrivateHeaders(normalized);
152
+ const cookie = normalized.get('cookie');
153
+ if (cookie !== undefined) {
154
+ const remaining = (Array.isArray(cookie) ? cookie.join(';') : cookie).split(';')
155
+ .map((part) => part.trim()).filter((part) => {
156
+ const separator = part.indexOf('=');
157
+ return separator > 0 && !isPlatformCookie(part.slice(0, separator).trim());
158
+ }).join('; ');
159
+ if (remaining)
160
+ normalized.set('cookie', remaining);
161
+ else
162
+ normalized.delete('cookie');
163
+ }
164
+ const url = new URL(origin);
165
+ normalized.set('host', url.host);
166
+ normalized.set('x-forwarded-host', url.host);
167
+ normalized.set('x-forwarded-proto', url.protocol.slice(0, -1));
168
+ return Object.fromEntries(normalized);
169
+ }
170
+ function constrainCookie(cookie) {
171
+ const [assignment, ...attributes] = cookie.split(';');
172
+ const separator = assignment.indexOf('=');
173
+ if (separator <= 0)
174
+ throw new Error('Invalid application Set-Cookie header');
175
+ const name = assignment.slice(0, separator).trim();
176
+ if (!HEADER_TOKEN.test(name))
177
+ throw new Error('Invalid application cookie name');
178
+ if (isPlatformCookie(name))
179
+ return undefined;
180
+ return [assignment.trim(), ...attributes.map((attribute) => attribute.trim())
181
+ .filter((attribute) => attribute && attribute.split('=', 1)[0].trim().toLowerCase() !== 'domain')].join('; ');
182
+ }
183
+ export function sanitizeResponseHeaders(headers) {
184
+ const normalized = normalizedHeaders(headers);
185
+ removePrivateHeaders(normalized);
186
+ const cookies = normalized.get('set-cookie');
187
+ if (cookies !== undefined) {
188
+ const constrained = (Array.isArray(cookies) ? cookies : [cookies])
189
+ .map(constrainCookie).filter((cookie) => cookie !== undefined);
190
+ if (constrained.length)
191
+ normalized.set('set-cookie', constrained);
192
+ else
193
+ normalized.delete('set-cookie');
194
+ }
195
+ return Object.fromEntries(normalized);
196
+ }
@@ -0,0 +1,38 @@
1
+ import { z } from 'zod';
2
+ import type { TunnelLease, TunnelOwner, TunnelOwnershipStore } from './broker.js';
3
+ export interface RedisTunnelCommands {
4
+ get(key: string): Promise<string | null>;
5
+ eval(script: string, numberOfKeys: number, ...arguments_: (string | number)[]): Promise<unknown>;
6
+ }
7
+ export declare const redisIdentifier: z.ZodString;
8
+ /** An APP_ENV deployment identity (tenant ids like banksouth_mortgage, or <tenant_id>_staging), kept
9
+ * free of the ':' key delimiter and '{}' hash-tag braces used in Redis keys and channels. */
10
+ export declare const redisNamespaceSchema: z.ZodString;
11
+ export declare const redisLeaseSchema: z.ZodObject<{
12
+ sessionId: z.ZodString;
13
+ epoch: z.ZodNumber;
14
+ ownerId: z.ZodString;
15
+ projectId: z.ZodString;
16
+ environment: z.ZodString;
17
+ expiresAt: z.ZodNumber;
18
+ publicOrigin: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
19
+ }, z.core.$strict>;
20
+ /** Retain epochs longer than any lease, including after owner disconnect. Atlas
21
+ * must still authenticate every claim against the durable session's lifetime;
22
+ * Redis is a routing fence, not a substitute for session authorization. */
23
+ export declare class RedisTunnelOwnership implements TunnelOwnershipStore {
24
+ private readonly options;
25
+ readonly namespace: string;
26
+ constructor(options: {
27
+ command: RedisTunnelCommands;
28
+ namespace: string;
29
+ });
30
+ private key;
31
+ claim(input: TunnelLease, nodeId: string): Promise<boolean>;
32
+ private read;
33
+ lookup(sessionId: string): Promise<TunnelOwner | null>;
34
+ resolveLease(sessionId: string): Promise<TunnelLease | null>;
35
+ /** Call only after Atlas has authorized and renewed the backing session. */
36
+ renew(input: TunnelLease, nodeId: string): Promise<boolean>;
37
+ release(sessionId: string, epoch: number, nodeId: string): Promise<void>;
38
+ }