@qilin-agent/host-preview-media 0.0.0-stage → 3.1.3
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 +21 -0
- package/README.i18n.yaml +34 -0
- package/README.md +53 -2
- package/README.zh.md +57 -0
- package/lib/index.js +282 -0
- package/lib/types/index.d.ts +20 -0
- package/lib/types/range.d.ts +33 -0
- package/package.json +44 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kkutysllb
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
|
|
2
|
+
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
|
3
|
+
# After editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/host/preview-media/README.md
|
|
5
|
+
/:
|
|
6
|
+
en: e64f5d01c95cea56
|
|
7
|
+
zh: 9761d65e30145b9c
|
|
8
|
+
/qilin-host-preview-media:
|
|
9
|
+
en: e65470c637ecb5e4
|
|
10
|
+
zh: db57c6508415526f
|
|
11
|
+
/qilin-host-preview-media/summary:
|
|
12
|
+
en: 404789ef764fe78e
|
|
13
|
+
zh: e10a8d8e40b33759
|
|
14
|
+
/qilin-host-preview-media/table-of-contents:
|
|
15
|
+
en: 35483a1f97ac9ea3
|
|
16
|
+
zh: 2b4d4af0605f0475
|
|
17
|
+
/qilin-host-preview-media/use-this-package:
|
|
18
|
+
en: 979f3aa7497c6c57
|
|
19
|
+
zh: 6dcf16bd29c5dd98
|
|
20
|
+
/qilin-host-preview-media/use-this-package/what-to-expect:
|
|
21
|
+
en: cc5101057995ecaa
|
|
22
|
+
zh: b9fcfd8eb9c921c4
|
|
23
|
+
/qilin-host-preview-media/model-experience:
|
|
24
|
+
en: 942a0174b133bcfe
|
|
25
|
+
zh: b6dca2d453309eb1
|
|
26
|
+
/qilin-host-preview-media/model-experience/kv-cache-effect:
|
|
27
|
+
en: ca75c51c89c2c9b0
|
|
28
|
+
zh: ac87fc083e6f2eb0
|
|
29
|
+
/qilin-host-preview-media/known-limitations-and-deferred-work:
|
|
30
|
+
en: e501ac1171311128
|
|
31
|
+
zh: 2ed1b3666cc8af6d
|
|
32
|
+
/qilin-host-preview-media/known-limitations-and-deferred-work/dev-note:
|
|
33
|
+
en: e67f633dc6fe7773
|
|
34
|
+
zh: 3bb0c20c93f16f98
|
package/README.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
description: "Host half of preview media: the /sidebar/media route serving session-workspace files to inline previews, with HTTP Range windows for seeking video."
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
# @qilin-agent/host-preview-media
|
|
7
|
+
|
|
8
|
+
English | [中文](README.zh.md)
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
Two webServer routes under `/sidebar/media` serve the browser's inline previews. `GET/HEAD /sidebar/media?sessionId=<id>&path=<p>` serves a session-workspace file. A request with a `Range` header is answered from a windowed read (`206`), so the document preview's video element can seek; `?download=1` switches the disposition so the browser saves the file. Whole-file responses are bounded by `mediaLimitBytes`.
|
|
13
|
+
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Use this package](#use-this-package)
|
|
17
|
+
- [Model Experience](#model-experience)
|
|
18
|
+
- [Dev Note](#dev-note)
|
|
19
|
+
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
|
20
|
+
|
|
21
|
+
## Use this package
|
|
22
|
+
|
|
23
|
+
Mount it in any composition that serves the web app and wants inline media previews. The route needs no configuration; `mediaLimitBytes` (default 20 MiB) bounds one whole-file response, while a ranged request reads only its window and is exempt.
|
|
24
|
+
|
|
25
|
+
### What to expect
|
|
26
|
+
|
|
27
|
+
- `206` with `Content-Range` for satisfiable single ranges, `416` for a start past EOF, plain `200` without a usable range header.
|
|
28
|
+
- Reads resolve through the abstract filesystem service, so remote execution worlds serve the same route.
|
|
29
|
+
- The connection service's request rejection (origin fence plus browser authentication) gates every request.
|
|
30
|
+
- `PUT /sidebar/media/upload?sessionId=<id>&path=<p>` writes one complete file into the session workspace through the filesystem's `writeBytes` (create or replace, no version guard); the body is bounded by `mediaLimitBytes` and an oversized body is refused with `400` before any write. The files tab's drop-upload is the caller.
|
|
31
|
+
|
|
32
|
+
## Model Experience
|
|
33
|
+
|
|
34
|
+
None, as this package registers no tool, contributes no prompt section, and appends no session event; the route serves bytes the user can already read through the preview surface.
|
|
35
|
+
|
|
36
|
+
#### KV Cache effect
|
|
37
|
+
|
|
38
|
+
None; this package neither assembles nor sends a provider request.
|
|
39
|
+
|
|
40
|
+
## Known Limitations and Deferred Work
|
|
41
|
+
|
|
42
|
+
- **Single-window ranges** — one `Range` spec is honored per request; a multi-spec header serves the first window only.
|
|
43
|
+
- **Whole-file GET bounded, upload capped once** — a plain `GET` beyond `mediaLimitBytes` is refused, and an upload body crossing the same cap is rejected before any write; there is no chunked or resumable transfer.
|
|
44
|
+
- **Disposition is all-or-nothing** — `?download=1` forces the attachment disposition on the whole answer; no partial-content download resumes.
|
|
45
|
+
|
|
46
|
+
<a id="dev-note"></a>
|
|
47
|
+
### Dev Note
|
|
48
|
+
|
|
49
|
+
<details>
|
|
50
|
+
<summary>Working context for maintainers — click to expand</summary>
|
|
51
|
+
|
|
52
|
+
None.
|
|
53
|
+
|
|
54
|
+
</details>
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "预览媒体的宿主半:/sidebar/media 路由向内联预览供出会话工作区文件,带 HTTP Range 窗口供视频拖动进度条。"
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @qilin-agent/host-preview-media
|
|
7
|
+
|
|
8
|
+
[English](README.md) | 中文
|
|
9
|
+
|
|
10
|
+
## 概述
|
|
11
|
+
|
|
12
|
+
`/sidebar/media` 之下的两条 webServer 路由服务浏览器的内联预览。`GET/HEAD /sidebar/media?sessionId=<id>&path=<p>` 供出一个会话工作区文件。带 `Range` 头的请求以窗口读回答 `206`,文档预览的 video 元素因此可拖动进度条;`?download=1` 切换处置方式让浏览器保存文件。整文件回答受 `mediaLimitBytes` 约束。
|
|
13
|
+
|
|
14
|
+
## 目录
|
|
15
|
+
|
|
16
|
+
- [使用本包](#use-this-package)
|
|
17
|
+
- [模型体验](#model-experience)
|
|
18
|
+
- [开发备注](#dev-note)
|
|
19
|
+
- [已知限制与后续工作](#known-limitations-and-deferred-work)
|
|
20
|
+
|
|
21
|
+
<a id="use-this-package"></a>
|
|
22
|
+
## 使用本包
|
|
23
|
+
|
|
24
|
+
在任何服务 Web 应用且需要内联媒体预览的组合中挂载。路由无需配置;`mediaLimitBytes`(默认 20 MiB)约束一次整文件回答,ranged 请求只读自身窗口、不受该上限约束。
|
|
25
|
+
|
|
26
|
+
### 预期行为
|
|
27
|
+
|
|
28
|
+
- 可满足的单区间得 `206` 与 `Content-Range`;起点越过 EOF 得 `416`;无可用 Range 头得普通 `200`。
|
|
29
|
+
- 读取经抽象文件系统服务解析,远程执行世界同样由此路由供出。
|
|
30
|
+
- connection 服务的请求拒绝(来源围栏加浏览器认证)先于一切字节放行。
|
|
31
|
+
- `PUT /sidebar/media/upload?sessionId=<id>&path=<p>` 经文件系统的 `writeBytes` 向会话工作区写入一个完整文件(创建或覆盖,无版本守卫);请求体受 `mediaLimitBytes` 约束,超限在写入前即以 `400` 拒绝。文件页的拖放上传是它的调用方。
|
|
32
|
+
|
|
33
|
+
<a id="model-experience"></a>
|
|
34
|
+
## 模型体验
|
|
35
|
+
|
|
36
|
+
无,本包不注册工具、不贡献 prompt 小节、不追加会话事件;路由供出的是用户本就能经预览面读到的字节。
|
|
37
|
+
|
|
38
|
+
#### KV 缓存影响
|
|
39
|
+
|
|
40
|
+
无;本包既不组装也不发送任何提供方请求。
|
|
41
|
+
|
|
42
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
43
|
+
## 已知限制与后续工作
|
|
44
|
+
|
|
45
|
+
- **单窗口 Range**——每次请求只尊重一个 `Range` 规格;多规格头只服务第一个窗口。
|
|
46
|
+
- **整文件 GET 有界、上传一次性设限**——超过 `mediaLimitBytes` 的普通 `GET` 被拒绝,越过同一上限的上传体在写入前即被拒绝;没有分块或可续传传输。
|
|
47
|
+
- **处置方式全有或全无**——`?download=1` 对整个回答强制附件处置;没有可续传的部分内容下载。
|
|
48
|
+
|
|
49
|
+
<a id="dev-note"></a>
|
|
50
|
+
### 开发备注
|
|
51
|
+
|
|
52
|
+
<details>
|
|
53
|
+
<summary>维护者的工作上下文——点击展开</summary>
|
|
54
|
+
|
|
55
|
+
无。
|
|
56
|
+
|
|
57
|
+
</details>
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import z from "@qilin-agent/schemastery";
|
|
2
|
+
//#region lib/types/range.js
|
|
3
|
+
/**
|
|
4
|
+
* Range-header parsing for the preview media route: RFC 9110 §14 single-range
|
|
5
|
+
* semantics, adapted to windowed reads over the abstract filesystem service.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Parse a single `Range: bytes=...` header against a known size.
|
|
9
|
+
*
|
|
10
|
+
* Semantics (mirroring the video-preview contract this route serves):
|
|
11
|
+
* - only the FIRST range of a multi-range set is honoured,
|
|
12
|
+
* - `bytes=-N` is a suffix range (the last N bytes; `N >= size` = whole file),
|
|
13
|
+
* - `bytes=N-` runs to EOF, `bytes=A-B` is clamped to EOF,
|
|
14
|
+
* - non-integer / negative / empty / inverted specs are ignored (serve 200),
|
|
15
|
+
* - a start at or past EOF is unsatisfiable (416).
|
|
16
|
+
* @param raw - the raw `Range` header value (undefined when absent).
|
|
17
|
+
* @param size - the file size in bytes.
|
|
18
|
+
* @returns the parsed window, `null` when the header admits no range, or an unsatisfiable marker for a start at or past EOF.
|
|
19
|
+
*/
|
|
20
|
+
function parseRange(raw, size) {
|
|
21
|
+
if (raw === void 0) return null;
|
|
22
|
+
if (size <= 0) return { unsatisfiable: true };
|
|
23
|
+
const match = /^bytes=(.+)$/i.exec(raw.trim());
|
|
24
|
+
if (match === null) return null;
|
|
25
|
+
const [firstSpec = ""] = (match[1] ?? "").split(",");
|
|
26
|
+
const spec = firstSpec.trim();
|
|
27
|
+
if (spec === "") return null;
|
|
28
|
+
if (spec.startsWith("-")) {
|
|
29
|
+
const suffix = Number(spec.slice(1));
|
|
30
|
+
if (!Number.isFinite(suffix) || suffix <= 0) return null;
|
|
31
|
+
if (suffix >= size) return {
|
|
32
|
+
start: 0,
|
|
33
|
+
end: size - 1
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
start: size - suffix,
|
|
37
|
+
end: size - 1
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const dash = spec.indexOf("-");
|
|
41
|
+
if (dash === -1) return null;
|
|
42
|
+
const startText = spec.slice(0, dash);
|
|
43
|
+
const endText = spec.slice(dash + 1);
|
|
44
|
+
const start = startText === "" ? 0 : Number(startText);
|
|
45
|
+
const end = endText === "" ? size - 1 : Number(endText);
|
|
46
|
+
if (!Number.isInteger(start) || start < 0 || !Number.isInteger(end)) return null;
|
|
47
|
+
if (start >= size) return { unsatisfiable: true };
|
|
48
|
+
if (end < start) return null;
|
|
49
|
+
return {
|
|
50
|
+
start,
|
|
51
|
+
end: Math.min(end, size - 1)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region lib/types/index.js
|
|
56
|
+
/** Route path the browser media URL builder points at (prefix match). */
|
|
57
|
+
const PREVIEW_MEDIA_PATH = "/sidebar/media";
|
|
58
|
+
/** Content types served by the route, by lowercase extension. */
|
|
59
|
+
const MEDIA_TYPES = {
|
|
60
|
+
".png": "image/png",
|
|
61
|
+
".jpg": "image/jpeg",
|
|
62
|
+
".jpeg": "image/jpeg",
|
|
63
|
+
".gif": "image/gif",
|
|
64
|
+
".svg": "image/svg+xml",
|
|
65
|
+
".webp": "image/webp",
|
|
66
|
+
".bmp": "image/bmp",
|
|
67
|
+
".ico": "image/x-icon",
|
|
68
|
+
".avif": "image/avif",
|
|
69
|
+
".pdf": "application/pdf",
|
|
70
|
+
".html": "text/html",
|
|
71
|
+
".htm": "text/html",
|
|
72
|
+
".mp4": "video/mp4",
|
|
73
|
+
".m4v": "video/mp4",
|
|
74
|
+
".webm": "video/webm",
|
|
75
|
+
".ogv": "video/ogg",
|
|
76
|
+
".ogg": "audio/ogg",
|
|
77
|
+
".mov": "video/quicktime",
|
|
78
|
+
".qt": "video/quicktime",
|
|
79
|
+
".mkv": "video/x-matroska",
|
|
80
|
+
".avi": "video/x-msvideo",
|
|
81
|
+
".wmv": "video/x-ms-wmv",
|
|
82
|
+
".flv": "video/x-flv",
|
|
83
|
+
".m2ts": "video/mp2t",
|
|
84
|
+
".mpeg": "video/mpeg",
|
|
85
|
+
".mpg": "video/mpeg",
|
|
86
|
+
".3gp": "video/3gpp",
|
|
87
|
+
".3g2": "video/3gpp2"
|
|
88
|
+
};
|
|
89
|
+
/** The binary-safe fallback for extensions the table does not name. */
|
|
90
|
+
const FALLBACK_TYPE = "application/octet-stream";
|
|
91
|
+
/** Content type for one path: the table's entry for its extension, or the fallback. */
|
|
92
|
+
function mediaTypeOf(path) {
|
|
93
|
+
const dot = path.lastIndexOf(".");
|
|
94
|
+
return MEDIA_TYPES[dot === -1 ? "" : path.slice(dot).toLowerCase()] ?? FALLBACK_TYPE;
|
|
95
|
+
}
|
|
96
|
+
/** One route failure: the HTTP status and the plain-text line to send. */
|
|
97
|
+
var MediaError = class extends Error {
|
|
98
|
+
status;
|
|
99
|
+
constructor(status, message) {
|
|
100
|
+
super(message);
|
|
101
|
+
this.status = status;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/** The composition's connection service (typed locally: its package is browser-side). */
|
|
105
|
+
function connectionOf(ctx) {
|
|
106
|
+
return Reflect.get(ctx, "connection");
|
|
107
|
+
}
|
|
108
|
+
/** Send the failure line and end the response. */
|
|
109
|
+
function writeError(res, error) {
|
|
110
|
+
const status = error instanceof MediaError ? error.status : 500;
|
|
111
|
+
const line = error instanceof Error ? error.message : String(error);
|
|
112
|
+
if (!res.headersSent) res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
|
|
113
|
+
res.end(status === 500 ? "internal error" : line);
|
|
114
|
+
}
|
|
115
|
+
/** Headers shared by every media response (206 and 200 alike). */
|
|
116
|
+
function mediaHeaders(type) {
|
|
117
|
+
return {
|
|
118
|
+
"content-type": type,
|
|
119
|
+
"accept-ranges": "bytes",
|
|
120
|
+
"cache-control": "no-cache"
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** The basename of a path, for the download disposition. */
|
|
124
|
+
function basenameOf(path) {
|
|
125
|
+
const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
126
|
+
return slash === -1 ? path : path.slice(slash + 1);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Resolve one request's `sessionId`/`path` pair to the file it names, against
|
|
130
|
+
* the session's live workspace root (the deployment sandbox root as fallback).
|
|
131
|
+
* Both routes that take the pair share this read; what each does with the
|
|
132
|
+
* target — bounded read, ranged window, or contained write — stays with the route.
|
|
133
|
+
* @param ctx - host context carrying the session registry, sandbox policy, and filesystem service.
|
|
134
|
+
* @param url - the request URL whose query names the target.
|
|
135
|
+
* @returns the requested path, the workspace root it resolved under, and the resolved target.
|
|
136
|
+
* @throws {MediaError} 400 when either query parameter is absent or empty, 404 when the session is unknown.
|
|
137
|
+
*/
|
|
138
|
+
async function resolveMediaTarget(ctx, url) {
|
|
139
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
140
|
+
const path = url.searchParams.get("path");
|
|
141
|
+
if (sessionId === null || path === null || path === "") throw new MediaError(400, "sessionId and path are required");
|
|
142
|
+
const live = ctx.sessions.get(sessionId);
|
|
143
|
+
if (live === void 0) throw new MediaError(404, `no session "${sessionId}"`);
|
|
144
|
+
const root = live.header.cwd ?? ctx.sandboxPolicy.workspaceRoot;
|
|
145
|
+
return {
|
|
146
|
+
path,
|
|
147
|
+
root,
|
|
148
|
+
target: await ctx.fs.resolve(path, { cwd: root })
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const inject = [
|
|
152
|
+
"webServer",
|
|
153
|
+
"connection",
|
|
154
|
+
"sessions",
|
|
155
|
+
"sandboxPolicy",
|
|
156
|
+
"fs"
|
|
157
|
+
];
|
|
158
|
+
/**
|
|
159
|
+
* Register the preview media route over the composition's filesystem.
|
|
160
|
+
* @param ctx - host context carrying the web server, connection fence,
|
|
161
|
+
* session registry, sandbox policy, and filesystem service.
|
|
162
|
+
* @param config - validated Cordis row (the media limit with its default applied).
|
|
163
|
+
*/
|
|
164
|
+
function apply(ctx, config) {
|
|
165
|
+
const mediaLimitBytes = config.mediaLimitBytes ?? 20 * 1024 * 1024;
|
|
166
|
+
const connection = connectionOf(ctx);
|
|
167
|
+
/** The composition's rejection for one request, or undefined when admitted. */
|
|
168
|
+
const rejectionOf = (req) => connection.requestRejection(req);
|
|
169
|
+
ctx.effect(() => ctx.webServer.register({
|
|
170
|
+
kind: "prefix",
|
|
171
|
+
path: PREVIEW_MEDIA_PATH,
|
|
172
|
+
handler: async (req, res) => {
|
|
173
|
+
const rejection = rejectionOf(req);
|
|
174
|
+
if (rejection !== void 0) {
|
|
175
|
+
res.statusCode = rejection;
|
|
176
|
+
res.end();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
180
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
181
|
+
res.end();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const url = new URL(req.url ?? "/", "http://qilin.internal");
|
|
186
|
+
const { path, target } = await resolveMediaTarget(ctx, url);
|
|
187
|
+
const info = await ctx.fs.stat(target);
|
|
188
|
+
if (info === void 0) throw new MediaError(404, `no entry at "${path}"`);
|
|
189
|
+
if (info.type !== "file") throw new MediaError(400, `"${path}" is a ${info.type}`);
|
|
190
|
+
const type = mediaTypeOf(path);
|
|
191
|
+
const range = info.size === void 0 ? null : parseRange(req.headers.range, info.size);
|
|
192
|
+
if (range !== null && "unsatisfiable" in range) {
|
|
193
|
+
const size = info.size ?? 0;
|
|
194
|
+
res.writeHead(416, {
|
|
195
|
+
...mediaHeaders(type),
|
|
196
|
+
"content-range": `bytes */${size}`
|
|
197
|
+
});
|
|
198
|
+
res.end();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (range !== null) {
|
|
202
|
+
const { start, end } = range;
|
|
203
|
+
const body = await ctx.fs.readByteRange(target, {
|
|
204
|
+
offset: start,
|
|
205
|
+
length: end - start + 1
|
|
206
|
+
});
|
|
207
|
+
res.writeHead(206, {
|
|
208
|
+
...mediaHeaders(type),
|
|
209
|
+
"content-range": `bytes ${start}-${end}/${info.size}`,
|
|
210
|
+
"content-length": String(body.byteLength)
|
|
211
|
+
});
|
|
212
|
+
res.end(req.method === "HEAD" ? void 0 : body);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (info.size !== void 0 && info.size > mediaLimitBytes) throw new MediaError(400, `"${path}" exceeds the preview media limit`);
|
|
216
|
+
const body = await ctx.fs.readBytes(target, void 0, mediaLimitBytes);
|
|
217
|
+
const headers = {
|
|
218
|
+
...mediaHeaders(type),
|
|
219
|
+
"content-length": String(body.byteLength)
|
|
220
|
+
};
|
|
221
|
+
if (url.searchParams.get("download") === "1") headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(basenameOf(path))}`;
|
|
222
|
+
res.writeHead(200, headers);
|
|
223
|
+
res.end(req.method === "HEAD" ? void 0 : body);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
writeError(res, error);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}), `preview-media: GET/HEAD ${PREVIEW_MEDIA_PATH}`);
|
|
229
|
+
/** Buffer one request body up to a byte ceiling; the caller names its limit. */
|
|
230
|
+
const readBody = async (req, limit) => {
|
|
231
|
+
const chunks = [];
|
|
232
|
+
let total = 0;
|
|
233
|
+
for await (const chunk of req) {
|
|
234
|
+
const piece = chunk;
|
|
235
|
+
total += piece.byteLength;
|
|
236
|
+
if (total > limit) throw new MediaError(400, "upload exceeds the media limit");
|
|
237
|
+
chunks.push(piece);
|
|
238
|
+
}
|
|
239
|
+
return Buffer.concat(chunks);
|
|
240
|
+
};
|
|
241
|
+
ctx.effect(() => ctx.webServer.register({
|
|
242
|
+
kind: "exact",
|
|
243
|
+
path: `${PREVIEW_MEDIA_PATH}/upload`,
|
|
244
|
+
handler: async (req, res) => {
|
|
245
|
+
const rejection = rejectionOf(req);
|
|
246
|
+
if (rejection !== void 0) {
|
|
247
|
+
res.statusCode = rejection;
|
|
248
|
+
res.end();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (req.method !== "PUT") {
|
|
252
|
+
res.writeHead(405, { allow: "PUT" });
|
|
253
|
+
res.end();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const { path, root, target } = await resolveMediaTarget(ctx, new URL(req.url ?? "/", "http://qilin.internal"));
|
|
258
|
+
if (!ctx.fs.contains(await ctx.fs.resolve(root), target)) throw new MediaError(403, `"${path}" is outside the workspace`);
|
|
259
|
+
const body = await readBody(req, mediaLimitBytes);
|
|
260
|
+
const outcome = await ctx.fs.writeBytes(target, body, void 0, void 0, {
|
|
261
|
+
mode: "workspace-write",
|
|
262
|
+
workspaceRoot: root
|
|
263
|
+
});
|
|
264
|
+
res.writeHead(200, {
|
|
265
|
+
"content-type": "application/json",
|
|
266
|
+
"cache-control": "no-store"
|
|
267
|
+
});
|
|
268
|
+
res.end(JSON.stringify({
|
|
269
|
+
ok: true,
|
|
270
|
+
path,
|
|
271
|
+
version: outcome.version
|
|
272
|
+
}));
|
|
273
|
+
} catch (error) {
|
|
274
|
+
writeError(res, error);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}), `preview-media: PUT ${PREVIEW_MEDIA_PATH}/upload`);
|
|
278
|
+
}
|
|
279
|
+
/** Cordis row Config schema: the media limit is a positive byte count. */
|
|
280
|
+
const Config = z.object({ mediaLimitBytes: z.number().step(1).min(1).max(2147483647).default(20 * 1024 * 1024) });
|
|
281
|
+
//#endregion
|
|
282
|
+
export { Config, PREVIEW_MEDIA_PATH, apply, inject };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Context } from '@qilin-agent/kylin';
|
|
2
|
+
import z from '@qilin-agent/schemastery';
|
|
3
|
+
/** Route path the browser media URL builder points at (prefix match). */
|
|
4
|
+
export declare const PREVIEW_MEDIA_PATH = "/sidebar/media";
|
|
5
|
+
/** Cordis row selecting the media limit. */
|
|
6
|
+
export interface Config {
|
|
7
|
+
/** Ceiling in bytes for one whole-file (non-ranged) response. */
|
|
8
|
+
mediaLimitBytes?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare const inject: string[];
|
|
11
|
+
/**
|
|
12
|
+
* Register the preview media route over the composition's filesystem.
|
|
13
|
+
* @param ctx - host context carrying the web server, connection fence,
|
|
14
|
+
* session registry, sandbox policy, and filesystem service.
|
|
15
|
+
* @param config - validated Cordis row (the media limit with its default applied).
|
|
16
|
+
*/
|
|
17
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
18
|
+
/** Cordis row Config schema: the media limit is a positive byte count. */
|
|
19
|
+
export declare const Config: z<Config>;
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Range-header parsing for the preview media route: RFC 9110 §14 single-range
|
|
3
|
+
* semantics, adapted to windowed reads over the abstract filesystem service.
|
|
4
|
+
*/
|
|
5
|
+
/** A resolved, satisfiable byte range (both ends inclusive). */
|
|
6
|
+
export interface ByteRange {
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The outcome of parsing one `Range` header:
|
|
12
|
+
* - {@link ByteRange} — serve 206 for these bytes,
|
|
13
|
+
* - `{ unsatisfiable: true }` — well-formed but past EOF: serve 416,
|
|
14
|
+
* - `null` — no (usable) range: serve the full 200 response.
|
|
15
|
+
*/
|
|
16
|
+
export type ParsedRange = ByteRange | {
|
|
17
|
+
unsatisfiable: true;
|
|
18
|
+
} | null;
|
|
19
|
+
/**
|
|
20
|
+
* Parse a single `Range: bytes=...` header against a known size.
|
|
21
|
+
*
|
|
22
|
+
* Semantics (mirroring the video-preview contract this route serves):
|
|
23
|
+
* - only the FIRST range of a multi-range set is honoured,
|
|
24
|
+
* - `bytes=-N` is a suffix range (the last N bytes; `N >= size` = whole file),
|
|
25
|
+
* - `bytes=N-` runs to EOF, `bytes=A-B` is clamped to EOF,
|
|
26
|
+
* - non-integer / negative / empty / inverted specs are ignored (serve 200),
|
|
27
|
+
* - a start at or past EOF is unsatisfiable (416).
|
|
28
|
+
* @param raw - the raw `Range` header value (undefined when absent).
|
|
29
|
+
* @param size - the file size in bytes.
|
|
30
|
+
* @returns the parsed window, `null` when the header admits no range, or an unsatisfiable marker for a start at or past EOF.
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseRange(raw: string | undefined, size: number): ParsedRange;
|
|
33
|
+
//# sourceMappingURL=range.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qilin-agent/host-preview-media",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"description": "Host half of preview media: the /sidebar/media route serving session-workspace files to inline previews, with HTTP Range windows for seeking video",
|
|
4
|
+
"version": "3.1.3",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kkutysllb/QiLin.git",
|
|
11
|
+
"directory": "packages/host/preview-media"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "lib/index.js",
|
|
18
|
+
"types": "lib/types/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./lib/types/index.d.ts",
|
|
22
|
+
"default": "./lib/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./src/*": "./src/*",
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib/index.js",
|
|
29
|
+
"lib/types/**/*.d.ts"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@qilin-agent/schemastery": "~3.18.3"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@qilin-agent/kylin": "~4.0.3"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@qilin-agent/kylin-plugin-loader": "~1.0.4",
|
|
40
|
+
"@qilin-agent/kylin": "~4.0.3",
|
|
41
|
+
"@qilin-agent/host-webserver": "3.1.3"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"bundle": "tsdown",
|
|
45
|
+
"watch": "tsdown --watch"
|
|
46
|
+
}
|
|
6
47
|
}
|