dsh-session-groups 0.0.0-stage → 0.2.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/PUBLISHING.md +133 -0
- package/README.md +160 -2
- package/assets/icon.svg +11 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +2337 -0
- package/lib/index.js +339 -0
- package/package.json +51 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-tree host half.
|
|
3
|
+
*
|
|
4
|
+
* The tree itself is a browser-side sidebar enhancement, but its *data* must
|
|
5
|
+
* outlive site-data cleanup and ports: this half owns a small JSON file under
|
|
6
|
+
* the DSH home and exposes it to the browser over the generic authenticated
|
|
7
|
+
* Connection RPC channel (`/session-tree`).
|
|
8
|
+
*
|
|
9
|
+
* No file is read while the plugin loads; the first `load` call decides
|
|
10
|
+
* whether the browser should hydrate from disk or migrate its existing
|
|
11
|
+
* `localStorage` tree.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-session-groups
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
/** Stable Cordis plugin name. */
|
|
21
|
+
export const name = 'session-tree'
|
|
22
|
+
|
|
23
|
+
/** The shared HTTP server supplies the persistence route. */
|
|
24
|
+
export const inject = ['webServer']
|
|
25
|
+
|
|
26
|
+
const CHANNEL = '/session-tree'
|
|
27
|
+
const FORMAT = 'dsh-session-tree'
|
|
28
|
+
const STATE_VERSION = 1
|
|
29
|
+
const MAX_STATE_BYTES = 4 * 1024 * 1024
|
|
30
|
+
const MAX_WORKSPACES = 2000
|
|
31
|
+
const MAX_GROUPS = 10000
|
|
32
|
+
|
|
33
|
+
let writeChain = Promise.resolve()
|
|
34
|
+
|
|
35
|
+
/** Serialize file operations so two browser tabs cannot interleave writes. */
|
|
36
|
+
function serialize(task) {
|
|
37
|
+
const run = writeChain.then(task, task)
|
|
38
|
+
writeChain = run.catch(() => {})
|
|
39
|
+
return run
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Resolve the same DSH home the rest of the harness uses. */
|
|
43
|
+
function resolveDshHome() {
|
|
44
|
+
const configured = String(process.env.DSH_HOME || '').trim()
|
|
45
|
+
if (configured) {
|
|
46
|
+
if (configured === '~') return homedir()
|
|
47
|
+
if (configured.startsWith('~/') || configured.startsWith('~\\')) {
|
|
48
|
+
return join(homedir(), configured.slice(2))
|
|
49
|
+
}
|
|
50
|
+
return configured
|
|
51
|
+
}
|
|
52
|
+
return join(homedir(), '.dsh')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stateFile() {
|
|
56
|
+
return join(resolveDshHome(), 'storages', 'dsh-session-tree', 'state.json')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function backupFile() {
|
|
60
|
+
return stateFile() + '.bak'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Structural validation only: the browser sanitizes names, parent links and
|
|
65
|
+
* assignments again. This keeps a corrupt or hand-edited file from being
|
|
66
|
+
* written back or served without bound.
|
|
67
|
+
*/
|
|
68
|
+
function assertState(value) {
|
|
69
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
70
|
+
throw new Error('state must be an object')
|
|
71
|
+
}
|
|
72
|
+
if (value.version !== STATE_VERSION) {
|
|
73
|
+
throw new Error('unsupported state version')
|
|
74
|
+
}
|
|
75
|
+
if (value.revision !== undefined && (!Number.isFinite(value.revision) || value.revision < 0)) {
|
|
76
|
+
throw new Error('invalid state revision')
|
|
77
|
+
}
|
|
78
|
+
const workspaces = value.workspaces
|
|
79
|
+
if (!workspaces || typeof workspaces !== 'object' || Array.isArray(workspaces)) {
|
|
80
|
+
throw new Error('state.workspaces must be an object')
|
|
81
|
+
}
|
|
82
|
+
const ids = Object.keys(workspaces)
|
|
83
|
+
if (ids.length > MAX_WORKSPACES) throw new Error('too many workspaces')
|
|
84
|
+
let groups = 0
|
|
85
|
+
for (const id of ids) {
|
|
86
|
+
const workspace = workspaces[id]
|
|
87
|
+
if (!workspace || typeof workspace !== 'object' || Array.isArray(workspace)) {
|
|
88
|
+
throw new Error('invalid workspace entry')
|
|
89
|
+
}
|
|
90
|
+
if (workspace.groups !== undefined && !Array.isArray(workspace.groups)) {
|
|
91
|
+
throw new Error('invalid workspace groups')
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
workspace.assignments !== undefined &&
|
|
95
|
+
(!workspace.assignments || typeof workspace.assignments !== 'object' || Array.isArray(workspace.assignments))
|
|
96
|
+
) {
|
|
97
|
+
throw new Error('invalid workspace assignments')
|
|
98
|
+
}
|
|
99
|
+
groups += Array.isArray(workspace.groups) ? workspace.groups.length : 0
|
|
100
|
+
}
|
|
101
|
+
if (groups > MAX_GROUPS) throw new Error('too many groups')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function readDocument(path) {
|
|
105
|
+
const raw = await readFile(path, 'utf8')
|
|
106
|
+
if (Buffer.byteLength(raw, 'utf8') > MAX_STATE_BYTES) throw new Error('state file is too large')
|
|
107
|
+
const parsed = JSON.parse(raw)
|
|
108
|
+
if (!parsed || parsed.format !== FORMAT || parsed.version !== STATE_VERSION) {
|
|
109
|
+
throw new Error('unsupported state file')
|
|
110
|
+
}
|
|
111
|
+
assertState(parsed.state)
|
|
112
|
+
return {
|
|
113
|
+
state: parsed.state,
|
|
114
|
+
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : null,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function loadStored() {
|
|
119
|
+
try {
|
|
120
|
+
return await readDocument(stateFile())
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error && error.code === 'ENOENT') return { state: null, updatedAt: null }
|
|
123
|
+
console.warn('[dsh-session-tree] could not read host state, trying backup', error)
|
|
124
|
+
try {
|
|
125
|
+
const backup = await readDocument(backupFile())
|
|
126
|
+
console.warn('[dsh-session-tree] recovered host state from backup')
|
|
127
|
+
return backup
|
|
128
|
+
} catch {
|
|
129
|
+
return { state: null, updatedAt: null }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function saveStored(state) {
|
|
135
|
+
assertState(state)
|
|
136
|
+
const file = stateFile()
|
|
137
|
+
const updatedAt = new Date().toISOString()
|
|
138
|
+
const body = JSON.stringify({ format: FORMAT, version: STATE_VERSION, updatedAt, state }, null, 2)
|
|
139
|
+
if (Buffer.byteLength(body, 'utf8') > MAX_STATE_BYTES) throw new Error('state is too large to save')
|
|
140
|
+
|
|
141
|
+
await mkdir(dirname(file), { recursive: true })
|
|
142
|
+
try {
|
|
143
|
+
// Only rotate a readable previous document; a corrupt main file must not
|
|
144
|
+
// replace the last good backup.
|
|
145
|
+
await readDocument(file)
|
|
146
|
+
await copyFile(file, backupFile())
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (!error || error.code !== 'ENOENT') {
|
|
149
|
+
console.warn('[dsh-session-tree] skipped host state backup', error)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const temporary = file + '.' + process.pid + '.' + Date.now().toString(36) + '.tmp'
|
|
154
|
+
await writeFile(temporary, body, 'utf8')
|
|
155
|
+
await rename(temporary, file)
|
|
156
|
+
return updatedAt
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function failure(code, message) {
|
|
160
|
+
return { ok: false, error: { code, message, details: {} } }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const MAX_REQUEST_BYTES = MAX_STATE_BYTES + 64 * 1024
|
|
164
|
+
|
|
165
|
+
/** Whether the Host connection service accepts this request as authenticated. */
|
|
166
|
+
function authorized(req, connection) {
|
|
167
|
+
if (connection && typeof connection.requestRejection === 'function') {
|
|
168
|
+
try {
|
|
169
|
+
return connection.requestRejection(req) === undefined
|
|
170
|
+
} catch {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// No Connection service (for example a non-Web combo): only loopback may
|
|
175
|
+
// read or replace local group data.
|
|
176
|
+
const address = (req.socket && req.socket.remoteAddress) || ''
|
|
177
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Read a bounded request body as UTF-8. */
|
|
181
|
+
function readBody(req) {
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
const chunks = []
|
|
184
|
+
let total = 0
|
|
185
|
+
let settled = false
|
|
186
|
+
const fail = (error) => {
|
|
187
|
+
if (settled) return
|
|
188
|
+
settled = true
|
|
189
|
+
reject(error)
|
|
190
|
+
}
|
|
191
|
+
req.on('data', (chunk) => {
|
|
192
|
+
if (settled) return
|
|
193
|
+
total += chunk.length
|
|
194
|
+
if (total > MAX_REQUEST_BYTES) {
|
|
195
|
+
fail(Object.assign(new Error('request is too large'), { code: 'request-too-large' }))
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
chunks.push(chunk)
|
|
199
|
+
})
|
|
200
|
+
req.on('end', () => {
|
|
201
|
+
if (settled) return
|
|
202
|
+
settled = true
|
|
203
|
+
resolve(Buffer.concat(chunks).toString('utf8'))
|
|
204
|
+
})
|
|
205
|
+
req.on('error', fail)
|
|
206
|
+
req.on('aborted', () => fail(new Error('request aborted')))
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Write one JSON response without leaking connection-level details. */
|
|
211
|
+
function sendJson(res, status, value) {
|
|
212
|
+
const body = JSON.stringify(value)
|
|
213
|
+
res.writeHead(status, {
|
|
214
|
+
'cache-control': 'no-store',
|
|
215
|
+
'content-type': 'application/json; charset=utf-8',
|
|
216
|
+
'content-length': Buffer.byteLength(body, 'utf8'),
|
|
217
|
+
})
|
|
218
|
+
res.end(body)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function handleRpc(req, res, connection) {
|
|
222
|
+
if (req.method !== 'POST') {
|
|
223
|
+
res.writeHead(405, { allow: 'POST', 'cache-control': 'no-store' })
|
|
224
|
+
res.end()
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
if (!authorized(req, connection)) {
|
|
228
|
+
res.writeHead(401, { 'cache-control': 'no-store' })
|
|
229
|
+
res.end('unauthorized')
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
const contentType = String(req.headers['content-type'] || '')
|
|
233
|
+
.split(';', 1)[0]
|
|
234
|
+
.trim()
|
|
235
|
+
.toLowerCase()
|
|
236
|
+
if (contentType !== 'application/json') {
|
|
237
|
+
res.writeHead(415, { 'cache-control': 'no-store' })
|
|
238
|
+
res.end('content type must be application/json')
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let envelope
|
|
243
|
+
try {
|
|
244
|
+
envelope = JSON.parse(await readBody(req))
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error && error.code === 'request-too-large') {
|
|
247
|
+
res.writeHead(413, { 'cache-control': 'no-store' })
|
|
248
|
+
res.end('request is too large')
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
res.writeHead(400, { 'cache-control': 'no-store' })
|
|
252
|
+
res.end('body is not JSON')
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
if (
|
|
256
|
+
!envelope ||
|
|
257
|
+
typeof envelope !== 'object' ||
|
|
258
|
+
envelope.type !== 'client-request' ||
|
|
259
|
+
typeof envelope.rpcId !== 'string' ||
|
|
260
|
+
typeof envelope.method !== 'string'
|
|
261
|
+
) {
|
|
262
|
+
res.writeHead(400, { 'cache-control': 'no-store' })
|
|
263
|
+
res.end('invalid request envelope')
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let result
|
|
268
|
+
if (envelope.method === 'load') {
|
|
269
|
+
try {
|
|
270
|
+
const stored = await serialize(loadStored)
|
|
271
|
+
result = { ok: true, value: stored }
|
|
272
|
+
} catch (error) {
|
|
273
|
+
console.warn('[dsh-session-tree] host load failed', error)
|
|
274
|
+
result = failure('session-tree/load-failed', 'could not read saved groups')
|
|
275
|
+
}
|
|
276
|
+
} else if (envelope.method === 'save') {
|
|
277
|
+
const state = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload.state : undefined
|
|
278
|
+
try {
|
|
279
|
+
assertState(state)
|
|
280
|
+
const savedAt = await serialize(() => saveStored(state))
|
|
281
|
+
result = { ok: true, value: { savedAt } }
|
|
282
|
+
} catch (error) {
|
|
283
|
+
console.warn('[dsh-session-tree] host save failed', error)
|
|
284
|
+
result = failure('session-tree/save-failed', 'could not save groups')
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
result = failure('session-tree/not-found', 'unknown endpoint ' + JSON.stringify(String(envelope.method)))
|
|
288
|
+
}
|
|
289
|
+
sendJson(res, 200, { type: 'server-response', rpcId: envelope.rpcId, result })
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function installRoute(scope, webServer) {
|
|
293
|
+
const dispose = webServer.register({
|
|
294
|
+
kind: 'prefix',
|
|
295
|
+
path: CHANNEL,
|
|
296
|
+
handler: (req, res) => {
|
|
297
|
+
let connection
|
|
298
|
+
try {
|
|
299
|
+
connection = typeof scope.get === 'function' ? scope.get('connection') : scope.connection
|
|
300
|
+
} catch {
|
|
301
|
+
connection = undefined
|
|
302
|
+
}
|
|
303
|
+
void handleRpc(req, res, connection).catch((error) => {
|
|
304
|
+
console.warn('[dsh-session-tree] host route failed', error)
|
|
305
|
+
if (!res.headersSent) {
|
|
306
|
+
res.writeHead(500, { 'cache-control': 'no-store' })
|
|
307
|
+
res.end('internal error')
|
|
308
|
+
} else {
|
|
309
|
+
res.end()
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
return dispose
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Mount the host half. */
|
|
318
|
+
export function apply(ctx) {
|
|
319
|
+
try {
|
|
320
|
+
const webServer = ctx.webServer || (typeof ctx.get === 'function' ? ctx.get('webServer') : undefined)
|
|
321
|
+
if (!webServer || typeof webServer.register !== 'function') {
|
|
322
|
+
console.warn('[dsh-session-tree] web server is unavailable; host persistence is off')
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
const dispose = installRoute(ctx, webServer)
|
|
326
|
+
ctx.effect(
|
|
327
|
+
() => () => {
|
|
328
|
+
try {
|
|
329
|
+
if (typeof dispose === 'function') dispose()
|
|
330
|
+
} catch {
|
|
331
|
+
/* ignore */
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
'session-tree: host persistence route',
|
|
335
|
+
)
|
|
336
|
+
} catch (error) {
|
|
337
|
+
console.warn('[dsh-session-tree] could not register host route', error)
|
|
338
|
+
}
|
|
339
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-groups",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "DSH Web GUI sidebar session tree: group sessions inside each workspace into folders, persisted to disk with import/export.",
|
|
5
|
+
"author": "westformula1",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/westformula1/dsh-session-groups.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/westformula1/dsh-session-groups#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/westformula1/dsh-session-groups/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "lib/index.js",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./lib/index.js",
|
|
19
|
+
"./client": "./lib/client.js",
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"lib",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"PUBLISHING.md",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"assets",
|
|
29
|
+
"cordis.patch.yml"
|
|
30
|
+
],
|
|
31
|
+
"keywords": [
|
|
32
|
+
"dsh-plugin",
|
|
33
|
+
"dsh-bundle",
|
|
34
|
+
"deepseek-harness",
|
|
35
|
+
"session-groups",
|
|
36
|
+
"sidebar",
|
|
37
|
+
"workspace"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"dsh": {
|
|
43
|
+
"engines": {
|
|
44
|
+
"dsh": ">=0.2.2-rc.1 <0.3.0"
|
|
45
|
+
},
|
|
46
|
+
"bundle": {
|
|
47
|
+
"patch": "./cordis.patch.yml"
|
|
48
|
+
},
|
|
49
|
+
"client": {
|
|
50
|
+
"platform": "web"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|