@herenfor/dsh-wsl 0.0.0-stage → 0.1.0
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/LICENSE +51 -0
- package/README.md +68 -2
- package/README.zh.md +69 -0
- package/agent/broker.mjs +107 -0
- package/agent/cordis.patch.yml +3 -0
- package/agent/index.mjs +130 -0
- package/agent/package.json +9 -0
- package/client.js +817 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +1 -0
- package/ids.mjs +89 -0
- package/index.mjs +586 -0
- package/language.mjs +27 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/merge.mjs +65 -0
- package/package.json +59 -4
- package/paths.mjs +24 -0
- package/plugins.mjs +77 -0
- package/remote-mux.mjs +154 -0
- package/routes.mjs +47 -0
- package/routing.mjs +502 -0
- package/selfupdate.mjs +79 -0
- package/sessions.mjs +101 -0
- package/settings.mjs +53 -0
- package/setup.mjs +286 -0
- package/transport/client.mjs +102 -0
- package/transport/daemon.mjs +268 -0
- package/transport/mux.mjs +118 -0
package/merge.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Pure graph merge for the UI mirror. Kept free of DSH imports so it can be unit-tested.
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The two Hosts can work together: DSH of the same major.minor (docs/deployment.md). Patch and prerelease may differ;
|
|
6
|
+
* the setup dialog offers to align them.
|
|
7
|
+
*/
|
|
8
|
+
export const compatibleReleases = (a, b) => {
|
|
9
|
+
const pa = /^(\d+)\.(\d+)\./.exec(a?.version ?? ''); const pb = /^(\d+)\.(\d+)\./.exec(b?.version ?? '')
|
|
10
|
+
return !!pa && !!pb && pa[1] === pb[1] && pa[2] === pb[2]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Merge the user rows of a remote graph into the local one. A remote user row replaces a local row of the same id
|
|
15
|
+
* (one UI for the window: the WSL side wins). Local rows of entries that a WSL plugin's bundle patch switches off
|
|
16
|
+
* (`remote.disabled`, e.g. DS Bot turns off DSH's general settings shell to hold its seat) leave the page too: the
|
|
17
|
+
* patch acts on the WSL Host's composition only, and the page must look as it would there.
|
|
18
|
+
* Every local batch that contained a replaced or dropped row is split into single-row batches for its remaining rows,
|
|
19
|
+
* because a batch script would register those rows' factories too.
|
|
20
|
+
* @param local - this Host's graph.
|
|
21
|
+
* @param remote - { graph, origin, disabled? } from the WSL agent, or undefined.
|
|
22
|
+
* @param order - orderByModuleGraph from dsh-client-modules.
|
|
23
|
+
* @returns { graph, mirrored, dropped } where mirrored maps a package id to its remote row and dropped holds the ids of
|
|
24
|
+
* local rows left out.
|
|
25
|
+
*/
|
|
26
|
+
export function mergeGraphs(local, remote, order) {
|
|
27
|
+
const mirrored = new Map()
|
|
28
|
+
const dropped = new Set()
|
|
29
|
+
if (remote === undefined) return { graph: local, mirrored, dropped, reason: 'no remote graph yet' }
|
|
30
|
+
if (!compatibleReleases(local.release, remote.graph.release)) {
|
|
31
|
+
return { graph: local, mirrored, dropped, reason: `release differs: local ${JSON.stringify(local.release)}, remote ${JSON.stringify(remote.graph.release)}` }
|
|
32
|
+
}
|
|
33
|
+
for (const row of remote.graph.entries) if (remote.origin[row.id] === 'user') mirrored.set(row.id, row)
|
|
34
|
+
const byId = new Map(local.entries.map((row) => [row.id, row]))
|
|
35
|
+
for (const { name } of remote.disabled ?? []) if (byId.has(name) && !mirrored.has(name)) dropped.add(name)
|
|
36
|
+
if (mirrored.size === 0 && dropped.size === 0) return { graph: local, mirrored, dropped, reason: 'remote has no user rows' }
|
|
37
|
+
const gone = (id) => mirrored.has(id) || dropped.has(id)
|
|
38
|
+
const entries = order([...local.entries.filter((row) => !gone(row.id)), ...mirrored.values()])
|
|
39
|
+
const batches = []
|
|
40
|
+
for (const batch of local.batches) {
|
|
41
|
+
if (!batch.entries.some(gone)) { batches.push(batch); continue }
|
|
42
|
+
for (const id of batch.entries) {
|
|
43
|
+
if (gone(id)) continue
|
|
44
|
+
const row = byId.get(id)
|
|
45
|
+
batches.push({ phase: batch.phase, url: row.url, rev: row.rev, entries: [id] })
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const row of mirrored.values()) batches.push({ phase: 'application', url: row.url, rev: row.rev, entries: [row.id] })
|
|
49
|
+
const rev = createHash('sha256').update(JSON.stringify({ entries, batches })).digest('base64url').slice(0, 12)
|
|
50
|
+
return { graph: { rev, release: local.release, entries, batches }, mirrored, dropped }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The package ids a /plugins request addresses: every id of a combo (`/plugins/??a/client.js,b/client.js&rev=`),
|
|
55
|
+
* or the package of a chunk or asset path (`/plugins/<id>/...`, scoped ids included).
|
|
56
|
+
*/
|
|
57
|
+
export function requestedPackages(url) {
|
|
58
|
+
const { pathname, search } = new URL(url, 'http://x')
|
|
59
|
+
if (search.startsWith('??')) {
|
|
60
|
+
const list = search.slice(2).split('&')[0]
|
|
61
|
+
return list.split(',').map((file) => file.replace(/\/client\.js(\.map)?$/, ''))
|
|
62
|
+
}
|
|
63
|
+
const parts = pathname.replace(/^\/plugins\//, '').split('/')
|
|
64
|
+
return [parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]]
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@herenfor/dsh-wsl",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "WSL run mode for DeepSeek Harness Desktop on Windows: run chosen tasks, and the plugins they use, in WSL from the Desktop window",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"icon": "./icon.svg",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"dsh",
|
|
10
|
+
"deepseek-harness",
|
|
11
|
+
"dsh-plugin",
|
|
12
|
+
"wsl"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/herenfor/dsh-wsl.git",
|
|
17
|
+
"directory": "experiments/mirror/mirror"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/herenfor/dsh-wsl#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/herenfor/dsh-wsl/issues"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"dsh": ">=0.2.0-0 <0.3.0-0"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./index.mjs",
|
|
31
|
+
"./client": "./client.js",
|
|
32
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
33
|
+
"./locale/*.json": "./locale/*.json",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"bundle": {
|
|
38
|
+
"patch": "./cordis.patch.yml"
|
|
39
|
+
},
|
|
40
|
+
"client": {
|
|
41
|
+
"platform": "web",
|
|
42
|
+
"inject": [
|
|
43
|
+
"@deepseek-ai/dsh-client-locale",
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-workspace",
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"manifestVersion": 1
|
|
50
|
+
},
|
|
51
|
+
"files": [
|
|
52
|
+
"*.mjs",
|
|
53
|
+
"client.js",
|
|
54
|
+
"cordis.patch.yml",
|
|
55
|
+
"icon.svg",
|
|
56
|
+
"locale/*.json",
|
|
57
|
+
"README.zh.md",
|
|
58
|
+
"transport/",
|
|
59
|
+
"agent/"
|
|
60
|
+
]
|
|
61
|
+
}
|
package/paths.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Local-directory install specs between Windows and WSL. A spec may carry pnpm's `file:` or `link:` prefix.
|
|
2
|
+
// Kept free of DSH imports so it can be unit-tested.
|
|
3
|
+
|
|
4
|
+
const split = (spec) => { const m = /^(file:|link:)?(.*)$/s.exec(spec); return [m[1] ?? '', m[2]] }
|
|
5
|
+
|
|
6
|
+
/** A Windows path (C:\..., \\wsl.localhost\<distro>\..., \\wsl$\<distro>\...) as WSL sees it; other specs unchanged. */
|
|
7
|
+
export function toWslSpec(spec) {
|
|
8
|
+
const [prefix, path] = split(spec.trim())
|
|
9
|
+
const drive = /^([A-Za-z]):[\\/](.*)$/s.exec(path)
|
|
10
|
+
if (drive) return `${prefix}/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}`.replace(/\/+$/, '')
|
|
11
|
+
const unc = /^[\\/]{2}(?:wsl\.localhost|wsl\$)[\\/][^\\/]+[\\/]?(.*)$/is.exec(path)
|
|
12
|
+
if (unc) return `${prefix}/${unc[1].replace(/\\/g, '/')}`.replace(/\/+$/, '') || `${prefix}/`
|
|
13
|
+
return spec
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A WSL path as Windows sees it (/mnt/c/... → C:\..., /home/... → \\wsl.localhost\<distro>\home\...); other specs unchanged. */
|
|
17
|
+
export function toWindowsSpec(spec, distro) {
|
|
18
|
+
const [prefix, path] = split(spec.trim())
|
|
19
|
+
if (!path.startsWith('/')) return spec
|
|
20
|
+
const mnt = /^\/mnt\/([a-z])(?:\/(.*))?$/s.exec(path)
|
|
21
|
+
if (mnt) return `${prefix}${mnt[1].toUpperCase()}:\\${(mnt[2] ?? '').replace(/\//g, '\\')}`
|
|
22
|
+
if (!distro) return spec
|
|
23
|
+
return `${prefix}\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`
|
|
24
|
+
}
|
package/plugins.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The WSL tab of the Plugins page: which of the WSL Host's bundles it lists, and what it shows of each. The official
|
|
2
|
+
// Installed tab is left to this Host alone. Kept free of DSH imports so it can be unit-tested.
|
|
3
|
+
|
|
4
|
+
// Bundles the WSL side needs to be reached at all: listed nowhere, so they cannot be switched off from the page.
|
|
5
|
+
const INTERNAL = new Set(['dsh-wsl-agent'])
|
|
6
|
+
|
|
7
|
+
// Package text as the manifest gives it (a string, or translations by lowercase locale with English among them); the
|
|
8
|
+
// page resolves it in the active language. A map without English gets one, so the page's resolver always has it.
|
|
9
|
+
const text = (value, fallback) => {
|
|
10
|
+
if (typeof value === 'string') return value
|
|
11
|
+
if (value && typeof value === 'object') {
|
|
12
|
+
const entries = Object.entries(value).filter(([, v]) => typeof v === 'string')
|
|
13
|
+
if (entries.length) return { en: value.en ?? entries[0][1], ...Object.fromEntries(entries) }
|
|
14
|
+
}
|
|
15
|
+
return fallback
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The rows of the WSL tab, as the official Installed tab picks them: the official optional bundles and the installed
|
|
20
|
+
* ones (the profile's own base bundles are neither), installed ones first.
|
|
21
|
+
*/
|
|
22
|
+
export function wslBundles(bundles) {
|
|
23
|
+
return bundles
|
|
24
|
+
.filter((b) => !INTERNAL.has(b.name) && (b.official || b.installed))
|
|
25
|
+
.map((b) => ({
|
|
26
|
+
name: b.name,
|
|
27
|
+
title: text(b.meta?.title, b.name),
|
|
28
|
+
description: text(b.meta?.description ?? b.description, ''),
|
|
29
|
+
icon: typeof b.meta?.icon === 'string' ? b.meta.icon : undefined,
|
|
30
|
+
version: b.version ?? null,
|
|
31
|
+
official: !!b.official,
|
|
32
|
+
installed: !!b.installed,
|
|
33
|
+
enabled: !!b.enabled,
|
|
34
|
+
availability: b.availability,
|
|
35
|
+
...(b.error ? { error: b.error.message ?? String(b.error.code ?? b.error) } : {}),
|
|
36
|
+
}))
|
|
37
|
+
.sort((a, b) => Number(a.official) - Number(b.official))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Official bundles the official page marks as experimental (its own list, by name).
|
|
41
|
+
const EXPERIMENTAL = /^@deepseek-ai\/dsh-(experimental-|cua-bundle$|personalized-recommendations$)/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One bundle's page in the WSL tab, as the official bundle page shows it: the bundle with its rows joined to the
|
|
45
|
+
* WSL Host's live entries (`listPlugins`).
|
|
46
|
+
* @returns undefined when the WSL Host lists no such bundle, or one the tab does not list.
|
|
47
|
+
*/
|
|
48
|
+
export function wslBundlePage(bundles, plugins, name) {
|
|
49
|
+
const bundle = bundles.find((b) => b.name === name)
|
|
50
|
+
if (bundle === undefined || INTERNAL.has(name)) return undefined
|
|
51
|
+
const [card] = wslBundles([bundle])
|
|
52
|
+
if (card === undefined) return undefined
|
|
53
|
+
const rows = (bundle.rows ?? []).map((row) => {
|
|
54
|
+
const live = row.entryId === undefined ? undefined : plugins.find((p) => p.entryId === row.entryId)
|
|
55
|
+
return {
|
|
56
|
+
rowId: row.rowId,
|
|
57
|
+
moduleName: row.moduleName,
|
|
58
|
+
title: text(row.meta?.title, row.rowId),
|
|
59
|
+
description: text(row.meta?.description, ''),
|
|
60
|
+
icon: typeof row.meta?.icon === 'string' ? row.meta.icon : undefined,
|
|
61
|
+
enabled: live?.enabled ?? false,
|
|
62
|
+
phase: live?.fiberPhase ?? null,
|
|
63
|
+
...(live?.diagnostic ? { diagnostic: live.diagnostic } : {}),
|
|
64
|
+
...(row.entryId !== undefined ? { entryId: row.entryId } : {}),
|
|
65
|
+
// The official page locks a row the Host cannot address through the profile patch.
|
|
66
|
+
locked: row.entryId === undefined || live?.readOnlyReason !== undefined,
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
return {
|
|
70
|
+
...card,
|
|
71
|
+
experimental: card.official && EXPERIMENTAL.test(name),
|
|
72
|
+
removable: !!bundle.removable && !card.official,
|
|
73
|
+
...(bundle.source ? { source: bundle.source } : {}),
|
|
74
|
+
...(bundle.readOnlyReason ? { readOnly: true } : {}),
|
|
75
|
+
rows,
|
|
76
|
+
}
|
|
77
|
+
}
|
package/remote-mux.mjs
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Streams on the WSL DSH Host: one WebSocket to its /api/remote.mux (DSH's JSON text mux), opened over the bridge.
|
|
2
|
+
// A minimal WebSocket client (RFC 6455, text frames, no extensions) so nothing depends on where the Desktop install
|
|
3
|
+
// keeps the `ws` package.
|
|
4
|
+
import { randomBytes, randomUUID, createHash } from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
7
|
+
|
|
8
|
+
/** Error of a remote stream, in the form DSH's gateway passes to the page unchanged. */
|
|
9
|
+
export const remoteError = (failure) => Object.assign(new Error(failure?.message || 'WSL stream failed'), {
|
|
10
|
+
isDSHRemoteError: true, code: failure?.code || 'internal', details: failure?.details ?? {},
|
|
11
|
+
})
|
|
12
|
+
const carrierError = (message) => remoteError({ code: 'gateway/carrier', message })
|
|
13
|
+
|
|
14
|
+
function encode(opcode, payload) {
|
|
15
|
+
const len = payload.length
|
|
16
|
+
const head = len < 126 ? Buffer.from([0x80 | opcode, 0x80 | len])
|
|
17
|
+
: len < 65536 ? Buffer.from([0x80 | opcode, 0x80 | 126, len >> 8, len & 255])
|
|
18
|
+
: (() => { const b = Buffer.alloc(10); b[0] = 0x80 | opcode; b[1] = 0x80 | 127; b.writeBigUInt64BE(BigInt(len), 2); return b })()
|
|
19
|
+
const mask = randomBytes(4)
|
|
20
|
+
const body = Buffer.alloc(len)
|
|
21
|
+
for (let i = 0; i < len; i++) body[i] = payload[i] ^ mask[i & 3]
|
|
22
|
+
return Buffer.concat([head, mask, body])
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class Socket {
|
|
26
|
+
constructor(socket, head, onText, onClose) {
|
|
27
|
+
this.socket = socket
|
|
28
|
+
this.buffer = head?.length ? Buffer.from(head) : Buffer.alloc(0)
|
|
29
|
+
this.fragments = []
|
|
30
|
+
this.open = true
|
|
31
|
+
socket.on('data', (chunk) => { this.buffer = Buffer.concat([this.buffer, chunk]); this.drain(onText) })
|
|
32
|
+
const closed = () => { if (!this.open) return; this.open = false; onClose() }
|
|
33
|
+
socket.once('close', closed)
|
|
34
|
+
socket.once('error', closed)
|
|
35
|
+
if (this.buffer.length) queueMicrotask(() => this.drain(onText))
|
|
36
|
+
}
|
|
37
|
+
drain(onText) {
|
|
38
|
+
for (;;) {
|
|
39
|
+
const b = this.buffer
|
|
40
|
+
if (b.length < 2) return
|
|
41
|
+
const fin = (b[0] & 0x80) !== 0
|
|
42
|
+
const opcode = b[0] & 0x0f
|
|
43
|
+
const masked = (b[1] & 0x80) !== 0
|
|
44
|
+
let len = b[1] & 0x7f
|
|
45
|
+
let offset = 2
|
|
46
|
+
if (len === 126) { if (b.length < 4) return; len = b.readUInt16BE(2); offset = 4 } else if (len === 127) { if (b.length < 10) return; len = Number(b.readBigUInt64BE(2)); offset = 10 }
|
|
47
|
+
const maskAt = offset
|
|
48
|
+
if (masked) offset += 4
|
|
49
|
+
if (b.length < offset + len) return
|
|
50
|
+
let payload = b.subarray(offset, offset + len)
|
|
51
|
+
if (masked) { const m = b.subarray(maskAt, maskAt + 4); payload = Buffer.from(payload.map((x, i) => x ^ m[i & 3])) }
|
|
52
|
+
this.buffer = b.subarray(offset + len)
|
|
53
|
+
if (opcode === 0x9) this.write(0xa, payload)
|
|
54
|
+
else if (opcode === 0x8) { this.write(0x8, Buffer.alloc(0)); this.socket.end(); return }
|
|
55
|
+
else if (opcode === 0x1 || opcode === 0x0) {
|
|
56
|
+
this.fragments.push(Buffer.from(payload))
|
|
57
|
+
if (fin) { const text = Buffer.concat(this.fragments).toString('utf8'); this.fragments = []; onText(text) }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
write(opcode, payload) { if (this.open && !this.socket.destroyed) this.socket.write(encode(opcode, payload)) }
|
|
62
|
+
send(text) { this.write(0x1, Buffer.from(text, 'utf8')) }
|
|
63
|
+
close() { if (this.open) { this.write(0x8, Buffer.alloc(0)); this.socket.end() } this.socket.destroy() }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param upgrade - async () => ({ socket, head }): performs the HTTP upgrade to /api/remote.mux on the WSL Host.
|
|
68
|
+
*/
|
|
69
|
+
export class WslMux {
|
|
70
|
+
constructor(upgrade) {
|
|
71
|
+
this.upgrade = upgrade
|
|
72
|
+
this.streams = new Map()
|
|
73
|
+
}
|
|
74
|
+
connect() {
|
|
75
|
+
if (this.socket?.open) return Promise.resolve()
|
|
76
|
+
return (this.connecting ??= (async () => {
|
|
77
|
+
const { socket, head } = await this.upgrade()
|
|
78
|
+
this.socket = new Socket(socket, head, (text) => this.receive(text), () => {
|
|
79
|
+
this.socket = undefined
|
|
80
|
+
for (const inbox of this.streams.values()) inbox.fail(carrierError('The connection to the WSL Host closed.'))
|
|
81
|
+
})
|
|
82
|
+
})().finally(() => { this.connecting = undefined }))
|
|
83
|
+
}
|
|
84
|
+
receive(text) {
|
|
85
|
+
let frame
|
|
86
|
+
try { frame = JSON.parse(text) } catch { return }
|
|
87
|
+
this.streams.get(frame.streamId)?.push(frame)
|
|
88
|
+
}
|
|
89
|
+
/** Open one stream on the WSL Host; yields its items, throws its error. */
|
|
90
|
+
async *open(endpoint, payload, signal, uplink) {
|
|
91
|
+
await this.connect()
|
|
92
|
+
signal?.throwIfAborted()
|
|
93
|
+
const id = randomUUID()
|
|
94
|
+
const inbox = new Inbox()
|
|
95
|
+
this.streams.set(id, inbox)
|
|
96
|
+
const socket = this.socket
|
|
97
|
+
const send = (frame) => socket.send(JSON.stringify({ streamId: id, ...frame }))
|
|
98
|
+
const abort = () => inbox.fail(signal.reason ?? new Error('aborted'))
|
|
99
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
100
|
+
send({ type: 'open', endpoint, payload })
|
|
101
|
+
let stopped = false
|
|
102
|
+
let iterator
|
|
103
|
+
if (uplink) {
|
|
104
|
+
iterator = uplink[Symbol.asyncIterator]()
|
|
105
|
+
void (async () => {
|
|
106
|
+
while (!stopped) {
|
|
107
|
+
const item = await iterator.next()
|
|
108
|
+
if (stopped) break
|
|
109
|
+
if (item.done) { send({ type: 'end' }); break }
|
|
110
|
+
send({ type: 'item', value: item.value })
|
|
111
|
+
}
|
|
112
|
+
})().catch((error) => inbox.fail(error))
|
|
113
|
+
}
|
|
114
|
+
let finished = false
|
|
115
|
+
try {
|
|
116
|
+
for (;;) {
|
|
117
|
+
const frame = await inbox.next()
|
|
118
|
+
if (frame.type === 'item') yield frame.value
|
|
119
|
+
else if (frame.type === 'error') { finished = true; throw remoteError(frame.error) }
|
|
120
|
+
else { finished = true; return }
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
stopped = true
|
|
124
|
+
signal?.removeEventListener('abort', abort)
|
|
125
|
+
if (!finished && socket.open) send({ type: 'cancel' })
|
|
126
|
+
this.streams.delete(id)
|
|
127
|
+
void iterator?.return?.()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
close() { this.socket?.close() }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class Inbox {
|
|
134
|
+
frames = [];
|
|
135
|
+
push(frame) { this.frames.push(frame); this.wake?.() }
|
|
136
|
+
fail(error) { this.error ??= error; this.wake?.() }
|
|
137
|
+
async next() {
|
|
138
|
+
while (!this.frames.length) {
|
|
139
|
+
if (this.error) throw this.error
|
|
140
|
+
await new Promise((resolve) => { this.wake = resolve })
|
|
141
|
+
this.wake = undefined
|
|
142
|
+
}
|
|
143
|
+
return this.frames.shift()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The headers and accept check of a client WebSocket upgrade. */
|
|
148
|
+
export function upgradeHeaders() {
|
|
149
|
+
const key = randomBytes(16).toString('base64')
|
|
150
|
+
return {
|
|
151
|
+
headers: { connection: 'Upgrade', upgrade: 'websocket', 'sec-websocket-version': '13', 'sec-websocket-key': key },
|
|
152
|
+
accept: createHash('sha1').update(key + GUID).digest('base64'),
|
|
153
|
+
}
|
|
154
|
+
}
|
package/routes.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Which WSL Host routes the Desktop Host forwards. Kept free of DSH imports so it can be unit-tested.
|
|
2
|
+
|
|
3
|
+
// Paths DSH itself owns on every Host; never forwarded even when only the WSL Host has them.
|
|
4
|
+
const RESERVED = [/^\/$/, /^\/api(\/|$)/, /^\/plugins(\/|$)/, /^\/connect$/, /^\/wsl-bridge(\/|$)/, /^\/assets(\/|$)/]
|
|
5
|
+
export const isReserved = (path) => RESERVED.some((re) => re.test(path))
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Routes the WSL Host serves and the local Host does not: with the same DSH version and the same official bundles
|
|
9
|
+
* on both sides, these are the routes of plugins installed only in WSL.
|
|
10
|
+
* @param remote - { exact: string[], prefix: string[] } from the WSL agent.
|
|
11
|
+
* @param local - { exact: Set|string[], prefix: Set|string[] } of this Host, without the routes the mirror added.
|
|
12
|
+
* @returns [{ kind, path }] in a stable order.
|
|
13
|
+
*/
|
|
14
|
+
export function routesToForward(remote, local) {
|
|
15
|
+
const out = []
|
|
16
|
+
for (const kind of ['exact', 'prefix']) {
|
|
17
|
+
const mine = new Set(local?.[kind] ?? [])
|
|
18
|
+
for (const path of [...(remote?.[kind] ?? [])].sort()) if (!mine.has(path) && !isReserved(path)) out.push({ kind, path })
|
|
19
|
+
}
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Request headers passed to the WSL Host; cookie, host and origin are replaced with the WSL Host's own.
|
|
24
|
+
export const FORWARD_REQUEST_HEADERS = ['accept', 'accept-language', 'content-type', 'cache-control', 'if-none-match', 'if-modified-since', 'last-event-id', 'range']
|
|
25
|
+
// Response headers passed back; set-cookie is dropped so the WSL Host's cookie never reaches the page.
|
|
26
|
+
export const DROP_RESPONSE_HEADERS = new Set(['set-cookie', 'connection', 'keep-alive', 'transfer-encoding', 'content-encoding', 'content-length'])
|
|
27
|
+
|
|
28
|
+
export const isJson = (type) => typeof type === 'string' && /^application\/(?:[\w.+-]*\+)?json\b/i.test(type.trim())
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A chunk rewriter for a Server-Sent Events body: each `data:` line holding JSON goes through `map`. Lines are cut at
|
|
32
|
+
* line ends, so a partial line waits for the next chunk.
|
|
33
|
+
*/
|
|
34
|
+
export function eventStreamRewriter(map) {
|
|
35
|
+
const decoder = new TextDecoder()
|
|
36
|
+
let pending = ''
|
|
37
|
+
return (chunk) => {
|
|
38
|
+
pending += decoder.decode(chunk, { stream: true })
|
|
39
|
+
const end = pending.lastIndexOf('\n')
|
|
40
|
+
if (end < 0) return Buffer.alloc(0)
|
|
41
|
+
const lines = pending.slice(0, end + 1)
|
|
42
|
+
pending = pending.slice(end + 1)
|
|
43
|
+
return Buffer.from(lines.replace(/^data:( ?)(.*)$/gm, (line, space, data) => {
|
|
44
|
+
try { return `data:${space}${JSON.stringify(map(JSON.parse(data)))}` } catch { return line }
|
|
45
|
+
}))
|
|
46
|
+
}
|
|
47
|
+
}
|