@serverkgg/bridge 0.0.16 → 0.0.18
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 +2 -2
- package/src/codecs/index.ts +1 -0
- package/src/codecs/javaProperties.test.ts +67 -0
- package/src/codecs/javaProperties.ts +99 -0
- package/src/context/bridgeContext.ts +8 -1
- package/src/core/bridgeAction.ts +2 -0
- package/src/modules/bridge.ts +2 -0
- package/src/modules/bridgeModule.ts +23 -6
- package/src/panel/bridgeCollection.ts +5 -0
- package/src/panel/index.ts +1 -0
- package/src/protocol/bridgeCatalog.ts +6 -0
- package/src/protocol/bridgeFrame.ts +13 -0
- package/src/protocol/bridgeManifest.ts +1 -0
- package/src/protocol/bridgePreview.ts +1 -0
- package/src/runtime/bridgeActions.ts +10 -0
- package/src/runtime/bridgeBackupLease.test.ts +148 -0
- package/src/runtime/bridgeBackupLease.ts +74 -0
- package/src/runtime/bridgeCodec.ts +12 -0
- package/src/runtime/bridgeFiles.test.ts +330 -4
- package/src/runtime/bridgeFiles.ts +112 -18
- package/src/runtime/bridgeRegistry.test.ts +61 -0
- package/src/runtime/bridgeRegistry.ts +31 -5
- package/src/runtime/bridgeServer.test.ts +57 -0
- package/src/runtime/bridgeServer.ts +14 -6
- package/src/runtime/bridgeZip.ts +73 -6
- package/src/runtime/serve.ts +48 -14
- package/src/wire/panel.ts +1 -0
|
@@ -31,6 +31,18 @@ const requiresRunning = (module: BridgePanelModule) => {
|
|
|
31
31
|
return false;
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
export const protectedActionsOf = (module: BridgePanelModule): string[] => {
|
|
35
|
+
const protectedActions = "protectedActions" in module ? (module.protectedActions ?? []) : [];
|
|
36
|
+
const declared = actionsOf(module);
|
|
37
|
+
if (
|
|
38
|
+
protectedActions.some((action) => isReadAction(action) || !declared.includes(action))
|
|
39
|
+
|| (protectedActions.length > 0 && requiresRunning(module))
|
|
40
|
+
) {
|
|
41
|
+
throw new BridgeDriverError("protected actions must be declared mutations that work while stopped");
|
|
42
|
+
}
|
|
43
|
+
return protectedActions;
|
|
44
|
+
};
|
|
45
|
+
|
|
34
46
|
const refreshOf = (module: BridgePanelModule) => {
|
|
35
47
|
if (module.kind === BridgeKind.Collection || module.kind === BridgeKind.Detail) {
|
|
36
48
|
return module.refreshSeconds ?? null;
|
|
@@ -312,9 +324,7 @@ export const createRegistry = (
|
|
|
312
324
|
}
|
|
313
325
|
|
|
314
326
|
if (action === BridgeAction.Add && module.add) {
|
|
315
|
-
await module.add(context, asString(args, "input"));
|
|
316
|
-
|
|
317
|
-
return null;
|
|
327
|
+
return (await module.add(context, asString(args, "input"))) ?? null;
|
|
318
328
|
}
|
|
319
329
|
|
|
320
330
|
const rowAction = module.actions?.[action];
|
|
@@ -358,6 +368,12 @@ export const createRegistry = (
|
|
|
358
368
|
}
|
|
359
369
|
|
|
360
370
|
case BridgeKind.Catalog: {
|
|
371
|
+
if (action === BridgeAction.Releases && module.releases) {
|
|
372
|
+
return await module.releases(context, asString(args, "id"));
|
|
373
|
+
}
|
|
374
|
+
if (action === BridgeAction.Preview && module.preview) {
|
|
375
|
+
return await module.preview(context, asString(args, "id"), asOptional(args, "releaseId") ?? undefined);
|
|
376
|
+
}
|
|
361
377
|
if (action === BridgeAction.Search) {
|
|
362
378
|
return await module.search(context, {
|
|
363
379
|
query: asText(args, "query"),
|
|
@@ -373,7 +389,7 @@ export const createRegistry = (
|
|
|
373
389
|
}
|
|
374
390
|
|
|
375
391
|
if (action === BridgeAction.Install) {
|
|
376
|
-
return await module.install(context, asString(args, "id"));
|
|
392
|
+
return await module.install(context, asString(args, "id"), asOptional(args, "releaseId") ?? undefined);
|
|
377
393
|
}
|
|
378
394
|
|
|
379
395
|
if (action === BridgeAction.Remove) {
|
|
@@ -398,6 +414,7 @@ export const createRegistry = (
|
|
|
398
414
|
return {
|
|
399
415
|
manifest(): BridgeManifest {
|
|
400
416
|
const entries: BridgeModuleManifest[] = Object.entries(modules).map(([id, module]) => {
|
|
417
|
+
const protectedActions = protectedActionsOf(module);
|
|
401
418
|
return {
|
|
402
419
|
id,
|
|
403
420
|
kind: module.kind,
|
|
@@ -406,6 +423,11 @@ export const createRegistry = (
|
|
|
406
423
|
pageSize: pageSizeOf(module),
|
|
407
424
|
dependsOn: dependsOf(module),
|
|
408
425
|
actions: actionsOf(module),
|
|
426
|
+
...(protectedActions.length > 0
|
|
427
|
+
? {
|
|
428
|
+
protectedActions,
|
|
429
|
+
}
|
|
430
|
+
: {}),
|
|
409
431
|
};
|
|
410
432
|
});
|
|
411
433
|
|
|
@@ -419,13 +441,17 @@ export const createRegistry = (
|
|
|
419
441
|
};
|
|
420
442
|
},
|
|
421
443
|
|
|
422
|
-
async invoke(name: string, action: string, args: BridgeValues = {}) {
|
|
444
|
+
async invoke(name: string, action: string, args: BridgeValues = {}, recoveryOperationId?: string) {
|
|
423
445
|
const module = modules[name];
|
|
424
446
|
|
|
425
447
|
if (!module) {
|
|
426
448
|
throw new BridgeDriverError(`no module named "${name}"`);
|
|
427
449
|
}
|
|
428
450
|
|
|
451
|
+
if (protectedActionsOf(module).includes(action) && (!recoveryOperationId || supervisor.running)) {
|
|
452
|
+
throw new BridgeDriverError("this action needs a recovery operation with the game stopped");
|
|
453
|
+
}
|
|
454
|
+
|
|
429
455
|
if (requiresRunning(module) && !supervisor.running) {
|
|
430
456
|
throw new BridgeDriverError(`module "${name}" needs the game to be running`);
|
|
431
457
|
}
|
|
@@ -597,3 +597,60 @@ describe("error serialisation", () => {
|
|
|
597
597
|
});
|
|
598
598
|
});
|
|
599
599
|
});
|
|
600
|
+
|
|
601
|
+
describe("backup ownership dispatch", () => {
|
|
602
|
+
let dir = "";
|
|
603
|
+
beforeEach(async () => {
|
|
604
|
+
dir = await mkdtemp(join(tmpdir(), "bridge-lease-"));
|
|
605
|
+
});
|
|
606
|
+
afterEach(async () => {
|
|
607
|
+
await rm(dir, {
|
|
608
|
+
force: true,
|
|
609
|
+
recursive: true,
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
test("carries the lease token through renew and release frames", async () => {
|
|
613
|
+
const socket = join(dir, "bridge.sock");
|
|
614
|
+
const renewals: (string | undefined)[] = [];
|
|
615
|
+
const releases: (string | undefined)[] = [];
|
|
616
|
+
const server = createServer({
|
|
617
|
+
...baseOptions(socket),
|
|
618
|
+
onQuiesce: async (supportsSnapshot, leaseId) => {
|
|
619
|
+
expect(supportsSnapshot).toBe(true);
|
|
620
|
+
renewals.push(leaseId);
|
|
621
|
+
return {
|
|
622
|
+
supported: true,
|
|
623
|
+
quiesced: true,
|
|
624
|
+
settleSeconds: 0,
|
|
625
|
+
leaseId,
|
|
626
|
+
};
|
|
627
|
+
},
|
|
628
|
+
onRelease: async (leaseId) => {
|
|
629
|
+
releases.push(leaseId);
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
try {
|
|
633
|
+
await call(socket, {
|
|
634
|
+
id: "renew",
|
|
635
|
+
token: TOKEN,
|
|
636
|
+
method: BridgeMethod.Quiesce,
|
|
637
|
+
supportsSnapshot: true,
|
|
638
|
+
leaseId: "owner",
|
|
639
|
+
});
|
|
640
|
+
await call(socket, {
|
|
641
|
+
id: "release",
|
|
642
|
+
token: TOKEN,
|
|
643
|
+
method: BridgeMethod.Release,
|
|
644
|
+
leaseId: "owner",
|
|
645
|
+
});
|
|
646
|
+
expect(renewals).toEqual([
|
|
647
|
+
"owner",
|
|
648
|
+
]);
|
|
649
|
+
expect(releases).toEqual([
|
|
650
|
+
"owner",
|
|
651
|
+
]);
|
|
652
|
+
} finally {
|
|
653
|
+
server.stop();
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
});
|
|
@@ -25,11 +25,11 @@ export interface ServerOptions {
|
|
|
25
25
|
onStart: () => Promise<void>;
|
|
26
26
|
onStop: () => Promise<void>;
|
|
27
27
|
onCommand: (input: string, expect: string | undefined, timeoutMs: number | undefined) => Promise<BridgeCommandResult>;
|
|
28
|
-
onInvoke: (module: string, action: string, args: BridgeValues) => Promise<unknown>;
|
|
28
|
+
onInvoke: (module: string, action: string, args: BridgeValues, recoveryOperationId?: string) => Promise<unknown>;
|
|
29
29
|
onEvents: (cursor: number) => BridgeEventsResult;
|
|
30
30
|
onSample: () => Promise<BridgeSampleResult>;
|
|
31
|
-
onQuiesce: () => Promise<BridgeQuiesceResult>;
|
|
32
|
-
onRelease: () => Promise<void>;
|
|
31
|
+
onQuiesce: (supportsSnapshot: boolean, leaseId?: string) => Promise<BridgeQuiesceResult>;
|
|
32
|
+
onRelease: (leaseId?: string) => Promise<void>;
|
|
33
33
|
onDescribe: () => Promise<BridgeDescribeResult>;
|
|
34
34
|
onAnnounce: (message: string) => Promise<BridgeAnnounceResult>;
|
|
35
35
|
onPendingList: () => Promise<BridgePendingResult>;
|
|
@@ -40,6 +40,9 @@ export interface ServerOptions {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
interface IncomingFrame {
|
|
43
|
+
leaseId?: unknown;
|
|
44
|
+
supportsSnapshot?: unknown;
|
|
45
|
+
recoveryOperationId?: unknown;
|
|
43
46
|
id?: unknown;
|
|
44
47
|
token?: unknown;
|
|
45
48
|
method?: unknown;
|
|
@@ -116,7 +119,12 @@ export const createServer = (options: ServerOptions) => {
|
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
case BridgeMethod.Invoke: {
|
|
119
|
-
return await options.onInvoke(
|
|
122
|
+
return await options.onInvoke(
|
|
123
|
+
String(request.module ?? ""),
|
|
124
|
+
String(request.action ?? ""),
|
|
125
|
+
asArgs(request.args),
|
|
126
|
+
asText(request.recoveryOperationId) ?? undefined,
|
|
127
|
+
);
|
|
120
128
|
}
|
|
121
129
|
|
|
122
130
|
case BridgeMethod.Events: {
|
|
@@ -128,11 +136,11 @@ export const createServer = (options: ServerOptions) => {
|
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
case BridgeMethod.Quiesce: {
|
|
131
|
-
return await options.onQuiesce();
|
|
139
|
+
return await options.onQuiesce(request.supportsSnapshot === true, asText(request.leaseId) ?? undefined);
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
case BridgeMethod.Release: {
|
|
135
|
-
await options.onRelease();
|
|
143
|
+
await options.onRelease(asText(request.leaseId) ?? undefined);
|
|
136
144
|
|
|
137
145
|
return null;
|
|
138
146
|
}
|
package/src/runtime/bridgeZip.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { inflateRawSync } from "node:zlib";
|
|
1
|
+
import { crc32, inflateRawSync } from "node:zlib";
|
|
2
2
|
|
|
3
3
|
const EOCD_SIGNATURE = 0x06_05_4b_50;
|
|
4
4
|
|
|
@@ -32,11 +32,14 @@ const DEFLATED = 8;
|
|
|
32
32
|
|
|
33
33
|
const ENCRYPTED_FLAG = 0x1;
|
|
34
34
|
|
|
35
|
+
const DATA_DESCRIPTOR_FLAG = 0x8;
|
|
36
|
+
|
|
35
37
|
export const ZIP_ENTRY_LIMIT = 2048;
|
|
36
38
|
|
|
37
39
|
export const ZIP_TREE_ENTRY_LIMIT = 16_384;
|
|
38
40
|
|
|
39
41
|
const MAX_ENTRY_BYTES = 128 * 1024 * 1024;
|
|
42
|
+
const MAX_CONFIGURED_ENTRY_BYTES = 512 * 1024 * 1024;
|
|
40
43
|
|
|
41
44
|
const UNIX_MODE_FORMAT_MASK = 0xf0_00;
|
|
42
45
|
|
|
@@ -48,6 +51,8 @@ export interface ZipEntry {
|
|
|
48
51
|
sizeBytes: number;
|
|
49
52
|
compressedSize: number;
|
|
50
53
|
method: number;
|
|
54
|
+
flags: number;
|
|
55
|
+
checksum: number;
|
|
51
56
|
localOffset: number;
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -155,7 +160,14 @@ const readZip64Extra = (extra: Buffer, sizeBytes: number, compressedSize: number
|
|
|
155
160
|
return extended;
|
|
156
161
|
};
|
|
157
162
|
|
|
158
|
-
export const openZip = async (
|
|
163
|
+
export const openZip = async (
|
|
164
|
+
path: string,
|
|
165
|
+
maxEntries: number = ZIP_ENTRY_LIMIT,
|
|
166
|
+
maxEntryBytes: number = MAX_ENTRY_BYTES,
|
|
167
|
+
): Promise<ZipArchive> => {
|
|
168
|
+
if (!Number.isSafeInteger(maxEntryBytes) || maxEntryBytes <= 0 || maxEntryBytes > MAX_CONFIGURED_ENTRY_BYTES) {
|
|
169
|
+
throw new Error("archive entry byte limit is invalid");
|
|
170
|
+
}
|
|
159
171
|
const size = Bun.file(path).size;
|
|
160
172
|
const tailStart = Math.max(0, size - (EOCD_SEARCH_LIMIT + ZIP64_LOCATOR_LENGTH));
|
|
161
173
|
const tail = await readRange(path, tailStart, size);
|
|
@@ -174,6 +186,9 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
|
|
|
174
186
|
if (directory.total > maxEntries) {
|
|
175
187
|
throw new Error(`archive has too many entries (${directory.total})`);
|
|
176
188
|
}
|
|
189
|
+
if (directory.sizeBytes > 16 * 1024 * 1024 || directory.offset + directory.sizeBytes > size) {
|
|
190
|
+
throw new Error("archive central directory exceeds its bounds");
|
|
191
|
+
}
|
|
177
192
|
|
|
178
193
|
const central = await readRange(path, directory.offset, directory.offset + directory.sizeBytes);
|
|
179
194
|
const entries: ZipEntry[] = [];
|
|
@@ -187,6 +202,7 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
|
|
|
187
202
|
|
|
188
203
|
const flags = central.readUInt16LE(cursor + 8);
|
|
189
204
|
const method = central.readUInt16LE(cursor + 10);
|
|
205
|
+
const checksum = central.readUInt32LE(cursor + 16);
|
|
190
206
|
const compressedSize = central.readUInt32LE(cursor + 20);
|
|
191
207
|
const sizeBytes = central.readUInt32LE(cursor + 24);
|
|
192
208
|
const nameLength = central.readUInt16LE(cursor + 28);
|
|
@@ -200,8 +216,14 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
|
|
|
200
216
|
const extended = readZip64Extra(extra, sizeBytes, compressedSize, localOffset);
|
|
201
217
|
|
|
202
218
|
cursor = nameStart + nameLength + extraLength + commentLength;
|
|
219
|
+
if (cursor > central.length) {
|
|
220
|
+
throw new Error("archive central entry exceeds its directory");
|
|
221
|
+
}
|
|
203
222
|
|
|
204
|
-
if (
|
|
223
|
+
if ((flags & ENCRYPTED_FLAG) !== 0) {
|
|
224
|
+
throw new Error("encrypted archive entries are not supported");
|
|
225
|
+
}
|
|
226
|
+
if (name.endsWith("/")) {
|
|
205
227
|
continue;
|
|
206
228
|
}
|
|
207
229
|
|
|
@@ -211,6 +233,8 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
|
|
|
211
233
|
sizeBytes: extended.sizeBytes ?? sizeBytes,
|
|
212
234
|
compressedSize: extended.compressedSize ?? compressedSize,
|
|
213
235
|
method,
|
|
236
|
+
flags,
|
|
237
|
+
checksum,
|
|
214
238
|
localOffset: extended.localOffset ?? localOffset,
|
|
215
239
|
});
|
|
216
240
|
}
|
|
@@ -219,20 +243,59 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
|
|
|
219
243
|
entries,
|
|
220
244
|
|
|
221
245
|
async read(entry) {
|
|
222
|
-
if (entry.sizeBytes >
|
|
246
|
+
if (entry.sizeBytes > maxEntryBytes || entry.compressedSize > maxEntryBytes) {
|
|
223
247
|
throw new Error(`archive entry "${entry.name}" is too large`);
|
|
224
248
|
}
|
|
225
249
|
|
|
250
|
+
if (entry.localOffset + LOCAL_HEADER_LENGTH > directory.offset) {
|
|
251
|
+
throw new Error(`archive entry "${entry.name}" has an out-of-bounds local header`);
|
|
252
|
+
}
|
|
253
|
+
|
|
226
254
|
const header = await readRange(path, entry.localOffset, entry.localOffset + LOCAL_HEADER_LENGTH);
|
|
227
255
|
|
|
228
256
|
if (header.length < LOCAL_HEADER_LENGTH || header.readUInt32LE(0) !== LOCAL_SIGNATURE) {
|
|
229
257
|
throw new Error(`archive entry "${entry.name}" has a corrupt local header`);
|
|
230
258
|
}
|
|
231
259
|
|
|
232
|
-
|
|
260
|
+
if (header.readUInt16LE(8) !== entry.method || header.readUInt16LE(6) !== entry.flags) {
|
|
261
|
+
throw new Error(`archive entry "${entry.name}" has inconsistent local compression flags or method`);
|
|
262
|
+
}
|
|
263
|
+
if ((entry.flags & DATA_DESCRIPTOR_FLAG) === 0 && header.readUInt32LE(14) !== entry.checksum) {
|
|
264
|
+
throw new Error(`archive entry "${entry.name}" has inconsistent local checksum`);
|
|
265
|
+
}
|
|
266
|
+
if ((entry.flags & DATA_DESCRIPTOR_FLAG) === 0) {
|
|
267
|
+
const localCompressed = header.readUInt32LE(18);
|
|
268
|
+
const localSize = header.readUInt32LE(22);
|
|
269
|
+
if (
|
|
270
|
+
(localCompressed !== SIZE_SENTINEL && localCompressed !== entry.compressedSize)
|
|
271
|
+
|| (localSize !== SIZE_SENTINEL && localSize !== entry.sizeBytes)
|
|
272
|
+
) {
|
|
273
|
+
throw new Error(`archive entry "${entry.name}" has inconsistent local sizes`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const nameOffset = entry.localOffset + LOCAL_HEADER_LENGTH;
|
|
278
|
+
const nameLength = header.readUInt16LE(26);
|
|
279
|
+
const dataOffset = nameOffset + nameLength + header.readUInt16LE(28);
|
|
280
|
+
if (dataOffset > directory.offset || entry.compressedSize > directory.offset - dataOffset) {
|
|
281
|
+
throw new Error(`archive entry "${entry.name}" data exceeds its local bounds`);
|
|
282
|
+
}
|
|
283
|
+
const name = await readRange(path, nameOffset, nameOffset + nameLength);
|
|
284
|
+
if (name.toString("utf8") !== entry.name) {
|
|
285
|
+
throw new Error(`archive entry "${entry.name}" has an inconsistent local name`);
|
|
286
|
+
}
|
|
233
287
|
const compressed = await readRange(path, dataOffset, dataOffset + entry.compressedSize);
|
|
288
|
+
if (compressed.length !== entry.compressedSize) {
|
|
289
|
+
throw new Error(`archive entry "${entry.name}" has truncated compressed data`);
|
|
290
|
+
}
|
|
234
291
|
|
|
235
292
|
if (entry.method === STORED) {
|
|
293
|
+
if (compressed.length !== entry.sizeBytes || compressed.length !== entry.compressedSize) {
|
|
294
|
+
throw new Error(`archive entry "${entry.name}" does not match its declared size`);
|
|
295
|
+
}
|
|
296
|
+
if (crc32(compressed) !== entry.checksum) {
|
|
297
|
+
throw new Error(`archive entry "${entry.name}" failed its CRC32 checksum`);
|
|
298
|
+
}
|
|
236
299
|
return compressed;
|
|
237
300
|
}
|
|
238
301
|
|
|
@@ -241,13 +304,17 @@ export const openZip = async (path: string, maxEntries: number = ZIP_ENTRY_LIMIT
|
|
|
241
304
|
}
|
|
242
305
|
|
|
243
306
|
const data = inflateRawSync(compressed, {
|
|
244
|
-
maxOutputLength:
|
|
307
|
+
maxOutputLength: maxEntryBytes,
|
|
245
308
|
});
|
|
246
309
|
|
|
247
310
|
if (data.length !== entry.sizeBytes) {
|
|
248
311
|
throw new Error(`archive entry "${entry.name}" did not inflate to its declared size`);
|
|
249
312
|
}
|
|
250
313
|
|
|
314
|
+
if (crc32(data) !== entry.checksum) {
|
|
315
|
+
throw new Error(`archive entry "${entry.name}" failed its CRC32 checksum`);
|
|
316
|
+
}
|
|
317
|
+
|
|
251
318
|
return data;
|
|
252
319
|
},
|
|
253
320
|
};
|
package/src/runtime/serve.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type BridgeSampleResult,
|
|
10
10
|
type BridgeStatus,
|
|
11
11
|
} from "../protocol";
|
|
12
|
+
import { createBackupLease } from "./bridgeBackupLease";
|
|
12
13
|
import { configPath, readConfig, socketPath, statePath } from "./bridgeConfigReader";
|
|
13
14
|
import { createConsoleHandler } from "./bridgeConsoleHandler";
|
|
14
15
|
import { createContext } from "./bridgeContext";
|
|
@@ -120,6 +121,18 @@ export const serve = async (driver: BridgeDriver) => {
|
|
|
120
121
|
});
|
|
121
122
|
|
|
122
123
|
const registry = createRegistry(driver, context, supervisor, ring);
|
|
124
|
+
const backupLease = createBackupLease(async () => {
|
|
125
|
+
if (supervisor.running) {
|
|
126
|
+
await driver.backup?.release?.(context);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
setInterval(() => {
|
|
130
|
+
void backupLease.expire().catch((error: unknown) => {
|
|
131
|
+
log.error("could not resume saves after the backup lease expired", {
|
|
132
|
+
error: errorText(error),
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}, 10_000).unref();
|
|
123
136
|
|
|
124
137
|
const runConsole = createConsoleHandler({
|
|
125
138
|
driver,
|
|
@@ -229,8 +242,8 @@ export const serve = async (driver: BridgeDriver) => {
|
|
|
229
242
|
timeoutMs,
|
|
230
243
|
});
|
|
231
244
|
},
|
|
232
|
-
async onInvoke(module, action, args) {
|
|
233
|
-
return await registry.invoke(module, action, args);
|
|
245
|
+
async onInvoke(module, action, args, recoveryOperationId) {
|
|
246
|
+
return await registry.invoke(module, action, args, recoveryOperationId);
|
|
234
247
|
},
|
|
235
248
|
onEvents(cursor) {
|
|
236
249
|
return ring.drain(cursor);
|
|
@@ -238,7 +251,18 @@ export const serve = async (driver: BridgeDriver) => {
|
|
|
238
251
|
async onSample() {
|
|
239
252
|
return await sample();
|
|
240
253
|
},
|
|
241
|
-
async onQuiesce() {
|
|
254
|
+
async onQuiesce(supportsSnapshot, leaseId) {
|
|
255
|
+
if (leaseId) {
|
|
256
|
+
return {
|
|
257
|
+
supported: true,
|
|
258
|
+
quiesced: true,
|
|
259
|
+
settleSeconds: 0,
|
|
260
|
+
leaseId: backupLease.renew(leaseId),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (backupLease.active) {
|
|
264
|
+
throw new Error("a backup already owns the save hold");
|
|
265
|
+
}
|
|
242
266
|
const backup = driver.backup;
|
|
243
267
|
|
|
244
268
|
if (!backup?.quiesce) {
|
|
@@ -257,19 +281,29 @@ export const serve = async (driver: BridgeDriver) => {
|
|
|
257
281
|
};
|
|
258
282
|
}
|
|
259
283
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
284
|
+
const quiesce = backup.quiesce;
|
|
285
|
+
const owner = backupLease.begin(!supportsSnapshot);
|
|
286
|
+
try {
|
|
287
|
+
const prepared = await backupLease.prepare(owner, () => quiesce(context));
|
|
288
|
+
if (prepared?.snapshot && !supportsSnapshot) {
|
|
289
|
+
throw new Error("this backup requires an agent that supports bounded snapshots");
|
|
290
|
+
}
|
|
291
|
+
backupLease.renew(owner);
|
|
292
|
+
return {
|
|
293
|
+
supported: true,
|
|
294
|
+
quiesced: true,
|
|
295
|
+
settleSeconds: backup.settleSeconds ?? 0,
|
|
296
|
+
snapshot: prepared?.snapshot,
|
|
297
|
+
leaseId: supportsSnapshot ? owner : undefined,
|
|
298
|
+
};
|
|
299
|
+
} catch (error) {
|
|
300
|
+
await backupLease.finish(owner);
|
|
301
|
+
throw error;
|
|
271
302
|
}
|
|
272
303
|
},
|
|
304
|
+
async onRelease(leaseId) {
|
|
305
|
+
await backupLease.finish(leaseId);
|
|
306
|
+
},
|
|
273
307
|
async onDescribe() {
|
|
274
308
|
const describe = driver.install.describe;
|
|
275
309
|
|
package/src/wire/panel.ts
CHANGED
|
@@ -337,6 +337,7 @@ export const bridgeModuleManifestSchema = z.object({
|
|
|
337
337
|
pageSize: z.int().positive().nullable().default(null),
|
|
338
338
|
dependsOn: z.array(zKey).max(8),
|
|
339
339
|
actions: z.array(z.string().min(1).max(64)).max(16),
|
|
340
|
+
protectedActions: z.array(z.string().min(1).max(64)).max(16).optional(),
|
|
340
341
|
});
|
|
341
342
|
|
|
342
343
|
export const bridgeManifestSchema: z.ZodType<BridgeManifest> = z.object({
|