@serverkgg/bridge 0.0.1 → 0.0.3
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/link.test.ts +199 -0
- package/src/cli/link.ts +319 -0
- package/src/cli/main.ts +19 -1
- package/src/cli/schema.ts +62 -0
- package/src/cli/validate.test.ts +89 -0
- package/src/cli/validate.ts +218 -35
- package/src/context/bridgeContext.ts +18 -2
- package/src/core/bridgeAction.ts +13 -0
- package/src/core/bridgeFailure.ts +16 -0
- package/src/core/bridgeKind.ts +3 -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 +25 -4
- package/src/modules/bridgeDriver.ts +4 -0
- package/src/modules/bridgeModule.ts +36 -24
- package/src/panel/bridgeAccess.ts +3 -0
- package/src/panel/bridgeLayout.ts +1 -0
- package/src/panel/bridgePanel.ts +40 -2
- package/src/panel/index.ts +1 -0
- package/src/protocol/bridgeCatalog.ts +57 -0
- package/src/protocol/bridgeEgress.ts +53 -0
- package/src/protocol/bridgeEvents.ts +20 -0
- package/src/protocol/bridgeFrame.ts +16 -0
- package/src/protocol/bridgeLimits.ts +41 -0
- 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 +5 -0
- package/src/runtime/bridgeActions.ts +53 -0
- package/src/runtime/bridgeContext.ts +29 -9
- package/src/runtime/bridgeDriver.test.ts +450 -0
- package/src/runtime/bridgeDriver.ts +246 -6
- package/src/runtime/bridgeEgress.ts +360 -0
- package/src/runtime/bridgeEvents.ts +4 -1
- package/src/runtime/bridgeExec.ts +15 -5
- package/src/runtime/bridgeFiles.test.ts +334 -0
- package/src/runtime/bridgeFiles.ts +130 -31
- package/src/runtime/bridgeLog.ts +26 -3
- package/src/runtime/bridgeNet.ts +75 -22
- package/src/runtime/bridgeRegistry.test.ts +291 -0
- package/src/runtime/bridgeRegistry.ts +91 -52
- package/src/runtime/bridgeServer.ts +35 -3
- package/src/runtime/bridgeSupervisor.ts +6 -6
- package/src/runtime/bridgeZip.ts +14 -3
- 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
|
@@ -1,8 +1,41 @@
|
|
|
1
|
-
import { BRIDGE_ID_PATTERN, BRIDGE_MODULE_ID_PATTERN, BridgeKind } from "../core";
|
|
1
|
+
import { BRIDGE_ID_PATTERN, BRIDGE_MODULE_ID_PATTERN, BridgeKind, type LocalizedText } from "../core";
|
|
2
2
|
import type { BridgeDriver, BridgePanelModule } from "../modules";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type BridgeField,
|
|
5
|
+
BridgeFormTarget,
|
|
6
|
+
BridgeLayout,
|
|
7
|
+
type BridgeSection,
|
|
8
|
+
type BridgeSectionAction,
|
|
9
|
+
type BridgeTab,
|
|
10
|
+
type BridgeVisibleWhen,
|
|
11
|
+
} from "../panel";
|
|
12
|
+
import {
|
|
13
|
+
BRIDGE_ACTION_FIELDS_LIMIT,
|
|
14
|
+
BRIDGE_DEPENDS_ON_LIMIT,
|
|
15
|
+
BRIDGE_EVENTS_LIMIT,
|
|
16
|
+
BRIDGE_EXTENSION_LENGTH_LIMIT,
|
|
17
|
+
BRIDGE_FIELD_OPTIONS_LIMIT,
|
|
18
|
+
BRIDGE_FORM_FIELDS_LIMIT,
|
|
19
|
+
BRIDGE_ID_LENGTH_LIMIT,
|
|
20
|
+
BRIDGE_KEY_LENGTH_LIMIT,
|
|
21
|
+
BRIDGE_MODULE_ACTIONS_LIMIT,
|
|
22
|
+
BRIDGE_MODULES_LIMIT,
|
|
23
|
+
BRIDGE_OPTION_VALUE_LENGTH_LIMIT,
|
|
24
|
+
BRIDGE_PANEL_TABS_LIMIT,
|
|
25
|
+
BRIDGE_PLACEHOLDER_LENGTH_LIMIT,
|
|
26
|
+
BRIDGE_ROW_ACTIONS_LIMIT,
|
|
27
|
+
BRIDGE_SECTION_ACTIONS_LIMIT,
|
|
28
|
+
BRIDGE_STAGING_LENGTH_LIMIT,
|
|
29
|
+
BRIDGE_TAB_SECTIONS_LIMIT,
|
|
30
|
+
BRIDGE_TABLE_COLUMNS_LIMIT,
|
|
31
|
+
BRIDGE_TEXT_LENGTH_LIMIT,
|
|
32
|
+
BRIDGE_UPLOAD_EXTENSIONS_LIMIT,
|
|
33
|
+
BRIDGE_VISIBLE_VALUES_LIMIT,
|
|
34
|
+
} from "../protocol";
|
|
35
|
+
import { actionsOf } from "./bridgeActions";
|
|
4
36
|
|
|
5
37
|
const RESERVED_MODULE_IDS = new Set([
|
|
38
|
+
"announce",
|
|
6
39
|
"backup",
|
|
7
40
|
"events",
|
|
8
41
|
"install",
|
|
@@ -22,16 +55,45 @@ const fail = (message: string): never => {
|
|
|
22
55
|
throw new BridgeDriverError(message);
|
|
23
56
|
};
|
|
24
57
|
|
|
58
|
+
const countIn = (count: number, limit: number, what: string, where: string) => {
|
|
59
|
+
if (count > limit) {
|
|
60
|
+
fail(`${where} declares ${count} ${what} — the platform takes at most ${limit}`);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const lengthIn = (value: string, limit: number, what: string, where: string) => {
|
|
65
|
+
if (value.length > limit) {
|
|
66
|
+
fail(`${where} ${what} is ${value.length} characters — the platform takes at most ${limit}`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
25
70
|
const validateId = (id: string, where: string) => {
|
|
26
71
|
if (!BRIDGE_ID_PATTERN.test(id)) {
|
|
27
72
|
fail(`${where} id "${id}" must be kebab-case — lowercase letters, digits, and dashes, starting with a letter`);
|
|
28
73
|
}
|
|
74
|
+
|
|
75
|
+
lengthIn(id, BRIDGE_ID_LENGTH_LIMIT, `id "${id}"`, where);
|
|
29
76
|
};
|
|
30
77
|
|
|
31
78
|
const validateModuleId = (id: string, where: string) => {
|
|
32
79
|
if (!BRIDGE_MODULE_ID_PATTERN.test(id)) {
|
|
33
80
|
fail(`${where} id "${id}" must be camelCase — letters and digits, starting with a lowercase letter`);
|
|
34
81
|
}
|
|
82
|
+
|
|
83
|
+
lengthIn(id, BRIDGE_ID_LENGTH_LIMIT, `id "${id}"`, where);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const validateText = (text: LocalizedText | undefined, what: string, where: string) => {
|
|
87
|
+
if (!text) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (text.ar.length === 0 || text.en.length === 0) {
|
|
92
|
+
fail(`${where} ${what} needs both arabic and english text`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
lengthIn(text.ar, BRIDGE_TEXT_LENGTH_LIMIT, `${what} arabic text`, where);
|
|
96
|
+
lengthIn(text.en, BRIDGE_TEXT_LENGTH_LIMIT, `${what} english text`, where);
|
|
35
97
|
};
|
|
36
98
|
|
|
37
99
|
const moduleOf = (modules: Record<string, BridgePanelModule>, id: string, where: string) => {
|
|
@@ -44,6 +106,18 @@ const moduleOf = (modules: Record<string, BridgePanelModule>, id: string, where:
|
|
|
44
106
|
return module as BridgePanelModule;
|
|
45
107
|
};
|
|
46
108
|
|
|
109
|
+
const validateVisibleWhen = (visibleWhen: BridgeVisibleWhen | undefined, where: string) => {
|
|
110
|
+
if (!visibleWhen) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (visibleWhen.empty === undefined && (visibleWhen.values ?? []).length === 0) {
|
|
115
|
+
fail(`${where} is visible when "${visibleWhen.variable}" matches nothing — declare values, empty, or both`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
countIn((visibleWhen.values ?? []).length, BRIDGE_VISIBLE_VALUES_LIMIT, "visible values", where);
|
|
119
|
+
};
|
|
120
|
+
|
|
47
121
|
const validateFields = (modules: Record<string, BridgePanelModule>, fields: BridgeField[], where: string) => {
|
|
48
122
|
const keys = new Set<string>();
|
|
49
123
|
|
|
@@ -54,18 +128,98 @@ const validateFields = (modules: Record<string, BridgePanelModule>, fields: Brid
|
|
|
54
128
|
|
|
55
129
|
keys.add(field.key);
|
|
56
130
|
|
|
131
|
+
const at = `${where} field "${field.key}"`;
|
|
132
|
+
|
|
133
|
+
lengthIn(field.key, BRIDGE_KEY_LENGTH_LIMIT, `key "${field.key}"`, where);
|
|
134
|
+
validateText(field.label, "label", at);
|
|
135
|
+
validateText(field.help, "help", at);
|
|
136
|
+
validateText(field.warning, "warning", at);
|
|
137
|
+
validateVisibleWhen(field.visibleWhen, at);
|
|
138
|
+
|
|
139
|
+
if (field.placeholder) {
|
|
140
|
+
lengthIn(field.placeholder, BRIDGE_PLACEHOLDER_LENGTH_LIMIT, "placeholder", at);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (Array.isArray(field.options)) {
|
|
144
|
+
countIn(field.options.length, BRIDGE_FIELD_OPTIONS_LIMIT, "options", at);
|
|
145
|
+
|
|
146
|
+
for (const option of field.options) {
|
|
147
|
+
lengthIn(option.value, BRIDGE_OPTION_VALUE_LENGTH_LIMIT, `option "${option.value}"`, at);
|
|
148
|
+
validateText(option.label, `option "${option.value}" label`, at);
|
|
149
|
+
validateText(option.help, `option "${option.value}" help`, at);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
57
153
|
if (field.options && !Array.isArray(field.options)) {
|
|
58
|
-
const options = moduleOf(modules, field.options.module,
|
|
154
|
+
const options = moduleOf(modules, field.options.module, at);
|
|
59
155
|
|
|
60
156
|
if (options.kind !== BridgeKind.Options) {
|
|
61
|
-
fail(`${
|
|
157
|
+
fail(`${at} references "${field.options.module}", which is not an options module`);
|
|
62
158
|
}
|
|
63
159
|
}
|
|
64
160
|
}
|
|
65
161
|
};
|
|
66
162
|
|
|
163
|
+
const validateActions = (modules: Record<string, BridgePanelModule>, actions: BridgeSectionAction[], where: string) => {
|
|
164
|
+
const ids = new Set<string>();
|
|
165
|
+
|
|
166
|
+
for (const action of actions) {
|
|
167
|
+
if (ids.has(action.id)) {
|
|
168
|
+
fail(`${where} declares action "${action.id}" more than once`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
ids.add(action.id);
|
|
172
|
+
|
|
173
|
+
const at = `${where} action "${action.id}"`;
|
|
174
|
+
const fields = action.fields ?? [];
|
|
175
|
+
|
|
176
|
+
lengthIn(action.id, BRIDGE_KEY_LENGTH_LIMIT, `id "${action.id}"`, where);
|
|
177
|
+
validateText(action.label, "label", at);
|
|
178
|
+
validateText(action.confirmText, "confirm text", at);
|
|
179
|
+
|
|
180
|
+
if (fields.length > BRIDGE_ACTION_FIELDS_LIMIT) {
|
|
181
|
+
fail(`${at} declares ${fields.length} fields — at most ${BRIDGE_ACTION_FIELDS_LIMIT} fit one prompt`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
validateFields(modules, fields, at);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
67
188
|
const validateSection = (modules: Record<string, BridgePanelModule>, section: BridgeSection, where: string) => {
|
|
189
|
+
validateVisibleWhen(section.visibleWhen, where);
|
|
190
|
+
validateText(section.title, "title", where);
|
|
191
|
+
|
|
192
|
+
if (section.layout !== BridgeLayout.Form) {
|
|
193
|
+
validateText(section.layout === BridgeLayout.Actions ? section.help : section.empty, "text", where);
|
|
194
|
+
}
|
|
195
|
+
|
|
68
196
|
switch (section.layout) {
|
|
197
|
+
case BridgeLayout.Actions: {
|
|
198
|
+
const module = moduleOf(modules, section.module, where);
|
|
199
|
+
|
|
200
|
+
if (module.kind !== BridgeKind.Actions) {
|
|
201
|
+
fail(`${where} references "${section.module}", which is not an actions module`);
|
|
202
|
+
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (section.actions.length === 0) {
|
|
207
|
+
fail(`${where} declares no actions`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
countIn(section.actions.length, BRIDGE_SECTION_ACTIONS_LIMIT, "actions", where);
|
|
211
|
+
|
|
212
|
+
validateActions(modules, section.actions, where);
|
|
213
|
+
|
|
214
|
+
for (const action of section.actions) {
|
|
215
|
+
if (!module.actions[action.id]) {
|
|
216
|
+
fail(`${where} declares action "${action.id}", which module "${section.module}" does not implement`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
69
223
|
case BridgeLayout.Form: {
|
|
70
224
|
if (section.target === BridgeFormTarget.Settings && !section.module) {
|
|
71
225
|
fail(`${where} targets settings but names no module`);
|
|
@@ -83,6 +237,13 @@ const validateSection = (modules: Record<string, BridgePanelModule>, section: Br
|
|
|
83
237
|
}
|
|
84
238
|
}
|
|
85
239
|
|
|
240
|
+
validateText(section.confirmText, "confirm text", where);
|
|
241
|
+
|
|
242
|
+
if (section.fields.length === 0) {
|
|
243
|
+
fail(`${where} declares no fields`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
countIn(section.fields.length, BRIDGE_FORM_FIELDS_LIMIT, "fields", where);
|
|
86
247
|
validateFields(modules, section.fields, where);
|
|
87
248
|
|
|
88
249
|
return;
|
|
@@ -98,11 +259,58 @@ const validateSection = (modules: Record<string, BridgePanelModule>, section: Br
|
|
|
98
259
|
return;
|
|
99
260
|
}
|
|
100
261
|
|
|
101
|
-
if (section.layout === BridgeLayout.Table
|
|
102
|
-
|
|
262
|
+
if (section.layout === BridgeLayout.Table) {
|
|
263
|
+
if (section.add && !module.add) {
|
|
264
|
+
fail(`${where} declares an add control but module "${section.module}" has no add handler`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (section.upload && !module.add) {
|
|
268
|
+
fail(`${where} declares an upload control but module "${section.module}" has no add handler`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (section.columns.length === 0) {
|
|
272
|
+
fail(`${where} declares no columns`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
countIn(section.columns.length, BRIDGE_TABLE_COLUMNS_LIMIT, "columns", where);
|
|
276
|
+
|
|
277
|
+
for (const column of section.columns) {
|
|
278
|
+
lengthIn(column.key, BRIDGE_KEY_LENGTH_LIMIT, `column key "${column.key}"`, where);
|
|
279
|
+
validateText(column.label, `column "${column.key}" label`, where);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (section.add) {
|
|
283
|
+
validateText(section.add.label, "add label", where);
|
|
284
|
+
|
|
285
|
+
if (section.add.placeholder) {
|
|
286
|
+
lengthIn(section.add.placeholder, BRIDGE_PLACEHOLDER_LENGTH_LIMIT, "add placeholder", where);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (section.upload) {
|
|
291
|
+
validateText(section.upload.label, "upload label", where);
|
|
292
|
+
lengthIn(section.upload.staging, BRIDGE_STAGING_LENGTH_LIMIT, "upload staging path", where);
|
|
293
|
+
|
|
294
|
+
if (section.upload.extensions.length === 0) {
|
|
295
|
+
fail(`${where} declares an upload control with no extensions`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
countIn(section.upload.extensions.length, BRIDGE_UPLOAD_EXTENSIONS_LIMIT, "upload extensions", where);
|
|
299
|
+
|
|
300
|
+
for (const extension of section.upload.extensions) {
|
|
301
|
+
lengthIn(extension, BRIDGE_EXTENSION_LENGTH_LIMIT, `upload extension "${extension}"`, where);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
103
304
|
}
|
|
104
305
|
|
|
306
|
+
countIn((section.actions ?? []).length, BRIDGE_ROW_ACTIONS_LIMIT, "actions", where);
|
|
307
|
+
validateActions(modules, section.actions ?? [], where);
|
|
308
|
+
|
|
105
309
|
for (const action of section.actions ?? []) {
|
|
310
|
+
if (action.download) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
106
314
|
if (!module.actions?.[action.id]) {
|
|
107
315
|
fail(`${where} declares action "${action.id}", which module "${section.module}" does not implement`);
|
|
108
316
|
}
|
|
@@ -118,6 +326,8 @@ const validateSection = (modules: Record<string, BridgePanelModule>, section: Br
|
|
|
118
326
|
fail(`${where} references "${section.module}", which is not a catalog module`);
|
|
119
327
|
}
|
|
120
328
|
|
|
329
|
+
validateText(section.confirmText, "confirm text", where);
|
|
330
|
+
|
|
121
331
|
return;
|
|
122
332
|
}
|
|
123
333
|
}
|
|
@@ -127,11 +337,14 @@ const validateTab = (modules: Record<string, BridgePanelModule>, tab: BridgeTab)
|
|
|
127
337
|
const sections = new Set<string>();
|
|
128
338
|
|
|
129
339
|
validateId(tab.id, "tab");
|
|
340
|
+
validateText(tab.title, "title", `tab "${tab.id}"`);
|
|
130
341
|
|
|
131
342
|
if (tab.sections.length === 0) {
|
|
132
343
|
fail(`tab "${tab.id}" has no sections`);
|
|
133
344
|
}
|
|
134
345
|
|
|
346
|
+
countIn(tab.sections.length, BRIDGE_TAB_SECTIONS_LIMIT, "sections", `tab "${tab.id}"`);
|
|
347
|
+
|
|
135
348
|
for (const section of tab.sections) {
|
|
136
349
|
if (sections.has(section.id)) {
|
|
137
350
|
fail(`tab "${tab.id}" declares section "${section.id}" more than once`);
|
|
@@ -147,6 +360,15 @@ const validateTab = (modules: Record<string, BridgePanelModule>, tab: BridgeTab)
|
|
|
147
360
|
export const validateDriver = (driver: BridgeDriver) => {
|
|
148
361
|
const modules = driver.modules ?? {};
|
|
149
362
|
|
|
363
|
+
countIn(Object.keys(modules).length, BRIDGE_MODULES_LIMIT, "panel modules", "the driver");
|
|
364
|
+
|
|
365
|
+
const emitted = new Set([
|
|
366
|
+
...(driver.events?.patterns ?? []).map((pattern) => pattern.emit),
|
|
367
|
+
...(driver.events?.emits ?? []),
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
countIn(emitted.size, BRIDGE_EVENTS_LIMIT, "event names", "the driver");
|
|
371
|
+
|
|
150
372
|
for (const [id, module] of Object.entries(modules)) {
|
|
151
373
|
validateModuleId(id, "module");
|
|
152
374
|
|
|
@@ -154,6 +376,18 @@ export const validateDriver = (driver: BridgeDriver) => {
|
|
|
154
376
|
fail(`module id "${id}" is reserved`);
|
|
155
377
|
}
|
|
156
378
|
|
|
379
|
+
countIn(actionsOf(module).length, BRIDGE_MODULE_ACTIONS_LIMIT, "actions", `module "${id}"`);
|
|
380
|
+
|
|
381
|
+
if (module.kind === BridgeKind.Options) {
|
|
382
|
+
countIn((module.dependsOn ?? []).length, BRIDGE_DEPENDS_ON_LIMIT, "dependencies", `module "${id}"`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (module.kind === BridgeKind.Actions) {
|
|
386
|
+
for (const action of Object.keys(module.actions)) {
|
|
387
|
+
validateModuleId(action, `module "${id}" action`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
157
391
|
if (module.kind === BridgeKind.Collection) {
|
|
158
392
|
for (const action of Object.keys(module.actions ?? {})) {
|
|
159
393
|
validateModuleId(action, `module "${id}" action`);
|
|
@@ -165,6 +399,12 @@ export const validateDriver = (driver: BridgeDriver) => {
|
|
|
165
399
|
return;
|
|
166
400
|
}
|
|
167
401
|
|
|
402
|
+
if (driver.panel.tabs.length === 0) {
|
|
403
|
+
fail("the panel declares no tabs");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
countIn(driver.panel.tabs.length, BRIDGE_PANEL_TABS_LIMIT, "tabs", "the panel");
|
|
407
|
+
|
|
168
408
|
const tabs = new Set<string>();
|
|
169
409
|
|
|
170
410
|
for (const tab of driver.panel.tabs) {
|
|
@@ -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
|
+
};
|