@achasoft/dsh-advanced-sidebar 0.1.0 → 0.3.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/README.md +279 -128
- package/cordis.patch.yml +31 -3
- package/lib/client.js +2803 -466
- package/lib/client.js.map +1 -1
- package/lib/host.js +2071 -418
- package/lib/index.js +6 -2
- package/lib/preview-content-BVUQ5oOR.js +465 -0
- package/lib/remote.js +330 -25
- package/lib/typert.host.js +330 -25
- package/lib/ui-preview.js +352 -0
- package/package.json +8 -2
- package/types/client/ActionMenu.d.ts +16 -1
- package/types/client/LogDownloadDialog.d.ts +24 -0
- package/types/client/contract.d.ts +57 -1
- package/types/client/index.d.ts +4 -2
- package/types/client/locales.d.ts +100 -0
- package/types/client/log-download.d.ts +179 -0
- package/types/client/panels/PreviewPanel.d.ts +20 -15
- package/types/client/panels/preview-file.d.ts +61 -0
- package/types/client/panels/preview-mode.d.ts +67 -0
- package/types/client/panels/preview-scratchpad.d.ts +53 -0
- package/types/client/panels/preview-url.d.ts +17 -0
- package/types/client/panels/shared.d.ts +15 -2
- package/types/client/preview-driver.d.ts +121 -0
- package/types/client/preview-storage.d.ts +43 -0
- package/types/client/preview-types.d.ts +21 -0
- package/types/client/preview-values.d.ts +43 -0
- package/types/host/deletion.d.ts +32 -23
- package/types/host/git.d.ts +94 -8
- package/types/host/index.d.ts +97 -5
- package/types/host/preview-content.d.ts +179 -0
- package/types/host/preview-serve.d.ts +242 -0
- package/types/host/settings-section.d.ts +49 -0
- package/types/host/types.d.ts +341 -0
- package/types/host/ui-bridge.d.ts +197 -0
- package/types/host/ui-preview-tool.d.ts +60 -0
- package/types/index.d.ts +6 -2
- package/types/ui-preview.d.ts +11 -0
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
//#region tsbuild/index.js
|
|
2
2
|
/**
|
|
3
|
-
* `@achasoft/dsh-advanced-sidebar` root entry —
|
|
4
|
-
* system requires them together.
|
|
3
|
+
* `@achasoft/dsh-advanced-sidebar` root entry — three roles in one module, because the client module
|
|
4
|
+
* system requires two of them together.
|
|
5
5
|
*
|
|
6
6
|
* **As a plugin**, this is the advanced sidebar's node half. The apply is empty: the browser half
|
|
7
7
|
* ships via `exports["./client"]` and is discovered through the package's `dsh.client` declaration.
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
*
|
|
12
12
|
* **As a library**, it re-exports the wire contract, so another package can type against the
|
|
13
13
|
* `advancedSidebar` namespace without depending on the Host endpoint or the browser surface.
|
|
14
|
+
*
|
|
15
|
+
* The agent-facing `ui_preview` tool ships as its own entry (`exports["./ui-preview"]`) and its own
|
|
16
|
+
* composition row, so a deployment that wants the sidebar but not a model-facing verb simply leaves
|
|
17
|
+
* that row out — and the row's own `inject` is what makes the tool wait for the service it calls.
|
|
14
18
|
* @module @achasoft/dsh-advanced-sidebar
|
|
15
19
|
*/
|
|
16
20
|
/** Host plugin body — no host-side behavior for this surface plugin. */
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
//#region tsbuild/host/paths.js
|
|
2
|
+
/**
|
|
3
|
+
* Path resolution and containment for every endpoint that takes a path from the browser.
|
|
4
|
+
*
|
|
5
|
+
* A browser-supplied path is untrusted input at a process boundary, so each one is resolved through
|
|
6
|
+
* `ctx.fs` and proved to sit inside the workspace it claims to belong to before any command, read,
|
|
7
|
+
* or launch sees it. `..` and symlinks are handled by the filesystem's own canonicalization rather
|
|
8
|
+
* than by string arithmetic here.
|
|
9
|
+
* @module @achasoft/dsh-advanced-sidebar/host/paths
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Resolve one absolute directory as a workspace root.
|
|
13
|
+
* @param ctx - Host context carrying the optional filesystem capability.
|
|
14
|
+
* @param path - absolute directory path supplied by the browser.
|
|
15
|
+
* @param signal - cancellation for the backend round-trip.
|
|
16
|
+
* @returns the canonical directory, or the reason it was refused.
|
|
17
|
+
*/
|
|
18
|
+
async function resolveWorkspace(ctx, path, signal) {
|
|
19
|
+
const fs = ctx.get("fs");
|
|
20
|
+
if (fs === void 0) return {
|
|
21
|
+
ok: false,
|
|
22
|
+
rejection: {
|
|
23
|
+
code: "no-filesystem",
|
|
24
|
+
message: "no filesystem capability is mounted: this deployment composes no @deepseek-ai/dsh-fs provider"
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
let target;
|
|
28
|
+
try {
|
|
29
|
+
target = await fs.resolve(path, signal === void 0 ? {} : { signal });
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
rejection: {
|
|
34
|
+
code: "path-denied",
|
|
35
|
+
message: describe(error, path)
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const info = await fs.stat(target, signal);
|
|
40
|
+
if (info === void 0 || info.type !== "directory") return {
|
|
41
|
+
ok: false,
|
|
42
|
+
rejection: {
|
|
43
|
+
code: "path-denied",
|
|
44
|
+
message: `${path} is not a directory`
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
value: {
|
|
50
|
+
target,
|
|
51
|
+
processPath: fs.processPath(target)
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve one path and prove it sits inside an already-resolved workspace.
|
|
57
|
+
* @param ctx - Host context carrying the optional filesystem capability.
|
|
58
|
+
* @param workspace - the canonical workspace directory the path must stay within.
|
|
59
|
+
* @param path - absolute path supplied by the browser.
|
|
60
|
+
* @param signal - cancellation for the backend round-trip.
|
|
61
|
+
* @returns the canonical path, or the reason it was refused.
|
|
62
|
+
*/
|
|
63
|
+
async function resolveInside(ctx, workspace, path, signal) {
|
|
64
|
+
const fs = ctx.get("fs");
|
|
65
|
+
/* v8 ignore next 4 -- the caller resolved `workspace` through the same service moments earlier. */
|
|
66
|
+
if (fs === void 0) return {
|
|
67
|
+
ok: false,
|
|
68
|
+
rejection: {
|
|
69
|
+
code: "no-filesystem",
|
|
70
|
+
message: "filesystem capability withdrawn mid-request"
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
let target;
|
|
74
|
+
try {
|
|
75
|
+
target = await fs.resolve(path, signal === void 0 ? {} : { signal });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
rejection: {
|
|
80
|
+
code: "path-denied",
|
|
81
|
+
message: describe(error, path)
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (!fs.contains(workspace.target, target)) return {
|
|
86
|
+
ok: false,
|
|
87
|
+
rejection: {
|
|
88
|
+
code: "path-denied",
|
|
89
|
+
message: `${path} is outside ${workspace.target.displayPath}`
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
value: {
|
|
95
|
+
target,
|
|
96
|
+
processPath: fs.processPath(target)
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Phrase one resolution failure without leaking a stack.
|
|
102
|
+
* @param error - whatever the backend threw.
|
|
103
|
+
* @param path - the path that was being resolved.
|
|
104
|
+
* @returns a single-line operator diagnostic.
|
|
105
|
+
*/
|
|
106
|
+
function describe(error, path) {
|
|
107
|
+
return `cannot resolve ${path}: ${error instanceof Error ? error.message : String(error)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region tsbuild/host/preview-content.js
|
|
112
|
+
/**
|
|
113
|
+
* Pure decisions the same-origin preview routes are built on: what a file is, what MIME type it
|
|
114
|
+
* gets, and whether a URL is one this plugin is willing to fetch or frame.
|
|
115
|
+
*
|
|
116
|
+
* Kept free of `ctx`, `node:http`, and the filesystem so every rule here is a table lookup a test
|
|
117
|
+
* can state in one line. The two rules that matter most — a proxy target must be loopback, and a
|
|
118
|
+
* framed document must be same-origin — are the whole difference between a helpful preview and an
|
|
119
|
+
* open proxy or a cross-origin hole, so they live in functions rather than in a request handler's
|
|
120
|
+
* middle.
|
|
121
|
+
* @module @achasoft/dsh-advanced-sidebar/host/preview-content
|
|
122
|
+
*/
|
|
123
|
+
/**
|
|
124
|
+
* Absolute path of the workspace-file route; one route, parameterized by query.
|
|
125
|
+
*
|
|
126
|
+
* Declared here rather than in the module that registers it because the BROWSER half builds URLs
|
|
127
|
+
* from these strings too, and this module is the one both halves already share. Two literals that
|
|
128
|
+
* had to agree would eventually stop agreeing, and the symptom would be a frame that 404s only
|
|
129
|
+
* after a rename nobody thought was load-bearing.
|
|
130
|
+
*/
|
|
131
|
+
const FILE_ROUTE = "/advanced-sidebar/preview-file";
|
|
132
|
+
/** Absolute path of the loopback reverse proxy; subpaths are forwarded as-is. */
|
|
133
|
+
const PROXY_ROUTE = "/advanced-sidebar/preview-proxy";
|
|
134
|
+
/** Absolute path of the scratchpad route: renders text the panel posts, with no file behind it. */
|
|
135
|
+
const SCRATCHPAD_ROUTE = "/advanced-sidebar/preview-scratchpad";
|
|
136
|
+
/**
|
|
137
|
+
* Extensions mapped to MIME types, for the files a preview frame actually renders.
|
|
138
|
+
*
|
|
139
|
+
* Deliberately a hand-written table rather than a dependency: the set is small, fixed by what the
|
|
140
|
+
* panel can display, and a lookup that cannot consult the host's `/etc/mime.types` behaves the same
|
|
141
|
+
* on every machine — which is what makes the tests here meaningful.
|
|
142
|
+
*/
|
|
143
|
+
const CONTENT_TYPES = {
|
|
144
|
+
html: "text/html",
|
|
145
|
+
htm: "text/html",
|
|
146
|
+
xhtml: "application/xhtml+xml",
|
|
147
|
+
css: "text/css",
|
|
148
|
+
js: "text/javascript",
|
|
149
|
+
mjs: "text/javascript",
|
|
150
|
+
cjs: "text/javascript",
|
|
151
|
+
json: "application/json",
|
|
152
|
+
map: "application/json",
|
|
153
|
+
md: "text/markdown",
|
|
154
|
+
markdown: "text/markdown",
|
|
155
|
+
txt: "text/plain",
|
|
156
|
+
csv: "text/csv",
|
|
157
|
+
xml: "application/xml",
|
|
158
|
+
svg: "image/svg+xml",
|
|
159
|
+
pdf: "application/pdf",
|
|
160
|
+
wasm: "application/wasm",
|
|
161
|
+
png: "image/png",
|
|
162
|
+
jpg: "image/jpeg",
|
|
163
|
+
jpeg: "image/jpeg",
|
|
164
|
+
gif: "image/gif",
|
|
165
|
+
webp: "image/webp",
|
|
166
|
+
avif: "image/avif",
|
|
167
|
+
bmp: "image/bmp",
|
|
168
|
+
ico: "image/x-icon",
|
|
169
|
+
mp3: "audio/mpeg",
|
|
170
|
+
wav: "audio/wav",
|
|
171
|
+
ogg: "audio/ogg",
|
|
172
|
+
oga: "audio/ogg",
|
|
173
|
+
m4a: "audio/mp4",
|
|
174
|
+
aac: "audio/aac",
|
|
175
|
+
flac: "audio/flac",
|
|
176
|
+
mp4: "video/mp4",
|
|
177
|
+
m4v: "video/mp4",
|
|
178
|
+
webm: "video/webm",
|
|
179
|
+
mov: "video/quicktime",
|
|
180
|
+
ogv: "video/ogg",
|
|
181
|
+
woff: "font/woff",
|
|
182
|
+
woff2: "font/woff2",
|
|
183
|
+
ttf: "font/ttf",
|
|
184
|
+
otf: "font/otf"
|
|
185
|
+
};
|
|
186
|
+
/** The fallback for a file whose extension says nothing; a native element will not render it. */
|
|
187
|
+
const OCTET_STREAM = "application/octet-stream";
|
|
188
|
+
/** Extensions `.svg` deliberately excluded from the image kind; see {@link classifyFile}. */
|
|
189
|
+
const IMAGE = new Set([
|
|
190
|
+
"png",
|
|
191
|
+
"jpg",
|
|
192
|
+
"jpeg",
|
|
193
|
+
"gif",
|
|
194
|
+
"webp",
|
|
195
|
+
"avif",
|
|
196
|
+
"bmp",
|
|
197
|
+
"ico"
|
|
198
|
+
]);
|
|
199
|
+
/** Extensions the browser plays in a `<video>` or `<audio>` element. */
|
|
200
|
+
const MEDIA = new Set([
|
|
201
|
+
"mp3",
|
|
202
|
+
"wav",
|
|
203
|
+
"ogg",
|
|
204
|
+
"oga",
|
|
205
|
+
"m4a",
|
|
206
|
+
"aac",
|
|
207
|
+
"flac",
|
|
208
|
+
"mp4",
|
|
209
|
+
"m4v",
|
|
210
|
+
"webm",
|
|
211
|
+
"mov",
|
|
212
|
+
"ogv"
|
|
213
|
+
]);
|
|
214
|
+
/** Extensions rendered as Markdown by this panel rather than by the browser. */
|
|
215
|
+
const MARKDOWN = new Set(["md", "markdown"]);
|
|
216
|
+
/** Extensions whose bytes are text worth reading in the monospace reader. */
|
|
217
|
+
const TEXT = new Set([
|
|
218
|
+
"txt",
|
|
219
|
+
"csv",
|
|
220
|
+
"json",
|
|
221
|
+
"xml",
|
|
222
|
+
"css",
|
|
223
|
+
"js",
|
|
224
|
+
"mjs",
|
|
225
|
+
"cjs",
|
|
226
|
+
"map",
|
|
227
|
+
"yml",
|
|
228
|
+
"yaml",
|
|
229
|
+
"toml",
|
|
230
|
+
"ini",
|
|
231
|
+
"log",
|
|
232
|
+
"ts",
|
|
233
|
+
"tsx",
|
|
234
|
+
"jsx",
|
|
235
|
+
"sh",
|
|
236
|
+
"zsh",
|
|
237
|
+
"bash",
|
|
238
|
+
"py",
|
|
239
|
+
"rb",
|
|
240
|
+
"go",
|
|
241
|
+
"rs",
|
|
242
|
+
"java",
|
|
243
|
+
"c",
|
|
244
|
+
"h",
|
|
245
|
+
"cpp",
|
|
246
|
+
"sql",
|
|
247
|
+
"env",
|
|
248
|
+
"conf",
|
|
249
|
+
"lock",
|
|
250
|
+
"patch",
|
|
251
|
+
"diff"
|
|
252
|
+
]);
|
|
253
|
+
/**
|
|
254
|
+
* The lower-case extension of a path, without its dot.
|
|
255
|
+
* @param path - a file path or a URL pathname.
|
|
256
|
+
* @returns the extension, or the empty string when there is none.
|
|
257
|
+
*/
|
|
258
|
+
function extensionOf(path) {
|
|
259
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
260
|
+
const dot = name.lastIndexOf(".");
|
|
261
|
+
if (dot <= 0) return "";
|
|
262
|
+
return name.slice(dot + 1).toLowerCase();
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The MIME type one path is served with.
|
|
266
|
+
* @param path - a file path or a URL pathname.
|
|
267
|
+
* @returns the type; `application/octet-stream` when the extension says nothing.
|
|
268
|
+
*/
|
|
269
|
+
function contentTypeOf(path) {
|
|
270
|
+
return CONTENT_TYPES[extensionOf(path)] ?? OCTET_STREAM;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Whether a MIME type is text that needs an explicit charset.
|
|
274
|
+
*
|
|
275
|
+
* `text/*` and the `+json`/`+xml` suffixes are the two families where a browser guessing a charset
|
|
276
|
+
* would be guessing right most of the time and wrong exactly when it matters — a UTF-8 source file
|
|
277
|
+
* rendered as latin-1.
|
|
278
|
+
* @param contentType - the type, without parameters.
|
|
279
|
+
* @returns true when the type should carry `; charset=utf-8`.
|
|
280
|
+
*/
|
|
281
|
+
function isTextual(contentType) {
|
|
282
|
+
return contentType.startsWith("text/") || contentType === "application/json" || contentType.endsWith("+json") || contentType.endsWith("+xml");
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* How the browser should present one path.
|
|
286
|
+
*
|
|
287
|
+
* `.html` is `iframe` rather than `text`: the frame is the point of the mode, and the same-origin
|
|
288
|
+
* route injects the base URL that makes its relative assets resolve. `.svg` is treated as an
|
|
289
|
+
* `iframe` too, because an SVG document is scriptable — handing it to an `<img>` would silently
|
|
290
|
+
* drop its scripts and hand it to the frame instead keeps one behaviour for "a document".
|
|
291
|
+
* @param path - a file path or a URL pathname.
|
|
292
|
+
* @param contentType - the type it is served with; defaults to the one {@link contentTypeOf} gives.
|
|
293
|
+
* @returns the preview kind.
|
|
294
|
+
*/
|
|
295
|
+
function classifyFile(path, contentType = contentTypeOf(path)) {
|
|
296
|
+
const extension = extensionOf(path);
|
|
297
|
+
if (extension === "html" || extension === "htm" || extension === "xhtml") return "iframe";
|
|
298
|
+
if (extension === "svg") return "iframe";
|
|
299
|
+
if (MARKDOWN.has(extension)) return "markdown";
|
|
300
|
+
if (IMAGE.has(extension)) return "image";
|
|
301
|
+
if (MEDIA.has(extension)) return "media";
|
|
302
|
+
if (extension === "pdf") return "pdf";
|
|
303
|
+
if (TEXT.has(extension)) return "text";
|
|
304
|
+
if (isTextual(contentType)) return "text";
|
|
305
|
+
return "other";
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Reject anything that is not plain HTTP(S).
|
|
309
|
+
*
|
|
310
|
+
* A `file:` or `data:` URL handed to the proxy would read the Host's own disk, and a `javascript:`
|
|
311
|
+
* one would be an injection; none of them is something a preview of a dev server needs.
|
|
312
|
+
* @param value - the candidate URL.
|
|
313
|
+
* @returns the parsed URL, or the reason it was refused.
|
|
314
|
+
*/
|
|
315
|
+
function parseHttpUrl(value) {
|
|
316
|
+
let url;
|
|
317
|
+
try {
|
|
318
|
+
url = new URL(value);
|
|
319
|
+
} catch {
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
message: `${JSON.stringify(value)} is not an absolute URL`
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return {
|
|
326
|
+
ok: false,
|
|
327
|
+
message: `only http and https can be previewed (got ${url.protocol})`
|
|
328
|
+
};
|
|
329
|
+
return {
|
|
330
|
+
ok: true,
|
|
331
|
+
url
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Whether a parsed URL points at this machine.
|
|
336
|
+
*
|
|
337
|
+
* The proxy exists so a loopback dev server can be framed same-origin. Without this check it would
|
|
338
|
+
* also fetch `http://10.0.0.5/admin` on the operator's behalf, from the operator's network position
|
|
339
|
+
* — an open proxy bolted to the GUI. Only literal loopback names and addresses pass: `localhost`,
|
|
340
|
+
* `127.0.0.0/8`, and `[::1]`.
|
|
341
|
+
*
|
|
342
|
+
* A hostname that merely *resolves* to loopback (a split-horizon DNS entry, a hostfile alias) is
|
|
343
|
+
* refused rather than probed. Deciding this by lookup would make the answer depend on the resolver
|
|
344
|
+
* at request time, and a DNS rebinding attack is exactly the case where the answer changes between
|
|
345
|
+
* the check and the fetch.
|
|
346
|
+
* @param url - a parsed URL.
|
|
347
|
+
* @returns true when the host is a loopback literal.
|
|
348
|
+
*/
|
|
349
|
+
function isLoopbackHost(url) {
|
|
350
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/gu, "");
|
|
351
|
+
if (host === "localhost" || host === "::1") return true;
|
|
352
|
+
const parts = host.split(".");
|
|
353
|
+
if (parts.length !== 4) return false;
|
|
354
|
+
if (parts.some((part) => !/^\d{1,3}$/u.test(part))) return false;
|
|
355
|
+
const [first, second] = parts.map((part) => Number.parseInt(part, 10));
|
|
356
|
+
if (first !== 127) return false;
|
|
357
|
+
return second !== void 0 && second >= 0 && second <= 255;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Refuse a URL this plugin will not fetch.
|
|
361
|
+
* @param value - the candidate URL.
|
|
362
|
+
* @returns the parsed URL, or the reason it was refused.
|
|
363
|
+
*/
|
|
364
|
+
function validateProxyTarget(value) {
|
|
365
|
+
const parsed = parseHttpUrl(value);
|
|
366
|
+
if (!parsed.ok) return parsed;
|
|
367
|
+
if (!isLoopbackHost(parsed.url)) return {
|
|
368
|
+
ok: false,
|
|
369
|
+
message: `the preview proxy refuses ${parsed.url.hostname}: only this machine's own loopback dev servers are proxied, so the GUI cannot become an open proxy`
|
|
370
|
+
};
|
|
371
|
+
return parsed;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Percent-encode a value for a query string, using the one encoder every runtime here has.
|
|
375
|
+
* @param value - the raw value.
|
|
376
|
+
* @returns the encoded value.
|
|
377
|
+
*/
|
|
378
|
+
function encodeQuery(value) {
|
|
379
|
+
return encodeURIComponent(value);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Build the same-origin URL one workspace file is framed from.
|
|
383
|
+
* @param fileRoute - the absolute file route path, no trailing slash.
|
|
384
|
+
* @param workspacePath - absolute Host workspace directory.
|
|
385
|
+
* @param filePath - absolute Host path inside it.
|
|
386
|
+
* @returns the path plus query string.
|
|
387
|
+
*/
|
|
388
|
+
function fileUrl(fileRoute, workspacePath, filePath) {
|
|
389
|
+
return `${fileRoute}?workspace=${encodeQuery(workspacePath)}&path=${encodeQuery(filePath)}`;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Build the same-origin URL that proxies one absolute upstream URL.
|
|
393
|
+
* @param proxyRoute - the absolute proxy route path, no trailing slash.
|
|
394
|
+
* @param target - the loopback URL to fetch.
|
|
395
|
+
* @returns the path plus query string.
|
|
396
|
+
*/
|
|
397
|
+
function proxyUrlFor(proxyRoute, target) {
|
|
398
|
+
return `${proxyRoute}?url=${encodeQuery(target)}`;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The marker the file route appends to an HTML document's head.
|
|
402
|
+
*
|
|
403
|
+
* It is a `<base>` and nothing else. The frame's document must resolve a relative `./app.js` against
|
|
404
|
+
* the file's own directory, not against the GUI's root, and `<base href>` is the one mechanism the
|
|
405
|
+
* browser offers that does that without rewriting every attribute in the document. A document that
|
|
406
|
+
* already declares a base is left alone, because a page that states its own base means it.
|
|
407
|
+
*/
|
|
408
|
+
const BASE_MARKER = "<!--advanced-sidebar:base-->";
|
|
409
|
+
/**
|
|
410
|
+
* Escape one string for a double-quoted HTML attribute.
|
|
411
|
+
*
|
|
412
|
+
* `&` first, or the escapes below would be escaped again. Every one of the four characters is
|
|
413
|
+
* replaced, because a base href is built from workspace paths and query strings — both of which can
|
|
414
|
+
* contain any of them — and a half-escaped value is an injection: `"` alone is not enough when the
|
|
415
|
+
* value also carries `<`, which is what starts a tag the parser then honours.
|
|
416
|
+
* @param value - the raw attribute value.
|
|
417
|
+
* @returns the escaped value.
|
|
418
|
+
*/
|
|
419
|
+
function escapeAttribute(value) {
|
|
420
|
+
return value.replaceAll("&", "&").replaceAll("\"", """).replaceAll("<", "<").replaceAll(">", ">");
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Insert a `<base>` element into an HTML document's head.
|
|
424
|
+
*
|
|
425
|
+
* String surgery rather than a parser on purpose: this runs on every HTML response, the document is
|
|
426
|
+
* untrusted, and the only structural fact needed — "where does the head begin" — is unambiguous in
|
|
427
|
+
* any HTML a browser will accept. The base goes immediately after the head's own opening tag, or
|
|
428
|
+
* immediately after `<html>` when there is no head, or at the very front when there is neither —
|
|
429
|
+
* which the parser then files into the implied head, where a base belongs.
|
|
430
|
+
*
|
|
431
|
+
* The global regex is what makes "no head" mean it: `indexOf('<head')` alone matches the substring
|
|
432
|
+
* inside `<header>`, and splicing a base into a `<header>` would leave the document resolving every
|
|
433
|
+
* relative URL against the wrong place.
|
|
434
|
+
* @param html - the document text, as bytes decoded by the caller.
|
|
435
|
+
* @param baseHref - the absolute same-origin prefix relative paths resolve against.
|
|
436
|
+
* @returns the document with the base element added.
|
|
437
|
+
*/
|
|
438
|
+
function injectBase(html, baseHref) {
|
|
439
|
+
const tag = `${BASE_MARKER}<base href="${escapeAttribute(baseHref)}">`;
|
|
440
|
+
const head = /<head(?=[\s/>])[^>]*>/iu.exec(html);
|
|
441
|
+
if (head !== null) {
|
|
442
|
+
const at = head.index + head[0].length;
|
|
443
|
+
return `${html.slice(0, at)}${tag}${html.slice(at)}`;
|
|
444
|
+
}
|
|
445
|
+
const root = /<html(?=[\s/>])[^>]*>/iu.exec(html);
|
|
446
|
+
if (root !== null) {
|
|
447
|
+
const at = root.index + root[0].length;
|
|
448
|
+
return `${html.slice(0, at)}${tag}${html.slice(at)}`;
|
|
449
|
+
}
|
|
450
|
+
return tag + html;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Decode a byte window as UTF-8 for the base injection, preserving nothing it cannot decode.
|
|
454
|
+
*
|
|
455
|
+
* Non-fatal on purpose: an HTML file whose tail is cut mid-sequence is still a document worth
|
|
456
|
+
* rendering, and one replacement character is better than a 500.
|
|
457
|
+
* @param bytes - the window.
|
|
458
|
+
* @returns the decoded text.
|
|
459
|
+
*/
|
|
460
|
+
function decodeText(bytes) {
|
|
461
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
//#endregion
|
|
465
|
+
export { contentTypeOf as a, fileUrl as c, parseHttpUrl as d, proxyUrlFor as f, resolveWorkspace as h, classifyFile as i, injectBase as l, resolveInside as m, PROXY_ROUTE as n, decodeText as o, validateProxyTarget as p, SCRATCHPAD_ROUTE as r, encodeQuery as s, FILE_ROUTE as t, isTextual as u };
|