dsh-builtin-browser 0.4.1 → 0.4.5
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/CHANGELOG.md +1699 -0
- package/README.en.md +114 -33
- package/README.md +108 -32
- package/client.js +478 -29
- package/cordis.patch.yml +21 -0
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +426 -167
- package/docs/architecture.md +14 -4
- package/docs/tool-reference.md +13 -13
- package/docs/user-guide.md +58 -3
- package/docs/why-browser.md +1 -1
- package/lib/browser/types.d.ts +34 -3
- package/lib/browser-electron/bridge-connection.d.ts +49 -26
- package/lib/browser-electron/bridge-connection.js +185 -22
- package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
- package/lib/browser-electron/desktop-bridge-host.js +151 -86
- package/lib/browser-electron/entry.d.ts +1 -1
- package/lib/browser-electron/entry.js +133 -40
- package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
- package/lib/browser-electron/ephemeral-profile.js +146 -0
- package/lib/browser-electron/history-store.d.ts +36 -2
- package/lib/browser-electron/history-store.js +76 -7
- package/lib/browser-electron/host-main.js +86 -28
- package/lib/browser-electron/provider.d.ts +106 -3
- package/lib/browser-electron/provider.js +407 -60
- package/lib/browser-electron/remote-host.d.ts +52 -3
- package/lib/browser-electron/remote-host.js +259 -25
- package/lib/browser-electron/rpc-ops.d.ts +18 -0
- package/lib/browser-electron/rpc-ops.js +36 -0
- package/lib/browser-electron/settings-route.d.ts +28 -0
- package/lib/browser-electron/settings-route.js +68 -0
- package/lib/browser-electron/settings-store.d.ts +40 -0
- package/lib/browser-electron/settings-store.js +160 -23
- package/lib/browser-electron/system-browser.d.ts +102 -12
- package/lib/browser-electron/system-browser.js +411 -51
- package/lib/index.js +0 -1
- package/lib/tool-browser/element-fields.d.ts +145 -0
- package/lib/tool-browser/element-fields.js +152 -0
- package/lib/tool-browser/index.js +149 -74
- package/package.json +10 -4
- package/scripts/verify-host-compat.mjs +195 -0
- package/src/browser/types.ts +34 -3
- package/src/browser-electron/bridge-connection.ts +179 -22
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +132 -39
- package/src/browser-electron/ephemeral-profile.ts +152 -0
- package/src/browser-electron/history-store.ts +81 -9
- package/src/browser-electron/host-main.ts +115 -36
- package/src/browser-electron/provider.ts +492 -69
- package/src/browser-electron/remote-host.ts +276 -38
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +87 -0
- package/src/browser-electron/settings-store.ts +173 -24
- package/src/browser-electron/system-browser.ts +408 -39
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- package/src/tool-browser/index.ts +155 -76
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Host-compatibility probe: does THIS build of the plugin run on the DSH in front of it?
|
|
4
|
+
*
|
|
5
|
+
* A version range in package.json is a claim, not evidence. This assembles a host out of the
|
|
6
|
+
* real DSH packages installed in a profile, loads the plugin's compiled output, registers its
|
|
7
|
+
* tools through the host's own `defineTool`, and calls two of them over the plugin's provider
|
|
8
|
+
* seam. It is the check that answers "did DSH 0.2.x break us?" without booting a browser.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/verify-host-compat.mjs # probe the profile named by DSH_PROFILE
|
|
12
|
+
* DSH_PROFILE=desktop node scripts/verify-host-compat.mjs
|
|
13
|
+
* DSH_HOME=/path/to/.dsh node scripts/verify-host-compat.mjs
|
|
14
|
+
*
|
|
15
|
+
* Resolution: the profile's own package.json is the module root, so `@deepseek-ai/*` resolves
|
|
16
|
+
* exactly as it does at runtime (links included). With no usable profile the probe reports
|
|
17
|
+
* that it could not run and exits 0 — an unprobeable host is not a compatibility failure, and
|
|
18
|
+
* making CI red for it would be reporting the wrong thing.
|
|
19
|
+
*
|
|
20
|
+
* What it does NOT cover: the browser actually launching (no Electron here), and the client
|
|
21
|
+
* panel rendering in a real session.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
24
|
+
import { createRequire } from 'node:module'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
import { pathToFileURL } from 'node:url'
|
|
27
|
+
|
|
28
|
+
const REPO = new URL('..', import.meta.url)
|
|
29
|
+
const failures = []
|
|
30
|
+
|
|
31
|
+
const ok = (label, condition, detail = '') => {
|
|
32
|
+
console.log(` ${condition ? 'OK ' : 'FAIL'} ${label}${detail === '' ? '' : ` — ${detail}`}`)
|
|
33
|
+
if (!condition) failures.push(label)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The profile whose node_modules resolve the host packages, or undefined. */
|
|
37
|
+
function findProfileRoot() {
|
|
38
|
+
const home = process.env.DSH_HOME ?? join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.dsh')
|
|
39
|
+
const profiles = join(home, 'profiles')
|
|
40
|
+
if (!existsSync(profiles)) return undefined
|
|
41
|
+
const named = process.env.DSH_PROFILE
|
|
42
|
+
const candidates = named !== undefined && named !== ''
|
|
43
|
+
? [named]
|
|
44
|
+
: readdirSync(profiles).filter(entry => existsSync(join(profiles, entry, 'package.json')))
|
|
45
|
+
for (const candidate of candidates) {
|
|
46
|
+
const manifest = join(profiles, candidate, 'package.json')
|
|
47
|
+
if (existsSync(manifest)) return { name: candidate, manifest }
|
|
48
|
+
}
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const profile = findProfileRoot()
|
|
53
|
+
if (profile === undefined) {
|
|
54
|
+
console.log('No DSH profile found (looked under $DSH_HOME/profiles).')
|
|
55
|
+
console.log('Set DSH_HOME or DSH_PROFILE to point at one; nothing to verify.')
|
|
56
|
+
process.exit(0)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const require = createRequire(profile.manifest)
|
|
60
|
+
const load = async (spec) => import(pathToFileURL(require.resolve(spec)).href)
|
|
61
|
+
|
|
62
|
+
console.log(`Host: profile "${profile.name}" at ${profile.manifest}`)
|
|
63
|
+
let hostVersion = 'unknown'
|
|
64
|
+
try {
|
|
65
|
+
hostVersion = JSON.parse(readFileSync(require.resolve('@deepseek-ai/dsh-system-prompt/package.json'), 'utf8')).version
|
|
66
|
+
} catch { /* reported as unknown; the probe below is what matters */ }
|
|
67
|
+
console.log(` @deepseek-ai/dsh-system-prompt = ${hostVersion}`)
|
|
68
|
+
console.log('')
|
|
69
|
+
|
|
70
|
+
console.log('=== 1. the host API surface this plugin imports ===')
|
|
71
|
+
let cordis
|
|
72
|
+
let dshTools
|
|
73
|
+
let dshLlm
|
|
74
|
+
let schemastery
|
|
75
|
+
try {
|
|
76
|
+
cordis = await load('@deepseek-ai/cordis')
|
|
77
|
+
dshTools = await load('@deepseek-ai/dsh-tools')
|
|
78
|
+
dshLlm = await load('@deepseek-ai/dsh-llm')
|
|
79
|
+
schemastery = await load('@deepseek-ai/schemastery')
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.log(` FAIL a host package could not be resolved: ${String(error && error.message)}`)
|
|
82
|
+
process.exit(1)
|
|
83
|
+
}
|
|
84
|
+
const z = schemastery.default ?? schemastery
|
|
85
|
+
|
|
86
|
+
ok('@deepseek-ai/cordis exports Context', typeof cordis.Context === 'function')
|
|
87
|
+
ok('@deepseek-ai/cordis exports Service', typeof cordis.Service === 'function')
|
|
88
|
+
ok('@deepseek-ai/dsh-tools exports defineTool', typeof dshTools.defineTool === 'function')
|
|
89
|
+
ok('@deepseek-ai/dsh-llm exports HarnessError', typeof dshLlm.HarnessError === 'function')
|
|
90
|
+
ok('@deepseek-ai/schemastery default is the schema builder', typeof z?.string === 'function')
|
|
91
|
+
|
|
92
|
+
console.log('')
|
|
93
|
+
console.log('=== 2. the plugin loads against a real cordis context ===')
|
|
94
|
+
const root = new cordis.Context()
|
|
95
|
+
|
|
96
|
+
// Stub services. DSH's own tools/systemPrompt implementations are not what is under test and
|
|
97
|
+
// standing them up would mean booting a profile; only their SHAPE matters here, and it is
|
|
98
|
+
// taken from the real ones: `ctx.tools.register(definition)`, `ctx.systemPrompt.section(spec)`.
|
|
99
|
+
const registered = new Map()
|
|
100
|
+
class StubTools extends cordis.Service {
|
|
101
|
+
constructor(ctx) { super(ctx, 'tools') }
|
|
102
|
+
register(definition) { registered.set(definition.name, definition); return () => {} }
|
|
103
|
+
}
|
|
104
|
+
class StubSystemPrompt extends cordis.Service {
|
|
105
|
+
constructor(ctx) { super(ctx, 'systemPrompt'); this.sections = [] }
|
|
106
|
+
section(spec) { this.sections.push(spec); return () => {} }
|
|
107
|
+
}
|
|
108
|
+
new StubTools(root)
|
|
109
|
+
const prompts = new StubSystemPrompt(root)
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const runtimeModule = await import(new URL('lib/browser/runtime.js', REPO).href)
|
|
113
|
+
ok('the plugin ships a browser seam service', typeof runtimeModule.default === 'function')
|
|
114
|
+
new runtimeModule.default(root)
|
|
115
|
+
ok('the seam registered itself as ctx.browser', root.get('browser') !== undefined)
|
|
116
|
+
|
|
117
|
+
const layer = await import(new URL('lib/tool-browser/index.js', REPO).href)
|
|
118
|
+
ok('the tool layer exports an apply()', typeof layer.apply === 'function')
|
|
119
|
+
layer.apply(root, {})
|
|
120
|
+
ok('tools registered through the host\'s own defineTool', registered.size > 30, `${registered.size} tools`)
|
|
121
|
+
ok('the system prompt section was declared', prompts.sections.length === 1)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
ok('the plugin loads and registers', false, String(error && error.stack).split('\n').slice(0, 2).join(' | '))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log('')
|
|
127
|
+
console.log('=== 3. the definitions the host will consume ===')
|
|
128
|
+
// `defineTool` already accepted every spec the plugin handed it — it throws on a shape it
|
|
129
|
+
// cannot compile, and the tools came back — so what is left is the stored definition: a JSON
|
|
130
|
+
// Schema the host can show a model, plus the output contract.
|
|
131
|
+
const SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array', 'object', 'null'])
|
|
132
|
+
const schemaFailures = []
|
|
133
|
+
for (const [name, definition] of registered) {
|
|
134
|
+
if (typeof definition.execute !== 'function') schemaFailures.push(`${name}: no execute`)
|
|
135
|
+
if (definition.output?.schema === undefined) schemaFailures.push(`${name}: no output.schema`)
|
|
136
|
+
if (typeof definition.output?.render !== 'function') schemaFailures.push(`${name}: no output.render`)
|
|
137
|
+
if (typeof definition.description !== 'string' || definition.description === '') schemaFailures.push(`${name}: no description`)
|
|
138
|
+
const parameters = definition.parameters
|
|
139
|
+
if (typeof parameters !== 'object' || parameters === null) {
|
|
140
|
+
schemaFailures.push(`${name}: parameters is not a schema object`)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (parameters.type !== 'object') schemaFailures.push(`${name}: parameters.type is ${String(parameters.type)}`)
|
|
144
|
+
for (const [field, spec] of Object.entries(parameters.properties ?? {})) {
|
|
145
|
+
if (typeof spec?.type !== 'string' || !SCALAR_TYPES.has(spec.type)) schemaFailures.push(`${name}.${field}: type ${String(spec?.type)}`)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
ok('every tool definition carries a usable schema', schemaFailures.length === 0, schemaFailures.slice(0, 4).join(' | '))
|
|
149
|
+
|
|
150
|
+
console.log('')
|
|
151
|
+
console.log('=== 4. a tool runs, over the plugin\'s own provider seam ===')
|
|
152
|
+
const fakeSession = 'browser:00000000-0000-4000-8000-000000000001'
|
|
153
|
+
try {
|
|
154
|
+
root.get('browser').registerBrowserProvider({
|
|
155
|
+
id: 'verify-host-compat',
|
|
156
|
+
available: () => true,
|
|
157
|
+
open: async () => fakeSession,
|
|
158
|
+
listTabs: async () => [{ id: 'tab:1', title: 'verify', url: 'about:blank', active: true }],
|
|
159
|
+
a11y: async () => ({
|
|
160
|
+
url: 'about:blank',
|
|
161
|
+
count: 1,
|
|
162
|
+
truncated: false,
|
|
163
|
+
nodes: [{ ref: 1, role: 'button', name: 'Probe', states: ['enabled'], depth: 0, tag: 'button', selector: '#probe', x: 1, y: 2 }],
|
|
164
|
+
}),
|
|
165
|
+
})
|
|
166
|
+
} catch (error) {
|
|
167
|
+
ok('a provider can register on the seam', false, String(error && error.message))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const exec = { agent: { id: 'verify-host-compat' } }
|
|
171
|
+
try {
|
|
172
|
+
const value = await registered.get('browser_session').execute({}, exec)
|
|
173
|
+
ok('browser_session ran', value !== undefined, JSON.stringify(value).slice(0, 100))
|
|
174
|
+
} catch (error) {
|
|
175
|
+
ok('browser_session ran', false, String(error && error.message))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const tool = registered.get('browser_a11y')
|
|
180
|
+
const value = await tool.execute({}, exec)
|
|
181
|
+
const text = JSON.stringify(tool.output.render({}, value))
|
|
182
|
+
ok('browser_a11y ran and rendered', value?.nodes?.length === 1, '')
|
|
183
|
+
ok('its renderer emits the element selector', text.includes('#probe'), text.slice(0, 120))
|
|
184
|
+
} catch (error) {
|
|
185
|
+
ok('browser_a11y ran', false, String(error && error.message))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log('')
|
|
189
|
+
if (failures.length === 0) {
|
|
190
|
+
console.log(`VERDICT: this build runs on the host above (${hostVersion}).`)
|
|
191
|
+
} else {
|
|
192
|
+
console.log(`VERDICT: ${failures.length} failure(s) — this build does NOT match the host above:`)
|
|
193
|
+
for (const failure of failures) console.log(` - ${failure}`)
|
|
194
|
+
process.exitCode = 1
|
|
195
|
+
}
|
package/src/browser/types.ts
CHANGED
|
@@ -266,6 +266,14 @@ export interface BrowserWaitResult {
|
|
|
266
266
|
export interface BrowserScreenshotResult {
|
|
267
267
|
/** Base64 data URL of the capture (PNG or JPEG per the request). */
|
|
268
268
|
readonly dataUrl: string
|
|
269
|
+
/** Pixel size of the image in `dataUrl`, when the capture was measured and scaled.
|
|
270
|
+
*
|
|
271
|
+
* Present when the request carried `maxWidth`/`maxHeight` and the layout metrics were
|
|
272
|
+
* readable: the scaling happens inside CDP through a clip, and without this the only way to
|
|
273
|
+
* learn what came back was to decode the data URL. Undefined means "not measured", which is
|
|
274
|
+
* also what an unscaled capture reports. */
|
|
275
|
+
readonly width?: number
|
|
276
|
+
readonly height?: number
|
|
269
277
|
/** File path the capture was also saved to, when the request asked for it. */
|
|
270
278
|
readonly path?: string
|
|
271
279
|
}
|
|
@@ -388,14 +396,29 @@ export interface BrowserA11yNode {
|
|
|
388
396
|
readonly role: string
|
|
389
397
|
/** Accessible name (aria-label, label text, placeholder, own text, …). */
|
|
390
398
|
readonly name: string
|
|
391
|
-
/**
|
|
392
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Current value of input-like nodes; absent when the node has none.
|
|
401
|
+
*
|
|
402
|
+
* The provider produces `null` for "not applicable" and the tool layer drops it, so absence
|
|
403
|
+
* and null mean the same thing here — which is what the output schema declares (`string`,
|
|
404
|
+
* not nullable). Saying `string | null` described the provider's internal shape rather than
|
|
405
|
+
* the contract, and the two disagreed at every call.
|
|
406
|
+
*/
|
|
407
|
+
readonly value?: string
|
|
393
408
|
/** ARIA/DOM states: enabled/disabled/checked/unchecked/expanded/… */
|
|
394
409
|
readonly states: readonly string[]
|
|
395
410
|
/** DOM depth (within its document), for reconstructing the tree. */
|
|
396
411
|
readonly depth: number
|
|
397
412
|
/** Element tag name (lowercase). */
|
|
398
413
|
readonly tag: string
|
|
414
|
+
/**
|
|
415
|
+
* A CSS selector for this node, when one can be derived from its id or name.
|
|
416
|
+
*
|
|
417
|
+
* Empty for the many elements that have neither. Present because a role and a name are not
|
|
418
|
+
* always enough to target a node: `{by: 'text'}` matches an accessible name only loosely,
|
|
419
|
+
* and this is what lets a caller point at the exact element it was shown.
|
|
420
|
+
*/
|
|
421
|
+
readonly selector: string
|
|
399
422
|
/** Viewport-relative center, for coordinate fallbacks. */
|
|
400
423
|
readonly x: number
|
|
401
424
|
readonly y: number
|
|
@@ -499,7 +522,15 @@ export interface BrowserProvider {
|
|
|
499
522
|
/**
|
|
500
523
|
* Open a new session. The provider mints the session id and prepares its
|
|
501
524
|
* backing surface (a view, a headless page, and so on). Sessions are isolated from
|
|
502
|
-
* each other
|
|
525
|
+
* each other in what they OWN — tabs, the active tab, history, the view — so one
|
|
526
|
+
* session must not be visible to any other session's caller.
|
|
527
|
+
*
|
|
528
|
+
* That isolation stops at the browser's own state, and is not meant to go further:
|
|
529
|
+
* cookies, logins and site storage are deliberately shared by every session, because
|
|
530
|
+
* every session drives the same browser and "sign in once, use it everywhere" is the
|
|
531
|
+
* point of using a real one. Reads that hand that state to a caller (see
|
|
532
|
+
* `flushAuth`) are scoped to the page in question, which is least exposure rather
|
|
533
|
+
* than a claim that the jar is per-session.
|
|
503
534
|
* @param label - optional human-readable label (e.g. the calling DSH task
|
|
504
535
|
* id) the provider may surface on the visible surface (window title) so a
|
|
505
536
|
* human can tell which task's page is currently shown.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
1
2
|
/**
|
|
2
3
|
* Transport for the desktop shell's browser bridge: one long-lived connection to a
|
|
3
4
|
* loopback TCP service.
|
|
@@ -34,50 +35,149 @@ interface PendingCall {
|
|
|
34
35
|
readonly reject: (error: Error) => void
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Safety cap on one un-answered reply line. CDP answers from the sidebar (a screenshot's
|
|
40
|
+
* base64 is the big one) cross this socket, so the cap is generous; what it exists for is a
|
|
41
|
+
* peer that never sends a newline — `this.buffer += chunk` grew without bound, unlike the
|
|
42
|
+
* same buffer in remote-host.ts, which caps at 512 MiB.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_BUFFER_BYTES = 128 * 1024 * 1024
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Field this client adds to every request so the shell can echo it back and never relies on
|
|
48
|
+
* byte order to pair an answer with its request. Loosely named, and additive on purpose: a
|
|
49
|
+
* bridge that does not know it simply ignores it (the current shipped shell does), and the
|
|
50
|
+
* client then falls back to arrival order, which is what the protocol guaranteed before.
|
|
51
|
+
*/
|
|
52
|
+
const REQUEST_ID_FIELD = 'bridgeRequestId'
|
|
53
|
+
|
|
37
54
|
/**
|
|
38
55
|
* A reusable connection to the bridge.
|
|
39
56
|
*
|
|
40
|
-
* Requests
|
|
41
|
-
*
|
|
42
|
-
*
|
|
57
|
+
* Requests may be in flight CONCURRENTLY, so answers are correlated by id rather than by
|
|
58
|
+
* position: `call()` stamps a unique {@link REQUEST_ID_FIELD} on the request and `onData()`
|
|
59
|
+
* resolves the entry that carries the echoed id. A bridge that does not echo it is still
|
|
60
|
+
* supported — its answer is handed to the oldest outstanding request, which is the
|
|
61
|
+
* arrival-order rule the protocol states. Once such an answer has been seen, the connection
|
|
62
|
+
* stops keeping two requests in flight (a second one re-dials), so position can never be the
|
|
63
|
+
* only thing distinguishing two callers' answers. An answer that matches nothing is never
|
|
64
|
+
* handed to whichever request happens to be first: the socket is dropped instead.
|
|
65
|
+
*
|
|
66
|
+
* A broken socket is discarded so the next call dials again — a dead connection must never
|
|
67
|
+
* become a dead plugin.
|
|
43
68
|
*/
|
|
44
69
|
export class BridgeConnection {
|
|
45
70
|
private socket: Socket | undefined
|
|
46
71
|
private buffer = ''
|
|
47
|
-
|
|
72
|
+
/** Outstanding requests by id. */
|
|
73
|
+
private readonly pending = new Map<number, PendingCall>()
|
|
74
|
+
/** Ids of the requests sent on the current socket, oldest first. */
|
|
75
|
+
private readonly queue: number[] = []
|
|
76
|
+
private nextId = 1
|
|
77
|
+
/** True once any reply arrived without an id: this bridge does not echo request ids. */
|
|
78
|
+
private idLessBridge = false
|
|
79
|
+
/** True once this connection may only have one request in flight at a time. */
|
|
80
|
+
private serialisable = false
|
|
48
81
|
|
|
49
82
|
/**
|
|
50
83
|
* @param endpoint - the shell's published bridge endpoint.
|
|
84
|
+
* @param endpointPath - where that endpoint is republished, so a moved port can be followed.
|
|
51
85
|
*/
|
|
52
|
-
constructor(private
|
|
86
|
+
constructor(private endpoint: BridgeEndpoint, private readonly endpointPath?: string) {}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Re-read the published endpoint, and adopt it if the shell has moved.
|
|
90
|
+
*
|
|
91
|
+
* The bridge binds an ephemeral port and picks a NEW one whenever its server is recreated. The
|
|
92
|
+
* endpoint file is rewritten every fifteen seconds, but a host that adopted the old one keeps
|
|
93
|
+
* dialling a dead port forever: measured here as `connect ECONNREFUSED 127.0.0.1:52276` while
|
|
94
|
+
* the live bridge sat on 52411. Discarding the socket is not enough — the comment below says a
|
|
95
|
+
* dead connection must never become a dead plugin, and a stale PORT makes exactly that happen.
|
|
96
|
+
*
|
|
97
|
+
* Cheap and safe: one small file read per call, no socket work.
|
|
98
|
+
*
|
|
99
|
+
* @returns true when a different endpoint was adopted.
|
|
100
|
+
*/
|
|
101
|
+
private refreshEndpoint(): boolean {
|
|
102
|
+
if (this.endpointPath === undefined) return false
|
|
103
|
+
try {
|
|
104
|
+
const raw = JSON.parse(readFileSync(this.endpointPath, 'utf8')) as Partial<BridgeEndpoint>
|
|
105
|
+
if (typeof raw.port !== 'number' || typeof raw.token !== 'string') return false
|
|
106
|
+
if (raw.port === this.endpoint.port && raw.token === this.endpoint.token) return false
|
|
107
|
+
this.endpoint = {
|
|
108
|
+
port: raw.port,
|
|
109
|
+
token: raw.token,
|
|
110
|
+
pid: typeof raw.pid === 'number' ? raw.pid : this.endpoint.pid,
|
|
111
|
+
}
|
|
112
|
+
// The old socket belongs to the old port.
|
|
113
|
+
this.reset(new Error('dsh-builtin-browser: the bridge moved; reconnecting to the new port'))
|
|
114
|
+
return true
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
53
119
|
|
|
54
120
|
/**
|
|
55
121
|
* Send one request, reusing the socket if it is still healthy.
|
|
56
|
-
*
|
|
122
|
+
*
|
|
123
|
+
* Requests may overlap: the request is written as soon as it is queued, so two concurrent
|
|
124
|
+
* callers have two requests in flight. Each carries a unique {@link REQUEST_ID_FIELD}, and
|
|
125
|
+
* `onData()` resolves the entry that carries the echoed id — which is what makes
|
|
126
|
+
* correlation independent of arrival order. A bridge that does not echo the field (the
|
|
127
|
+
* shipped shell today) still works by arrival order; see {@link onData}.
|
|
128
|
+
* @param request - the request body (the token and the request id are added here).
|
|
57
129
|
* @param timeoutMs - how long to wait for the answer before dropping the socket.
|
|
58
130
|
* @returns the bridge's answer.
|
|
59
131
|
*/
|
|
60
132
|
async call(request: Record<string, unknown>, timeoutMs = 20_000): Promise<Record<string, unknown>> {
|
|
61
|
-
|
|
133
|
+
// One re-dial, and only for the case this exists for: a socket that has already carried
|
|
134
|
+
// an id-less answer cannot have a second request in flight, because two outstanding
|
|
135
|
+
// requests would be matched by position — the swap this correlation prevents. It is a
|
|
136
|
+
// straight-line re-dial rather than a retry loop: an earlier revision re-entered call()
|
|
137
|
+
// from inside its own promise executor, which left an orphaned pending entry behind and,
|
|
138
|
+
// when the condition could not clear, spun until the process ran out of heap.
|
|
139
|
+
// Follow the shell if its bridge moved to a new port (see refreshEndpoint).
|
|
140
|
+
this.refreshEndpoint()
|
|
141
|
+
let socket = this.ensureSocket()
|
|
142
|
+
if (this.serialisable && this.queue.length > 0) {
|
|
143
|
+
this.reset(new Error('dsh-builtin-browser: bridge connection reopened so replies stay attributable'))
|
|
144
|
+
socket = this.ensureSocket()
|
|
145
|
+
}
|
|
146
|
+
const id = this.nextId++
|
|
62
147
|
return await new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
148
|
+
this.pending.set(id, {
|
|
149
|
+
resolve: answer => { clearTimeout(timer); resolve(answer) },
|
|
150
|
+
reject: error => { clearTimeout(timer); reject(error) },
|
|
151
|
+
})
|
|
152
|
+
this.queue.push(id)
|
|
63
153
|
const timer = setTimeout(() => {
|
|
64
|
-
const index = this.queue.findIndex(entry => entry.resolve === resolve)
|
|
65
|
-
if (index !== -1) this.queue.splice(index, 1)
|
|
66
154
|
const error = new Error(`dsh-builtin-browser: bridge timed out after ${timeoutMs}ms`)
|
|
67
|
-
|
|
155
|
+
// An unanswered request desynchronises the stream for an id-less bridge, and leaves one
|
|
156
|
+
// outstanding for one that echoes ids — either way the next call should start on a
|
|
157
|
+
// fresh socket. Only when THIS request's socket is still the live one: tearing down the
|
|
158
|
+
// REPLACEMENT because an abandoned request timed out would break whatever is using it.
|
|
159
|
+
//
|
|
160
|
+
// This test used to read `this.pending.has(id) && this.socket === socket`, with
|
|
161
|
+
// `forget(id)` on the line above it — and forget() is what removes the entry that `has`
|
|
162
|
+
// was looking for, so the condition was false every single time and the rebuild never
|
|
163
|
+
// ran. A timed-out call therefore left the desynchronised socket in place, and every
|
|
164
|
+
// later call inherited exactly the ambiguity this reset exists to clear. Deciding
|
|
165
|
+
// before forgetting is the whole fix; the `else` still drops the stale entry.
|
|
166
|
+
if (this.socket === socket) this.reset(error)
|
|
167
|
+
else this.forget(id)
|
|
168
|
+
// reset() already rejected this entry through its wrapper when it ran, so this is
|
|
169
|
+
// either the only rejection or a no-op that guarantees the caller settles.
|
|
68
170
|
reject(error)
|
|
69
171
|
}, timeoutMs)
|
|
70
|
-
this.queue.push({
|
|
71
|
-
resolve: answer => { clearTimeout(timer); resolve(answer) },
|
|
72
|
-
reject: error => { clearTimeout(timer); reject(error) },
|
|
73
|
-
})
|
|
74
172
|
try {
|
|
75
|
-
|
|
173
|
+
// The id is added LAST so a request body can never shadow it, and the token is
|
|
174
|
+
// added here rather than by the caller.
|
|
175
|
+
socket.write(JSON.stringify({ token: this.endpoint.token, ...request, [REQUEST_ID_FIELD]: id }) + '\n')
|
|
76
176
|
} catch (error) {
|
|
77
177
|
clearTimeout(timer)
|
|
78
|
-
this.
|
|
178
|
+
this.forget(id)
|
|
79
179
|
const failure = error instanceof Error ? error : new Error(String(error))
|
|
80
|
-
this.reset(failure)
|
|
180
|
+
if (this.socket === socket) this.reset(failure)
|
|
81
181
|
reject(failure)
|
|
82
182
|
}
|
|
83
183
|
})
|
|
@@ -94,25 +194,60 @@ export class BridgeConnection {
|
|
|
94
194
|
const socket = connect({ host: '127.0.0.1', port: this.endpoint.port })
|
|
95
195
|
socket.setEncoding('utf8')
|
|
96
196
|
socket.on('data', chunk => this.onData(String(chunk)))
|
|
97
|
-
socket
|
|
98
|
-
|
|
197
|
+
// Both handlers pass the socket they belong to, so a late event from a socket that was
|
|
198
|
+
// already replaced cannot tear down its successor: 'close' still fires for a socket that
|
|
199
|
+
// was removed mid-recycle, and acting on it there would kill a perfectly good connection.
|
|
200
|
+
socket.on('error', error => this.socketFailed(socket, error))
|
|
201
|
+
socket.on('close', () => this.socketFailed(socket, new Error('dsh-builtin-browser: bridge connection closed')))
|
|
99
202
|
this.buffer = ''
|
|
100
203
|
this.socket = socket
|
|
101
204
|
socket.write(JSON.stringify({ token: this.endpoint.token }) + '\n')
|
|
102
205
|
return socket
|
|
103
206
|
}
|
|
104
207
|
|
|
105
|
-
/**
|
|
208
|
+
/** Tear the connection down for an event raised by `source`, unless it is already gone. */
|
|
209
|
+
private socketFailed(source: Socket, error: Error): void {
|
|
210
|
+
if (this.socket !== source) return
|
|
211
|
+
this.reset(error)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Resolve queued requests by id, or in order when the bridge does not echo one. */
|
|
106
215
|
private onData(chunk: string): void {
|
|
107
216
|
this.buffer += chunk
|
|
217
|
+
// A peer that never sends a newline must not grow this buffer without bound. Failing
|
|
218
|
+
// the connection is the honest outcome: the caller gets an error naming the cap, while
|
|
219
|
+
// the next call dials a fresh socket.
|
|
220
|
+
if (this.buffer.length > MAX_BUFFER_BYTES) {
|
|
221
|
+
this.buffer = ''
|
|
222
|
+
this.reset(new Error(`dsh-builtin-browser: bridge reply exceeded ${String(MAX_BUFFER_BYTES)} bytes`))
|
|
223
|
+
return
|
|
224
|
+
}
|
|
108
225
|
let newline = this.buffer.indexOf('\n')
|
|
109
226
|
while (newline !== -1) {
|
|
110
227
|
const line = this.buffer.slice(0, newline).trim()
|
|
111
228
|
this.buffer = this.buffer.slice(newline + 1)
|
|
112
229
|
newline = this.buffer.indexOf('\n')
|
|
113
230
|
if (line === '') continue
|
|
114
|
-
const
|
|
231
|
+
const echoed = echoOf(line)
|
|
232
|
+
const id = echoed ?? this.queue[0]
|
|
233
|
+
if (id === undefined) {
|
|
234
|
+
// Nothing is waiting for this answer: the peer is answering something this socket
|
|
235
|
+
// never asked (or answered twice). Drop the socket rather than trust the stream.
|
|
236
|
+
this.reset(new Error('dsh-builtin-browser: bridge answered with no request outstanding'))
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
const index = this.queue.indexOf(id)
|
|
240
|
+
if (index !== -1) this.queue.splice(index, 1)
|
|
241
|
+
const entry = this.pending.get(id)
|
|
115
242
|
if (entry === undefined) continue
|
|
243
|
+
this.pending.delete(id)
|
|
244
|
+
// Id-less bridges pair by order, so a reply for the WRONG request cannot be told apart
|
|
245
|
+
// from the right one. Remember that this bridge does not echo ids, and serialise every
|
|
246
|
+
// later call so the ambiguity cannot arise again.
|
|
247
|
+
if (echoed === undefined) {
|
|
248
|
+
this.idLessBridge = true
|
|
249
|
+
this.serialisable = true
|
|
250
|
+
}
|
|
116
251
|
try {
|
|
117
252
|
const answer = JSON.parse(line) as Record<string, unknown>
|
|
118
253
|
// A rejected token means the shell restarted with a new one, so this socket
|
|
@@ -130,6 +265,13 @@ export class BridgeConnection {
|
|
|
130
265
|
}
|
|
131
266
|
}
|
|
132
267
|
|
|
268
|
+
/** Forget one request (its answer can no longer be matched). */
|
|
269
|
+
private forget(id: number): void {
|
|
270
|
+
this.pending.delete(id)
|
|
271
|
+
const index = this.queue.indexOf(id)
|
|
272
|
+
if (index !== -1) this.queue.splice(index, 1)
|
|
273
|
+
}
|
|
274
|
+
|
|
133
275
|
/** Tear the socket down, failing pending work. */
|
|
134
276
|
private reset(error: Error): void {
|
|
135
277
|
if (this.socket !== undefined) {
|
|
@@ -139,7 +281,22 @@ export class BridgeConnection {
|
|
|
139
281
|
socket.destroy()
|
|
140
282
|
}
|
|
141
283
|
this.buffer = ''
|
|
142
|
-
|
|
284
|
+
this.queue.length = 0
|
|
285
|
+
const pending = [...this.pending.values()]
|
|
286
|
+
this.pending.clear()
|
|
143
287
|
for (const entry of pending) entry.reject(error)
|
|
144
288
|
}
|
|
145
289
|
}
|
|
290
|
+
|
|
291
|
+
/** The request id a raw answer line echoes back, when it carries one. */
|
|
292
|
+
function echoOf(line: string): number | undefined {
|
|
293
|
+
try {
|
|
294
|
+
const answer = JSON.parse(line) as Record<string, unknown>
|
|
295
|
+
const value = answer[REQUEST_ID_FIELD]
|
|
296
|
+
return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined
|
|
297
|
+
} catch {
|
|
298
|
+
// A malformed line cannot be matched by id; the caller consumes it in order and
|
|
299
|
+
// reports the parse failure itself.
|
|
300
|
+
return undefined
|
|
301
|
+
}
|
|
302
|
+
}
|