@idoall/dsh-session-colors 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/CHANGELOG.md +76 -0
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/README.zh.md +192 -0
- package/cordis.patch.yml +11 -0
- package/docs/images/color-picker.png +0 -0
- package/docs/images/mobile-drawer.png +0 -0
- package/docs/images/sidebar-chips.png +0 -0
- package/package.json +87 -0
- package/src/client.js +1109 -0
- package/src/index.js +244 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-colour plugin, Host half.
|
|
3
|
+
*
|
|
4
|
+
* This half owns the durable store for colour marks and the two HTTP routes the
|
|
5
|
+
* browser half talks to.
|
|
6
|
+
*
|
|
7
|
+
* Why routes and a file instead of the `settings` service: DSH only host-backs
|
|
8
|
+
* user settings for loopback pages (`persistence = $host.isLoopback ? "host" :
|
|
9
|
+
* "memory"`), so a page opened over the LAN or a tunnel gets a permanently
|
|
10
|
+
* `unavailable` settings scope. Its own routes are not subject to that rule,
|
|
11
|
+
* which is what makes a mark set on one device visible on another.
|
|
12
|
+
*
|
|
13
|
+
* A mark is only a colour keyed by Session id. This half never touches Session
|
|
14
|
+
* identity, history, membership, ordering or archive records.
|
|
15
|
+
*
|
|
16
|
+
* Everything is defensive: a Host half that throws can take the Host's boot
|
|
17
|
+
* down with it, so every step degrades to "no durable store" instead.
|
|
18
|
+
*/
|
|
19
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { dirname, isAbsolute, join } from 'node:path'
|
|
21
|
+
import z from '@deepseek-ai/schemastery'
|
|
22
|
+
|
|
23
|
+
/** Mount name, kept in sync with package.json and cordis.patch.yml. */
|
|
24
|
+
export const name = 'dsh-session-colors'
|
|
25
|
+
|
|
26
|
+
/** Host services are acquired inside `apply`; nothing must exist beforehand. */
|
|
27
|
+
export const inject = []
|
|
28
|
+
|
|
29
|
+
/** Route prefix owned by this plugin; the browser half uses the same constant. */
|
|
30
|
+
export const ROUTE_PREFIX = '/plugins/dsh-session-colors'
|
|
31
|
+
|
|
32
|
+
/** Request bodies are tiny: a Session id and four numbers. */
|
|
33
|
+
const BODY_CAP = 256 * 1024
|
|
34
|
+
|
|
35
|
+
/** One HSVA colour. Alpha is kept because the picker offers transparency. */
|
|
36
|
+
const ColorSchema = z.object({
|
|
37
|
+
h: z.number().min(0).max(360),
|
|
38
|
+
s: z.number().min(0).max(1),
|
|
39
|
+
v: z.number().min(0).max(1),
|
|
40
|
+
a: z.number().min(0).max(1),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/** The durable document: Session id → colour. `dict` because ids are dynamic. */
|
|
44
|
+
export const MarksSchema = z.object({
|
|
45
|
+
marks: z.dict(ColorSchema).default({}),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Plugin config. `dataDir` is supplied by the profile owner, exactly as
|
|
50
|
+
* `dsh-notify` does it: the plugin never guesses a profile directory.
|
|
51
|
+
*/
|
|
52
|
+
export const Config = z.object({
|
|
53
|
+
dataDir: z.string().description('Absolute path to the profile-owned dsh-session-colors data directory.').required(false),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/** JSON response helper, matching the Host's own no-store convention. */
|
|
57
|
+
function sendJson(res, status, payload) {
|
|
58
|
+
res.statusCode = status
|
|
59
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
60
|
+
res.setHeader('cache-control', 'no-store')
|
|
61
|
+
res.end(JSON.stringify(payload))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A write must come from the page that owns this app. The LAN reverse proxy
|
|
66
|
+
* rewrites Origin/Host to loopback before forwarding, so a legitimate remote
|
|
67
|
+
* write still satisfies this check.
|
|
68
|
+
*/
|
|
69
|
+
function sameOriginRequest(req) {
|
|
70
|
+
const site = String(req.headers?.['sec-fetch-site'] ?? '').toLowerCase()
|
|
71
|
+
if (site && site !== 'same-origin' && site !== 'same-site' && site !== 'none') return false
|
|
72
|
+
const origin = req.headers?.origin
|
|
73
|
+
if (!origin) return true
|
|
74
|
+
try {
|
|
75
|
+
return new URL(String(origin)).host === req.headers?.host
|
|
76
|
+
} catch {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read a JSON object body, refusing anything oversized or mislabelled. */
|
|
82
|
+
async function readJson(req, limit = BODY_CAP) {
|
|
83
|
+
const contentType = String(req.headers?.['content-type'] ?? '').split(';', 1)[0].trim().toLowerCase()
|
|
84
|
+
if (contentType !== 'application/json') throw Object.assign(new Error('content-type must be application/json'), { status: 415 })
|
|
85
|
+
const declared = Number(req.headers?.['content-length'] ?? 0)
|
|
86
|
+
if (Number.isFinite(declared) && declared > limit) throw Object.assign(new Error('request body too large'), { status: 413 })
|
|
87
|
+
const chunks = []
|
|
88
|
+
let size = 0
|
|
89
|
+
for await (const chunk of req) {
|
|
90
|
+
size += chunk.byteLength
|
|
91
|
+
if (size > limit) throw Object.assign(new Error('request body too large'), { status: 413 })
|
|
92
|
+
chunks.push(chunk)
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const value = JSON.parse(Buffer.concat(chunks, size).toString('utf8'))
|
|
96
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('body must be an object')
|
|
97
|
+
return value
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw Object.assign(error, { status: 400 })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Keep only well-formed entries so one bad record cannot break the layer. */
|
|
104
|
+
function cleanMarks(input) {
|
|
105
|
+
const marks = {}
|
|
106
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) return marks
|
|
107
|
+
for (const id of Object.keys(input)) {
|
|
108
|
+
const color = input[id]
|
|
109
|
+
if (color === null || typeof color !== 'object') continue
|
|
110
|
+
if (typeof color.h !== 'number' || typeof color.s !== 'number' || typeof color.v !== 'number' || typeof color.a !== 'number') continue
|
|
111
|
+
marks[id] = { h: color.h, s: color.s, v: color.v, a: color.a }
|
|
112
|
+
}
|
|
113
|
+
return marks
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The durable marks store: one JSON document under the profile's data
|
|
118
|
+
* directory, written atomically. Without a usable `dataDir` it still serves the
|
|
119
|
+
* process's own view, so the feature degrades instead of disappearing.
|
|
120
|
+
* @param dataDir - absolute profile-owned directory, or undefined.
|
|
121
|
+
*/
|
|
122
|
+
export function createMarksFile(dataDir) {
|
|
123
|
+
const file = typeof dataDir === 'string' && isAbsolute(dataDir) ? join(dataDir, 'session-colors.json') : null
|
|
124
|
+
let marks = {}
|
|
125
|
+
if (file !== null) {
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
128
|
+
marks = cleanMarks(parsed?.marks)
|
|
129
|
+
} catch {
|
|
130
|
+
// First run, or a file we are about to overwrite.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
get: () => ({ ...marks }),
|
|
135
|
+
/**
|
|
136
|
+
* Apply one change and persist the whole document.
|
|
137
|
+
* @param sessionId - Session the mark belongs to.
|
|
138
|
+
* @param color - the colour, or undefined to clear the mark.
|
|
139
|
+
*/
|
|
140
|
+
set(sessionId, color) {
|
|
141
|
+
const next = { ...marks }
|
|
142
|
+
if (color === undefined) delete next[sessionId]
|
|
143
|
+
else next[sessionId] = color
|
|
144
|
+
marks = cleanMarks(next)
|
|
145
|
+
if (file === null) return { ...marks }
|
|
146
|
+
try {
|
|
147
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
148
|
+
const temporary = `${file}.tmp`
|
|
149
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, marks }, null, 2)}\n`, 'utf8')
|
|
150
|
+
renameSync(temporary, file)
|
|
151
|
+
} catch {
|
|
152
|
+
// A read-only profile still gets the change for this process.
|
|
153
|
+
}
|
|
154
|
+
return { ...marks }
|
|
155
|
+
},
|
|
156
|
+
/** What `/marks` reports about durability. */
|
|
157
|
+
get status() {
|
|
158
|
+
return { persistence: file === null ? 'session' : 'file' }
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Register the durable store and its routes when the web server is composed.
|
|
165
|
+
* @param ctx - Host context.
|
|
166
|
+
* @param config - plugin config; `dataDir` enables persistence.
|
|
167
|
+
*/
|
|
168
|
+
export function apply(ctx, config = {}) {
|
|
169
|
+
try {
|
|
170
|
+
if (ctx === undefined || ctx === null || typeof ctx.inject !== 'function') return
|
|
171
|
+
const store = createMarksFile(config === null || config === undefined ? undefined : config.dataDir)
|
|
172
|
+
ctx.inject(['webServer', 'connection'], (hostCtx) => {
|
|
173
|
+
try {
|
|
174
|
+
const webServer = hostCtx === undefined || hostCtx === null ? undefined : hostCtx.webServer
|
|
175
|
+
const connection = hostCtx === undefined || hostCtx === null ? undefined : hostCtx.connection
|
|
176
|
+
if (webServer === undefined || webServer === null || typeof webServer.register !== 'function') return
|
|
177
|
+
|
|
178
|
+
const route = (path, handler) => {
|
|
179
|
+
const register = () => webServer.register({
|
|
180
|
+
kind: 'exact',
|
|
181
|
+
path: `${ROUTE_PREFIX}${path}`,
|
|
182
|
+
handler: async (req, res) => {
|
|
183
|
+
// Same fence the shipped authenticated routes use.
|
|
184
|
+
try {
|
|
185
|
+
const rejection = connection === undefined || connection === null ? undefined : connection.requestRejection(req)
|
|
186
|
+
if (rejection !== undefined) {
|
|
187
|
+
res.statusCode = rejection
|
|
188
|
+
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// A fence that cannot answer must not turn into an open door.
|
|
193
|
+
res.statusCode = 403
|
|
194
|
+
res.end('forbidden')
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
await handler(req, res)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
sendJson(res, error?.status ?? 500, { error: error?.status ? error.message : 'internal error' })
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
return typeof ctx.effect === 'function' ? ctx.effect(register, `dsh-session-colors: ${path}`) : register()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
route('/marks', async (req, res) => {
|
|
208
|
+
if (req.method === 'GET') {
|
|
209
|
+
sendJson(res, 200, { marks: store.get(), storage: store.status })
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
if (req.method !== 'POST') {
|
|
213
|
+
res.statusCode = 405
|
|
214
|
+
res.setHeader('allow', 'GET, POST')
|
|
215
|
+
res.end()
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (!sameOriginRequest(req)) {
|
|
219
|
+
res.statusCode = 403
|
|
220
|
+
res.end('forbidden')
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
const body = await readJson(req)
|
|
224
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined
|
|
225
|
+
if (sessionId === undefined || sessionId === '') {
|
|
226
|
+
sendJson(res, 400, { error: 'sessionId is required' })
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
const clearing = body.color === null || body.color === undefined
|
|
230
|
+
const color = clearing ? undefined : cleanMarks({ one: body.color }).one
|
|
231
|
+
if (!clearing && color === undefined) {
|
|
232
|
+
sendJson(res, 400, { error: 'color must be {h,s,v,a}' })
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
sendJson(res, 200, { marks: store.set(sessionId, color), storage: store.status })
|
|
236
|
+
})
|
|
237
|
+
} catch {
|
|
238
|
+
// No routes: the browser half keeps marks for the life of the page.
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
} catch {
|
|
242
|
+
// Never let this half break the Host boot.
|
|
243
|
+
}
|
|
244
|
+
}
|