@sequenceholdings/artifact-studio 0.2.5 → 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.
- package/README.md +35 -0
- package/VERSION-PIN.md +14 -3
- package/dist/api.d.ts +11 -1
- package/dist/api.js +26 -9
- package/dist/atlas-ui-css-plugin.d.ts +11 -7
- package/dist/atlas-ui-css-plugin.js +38 -10
- package/dist/build.d.ts +2 -2
- package/dist/build.js +14 -6
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +108 -37
- package/dist/dev-link.d.ts +1 -1
- package/dist/dev-link.js +2 -1
- package/dist/gateway/admission.d.ts +55 -0
- package/dist/gateway/admission.js +132 -0
- package/dist/gateway/control.d.ts +91 -0
- package/dist/gateway/control.js +113 -0
- package/dist/gateway/hosted-proxy.d.ts +36 -0
- package/dist/gateway/hosted-proxy.js +347 -0
- package/dist/gateway/release-admission.d.ts +23 -0
- package/dist/gateway/release-admission.js +259 -0
- package/dist/gateway/server.d.ts +29 -0
- package/dist/gateway/server.js +298 -0
- package/dist/gateway/session.d.ts +71 -0
- package/dist/gateway/session.js +143 -0
- package/dist/gateway/usage.d.ts +37 -0
- package/dist/gateway/usage.js +46 -0
- package/dist/gateway-main.d.ts +1 -0
- package/dist/gateway-main.js +84 -0
- package/dist/git-clone.d.ts +23 -1
- package/dist/git-clone.js +32 -15
- package/dist/manifest-next.d.ts +217 -0
- package/dist/manifest-next.js +120 -0
- package/dist/manifest-static.d.ts +76 -0
- package/dist/manifest-static.js +124 -0
- package/dist/manifest.d.ts +124 -19
- package/dist/manifest.js +12 -118
- package/dist/next/bridge.d.ts +47 -0
- package/dist/next/bridge.js +229 -0
- package/dist/next/browser-logs.d.ts +7 -0
- package/dist/next/browser-logs.js +51 -0
- package/dist/next/browser.d.ts +6 -0
- package/dist/next/browser.js +57 -0
- package/dist/next/protocol.d.ts +73 -0
- package/dist/next/protocol.js +56 -0
- package/dist/next/release.d.ts +15 -0
- package/dist/next/release.js +46 -0
- package/dist/next/requests.d.ts +15 -0
- package/dist/next/requests.js +59 -0
- package/dist/next/routes.d.ts +4 -0
- package/dist/next/routes.js +21 -0
- package/dist/next/streams.d.ts +16 -0
- package/dist/next/streams.js +132 -0
- package/dist/next/theme.d.ts +10 -0
- package/dist/next/theme.js +45 -0
- package/dist/next/transport.d.ts +15 -0
- package/dist/next/transport.js +13 -0
- package/dist/next-build.d.ts +29 -0
- package/dist/next-build.js +129 -0
- package/dist/next-cache/adapter-runtime.d.ts +51 -0
- package/dist/next-cache/adapter-runtime.js +73 -0
- package/dist/next-cache/component-handler.d.ts +3 -0
- package/dist/next-cache/component-handler.js +10 -0
- package/dist/next-cache/components.d.ts +20 -0
- package/dist/next-cache/components.js +113 -0
- package/dist/next-cache/config.d.ts +9 -0
- package/dist/next-cache/config.js +78 -0
- package/dist/next-cache/incremental-handler.d.ts +18 -0
- package/dist/next-cache/incremental-handler.js +17 -0
- package/dist/next-cache/incremental.d.ts +27 -0
- package/dist/next-cache/incremental.js +99 -0
- package/dist/next-cache/index.d.ts +18 -0
- package/dist/next-cache/index.js +16 -0
- package/dist/next-cache/memory-store.d.ts +27 -0
- package/dist/next-cache/memory-store.js +118 -0
- package/dist/next-cache/redis-scripts.d.ts +3 -0
- package/dist/next-cache/redis-scripts.js +85 -0
- package/dist/next-cache/redis-store.d.ts +18 -0
- package/dist/next-cache/redis-store.js +56 -0
- package/dist/next-cache/runtime.d.ts +29 -0
- package/dist/next-cache/runtime.js +63 -0
- package/dist/next-cache/serialization.d.ts +39 -0
- package/dist/next-cache/serialization.js +333 -0
- package/dist/next-cache/store.d.ts +54 -0
- package/dist/next-cache/store.js +45 -0
- package/dist/next-cache/valkey-transport.d.ts +52 -0
- package/dist/next-cache/valkey-transport.js +189 -0
- package/dist/next-dev-control.d.ts +48 -0
- package/dist/next-dev-control.js +63 -0
- package/dist/next-dev-process.d.ts +17 -0
- package/dist/next-dev-process.js +110 -0
- package/dist/next-dev.d.ts +12 -0
- package/dist/next-dev.js +267 -0
- package/dist/next-image-config.d.ts +9 -0
- package/dist/next-image-config.js +101 -0
- package/dist/next-project.d.ts +22 -0
- package/dist/next-project.js +64 -0
- package/dist/next-public-fonts-local.d.ts +7 -0
- package/dist/next-public-fonts-local.js +64 -0
- package/dist/next-public-fonts.d.ts +13 -0
- package/dist/next-public-fonts.js +49 -0
- package/dist/next-source-hash.d.ts +14 -0
- package/dist/next-source-hash.js +18 -0
- package/dist/next-source.d.ts +35 -0
- package/dist/next-source.js +155 -0
- package/dist/next.d.ts +9 -0
- package/dist/next.js +5 -0
- package/dist/project.d.ts +3 -0
- package/dist/project.js +9 -1
- package/dist/runtime-contracts.d.ts +229 -0
- package/dist/runtime-contracts.js +224 -0
- package/dist/sdk.d.ts +420 -1
- package/dist/sdk.js +35 -0
- package/dist/server/broker-contract.d.ts +68 -0
- package/dist/server/broker-contract.js +33 -0
- package/dist/server/client.d.ts +67 -0
- package/dist/server/client.js +159 -0
- package/dist/server/logger.d.ts +26 -0
- package/dist/server/logger.js +24 -0
- package/dist/server/release-forms.d.ts +13 -0
- package/dist/server/release-forms.js +124 -0
- package/dist/server/request-context.d.ts +74 -0
- package/dist/server/request-context.js +103 -0
- package/dist/server/runtime-config.d.ts +53 -0
- package/dist/server/runtime-config.js +64 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +4 -0
- package/dist/templates/nextjs/.artifactignore +14 -0
- package/dist/templates/nextjs/README.md +151 -0
- package/dist/templates/nextjs/app/about/page.tsx +26 -0
- package/dist/templates/nextjs/app/actions.ts +18 -0
- package/dist/templates/nextjs/app/api/health/route.ts +6 -0
- package/dist/templates/nextjs/app/components/counter.tsx +19 -0
- package/dist/templates/nextjs/app/components/greeting-form.tsx +38 -0
- package/dist/templates/nextjs/app/globals.css +4 -0
- package/dist/templates/nextjs/app/layout.tsx +27 -0
- package/dist/templates/nextjs/app/page.tsx +51 -0
- package/dist/templates/nextjs/app/providers.tsx +107 -0
- package/dist/templates/nextjs/artifact.bundle.yml +55 -0
- package/dist/templates/nextjs/next.config.mjs +27 -0
- package/dist/templates/nextjs/package.json +32 -0
- package/dist/templates/nextjs/pnpm-workspace.yaml +8 -0
- package/dist/templates/nextjs/postcss.config.mjs +5 -0
- package/dist/templates/nextjs/tsconfig.json +20 -0
- package/dist/templates/react-vite/artifact.bundle.yml +0 -1
- package/dist/templates/react-vite/package.json +1 -1
- package/dist/tunnel/broker.d.ts +75 -0
- package/dist/tunnel/broker.js +451 -0
- package/dist/tunnel/client.d.ts +22 -0
- package/dist/tunnel/client.js +177 -0
- package/dist/tunnel/index.d.ts +6 -0
- package/dist/tunnel/index.js +4 -0
- package/dist/tunnel/io.d.ts +33 -0
- package/dist/tunnel/io.js +108 -0
- package/dist/tunnel/peer.d.ts +25 -0
- package/dist/tunnel/peer.js +147 -0
- package/dist/tunnel/protocol.d.ts +69 -0
- package/dist/tunnel/protocol.js +196 -0
- package/dist/tunnel/redis-ownership.d.ts +38 -0
- package/dist/tunnel/redis-ownership.js +100 -0
- package/dist/tunnel/redis-socket.d.ts +13 -0
- package/dist/tunnel/redis-socket.js +48 -0
- package/dist/tunnel/redis-types.d.ts +70 -0
- package/dist/tunnel/redis-types.js +9 -0
- package/dist/tunnel/redis.d.ts +53 -0
- package/dist/tunnel/redis.js +391 -0
- package/dist/tunnel/transport.d.ts +17 -0
- package/dist/tunnel/transport.js +1 -0
- package/package.json +68 -4
- package/templates/nextjs/.artifactignore +14 -0
- package/templates/nextjs/README.md +151 -0
- package/templates/nextjs/app/about/page.tsx +26 -0
- package/templates/nextjs/app/actions.ts +18 -0
- package/templates/nextjs/app/api/health/route.ts +6 -0
- package/templates/nextjs/app/components/counter.tsx +19 -0
- package/templates/nextjs/app/components/greeting-form.tsx +38 -0
- package/templates/nextjs/app/globals.css +4 -0
- package/templates/nextjs/app/layout.tsx +27 -0
- package/templates/nextjs/app/page.tsx +51 -0
- package/templates/nextjs/app/providers.tsx +107 -0
- package/templates/nextjs/artifact.bundle.yml +55 -0
- package/templates/nextjs/next.config.mjs +27 -0
- package/templates/nextjs/package.json +32 -0
- package/templates/nextjs/pnpm-workspace.yaml +8 -0
- package/templates/nextjs/postcss.config.mjs +5 -0
- package/templates/nextjs/tsconfig.json +20 -0
- package/templates/react-vite/artifact.bundle.yml +0 -1
- package/templates/react-vite/package.json +1 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
|
|
2
|
+
const RELEASE_FIELD = '__sequence_release';
|
|
3
|
+
const RELEASE_HEADER = 'x-sequence-artifact-release';
|
|
4
|
+
const FRAMEWORK_BUILD_HEADER = 'x-deployment-id';
|
|
5
|
+
const MAX_PREFIX_BYTES = 64 * 1024;
|
|
6
|
+
const MAX_RELEASE_BYTES = 255;
|
|
7
|
+
const RELEASE_ID = /^[a-zA-Z0-9_-]{1,255}$/;
|
|
8
|
+
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
9
|
+
export class ReleaseAdmissionError extends Error {
|
|
10
|
+
reason;
|
|
11
|
+
status = 409;
|
|
12
|
+
code = 'update-required';
|
|
13
|
+
constructor(reason = 'missing-release') {
|
|
14
|
+
super('Reload the application before submitting this request. The request was not executed.');
|
|
15
|
+
this.reason = reason;
|
|
16
|
+
this.name = 'ReleaseAdmissionError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function parseUrlEncoded({ prefix, ended }) {
|
|
20
|
+
const separator = prefix.indexOf(0x26);
|
|
21
|
+
if (separator < 0 && !ended)
|
|
22
|
+
return undefined;
|
|
23
|
+
const first = prefix.subarray(0, separator < 0 ? prefix.length : separator).toString('utf8');
|
|
24
|
+
const equals = first.indexOf('=');
|
|
25
|
+
if (equals < 0)
|
|
26
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
27
|
+
try {
|
|
28
|
+
const name = decodeURIComponent(first.slice(0, equals).replace(/\+/g, ' '));
|
|
29
|
+
const release = decodeURIComponent(first.slice(equals + 1).replace(/\+/g, ' '));
|
|
30
|
+
if (name !== RELEASE_FIELD || Buffer.byteLength(release) > MAX_RELEASE_BYTES || !RELEASE_ID.test(release)) {
|
|
31
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
32
|
+
}
|
|
33
|
+
return release;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function multipartParser({ boundary }) {
|
|
40
|
+
const opening = Buffer.from(`--${boundary}\r\n`);
|
|
41
|
+
const delimiter = Buffer.from(`\r\n--${boundary}`);
|
|
42
|
+
return ({ prefix, ended }) => {
|
|
43
|
+
if (!prefix.subarray(0, Math.min(prefix.length, opening.length)).equals(opening.subarray(0, Math.min(prefix.length, opening.length)))) {
|
|
44
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
45
|
+
}
|
|
46
|
+
if (prefix.length < opening.length) {
|
|
47
|
+
if (ended)
|
|
48
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const headerEnd = prefix.indexOf('\r\n\r\n', opening.length);
|
|
52
|
+
if (headerEnd < 0) {
|
|
53
|
+
if (ended)
|
|
54
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const headers = new Map();
|
|
58
|
+
for (const line of prefix.subarray(opening.length, headerEnd).toString('latin1').split('\r\n')) {
|
|
59
|
+
const colon = line.indexOf(':');
|
|
60
|
+
if (colon <= 0)
|
|
61
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
62
|
+
const name = line.slice(0, colon).toLowerCase();
|
|
63
|
+
const value = line.slice(colon + 1).trim();
|
|
64
|
+
if (!['content-disposition', 'content-type'].includes(name) || headers.has(name)) {
|
|
65
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
66
|
+
}
|
|
67
|
+
headers.set(name, value);
|
|
68
|
+
}
|
|
69
|
+
const disposition = headers.get('content-disposition')?.split(';').map((part) => part.trim());
|
|
70
|
+
if (!disposition || disposition.length !== 2 || disposition[0].toLowerCase() !== 'form-data') {
|
|
71
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
72
|
+
}
|
|
73
|
+
const field = disposition[1];
|
|
74
|
+
const equals = field.indexOf('=');
|
|
75
|
+
if (equals < 0 || field.slice(0, equals).toLowerCase() !== 'name' ||
|
|
76
|
+
![RELEASE_FIELD, `"${RELEASE_FIELD}"`].includes(field.slice(equals + 1))) {
|
|
77
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
78
|
+
}
|
|
79
|
+
const contentType = headers.get('content-type');
|
|
80
|
+
if (contentType && !/^text\/plain(?:\s*;\s*charset=(?:utf-8|"utf-8"))?$/i.test(contentType)) {
|
|
81
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
82
|
+
}
|
|
83
|
+
const valueStart = headerEnd + 4;
|
|
84
|
+
const valueEnd = prefix.indexOf(delimiter, valueStart);
|
|
85
|
+
if (valueEnd < 0) {
|
|
86
|
+
if (ended || prefix.length - valueStart > MAX_RELEASE_BYTES + delimiter.length - 1) {
|
|
87
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
if (valueEnd - valueStart > MAX_RELEASE_BYTES)
|
|
92
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
93
|
+
const suffixOffset = valueEnd + delimiter.length;
|
|
94
|
+
if (prefix.length < suffixOffset + 2) {
|
|
95
|
+
if (ended)
|
|
96
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const suffix = prefix.subarray(suffixOffset, suffixOffset + 2).toString('ascii');
|
|
100
|
+
if (suffix !== '\r\n' && suffix !== '--')
|
|
101
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
102
|
+
if (suffix === '--') {
|
|
103
|
+
const closingEnd = suffixOffset + 2;
|
|
104
|
+
if (prefix.length < closingEnd + 2) {
|
|
105
|
+
if (!ended)
|
|
106
|
+
return undefined;
|
|
107
|
+
if (prefix.length !== closingEnd)
|
|
108
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
109
|
+
}
|
|
110
|
+
else if (!prefix.subarray(closingEnd, closingEnd + 2).equals(Buffer.from('\r\n'))) {
|
|
111
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const release = prefix.subarray(valueStart, valueEnd).toString('utf8');
|
|
115
|
+
if (!RELEASE_ID.test(release))
|
|
116
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
117
|
+
return release;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function getFormParser({ contentType }) {
|
|
121
|
+
if (typeof contentType !== 'string')
|
|
122
|
+
throw new ReleaseAdmissionError('missing-release');
|
|
123
|
+
const [mediaType, ...parameters] = contentType.split(';').map((part) => part.trim());
|
|
124
|
+
if (mediaType.toLowerCase() === 'application/x-www-form-urlencoded')
|
|
125
|
+
return parseUrlEncoded;
|
|
126
|
+
if (mediaType.toLowerCase() !== 'multipart/form-data')
|
|
127
|
+
throw new ReleaseAdmissionError('missing-release');
|
|
128
|
+
const boundaries = parameters.filter((parameter) => parameter.slice(0, parameter.indexOf('=')).toLowerCase() === 'boundary');
|
|
129
|
+
if (boundaries.length !== 1)
|
|
130
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
131
|
+
const raw = boundaries[0].slice(boundaries[0].indexOf('=') + 1);
|
|
132
|
+
const boundary = raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw;
|
|
133
|
+
if (!/^[0-9A-Za-z'()+_,./:=? -]{1,70}$/.test(boundary) || boundary.endsWith(' ') ||
|
|
134
|
+
(!raw.startsWith('"') && boundary.includes(' '))) {
|
|
135
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
136
|
+
}
|
|
137
|
+
return multipartParser({ boundary });
|
|
138
|
+
}
|
|
139
|
+
function checkRelease({ release, expectedDeploymentId }) {
|
|
140
|
+
if (!RELEASE_ID.test(release) || release !== expectedDeploymentId)
|
|
141
|
+
throw new ReleaseAdmissionError('stale-release');
|
|
142
|
+
}
|
|
143
|
+
function readReleaseHeader({ request, name }) {
|
|
144
|
+
const value = request.headers[name];
|
|
145
|
+
// Inspect raw headers too: Node may discard rather than join duplicate values.
|
|
146
|
+
const count = request.rawHeaders.filter((_, index) => index % 2 === 0 && request.rawHeaders[index].toLowerCase() === name).length;
|
|
147
|
+
if (count > 1)
|
|
148
|
+
throw new ReleaseAdmissionError('conflicting-release');
|
|
149
|
+
if (value === undefined)
|
|
150
|
+
return undefined;
|
|
151
|
+
if (typeof value !== 'string' || !RELEASE_ID.test(value))
|
|
152
|
+
throw new ReleaseAdmissionError('conflicting-release');
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
/** Framework hints identify compiled assets, never the Atlas activation authorizing a mutation. */
|
|
156
|
+
function validateFrameworkHints({ request, expectedFrameworkBuildId }) {
|
|
157
|
+
const header = readReleaseHeader({ request, name: FRAMEWORK_BUILD_HEADER });
|
|
158
|
+
let query;
|
|
159
|
+
try {
|
|
160
|
+
query = new URL(request.url ?? '/', 'http://release.invalid').searchParams.getAll('dpl');
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
throw new ReleaseAdmissionError('conflicting-release');
|
|
164
|
+
}
|
|
165
|
+
if (!artifactNextFrameworkBuildIdSchema.safeParse(expectedFrameworkBuildId).success ||
|
|
166
|
+
(header !== undefined && !artifactNextFrameworkBuildIdSchema.safeParse(header).success) ||
|
|
167
|
+
query.length > 1 || (query.length === 1 && !artifactNextFrameworkBuildIdSchema.safeParse(query[0]).success)) {
|
|
168
|
+
throw new ReleaseAdmissionError('conflicting-release');
|
|
169
|
+
}
|
|
170
|
+
if (header !== undefined && query.length === 1 && header !== query[0]) {
|
|
171
|
+
throw new ReleaseAdmissionError('conflicting-release');
|
|
172
|
+
}
|
|
173
|
+
const frameworkBuildId = header ?? query[0];
|
|
174
|
+
if (frameworkBuildId !== undefined && frameworkBuildId !== expectedFrameworkBuildId) {
|
|
175
|
+
throw new ReleaseAdmissionError('stale-release');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function bytes({ value }) {
|
|
179
|
+
if (!(value instanceof Uint8Array))
|
|
180
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Admit a mutation against the release that rendered its document. No request is
|
|
185
|
+
* forwarded until admission succeeds. An admitted form exposes one continuation
|
|
186
|
+
* over the original iterator, including every byte inspected here.
|
|
187
|
+
*/
|
|
188
|
+
export async function admitHostedMutation({ request, expectedDeploymentId, expectedFrameworkBuildId, active, published }) {
|
|
189
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
190
|
+
validateFrameworkHints({ request, expectedFrameworkBuildId });
|
|
191
|
+
if (READ_METHODS.has(method))
|
|
192
|
+
return {};
|
|
193
|
+
if (published && !active)
|
|
194
|
+
throw new ReleaseAdmissionError('inactive-release');
|
|
195
|
+
const release = readReleaseHeader({ request, name: RELEASE_HEADER });
|
|
196
|
+
if (release !== undefined) {
|
|
197
|
+
checkRelease({ release, expectedDeploymentId });
|
|
198
|
+
return {};
|
|
199
|
+
}
|
|
200
|
+
if (method !== 'POST')
|
|
201
|
+
throw new ReleaseAdmissionError('missing-release');
|
|
202
|
+
const parse = getFormParser({ contentType: request.headers['content-type'] });
|
|
203
|
+
// A normal for-await break destroys IncomingMessage. Retain this one iterator
|
|
204
|
+
// while peeking, then hand its continuation to the proxy without a second read.
|
|
205
|
+
const iterator = request.iterator({ destroyOnReturn: false });
|
|
206
|
+
const buffered = [];
|
|
207
|
+
const prefixBuffer = Buffer.alloc(MAX_PREFIX_BYTES);
|
|
208
|
+
let prefixLength = 0;
|
|
209
|
+
let ended = false;
|
|
210
|
+
try {
|
|
211
|
+
while (true) {
|
|
212
|
+
const release = parse({ prefix: prefixBuffer.subarray(0, prefixLength), ended });
|
|
213
|
+
if (release !== undefined) {
|
|
214
|
+
checkRelease({ release, expectedDeploymentId });
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
if (ended)
|
|
218
|
+
throw new ReleaseAdmissionError('invalid-form');
|
|
219
|
+
if (prefixLength >= MAX_PREFIX_BYTES)
|
|
220
|
+
throw new ReleaseAdmissionError('prefix-limit');
|
|
221
|
+
const next = await iterator.next();
|
|
222
|
+
ended = next.done === true;
|
|
223
|
+
if (!ended) {
|
|
224
|
+
const chunk = bytes({ value: next.value });
|
|
225
|
+
buffered.push(chunk);
|
|
226
|
+
// Inspect at most 64 KiB even when a transport read returns a larger
|
|
227
|
+
// chunk. Keep that original chunk for lossless, zero-copy forwarding.
|
|
228
|
+
const inspected = chunk.subarray(0, MAX_PREFIX_BYTES - prefixLength);
|
|
229
|
+
prefixBuffer.set(inspected, prefixLength);
|
|
230
|
+
prefixLength += inspected.length;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
await iterator.return?.();
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
async function* continuation() {
|
|
239
|
+
try {
|
|
240
|
+
for (const chunk of buffered)
|
|
241
|
+
yield chunk;
|
|
242
|
+
if (ended)
|
|
243
|
+
return;
|
|
244
|
+
while (true) {
|
|
245
|
+
const next = await iterator.next();
|
|
246
|
+
if (next.done)
|
|
247
|
+
return;
|
|
248
|
+
yield bytes({ value: next.value });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
// The transport owns abort/connection-close behavior. Returning this
|
|
253
|
+
// non-destructive iterator releases its listeners without losing a 409
|
|
254
|
+
// or upstream-error response by prematurely destroying the client socket.
|
|
255
|
+
await iterator.return?.();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { body: continuation() };
|
|
259
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
+
import { type TunnelLease, type TunnelBrokerOptions } from '../tunnel/index.js';
|
|
3
|
+
import { ArtifactGatewayControl } from './control.js';
|
|
4
|
+
import { type ArtifactRequestAdmission } from './admission.js';
|
|
5
|
+
export interface ArtifactGatewayOptions {
|
|
6
|
+
control: ArtifactGatewayControl;
|
|
7
|
+
runtimeDomain: string;
|
|
8
|
+
parentOrigin: string;
|
|
9
|
+
gatewayOrigin: string;
|
|
10
|
+
sessionKey: string;
|
|
11
|
+
contextPrivateKey: string;
|
|
12
|
+
contextKeyId: string;
|
|
13
|
+
/** Unique per process, never a shared Cloud Run revision name. */
|
|
14
|
+
nodeId: string;
|
|
15
|
+
routing: TunnelBrokerOptions['routing'];
|
|
16
|
+
relay?: TunnelBrokerOptions['relay'];
|
|
17
|
+
resolveLease?: TunnelBrokerOptions['resolveLease'];
|
|
18
|
+
renewLease?: (lease: TunnelLease) => Promise<boolean>;
|
|
19
|
+
ready?: () => boolean;
|
|
20
|
+
admission?: Pick<ArtifactRequestAdmission, 'acquire'>;
|
|
21
|
+
onError?: (event: {
|
|
22
|
+
kind: string;
|
|
23
|
+
requestId?: string;
|
|
24
|
+
}) => void;
|
|
25
|
+
}
|
|
26
|
+
export declare function createArtifactGateway(options: ArtifactGatewayOptions): {
|
|
27
|
+
server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
28
|
+
drain(): Promise<void>;
|
|
29
|
+
};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { createPrivateKey, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { ArtifactTunnelBroker } from '../tunnel/index.js';
|
|
5
|
+
import { assertSafeRequest, normalizePublicOrigin } from '../tunnel/protocol.js';
|
|
6
|
+
import { GatewayControlError } from './control.js';
|
|
7
|
+
import { decodeGatewaySession, gatewaySessionCookies, renewGatewaySession, signGatewayContext } from './session.js';
|
|
8
|
+
import { proxyHostedArtifact } from './hosted-proxy.js';
|
|
9
|
+
import { admitHostedMutation, ReleaseAdmissionError } from './release-admission.js';
|
|
10
|
+
import { ArtifactAdmissionError } from './admission.js';
|
|
11
|
+
import { GatewayUsageReporter } from './usage.js';
|
|
12
|
+
import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
|
|
13
|
+
function json(response, status, error) {
|
|
14
|
+
if (response.headersSent) {
|
|
15
|
+
response.destroy();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (status >= 400)
|
|
19
|
+
response.setHeader('connection', 'close');
|
|
20
|
+
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
21
|
+
response.end(JSON.stringify({ error }));
|
|
22
|
+
}
|
|
23
|
+
function rejectUpgrade(socket, status = 403) {
|
|
24
|
+
socket.end(`HTTP/1.1 ${status} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
25
|
+
}
|
|
26
|
+
function header(request, key) {
|
|
27
|
+
const value = request.headers[key];
|
|
28
|
+
return typeof value === 'string' ? value : undefined;
|
|
29
|
+
}
|
|
30
|
+
function releaseHints(request) {
|
|
31
|
+
function uniqueHeader(name) {
|
|
32
|
+
const value = request.headers[name];
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
return undefined;
|
|
35
|
+
const count = request.rawHeaders.filter((_, index) => index % 2 === 0 && request.rawHeaders[index].toLowerCase() === name).length;
|
|
36
|
+
if (typeof value !== 'string' || count !== 1 || !artifactNextFrameworkBuildIdSchema.safeParse(value).success)
|
|
37
|
+
throw new ReleaseAdmissionError('conflicting-release');
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
const deploymentId = uniqueHeader('x-sequence-artifact-release');
|
|
41
|
+
const frameworkHeader = uniqueHeader('x-deployment-id');
|
|
42
|
+
const query = new URL(request.url ?? '/', 'https://gateway.invalid').searchParams.getAll('dpl');
|
|
43
|
+
if (query.length > 1 || (query.length === 1 && !artifactNextFrameworkBuildIdSchema.safeParse(query[0]).success) ||
|
|
44
|
+
(frameworkHeader && query.length === 1 && frameworkHeader !== query[0]))
|
|
45
|
+
throw new ReleaseAdmissionError('conflicting-release');
|
|
46
|
+
return { deploymentId, frameworkBuildId: frameworkHeader ?? query[0] };
|
|
47
|
+
}
|
|
48
|
+
async function boundedJson(request) {
|
|
49
|
+
if (header(request, 'content-type')?.split(';', 1)[0] !== 'application/json')
|
|
50
|
+
throw new GatewayControlError(400);
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let length = 0;
|
|
53
|
+
for await (const chunk of request) {
|
|
54
|
+
const bytes = Buffer.from(chunk);
|
|
55
|
+
length += bytes.length;
|
|
56
|
+
if (length > 2048)
|
|
57
|
+
throw new GatewayControlError(413);
|
|
58
|
+
chunks.push(bytes);
|
|
59
|
+
}
|
|
60
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
61
|
+
}
|
|
62
|
+
/** The bootstrap ticket travels in a fragment and a POST body, never a request URL or log. The page
|
|
63
|
+
* exchanges it only inside the Atlas frame; browsers without Fetch Metadata are covered by the script check. */
|
|
64
|
+
function bootstrapPage({ response, parentOrigin }) {
|
|
65
|
+
const nonce = randomBytes(24).toString('base64');
|
|
66
|
+
response.writeHead(200, {
|
|
67
|
+
'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer',
|
|
68
|
+
'x-content-type-options': 'nosniff',
|
|
69
|
+
'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${parentOrigin}; base-uri 'none'; form-action 'none'`,
|
|
70
|
+
});
|
|
71
|
+
response.end(`<!doctype html><meta charset="utf-8"><title>Connecting application</title><p id="status">Connecting application…</p><script nonce="${nonce}">(() => {const ticket=new URLSearchParams(location.hash.slice(1)).get('ticket');history.replaceState(null,'',location.pathname);if(!ticket||window.top===window){document.getElementById('status').textContent='Open this application from Atlas.';return;}fetch('/_sequence/session',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify({ticket})}).then(r=>{if(!r.ok)throw new Error();location.replace('/');}).catch(()=>{document.getElementById('status').textContent='This preview expired. Open it again from Atlas.';});})();</script>`);
|
|
72
|
+
}
|
|
73
|
+
export function createArtifactGateway(options) {
|
|
74
|
+
const parentOrigin = normalizePublicOrigin(options.parentOrigin);
|
|
75
|
+
const gatewayOrigin = normalizePublicOrigin(options.gatewayOrigin);
|
|
76
|
+
if (!/^[a-z0-9.-]+$/.test(options.runtimeDomain) || options.runtimeDomain.includes('..'))
|
|
77
|
+
throw new Error('Invalid Artifact runtime domain');
|
|
78
|
+
// Validate signing and cookie keys before accepting traffic.
|
|
79
|
+
if (Buffer.byteLength(options.sessionKey) < 32)
|
|
80
|
+
throw new Error('Artifact gateway session key is too short');
|
|
81
|
+
if (createPrivateKey(options.contextPrivateKey).asymmetricKeyType !== 'ed25519')
|
|
82
|
+
throw new Error('Artifact context key must be Ed25519');
|
|
83
|
+
const activeRequests = new WeakMap();
|
|
84
|
+
const leases = new Map();
|
|
85
|
+
const usage = new GatewayUsageReporter({ send: event => options.control.usage(event), onFailure: event => options.onError?.(event) });
|
|
86
|
+
let draining = false;
|
|
87
|
+
function requestOrigin(request) {
|
|
88
|
+
const host = header(request, 'host');
|
|
89
|
+
if (!host || /[\s/@\\?#]/.test(host))
|
|
90
|
+
throw new GatewayControlError(403);
|
|
91
|
+
const origin = `${new URL(gatewayOrigin).protocol}//${host}`;
|
|
92
|
+
const url = new URL(origin);
|
|
93
|
+
// One DNS label per application/session prevents suffix tricks and parent-domain cookies.
|
|
94
|
+
const suffix = `.${options.runtimeDomain}`;
|
|
95
|
+
if (url.hostname !== new URL(gatewayOrigin).hostname &&
|
|
96
|
+
(!url.hostname.endsWith(suffix) || !/^[a-z0-9-]+$/.test(url.hostname.slice(0, -suffix.length))))
|
|
97
|
+
throw new GatewayControlError(403);
|
|
98
|
+
if (url.port !== new URL(gatewayOrigin).port)
|
|
99
|
+
throw new GatewayControlError(403);
|
|
100
|
+
return origin;
|
|
101
|
+
}
|
|
102
|
+
async function authorize(request) {
|
|
103
|
+
const cached = activeRequests.get(request);
|
|
104
|
+
if (cached)
|
|
105
|
+
return cached;
|
|
106
|
+
const origin = requestOrigin(request);
|
|
107
|
+
assertSafeRequest(request.method ?? 'GET', request.url ?? '/');
|
|
108
|
+
const hints = releaseHints(request);
|
|
109
|
+
// Retained releases must belong to the valid main session's actor and scope.
|
|
110
|
+
const session = decodeGatewaySession({ cookie: header(request, 'cookie'), key: options.sessionKey, origin, ...hints });
|
|
111
|
+
if (!session || session.bridge.parentOrigin !== parentOrigin)
|
|
112
|
+
throw new GatewayControlError(403);
|
|
113
|
+
const method = request.method ?? 'GET';
|
|
114
|
+
if ((!['GET', 'HEAD', 'OPTIONS'].includes(method) || header(request, 'upgrade')) && header(request, 'origin') !== origin)
|
|
115
|
+
throw new GatewayControlError(403);
|
|
116
|
+
const target = await options.control.authorize(session);
|
|
117
|
+
const requestId = randomUUID();
|
|
118
|
+
const result = { session, target, requestId, requestContext: signGatewayContext({
|
|
119
|
+
session: { ...session, lattice: target.lattice, taskRoutes: target.taskRoutes }, method, path: request.url ?? '/', privateKey: options.contextPrivateKey, keyId: options.contextKeyId,
|
|
120
|
+
capabilities: target.capabilities, serviceAccountId: target.serviceAccountId, requestId,
|
|
121
|
+
}) };
|
|
122
|
+
activeRequests.set(request, result);
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
const broker = new ArtifactTunnelBroker({
|
|
126
|
+
nodeId: options.nodeId, routing: options.routing, relay: options.relay, resolveLease: options.resolveLease,
|
|
127
|
+
authorizeTunnel: async (request) => {
|
|
128
|
+
if (draining || requestOrigin(request) !== gatewayOrigin || header(request, 'origin'))
|
|
129
|
+
return null;
|
|
130
|
+
const authorization = header(request, 'authorization'), sessionId = header(request, 'x-artifact-session-id');
|
|
131
|
+
const epoch = Number(header(request, 'x-artifact-tunnel-epoch'));
|
|
132
|
+
if (!authorization?.startsWith('Bearer ') || !sessionId || !Number.isSafeInteger(epoch) || epoch < 1)
|
|
133
|
+
return null;
|
|
134
|
+
const token = authorization.slice(7);
|
|
135
|
+
const lease = await options.control.tunnel({ token, sessionId, epoch });
|
|
136
|
+
leases.set(sessionId, { token, lease });
|
|
137
|
+
return lease;
|
|
138
|
+
},
|
|
139
|
+
authorizeRequest: async (request, sessionId) => {
|
|
140
|
+
const authorized = await authorize(request);
|
|
141
|
+
if (authorized.target.kind !== 'development' || authorized.target.sessionId !== sessionId)
|
|
142
|
+
return null;
|
|
143
|
+
return { ownerId: authorized.session.ownerId, publicOrigin: authorized.session.publicOrigin, requestContext: authorized.requestContext };
|
|
144
|
+
},
|
|
145
|
+
validateLease: async (lease) => {
|
|
146
|
+
const credential = leases.get(lease.sessionId);
|
|
147
|
+
// Remote browser nodes use the control-plane session check above; only the owner holds the tunnel credential.
|
|
148
|
+
if (!credential) {
|
|
149
|
+
const owner = await options.routing.lookup(lease.sessionId);
|
|
150
|
+
return Boolean(options.relay && owner && owner.nodeId !== options.nodeId && owner.epoch === lease.epoch);
|
|
151
|
+
}
|
|
152
|
+
if (credential.lease.epoch !== lease.epoch)
|
|
153
|
+
return false;
|
|
154
|
+
try {
|
|
155
|
+
const current = await options.control.tunnel({ token: credential.token, sessionId: lease.sessionId, epoch: lease.epoch });
|
|
156
|
+
lease.expiresAt = current.expiresAt;
|
|
157
|
+
credential.lease = current;
|
|
158
|
+
const owner = await options.routing.lookup(lease.sessionId);
|
|
159
|
+
// Before the claim, an authorized newer epoch must reach routing.claim, which atomically fences
|
|
160
|
+
// the older owner, including a record a dead node left behind. Only a current owner is checked and renewed.
|
|
161
|
+
if (!owner || owner.epoch < lease.epoch)
|
|
162
|
+
return true;
|
|
163
|
+
if (owner.nodeId !== options.nodeId || owner.epoch !== lease.epoch)
|
|
164
|
+
return false;
|
|
165
|
+
if (options.renewLease && !await options.renewLease(lease))
|
|
166
|
+
return false;
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
if (leases.get(lease.sessionId) === credential)
|
|
171
|
+
leases.delete(lease.sessionId);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
onTunnelClosed: lease => { if (leases.get(lease.sessionId)?.lease.epoch === lease.epoch)
|
|
176
|
+
leases.delete(lease.sessionId); },
|
|
177
|
+
});
|
|
178
|
+
const server = createServer({ maxHeaderSize: 32 * 1024, requestTimeout: 60_000, headersTimeout: 15_000 }, (request, response) => {
|
|
179
|
+
const startedAt = new Date().toISOString(), started = performance.now();
|
|
180
|
+
void (async () => {
|
|
181
|
+
if ((request.url === '/_sequence/health' || request.url === '/health') && request.method === 'GET') {
|
|
182
|
+
const ready = !draining && (options.ready?.() ?? true);
|
|
183
|
+
json(response, ready ? 200 : 503, ready ? 'ready' : 'unavailable');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (draining) {
|
|
187
|
+
response.setHeader('connection', 'close');
|
|
188
|
+
json(response, 503, 'gateway_draining');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const origin = requestOrigin(request);
|
|
192
|
+
if (request.url === '/_sequence/bootstrap' && request.method === 'GET') {
|
|
193
|
+
// Atlas only embeds this page (see frame-ancestors). Refusing top-level loads keeps a crafted
|
|
194
|
+
// link from exchanging someone else's ticket in the victim's browser (login CSRF).
|
|
195
|
+
const destination = header(request, 'sec-fetch-dest');
|
|
196
|
+
if (destination !== undefined && destination !== 'iframe')
|
|
197
|
+
throw new GatewayControlError(403);
|
|
198
|
+
bootstrapPage({ response, parentOrigin });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (request.url === '/_sequence/session' && request.method === 'POST') {
|
|
202
|
+
if (header(request, 'origin') !== origin)
|
|
203
|
+
throw new GatewayControlError(403);
|
|
204
|
+
const { ticket } = z.object({ ticket: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict().parse(await boundedJson(request));
|
|
205
|
+
const { session } = await options.control.bootstrap(ticket);
|
|
206
|
+
if (session.publicOrigin !== origin || session.bridge.parentOrigin !== parentOrigin)
|
|
207
|
+
throw new GatewayControlError(403);
|
|
208
|
+
response.setHeader('set-cookie', gatewaySessionCookies({ session, key: options.sessionKey, cookie: header(request, 'cookie') }));
|
|
209
|
+
json(response, 200, 'connected');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (request.url?.startsWith('/_sequence/')) {
|
|
213
|
+
json(response, 404, 'not_found');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (['GET', 'HEAD'].includes(request.method ?? 'GET')) {
|
|
217
|
+
const url = new URL(request.url ?? '/', origin);
|
|
218
|
+
if (url.searchParams.has('__sequence_release')) {
|
|
219
|
+
url.searchParams.delete('__sequence_release');
|
|
220
|
+
request.url = url.pathname + url.search;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const { session, target, requestContext, requestId } = await authorize(request);
|
|
224
|
+
let reported = false;
|
|
225
|
+
const report = () => {
|
|
226
|
+
if (reported)
|
|
227
|
+
return;
|
|
228
|
+
reported = true;
|
|
229
|
+
usage.record({
|
|
230
|
+
eventId: `gateway:${requestId}`, requestId, projectId: target.projectId, environment: target.environment,
|
|
231
|
+
...(target.kind === 'development' ? { devSessionId: target.sessionId } : { deploymentId: target.deploymentId }),
|
|
232
|
+
startedAt, endedAt: new Date().toISOString(),
|
|
233
|
+
measurements: { requestCount: 1, durationMs: Math.max(0, Math.round(performance.now() - started)) },
|
|
234
|
+
outcome: !response.writableFinished ? 'cancelled' : response.statusCode >= 400 ? 'error' : 'success',
|
|
235
|
+
httpStatus: response.statusCode,
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
response.once('finish', report);
|
|
239
|
+
response.once('close', report);
|
|
240
|
+
if (response.destroyed) {
|
|
241
|
+
report();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const renewed = renewGatewaySession({ session, authorizedExpiresAt: target.expiresAt });
|
|
245
|
+
if (renewed)
|
|
246
|
+
response.setHeader('set-cookie', gatewaySessionCookies({ session: renewed, key: options.sessionKey, cookie: header(request, 'cookie'), renewal: true }));
|
|
247
|
+
// Authorization precedes every response, including Next static assets and cached pages.
|
|
248
|
+
response.setHeader('content-security-policy', `frame-ancestors ${session.bridge.parentOrigin}`);
|
|
249
|
+
response.setHeader('referrer-policy', 'same-origin');
|
|
250
|
+
response.setHeader('x-content-type-options', 'nosniff');
|
|
251
|
+
if (target.kind === 'development') {
|
|
252
|
+
await broker.handleHttp(request, response, target.sessionId);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!options.admission)
|
|
256
|
+
throw new ArtifactAdmissionError(503, 'admission_unavailable');
|
|
257
|
+
const admitted = await options.admission.acquire({ environment: target.environment, projectId: target.projectId, deploymentId: target.deploymentId, resources: target.manifest?.server.resources });
|
|
258
|
+
admitted.bind(response, () => response.destroy());
|
|
259
|
+
if (response.destroyed)
|
|
260
|
+
return;
|
|
261
|
+
const { body } = await admitHostedMutation({ request, expectedDeploymentId: target.deploymentId, expectedFrameworkBuildId: target.frameworkBuildId, active: target.active, published: session.published });
|
|
262
|
+
await proxyHostedArtifact({ request, response, target, requestContext, body, limits: { timeoutMs: admitted.timeoutMs } });
|
|
263
|
+
})().catch(error => {
|
|
264
|
+
options.onError?.({ kind: error instanceof ReleaseAdmissionError ? 'release_mismatch' : error instanceof GatewayControlError ? 'authorization_denied' : 'request_failed' });
|
|
265
|
+
const status = error instanceof ReleaseAdmissionError || error instanceof GatewayControlError || error instanceof ArtifactAdmissionError ? error.status : 400;
|
|
266
|
+
json(response, status === 401 || status === 404 ? 403 : status, error instanceof ReleaseAdmissionError ? 'update_required' : 'artifact_request_failed');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
server.on('upgrade', (request, socket, head) => {
|
|
270
|
+
void (async () => {
|
|
271
|
+
if (draining) {
|
|
272
|
+
rejectUpgrade(socket, 503);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (request.url === '/_sequence/tunnel') {
|
|
276
|
+
await broker.handleTunnelUpgrade(request, socket, head);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const authorized = await authorize(request);
|
|
280
|
+
if (authorized.target.kind !== 'development') {
|
|
281
|
+
rejectUpgrade(socket);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
await broker.handleWebSocketUpgrade(request, socket, head, authorized.target.sessionId);
|
|
285
|
+
})().catch(() => rejectUpgrade(socket));
|
|
286
|
+
});
|
|
287
|
+
return {
|
|
288
|
+
server,
|
|
289
|
+
async drain() {
|
|
290
|
+
draining = true;
|
|
291
|
+
server.closeIdleConnections();
|
|
292
|
+
broker.close();
|
|
293
|
+
leases.clear();
|
|
294
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
295
|
+
await usage.flush();
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const GATEWAY_SESSION_COOKIE = "__Host-atlas-artifact-session";
|
|
3
|
+
export declare function gatewayReleaseCookieName(deploymentId: string): string;
|
|
4
|
+
declare const sessionSchema: z.ZodObject<{
|
|
5
|
+
version: z.ZodLiteral<1>;
|
|
6
|
+
kind: z.ZodEnum<{
|
|
7
|
+
development: "development";
|
|
8
|
+
hosted: "hosted";
|
|
9
|
+
}>;
|
|
10
|
+
sessionId: z.ZodNullable<z.ZodString>;
|
|
11
|
+
deploymentId: z.ZodNullable<z.ZodString>;
|
|
12
|
+
frameworkBuildId: z.ZodNullable<z.ZodString>;
|
|
13
|
+
projectId: z.ZodString;
|
|
14
|
+
ownerId: z.ZodString;
|
|
15
|
+
environment: z.ZodString;
|
|
16
|
+
publicOrigin: z.ZodString;
|
|
17
|
+
expiresAt: z.ZodNumber;
|
|
18
|
+
maxExpiresAt: z.ZodOptional<z.ZodNumber>;
|
|
19
|
+
published: z.ZodBoolean;
|
|
20
|
+
bridge: z.ZodObject<{
|
|
21
|
+
parentOrigin: z.ZodString;
|
|
22
|
+
channelId: z.ZodString;
|
|
23
|
+
nonce: z.ZodString;
|
|
24
|
+
}, z.core.$strict>;
|
|
25
|
+
lattice: z.ZodOptional<z.ZodObject<{
|
|
26
|
+
runId: z.ZodNullable<z.ZodString>;
|
|
27
|
+
nodeId: z.ZodNullable<z.ZodString>;
|
|
28
|
+
stepId: z.ZodNullable<z.ZodString>;
|
|
29
|
+
processId: z.ZodNullable<z.ZodString>;
|
|
30
|
+
}, z.core.$strict>>;
|
|
31
|
+
taskRoutes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
32
|
+
}, z.core.$strict>;
|
|
33
|
+
export type GatewaySession = z.infer<typeof sessionSchema>;
|
|
34
|
+
/** Active use renews the idle window, but never extends the original admission's eight-hour bound. */
|
|
35
|
+
export declare function renewGatewaySession({ session, authorizedExpiresAt, now }: {
|
|
36
|
+
session: GatewaySession;
|
|
37
|
+
authorizedExpiresAt: number;
|
|
38
|
+
now?: number;
|
|
39
|
+
}): GatewaySession | null;
|
|
40
|
+
export declare function gatewaySessionCookies({ session, key, cookie, renewal, now }: {
|
|
41
|
+
session: GatewaySession;
|
|
42
|
+
key: string;
|
|
43
|
+
cookie?: string;
|
|
44
|
+
renewal?: boolean;
|
|
45
|
+
now?: number;
|
|
46
|
+
}): string[];
|
|
47
|
+
export declare function encodeGatewaySession({ session, key }: {
|
|
48
|
+
session: GatewaySession;
|
|
49
|
+
key: string;
|
|
50
|
+
}): string;
|
|
51
|
+
/** Retained releases preserve tabs, never a different login or runtime scope. */
|
|
52
|
+
export declare function decodeGatewaySession(input: {
|
|
53
|
+
cookie: string | undefined;
|
|
54
|
+
key: string;
|
|
55
|
+
origin: string;
|
|
56
|
+
now?: number;
|
|
57
|
+
deploymentId?: string;
|
|
58
|
+
frameworkBuildId?: string;
|
|
59
|
+
}): GatewaySession | null;
|
|
60
|
+
export declare function signGatewayContext({ session, method, path, privateKey, keyId, now, capabilities, serviceAccountId, requestId }: {
|
|
61
|
+
session: GatewaySession;
|
|
62
|
+
method: string;
|
|
63
|
+
path: string;
|
|
64
|
+
privateKey: string;
|
|
65
|
+
keyId: string;
|
|
66
|
+
now?: number;
|
|
67
|
+
capabilities?: string[];
|
|
68
|
+
serviceAccountId?: string | null;
|
|
69
|
+
requestId?: string;
|
|
70
|
+
}): string;
|
|
71
|
+
export {};
|