@cowwo/dsh-lookout 0.5.3 → 0.5.4

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/client/client.js CHANGED
@@ -7293,8 +7293,8 @@ window.__ModuleLoader__.load({
7293
7293
  const ROW_MENU_BODY_SELECTOR = "[data-files-body]";
7294
7294
  const ROW_MENU_ROWS_SELECTOR = 'li[data-files-entry="file"],li[data-files-entry="directory"]';
7295
7295
  const ROW_MENU_PATH_ATTR = "data-files-path";
7296
- /** Same-origin download route: HEAD is our pre-flight, the browser streams the GET. */
7297
- const ROW_MENU_DOWNLOAD_ROUTE = "/api/lookout.download";
7296
+ /** Same-origin ticket route: a signed-in POST mints a one-shot download URL. */
7297
+ const ROW_MENU_TICKET_ROUTE = "/api/lookout.ticket";
7298
7298
  /** Where the current session id is read from, sidebar marker first, storage second. */
7299
7299
  const ROW_MENU_SESSION_ATTR = "data-sidebar-right-session";
7300
7300
  const ROW_MENU_SESSION_KEY = "dsh.sessions.current";
@@ -7331,6 +7331,7 @@ window.__ModuleLoader__.load({
7331
7331
  err401: "登录已过期,请刷新页面重新登录",
7332
7332
  err404: "文件不存在或已被删除",
7333
7333
  err403: "只能下载当前会话工作区内的文件",
7334
+ err410: "下载链接已失效或被用尽,请重新点击下载",
7334
7335
  errOther: "下载失败(HTTP {status})",
7335
7336
  errNetwork: "下载失败,请检查连接",
7336
7337
  errSession: "无法确定当前会话,已取消下载",
@@ -7350,6 +7351,7 @@ window.__ModuleLoader__.load({
7350
7351
  err401: "Your session has expired. Refresh the page and sign in again.",
7351
7352
  err404: "The file no longer exists or was deleted.",
7352
7353
  err403: "You can only download files inside the current session's workspace.",
7354
+ err410: "This download link has expired or was already used — click download again.",
7353
7355
  errOther: "Download failed (HTTP {status}).",
7354
7356
  errNetwork: "Download failed — check your connection.",
7355
7357
  errSession: "Cannot determine the current session — download cancelled.",
@@ -8144,6 +8146,7 @@ window.__ModuleLoader__.load({
8144
8146
  case 401: return translate("err401");
8145
8147
  case 403: return translate("err403");
8146
8148
  case 404: return translate("err404");
8149
+ case 410: return translate("err410");
8147
8150
  default: return translate("errOther", { status: status > 0 ? status : "?" });
8148
8151
  }
8149
8152
  }
@@ -8162,41 +8165,69 @@ window.__ModuleLoader__.load({
8162
8165
  return "";
8163
8166
  }
8164
8167
 
8165
- function downloadUrl(sessionId, path) {
8166
- return ROW_MENU_DOWNLOAD_ROUTE
8167
- + "?sessionId=" + encodeURIComponent(sessionId)
8168
- + "&path=" + encodeURIComponent(path);
8169
- }
8170
-
8171
8168
  /**
8172
- * Download without ever holding the file: HEAD pre-flights the route, then a
8173
- * plain <a download> lets the browser stream its own GET to disk. A failed
8174
- * pre-flight never starts a download, it only explains why.
8169
+ * Ask the host for a one-shot download URL.
8170
+ *
8171
+ * The ticket route is the only place that knows the session workspace, so the
8172
+ * browser never puts a file path into a URL it then hands to a download
8173
+ * manager: the returned link already carries a single-use token.
8174
+ * @param sessionId - current session id.
8175
+ * @param path - absolute path of the row.
8176
+ * @returns {Promise<{url: string, filename: string}|null>} the ticket, or null when refused.
8175
8177
  */
8176
- async function downloadRowFile(path) {
8177
- const sessionId = currentSessionId();
8178
- if (sessionId === "") {
8179
- if (!disposed) showToast(translate("errSession"), false);
8180
- return;
8181
- }
8182
- const url = downloadUrl(sessionId, path);
8178
+ async function requestDownloadTicket(sessionId, path) {
8183
8179
  let response = null;
8184
8180
  try {
8185
- response = await window.fetch(url, { method: "HEAD", credentials: "same-origin" });
8181
+ response = await window.fetch(ROW_MENU_TICKET_ROUTE, {
8182
+ method: "POST",
8183
+ credentials: "same-origin",
8184
+ headers: { "content-type": "application/json" },
8185
+ body: JSON.stringify({ sessionId, path }),
8186
+ });
8186
8187
  } catch (error) {
8187
8188
  if (!disposed) showToast(translate("errNetwork"), false);
8188
- return;
8189
+ return null;
8189
8190
  }
8190
8191
  if (response === null || response.ok !== true) {
8191
8192
  const status = response !== null && typeof response.status === "number" ? response.status : 0;
8192
8193
  if (!disposed) showToast(downloadErrorText(status), false);
8194
+ return null;
8195
+ }
8196
+ let payload = null;
8197
+ try {
8198
+ payload = await response.json();
8199
+ } catch (error) {
8200
+ if (!disposed) showToast(translate("errOther", { status: "?" }), false);
8201
+ return null;
8202
+ }
8203
+ const url = payload !== null && typeof payload === "object" ? payload.url : null;
8204
+ if (typeof url !== "string" || url === "") {
8205
+ if (!disposed) showToast(translate("errOther", { status: "?" }), false);
8206
+ return null;
8207
+ }
8208
+ const name = payload !== null && typeof payload === "object" && typeof payload.filename === "string" && payload.filename !== ""
8209
+ ? payload.filename
8210
+ : fileNameFor(path);
8211
+ return { url, filename: name };
8212
+ }
8213
+
8214
+ /**
8215
+ * Download without ever holding the file: mint a one-shot ticket, then let a
8216
+ * plain <a download> stream the host's own GET to disk. A refused ticket
8217
+ * never starts a download, it only explains why.
8218
+ */
8219
+ async function downloadRowFile(path) {
8220
+ const sessionId = currentSessionId();
8221
+ if (sessionId === "") {
8222
+ if (!disposed) showToast(translate("errSession"), false);
8193
8223
  return;
8194
8224
  }
8195
- if (disposed) return;
8225
+ const ticket = await requestDownloadTicket(sessionId, path);
8226
+ if (ticket === null || disposed) return;
8196
8227
  try {
8197
8228
  const link = document.createElement("a");
8198
- link.href = url;
8199
- link.download = fileNameFor(path);
8229
+ link.href = ticket.url;
8230
+ link.download = ticket.filename;
8200
8231
  link.rel = "noopener";
8201
8232
  link.style.display = "none";
8202
8233
  document.body.appendChild(link);
package/lib/download.js CHANGED
@@ -1,29 +1,60 @@
1
1
  /**
2
- * dsh-lookout · 宿主侧流式下载路由
2
+ * dsh-lookout · 宿主侧票据下载路由
3
3
  *
4
- * 一条精确的 Fetch 路由 `GET|HEAD /api/lookout.download`,把**某个会话 cwd 之内**的
5
- * 一个普通文件流式发给浏览器(内存恒定:createReadStream → Readable.toWeb → Response)。
4
+ * 两条精确的 Fetch 路由:
5
+ * POST /api/lookout.ticket 签发一次性票据(JSON in → JSON out)
6
+ * GET|HEAD /api/lookout.download?ticket=... 用票据换一个限时下载会话
6
7
  *
7
- * 三条硬约束(改代码时别动):
8
- * 1. 路径逐段必须匹配 [A-Za-z0-9_$.-]+,且宿主强制 `/api/` 前缀——所以是
9
- * `/api/lookout.download` 这个字面量,不能把参数拼进路径。
8
+ * 为什么要有票据这一层:老路由把 sessionId/path 直接放在 query 里,任何能发请求的
9
+ * 页面都能拼一条 URL 去读别人工作区里的文件。现在 URL 里只有一个不可猜的随机串,
10
+ * 服务端只存它的 SHA-256,并且第一次 GET 之后就把票据换成"限时 + 限次"的会话。
11
+ *
12
+ * 五条硬约束(改代码时别动):
13
+ * 1. 路径逐段必须匹配 [A-Za-z0-9_$.-]+,且宿主强制 /api/ 前缀——所以是
14
+ * '/api/lookout.download' 这个字面量,不能把参数拼进路径。
10
15
  * 2. **不要把 connection/fs 塞进 lib/index.js 顶层的 inject 数组**:那会让没有 web 载体的
11
16
  * 组合(CLI)一直等不到服务、卡死插件加载。这里用 ctx.inject 局部等待。
12
17
  * 3. 放行判定是"词法归一化 + realpath"两道:只有词法那道挡不住符号链接逃逸。
18
+ * 4. 票据只在服务端以 SHA-256 十六进制形式存在,日志里一个字都不许出现。
19
+ * 5. 清理定时器必须 unref,disposer 必须把它清掉,否则插件卸载后进程退不出去。
13
20
  *
14
- * 错误码:缺参/非绝对路径 400;越界或非普通文件 403;不存在 404;其它 500(只 warn 一次,不吐堆栈)。
21
+ * 错误码:
22
+ * 签发:缺参/非绝对路径/JSON 坏掉 400;越界或非普通文件 403;不存在 404;其它 500。
23
+ * 下载:缺 ticket 400;查不到(过期/不存在/已用尽,一律不区分)410;其它同上。
15
24
  */
25
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
16
26
  import { createReadStream, fstat } from 'node:fs'
17
27
  import { realpath } from 'node:fs/promises'
18
28
  import { Readable } from 'node:stream'
19
29
  import { basename, isAbsolute, normalize, sep } from 'node:path'
20
30
 
21
- /** 路由路径:宿主强制 /api/ 前缀,且每段要过 [A-Za-z0-9_$.-]+ 校验。 */
31
+ /** 下载路由路径:宿主强制 /api/ 前缀,且每段要过 [A-Za-z0-9_$.-]+ 校验。 */
22
32
  export const DOWNLOAD_ROUTE_PATH = '/api/lookout.download'
23
33
 
24
34
  /** 同一路由路径的短名(老调用方只认这个名字)。 */
25
35
  export const DOWNLOAD_ROUTE = DOWNLOAD_ROUTE_PATH
26
36
 
37
+ /** 签发路由路径。 */
38
+ export const TICKET_ROUTE_PATH = '/api/lookout.ticket'
39
+
40
+ /** 未兑换票据的存活时间:5 分钟。 */
41
+ export const TICKET_TTL_MS = 5 * 60 * 1000
42
+
43
+ /** 兑换后下载会话的存活时间:10 分钟。 */
44
+ export const SESSION_TTL_MS = 10 * 60 * 1000
45
+
46
+ /** 一个下载会话里允许的 GET 次数(首次兑换那次不计数)。 */
47
+ export const SESSION_QUOTA = 32
48
+
49
+ /** 同一个会话里最多存多少张未兑换票据,超出踢最旧的。 */
50
+ export const MAX_TICKETS_PER_SESSION = 32
51
+
52
+ /** 全局最多存多少条 entry(票据 + 会话共用)。 */
53
+ export const MAX_ENTRIES = 512
54
+
55
+ /** 空闲清理周期。 */
56
+ export const SWEEP_INTERVAL_MS = 60 * 1000
57
+
27
58
  /** 认不出扩展名时一律给这个,避免浏览器瞎猜。 */
28
59
  const DEFAULT_CONTENT_TYPE = 'application/octet-stream'
29
60
 
@@ -86,7 +117,7 @@ export function asciiFileName(fileName) {
86
117
  */
87
118
  export function contentDisposition(fileName) {
88
119
  const name = String(fileName ?? '')
89
- return `attachment; filename="${asciiFileName(name)}"; filename*=UTF-8''${encodeURIComponent(name || 'download')}`
120
+ return 'attachment; filename="' + asciiFileName(name) + '"; filename*=UTF-8\'\'' + encodeURIComponent(name || 'download')
90
121
  }
91
122
 
92
123
  /**
@@ -114,8 +145,8 @@ function serviceOf(ctx, name) {
114
145
  }
115
146
 
116
147
  /**
117
- * 取会话 cwd。字段名现场确认过:dsh-session 的 Session 上是 `session.header.cwd`
118
- * (SessionHeader.cwd,创建时校验必须是绝对路径字符串);`meta.cwd` / `cwd` 只作兜底。
148
+ * 取会话 cwd。字段名现场确认过:dsh-session 的 Session 上是 session.header.cwd
149
+ * (SessionHeader.cwd,创建时校验必须是绝对路径字符串);meta.cwd / cwd 只作兜底。
119
150
  * @param {object|undefined} session ctx.sessions.get() 的返回值
120
151
  * @returns {string|undefined} 绝对路径 cwd
121
152
  */
@@ -163,18 +194,42 @@ async function realPathOrSelf(path) {
163
194
  }
164
195
 
165
196
  /**
166
- * 统一的错误响应:纯文本 + 禁缓存。
197
+ * 统一的安全响应头。所有出口(含错误页)都要带上这一份。
198
+ * @param {Record<string, string>} [extra] 追加的头
199
+ * @returns {Record<string, string>} 头对象
200
+ */
201
+ function securityHeaders(extra = {}) {
202
+ return {
203
+ 'cache-control': 'no-store',
204
+ 'referrer-policy': 'no-referrer',
205
+ 'x-content-type-options': 'nosniff',
206
+ ...extra,
207
+ }
208
+ }
209
+
210
+ /**
211
+ * 统一的错误响应:纯文本 + 安全头(不吐内部细节)。
167
212
  * @param {number} status HTTP 状态码
168
- * @param {string} message 一行说明(不吐内部细节)
213
+ * @param {string} message 一行说明
169
214
  * @returns {Response} 响应
170
215
  */
171
216
  function errorResponse(status, message) {
172
217
  return new Response(message, {
173
218
  status,
174
- headers: {
175
- 'content-type': 'text/plain; charset=utf-8',
176
- 'cache-control': 'private, no-store',
177
- },
219
+ headers: securityHeaders({ 'content-type': 'text/plain; charset=utf-8' }),
220
+ })
221
+ }
222
+
223
+ /**
224
+ * JSON 响应:签发接口与它的错误都用这一份,保证 content-type 与禁缓存一致。
225
+ * @param {number} status HTTP 状态码
226
+ * @param {unknown} payload 任意可序列化对象
227
+ * @returns {Response} 响应
228
+ */
229
+ function jsonResponse(status, payload) {
230
+ return new Response(JSON.stringify(payload), {
231
+ status,
232
+ headers: securityHeaders({ 'content-type': 'application/json' }),
178
233
  })
179
234
  }
180
235
 
@@ -189,108 +244,517 @@ function isNotFound(error) {
189
244
  }
190
245
 
191
246
  /**
192
- * 造一个下载 handler。
247
+ * 事件日志:只在调用方显式给了 logger 时才写,且只允许"事件 + 文件名"。
248
+ * token / 哈希在任何情况下都不进日志——这一点由调用点的参数结构保证。
249
+ * @param {object} deps 依赖包
250
+ * @returns {(event: string, detail?: string) => void} 记录函数
251
+ */
252
+ function createEventLogger(deps) {
253
+ const sink = typeof deps?.logger === 'function' ? deps.logger : undefined
254
+ return (event, detail) => {
255
+ if (sink === undefined) return
256
+ try {
257
+ sink(event, detail)
258
+ } catch {
259
+ /* 日志不许影响主流程 */
260
+ }
261
+ }
262
+ }
263
+
264
+ // ───────────────────────── 票据 / 会话仓库 ─────────────────────────
265
+
266
+ /**
267
+ * SHA-256(token) 的十六进制。服务端只存这个,原文一出生就丢。
268
+ * @param {string} token 票据原文
269
+ * @returns {string} 64 位小写十六进制
270
+ */
271
+ export function hashToken(token) {
272
+ return createHash('sha256').update(String(token)).digest('hex')
273
+ }
274
+
275
+ /**
276
+ * 常量时间的十六进制比对:长度不等/非十六进制先返回假,再交给 timingSafeEqual。
277
+ * @param {string} actual 候选值
278
+ * @param {string} expected 期望值
279
+ * @returns {boolean} 是否相等
280
+ */
281
+ export function timingSafeEqualHex(actual, expected) {
282
+ if (typeof actual !== 'string' || typeof expected !== 'string') return false
283
+ if (actual.length !== expected.length || actual.length === 0) return false
284
+ const left = Buffer.from(actual, 'hex')
285
+ const right = Buffer.from(expected, 'hex')
286
+ if (left.length === 0 || left.length !== right.length) return false
287
+ return timingSafeEqual(left, right)
288
+ }
289
+
290
+ /**
291
+ * 造一张票据仓库。注入时钟是为了让"过期"能在单测里被推进,而不是靠 sleep。
292
+ *
293
+ * entry 形态(票据期与会话期复用同一个对象,只换 kind):
294
+ * { tokenHash, kind: 'ticket' | 'session', sessionId, path, filename, size,
295
+ * expiresAt, remaining, createdAt }
296
+ *
297
+ * @param {{
298
+ * now?: () => number,
299
+ * createToken?: () => string,
300
+ * ticketTtl?: number,
301
+ * sessionTtl?: number,
302
+ * sessionQuota?: number,
303
+ * maxPerSession?: number,
304
+ * maxEntries?: number,
305
+ * sweepInterval?: number,
306
+ * setInterval?: typeof setInterval,
307
+ * clearInterval?: typeof clearInterval,
308
+ * }} [options] 覆盖项
309
+ * @returns {object} 仓库
310
+ */
311
+ export function createTicketStore(options = {}) {
312
+ const now = typeof options.now === 'function' ? options.now : Date.now
313
+ const ticketTtl = Number.isFinite(options.ticketTtl) ? options.ticketTtl : TICKET_TTL_MS
314
+ const sessionTtl = Number.isFinite(options.sessionTtl) ? options.sessionTtl : SESSION_TTL_MS
315
+ const sessionQuota = Number.isSafeInteger(options.sessionQuota) ? options.sessionQuota : SESSION_QUOTA
316
+ const maxPerSession = Number.isSafeInteger(options.maxPerSession) ? options.maxPerSession : MAX_TICKETS_PER_SESSION
317
+ const maxEntries = Number.isSafeInteger(options.maxEntries) ? options.maxEntries : MAX_ENTRIES
318
+ const sweepInterval = Number.isFinite(options.sweepInterval) ? options.sweepInterval : SWEEP_INTERVAL_MS
319
+ const issueToken = typeof options.createToken === 'function' ? options.createToken : () => randomBytes(32).toString('base64url')
320
+
321
+ /** @type {Map<string, object>} key = SHA-256(token) 十六进制 */
322
+ const entries = new Map()
323
+ let timer
324
+
325
+ /** 清掉所有已过期的 entry。 */
326
+ const sweep = () => {
327
+ const at = now()
328
+ for (const [key, entry] of entries) if (entry.expiresAt <= at) entries.delete(key)
329
+ }
330
+
331
+ /** 第一次签发时才挂周期清理,并立刻 unref:绝不阻止进程退出。 */
332
+ const ensureTimer = () => {
333
+ if (timer !== undefined) return
334
+ const schedule = typeof options.setInterval === 'function' ? options.setInterval : setInterval
335
+ timer = schedule(() => {
336
+ try {
337
+ sweep()
338
+ } catch {
339
+ /* 清理失败不影响请求 */
340
+ }
341
+ }, sweepInterval)
342
+ /** 定时器绝不能拖住进程退出。 */
343
+ timer?.unref?.()
344
+ }
345
+
346
+ /**
347
+ * 按 hash 查一条 entry。过期即删;命中的那一条再用常量时间比一次哈希。
348
+ * @param {string} token 票据原文
349
+ * @returns {object|null} entry 或 null
350
+ */
351
+ const lookup = (token) => {
352
+ if (typeof token !== 'string' || token.length === 0) return null
353
+ const tokenHash = hashToken(token)
354
+ const entry = entries.get(tokenHash)
355
+ if (entry === undefined) return null
356
+ // Map 已经保证键相等,这里再比一次:把"比 token"这件事锁在常量时间实现里,
357
+ // 而不是依赖 Map 的内部实现。
358
+ if (!timingSafeEqualHex(tokenHash, entry.tokenHash)) return null
359
+ if (entry.expiresAt <= now()) {
360
+ entries.delete(entry.tokenHash)
361
+ return null
362
+ }
363
+ return entry
364
+ }
365
+
366
+ /**
367
+ * 踢掉指定会话里最旧的未兑换票据,直到低于配额。
368
+ * @param {string} sessionId 会话 id
369
+ */
370
+ const trimSessionTickets = (sessionId) => {
371
+ const keys = []
372
+ for (const [key, entry] of entries) {
373
+ if (entry.kind === 'ticket' && entry.sessionId === sessionId) keys.push(key)
374
+ }
375
+ while (keys.length >= maxPerSession) entries.delete(keys.shift())
376
+ }
377
+
378
+ /** 全局超限时按插入顺序踢最旧的一条。 */
379
+ const trimGlobal = () => {
380
+ while (entries.size >= maxEntries) {
381
+ const oldest = entries.keys().next()
382
+ if (oldest.done === true) return
383
+ entries.delete(oldest.value)
384
+ }
385
+ }
386
+
387
+ return {
388
+ /**
389
+ * 签一张票据。返回原文(只在这里出现一次)与过期时间。
390
+ * @param {{sessionId: string, path: string, filename: string, size?: number}} input 绑定信息
391
+ * @returns {{token: string, expiresAt: number}} 签发结果
392
+ */
393
+ issue(input) {
394
+ ensureTimer()
395
+ sweep()
396
+ const token = issueToken()
397
+ const tokenHash = hashToken(token)
398
+ trimSessionTickets(input.sessionId)
399
+ trimGlobal()
400
+ const createdAt = now()
401
+ entries.set(tokenHash, {
402
+ tokenHash,
403
+ kind: 'ticket',
404
+ sessionId: input.sessionId,
405
+ path: input.path,
406
+ filename: input.filename,
407
+ size: input.size,
408
+ expiresAt: createdAt + ticketTtl,
409
+ remaining: 0,
410
+ createdAt,
411
+ })
412
+ return { token, expiresAt: createdAt + ticketTtl }
413
+ },
414
+
415
+ /**
416
+ * 只看不动(HEAD 用):过期/不存在都给 null,且不消费、不建会话。
417
+ * @param {string} token 票据原文
418
+ * @returns {object|null} entry 或 null
419
+ */
420
+ peek: lookup,
421
+
422
+ /**
423
+ * 原子地"消费票据 → 建下载会话",或在既有会话里扣一次配额。
424
+ *
425
+ * - 票据期:kind 改成 'session'、续 10 分钟、remaining = 配额(本次不扣)。
426
+ * - 会话期:remaining > 0 则 -1;扣到 0 立刻删掉(之后 410)。
427
+ * - 过期 / 不存在 / 已用尽:null。
428
+ *
429
+ * 整个函数是同步的,Node 单线程下天然原子:不会出现"两个请求各拿到一半配额"。
430
+ * @param {string} token 票据原文
431
+ * @returns {object|null} 可用的 entry 或 null
432
+ */
433
+ openForRead(token) {
434
+ const entry = lookup(token)
435
+ if (entry === null) return null
436
+ if (entry.kind === 'ticket') {
437
+ entry.kind = 'session'
438
+ entry.expiresAt = now() + sessionTtl
439
+ entry.remaining = sessionQuota
440
+ // 配额为 0 表示"只允许这一次":本次请求照常放行,但立刻让这条链接失效,
441
+ // 免得它还能被 HEAD 探到(严格一次性场景)。
442
+ if (sessionQuota === 0) entries.delete(entry.tokenHash)
443
+ return entry
444
+ }
445
+ if (entry.remaining > 0) {
446
+ entry.remaining -= 1
447
+ if (entry.remaining === 0) entries.delete(entry.tokenHash)
448
+ return entry
449
+ }
450
+ entries.delete(entry.tokenHash)
451
+ return null
452
+ },
453
+
454
+ /**
455
+ * 当前 entry 数量(诊断用)。
456
+ * @returns {number} 条数
457
+ */
458
+ size() {
459
+ return entries.size
460
+ },
461
+
462
+ /** 清定时器 + 清数据;重复调用无害。 */
463
+ dispose() {
464
+ if (timer !== undefined) {
465
+ const clear = typeof options.clearInterval === 'function' ? options.clearInterval : clearInterval
466
+ try {
467
+ clear(timer)
468
+ } catch {
469
+ /* 已经清过了 */
470
+ }
471
+ timer = undefined
472
+ }
473
+ entries.clear()
474
+ },
475
+ }
476
+ }
477
+
478
+ // ───────────────────────── Range ─────────────────────────
479
+
480
+ /**
481
+ * 解析 Range 头。
482
+ *
483
+ * 只认 bytes=;支持 start-end / start- / -suffix。
484
+ * 多段(含逗号)、越界、格式非法 → unsatisfiable(调用方回 416 + bytes * /size)。
485
+ * 其它单位(如 items=)按 RFC 7233 忽略,当没带 Range 处理。
486
+ *
487
+ * @param {string|null|undefined} header Range 头原文
488
+ * @param {number|undefined} size 文件总字节数;拿不到按 0 算(任何区间都不满足)
489
+ * @returns {{kind: 'none'}|{kind: 'range', start: number, end: number}|{kind: 'unsatisfiable'}} 解析结果
490
+ */
491
+ export function parseRange(header, size) {
492
+ if (header === null || header === undefined) return { kind: 'none' }
493
+ const raw = String(header).trim()
494
+ if (raw.length === 0) return { kind: 'none' }
495
+ const eq = raw.indexOf('=')
496
+ if (eq < 0) return { kind: 'unsatisfiable' }
497
+ if (raw.slice(0, eq).trim().toLowerCase() !== 'bytes') return { kind: 'none' }
498
+ const spec = raw.slice(eq + 1).trim()
499
+ if (spec.length === 0) return { kind: 'unsatisfiable' }
500
+ // 多段:契约要求 416(客户端本来就是分段多线程下载,用不到多段响应)。
501
+ if (spec.includes(',')) return { kind: 'unsatisfiable' }
502
+ const match = /^(\d*)-(\d*)$/.exec(spec)
503
+ if (match === null) return { kind: 'unsatisfiable' }
504
+ const startText = match[1]
505
+ const endText = match[2]
506
+ if (startText.length === 0 && endText.length === 0) return { kind: 'unsatisfiable' }
507
+ const total = typeof size === 'number' && Number.isFinite(size) && size >= 0 ? size : 0
508
+ if (startText.length === 0) {
509
+ const suffix = Number(endText)
510
+ if (!Number.isSafeInteger(suffix) || suffix <= 0) return { kind: 'unsatisfiable' }
511
+ if (total === 0) return { kind: 'unsatisfiable' }
512
+ return { kind: 'range', start: Math.max(0, total - suffix), end: total - 1 }
513
+ }
514
+ const start = Number(startText)
515
+ if (!Number.isSafeInteger(start) || start < 0 || start >= total) return { kind: 'unsatisfiable' }
516
+ let end = total - 1
517
+ if (endText.length > 0) {
518
+ const parsed = Number(endText)
519
+ if (!Number.isSafeInteger(parsed) || parsed < start) return { kind: 'unsatisfiable' }
520
+ end = parsed > total - 1 ? total - 1 : parsed
521
+ }
522
+ return { kind: 'range', start, end }
523
+ }
524
+
525
+ // ───────────────────────── 路径放行 ─────────────────────────
526
+
527
+ /**
528
+ * 把"会话 cwd + 一个绝对路径"变成可流式打开的宿主机真实路径。
529
+ * 两道闸:词法归一化(挡 ..),realpath(挡符号链接逃逸)。
193
530
  *
194
- * deps 只为一件事存在:单测要数"到底有没有真去读文件"。生产路径不传,用 node:fs 的真身。
195
- * @param {object} ctx 上下文(真实 ctx / inject 回调里的 scoped / {fs, sessions} 直给对象)
196
- * @param {{createReadStream?: typeof createReadStream}} [deps] 可替换的底层流工厂
531
+ * @param {object} ctx 上下文
532
+ * @param {{sessionId: string, requestedPath: string, signal?: AbortSignal}} options 请求要素
533
+ * @returns {Promise<{ok: true, hostPath: string, fileName: string, size?: number}
534
+ * | {ok: false, response: Response}>} 结果或现成的错误响应
535
+ */
536
+ async function resolveTarget(ctx, options) {
537
+ const sessions = serviceOf(ctx, 'sessions')
538
+ if (sessions === undefined) return { ok: false, response: errorResponse(500, 'download unavailable: missing sessions service') }
539
+
540
+ // —— 403:会话或它的 cwd 拿不到,就没有边界可谈 ——
541
+ const cwd = sessionWorkspace(sessions, options.sessionId)
542
+ if (cwd === null) return { ok: false, response: errorResponse(403, 'session cwd unavailable') }
543
+
544
+ // —— 第一道:词法归一化(挡 .. 这类纯文本穿越) ——
545
+ const root = normalize(cwd)
546
+ const candidate = normalize(options.requestedPath)
547
+ if (!withinRoot(root, candidate)) return { ok: false, response: errorResponse(403, 'path outside session cwd') }
548
+
549
+ const fs = serviceOf(ctx, 'fs')
550
+ if (typeof fs?.resolve !== 'function' || typeof fs?.stat !== 'function' || typeof fs?.processPath !== 'function') {
551
+ return { ok: false, response: errorResponse(500, 'download unavailable: missing fs service') }
552
+ }
553
+
554
+ let target
555
+ let rootTarget
556
+ try {
557
+ target = await fs.resolve(candidate, { signal: options.signal })
558
+ rootTarget = await fs.resolve(root, { signal: options.signal })
559
+ } catch (error) {
560
+ if (isNotFound(error)) return { ok: false, response: errorResponse(404, 'file not found') }
561
+ throw error
562
+ }
563
+
564
+ // —— 第二道:realpath 之后仍在范围内(挡符号链接逃逸) ——
565
+ // 双保险:既信后端的 processPath(本地后端本身就是 realpath 结果),
566
+ // 也自己 realpath 一次,后端换成非 realpath 的实现时这道闸照样有效。
567
+ const realRoot = String(fs.processPath(rootTarget))
568
+ const hostPath = String(fs.processPath(target))
569
+ const [realRootPath, realTargetPath] = await Promise.all([realPathOrSelf(realRoot), realPathOrSelf(hostPath)])
570
+ if (!withinRoot(realRootPath, realTargetPath)) {
571
+ return { ok: false, response: errorResponse(403, 'path outside session cwd after realpath') }
572
+ }
573
+
574
+ let info
575
+ try {
576
+ info = await fs.stat(target, options.signal)
577
+ } catch (error) {
578
+ if (isNotFound(error) || error?.code === 'ENOTDIR' || error?.code === 'EISDIR') {
579
+ return { ok: false, response: errorResponse(404, 'file not found') }
580
+ }
581
+ throw error
582
+ }
583
+ if (info === undefined) return { ok: false, response: errorResponse(404, 'file not found') }
584
+ if (info.type !== 'file') return { ok: false, response: errorResponse(403, 'not a regular file') }
585
+
586
+ // 后端不保证给 size;缺了就留 undefined,让调用方决定"不写长度"还是 500。
587
+ const size = typeof info.size === 'number' && Number.isFinite(info.size) && info.size >= 0 ? info.size : undefined
588
+ return { ok: true, hostPath: realTargetPath, fileName: basename(candidate), size }
589
+ }
590
+
591
+ /**
592
+ * 给流挂两个保护:打开后复核大小、错误日志过滤掉客户端取消。
593
+ * @param {import('node:stream').Readable} stream 文件流
594
+ * @param {number|undefined} declaredSize 之前 stat 出来的总大小
595
+ * @returns {void}
596
+ */
597
+ function guardStream(stream, declaredSize) {
598
+ stream.once('open', (fd) => {
599
+ if (declaredSize === undefined) return
600
+ try {
601
+ fstat(fd, (error, stats) => {
602
+ // stat 与 open 之间文件被换掉:宁可断流,也不发一份长度对不上的响应。
603
+ if (error === null && stats !== undefined && stats.size !== declaredSize) {
604
+ stream.destroy(new Error('size changed while downloading'))
605
+ }
606
+ })
607
+ } catch {
608
+ /* best effort */
609
+ }
610
+ })
611
+ stream.on('error', (error) => {
612
+ const code = error?.code ?? ''
613
+ // 客户端主动取消/连接被掐断是常态,不该刷日志。
614
+ if (code === 'ERR_STREAM_PREMATURE_CLOSE' || code === 'ECONNRESET' || code === 'EPIPE' || error?.name === 'AbortError') return
615
+ console.warn('[dsh-lookout] download stream failed:', code !== '' ? code : (error?.message ?? error))
616
+ })
617
+ }
618
+
619
+ // ───────────────────────── handlers ─────────────────────────
620
+
621
+ /**
622
+ * 造签发 handler:校验 → 放行 → stat → 签票。
623
+ *
624
+ * @param {object} ctx 上下文
625
+ * @param {{store?: object, now?: () => number, logger?: Function}} [deps] 依赖(store 供测试注入)
197
626
  * @returns {(request: Request) => Promise<Response>} Fetch handler
198
627
  */
199
- export function createDownloadHandler(ctx, deps = {}) {
200
- const openStream = typeof deps.createReadStream === 'function' ? deps.createReadStream : createReadStream
628
+ export function createTicketHandler(ctx, deps = {}) {
629
+ const store = deps.store ?? createTicketStore({ now: deps.now })
630
+ const log = createEventLogger(deps)
201
631
 
202
- return async function handleDownload(request) {
632
+ return async function handleTicket(request) {
203
633
  try {
204
- const signal = request.signal
205
- const params = new URL(request.url).searchParams
206
- const sessionId = params.get('sessionId')
207
- const requestedPath = params.get('path')
634
+ let payload
635
+ try {
636
+ payload = await request.json()
637
+ } catch {
638
+ return jsonResponse(400, { error: 'invalid json' })
639
+ }
640
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
641
+ return jsonResponse(400, { error: 'invalid body' })
642
+ }
208
643
 
644
+ const sessionId = payload.sessionId
645
+ const requestedPath = payload.path
209
646
  // —— 400:参数这一关先过,不合法就不碰文件系统 ——
210
- if (sessionId === null || sessionId.length === 0) return errorResponse(400, 'missing sessionId')
211
- if (requestedPath === null || requestedPath.length === 0) return errorResponse(400, 'missing path')
212
- if (!isAbsolute(requestedPath)) return errorResponse(400, 'path must be absolute')
647
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return jsonResponse(400, { error: 'missing sessionId' })
648
+ if (typeof requestedPath !== 'string' || requestedPath.length === 0) return jsonResponse(400, { error: 'missing path' })
649
+ if (!isAbsolute(requestedPath)) return jsonResponse(400, { error: 'path must be absolute' })
213
650
  // 尾随分隔符是"目录写法":按"没有这个文件"处理,别让它走到 stat 再变成 500。
214
- if (/[\\/]$/.test(requestedPath)) return errorResponse(404, 'file not found')
651
+ if (/[\\/]$/.test(requestedPath)) return jsonResponse(404, { error: 'file not found' })
652
+
653
+ const resolved = await resolveTarget(ctx, { sessionId, requestedPath, signal: request.signal })
654
+ if (resolved.ok !== true) return resolved.response
215
655
 
216
- const sessions = serviceOf(ctx, 'sessions')
217
- if (sessions === undefined) return errorResponse(500, 'download unavailable: missing sessions service')
656
+ // Range 需要确切大小,签票阶段就定死;拿不到就别签,免得后面算错区间。
657
+ if (typeof resolved.size !== 'number') return jsonResponse(500, { error: 'file size unavailable' })
218
658
 
219
- // —— 403:会话或它的 cwd 拿不到,就没有边界可谈 ——
220
- const cwd = sessionWorkspace(sessions, sessionId)
221
- if (cwd === null) return errorResponse(403, 'session cwd unavailable')
659
+ const issued = store.issue({
660
+ sessionId,
661
+ path: resolved.hostPath,
662
+ filename: resolved.fileName,
663
+ size: resolved.size,
664
+ })
665
+ // 日志只写事件 + 文件名;token / 哈希绝不进日志。
666
+ log('ticket.issued', resolved.fileName)
667
+ return jsonResponse(200, {
668
+ url: DOWNLOAD_ROUTE_PATH + '?ticket=' + issued.token,
669
+ expiresAt: issued.expiresAt,
670
+ filename: resolved.fileName,
671
+ size: resolved.size,
672
+ })
673
+ } catch (error) {
674
+ console.warn('[dsh-lookout] ticket route failed:', error?.message ?? error)
675
+ return jsonResponse(500, { error: 'ticket failed' })
676
+ }
677
+ }
678
+ }
222
679
 
223
- // —— 第一道:词法归一化(挡 .. 这类纯文本穿越) ——
224
- const root = normalize(cwd)
225
- const candidate = normalize(requestedPath)
226
- if (!withinRoot(root, candidate)) return errorResponse(403, 'path outside session cwd')
680
+ /**
681
+ * 造下载 handler:票据 → 会话 → Range → 流。
682
+ *
683
+ * deps 只为一件事存在:单测要数"到底有没有真去读文件"、要注入时钟与仓库。
684
+ * 生产路径不传,用 node:fs 的真身。
685
+ *
686
+ * @param {object} ctx 上下文
687
+ * @param {{createReadStream?: typeof createReadStream, store?: object, now?: () => number, logger?: Function}} [deps] 依赖
688
+ * @returns {(request: Request) => Promise<Response>} Fetch handler
689
+ */
690
+ export function createDownloadHandler(ctx, deps = {}) {
691
+ const openStream = typeof deps.createReadStream === 'function' ? deps.createReadStream : createReadStream
692
+ const store = deps.store ?? createTicketStore({ now: deps.now })
693
+ const log = createEventLogger(deps)
227
694
 
228
- const fs = serviceOf(ctx, 'fs')
229
- if (typeof fs?.resolve !== 'function' || typeof fs?.stat !== 'function' || typeof fs?.processPath !== 'function') {
230
- return errorResponse(500, 'download unavailable: missing fs service')
231
- }
695
+ return async function handleDownload(request) {
696
+ try {
697
+ const params = new URL(request.url).searchParams
698
+ const token = params.get('ticket')
699
+ // 只认 ticket:sessionId / path 一概不看,杜绝"用票据绕过边界"这条路。
700
+ if (token === null || token.length === 0) return errorResponse(400, 'missing ticket')
232
701
 
233
- let target
234
- let rootTarget
235
- try {
236
- target = await fs.resolve(candidate, { signal })
237
- rootTarget = await fs.resolve(root, { signal })
238
- } catch (error) {
239
- if (isNotFound(error)) return errorResponse(404, 'file not found')
240
- throw error
241
- }
702
+ // 先只看不动:票据不存在/过期/用尽一律 410,且不区分原因(防枚举)。
703
+ const peeked = store.peek(token)
704
+ if (peeked === null) return errorResponse(410, 'ticket gone')
242
705
 
243
- // —— 第二道:realpath 之后仍在范围内(挡符号链接逃逸) ——
244
- // 双保险:既信后端的 processPath(本地后端本身就是 realpath 结果),
245
- // 也自己 realpath 一次,后端换成非 realpath 的实现时这道闸照样有效。
246
- const realRoot = String(fs.processPath(rootTarget))
247
- const hostPath = String(fs.processPath(target))
248
- const [realRootPath, realTargetPath] = await Promise.all([realPathOrSelf(realRoot), realPathOrSelf(hostPath)])
249
- if (!withinRoot(realRootPath, realTargetPath)) return errorResponse(403, 'path outside session cwd after realpath')
706
+ const resolved = await resolveTarget(ctx, {
707
+ sessionId: peeked.sessionId,
708
+ requestedPath: peeked.path,
709
+ signal: request.signal,
710
+ })
711
+ if (resolved.ok !== true) return resolved.response
250
712
 
251
- let info
252
- try {
253
- info = await fs.stat(target, signal)
254
- } catch (error) {
255
- if (isNotFound(error) || error?.code === 'ENOTDIR' || error?.code === 'EISDIR') return errorResponse(404, 'file not found')
256
- throw error
713
+ // GET 才消费;HEAD 只读头,不动配额、不建会话。
714
+ let entry = peeked
715
+ if (request.method === 'HEAD') {
716
+ if (peeked.kind !== 'ticket') log('download.head', peeked.filename)
717
+ } else {
718
+ const opened = store.openForRead(token)
719
+ if (opened === null) return errorResponse(410, 'ticket gone')
720
+ entry = opened
721
+ log('download.started', entry.filename)
257
722
  }
258
- if (info === undefined) return errorResponse(404, 'file not found')
259
- if (info.type !== 'file') return errorResponse(403, 'not a regular file')
260
723
 
261
- const fileName = basename(candidate)
262
- const headers = {
724
+ const fileName = resolved.fileName.length > 0 ? resolved.fileName : (entry.filename ?? 'download')
725
+ const size = resolved.size
726
+ const headers = securityHeaders({
263
727
  'content-type': contentTypeOf(fileName),
264
728
  'content-disposition': contentDisposition(fileName),
265
- 'cache-control': 'private, no-store',
729
+ 'accept-ranges': 'bytes',
730
+ })
731
+
732
+ const range = parseRange(request.headers.get('range'), size)
733
+ if (range.kind === 'unsatisfiable') {
734
+ return new Response(request.method === 'HEAD' ? null : 'range not satisfiable', {
735
+ status: 416,
736
+ headers: securityHeaders({
737
+ 'content-type': 'text/plain; charset=utf-8',
738
+ 'accept-ranges': 'bytes',
739
+ 'content-range': 'bytes */' + (typeof size === 'number' ? size : 0),
740
+ }),
741
+ })
266
742
  }
267
- // 后端不保证给 size;缺了就不写 content-length(宁可不写,也不写一个假的 0)。
268
- let declaredSize = 0
269
- if (typeof info.size === 'number' && Number.isFinite(info.size) && info.size >= 0) {
270
- declaredSize = info.size
271
- headers['content-length'] = String(info.size)
743
+ if (range.kind === 'range') {
744
+ headers['content-range'] = 'bytes ' + range.start + '-' + range.end + '/' + size
745
+ headers['content-length'] = String(range.end - range.start + 1)
746
+ // HEAD 带 Range:同样的 206 + 区间头,body 为空,且**不打开文件**。
747
+ if (request.method === 'HEAD') return new Response(null, { status: 206, headers })
748
+ const stream = openStream(resolved.hostPath, { start: range.start, end: range.end })
749
+ guardStream(stream, size)
750
+ return new Response(Readable.toWeb(stream), { status: 206, headers })
272
751
  }
273
752
 
274
- // HEAD 只要头:同样的 status + headers,body 必须为空,且**不打开文件**。
753
+ // 无 Range:全文件 200(保持现状);size 未知就不写 content-length。
754
+ if (typeof size === 'number') headers['content-length'] = String(size)
275
755
  if (request.method === 'HEAD') return new Response(null, { status: 200, headers })
276
-
277
- const stream = openStream(realTargetPath)
278
- // 打开后用同一个 fd 复核一次大小:stat 与 open 之间被换掉的极端情况,
279
- // 宁可断流也不要发出一份长度对不上的响应。
280
- stream.once('open', (fd) => {
281
- if (declaredSize === 0) return
282
- try {
283
- fstat(fd, (error, stats) => {
284
- if (error === null && stats !== undefined && stats.size !== declaredSize) stream.destroy(new Error('size changed while downloading'))
285
- })
286
- } catch { /* best effort */ }
287
- })
288
- stream.on('error', (error) => {
289
- const code = error?.code ?? ''
290
- // 客户端主动取消/连接被掐断是常态,不该刷日志。
291
- if (code === 'ERR_STREAM_PREMATURE_CLOSE' || code === 'ECONNRESET' || code === 'EPIPE' || error?.name === 'AbortError') return
292
- console.warn('[dsh-lookout] download stream failed:', code !== '' ? code : (error?.message ?? error))
293
- })
756
+ const stream = openStream(resolved.hostPath)
757
+ guardStream(stream, size)
294
758
  return new Response(Readable.toWeb(stream), { status: 200, headers })
295
759
  } catch (error) {
296
760
  // 500 只说一句,不带堆栈:日志里也不要整段内部路径。
@@ -300,43 +764,78 @@ export function createDownloadHandler(ctx, deps = {}) {
300
764
  }
301
765
  }
302
766
 
767
+ // ───────────────────────── 注册 ─────────────────────────
768
+
769
+ /** 同一个 connection 只挂一次:宿主对重复路径会直接抛错。 */
770
+ const REGISTERED_CONNECTIONS = new WeakSet()
771
+
303
772
  /**
304
- * 把下载路由挂到 connection 上。
773
+ * 把签发 + 下载两条路由挂到 connection 上(同一个 effect 里,卸载时一并撤销)。
305
774
  *
306
775
  * 用 ctx.inject 局部等 connection/fs/sessions:缺任何一个就不注册,而不是让整个插件
307
776
  * 卡在加载阶段(那正是不能把它们塞进顶层 inject 的原因)。
777
+ *
308
778
  * @param {object} ctx 插件上下文
309
- * @param {{createReadStream?: typeof createReadStream}} [deps] 仅单测使用
779
+ * @param {{createReadStream?: typeof createReadStream, store?: object, now?: () => number}} [deps] 仅单测使用
310
780
  * @returns {void}
311
781
  */
312
- const REGISTERED_CONNECTIONS = new WeakSet()
313
-
314
782
  export function registerDownloadRoute(ctx, deps = {}) {
315
783
  const attach = (scoped) => {
316
784
  const connection = serviceOf(scoped, 'connection')
317
785
  if (typeof connection?.fetch?.register !== 'function') return
318
786
  // 同一个 connection 只挂一次:宿主对重复路径会直接抛错,抛在 apply 里会带崩整个插件。
319
787
  if (REGISTERED_CONNECTIONS.has(connection)) return
320
- const route = {
788
+
789
+ const store = deps.store ?? createTicketStore({ now: deps.now })
790
+ const ticketRoute = {
791
+ path: TICKET_ROUTE_PATH,
792
+ methods: ['POST'],
793
+ requestBody: 'buffered',
794
+ fetch: createTicketHandler(scoped, { ...deps, store }),
795
+ }
796
+ const downloadRoute = {
321
797
  path: DOWNLOAD_ROUTE_PATH,
322
798
  methods: ['GET', 'HEAD'],
323
799
  requestBody: 'buffered',
324
- fetch: createDownloadHandler(scoped, deps),
800
+ fetch: createDownloadHandler(scoped, { ...deps, store }),
325
801
  }
802
+
326
803
  const register = () => {
327
- if (REGISTERED_CONNECTIONS.has(connection)) return
804
+ if (REGISTERED_CONNECTIONS.has(connection)) return undefined
805
+ const disposers = []
328
806
  try {
329
- const dispose = connection.fetch.register(route)
807
+ disposers.push(connection.fetch.register(ticketRoute))
808
+ disposers.push(connection.fetch.register(downloadRoute))
330
809
  REGISTERED_CONNECTIONS.add(connection)
331
- return dispose
332
810
  } catch (error) {
333
811
  // 已经被别人占了同名路径(或宿主换了校验):记一条,不拖累插件加载。
334
812
  console.warn('[dsh-lookout] download route not registered:', error?.code ?? error?.message ?? error)
813
+ for (const dispose of disposers) {
814
+ try {
815
+ dispose?.()
816
+ } catch {
817
+ /* 回滚尽力而为 */
818
+ }
819
+ }
820
+ store.dispose()
335
821
  return undefined
336
822
  }
823
+ return () => {
824
+ for (const dispose of disposers) {
825
+ try {
826
+ dispose?.()
827
+ } catch {
828
+ /* 撤销尽力而为 */
829
+ }
830
+ }
831
+ // 清定时器 + 清票据:插件卸载后不留任何活物。
832
+ store.dispose()
833
+ REGISTERED_CONNECTIONS.delete(connection)
834
+ }
337
835
  }
338
- // 包一层 effect:插件卸载时路由跟着撤掉。
339
- if (typeof scoped?.effect === 'function') scoped.effect(register, `dsh-lookout: ${DOWNLOAD_ROUTE_PATH}`)
836
+
837
+ // 包一层 effect:插件卸载时路由与定时器跟着撤掉。
838
+ if (typeof scoped?.effect === 'function') scoped.effect(register, 'dsh-lookout: ' + DOWNLOAD_ROUTE_PATH)
340
839
  else register()
341
840
  }
342
841
  if (typeof ctx?.inject === 'function') ctx.inject(['connection', 'fs', 'sessions'], attach)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-lookout",
3
3
  "description": "瞭望哨:盯着你的每个会话——谁跑完了、谁出错了、谁卡住了、谁在等你审批,用浏览器图标角标、红点/黄点和邮件通知你;顺带显示当前会话 ID。· Lookout: watches every session — who finished, who errored, who is stuck, and who is waiting for your approval — and notifies you through the browser favicon badge, red/yellow dots, and email, while also showing the current session ID.",
4
- "version": "0.5.3",
4
+ "version": "0.5.4",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",