@serverkgg/bridge 0.0.8 → 0.0.9

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 (48) hide show
  1. package/package.json +3 -3
  2. package/src/cli/validate.ts +1 -0
  3. package/src/context/bridgeContext.ts +10 -0
  4. package/src/context/index.ts +1 -0
  5. package/src/core/bridgeDownloadError.ts +10 -0
  6. package/src/core/bridgeHold.ts +12 -0
  7. package/src/core/bridgeKind.ts +2 -0
  8. package/src/core/bridgeUserError.test.ts +23 -0
  9. package/src/core/bridgeUserError.ts +12 -0
  10. package/src/core/index.ts +3 -0
  11. package/src/manifest/index.ts +1 -0
  12. package/src/manifest/manifest.test.ts +85 -0
  13. package/src/manifest/manifest.ts +21 -0
  14. package/src/manifest/presence.ts +48 -0
  15. package/src/manifest/primitives.ts +6 -0
  16. package/src/modules/bridge.ts +45 -2
  17. package/src/modules/bridgeDriver.ts +2 -0
  18. package/src/modules/bridgeModule.ts +25 -3
  19. package/src/panel/bridgeDetail.ts +49 -0
  20. package/src/panel/bridgeLayout.ts +1 -0
  21. package/src/panel/bridgePanel.ts +30 -0
  22. package/src/panel/bridgeUploadMode.ts +4 -0
  23. package/src/panel/index.ts +2 -0
  24. package/src/protocol/bridgeEgress.ts +8 -1
  25. package/src/protocol/bridgeEvents.ts +4 -0
  26. package/src/protocol/bridgeFrame.ts +28 -1
  27. package/src/protocol/bridgeLimits.ts +10 -0
  28. package/src/runtime/bridgeActions.ts +7 -0
  29. package/src/runtime/bridgeDriver.test.ts +477 -2
  30. package/src/runtime/bridgeDriver.ts +116 -3
  31. package/src/runtime/bridgeEgress.ts +3 -0
  32. package/src/runtime/bridgeEvents.ts +7 -5
  33. package/src/runtime/bridgeFiles.test.ts +625 -15
  34. package/src/runtime/bridgeFiles.ts +152 -45
  35. package/src/runtime/bridgeInstall.test.ts +118 -0
  36. package/src/runtime/bridgeInstall.ts +55 -0
  37. package/src/runtime/bridgeNet.ts +32 -3
  38. package/src/runtime/bridgePending.test.ts +148 -0
  39. package/src/runtime/bridgePending.ts +43 -0
  40. package/src/runtime/bridgeRegistry.test.ts +134 -0
  41. package/src/runtime/bridgeRegistry.ts +26 -4
  42. package/src/runtime/bridgeServer.test.ts +338 -0
  43. package/src/runtime/bridgeServer.ts +25 -3
  44. package/src/runtime/bridgeSupervisor.ts +18 -0
  45. package/src/runtime/bridgeZip.ts +179 -58
  46. package/src/runtime/serve.ts +64 -4
  47. package/src/wire/panel.test.ts +166 -0
  48. package/src/wire/panel.ts +32 -0
@@ -1,5 +1,5 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
- import { BridgeFailureError, BridgeSecretError, type BridgeValues } from "../core";
2
+ import { BridgeFailureError, BridgeSecretError, BridgeUserError, type BridgeValues } from "../core";
3
3
  import {
4
4
  BRIDGE_FRAME_LIMIT,
5
5
  type BridgeAnnounceResult,
@@ -8,6 +8,7 @@ import {
8
8
  type BridgeEventsResult,
9
9
  type BridgeManifest,
10
10
  BridgeMethod,
11
+ type BridgePendingResult,
11
12
  type BridgeQuiesceResult,
12
13
  type BridgeResponse,
13
14
  type BridgeSampleResult,
@@ -30,6 +31,8 @@ export interface ServerOptions {
30
31
  onRelease: () => Promise<void>;
31
32
  onDescribe: () => Promise<BridgeDescribeResult>;
32
33
  onAnnounce: (message: string) => Promise<BridgeAnnounceResult>;
34
+ onPendingList: () => Promise<BridgePendingResult>;
35
+ onPendingAdd: (input: string) => Promise<BridgePendingResult>;
33
36
  onLog: BridgeLogger;
34
37
  }
35
38
 
@@ -47,7 +50,7 @@ interface IncomingFrame {
47
50
  message?: unknown;
48
51
  }
49
52
 
50
- const asMessage = (value: unknown): string | null => {
53
+ const asText = (value: unknown): string | null => {
51
54
  return typeof value === "string" && value.length > 0 ? value : null;
52
55
  };
53
56
 
@@ -136,7 +139,7 @@ export const createServer = (options: ServerOptions) => {
136
139
  }
137
140
 
138
141
  case BridgeMethod.Announce: {
139
- const message = asMessage(request.message);
142
+ const message = asText(request.message);
140
143
 
141
144
  if (!message) {
142
145
  throw new Error("announce needs a message");
@@ -145,6 +148,20 @@ export const createServer = (options: ServerOptions) => {
145
148
  return await options.onAnnounce(message);
146
149
  }
147
150
 
151
+ case BridgeMethod.PendingList: {
152
+ return await options.onPendingList();
153
+ }
154
+
155
+ case BridgeMethod.PendingAdd: {
156
+ const input = asText(request.input);
157
+
158
+ if (!input) {
159
+ throw new Error("pendingAdd needs an input path");
160
+ }
161
+
162
+ return await options.onPendingAdd(input);
163
+ }
164
+
148
165
  default: {
149
166
  throw new Error(`unknown method "${method}"`);
150
167
  }
@@ -249,6 +266,11 @@ export const createServer = (options: ServerOptions) => {
249
266
  failure: error.code,
250
267
  }
251
268
  : {}),
269
+ ...(error instanceof BridgeUserError
270
+ ? {
271
+ text: error.text,
272
+ }
273
+ : {}),
252
274
  }),
253
275
  );
254
276
  });
@@ -18,6 +18,8 @@ const RESTART_LIMIT = 5;
18
18
 
19
19
  const GAME_OOM_SCORE_ADJ = 500;
20
20
 
21
+ const SIGKILL_EXIT_CODE = 128 + 9;
22
+
21
23
  export interface SupervisorLine {
22
24
  stream: BridgeStream;
23
25
  line: string;
@@ -113,6 +115,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
113
115
  let shouldRun = false;
114
116
  let breaker = false;
115
117
  let exitCode: number | null = null;
118
+ let outOfMemory = false;
116
119
  let restarts = 0;
117
120
  let queue: Promise<unknown> = Promise.resolve();
118
121
 
@@ -307,6 +310,8 @@ export const createSupervisor = (options: SupervisorOptions) => {
307
310
  exitCode = code;
308
311
 
309
312
  if (!shouldRun) {
313
+ outOfMemory = false;
314
+
310
315
  runtime = BridgeRuntime.Stopped;
311
316
 
312
317
  options.onLog("game stopped", {
@@ -316,6 +321,14 @@ export const createSupervisor = (options: SupervisorOptions) => {
316
321
  return;
317
322
  }
318
323
 
324
+ outOfMemory = code === SIGKILL_EXIT_CODE;
325
+
326
+ if (outOfMemory) {
327
+ options.onLog.error("the game was killed for running out of memory", {
328
+ code,
329
+ });
330
+ }
331
+
319
332
  restarts += 1;
320
333
  restartStamps.push(performance.now());
321
334
 
@@ -375,6 +388,10 @@ export const createSupervisor = (options: SupervisorOptions) => {
375
388
  return exitCode;
376
389
  },
377
390
 
391
+ get outOfMemory() {
392
+ return outOfMemory;
393
+ },
394
+
378
395
  get restarts() {
379
396
  return restarts;
380
397
  },
@@ -382,6 +399,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
382
399
  async start() {
383
400
  shouldRun = true;
384
401
  breaker = false;
402
+ outOfMemory = false;
385
403
  restartStamps.length = 0;
386
404
 
387
405
  if (child) {
@@ -6,12 +6,26 @@ const CENTRAL_SIGNATURE = 0x02_01_4b_50;
6
6
 
7
7
  const LOCAL_SIGNATURE = 0x04_03_4b_50;
8
8
 
9
+ const ZIP64_LOCATOR_SIGNATURE = 0x07_06_4b_50;
10
+
11
+ const ZIP64_EOCD_SIGNATURE = 0x06_06_4b_50;
12
+
9
13
  const EOCD_MIN_LENGTH = 22;
10
14
 
11
15
  const EOCD_SEARCH_LIMIT = 65_557;
12
16
 
17
+ const ZIP64_LOCATOR_LENGTH = 20;
18
+
19
+ const ZIP64_EOCD_MIN_LENGTH = 56;
20
+
21
+ const ZIP64_EXTRA_ID = 0x00_01;
22
+
23
+ const CENTRAL_HEADER_LENGTH = 46;
24
+
13
25
  const LOCAL_HEADER_LENGTH = 30;
14
26
 
27
+ const SIZE_SENTINEL = 0xff_ff_ff_ff;
28
+
15
29
  const STORED = 0;
16
30
 
17
31
  const DEFLATED = 8;
@@ -20,7 +34,7 @@ const ENCRYPTED_FLAG = 0x1;
20
34
 
21
35
  export const ZIP_ENTRY_LIMIT = 2048;
22
36
 
23
- export const ZIP_TREE_ENTRY_LIMIT = 8192;
37
+ export const ZIP_TREE_ENTRY_LIMIT = 16_384;
24
38
 
25
39
  const MAX_ENTRY_BYTES = 128 * 1024 * 1024;
26
40
 
@@ -30,14 +44,34 @@ const UNIX_MODE_SYMLINK = 0xa0_00;
30
44
 
31
45
  export interface ZipEntry {
32
46
  name: string;
33
- data: Uint8Array;
34
47
  symlink: boolean;
48
+ sizeBytes: number;
49
+ compressedSize: number;
50
+ method: number;
51
+ localOffset: number;
35
52
  }
36
53
 
37
- const findEndOfCentralDirectory = (buffer: Buffer) => {
38
- const start = Math.max(0, buffer.length - EOCD_SEARCH_LIMIT);
54
+ export interface ZipArchive {
55
+ entries: ZipEntry[];
56
+ read(entry: ZipEntry): Promise<Uint8Array>;
57
+ }
58
+
59
+ const readRange = async (path: string, start: number, end: number) => {
60
+ return Buffer.from(await Bun.file(path).slice(start, end).arrayBuffer());
61
+ };
62
+
63
+ const readOffset = (buffer: Buffer, offset: number) => {
64
+ const value = buffer.readBigUInt64LE(offset);
65
+
66
+ if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
67
+ throw new Error("archive declares an offset this runtime cannot address");
68
+ }
69
+
70
+ return Number(value);
71
+ };
39
72
 
40
- for (let offset = buffer.length - EOCD_MIN_LENGTH; offset >= start; offset--) {
73
+ const findEndOfCentralDirectory = (buffer: Buffer) => {
74
+ for (let offset = buffer.length - EOCD_MIN_LENGTH; offset >= 0; offset--) {
41
75
  if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) {
42
76
  return offset;
43
77
  }
@@ -46,88 +80,175 @@ const findEndOfCentralDirectory = (buffer: Buffer) => {
46
80
  return -1;
47
81
  };
48
82
 
49
- export const readZip = (buffer: Buffer, maxEntries: number = ZIP_ENTRY_LIMIT): ZipEntry[] => {
50
- const eocd = findEndOfCentralDirectory(buffer);
83
+ interface CentralDirectory {
84
+ total: number;
85
+ offset: number;
86
+ sizeBytes: number;
87
+ }
51
88
 
52
- if (eocd === -1) {
53
- throw new Error("archive has no zip central directory");
89
+ const readZip64Directory = async (path: string, tail: Buffer, eocd: number): Promise<CentralDirectory | null> => {
90
+ const locator = eocd - ZIP64_LOCATOR_LENGTH;
91
+
92
+ if (locator < 0 || tail.readUInt32LE(locator) !== ZIP64_LOCATOR_SIGNATURE) {
93
+ return null;
54
94
  }
55
95
 
56
- const total = buffer.readUInt16LE(eocd + 10);
57
- const directoryOffset = buffer.readUInt32LE(eocd + 16);
96
+ const record = readOffset(tail, locator + 8);
97
+ const buffer = await readRange(path, record, record + ZIP64_EOCD_MIN_LENGTH);
58
98
 
59
- if (total > maxEntries) {
60
- throw new Error(`archive has too many entries (${total})`);
99
+ if (buffer.length < ZIP64_EOCD_MIN_LENGTH || buffer.readUInt32LE(0) !== ZIP64_EOCD_SIGNATURE) {
100
+ throw new Error("archive has a corrupt zip64 central directory record");
61
101
  }
62
102
 
63
- const entries: ZipEntry[] = [];
103
+ return {
104
+ total: readOffset(buffer, 32),
105
+ sizeBytes: readOffset(buffer, 40),
106
+ offset: readOffset(buffer, 48),
107
+ };
108
+ };
64
109
 
65
- let cursor = directoryOffset;
110
+ interface Zip64Extended {
111
+ sizeBytes: number | null;
112
+ compressedSize: number | null;
113
+ localOffset: number | null;
114
+ }
66
115
 
67
- for (let index = 0; index < total; index++) {
68
- if (cursor + 46 > buffer.length || buffer.readUInt32LE(cursor) !== CENTRAL_SIGNATURE) {
69
- throw new Error("archive central directory is corrupt");
70
- }
116
+ const readZip64Extra = (extra: Buffer, sizeBytes: number, compressedSize: number, localOffset: number) => {
117
+ const extended: Zip64Extended = {
118
+ sizeBytes: null,
119
+ compressedSize: null,
120
+ localOffset: null,
121
+ };
71
122
 
72
- const flags = buffer.readUInt16LE(cursor + 8);
73
- const method = buffer.readUInt16LE(cursor + 10);
74
- const compressedSize = buffer.readUInt32LE(cursor + 20);
75
- const uncompressedSize = buffer.readUInt32LE(cursor + 24);
76
- const nameLength = buffer.readUInt16LE(cursor + 28);
77
- const extraLength = buffer.readUInt16LE(cursor + 30);
78
- const commentLength = buffer.readUInt16LE(cursor + 32);
79
- const externalAttributes = buffer.readUInt32LE(cursor + 38);
80
- const localOffset = buffer.readUInt32LE(cursor + 42);
81
- const name = buffer.subarray(cursor + 46, cursor + 46 + nameLength).toString("utf8");
82
- const symlink = ((externalAttributes >>> 16) & UNIX_MODE_FORMAT_MASK) === UNIX_MODE_SYMLINK;
123
+ let cursor = 0;
83
124
 
84
- cursor += 46 + nameLength + extraLength + commentLength;
125
+ while (cursor + 4 <= extra.length) {
126
+ const id = extra.readUInt16LE(cursor);
127
+ const length = extra.readUInt16LE(cursor + 2);
128
+ const body = extra.subarray(cursor + 4, cursor + 4 + length);
85
129
 
86
- if (name.endsWith("/") || (flags & ENCRYPTED_FLAG) !== 0) {
130
+ cursor += 4 + length;
131
+
132
+ if (id !== ZIP64_EXTRA_ID) {
87
133
  continue;
88
134
  }
89
135
 
90
- if (uncompressedSize > MAX_ENTRY_BYTES) {
91
- throw new Error(`archive entry "${name}" is too large`);
136
+ let field = 0;
137
+
138
+ if (sizeBytes === SIZE_SENTINEL && field + 8 <= body.length) {
139
+ extended.sizeBytes = readOffset(body, field);
140
+ field += 8;
141
+ }
142
+
143
+ if (compressedSize === SIZE_SENTINEL && field + 8 <= body.length) {
144
+ extended.compressedSize = readOffset(body, field);
145
+ field += 8;
92
146
  }
93
147
 
94
- if (localOffset + LOCAL_HEADER_LENGTH > buffer.length || buffer.readUInt32LE(localOffset) !== LOCAL_SIGNATURE) {
95
- throw new Error(`archive entry "${name}" has a corrupt local header`);
148
+ if (localOffset === SIZE_SENTINEL && field + 8 <= body.length) {
149
+ extended.localOffset = readOffset(body, field);
96
150
  }
97
151
 
98
- const localNameLength = buffer.readUInt16LE(localOffset + 26);
99
- const localExtraLength = buffer.readUInt16LE(localOffset + 28);
100
- const dataOffset = localOffset + LOCAL_HEADER_LENGTH + localNameLength + localExtraLength;
101
- const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize);
152
+ return extended;
153
+ }
102
154
 
103
- if (method === STORED) {
104
- entries.push({
105
- name,
106
- data: compressed,
107
- symlink,
108
- });
155
+ return extended;
156
+ };
109
157
 
110
- continue;
111
- }
158
+ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT): Promise<ZipArchive> => {
159
+ const size = Bun.file(path).size;
160
+ const tailStart = Math.max(0, size - (EOCD_SEARCH_LIMIT + ZIP64_LOCATOR_LENGTH));
161
+ const tail = await readRange(path, tailStart, size);
162
+ const eocd = tail.length < EOCD_MIN_LENGTH ? -1 : findEndOfCentralDirectory(tail);
163
+
164
+ if (eocd === -1) {
165
+ throw new Error("archive has no zip central directory");
166
+ }
167
+
168
+ const directory = (await readZip64Directory(path, tail, eocd)) ?? {
169
+ total: tail.readUInt16LE(eocd + 10),
170
+ sizeBytes: tail.readUInt32LE(eocd + 12),
171
+ offset: tail.readUInt32LE(eocd + 16),
172
+ };
173
+
174
+ if (directory.total > maxEntries) {
175
+ throw new Error(`archive has too many entries (${directory.total})`);
176
+ }
177
+
178
+ const central = await readRange(path, directory.offset, directory.offset + directory.sizeBytes);
179
+ const entries: ZipEntry[] = [];
112
180
 
113
- if (method !== DEFLATED) {
114
- throw new Error(`archive entry "${name}" uses unsupported compression ${method}`);
181
+ let cursor = 0;
182
+
183
+ for (let index = 0; index < directory.total; index++) {
184
+ if (cursor + CENTRAL_HEADER_LENGTH > central.length || central.readUInt32LE(cursor) !== CENTRAL_SIGNATURE) {
185
+ throw new Error("archive central directory is corrupt");
115
186
  }
116
187
 
117
- const data = inflateRawSync(compressed, {
118
- maxOutputLength: MAX_ENTRY_BYTES,
119
- });
188
+ const flags = central.readUInt16LE(cursor + 8);
189
+ const method = central.readUInt16LE(cursor + 10);
190
+ const compressedSize = central.readUInt32LE(cursor + 20);
191
+ const sizeBytes = central.readUInt32LE(cursor + 24);
192
+ const nameLength = central.readUInt16LE(cursor + 28);
193
+ const extraLength = central.readUInt16LE(cursor + 30);
194
+ const commentLength = central.readUInt16LE(cursor + 32);
195
+ const externalAttributes = central.readUInt32LE(cursor + 38);
196
+ const localOffset = central.readUInt32LE(cursor + 42);
197
+ const nameStart = cursor + CENTRAL_HEADER_LENGTH;
198
+ const name = central.subarray(nameStart, nameStart + nameLength).toString("utf8");
199
+ const extra = central.subarray(nameStart + nameLength, nameStart + nameLength + extraLength);
200
+ const extended = readZip64Extra(extra, sizeBytes, compressedSize, localOffset);
201
+
202
+ cursor = nameStart + nameLength + extraLength + commentLength;
120
203
 
121
- if (data.length !== uncompressedSize) {
122
- throw new Error(`archive entry "${name}" did not inflate to its declared size`);
204
+ if (name.endsWith("/") || (flags & ENCRYPTED_FLAG) !== 0) {
205
+ continue;
123
206
  }
124
207
 
125
208
  entries.push({
126
209
  name,
127
- data,
128
- symlink,
210
+ symlink: ((externalAttributes >>> 16) & UNIX_MODE_FORMAT_MASK) === UNIX_MODE_SYMLINK,
211
+ sizeBytes: extended.sizeBytes ?? sizeBytes,
212
+ compressedSize: extended.compressedSize ?? compressedSize,
213
+ method,
214
+ localOffset: extended.localOffset ?? localOffset,
129
215
  });
130
216
  }
131
217
 
132
- return entries;
218
+ return {
219
+ entries,
220
+
221
+ async read(entry) {
222
+ if (entry.sizeBytes > MAX_ENTRY_BYTES || entry.compressedSize > MAX_ENTRY_BYTES) {
223
+ throw new Error(`archive entry "${entry.name}" is too large`);
224
+ }
225
+
226
+ const header = await readRange(path, entry.localOffset, entry.localOffset + LOCAL_HEADER_LENGTH);
227
+
228
+ if (header.length < LOCAL_HEADER_LENGTH || header.readUInt32LE(0) !== LOCAL_SIGNATURE) {
229
+ throw new Error(`archive entry "${entry.name}" has a corrupt local header`);
230
+ }
231
+
232
+ const dataOffset = entry.localOffset + LOCAL_HEADER_LENGTH + header.readUInt16LE(26) + header.readUInt16LE(28);
233
+ const compressed = await readRange(path, dataOffset, dataOffset + entry.compressedSize);
234
+
235
+ if (entry.method === STORED) {
236
+ return compressed;
237
+ }
238
+
239
+ if (entry.method !== DEFLATED) {
240
+ throw new Error(`archive entry "${entry.name}" uses unsupported compression ${entry.method}`);
241
+ }
242
+
243
+ const data = inflateRawSync(compressed, {
244
+ maxOutputLength: MAX_ENTRY_BYTES,
245
+ });
246
+
247
+ if (data.length !== entry.sizeBytes) {
248
+ throw new Error(`archive entry "${entry.name}" did not inflate to its declared size`);
249
+ }
250
+
251
+ return data;
252
+ },
253
+ };
133
254
  };
@@ -1,10 +1,11 @@
1
1
  import { chmod, chown, mkdir, rm } from "node:fs/promises";
2
- import { BridgeDesired, BridgeRuntime } from "../core";
2
+ import { BridgeDesired, type BridgeInstallHold, BridgeRuntime, BridgeUserError } from "../core";
3
3
  import type { BridgeDriver } from "../modules";
4
4
  import {
5
5
  BRIDGE_INSTALL_ERROR_LIMIT,
6
6
  BRIDGE_VERSION,
7
7
  type BridgeConfig,
8
+ type BridgePendingResult,
8
9
  type BridgeSampleResult,
9
10
  type BridgeStatus,
10
11
  } from "../protocol";
@@ -12,7 +13,9 @@ import { configPath, readConfig, socketPath, statePath } from "./bridgeConfigRea
12
13
  import { createContext } from "./bridgeContext";
13
14
  import { validateDriver } from "./bridgeDriver";
14
15
  import { createEventRing } from "./bridgeEvents";
16
+ import { decideInstall, recountHold } from "./bridgeInstall";
15
17
  import { errorText, log } from "./bridgeLog";
18
+ import { pendingResult, rejectedPending, unsupportedPending } from "./bridgePending";
16
19
  import { createRegistry } from "./bridgeRegistry";
17
20
  import { createServer } from "./bridgeServer";
18
21
  import { readDesired, writeDesired } from "./bridgeState";
@@ -64,6 +67,8 @@ export const serve = async (driver: BridgeDriver) => {
64
67
 
65
68
  let installError: string | null = null;
66
69
 
70
+ let installHold: BridgeInstallHold | null = null;
71
+
67
72
  const supervisor = createSupervisor({
68
73
  cwd: config.cwd,
69
74
  uid: config.gameUid,
@@ -105,8 +110,10 @@ export const serve = async (driver: BridgeDriver) => {
105
110
  runtime: supervisor.runtime,
106
111
  desired: current,
107
112
  exitCode: supervisor.exitCode,
113
+ outOfMemory: supervisor.outOfMemory,
108
114
  restarts: supervisor.restarts,
109
115
  installError,
116
+ installHold,
110
117
  };
111
118
  };
112
119
 
@@ -152,6 +159,20 @@ export const serve = async (driver: BridgeDriver) => {
152
159
  return value;
153
160
  };
154
161
 
162
+ const pending = async (): Promise<BridgePendingResult> => {
163
+ const module = driver.pending;
164
+
165
+ if (!module) {
166
+ return unsupportedPending();
167
+ }
168
+
169
+ const files = await module.list(context);
170
+
171
+ installHold = recountHold(installHold, files.length);
172
+
173
+ return pendingResult(module.staging, module.extensions, files, log);
174
+ };
175
+
155
176
  const server = createServer({
156
177
  socket: socketPath(config),
157
178
  token: config.token,
@@ -260,6 +281,32 @@ export const serve = async (driver: BridgeDriver) => {
260
281
  delivered: true,
261
282
  };
262
283
  },
284
+ async onPendingList() {
285
+ return await pending();
286
+ },
287
+ async onPendingAdd(input) {
288
+ const module = driver.pending;
289
+
290
+ if (!module) {
291
+ return unsupportedPending();
292
+ }
293
+
294
+ try {
295
+ await module.add(context, input);
296
+ } catch (error) {
297
+ if (!(error instanceof BridgeUserError)) {
298
+ throw error;
299
+ }
300
+
301
+ log.warn("pending upload rejected", {
302
+ error: error.message,
303
+ });
304
+
305
+ return rejectedPending(await pending());
306
+ }
307
+
308
+ return await pending();
309
+ },
263
310
  });
264
311
 
265
312
  await chmod(socketPath(config), SOCKET_MODE);
@@ -303,8 +350,10 @@ export const serve = async (driver: BridgeDriver) => {
303
350
 
304
351
  log("installing");
305
352
 
353
+ let decision = decideInstall(undefined, current);
354
+
306
355
  try {
307
- await driver.install.run(context);
356
+ decision = decideInstall(await driver.install.run(context), current);
308
357
 
309
358
  log("install complete");
310
359
  } catch (error) {
@@ -319,9 +368,20 @@ export const serve = async (driver: BridgeDriver) => {
319
368
  return;
320
369
  }
321
370
 
322
- supervisor.setRuntime(current === BridgeDesired.Running ? BridgeRuntime.Starting : BridgeRuntime.Stopped);
371
+ installHold = decision.hold;
372
+
373
+ supervisor.setRuntime(decision.runtime);
374
+
375
+ if (installHold) {
376
+ log("install held", {
377
+ reason: installHold.reason,
378
+ count: installHold.count,
379
+ });
380
+
381
+ return;
382
+ }
323
383
 
324
- if (current === BridgeDesired.Running) {
384
+ if (decision.start) {
325
385
  try {
326
386
  await supervisor.start();
327
387
  } catch (error) {