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/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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }