@sequenceholdings/artifact-studio 0.2.6 → 0.3.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 (190) hide show
  1. package/VERSION-PIN.md +12 -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 +134 -13
  8. package/dist/dev-link.d.ts +1 -1
  9. package/dist/dev-link.js +2 -1
  10. package/dist/gateway/admission.d.ts +56 -0
  11. package/dist/gateway/admission.js +134 -0
  12. package/dist/gateway/authorization-cache.d.ts +52 -0
  13. package/dist/gateway/authorization-cache.js +111 -0
  14. package/dist/gateway/control-admission.d.ts +48 -0
  15. package/dist/gateway/control-admission.js +127 -0
  16. package/dist/gateway/control.d.ts +94 -0
  17. package/dist/gateway/control.js +116 -0
  18. package/dist/gateway/hosted-proxy.d.ts +36 -0
  19. package/dist/gateway/hosted-proxy.js +344 -0
  20. package/dist/gateway/release-admission.d.ts +23 -0
  21. package/dist/gateway/release-admission.js +259 -0
  22. package/dist/gateway/server.d.ts +38 -0
  23. package/dist/gateway/server.js +382 -0
  24. package/dist/gateway/session.d.ts +72 -0
  25. package/dist/gateway/session.js +144 -0
  26. package/dist/gateway/usage.d.ts +70 -0
  27. package/dist/gateway/usage.js +116 -0
  28. package/dist/gateway-main.d.ts +1 -0
  29. package/dist/gateway-main.js +114 -0
  30. package/dist/git-clone.d.ts +23 -1
  31. package/dist/git-clone.js +32 -15
  32. package/dist/manifest-next.d.ts +217 -0
  33. package/dist/manifest-next.js +132 -0
  34. package/dist/manifest-static.d.ts +76 -0
  35. package/dist/manifest-static.js +124 -0
  36. package/dist/manifest.d.ts +118 -13
  37. package/dist/manifest.js +12 -119
  38. package/dist/next/bridge.d.ts +47 -0
  39. package/dist/next/bridge.js +236 -0
  40. package/dist/next/browser-logs.d.ts +7 -0
  41. package/dist/next/browser-logs.js +51 -0
  42. package/dist/next/browser.d.ts +6 -0
  43. package/dist/next/browser.js +57 -0
  44. package/dist/next/protocol.d.ts +73 -0
  45. package/dist/next/protocol.js +56 -0
  46. package/dist/next/release.d.ts +15 -0
  47. package/dist/next/release.js +46 -0
  48. package/dist/next/requests.d.ts +15 -0
  49. package/dist/next/requests.js +59 -0
  50. package/dist/next/routes.d.ts +4 -0
  51. package/dist/next/routes.js +21 -0
  52. package/dist/next/streams.d.ts +16 -0
  53. package/dist/next/streams.js +132 -0
  54. package/dist/next/theme.d.ts +10 -0
  55. package/dist/next/theme.js +45 -0
  56. package/dist/next/transport.d.ts +15 -0
  57. package/dist/next/transport.js +13 -0
  58. package/dist/next-build.d.ts +29 -0
  59. package/dist/next-build.js +129 -0
  60. package/dist/next-cache/adapter-runtime.d.ts +51 -0
  61. package/dist/next-cache/adapter-runtime.js +73 -0
  62. package/dist/next-cache/component-handler.d.ts +3 -0
  63. package/dist/next-cache/component-handler.js +10 -0
  64. package/dist/next-cache/components.d.ts +20 -0
  65. package/dist/next-cache/components.js +113 -0
  66. package/dist/next-cache/config.d.ts +9 -0
  67. package/dist/next-cache/config.js +105 -0
  68. package/dist/next-cache/incremental-handler.d.ts +18 -0
  69. package/dist/next-cache/incremental-handler.js +17 -0
  70. package/dist/next-cache/incremental.d.ts +27 -0
  71. package/dist/next-cache/incremental.js +99 -0
  72. package/dist/next-cache/index.d.ts +18 -0
  73. package/dist/next-cache/index.js +16 -0
  74. package/dist/next-cache/memory-store.d.ts +27 -0
  75. package/dist/next-cache/memory-store.js +118 -0
  76. package/dist/next-cache/redis-scripts.d.ts +3 -0
  77. package/dist/next-cache/redis-scripts.js +85 -0
  78. package/dist/next-cache/redis-store.d.ts +18 -0
  79. package/dist/next-cache/redis-store.js +56 -0
  80. package/dist/next-cache/runtime.d.ts +29 -0
  81. package/dist/next-cache/runtime.js +63 -0
  82. package/dist/next-cache/serialization.d.ts +39 -0
  83. package/dist/next-cache/serialization.js +333 -0
  84. package/dist/next-cache/store.d.ts +54 -0
  85. package/dist/next-cache/store.js +45 -0
  86. package/dist/next-cache/valkey-transport.d.ts +52 -0
  87. package/dist/next-cache/valkey-transport.js +189 -0
  88. package/dist/next-dev-control.d.ts +48 -0
  89. package/dist/next-dev-control.js +63 -0
  90. package/dist/next-dev-process.d.ts +17 -0
  91. package/dist/next-dev-process.js +110 -0
  92. package/dist/next-dev.d.ts +12 -0
  93. package/dist/next-dev.js +267 -0
  94. package/dist/next-image-config.d.ts +9 -0
  95. package/dist/next-image-config.js +101 -0
  96. package/dist/next-project.d.ts +22 -0
  97. package/dist/next-project.js +64 -0
  98. package/dist/next-public-fonts-local.d.ts +7 -0
  99. package/dist/next-public-fonts-local.js +64 -0
  100. package/dist/next-public-fonts.d.ts +13 -0
  101. package/dist/next-public-fonts.js +49 -0
  102. package/dist/next-source-hash.d.ts +14 -0
  103. package/dist/next-source-hash.js +18 -0
  104. package/dist/next-source.d.ts +35 -0
  105. package/dist/next-source.js +155 -0
  106. package/dist/next.d.ts +9 -0
  107. package/dist/next.js +5 -0
  108. package/dist/project.d.ts +3 -0
  109. package/dist/project.js +9 -1
  110. package/dist/runtime-contracts.d.ts +229 -0
  111. package/dist/runtime-contracts.js +224 -0
  112. package/dist/sdk.d.ts +2 -2
  113. package/dist/server/broker-contract.d.ts +68 -0
  114. package/dist/server/broker-contract.js +33 -0
  115. package/dist/server/client.d.ts +81 -0
  116. package/dist/server/client.js +159 -0
  117. package/dist/server/logger.d.ts +26 -0
  118. package/dist/server/logger.js +24 -0
  119. package/dist/server/release-forms.d.ts +13 -0
  120. package/dist/server/release-forms.js +124 -0
  121. package/dist/server/request-context.d.ts +75 -0
  122. package/dist/server/request-context.js +107 -0
  123. package/dist/server/runtime-config.d.ts +54 -0
  124. package/dist/server/runtime-config.js +64 -0
  125. package/dist/server.d.ts +8 -0
  126. package/dist/server.js +4 -0
  127. package/dist/templates/nextjs/.artifactignore +14 -0
  128. package/dist/templates/nextjs/README.md +157 -0
  129. package/dist/templates/nextjs/app/about/page.tsx +26 -0
  130. package/dist/templates/nextjs/app/actions.ts +18 -0
  131. package/dist/templates/nextjs/app/api/health/route.ts +6 -0
  132. package/dist/templates/nextjs/app/components/counter.tsx +19 -0
  133. package/dist/templates/nextjs/app/components/greeting-form.tsx +38 -0
  134. package/dist/templates/nextjs/app/globals.css +4 -0
  135. package/dist/templates/nextjs/app/layout.tsx +27 -0
  136. package/dist/templates/nextjs/app/page.tsx +51 -0
  137. package/dist/templates/nextjs/app/providers.tsx +107 -0
  138. package/dist/templates/nextjs/artifact.bundle.yml +55 -0
  139. package/dist/templates/nextjs/gitignore +9 -0
  140. package/dist/templates/nextjs/next.config.mjs +27 -0
  141. package/dist/templates/nextjs/package.json +32 -0
  142. package/dist/templates/nextjs/pnpm-workspace.yaml +22 -0
  143. package/dist/templates/nextjs/postcss.config.mjs +5 -0
  144. package/dist/templates/nextjs/tsconfig.json +20 -0
  145. package/dist/templates/react-vite/package.json +1 -1
  146. package/dist/trusted-install.js +62 -2
  147. package/dist/tunnel/broker.d.ts +75 -0
  148. package/dist/tunnel/broker.js +463 -0
  149. package/dist/tunnel/client.d.ts +22 -0
  150. package/dist/tunnel/client.js +177 -0
  151. package/dist/tunnel/index.d.ts +6 -0
  152. package/dist/tunnel/index.js +4 -0
  153. package/dist/tunnel/io.d.ts +33 -0
  154. package/dist/tunnel/io.js +108 -0
  155. package/dist/tunnel/peer.d.ts +25 -0
  156. package/dist/tunnel/peer.js +147 -0
  157. package/dist/tunnel/protocol.d.ts +78 -0
  158. package/dist/tunnel/protocol.js +210 -0
  159. package/dist/tunnel/redis-auth.d.ts +37 -0
  160. package/dist/tunnel/redis-auth.js +92 -0
  161. package/dist/tunnel/redis-ownership.d.ts +38 -0
  162. package/dist/tunnel/redis-ownership.js +100 -0
  163. package/dist/tunnel/redis-socket.d.ts +13 -0
  164. package/dist/tunnel/redis-socket.js +48 -0
  165. package/dist/tunnel/redis-types.d.ts +75 -0
  166. package/dist/tunnel/redis-types.js +9 -0
  167. package/dist/tunnel/redis.d.ts +56 -0
  168. package/dist/tunnel/redis.js +404 -0
  169. package/dist/tunnel/transport.d.ts +17 -0
  170. package/dist/tunnel/transport.js +1 -0
  171. package/package.json +67 -4
  172. package/templates/nextjs/.artifactignore +14 -0
  173. package/templates/nextjs/README.md +157 -0
  174. package/templates/nextjs/app/about/page.tsx +26 -0
  175. package/templates/nextjs/app/actions.ts +18 -0
  176. package/templates/nextjs/app/api/health/route.ts +6 -0
  177. package/templates/nextjs/app/components/counter.tsx +19 -0
  178. package/templates/nextjs/app/components/greeting-form.tsx +38 -0
  179. package/templates/nextjs/app/globals.css +4 -0
  180. package/templates/nextjs/app/layout.tsx +27 -0
  181. package/templates/nextjs/app/page.tsx +51 -0
  182. package/templates/nextjs/app/providers.tsx +107 -0
  183. package/templates/nextjs/artifact.bundle.yml +55 -0
  184. package/templates/nextjs/gitignore +9 -0
  185. package/templates/nextjs/next.config.mjs +27 -0
  186. package/templates/nextjs/package.json +32 -0
  187. package/templates/nextjs/pnpm-workspace.yaml +22 -0
  188. package/templates/nextjs/postcss.config.mjs +5 -0
  189. package/templates/nextjs/tsconfig.json +20 -0
  190. 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,78 @@
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
+ /** Response headers through which application output could opt into CDN or proxy reuse. */
69
+ export declare function isSharedCacheDirective(name: string): boolean;
70
+ /** Sent in their place on every protected response, so each reuse must pass gateway authorization again. */
71
+ export declare const SHARED_CACHE_REFUSAL: Readonly<{
72
+ 'cdn-cache-control': "no-store";
73
+ 'surrogate-control': "no-store";
74
+ pragma: "no-cache";
75
+ expires: "0";
76
+ }>;
77
+ export declare function sanitizeResponseHeaders(headers: HeaderInput): TunnelHeaders;
78
+ export {};
@@ -0,0 +1,210 @@
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 MAX_HEADER_BYTES = 32 * 1024;
30
+ function headerMapSchema({ maxValueLength }) {
31
+ const value = z.string().max(maxValueLength).regex(HEADER_VALUE);
32
+ return z.record(z.string().max(256).regex(HEADER_TOKEN), z.union([value, z.array(value).min(1).max(32)])).superRefine((headers, context) => {
33
+ const names = new Set();
34
+ let bytes = 0;
35
+ for (const [name, value] of Object.entries(headers)) {
36
+ if (names.has(name.toLowerCase())) {
37
+ context.addIssue({ code: 'custom', message: 'Duplicate case-insensitive header' });
38
+ }
39
+ names.add(name.toLowerCase());
40
+ bytes += Buffer.byteLength(name) + Buffer.byteLength(Array.isArray(value) ? value.join(',') : value);
41
+ }
42
+ if (names.size > 128 || bytes > MAX_HEADER_BYTES) {
43
+ context.addIssue({ code: 'custom', message: 'Tunnel headers exceed their size limit' });
44
+ }
45
+ });
46
+ }
47
+ // A frame carries the gateway's signed request context, which the hosted proxy accepts up to 16 KiB.
48
+ const headersSchema = headerMapSchema({ maxValueLength: 16 * 1024 });
49
+ // A browser request still holds the gateway's own session cookies, which are removed before any
50
+ // frame is built, so they must not use up the application's per-value allowance.
51
+ const browserRequestHeadersSchema = headerMapSchema({ maxValueLength: MAX_HEADER_BYTES });
52
+ /** Enforce origin-form paths before a URL parser can reinterpret a destination. */
53
+ export function assertSafeRequest(method, path) {
54
+ if (!METHODS.has(method))
55
+ throw new Error('Unsupported tunnel request method');
56
+ if (path.length > 16 * 1024 || !path.startsWith('/') || path.startsWith('//') ||
57
+ !/^[\x21-\x7e]+$/.test(path) || /[\\#]/.test(path) ||
58
+ /%(?![a-f0-9]{2})/i.test(path) || /%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/i.test(path)) {
59
+ throw new Error('Tunnel request requires a safe origin-form path');
60
+ }
61
+ }
62
+ const requestPathSchema = z.string().refine((path) => {
63
+ try {
64
+ assertSafeRequest('GET', path);
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }, 'Tunnel request requires a safe origin-form path');
71
+ const streamIdSchema = z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
72
+ const dataSchema = z.string()
73
+ .max(4 * Math.ceil(DEFAULT_TUNNEL_LIMITS.maxChunkBytes / 3))
74
+ .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/)
75
+ .refine((data) => {
76
+ const decoded = Buffer.from(data, 'base64');
77
+ return decoded.length <= DEFAULT_TUNNEL_LIMITS.maxChunkBytes && decoded.toString('base64') === data;
78
+ }, 'Tunnel data must be canonical, bounded base64');
79
+ export const tunnelFrameSchema = z.discriminatedUnion('type', [
80
+ z.object({ type: z.literal('ready'), id: streamIdSchema, epoch: z.number().int().positive() }).strict(),
81
+ z.object({
82
+ type: z.literal('request'), id: streamIdSchema,
83
+ method: z.string().refine((method) => METHODS.has(method)),
84
+ path: requestPathSchema, headers: headersSchema, websocket: z.boolean(),
85
+ }).strict(),
86
+ z.object({
87
+ type: z.literal('response'), id: streamIdSchema,
88
+ status: z.number().int().min(200).max(599), headers: headersSchema,
89
+ }).strict(),
90
+ z.object({
91
+ type: z.literal('data'), id: streamIdSchema, data: dataSchema,
92
+ binary: z.boolean().optional(), final: z.boolean().optional(),
93
+ }).strict(),
94
+ z.object({ type: z.literal('end'), id: streamIdSchema }).strict(),
95
+ z.object({ type: z.literal('cancel'), id: streamIdSchema }).strict(),
96
+ z.object({
97
+ type: z.literal('ws-open'), id: streamIdSchema,
98
+ protocol: z.string().max(256).refine((protocol) => protocol === '' || HEADER_TOKEN.test(protocol)),
99
+ }).strict(),
100
+ z.object({
101
+ type: z.literal('ws-close'), id: streamIdSchema,
102
+ code: z.number().int().refine((code) => (code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code)) ||
103
+ (code >= 3000 && code <= 4999)),
104
+ reason: z.string().refine((reason) => Buffer.byteLength(reason) <= 123),
105
+ }).strict(),
106
+ z.object({
107
+ type: z.literal('ack'), id: streamIdSchema,
108
+ bytes: z.number().int().min(1).max(DEFAULT_TUNNEL_LIMITS.maxInFlightBytes),
109
+ }).strict(),
110
+ ]);
111
+ /** Compatible with ws RawData without introducing a websocket dependency here. */
112
+ export function parseTunnelFrame(input) {
113
+ const bytes = typeof input === 'string' ? Buffer.byteLength(input)
114
+ : Array.isArray(input) ? input.reduce((total, part) => total + part.byteLength, 0)
115
+ : input.byteLength;
116
+ if (bytes > DEFAULT_TUNNEL_LIMITS.maxFrameBytes)
117
+ throw new Error('Tunnel frame exceeds its size limit');
118
+ const source = typeof input === 'string' ? input : new TextDecoder('utf-8', { fatal: true }).decode(Array.isArray(input) ? Buffer.concat(input) : input);
119
+ return tunnelFrameSchema.parse(JSON.parse(source));
120
+ }
121
+ export function encodeTunnelFrame(frame) {
122
+ const source = JSON.stringify(tunnelFrameSchema.parse(frame));
123
+ if (Buffer.byteLength(source) > DEFAULT_TUNNEL_LIMITS.maxFrameBytes) {
124
+ throw new Error('Tunnel frame exceeds its size limit');
125
+ }
126
+ return source;
127
+ }
128
+ export function normalizePublicOrigin(value) {
129
+ const url = new URL(value);
130
+ const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
131
+ if (!/^https?:\/\/[^/?#@\\\s]+\/?$/i.test(value) || url.username || url.password || url.search || url.hash ||
132
+ url.pathname !== '/' || (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)))
133
+ throw new Error('Tunnel public origin must be an HTTPS origin or HTTP loopback origin');
134
+ return url.origin;
135
+ }
136
+ function normalizedHeaders(headers, schema = headersSchema) {
137
+ const present = Object.fromEntries(Object.entries(headers).filter(([, value]) => value !== undefined));
138
+ return new Map(Object.entries(schema.parse(present)).map(([name, value]) => [name.toLowerCase(), value]));
139
+ }
140
+ function removePrivateHeaders(headers) {
141
+ const connection = headers.get('connection');
142
+ const nominated = new Set((Array.isArray(connection) ? connection.join(',') : connection ?? '')
143
+ .split(',').map((name) => name.trim().toLowerCase()));
144
+ for (const name of headers.keys()) {
145
+ if (HOP_HEADERS.has(name) || nominated.has(name) || name === 'forwarded' ||
146
+ name === 'authorization' || name === 'remote-user' || name === 'x-remote-user' ||
147
+ name === 'x-serverless-authorization' || name === 'x-goog-iap-jwt-assertion' ||
148
+ /^(?:x-forwarded-|x-sequence-|x-artifact-|x-atlas-|x-user-|x-auth-|x-authenticated-|x-goog-authenticated-|proxy-)/.test(name))
149
+ headers.delete(name);
150
+ }
151
+ }
152
+ /** Rebuild trusted routing headers; no Atlas bearer/session crosses into app code. */
153
+ export function sanitizeRequestHeaders(headers, publicOrigin) {
154
+ const origin = normalizePublicOrigin(publicOrigin);
155
+ const normalized = normalizedHeaders(headers, browserRequestHeadersSchema);
156
+ const suppliedOrigin = normalized.get('origin');
157
+ if (suppliedOrigin !== undefined && suppliedOrigin !== origin)
158
+ throw new Error('Tunnel request origin mismatch');
159
+ removePrivateHeaders(normalized);
160
+ const cookie = normalized.get('cookie');
161
+ if (cookie !== undefined) {
162
+ const remaining = (Array.isArray(cookie) ? cookie.join(';') : cookie).split(';')
163
+ .map((part) => part.trim()).filter((part) => {
164
+ const separator = part.indexOf('=');
165
+ return separator > 0 && !isPlatformCookie(part.slice(0, separator).trim());
166
+ }).join('; ');
167
+ if (remaining)
168
+ normalized.set('cookie', remaining);
169
+ else
170
+ normalized.delete('cookie');
171
+ }
172
+ const url = new URL(origin);
173
+ normalized.set('host', url.host);
174
+ normalized.set('x-forwarded-host', url.host);
175
+ normalized.set('x-forwarded-proto', url.protocol.slice(0, -1));
176
+ return Object.fromEntries(normalized);
177
+ }
178
+ function constrainCookie(cookie) {
179
+ const [assignment, ...attributes] = cookie.split(';');
180
+ const separator = assignment.indexOf('=');
181
+ if (separator <= 0)
182
+ throw new Error('Invalid application Set-Cookie header');
183
+ const name = assignment.slice(0, separator).trim();
184
+ if (!HEADER_TOKEN.test(name))
185
+ throw new Error('Invalid application cookie name');
186
+ if (isPlatformCookie(name))
187
+ return undefined;
188
+ return [assignment.trim(), ...attributes.map((attribute) => attribute.trim())
189
+ .filter((attribute) => attribute && attribute.split('=', 1)[0].trim().toLowerCase() !== 'domain')].join('; ');
190
+ }
191
+ /** Response headers through which application output could opt into CDN or proxy reuse. */
192
+ export function isSharedCacheDirective(name) {
193
+ return /(?:^|-)cdn-cache-control$/.test(name) || ['surrogate-control', 'x-accel-expires', 'expires', 'age'].includes(name);
194
+ }
195
+ /** Sent in their place on every protected response, so each reuse must pass gateway authorization again. */
196
+ export const SHARED_CACHE_REFUSAL = Object.freeze({ 'cdn-cache-control': 'no-store', 'surrogate-control': 'no-store', pragma: 'no-cache', expires: '0' });
197
+ export function sanitizeResponseHeaders(headers) {
198
+ const normalized = normalizedHeaders(headers);
199
+ removePrivateHeaders(normalized);
200
+ const cookies = normalized.get('set-cookie');
201
+ if (cookies !== undefined) {
202
+ const constrained = (Array.isArray(cookies) ? cookies : [cookies])
203
+ .map(constrainCookie).filter((cookie) => cookie !== undefined);
204
+ if (constrained.length)
205
+ normalized.set('set-cookie', constrained);
206
+ else
207
+ normalized.delete('set-cookie');
208
+ }
209
+ return Object.fromEntries(normalized);
210
+ }
@@ -0,0 +1,37 @@
1
+ import { type Envelope } from './redis-types.js';
2
+ /** Frames older (or further in the future) than this are replays or come from a stalled node. */
3
+ export declare const RELAY_FRAME_MAX_AGE_MS = 30000;
4
+ /** An envelope quotes a frame as a JSON string: at most double its size, plus fixed fields and the MAC. */
5
+ export declare const RELAY_MESSAGE_MAX_BYTES: number;
6
+ /**
7
+ * The relay's frame key: an HKDF-SHA256 subkey of the gateway-only session key, bound to one
8
+ * environment. Atlas and every other Redis client lack that key, so they cannot forge frames, and
9
+ * the session key itself is never used as a MAC key for anything but cookies.
10
+ */
11
+ export declare function deriveRelayFrameKey({ secret, namespace }: {
12
+ secret: string;
13
+ namespace: string;
14
+ }): Buffer;
15
+ /**
16
+ * Authenticates relay messages: the MAC binds the destination channel, so a captured frame cannot be
17
+ * redirected to another node, and each source's strictly increasing sequence plus a freshness window
18
+ * rejects replays. A single publisher connection per node keeps Redis delivery in sequence order.
19
+ */
20
+ export declare class RelayFrameAuthenticator {
21
+ private readonly key;
22
+ private readonly now;
23
+ private sequence;
24
+ private readonly peers;
25
+ constructor(key: Buffer, now?: () => number);
26
+ seal({ channel, envelope }: {
27
+ channel: string;
28
+ envelope: Envelope;
29
+ }): string;
30
+ /** Null for any frame that is oversized, unauthenticated, stale, replayed or malformed. */
31
+ open({ channel, message }: {
32
+ channel: string;
33
+ message: string;
34
+ }): Envelope | null;
35
+ /** Forgotten peers can only send frames newer than the freshness window, which rejects replays. */
36
+ private forgetIdlePeers;
37
+ }