@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.
Files changed (59) hide show
  1. package/package.json +10 -3
  2. package/src/cli/main.ts +11 -1
  3. package/src/cli/schema.ts +62 -0
  4. package/src/cli/validate.ts +119 -36
  5. package/src/context/bridgeContext.ts +11 -1
  6. package/src/core/bridgeAction.ts +13 -0
  7. package/src/core/bridgeFailure.ts +16 -0
  8. package/src/core/bridgeKind.ts +2 -0
  9. package/src/core/bridgeNetError.ts +10 -0
  10. package/src/core/index.ts +2 -0
  11. package/src/guides/blocks.ts +167 -0
  12. package/src/guides/compile.ts +193 -0
  13. package/src/guides/compiled.ts +28 -0
  14. package/src/guides/index.ts +6 -0
  15. package/src/guides/markdown.test.ts +374 -0
  16. package/src/guides/markdown.ts +769 -0
  17. package/src/guides/variables.ts +52 -0
  18. package/src/guides/version.ts +1 -0
  19. package/src/main.ts +2 -1
  20. package/src/manifest/assets.ts +4 -4
  21. package/src/manifest/guide.ts +23 -18
  22. package/src/manifest/index.ts +3 -2
  23. package/src/manifest/jsonSchema.ts +16 -0
  24. package/src/manifest/localized.ts +7 -0
  25. package/src/manifest/manifest.test.ts +366 -0
  26. package/src/manifest/manifest.ts +354 -120
  27. package/src/manifest/primitives.ts +63 -0
  28. package/src/manifest/source.ts +5 -4
  29. package/src/modules/bridge.ts +19 -3
  30. package/src/modules/bridgeDriver.ts +4 -0
  31. package/src/modules/bridgeModule.ts +22 -24
  32. package/src/panel/bridgeAccess.ts +3 -0
  33. package/src/panel/bridgePanel.ts +6 -0
  34. package/src/panel/index.ts +1 -0
  35. package/src/protocol/bridgeCatalog.ts +56 -0
  36. package/src/protocol/bridgeEgress.ts +53 -0
  37. package/src/protocol/bridgeFrame.ts +17 -1
  38. package/src/protocol/bridgeLine.ts +41 -6
  39. package/src/protocol/bridgeManifest.ts +15 -1
  40. package/src/protocol/bridgeVersion.ts +1 -1
  41. package/src/protocol/index.ts +3 -0
  42. package/src/runtime/bridgeContext.ts +29 -9
  43. package/src/runtime/bridgeDriver.ts +1 -0
  44. package/src/runtime/bridgeEgress.ts +360 -0
  45. package/src/runtime/bridgeExec.ts +15 -5
  46. package/src/runtime/bridgeFiles.ts +58 -21
  47. package/src/runtime/bridgeLog.ts +26 -3
  48. package/src/runtime/bridgeNet.ts +75 -22
  49. package/src/runtime/bridgeRegistry.ts +51 -6
  50. package/src/runtime/bridgeServer.ts +48 -1
  51. package/src/runtime/bridgeSupervisor.ts +6 -6
  52. package/src/runtime/index.ts +0 -17
  53. package/src/runtime/serve.ts +44 -3
  54. package/src/terminal/bridgeTerminalArg.ts +11 -0
  55. package/src/terminal/bridgeTerminalCommand.ts +11 -0
  56. package/src/terminal/bridgeTerminalLevel.ts +6 -0
  57. package/src/terminal/bridgeTerminalRule.ts +8 -0
  58. package/src/terminal/index.ts +4 -0
  59. 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 BASE_ENVIRONMENT = {
12
- HOME: "/home/container",
13
- LANG: "C.UTF-8",
14
- PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
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
- ...BASE_ENVIRONMENT,
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", `url "${url}" is not https`);
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 response = await fetch(url, {
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 declared = Number(response.headers.get("content-length"));
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 response.body) {
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", `${url} returned more than ${limit} bytes`);
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
- `${url} returned ${readBytes} bytes, expected ${downloadOptions.sizeBytes}`,
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", `${url} failed digest verification`);
384
+ throw new BridgePathError("invalid", `${remote.host} failed digest verification`);
348
385
  }
349
386
  }
350
387
  } catch (error) {
@@ -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 const log: BridgeLogger = (message, fields = {}) => {
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
  };
@@ -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
- export class BridgeNetError extends Error {
10
- readonly status: number | null;
11
+ const LINE_BREAK = /[\r\n]/;
11
12
 
12
- constructor(status: number | null, message: string) {
13
- super(message);
13
+ export const safeHeaders = (headers: Record<string, string> = {}) => {
14
+ const checked: Record<string, string> = {};
14
15
 
15
- this.name = "BridgeNetError";
16
- this.status = status;
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
- export const createBridgeNet = (): BridgeNet => {
21
- const request = async (url: string, options: BridgeRequestOptions = {}) => {
22
- const remote = new URL(url);
24
+ return checked;
25
+ };
23
26
 
24
- if (remote.protocol !== "https:") {
25
- throw new BridgeNetError(null, `"${url}" is not https`);
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
- const response = await fetch(url, {
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: "follow",
34
- signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS),
63
+ redirect: "error",
64
+ signal: AbortSignal.timeout(timeoutMs),
35
65
  });
36
66
 
37
67
  if (!response.ok) {
38
- throw new BridgeNetError(response.status, `${url} answered ${response.status}`);
68
+ throw new BridgeNetError(response.status, `${remote.host} answered ${response.status}`);
39
69
  }
40
70
 
41
- const body = await response.text();
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
- if (body.length > RESPONSE_LIMIT) {
44
- throw new BridgeNetError(null, `${url} returned more than ${RESPONSE_LIMIT} bytes`);
95
+ if (!(error instanceof BridgeEgressUnavailableError)) {
96
+ throw error;
97
+ }
45
98
  }
46
99
 
47
- return body;
100
+ return await direct(remote, headers, options.timeoutMs ?? REQUEST_TIMEOUT_MS);
48
101
  };
49
102
 
50
103
  return {