@serverkgg/bridge 0.0.16 → 0.0.17

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -27,6 +27,8 @@ export interface BridgeDownloadOptions {
27
27
  }
28
28
 
29
29
  export interface BridgeExtractOptions {
30
+ maxBytes?: number;
31
+ maxEntryBytes?: number;
30
32
  extensions?: string[];
31
33
  tree?: boolean;
32
34
  select?: string;
@@ -34,10 +36,14 @@ export interface BridgeExtractOptions {
34
36
  exclude?: string[];
35
37
  }
36
38
 
39
+ export interface BridgeListOptions {
40
+ directory?: string;
41
+ }
42
+
37
43
  export interface BridgeFiles {
38
44
  read(path: string): Promise<string>;
39
45
  write(path: string, content: string): Promise<void>;
40
- list(glob: string): Promise<BridgeFileEntry[]>;
46
+ list(glob: string, options?: BridgeListOptions): Promise<BridgeFileEntry[]>;
41
47
  remove(path: string): Promise<void>;
42
48
  move(from: string, to: string): Promise<void>;
43
49
  ensure(...paths: string[]): Promise<void>;
@@ -7,6 +7,7 @@ export enum BridgeAction {
7
7
  Quiesce = "quiesce",
8
8
  Read = "read",
9
9
  Release = "release",
10
+ Releases = "releases",
10
11
  Remove = "remove",
11
12
  Sample = "sample",
12
13
  Search = "search",
@@ -15,6 +16,7 @@ export enum BridgeAction {
15
16
  }
16
17
 
17
18
  export const BRIDGE_READ_ACTIONS: readonly string[] = [
19
+ BridgeAction.Releases,
18
20
  BridgeAction.Installed,
19
21
  BridgeAction.List,
20
22
  BridgeAction.Preview,
@@ -1,8 +1,13 @@
1
1
  import type { BridgeContext, BridgeReply } from "../context";
2
2
  import type { BridgeInstallOutcome, BridgeKind, BridgeOption, BridgeRow, BridgeStream, BridgeValues } from "../core";
3
3
  import type { BridgeDetail, BridgeDetailOutcome } from "../panel";
4
- import type { BridgeCatalogEntry, BridgeCatalogPage, BridgeCatalogQuery } from "../protocol/bridgeCatalog";
5
- import type { BridgeDescribeResult, BridgePendingFile } from "../protocol/bridgeFrame";
4
+ import type {
5
+ BridgeCatalogEntry,
6
+ BridgeCatalogPage,
7
+ BridgeCatalogQuery,
8
+ BridgeCatalogRelease,
9
+ } from "../protocol/bridgeCatalog";
10
+ import type { BridgeBackupSnapshot, BridgeDescribeResult, BridgePendingFile } from "../protocol/bridgeFrame";
6
11
  import type { BridgePreview } from "../protocol/bridgePreview";
7
12
  import type { BridgeSetupStep } from "../protocol/bridgeSetup";
8
13
  import type { BridgeTerminalCommand, BridgeTerminalRule } from "../terminal";
@@ -32,7 +37,12 @@ export interface BridgeAnnounceModule {
32
37
  export interface BridgeBackupModule {
33
38
  kind: BridgeKind.Backup;
34
39
  settleSeconds?: number;
35
- quiesce?(context: BridgeContext): Promise<void>;
40
+ quiesce?(context: BridgeContext): Promise<
41
+ | undefined
42
+ | {
43
+ snapshot?: BridgeBackupSnapshot;
44
+ }
45
+ >;
36
46
  release?(context: BridgeContext): Promise<void>;
37
47
  }
38
48
 
@@ -95,6 +105,7 @@ export interface BridgePreviewModule {
95
105
 
96
106
  export interface BridgeSettingsModule {
97
107
  kind: BridgeKind.Settings;
108
+ protectedActions?: string[];
98
109
  read(context: BridgeContext): Promise<BridgeValues>;
99
110
  write?(context: BridgeContext, values: BridgeValues): Promise<void>;
100
111
  }
@@ -109,12 +120,14 @@ export type BridgeServerAction = (context: BridgeContext, args: BridgeValues) =>
109
120
 
110
121
  export interface BridgeActionsModule {
111
122
  kind: BridgeKind.Actions;
123
+ protectedActions?: string[];
112
124
  requiresRunning?: boolean;
113
125
  actions: Record<string, BridgeServerAction>;
114
126
  }
115
127
 
116
128
  export interface BridgeCollectionModule<Row extends BridgeRow = BridgeRow> {
117
129
  kind: BridgeKind.Collection;
130
+ protectedActions?: string[];
118
131
  requiresRunning?: boolean;
119
132
  refreshSeconds?: number;
120
133
  list(context: BridgeContext): Promise<Row[]>;
@@ -126,6 +139,7 @@ export type BridgeDetailAction = (context: BridgeContext, args: BridgeValues) =>
126
139
 
127
140
  export interface BridgeDetailModule {
128
141
  kind: BridgeKind.Detail;
142
+ protectedActions?: string[];
129
143
  requiresRunning?: boolean;
130
144
  refreshSeconds?: number;
131
145
  read(context: BridgeContext): Promise<BridgeDetail | null>;
@@ -134,10 +148,13 @@ export interface BridgeDetailModule {
134
148
 
135
149
  export interface BridgeCatalogModule {
136
150
  kind: BridgeKind.Catalog;
151
+ protectedActions?: string[];
137
152
  pageSize?: number;
138
153
  search(context: BridgeContext, query: BridgeCatalogQuery): Promise<BridgeCatalogPage>;
139
154
  installed(context: BridgeContext): Promise<BridgeCatalogEntry[]>;
140
- install(context: BridgeContext, id: string): Promise<BridgeCatalogEntry>;
155
+ releases?(context: BridgeContext, id: string): Promise<BridgeCatalogRelease[]>;
156
+ preview?(context: BridgeContext, id: string, releaseId?: string): Promise<BridgePreview>;
157
+ install(context: BridgeContext, id: string, releaseId?: string): Promise<BridgeCatalogEntry>;
141
158
  remove(context: BridgeContext, id: string): Promise<void>;
142
159
  toggle?(context: BridgeContext, id: string, enabled: boolean): Promise<void>;
143
160
  }
@@ -1,5 +1,11 @@
1
1
  import type { LocalizedText } from "../core";
2
2
 
3
+ export interface BridgeCatalogRelease {
4
+ id: string;
5
+ label: string;
6
+ gameVersion?: string;
7
+ }
8
+
3
9
  export interface BridgeCatalogProvider {
4
10
  id: string;
5
11
  label: LocalizedText;
@@ -44,6 +44,9 @@ export interface BridgeRequest {
44
44
  args?: BridgeValues;
45
45
  cursor?: number;
46
46
  message?: string;
47
+ supportsSnapshot?: boolean;
48
+ leaseId?: string;
49
+ recoveryOperationId?: string;
47
50
  }
48
51
 
49
52
  export interface BridgeCommandResult {
@@ -93,10 +96,20 @@ export interface BridgeAnnounceResult {
93
96
  delivered: boolean;
94
97
  }
95
98
 
99
+ export interface BridgeBackupSnapshot {
100
+ roots: string[];
101
+ files: Array<{
102
+ path: string;
103
+ size: number;
104
+ }>;
105
+ }
106
+
96
107
  export interface BridgeQuiesceResult {
108
+ leaseId?: string;
97
109
  supported: boolean;
98
110
  quiesced: boolean;
99
111
  settleSeconds: number;
112
+ snapshot?: BridgeBackupSnapshot;
100
113
  }
101
114
 
102
115
  export interface BridgeDescribeResult {
@@ -23,6 +23,7 @@ export interface BridgeModuleManifest {
23
23
  pageSize: number | null;
24
24
  dependsOn: string[];
25
25
  actions: string[];
26
+ protectedActions?: string[];
26
27
  }
27
28
 
28
29
  export interface BridgeManifest {
@@ -2,6 +2,7 @@ import type { LocalizedText } from "../core";
2
2
  import type { BridgeConfirm } from "../panel";
3
3
 
4
4
  export interface BridgePreview {
5
+ variables?: Record<string, string>;
5
6
  confirm: BridgeConfirm;
6
7
  lines: LocalizedText[];
7
8
  }
@@ -51,6 +51,16 @@ export const actionsOf = (module: BridgePanelModule): string[] => {
51
51
 
52
52
  case BridgeKind.Catalog: {
53
53
  return [
54
+ ...(module.releases
55
+ ? [
56
+ BridgeAction.Releases,
57
+ ]
58
+ : []),
59
+ ...(module.preview
60
+ ? [
61
+ BridgeAction.Preview,
62
+ ]
63
+ : []),
54
64
  BridgeAction.Install,
55
65
  BridgeAction.Installed,
56
66
  BridgeAction.Remove,
@@ -0,0 +1,148 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createBackupLease } from "./bridgeBackupLease";
3
+
4
+ describe("backup save ownership", () => {
5
+ test("keeps a long backup frozen while renewed and releases when its owner disappears", async () => {
6
+ let now = 0;
7
+ let releases = 0;
8
+ const lease = createBackupLease(
9
+ async () => {
10
+ releases++;
11
+ },
12
+ () => now,
13
+ 120,
14
+ );
15
+ const id = lease.begin();
16
+ for (let index = 0; index < 10; index++) {
17
+ now += 100;
18
+ lease.renew(id);
19
+ await lease.expire();
20
+ }
21
+ expect(releases).toBe(0);
22
+ now += 121;
23
+ expect(() => lease.renew(id)).toThrow("expired");
24
+ await lease.expire();
25
+ expect(releases).toBe(1);
26
+ expect(lease.active).toBe(false);
27
+ });
28
+ test("refuses overlapping holds and retains ownership after a failed resume", async () => {
29
+ let fail = true;
30
+ const lease = createBackupLease(async () => {
31
+ if (fail) {
32
+ throw new Error("resume failed");
33
+ }
34
+ });
35
+ const owner = lease.begin();
36
+ expect(() => lease.begin()).toThrow("already owns");
37
+ expect(() => lease.renew("another-owner")).toThrow();
38
+ await expect(lease.finish(owner)).rejects.toThrow("resume failed");
39
+ expect(lease.active).toBe(true);
40
+ fail = false;
41
+ await lease.finish(owner);
42
+ expect(lease.active).toBe(false);
43
+ });
44
+ });
45
+
46
+ test("reserves ownership while the quiesce operation is still awaiting confirmation", async () => {
47
+ const prepared = Promise.withResolvers<void>();
48
+ const lease = createBackupLease(async () => {});
49
+ const owner = lease.begin();
50
+ const operation = lease.prepare(owner, () => prepared.promise);
51
+ expect(() => lease.begin()).toThrow("already owns");
52
+ expect(() => lease.renew(owner)).toThrow("not ready");
53
+ await expect(lease.finish("competitor")).rejects.toThrow("owner");
54
+ await expect(lease.finish()).rejects.toThrow("owner");
55
+ prepared.resolve();
56
+ await operation;
57
+ expect(lease.renew(owner)).toBe(owner);
58
+ await lease.finish(owner);
59
+ });
60
+
61
+ test("expiration waits for in-flight preparation before resuming saves", async () => {
62
+ let now = 0;
63
+ let releases = 0;
64
+ const prepared = Promise.withResolvers<void>();
65
+ const lease = createBackupLease(
66
+ async () => {
67
+ releases++;
68
+ },
69
+ () => now,
70
+ 10,
71
+ );
72
+ const owner = lease.begin();
73
+ const operation = lease.prepare(owner, () => prepared.promise);
74
+ now = 11;
75
+ const expired = lease.expire();
76
+ await Promise.resolve();
77
+ expect(releases).toBe(0);
78
+ expect(() => lease.begin()).toThrow();
79
+ prepared.resolve();
80
+ await operation;
81
+ await expired;
82
+ expect(releases).toBe(1);
83
+ expect(lease.active).toBe(false);
84
+ });
85
+
86
+ test("late releases and tokenless callers cannot release a newer owner", async () => {
87
+ let releases = 0;
88
+ const lease = createBackupLease(async () => {
89
+ releases++;
90
+ });
91
+ const first = lease.begin();
92
+ await lease.finish(first);
93
+ const second = lease.begin();
94
+ await expect(lease.finish(first)).rejects.toThrow("owner");
95
+ await expect(lease.finish()).rejects.toThrow("owner");
96
+ expect(releases).toBe(1);
97
+ expect(lease.active).toBe(true);
98
+ await lease.finish(second);
99
+ expect(releases).toBe(2);
100
+ });
101
+
102
+ test("legacy release is accepted only for a legacy hold", async () => {
103
+ let releases = 0;
104
+ const lease = createBackupLease(async () => {
105
+ releases++;
106
+ });
107
+ lease.begin(true);
108
+ await lease.finish();
109
+ expect(releases).toBe(1);
110
+ await lease.finish();
111
+ expect(releases).toBe(1);
112
+ });
113
+
114
+ test("failed quiesce cleanup keeps the owner until resume succeeds", async () => {
115
+ let releases = 0;
116
+ const lease = createBackupLease(async () => {
117
+ releases++;
118
+ });
119
+ const owner = lease.begin();
120
+ await expect(
121
+ lease.prepare(owner, async () => {
122
+ throw new Error("quiesce failed");
123
+ }),
124
+ ).rejects.toThrow();
125
+ await lease.finish(owner);
126
+ expect(releases).toBe(1);
127
+ expect(lease.active).toBe(false);
128
+ });
129
+
130
+ test("legacy owners retain the historical hold until explicit release without renewal", async () => {
131
+ let now = 0;
132
+ let releases = 0;
133
+ const lease = createBackupLease(
134
+ async () => {
135
+ releases++;
136
+ },
137
+ () => now,
138
+ 120,
139
+ );
140
+ lease.begin(true);
141
+ now = 120_000;
142
+ await lease.expire();
143
+ expect(lease.active).toBe(true);
144
+ expect(releases).toBe(0);
145
+ await lease.finish();
146
+ expect(releases).toBe(1);
147
+ expect(lease.active).toBe(false);
148
+ });
@@ -0,0 +1,74 @@
1
+ export const createBackupLease = (
2
+ release: () => Promise<void>,
3
+ now: () => number = () => performance.now(),
4
+ ttlMs = 120_000,
5
+ ) => {
6
+ let id: string | null = null;
7
+ let legacy = false;
8
+ let deadline = 0;
9
+ let preparing: Promise<unknown> | null = null;
10
+ let releasing: Promise<void> | null = null;
11
+ const finish = async (token?: string) => {
12
+ if (id === null) {
13
+ return;
14
+ }
15
+ if (token !== id && !(token === undefined && legacy)) {
16
+ throw new Error("only the backup save lease owner can release it");
17
+ }
18
+ if (releasing) {
19
+ return await releasing;
20
+ }
21
+ releasing = Promise.resolve()
22
+ .then(async () => {
23
+ await preparing?.catch(() => undefined);
24
+ await release();
25
+ id = null;
26
+ legacy = false;
27
+ })
28
+ .finally(() => {
29
+ releasing = null;
30
+ });
31
+ await releasing;
32
+ };
33
+ return {
34
+ get active() {
35
+ return id !== null;
36
+ },
37
+ begin(legacyOwner = false) {
38
+ if (id !== null) {
39
+ throw new Error("a backup already owns the save hold");
40
+ }
41
+ id = crypto.randomUUID();
42
+ legacy = legacyOwner;
43
+ deadline = now() + ttlMs;
44
+ return id;
45
+ },
46
+ async prepare<Result>(token: string, run: () => Promise<Result>): Promise<Result> {
47
+ if (id !== token || releasing || preparing) {
48
+ throw new Error("the backup save lease is not available for preparation");
49
+ }
50
+ const operation = Promise.resolve().then(run);
51
+ preparing = operation;
52
+ try {
53
+ return await operation;
54
+ } finally {
55
+ if (preparing === operation) {
56
+ preparing = null;
57
+ }
58
+ }
59
+ },
60
+ renew(token: string) {
61
+ if (id !== token || now() >= deadline || releasing || preparing) {
62
+ throw new Error("the backup save lease expired or is not ready");
63
+ }
64
+ deadline = now() + ttlMs;
65
+ return token;
66
+ },
67
+ finish,
68
+ async expire() {
69
+ if (id !== null && !legacy && now() >= deadline) {
70
+ await finish(id);
71
+ }
72
+ },
73
+ };
74
+ };