@serverkgg/bridge 0.0.1 → 0.0.2
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/package.json +10 -3
- package/src/cli/main.ts +11 -1
- package/src/cli/schema.ts +62 -0
- package/src/cli/validate.ts +119 -36
- package/src/context/bridgeContext.ts +11 -1
- package/src/core/bridgeAction.ts +13 -0
- package/src/core/bridgeFailure.ts +16 -0
- package/src/core/bridgeKind.ts +2 -0
- package/src/core/bridgeNetError.ts +10 -0
- package/src/core/index.ts +2 -0
- package/src/guides/blocks.ts +167 -0
- package/src/guides/compile.ts +193 -0
- package/src/guides/compiled.ts +28 -0
- package/src/guides/index.ts +6 -0
- package/src/guides/markdown.test.ts +374 -0
- package/src/guides/markdown.ts +769 -0
- package/src/guides/variables.ts +52 -0
- package/src/guides/version.ts +1 -0
- package/src/main.ts +2 -1
- package/src/manifest/assets.ts +4 -4
- package/src/manifest/guide.ts +23 -18
- package/src/manifest/index.ts +3 -2
- package/src/manifest/jsonSchema.ts +16 -0
- package/src/manifest/localized.ts +7 -0
- package/src/manifest/manifest.test.ts +366 -0
- package/src/manifest/manifest.ts +354 -120
- package/src/manifest/primitives.ts +63 -0
- package/src/manifest/source.ts +5 -4
- package/src/modules/bridge.ts +19 -3
- package/src/modules/bridgeDriver.ts +4 -0
- package/src/modules/bridgeModule.ts +22 -24
- package/src/panel/bridgeAccess.ts +3 -0
- package/src/panel/bridgePanel.ts +6 -0
- package/src/panel/index.ts +1 -0
- package/src/protocol/bridgeCatalog.ts +56 -0
- package/src/protocol/bridgeEgress.ts +53 -0
- package/src/protocol/bridgeFrame.ts +17 -1
- package/src/protocol/bridgeLine.ts +41 -6
- package/src/protocol/bridgeManifest.ts +15 -1
- package/src/protocol/bridgeVersion.ts +1 -1
- package/src/protocol/index.ts +3 -0
- package/src/runtime/bridgeContext.ts +29 -9
- package/src/runtime/bridgeDriver.ts +1 -0
- package/src/runtime/bridgeEgress.ts +360 -0
- package/src/runtime/bridgeExec.ts +15 -5
- package/src/runtime/bridgeFiles.ts +58 -21
- package/src/runtime/bridgeLog.ts +26 -3
- package/src/runtime/bridgeNet.ts +75 -22
- package/src/runtime/bridgeRegistry.ts +51 -6
- package/src/runtime/bridgeServer.ts +48 -1
- package/src/runtime/bridgeSupervisor.ts +6 -6
- package/src/runtime/index.ts +0 -17
- package/src/runtime/serve.ts +44 -3
- package/src/terminal/bridgeTerminalArg.ts +11 -0
- package/src/terminal/bridgeTerminalCommand.ts +11 -0
- package/src/terminal/bridgeTerminalLevel.ts +6 -0
- package/src/terminal/bridgeTerminalRule.ts +8 -0
- package/src/terminal/index.ts +4 -0
- package/src/manifest/checks.ts +0 -50
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BRIDGE_EGRESS_FRAME_LIMIT,
|
|
3
|
+
BRIDGE_EGRESS_SOCKET_NAME,
|
|
4
|
+
type BridgeConfig,
|
|
5
|
+
type BridgeEgressDownloadResult,
|
|
6
|
+
type BridgeEgressFetchResult,
|
|
7
|
+
BridgeEgressMethod,
|
|
8
|
+
type BridgeEgressRequest,
|
|
9
|
+
type BridgeEgressResponse,
|
|
10
|
+
} from "../protocol";
|
|
11
|
+
|
|
12
|
+
const CONNECT_RETRY_MS = 10_000;
|
|
13
|
+
|
|
14
|
+
const CALL_TIMEOUT_MS = 20_000;
|
|
15
|
+
|
|
16
|
+
const DOWNLOAD_TIMEOUT_MS = 300_000;
|
|
17
|
+
|
|
18
|
+
const QUEUE_HIGH_WATER_BYTES = 8 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
const NEWLINE = 0x0a;
|
|
21
|
+
|
|
22
|
+
export class BridgeEgressUnavailableError extends Error {
|
|
23
|
+
constructor(message: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
|
|
26
|
+
this.name = "BridgeEgressUnavailableError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class BridgeEgressCallError extends Error {
|
|
31
|
+
readonly status: number | null;
|
|
32
|
+
|
|
33
|
+
constructor(status: number | null, message: string) {
|
|
34
|
+
super(message);
|
|
35
|
+
|
|
36
|
+
this.name = "BridgeEgressCallError";
|
|
37
|
+
this.status = status;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BridgeEgressCallOptions {
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
cache?: boolean;
|
|
45
|
+
cacheSeconds?: number;
|
|
46
|
+
refresh?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface BridgeEgressDownloadOptions extends BridgeEgressCallOptions {
|
|
50
|
+
digest?: string;
|
|
51
|
+
sizeBytes?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BridgeEgressDownload extends BridgeEgressDownloadResult {
|
|
55
|
+
body: AsyncIterable<Uint8Array>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface BridgeEgress {
|
|
59
|
+
fetch(url: string, options?: BridgeEgressCallOptions): Promise<BridgeEgressFetchResult>;
|
|
60
|
+
download(url: string, options?: BridgeEgressDownloadOptions): Promise<BridgeEgressDownload>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const createByteStream = () => {
|
|
64
|
+
const chunks: Uint8Array[] = [];
|
|
65
|
+
|
|
66
|
+
let queued = 0;
|
|
67
|
+
let done = false;
|
|
68
|
+
let failure: unknown = null;
|
|
69
|
+
let wake: (() => void) | null = null;
|
|
70
|
+
let drain: (() => void) | null = null;
|
|
71
|
+
|
|
72
|
+
const signal = () => {
|
|
73
|
+
const resume = wake;
|
|
74
|
+
|
|
75
|
+
wake = null;
|
|
76
|
+
resume?.();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
get queued() {
|
|
81
|
+
return queued;
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
push(chunk: Uint8Array) {
|
|
85
|
+
chunks.push(chunk);
|
|
86
|
+
queued += chunk.byteLength;
|
|
87
|
+
signal();
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
finish() {
|
|
91
|
+
done = true;
|
|
92
|
+
signal();
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
fail(error: unknown) {
|
|
96
|
+
failure = error;
|
|
97
|
+
done = true;
|
|
98
|
+
signal();
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
onDrain(listener: () => void) {
|
|
102
|
+
drain = listener;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async *stream(): AsyncGenerator<Uint8Array> {
|
|
106
|
+
while (true) {
|
|
107
|
+
const chunk = chunks.shift();
|
|
108
|
+
|
|
109
|
+
if (chunk) {
|
|
110
|
+
queued -= chunk.byteLength;
|
|
111
|
+
|
|
112
|
+
if (queued < QUEUE_HIGH_WATER_BYTES) {
|
|
113
|
+
drain?.();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
yield chunk;
|
|
117
|
+
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (failure) {
|
|
122
|
+
throw failure;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (done) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await new Promise<void>((resolve) => {
|
|
130
|
+
wake = resolve;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const createBridgeEgress = (config: BridgeConfig): BridgeEgress => {
|
|
138
|
+
const path = `${config.run}/${BRIDGE_EGRESS_SOCKET_NAME}`;
|
|
139
|
+
|
|
140
|
+
let blockedUntil = 0;
|
|
141
|
+
|
|
142
|
+
const frameOf = (method: BridgeEgressMethod, url: string, options: BridgeEgressDownloadOptions) => {
|
|
143
|
+
const request: BridgeEgressRequest = {
|
|
144
|
+
id: 1,
|
|
145
|
+
token: config.token,
|
|
146
|
+
method,
|
|
147
|
+
url,
|
|
148
|
+
...options,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return `${JSON.stringify(request)}\n`;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const connect = async <Data>(handlers: Bun.SocketHandler<Data>) => {
|
|
155
|
+
if (performance.now() < blockedUntil) {
|
|
156
|
+
throw new BridgeEgressUnavailableError("the egress channel is not available");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
return await Bun.connect<Data>({
|
|
161
|
+
unix: path,
|
|
162
|
+
socket: handlers,
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
blockedUntil = performance.now() + CONNECT_RETRY_MS;
|
|
166
|
+
|
|
167
|
+
throw new BridgeEgressUnavailableError("the egress channel is not available");
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
async fetch(url, options = {}) {
|
|
173
|
+
let settle: (response: BridgeEgressResponse<BridgeEgressFetchResult>) => void = () => undefined;
|
|
174
|
+
let fail: (error: Error) => void = () => undefined;
|
|
175
|
+
|
|
176
|
+
const pending = new Promise<BridgeEgressResponse<BridgeEgressFetchResult>>((resolve, reject) => {
|
|
177
|
+
settle = resolve;
|
|
178
|
+
fail = reject;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
let buffer = "";
|
|
182
|
+
|
|
183
|
+
const socket = await connect<undefined>({
|
|
184
|
+
data(_connection, chunk) {
|
|
185
|
+
buffer += chunk.toString();
|
|
186
|
+
|
|
187
|
+
if (buffer.length > BRIDGE_EGRESS_FRAME_LIMIT) {
|
|
188
|
+
fail(new BridgeEgressUnavailableError("the egress reply is too large"));
|
|
189
|
+
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const frame = buffer.split("\n").at(0) ?? "";
|
|
194
|
+
|
|
195
|
+
if (buffer.includes("\n") && frame.trim().length > 0) {
|
|
196
|
+
try {
|
|
197
|
+
settle(JSON.parse(frame) as BridgeEgressResponse<BridgeEgressFetchResult>);
|
|
198
|
+
} catch {
|
|
199
|
+
fail(new BridgeEgressUnavailableError("the egress channel sent a malformed reply"));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
error(_connection, error) {
|
|
205
|
+
fail(new BridgeEgressUnavailableError(error.message));
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
close() {
|
|
209
|
+
fail(new BridgeEgressUnavailableError("the egress channel closed the connection"));
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
socket.write(frameOf(BridgeEgressMethod.Fetch, url, options));
|
|
215
|
+
|
|
216
|
+
const response = await Promise.race([
|
|
217
|
+
pending,
|
|
218
|
+
Bun.sleep(options.timeoutMs ?? CALL_TIMEOUT_MS).then(() => {
|
|
219
|
+
throw new BridgeEgressUnavailableError("the egress channel timed out");
|
|
220
|
+
}),
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
throw new BridgeEgressCallError(response.status, response.error);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return response.result;
|
|
228
|
+
} finally {
|
|
229
|
+
socket.end();
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
async download(url, options = {}) {
|
|
234
|
+
let settle: (response: BridgeEgressResponse<BridgeEgressDownloadResult>) => void = () => undefined;
|
|
235
|
+
let fail: (error: Error) => void = () => undefined;
|
|
236
|
+
|
|
237
|
+
const pending = new Promise<BridgeEgressResponse<BridgeEgressDownloadResult>>((resolve, reject) => {
|
|
238
|
+
settle = resolve;
|
|
239
|
+
fail = reject;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const stream = createByteStream();
|
|
243
|
+
|
|
244
|
+
let header: string | null = null;
|
|
245
|
+
let headerBuffer = "";
|
|
246
|
+
let received = 0;
|
|
247
|
+
let expected: number | null = null;
|
|
248
|
+
|
|
249
|
+
const socket = await connect<undefined>({
|
|
250
|
+
data(connection, chunk) {
|
|
251
|
+
let body = chunk;
|
|
252
|
+
|
|
253
|
+
if (header === null) {
|
|
254
|
+
const boundary = chunk.indexOf(NEWLINE);
|
|
255
|
+
|
|
256
|
+
if (boundary === -1) {
|
|
257
|
+
headerBuffer += chunk.toString();
|
|
258
|
+
|
|
259
|
+
if (headerBuffer.length > BRIDGE_EGRESS_FRAME_LIMIT) {
|
|
260
|
+
fail(new BridgeEgressUnavailableError("the egress reply is too large"));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
header = headerBuffer + chunk.subarray(0, boundary).toString();
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const response = JSON.parse(header) as BridgeEgressResponse<BridgeEgressDownloadResult>;
|
|
270
|
+
|
|
271
|
+
settle(response);
|
|
272
|
+
|
|
273
|
+
if (!response.ok) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
expected = response.result.sizeBytes;
|
|
278
|
+
} catch {
|
|
279
|
+
fail(new BridgeEgressUnavailableError("the egress channel sent a malformed reply"));
|
|
280
|
+
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
body = chunk.subarray(boundary + 1);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (body.byteLength === 0) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
received += body.byteLength;
|
|
292
|
+
stream.push(new Uint8Array(body));
|
|
293
|
+
|
|
294
|
+
if (stream.queued >= QUEUE_HIGH_WATER_BYTES) {
|
|
295
|
+
connection.pause();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (expected !== null && expected > 0 && received >= expected) {
|
|
299
|
+
stream.finish();
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
error(_connection, error) {
|
|
304
|
+
fail(new BridgeEgressUnavailableError(error.message));
|
|
305
|
+
stream.fail(new BridgeEgressUnavailableError(error.message));
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
close() {
|
|
309
|
+
fail(new BridgeEgressUnavailableError("the egress channel closed the connection"));
|
|
310
|
+
|
|
311
|
+
if (expected !== null && expected > 0 && received < expected) {
|
|
312
|
+
stream.fail(new BridgeEgressUnavailableError("the egress channel closed mid-transfer"));
|
|
313
|
+
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
stream.finish();
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
stream.onDrain(() => {
|
|
322
|
+
socket.resume();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
socket.write(frameOf(BridgeEgressMethod.Download, url, options));
|
|
327
|
+
|
|
328
|
+
const response = await Promise.race([
|
|
329
|
+
pending,
|
|
330
|
+
Bun.sleep(options.timeoutMs ?? DOWNLOAD_TIMEOUT_MS).then(() => {
|
|
331
|
+
throw new BridgeEgressUnavailableError("the egress channel timed out");
|
|
332
|
+
}),
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
if (!response.ok) {
|
|
336
|
+
socket.end();
|
|
337
|
+
|
|
338
|
+
throw new BridgeEgressCallError(response.status, response.error);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const body = async function* () {
|
|
342
|
+
try {
|
|
343
|
+
yield* stream.stream();
|
|
344
|
+
} finally {
|
|
345
|
+
socket.end();
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
...response.result,
|
|
351
|
+
body: body(),
|
|
352
|
+
};
|
|
353
|
+
} catch (error) {
|
|
354
|
+
socket.end();
|
|
355
|
+
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
};
|
|
@@ -8,10 +8,20 @@ const OUTPUT_LIMIT = 256 * 1024;
|
|
|
8
8
|
|
|
9
9
|
const EXIT_GRACE_MS = 3000;
|
|
10
10
|
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
PATH
|
|
11
|
+
const FALLBACK_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
12
|
+
|
|
13
|
+
const inheritedPath = () => {
|
|
14
|
+
const declared = process.env.PATH ?? "";
|
|
15
|
+
|
|
16
|
+
return declared.length > 0 ? declared : FALLBACK_PATH;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const baseEnvironment = () => {
|
|
20
|
+
return {
|
|
21
|
+
HOME: "/home/container",
|
|
22
|
+
LANG: "C.UTF-8",
|
|
23
|
+
PATH: inheritedPath(),
|
|
24
|
+
};
|
|
15
25
|
};
|
|
16
26
|
|
|
17
27
|
export class BridgeExecError extends Error {
|
|
@@ -92,7 +102,7 @@ export const createBridgeExec = (target: BridgeExecTarget) => {
|
|
|
92
102
|
const child = Bun.spawn(argv, {
|
|
93
103
|
cwd: target.cwd,
|
|
94
104
|
env: {
|
|
95
|
-
...
|
|
105
|
+
...baseEnvironment(),
|
|
96
106
|
HOME: target.cwd,
|
|
97
107
|
...environment,
|
|
98
108
|
},
|
|
@@ -2,7 +2,9 @@ import type { Dirent } from "node:fs";
|
|
|
2
2
|
import { chown, mkdir, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import type { BridgeDownloadOptions, BridgeFileEntry, BridgeFiles } from "../context";
|
|
4
4
|
import { compileGlob } from "../utils";
|
|
5
|
+
import { type BridgeEgress, BridgeEgressCallError, BridgeEgressUnavailableError } from "./bridgeEgress";
|
|
5
6
|
import type { BridgeLogger } from "./bridgeLog";
|
|
7
|
+
import { USER_AGENT } from "./bridgeNet";
|
|
6
8
|
import { BridgePathError, resolveWithin, toSegments } from "./bridgePath";
|
|
7
9
|
import { isPrivileged } from "./bridgePrivilege";
|
|
8
10
|
import { readZip } from "./bridgeZip";
|
|
@@ -21,8 +23,6 @@ const WALK_LIMIT = 4096;
|
|
|
21
23
|
|
|
22
24
|
const MILESTONE_STEP = 25;
|
|
23
25
|
|
|
24
|
-
const USER_AGENT = "serverk-bridge/1.0 (serverk.gg)";
|
|
25
|
-
|
|
26
26
|
const DIGEST_LENGTHS: Record<string, number> = {
|
|
27
27
|
sha1: 40,
|
|
28
28
|
sha256: 64,
|
|
@@ -33,9 +33,15 @@ export interface BridgeFilesOptions {
|
|
|
33
33
|
root: string;
|
|
34
34
|
uid: number;
|
|
35
35
|
gid: number;
|
|
36
|
+
egress: BridgeEgress;
|
|
36
37
|
onLog: BridgeLogger;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
interface DownloadSource {
|
|
41
|
+
body: AsyncIterable<Uint8Array>;
|
|
42
|
+
totalBytes: number | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
const parseDigest = (digest: string) => {
|
|
40
46
|
const [algo, expected] = digest.split(":");
|
|
41
47
|
|
|
@@ -169,6 +175,49 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
169
175
|
await ensureDirectory(segments.slice(0, -1).join("/"));
|
|
170
176
|
};
|
|
171
177
|
|
|
178
|
+
const openSource = async (remote: URL, downloadOptions: BridgeDownloadOptions): Promise<DownloadSource> => {
|
|
179
|
+
if (downloadOptions.cache !== false) {
|
|
180
|
+
try {
|
|
181
|
+
const shared = await options.egress.download(remote.href, {
|
|
182
|
+
digest: downloadOptions.digest,
|
|
183
|
+
sizeBytes: downloadOptions.sizeBytes,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
body: shared.body,
|
|
188
|
+
totalBytes: shared.sizeBytes > 0 ? shared.sizeBytes : (downloadOptions.sizeBytes ?? null),
|
|
189
|
+
};
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error instanceof BridgeEgressCallError) {
|
|
192
|
+
throw new BridgePathError("missing", `${remote.host} answered ${error.status ?? error.message}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!(error instanceof BridgeEgressUnavailableError)) {
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const response = await fetch(remote, {
|
|
202
|
+
headers: {
|
|
203
|
+
"user-agent": USER_AGENT,
|
|
204
|
+
},
|
|
205
|
+
redirect: "error",
|
|
206
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (!response.ok || !response.body) {
|
|
210
|
+
throw new BridgePathError("missing", `${remote.host} answered ${response.status}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const declared = Number(response.headers.get("content-length"));
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
body: response.body,
|
|
217
|
+
totalBytes: Number.isSafeInteger(declared) && declared > 0 ? declared : (downloadOptions.sizeBytes ?? null),
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
|
|
172
221
|
return {
|
|
173
222
|
async read(path) {
|
|
174
223
|
const resolved = await resolveWithin(options.root, path);
|
|
@@ -262,7 +311,7 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
262
311
|
const remote = new URL(url);
|
|
263
312
|
|
|
264
313
|
if (remote.protocol !== "https:") {
|
|
265
|
-
throw new BridgePathError("invalid",
|
|
314
|
+
throw new BridgePathError("invalid", `${remote.host} is not https`);
|
|
266
315
|
}
|
|
267
316
|
|
|
268
317
|
const limit = downloadOptions.sizeBytes ?? DOWNLOAD_LIMIT;
|
|
@@ -271,26 +320,14 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
271
320
|
throw new BridgePathError("invalid", `download of ${limit} bytes exceeds the limit`);
|
|
272
321
|
}
|
|
273
322
|
|
|
274
|
-
const
|
|
275
|
-
headers: {
|
|
276
|
-
"user-agent": USER_AGENT,
|
|
277
|
-
},
|
|
278
|
-
redirect: "follow",
|
|
279
|
-
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
if (!response.ok || !response.body) {
|
|
283
|
-
throw new BridgePathError("missing", `${url} answered ${response.status}`);
|
|
284
|
-
}
|
|
323
|
+
const source = await openSource(remote, downloadOptions);
|
|
285
324
|
|
|
286
325
|
await ensureParent(path);
|
|
287
326
|
|
|
288
327
|
const resolved = await resolveWithin(options.root, path);
|
|
289
328
|
const staging = `${resolved.target}.download`;
|
|
290
329
|
|
|
291
|
-
const
|
|
292
|
-
const totalBytes =
|
|
293
|
-
Number.isSafeInteger(declared) && declared > 0 ? declared : (downloadOptions.sizeBytes ?? null);
|
|
330
|
+
const totalBytes = source.totalBytes;
|
|
294
331
|
|
|
295
332
|
const hasher = downloadOptions.digest ? new Bun.CryptoHasher(parseDigest(downloadOptions.digest).algo) : null;
|
|
296
333
|
const writer = Bun.file(staging).writer();
|
|
@@ -305,11 +342,11 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
305
342
|
let milestone = 0;
|
|
306
343
|
|
|
307
344
|
try {
|
|
308
|
-
for await (const chunk of
|
|
345
|
+
for await (const chunk of source.body) {
|
|
309
346
|
readBytes += chunk.byteLength;
|
|
310
347
|
|
|
311
348
|
if (readBytes > limit) {
|
|
312
|
-
throw new BridgePathError("invalid", `${
|
|
349
|
+
throw new BridgePathError("invalid", `${remote.host} returned more than ${limit} bytes`);
|
|
313
350
|
}
|
|
314
351
|
|
|
315
352
|
hasher?.update(chunk);
|
|
@@ -336,7 +373,7 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
336
373
|
if (downloadOptions.sizeBytes !== undefined && readBytes !== downloadOptions.sizeBytes) {
|
|
337
374
|
throw new BridgePathError(
|
|
338
375
|
"invalid",
|
|
339
|
-
`${
|
|
376
|
+
`${remote.host} returned ${readBytes} bytes, expected ${downloadOptions.sizeBytes}`,
|
|
340
377
|
);
|
|
341
378
|
}
|
|
342
379
|
|
|
@@ -344,7 +381,7 @@ export const createBridgeFiles = (options: BridgeFilesOptions): BridgeFiles => {
|
|
|
344
381
|
const { expected } = parseDigest(downloadOptions.digest);
|
|
345
382
|
|
|
346
383
|
if (hasher.digest("hex") !== expected) {
|
|
347
|
-
throw new BridgePathError("invalid", `${
|
|
384
|
+
throw new BridgePathError("invalid", `${remote.host} failed digest verification`);
|
|
348
385
|
}
|
|
349
386
|
}
|
|
350
387
|
} catch (error) {
|
package/src/runtime/bridgeLog.ts
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
|
+
import { BRIDGE_LOG_SOURCE } from "../protocol/bridgeLine";
|
|
2
|
+
import { BridgeTerminalLevel } from "../terminal";
|
|
3
|
+
|
|
1
4
|
export type BridgeLogger = (message: string, fields?: Record<string, unknown>) => void;
|
|
2
5
|
|
|
3
|
-
export
|
|
6
|
+
export interface BridgeLeveledLogger extends BridgeLogger {
|
|
7
|
+
warn: BridgeLogger;
|
|
8
|
+
error: BridgeLogger;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const write = (level: BridgeTerminalLevel, message: string, fields: Record<string, unknown>) => {
|
|
4
12
|
process.stdout.write(
|
|
5
13
|
`${JSON.stringify({
|
|
6
|
-
source: "bridge",
|
|
7
|
-
message,
|
|
8
14
|
...fields,
|
|
15
|
+
source: BRIDGE_LOG_SOURCE,
|
|
16
|
+
level,
|
|
17
|
+
message,
|
|
9
18
|
})}\n`,
|
|
10
19
|
);
|
|
11
20
|
};
|
|
12
21
|
|
|
22
|
+
export const log: BridgeLeveledLogger = Object.assign(
|
|
23
|
+
(message: string, fields: Record<string, unknown> = {}) => {
|
|
24
|
+
write(BridgeTerminalLevel.Info, message, fields);
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
warn: (message: string, fields: Record<string, unknown> = {}) => {
|
|
28
|
+
write(BridgeTerminalLevel.Warn, message, fields);
|
|
29
|
+
},
|
|
30
|
+
error: (message: string, fields: Record<string, unknown> = {}) => {
|
|
31
|
+
write(BridgeTerminalLevel.Error, message, fields);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
13
36
|
export const errorText = (error: unknown) => {
|
|
14
37
|
return error instanceof Error ? error.message : String(error);
|
|
15
38
|
};
|
package/src/runtime/bridgeNet.ts
CHANGED
|
@@ -1,50 +1,103 @@
|
|
|
1
1
|
import type { BridgeNet, BridgeRequestOptions } from "../context";
|
|
2
|
+
import { BridgeNetError } from "../core";
|
|
3
|
+
import { type BridgeEgress, BridgeEgressCallError, BridgeEgressUnavailableError } from "./bridgeEgress";
|
|
2
4
|
|
|
3
5
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
4
6
|
|
|
5
7
|
const RESPONSE_LIMIT = 8 * 1024 * 1024;
|
|
6
8
|
|
|
7
|
-
const USER_AGENT = "serverk-bridge/1.0 (serverk.gg)";
|
|
9
|
+
export const USER_AGENT = "serverk-bridge/1.0 (serverk.gg)";
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
readonly status: number | null;
|
|
11
|
+
const LINE_BREAK = /[\r\n]/;
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
export const safeHeaders = (headers: Record<string, string> = {}) => {
|
|
14
|
+
const checked: Record<string, string> = {};
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
17
|
+
if (LINE_BREAK.test(key) || LINE_BREAK.test(value)) {
|
|
18
|
+
throw new BridgeNetError(null, "a header contains a line break");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
checked[key] = value;
|
|
17
22
|
}
|
|
18
|
-
}
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const remote = new URL(url);
|
|
24
|
+
return checked;
|
|
25
|
+
};
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
const readCapped = async (response: Response, host: string) => {
|
|
28
|
+
if (!response.body) {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const decoder = new TextDecoder();
|
|
33
|
+
const parts: string[] = [];
|
|
34
|
+
|
|
35
|
+
let read = 0;
|
|
36
|
+
|
|
37
|
+
for await (const chunk of response.body) {
|
|
38
|
+
read += chunk.byteLength;
|
|
39
|
+
|
|
40
|
+
if (read > RESPONSE_LIMIT) {
|
|
41
|
+
throw new BridgeNetError(null, `${host} returned more than ${RESPONSE_LIMIT} bytes`);
|
|
26
42
|
}
|
|
27
43
|
|
|
28
|
-
|
|
44
|
+
parts.push(
|
|
45
|
+
decoder.decode(chunk, {
|
|
46
|
+
stream: true,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
parts.push(decoder.decode());
|
|
52
|
+
|
|
53
|
+
return parts.join("");
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const createBridgeNet = (egress: BridgeEgress): BridgeNet => {
|
|
57
|
+
const direct = async (remote: URL, headers: Record<string, string>, timeoutMs: number) => {
|
|
58
|
+
const response = await fetch(remote, {
|
|
29
59
|
headers: {
|
|
60
|
+
...headers,
|
|
30
61
|
"user-agent": USER_AGENT,
|
|
31
|
-
...options.headers,
|
|
32
62
|
},
|
|
33
|
-
redirect: "
|
|
34
|
-
signal: AbortSignal.timeout(
|
|
63
|
+
redirect: "error",
|
|
64
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
35
65
|
});
|
|
36
66
|
|
|
37
67
|
if (!response.ok) {
|
|
38
|
-
throw new BridgeNetError(response.status, `${
|
|
68
|
+
throw new BridgeNetError(response.status, `${remote.host} answered ${response.status}`);
|
|
39
69
|
}
|
|
40
70
|
|
|
41
|
-
|
|
71
|
+
return await readCapped(response, remote.host);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const request = async (url: string, options: BridgeRequestOptions = {}) => {
|
|
75
|
+
const remote = new URL(url);
|
|
76
|
+
|
|
77
|
+
if (remote.protocol !== "https:") {
|
|
78
|
+
throw new BridgeNetError(null, `${remote.host} is not https`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const headers = safeHeaders(options.headers);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const result = await egress.fetch(url, {
|
|
85
|
+
...options,
|
|
86
|
+
headers,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return result.body;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error instanceof BridgeEgressCallError) {
|
|
92
|
+
throw new BridgeNetError(error.status, error.message);
|
|
93
|
+
}
|
|
42
94
|
|
|
43
|
-
|
|
44
|
-
|
|
95
|
+
if (!(error instanceof BridgeEgressUnavailableError)) {
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
45
98
|
}
|
|
46
99
|
|
|
47
|
-
return
|
|
100
|
+
return await direct(remote, headers, options.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
48
101
|
};
|
|
49
102
|
|
|
50
103
|
return {
|