@herenfor/dsh-wsl 0.0.0-stage → 0.1.1

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/routing.mjs ADDED
@@ -0,0 +1,507 @@
1
+ // Per-session routing: each Session runs on this Host or on the WSL Host, and the page lists both.
2
+ //
3
+ // Unary calls arrive through client-connection's `connection/request` waterfall (see index.mjs); streams are opened by
4
+ // the Typert gateway's `openWireStream`, which this module wraps. A call or stream that names WSL identities (ids.mjs)
5
+ // goes to the WSL Host with the prefix removed, and what comes back gets it added. The Workspace list, Session list,
6
+ // Session control and forwarded-event streams are merged from both Hosts (sessions.mjs).
7
+ //
8
+ // Where a new Session runs is decided when the page asks for one (`session/create`): folders that live in WSL always
9
+ // run there; for other folders the run location chosen on the new-task page (`/wsl-bridge/run-target`) decides.
10
+ import { noteLanguage, pick } from './language.mjs'
11
+ import http from 'node:http'
12
+ import { randomUUID } from 'node:crypto'
13
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
14
+ import { dirname, join } from 'node:path'
15
+ import { homedir } from 'node:os'
16
+ import { isWsl, routeOf, toPage, toWsl, translate } from './ids.mjs'
17
+ import { applyWorkspaceFrame, baselineFrames, localizeSessions, mergeWorkspaces, pathMapping, placeSession, prefixEvent, prefixProjections } from './sessions.mjs'
18
+ import { remoteError, upgradeHeaders, WslMux } from './remote-mux.mjs'
19
+
20
+ /** Namespaces that belong to this Host whatever they name (handled elsewhere or never per-session). */
21
+ const LOCAL_ONLY = /^(?:settings|credentials|plugins|pluginManager|pluginInventory|pluginMarket|pluginRegistryProbe|pluginRecommendations|cordis|account|remoteAccess|productAnalytics|llm|directoryPicker)\//
22
+ const MERGED_LISTS = new Set(['session/list', 'session/search'])
23
+ const MERGED_STREAMS = new Set(['workspace/follow', 'session/control', '$events'])
24
+ const RUN_TARGETS_FILE = join(homedir(), '.dsh-wsl', 'run-targets.json')
25
+
26
+ export function installSessionRouting(ctx, { bridge, remoteOrigin, cookie, forwardApi, remoteFetch, readBody, available, whenAvailable, distro, setupState = () => 'connected' }) {
27
+ const logger = ctx.logger
28
+ const paths = () => pathMapping(process.platform, distro())
29
+
30
+ // State shared by every page: the latest Workspace lists seen on either Host, twin folders, event clients.
31
+ let localWorkspaces
32
+ let wslWorkspaces
33
+ let twins = new Map()
34
+ const eventClients = new Map() // this Host's $events client id → the WSL Host's
35
+ // Creator (cordis) in a WSL task asks the page through $events: inspection queries and run approvals carry ids the
36
+ // page answers with; plugin runs it starts are named by pluginRunId. Remembered so the answers go back to WSL.
37
+ const wslCordis = { requests: new Set(), runs: new Set(), manifest: undefined }
38
+ let runTargets = new Map()
39
+ try { runTargets = new Map(Object.entries(JSON.parse(readFileSync(RUN_TARGETS_FILE, 'utf8')))) } catch {}
40
+ const saveRunTargets = () => {
41
+ try { mkdirSync(dirname(RUN_TARGETS_FILE), { recursive: true }); writeFileSync(RUN_TARGETS_FILE, `${JSON.stringify(Object.fromEntries(runTargets), null, 2)}\n`) } catch {}
42
+ }
43
+ const remember = () => {
44
+ const merged = mergeWorkspaces(localWorkspaces, wslWorkspaces, paths())
45
+ twins = merged.twins
46
+ return merged.value
47
+ }
48
+
49
+ // ---- the WSL Host's stream mux, over the bridge
50
+ const mux = new WslMux(async () => {
51
+ const origin = remoteOrigin()
52
+ if (!origin) throw new Error('the WSL DSH Host is not available')
53
+ const url = new URL('/api/remote.mux', origin)
54
+ const { headers, accept } = upgradeHeaders()
55
+ const cookieHeader = await cookie()
56
+ return new Promise((resolve, reject) => {
57
+ const req = http.request({ agent: bridge.agent, host: url.hostname, port: url.port, path: url.pathname, headers: { ...headers, host: url.host, origin, cookie: cookieHeader } })
58
+ req.once('upgrade', (res, socket, head) => {
59
+ if (res.headers['sec-websocket-accept'] !== accept) { socket.destroy(); reject(new Error('the WSL Host answered the stream upgrade wrongly')); return }
60
+ resolve({ socket, head })
61
+ })
62
+ req.once('response', (res) => { res.resume(); reject(new Error(`the WSL Host refused the stream connection (${res.statusCode})`)) })
63
+ req.once('error', reject)
64
+ req.end()
65
+ })
66
+ })
67
+ ctx.effect(() => () => mux.close(), 'wsl-mirror: WSL stream mux')
68
+ let lastToken = bridge.state.dsh.token
69
+ bridge.on('state', (s) => { if (s.dsh.token !== lastToken) { lastToken = s.dsh.token; mux.close() } })
70
+
71
+ // ---- unary calls
72
+ const rpc = async (endpoint, args) => {
73
+ const r = await forwardApi(`/api/${endpoint}`, Buffer.from(JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: endpoint, payload: { args } })))
74
+ const json = await r.json()
75
+ if (!json?.result) throw new Error(`${endpoint} answered ${r.status}`)
76
+ return json.result
77
+ }
78
+ const reply = (res, rpcId, result) => {
79
+ res.writeHead(200, { 'content-type': 'application/json' })
80
+ res.end(JSON.stringify({ type: 'server-response', rpcId, result }))
81
+ }
82
+ const failure = (code, message) => ({ ok: false, error: { code, message, details: {} } })
83
+ /** Forward one call to the WSL Host, identities rewritten both ways; non-JSON answers are passed through. */
84
+ async function forward(res, pathname, message, payload) {
85
+ const upstream = await forwardApi(pathname, Buffer.from(JSON.stringify({ ...message, payload: translate(payload, false) })))
86
+ const type = upstream.headers.get('content-type') ?? ''
87
+ if (!type.startsWith('application/json')) {
88
+ res.writeHead(upstream.status, { 'content-type': type })
89
+ res.end(Buffer.from(await upstream.arrayBuffer()))
90
+ return
91
+ }
92
+ const json = await upstream.json()
93
+ if (json?.result?.ok) json.result.value = translate(json.result.value, true)
94
+ res.writeHead(200, { 'content-type': 'application/json' })
95
+ res.end(JSON.stringify(json))
96
+ }
97
+ const replay = (req, body) => { req[Symbol.asyncIterator] = async function* () { yield body }; req.headers['content-length'] = String(body.length) }
98
+ const ownHost = async (req, pathname, body) => {
99
+ const r = await fetch(`${ctx.webServer.getOrigin()}${pathname}`, {
100
+ method: 'POST', body,
101
+ headers: { cookie: req.headers.cookie ?? '', origin: ctx.webServer.getOrigin(), 'content-type': 'application/json', 'x-dsh-wsl-mirror-inner': '1' },
102
+ })
103
+ return r.json()
104
+ }
105
+
106
+ /** Returns true when the call was handled here; otherwise the caller passes it on (the body is replayed). */
107
+ const debug = process.env.DSH_WSL_DEBUG ? (...a) => { try { writeFileSync(process.env.DSH_WSL_DEBUG, `${new Date().toISOString()} ${a.join(' ')}\n`, { flag: 'a' }) } catch {} } : () => {}
108
+ async function call(req, res, next, pathname, endpoint) {
109
+ debug('call', endpoint, available())
110
+ if (pathname === '/api/session/uploadFileBinary') return upload(req, res, next)
111
+ if (LOCAL_ONLY.test(endpoint) || !available()) return next()
112
+ const body = await readBody(req)
113
+ let message
114
+ try { message = JSON.parse(body.toString('utf8')) } catch { replay(req, body); return next() }
115
+ const payload = message?.payload
116
+ const args = payload?.args ?? {}
117
+ const local = (changed) => { replay(req, changed === undefined ? body : Buffer.from(JSON.stringify({ ...message, payload: { ...payload, args: changed } }))); return next() }
118
+
119
+ if (MERGED_LISTS.has(endpoint)) {
120
+ const [mine, theirs] = await Promise.all([ownHost(req, pathname, body), rpc(endpoint, translate(args, false)).catch((error) => { debug('merged list', endpoint, 'WSL failed:', error.message); return undefined })])
121
+ debug('merged list', endpoint, 'local', mine?.result?.ok, mine?.result?.value?.items?.length, 'wsl', theirs?.ok, theirs?.value?.items?.length ?? theirs?.error?.code)
122
+ if (mine?.result?.ok && theirs?.ok) mine.result.value = { ...mine.result.value, items: [...mine.result.value.items, ...localizeSessions(translate(theirs.value, true).items, paths())] }
123
+ res.writeHead(200, { 'content-type': 'application/json' })
124
+ res.end(JSON.stringify(mine))
125
+ return
126
+ }
127
+ if (endpoint === 'workspace/delete' && !isWsl(args.request?.workspaceId) && twins.has(args.request?.workspaceId)) {
128
+ // Removing a Windows folder also removes the WSL Host's twin; otherwise its tasks would reappear as a row of
129
+ // their own. Like the official delete, this only unregisters the folders: no file or task record is deleted.
130
+ const workspaceId = args.request.workspaceId
131
+ const mine = await ownHost(req, pathname, body)
132
+ if (mine?.result?.ok) {
133
+ const theirs = await rpc(endpoint, translate({ request: { workspaceId: twins.get(workspaceId) } }, false)).catch((error) => ({ ok: false, error }))
134
+ if (!theirs.ok) ctx.logger?.warn?.('wsl-mirror: removing the WSL twin of %s failed: %s', workspaceId, theirs.error?.message)
135
+ twins.delete(workspaceId)
136
+ if (runTargets.delete(workspaceId)) saveRunTargets()
137
+ }
138
+ return reply(res, message.rpcId, mine?.result ?? failure('internal', 'workspace/delete failed'))
139
+ }
140
+ if ((endpoint === 'session/openWorkspacePath' || endpoint === 'session/workspacePathApplications') && typeof args.request?.path === 'string' && args.request.path.startsWith('/')) {
141
+ // A WSL task's file paths are the WSL Host's (/home/..., /mnt/d/...). Opening happens on this desktop, so the
142
+ // path is turned into this Host's form (\\wsl.localhost\<distro>\..., D:\...) and the call stays here.
143
+ const path = paths().toLocal(args.request.path)
144
+ if (path !== args.request.path) return local({ ...args, request: { ...args.request, path } })
145
+ }
146
+ if (endpoint.startsWith('dynamicCordisRunner/')) {
147
+ const method = endpoint.slice('dynamicCordisRunner/'.length)
148
+ if (method === 'syncInspectManifest') {
149
+ // The page's own APIs: both Hosts' Creator may inspect them.
150
+ wslCordis.manifest = args
151
+ rpc(endpoint, args).catch(() => {})
152
+ return local()
153
+ }
154
+ if (method === 'inventory') {
155
+ const [mine, theirs] = await Promise.all([ownHost(req, pathname, body), rpc(endpoint, {}).catch(() => undefined)])
156
+ if (mine?.result?.ok && theirs?.ok) mine.result.value = [...mine.result.value, ...translate(theirs.value, true)]
157
+ return reply(res, message.rpcId, mine?.result ?? failure('internal', 'no inventory'))
158
+ }
159
+ const toWslHost = method === 'resolveRequestRun' ? wslCordis.requests.has(args.requestId)
160
+ : method === 'invoke' ? wslCordis.runs.has(args.pluginRunId)
161
+ : isWsl(args.agentId)
162
+ if (!toWslHost) return local()
163
+ if (method === 'getClientCode' && typeof args.pluginRunId === 'string') wslCordis.runs.add(args.pluginRunId)
164
+ if (method === 'resolveInspectQuery' || method === 'resolveRequestRun') wslCordis.requests.delete(args.requestId)
165
+ const result = await rpc(endpoint, translate(args, false))
166
+ return reply(res, message.rpcId, result.ok ? { ok: true, value: translate(result.value, true) } : result)
167
+ }
168
+ if (endpoint === '$events/result') {
169
+ if (!isWsl(args.eventId)) return local()
170
+ const clientId = eventClients.get(args.clientId)
171
+ if (clientId === undefined) return reply(res, message.rpcId, failure('wsl/events', 'No WSL event stream for this page.'))
172
+ return reply(res, message.rpcId, await rpc(endpoint, { ...args, clientId, eventId: toWsl(args.eventId) }))
173
+ }
174
+ if (endpoint === 'workspace/create' && paths().insideWsl(args.request?.path)) {
175
+ const result = await rpc(endpoint, { request: { ...args.request, path: paths().toWsl(args.request.path) } })
176
+ if (result.ok) result.value = { ...translate(result.value, true), workspace: { ...translate(result.value.workspace, true), path: args.request.path } }
177
+ return reply(res, message.rpcId, result)
178
+ }
179
+ if (endpoint === 'agentPresets/list') {
180
+ const { mine, theirs } = await presets(req)
181
+ if (!mine?.ok) return reply(res, message.rpcId, mine ?? failure('internal', 'no preset list'))
182
+ return reply(res, message.rpcId, { ok: true, value: { ...mine.value, presets: mergePresets(mine.value.presets, theirs) } })
183
+ }
184
+ if (endpoint === 'agentPresets/read') {
185
+ const mine = await ownHost(req, pathname, body)
186
+ if (mine?.result?.ok || mine?.result?.error?.code !== 'agent-preset/not-found') return reply(res, message.rpcId, mine?.result)
187
+ return reply(res, message.rpcId, await rpc(endpoint, args))
188
+ }
189
+ if (endpoint === 'agentPresets/select' && !isWsl(args.agentId)) {
190
+ const refusal = await wslOnlyPreset(req, args.agentPreset)
191
+ if (refusal) return reply(res, message.rpcId, refusal)
192
+ }
193
+ if (endpoint === 'session/create') {
194
+ const request = args.request ?? {}
195
+ const runsHere = !isWsl(request.workspaceId) && !paths().insideWsl(request.cwd) && (runTargets.get(request.workspaceId) ?? 'local') === 'local'
196
+ const refusal = runsHere ? await wslOnlyPreset(req, request.agentPreset) : undefined
197
+ if (refusal) return reply(res, message.rpcId, refusal)
198
+ return create(req, res, next, message, args, local)
199
+ }
200
+ if (endpoint === 'workspace/insertSessionBefore' && isWsl(args.request?.sessionId) && !isWsl(args.request?.workspaceId)) {
201
+ // Reordering a WSL Session inside a Windows folder: the order lives in the folder's twin on the WSL Host.
202
+ const twin = twins.get(args.request.workspaceId)
203
+ if (twin === undefined) return reply(res, message.rpcId, failure('wsl/no-twin', 'This folder has no WSL counterpart.'))
204
+ const { beforeSessionId, ...rest } = args.request
205
+ return forward(res, pathname, message, { ...payload, args: { request: { ...rest, workspaceId: twin, ...(isWsl(beforeSessionId) ? { beforeSessionId } : {}) } } })
206
+ }
207
+ let route
208
+ try { route = routeOf(args, endpoint) } catch (error) { return reply(res, message.rpcId, failure(error.code, error.message)) }
209
+ if (route === 'local') return local()
210
+ return forward(res, pathname, message, payload)
211
+ }
212
+
213
+ // Agent presets (modes): plugins usually live in WSL, so the mode list shows both Hosts'. A mode only the WSL Host has
214
+ // is marked, and choosing it for a task that runs here says to switch the run location.
215
+ const presetCall = (req, endpoint) => ownHost(req, `/api/${endpoint}`, Buffer.from(JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: endpoint, payload: { args: {} } })))
216
+ async function presets(req) {
217
+ const [mine, theirs] = await Promise.all([presetCall(req, 'agentPresets/list').then((r) => r?.result), rpc('agentPresets/list', {}).catch(() => undefined)])
218
+ return { mine, theirs: theirs?.ok ? theirs.value.presets : [] }
219
+ }
220
+ async function wslOnlyPreset(req, id) {
221
+ if (typeof id !== 'string') return undefined
222
+ const { mine, theirs } = await presets(req)
223
+ if (!mine?.ok || mine.value.presets.some((p) => p.id === id)) return undefined
224
+ const row = theirs.find((p) => p.id === id)
225
+ if (!row) return undefined
226
+ return failure('agent-preset/not-found', pick({ zh: `"${row.name ?? id}" 模式来自 WSL 中的插件,只能用于在 WSL 中运行的任务。请在输入框下方把运行位置切换到 WSL。`, en: `The "${row.name ?? id}" mode comes from a plugin in WSL and works only for tasks that run in WSL. Switch the run location under the input box to WSL.` }))
227
+ }
228
+
229
+ async function create(req, res, next, message, args, local) {
230
+ const request = args.request ?? {}
231
+ if (request.workspaceId === undefined && paths().insideWsl(request.cwd)) {
232
+ const result = await rpc('session/create', { request: { ...request, cwd: paths().toWsl(request.cwd) } })
233
+ return reply(res, message.rpcId, result.ok ? { ok: true, value: translate(result.value, true) } : result)
234
+ }
235
+ const target = runTargets.get(request.workspaceId) ?? 'local'
236
+ const place = placeSession(request, { target, twin: twins.get(request.workspaceId) })
237
+ if (place.host === 'local') return local({ ...args, request: place.request })
238
+ if (place.needsTwin) {
239
+ const folder = localWorkspaces?.items.find((w) => w.workspaceId === request.workspaceId)
240
+ if (folder === undefined) return reply(res, message.rpcId, failure('wsl/unknown-folder', 'This folder is not known yet; reopen the page and try again.'))
241
+ const created = await rpc('workspace/create', { request: { path: paths().toWsl(folder.path) } })
242
+ if (!created.ok) return reply(res, message.rpcId, created)
243
+ place.request.workspaceId = toPage(created.value.workspace.workspaceId)
244
+ twins.set(request.workspaceId, place.request.workspaceId)
245
+ }
246
+ const result = await rpc('session/create', translate({ request: place.request }, false))
247
+ return reply(res, message.rpcId, result.ok ? { ok: true, value: translate(result.value, true) } : result)
248
+ }
249
+
250
+ async function upload(req, res, next) {
251
+ const url = new URL(req.url, 'http://x')
252
+ const sessionId = url.searchParams.get('sessionId')
253
+ if (!isWsl(sessionId) || !available()) return next()
254
+ url.searchParams.set('sessionId', toWsl(sessionId))
255
+ const upstream = await remoteFetch(`${url.pathname}${url.search}`, {
256
+ method: 'POST', body: req,
257
+ headers: { cookie: await cookie(), origin: remoteOrigin(), 'content-type': req.headers['content-type'] ?? 'application/octet-stream' },
258
+ })
259
+ res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') ?? 'application/json' })
260
+ const text = await upstream.text()
261
+ try { res.end(JSON.stringify(translate(JSON.parse(text), true))) } catch { res.end(text) }
262
+ }
263
+
264
+ // ---- run location of new Sessions in a folder of this Host: GET ?workspaceId= / POST {workspaceId, target}
265
+ ctx.effect(() => ctx.webServer.register({
266
+ kind: 'exact', path: '/wsl-bridge/run-target',
267
+ async handler(req, res) {
268
+ const admission = ctx.connection.admit(req)
269
+ if ('rejection' in admission) { res.writeHead(admission.rejection); res.end(); return }
270
+ noteLanguage(req)
271
+ let workspaceId = new URL(req.url, 'http://x').searchParams.get('workspaceId')
272
+ let presetAvailable
273
+ if (req.method === 'POST') {
274
+ try {
275
+ const body = JSON.parse((await readBody(req)).toString('utf8'))
276
+ workspaceId = body.workspaceId
277
+ if (typeof workspaceId === 'string' && !isWsl(workspaceId) && (body.target === 'wsl' || body.target === 'local')) {
278
+ if (body.target === 'local') runTargets.delete(workspaceId); else runTargets.set(workspaceId, 'wsl')
279
+ saveRunTargets()
280
+ }
281
+ // Switching a blank task carries its mode over when the chosen side has it (the page asks with agentPreset).
282
+ if (typeof body.agentPreset === 'string') {
283
+ const { mine, theirs } = await presets(req)
284
+ const side = (isWsl(workspaceId) || runTargets.get(workspaceId) === 'wsl') ? theirs : mine?.ok ? mine.value.presets : []
285
+ presetAvailable = side.some((p) => p.id === body.agentPreset)
286
+ }
287
+ } catch {}
288
+ }
289
+ const folder = isWsl(workspaceId) ? wslWorkspaces?.items.find((w) => w.workspaceId === workspaceId) : localWorkspaces?.items.find((w) => w.workspaceId === workspaceId)
290
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
291
+ res.end(JSON.stringify({
292
+ workspaceId,
293
+ target: isWsl(workspaceId) ? 'wsl' : runTargets.get(workspaceId) ?? 'local',
294
+ insideWsl: isWsl(workspaceId),
295
+ wslAvailable: available(),
296
+ setup: setupState(),
297
+ distro: distro() ?? null,
298
+ wslPath: folder ? (isWsl(workspaceId) ? folder.path : paths().toWsl(folder.path)) : null,
299
+ ...(presetAvailable === undefined ? {} : { presetAvailable }),
300
+ }))
301
+ },
302
+ }), 'wsl-mirror: run target route')
303
+
304
+ // ---- WSL directory browsing for the add-folder dialog: GET ?path= lists, POST {path, name} creates a folder.
305
+ // Paths are the WSL Host's (/home/...); the page turns the chosen one into \\wsl.localhost\<distro>\...
306
+ ctx.effect(() => ctx.webServer.register({
307
+ kind: 'exact', path: '/wsl-bridge/dirs',
308
+ async handler(req, res) {
309
+ const admission = ctx.connection.admit(req)
310
+ if ('rejection' in admission) { res.writeHead(admission.rejection); res.end(); return }
311
+ noteLanguage(req)
312
+ const send = (result) => { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(result)) }
313
+ if (!available()) return send(failure('wsl/unavailable', pick({ zh: 'WSL 端未连接或版本不同', en: 'The WSL side is not connected or runs a different version' })))
314
+ try {
315
+ if (req.method === 'POST') {
316
+ const body = JSON.parse((await readBody(req)).toString('utf8'))
317
+ return send(await rpc('directoryPicker/createDirectory', { path: body.path, name: body.name }))
318
+ }
319
+ const path = new URL(req.url, 'http://x').searchParams.get('path')
320
+ return send(await rpc('directoryPicker/list', path ? { path } : {}))
321
+ } catch (error) { return send(failure('wsl/unavailable', error.message)) }
322
+ },
323
+ }), 'wsl-mirror: WSL directory browsing route')
324
+
325
+ // ---- streams
326
+ const gateway = ctx.typertGateway
327
+ const ownOpen = Object.hasOwn(gateway, 'openWireStream')
328
+ const originalOpen = gateway.openWireStream
329
+ gateway.openWireStream = async function (endpoint, payload, uplink, peer, signal, control, window) {
330
+ const native = () => originalOpen.call(this, endpoint, payload, uplink, peer, signal, control, window)
331
+ // The merged streams take in the WSL Host whenever it comes: one opened before WSL connected (Desktop starting,
332
+ // the person still setting WSL up) keeps trying it, and picks it up once it is there.
333
+ if (MERGED_STREAMS.has(endpoint)) return merged(endpoint, payload, signal, await native())
334
+ if (!available()) return native()
335
+ let route
336
+ try { route = routeOf(payload?.args, endpoint) } catch (error) { throw remoteError({ code: error.code, message: error.message }) }
337
+ if (route === 'local') return native()
338
+ return (async function* () {
339
+ for await (const value of mux.open(endpoint, translate(payload, false), signal, uplink)) yield translate(value, true)
340
+ })()
341
+ }
342
+ ctx.effect(() => () => { if (ownOpen) gateway.openWireStream = originalOpen; else delete gateway.openWireStream }, 'wsl-mirror: restore gateway')
343
+
344
+ /** One merged stream: this Host's (`native`, already open) and the WSL Host's, kept open with retries. */
345
+ async function* merged(endpoint, payload, signal, native) {
346
+ debug('stream open', endpoint, 'wsl available', available())
347
+ const queue = new Queue()
348
+ const scope = new AbortController()
349
+ const combined = AbortSignal.any([signal, scope.signal])
350
+ void (async () => {
351
+ try { for await (const value of native) queue.push({ source: 'local', value }); queue.push({ source: 'local', end: true }) } catch (error) { queue.push({ source: 'local', error }) }
352
+ })()
353
+ void (async () => {
354
+ // While WSL is not there this waits for it to come (no polling); while the WSL stream is open nothing retries.
355
+ // Only a stream that fails while WSL is there is retried, with backoff.
356
+ for (let attempt = 0; !combined.aborted; attempt++) {
357
+ if (!available()) {
358
+ queue.push({ source: 'wsl', down: true })
359
+ await whenAvailable(combined)
360
+ attempt = 0
361
+ continue
362
+ }
363
+ try {
364
+ for await (const value of mux.open(endpoint, translate(payload, false), combined)) { attempt = 0; queue.push({ source: 'wsl', value: translate(value, true) }) }
365
+ } catch (error) { if (combined.aborted) return; if (attempt === 0) logger.debug('wsl-mirror: %s from WSL: %s', endpoint, error.message) }
366
+ queue.push({ source: 'wsl', down: true })
367
+ await new Promise((r) => { const t = setTimeout(r, Math.min(10_000, 500 * 2 ** Math.min(attempt, 5))); combined.addEventListener('abort', () => { clearTimeout(t); r() }, { once: true }) })
368
+ }
369
+ })()
370
+ try {
371
+ if (endpoint === 'workspace/follow') yield* followWorkspaces(queue, combined)
372
+ else if (endpoint === 'session/control') yield* followControl(queue, combined)
373
+ else yield* followEvents(queue, combined)
374
+ } finally { scope.abort() }
375
+ }
376
+
377
+ async function* followWorkspaces(queue, signal) {
378
+ // The first frame waits briefly for the WSL Host's folders, so the sidebar does not fill in twice.
379
+ let started = false
380
+ let localReady = false
381
+ let wslReady = false
382
+ let grace
383
+ try {
384
+ for (;;) {
385
+ const item = await queue.next(signal)
386
+ if (item.source === 'local' && item.end) return
387
+ if (item.error) throw item.error
388
+ if (item.down || item.graceOver) wslReady = true // keep the last known WSL folders while it reconnects
389
+ else if (item.source === 'local') { localWorkspaces = applyWorkspaceFrame(localWorkspaces, item.value); localReady = true }
390
+ else { wslWorkspaces = applyWorkspaceFrame(wslWorkspaces, item.value); wslReady = true }
391
+ if (!started) {
392
+ if (!localReady) continue
393
+ if (!wslReady) { grace ??= setTimeout(() => queue.push({ graceOver: true }), 1500); continue }
394
+ } else if (item.down || item.graceOver) continue
395
+ yield { type: started ? 'replace' : 'baseline', value: remember() }
396
+ started = true
397
+ }
398
+ } finally { clearTimeout(grace) }
399
+ }
400
+
401
+ async function* followControl(queue, signal) {
402
+ // One baseline per stream generation, from both Hosts; the WSL Host gets a short grace period.
403
+ const snapshots = {}
404
+ const waiting = new Set(['local', 'wsl'])
405
+ const buffered = []
406
+ const grace = setTimeout(() => queue.push({ source: 'wsl', down: true }), 3000)
407
+ try {
408
+ for (;;) {
409
+ const item = await queue.next(signal)
410
+ if (item.source === 'local' && item.end) return
411
+ if (item.error) throw item.error
412
+ if (waiting.size > 0) {
413
+ if (item.down) waiting.delete('wsl')
414
+ else if (item.value.type === 'baseline') {
415
+ Object.assign(snapshots, item.source === 'wsl' ? prefixProjections(item.value.value).projections : item.value.value.projections)
416
+ waiting.delete(item.source)
417
+ } else buffered.push(item.value)
418
+ if (waiting.size === 0) { yield { type: 'baseline', value: { projections: snapshots } }; yield* buffered.splice(0) }
419
+ continue
420
+ }
421
+ if (item.down) continue
422
+ if (item.value.type === 'baseline') {
423
+ // The page does not reopen a failed control stream, so a WSL Host that connects late (it starts with the
424
+ // Desktop, after the page) or reconnects is folded in as one projection frame per Session: the page's
425
+ // stores keep the higher seq per key, as with any live frame.
426
+ if (item.source === 'wsl') { yield* baselineFrames(prefixProjections(item.value.value)); continue }
427
+ throw remoteError({ code: 'gateway/carrier', message: 'Session control restarted.' })
428
+ }
429
+ yield item.value
430
+ }
431
+ } finally { clearTimeout(grace) }
432
+ }
433
+
434
+ async function* followEvents(queue, signal) {
435
+ let localClient
436
+ let wslClient
437
+ const buffered = []
438
+ try {
439
+ for (;;) {
440
+ const item = await queue.next(signal)
441
+ if (item.source === 'local' && item.end) return
442
+ if (item.error) throw item.error
443
+ if (item.down) continue
444
+ const frame = item.value
445
+ if (frame.type === 'ready') {
446
+ if (item.source === 'local') { localClient = frame.clientId; yield frame; yield* buffered.splice(0) } else {
447
+ wslClient = frame.clientId
448
+ // A WSL Host that (re)connected has not seen the page's APIs yet.
449
+ if (wslCordis.manifest !== undefined) rpc('dynamicCordisRunner/syncInspectManifest', wslCordis.manifest).catch(() => {})
450
+ // The page lists Sessions once per connection (session/list); WSL may come after that (Desktop started
451
+ // first, the person still setting WSL up, the WSL Host restarted). Its Sessions are announced to the page
452
+ // as added, once per WSL connection, which the page merges by id.
453
+ debug('events: WSL ready', frame.clientId)
454
+ void rpc('session/list', { _request: {} }).then((result) => {
455
+ debug('events: WSL session list', result?.ok, result?.value?.items?.length ?? result?.error?.code, 'aborted', signal.aborted)
456
+ if (!result?.ok || signal.aborted) return
457
+ for (const summary of localizeSessions(translate(result.value, true).items, paths())) queue.push({ source: 'wsl', value: { type: 'emit', event: 'api-session/added', args: [summary] }, listed: true })
458
+ }).catch((error) => logger.debug('wsl-mirror: WSL session list: %s', error.message))
459
+ }
460
+ if (item.source === 'local') debug('events: local ready', frame.clientId)
461
+ if (localClient !== undefined && wslClient !== undefined) eventClients.set(localClient, wslClient)
462
+ continue
463
+ }
464
+ if (item.source === 'wsl' && frame.type === 'emit') noteCordis(frame)
465
+ let out = frame
466
+ if (item.source === 'wsl' && !item.listed) {
467
+ out = prefixEvent(frame)
468
+ // A Session the WSL Host adds names its folder as WSL sees it; the page knows it as this side does.
469
+ if (out.event === 'api-session/added' && typeof out.args?.[0]?.cwd === 'string') out = { ...out, args: [localizeSessions([out.args[0]], paths())[0], ...out.args.slice(1)] }
470
+ }
471
+ if (localClient === undefined) buffered.push(out); else yield out
472
+ }
473
+ } finally { if (localClient !== undefined) eventClients.delete(localClient) }
474
+ }
475
+
476
+ function noteCordis(frame) {
477
+ const [request] = frame.args ?? []
478
+ const id = request?.requestId
479
+ if (typeof id !== 'string') return
480
+ if (frame.event === 'cordis/inspect-query' || frame.event === 'cordis/request-run') wslCordis.requests.add(id)
481
+ else if (frame.event === 'cordis/inspect-query-resolved' || frame.event === 'cordis/request-run-resolved') wslCordis.requests.delete(id)
482
+ }
483
+
484
+ // Raw ids of the WSL Host's Workspaces, for plugin bodies that name them.
485
+ const wslWorkspaceIds = () => new Set((wslWorkspaces?.items ?? []).map((w) => toWsl(w.workspaceId)))
486
+ return { call, wslWorkspaceIds }
487
+ }
488
+
489
+ /** Both Hosts' modes: this Host's as they are, then the WSL Host's that this one lacks, marked. */
490
+ export function mergePresets(mine, theirs) {
491
+ const have = new Set(mine.map((p) => p.id))
492
+ return [...mine, ...theirs.filter((p) => !have.has(p.id)).map((p) => ({ ...p, isDefault: false, name: `${p.name ?? p.id} · WSL` }))]
493
+ }
494
+
495
+ class Queue {
496
+ values = [];
497
+ push(value) { this.values.push(value); this.wake?.() }
498
+ async next(signal) {
499
+ while (!this.values.length) {
500
+ signal.throwIfAborted()
501
+ await new Promise((resolve) => { this.wake = resolve; this.signal = signal; signal.addEventListener('abort', resolve, { once: true }) })
502
+ signal.removeEventListener('abort', this.wake)
503
+ this.wake = undefined
504
+ }
505
+ return this.values.shift()
506
+ }
507
+ }
package/selfupdate.mjs ADDED
@@ -0,0 +1,79 @@
1
+ // Keeps this plugin up to date (docs/deployment.md, decision 8) through DSH's own plugin updates: the plugin manager
2
+ // lists the registry's newer versions (marking those whose engines.dsh rejects this DSH), and updateBundle replaces the
3
+ // installed one, putting the old one back when the new one does not install or start. The WSL half follows by itself:
4
+ // it is copied from this package on the next connect (setup.mjs, STAMP).
5
+ //
6
+ // Only a plugin installed from a registry can update this way; one installed from a folder or a Git URL reports
7
+ // `not-updatable`. The person can switch automatic updates off (config.json `autoUpdate: false`).
8
+
9
+ const FIRST_CHECK_MS = 60_000
10
+ const EVERY_MS = 6 * 60 * 60_000
11
+
12
+ /**
13
+ * The version to update to: the newest that declares a bundle, works with this DSH, is not deprecated, and is a
14
+ * release unless the installed version is itself a prerelease. `list` is the plugin manager's PluginVersionList.
15
+ */
16
+ export function pickUpdate(list, installed) {
17
+ if (list?.status !== 'listed') return undefined
18
+ const onPrerelease = /-/.test(installed ?? '')
19
+ return list.versions.find((v) => v.bundle && !v.incompatible && !v.deprecated && (onPrerelease || !v.prerelease))
20
+ }
21
+
22
+ /**
23
+ * How the plugin manager can update a listed bundle: DSH 0.2.2-rc.2 lists `update: 'registry' | 'git'`, earlier
24
+ * releases `updatable: boolean` (registry only). Only registry updates are used here.
25
+ */
26
+ export const updateKind = (row) => row?.update ?? (row?.updatable ? 'registry' : undefined)
27
+
28
+ /**
29
+ * Check now and then; returns {state(), check()}. `state()` is what the setup dialog shows:
30
+ * {name, version, auto, status, latest?, error?, at?} with status one of: waiting, off, checking, not-updatable,
31
+ * up-to-date, updating, updated, restart-required, failed.
32
+ */
33
+ export function installSelfUpdate(ctx, { name, version, auto, log = () => {} }) {
34
+ let state = { name, version, auto: auto(), status: 'waiting' }
35
+ let manager
36
+ let running
37
+ const set = (patch) => { state = { ...state, ...patch, at: new Date().toISOString() } }
38
+
39
+ async function check() {
40
+ if (running) return running
41
+ running = (async () => {
42
+ set({ auto: auto() })
43
+ if (!state.auto) { set({ status: 'off' }); return }
44
+ if (!manager) { set({ status: 'failed', error: 'the plugin manager is not available' }); return }
45
+ set({ status: 'checking', error: undefined })
46
+ try {
47
+ const row = (await manager.listBundles()).find((b) => b.name === name)
48
+ if (updateKind(row) !== 'registry') { set({ status: 'not-updatable' }); return }
49
+ const list = await manager.listVersions(name)
50
+ if (list.status !== 'listed') { set({ status: 'failed', error: list.problem ?? list.status }); return }
51
+ const next = pickUpdate(list, row.version ?? version)
52
+ if (!next) { set({ status: 'up-to-date', latest: list.versions[0]?.version }); return }
53
+ set({ status: 'updating', latest: next.version })
54
+ log(`updating ${name} ${row.version} → ${next.version}`)
55
+ // With HMR this disposes this plugin before the new version starts; nothing below may run then.
56
+ const result = await manager.updateBundle(name, next.version, { registry: list.registry })
57
+ const application = result.result.application
58
+ if (application === 'applied') set({ status: 'updated' })
59
+ else if (application === 'restart-required') set({ status: 'restart-required' })
60
+ else set({ status: 'failed', error: result.result.error?.message ?? `${result.step}: ${application}` })
61
+ } catch (error) {
62
+ set({ status: 'failed', error: error.message })
63
+ }
64
+ })().finally(() => { running = undefined })
65
+ return running
66
+ }
67
+
68
+ ctx.inject(['pluginManager'], (c) => {
69
+ manager = c.pluginManager
70
+ const first = setTimeout(() => void check(), FIRST_CHECK_MS); first.unref?.()
71
+ const every = setInterval(() => void check(), EVERY_MS); every.unref?.()
72
+ return () => { manager = undefined; clearTimeout(first); clearInterval(every) }
73
+ })
74
+ return {
75
+ // The switch takes effect at once in what the dialog shows; a check in flight finishes.
76
+ state: () => (auto() ? { ...state, auto: true, ...(state.status === 'off' ? { status: 'waiting' } : {}) } : { ...state, auto: false, status: state.status === 'updating' ? 'updating' : 'off' }),
77
+ check,
78
+ }
79
+ }