@sparkelf/dsh-patch-web-base-path 0.1.0-rc.10
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 +6 -0
- package/README.md +9 -0
- package/README.zh.md +9 -0
- package/package.json +40 -0
- package/patches/web-base-path.patch +339 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
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,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write patches/npm/web-base-path/README.md
|
|
5
|
+
README.md: 01ce2dd51187b11a7cf24b40115e0620465a1cd3
|
|
6
|
+
README.zh.md: 01156396482f6c55f1f6bfc13641e1f8983c6cb6
|
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @sparkelf/dsh-patch-web-base-path
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
This data-only package adds complete reverse-proxy mount-prefix support to the exact official DSH source base. One `--base-path /prefix` or `DSH_WEB_BASE_PATH=/prefix` value is normalized by WebServer, stripped before HTTP and upgrade route dispatch, injected as the document `<base>`, propagated to startup/browser URLs, and consumed by Connection RPC and Session export. Vite assets, parser preloads, PWA metadata, Backup, and DataOps browser requests resolve below the same document base.
|
|
6
|
+
|
|
7
|
+
The payload intentionally spans the WebServer, frontend static owner, Web app startup and shell, Connection browser transport, Session export, and Web source assets. Splitting those files would permit partially mounted deployments, so they share one patch lifecycle. Default empty-prefix behavior remains byte-for-byte root-relative from the user's perspective.
|
|
8
|
+
|
|
9
|
+
The target is official source base `cd5ef8148158c3a752a658978873241fdf8e2bbc`; the package has no JavaScript entry, lifecycle script, compatibility fallback, or credential data. Retire it when official DSH ships equivalent end-to-end base-path behavior.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @sparkelf/dsh-patch-web-base-path
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
该data-only package为exact official DSH source base增加完整reverse-proxy mount-prefix support。一个`--base-path /prefix`或`DSH_WEB_BASE_PATH=/prefix`值由WebServer normalize,在HTTP与upgrade route dispatch前strip,作为document `<base>`注入,并传到startup/browser URLs,由Connection RPC与Session export消费。Vite assets、parser preloads、PWA metadata、Backup与DataOps browser requests都在同一document base下解析。
|
|
6
|
+
|
|
7
|
+
payload有意跨越WebServer、frontend static owner、Web app startup/shell、Connection browser transport、Session export与Web source assets。拆分这些files会允许partial mounted deployments,因此它们共享一个patch lifecycle。empty-prefix default behavior从用户视角保持root-relative不变。
|
|
8
|
+
|
|
9
|
+
target是official source base `cd5ef8148158c3a752a658978873241fdf8e2bbc`;package没有JavaScript entry、lifecycle script、compatibility fallback或credential data。official DSH发布等价end-to-end base-path behavior后retire。
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Data-only exact official-source patch for complete reverse-proxy Web base-path support",
|
|
3
|
+
"dshPatch": {
|
|
4
|
+
"formatVersion": 1,
|
|
5
|
+
"variants": [
|
|
6
|
+
{
|
|
7
|
+
"dsh": ">=0.1.2-alpha.1",
|
|
8
|
+
"file": "./patches/web-base-path.patch",
|
|
9
|
+
"id": "web-reverse-proxy-base-path",
|
|
10
|
+
"target": {
|
|
11
|
+
"baseRevision": "cd5ef8148158c3a752a658978873241fdf8e2bbc",
|
|
12
|
+
"kind": "dsh-source",
|
|
13
|
+
"paths": [
|
|
14
|
+
"apps/web/",
|
|
15
|
+
"packages/bundle/web-app/",
|
|
16
|
+
"packages/client/connection/",
|
|
17
|
+
"packages/host/frontend-static/",
|
|
18
|
+
"packages/host/webserver/",
|
|
19
|
+
"packages/session-query/session-log-export/"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"patches/*.patch"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"name": "@sparkelf/dsh-patch-web-base-path",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"directory": "patches/npm/web-base-path",
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/SparkElf/deepseek-harness-plus.git"
|
|
37
|
+
},
|
|
38
|
+
"type": "module",
|
|
39
|
+
"version": "0.1.0-rc.10"
|
|
40
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
diff --git a/apps/web/index.html b/apps/web/index.html
|
|
2
|
+
index 75c4e5bac6..27024bbef4 100644
|
|
3
|
+
--- a/apps/web/index.html
|
|
4
|
+
+++ b/apps/web/index.html
|
|
5
|
+
@@ -3,12 +3,12 @@
|
|
6
|
+
<head>
|
|
7
|
+
<meta charset="utf-8" />
|
|
8
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
9
|
+
- <link rel="manifest" href="/manifest.webmanifest" />
|
|
10
|
+
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
11
|
+
+ <link rel="manifest" href="./manifest.webmanifest" />
|
|
12
|
+
+ <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
|
13
|
+
<title>DSH Local Build</title>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<div id="root"></div>
|
|
17
|
+
- <script type="module" src="/src/main.ts"></script>
|
|
18
|
+
+ <script type="module" src="./src/main.ts"></script>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
21
|
+
diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest
|
|
22
|
+
index 20a428fee6..a7cc973d9e 100644
|
|
23
|
+
--- a/apps/web/public/manifest.webmanifest
|
|
24
|
+
+++ b/apps/web/public/manifest.webmanifest
|
|
25
|
+
@@ -1,13 +1,13 @@
|
|
26
|
+
{
|
|
27
|
+
- "id": "/",
|
|
28
|
+
+ "id": ".",
|
|
29
|
+
"name": "DeepSeek Harness",
|
|
30
|
+
"short_name": "DSH",
|
|
31
|
+
- "start_url": "/",
|
|
32
|
+
- "scope": "/",
|
|
33
|
+
+ "start_url": ".",
|
|
34
|
+
+ "scope": ".",
|
|
35
|
+
"display": "fullscreen",
|
|
36
|
+
"icons": [
|
|
37
|
+
{
|
|
38
|
+
- "src": "/favicon.svg",
|
|
39
|
+
+ "src": "./favicon.svg",
|
|
40
|
+
"sizes": "any",
|
|
41
|
+
"type": "image/svg+xml",
|
|
42
|
+
"purpose": "any"
|
|
43
|
+
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml
|
|
44
|
+
index 9ee698044e..57757915ba 100644
|
|
45
|
+
--- a/packages/bundle/web-app/cordis.patch.yml
|
|
46
|
+
+++ b/packages/bundle/web-app/cordis.patch.yml
|
|
47
|
+
@@ -114,6 +114,7 @@
|
|
48
|
+
config:
|
|
49
|
+
host: !!js ctx.webStartup.host ?? '127.0.0.1'
|
|
50
|
+
port: !!js ctx.webStartup.port ?? 3080
|
|
51
|
+
+ basePath: !!js ctx.webStartup.basePath ?? process.env.DSH_WEB_BASE_PATH ?? ''
|
|
52
|
+
compression: gzip
|
|
53
|
+
compressionLevel: 1
|
|
54
|
+
compressionThresholdBytes: 1024
|
|
55
|
+
diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts
|
|
56
|
+
index d0b2635f8e..669dc756ea 100644
|
|
57
|
+
--- a/packages/bundle/web-app/src/index.ts
|
|
58
|
+
+++ b/packages/bundle/web-app/src/index.ts
|
|
59
|
+
@@ -157,9 +157,9 @@ function webSurfacePrompt(webUrl: string): string {
|
|
60
|
+
|
|
61
|
+
/** Resolve the canonical loopback URL from the active Web server. */
|
|
62
|
+
function localWebUrl(ctx: Context): string {
|
|
63
|
+
- const port = ctx.get('webServer')?.port
|
|
64
|
+
- if (port === undefined) throw new Error('web-app: webServer service missing while resolving Web runtime')
|
|
65
|
+
- return `http://${LOOPBACK_HOST}:${String(port)}`
|
|
66
|
+
+ const webServer = ctx.get('webServer')
|
|
67
|
+
+ if (webServer === undefined) throw new Error('web-app: webServer service missing while resolving Web runtime')
|
|
68
|
+
+ return `http://${LOOPBACK_HOST}:${String(webServer.port)}${webServer.basePath}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
@@ -275,7 +275,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
73
|
+
const port = connectionCtx.webServer.port
|
|
74
|
+
const lanUrl = lanCandidate === undefined
|
|
75
|
+
? undefined
|
|
76
|
+
- : connectionCtx.connection.authenticatedUrl(`http://${lanCandidate}:${String(port)}`)
|
|
77
|
+
+ : connectionCtx.connection.authenticatedUrl(`http://${lanCandidate}:${String(port)}${connectionCtx.webServer.basePath}`)
|
|
78
|
+
ANNOUNCED_ROOTS.add(connectionCtx.root)
|
|
79
|
+
if (config.printUrl) {
|
|
80
|
+
console.log(`dsh web: ${authenticatedUrl}${lanUrl === undefined ? '' : ` (LAN: ${lanUrl})`}`)
|
|
81
|
+
diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts
|
|
82
|
+
index 9faad244dd..e203b432a7 100644
|
|
83
|
+
--- a/packages/bundle/web-app/src/startup.ts
|
|
84
|
+
+++ b/packages/bundle/web-app/src/startup.ts
|
|
85
|
+
@@ -1,6 +1,6 @@
|
|
86
|
+
/**
|
|
87
|
+
* The web app's command-line provider: it parses the `dsh --profile web` flag
|
|
88
|
+
- * family (`--host`, `--port`, `--trusted-host`, `--no-open`) and its `--help`
|
|
89
|
+
+ * family (`--host`, `--port`, `--base-path`, `--trusted-host`, `--no-open`) and its `--help`
|
|
90
|
+
* text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}.
|
|
91
|
+
* Ordinary rows inject that service before reading it from lazy config.
|
|
92
|
+
* @module @deepseek-ai/dsh-web-app/startup
|
|
93
|
+
@@ -27,12 +27,15 @@ export interface WebStartupValues {
|
|
94
|
+
host?: string
|
|
95
|
+
/** `--port`, absent when the invocation did not name one. */
|
|
96
|
+
port?: number
|
|
97
|
+
+ /** `--base-path`, absent when the invocation did not name one. */
|
|
98
|
+
+ basePath?: string
|
|
99
|
+
/** Explicit `--trusted-host` authorities, in argument order. */
|
|
100
|
+
trustedHosts: string[]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The web flag family, as commander parsed it. */
|
|
104
|
+
interface WebOptions {
|
|
105
|
+
+ basePath?: string
|
|
106
|
+
host?: string
|
|
107
|
+
open: boolean
|
|
108
|
+
port?: string
|
|
109
|
+
@@ -49,12 +52,14 @@ function webCommand(): Command {
|
|
110
|
+
.description('Serve the DeepSeek Harness browser UI.')
|
|
111
|
+
.helpOption('-h, --help', 'show this help')
|
|
112
|
+
.option('--host <host>', 'bind host')
|
|
113
|
+
+ .option('--base-path <path>', 'mount the Web UI below a reverse-proxy path prefix (for example /dsh)')
|
|
114
|
+
.option('--no-open', 'do not open the Web UI in the default browser')
|
|
115
|
+
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
|
|
116
|
+
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
|
|
117
|
+
.addHelpText('after', `
|
|
118
|
+
Examples:
|
|
119
|
+
dsh --profile web serve on the composed host and port
|
|
120
|
+
+ dsh --profile web --base-path /dsh serve below a reverse-proxy prefix
|
|
121
|
+
dsh --profile web --no-open serve without opening a browser
|
|
122
|
+
dsh --profile web --port 8080 serve on another port
|
|
123
|
+
`)
|
|
124
|
+
@@ -79,6 +84,7 @@ export function apply(ctx: Context): void {
|
|
125
|
+
}
|
|
126
|
+
ctx.provide(WEB_STARTUP_SERVICE, {
|
|
127
|
+
openBrowser: options.open,
|
|
128
|
+
+ ...options.basePath !== undefined && { basePath: options.basePath },
|
|
129
|
+
...options.host !== undefined && { host: options.host },
|
|
130
|
+
...options.port !== undefined && { port: Number(options.port) },
|
|
131
|
+
trustedHosts: options.trustedHost ?? [],
|
|
132
|
+
diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts
|
|
133
|
+
index 2c3f7d3e28..1c32c56d9e 100644
|
|
134
|
+
--- a/packages/client/connection/src/client/rpc.ts
|
|
135
|
+
+++ b/packages/client/connection/src/client/rpc.ts
|
|
136
|
+
@@ -7,8 +7,7 @@ import {
|
|
137
|
+
} from '../rpc.ts'
|
|
138
|
+
import type { ClientConnectionRpc, ConnectionRpcResult } from '../rpc.ts'
|
|
139
|
+
import { randomUuid } from './random-uuid.ts'
|
|
140
|
+
-
|
|
141
|
+
-const INTERNAL_BASE = 'http://dsh.internal'
|
|
142
|
+
+const INTERNAL_BASE = 'http://dsh.internal/'
|
|
143
|
+
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
|
|
144
|
+
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
|
145
|
+
|
|
146
|
+
@@ -22,6 +21,15 @@ export type RpcStreamOpen = (
|
|
147
|
+
signal: AbortSignal,
|
|
148
|
+
) => AsyncIterable<unknown>
|
|
149
|
+
|
|
150
|
+
+/** Resolve one logical route below the runtime-injected document base. */
|
|
151
|
+
+function resolveWebUrl(path: string): URL {
|
|
152
|
+
+ const documentBase = typeof document === 'undefined' ? undefined : document.baseURI
|
|
153
|
+
+ if (documentBase !== undefined) return new URL(path.replace(/^\/+/, ''), documentBase)
|
|
154
|
+
+ const origin = (globalThis as { location?: { origin?: string } }).location?.origin
|
|
155
|
+
+ const base = origin !== undefined && origin !== 'null' ? `${origin}/` : INTERNAL_BASE
|
|
156
|
+
+ return new URL(path.replace(/^\/+/, ''), base)
|
|
157
|
+
+}
|
|
158
|
+
+
|
|
159
|
+
/**
|
|
160
|
+
* Create the browser-backed generic RPC caller.
|
|
161
|
+
* @param doFetch - transport override; defaults to the page's global fetch.
|
|
162
|
+
@@ -41,7 +49,7 @@ export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStrea
|
|
163
|
+
payload,
|
|
164
|
+
}
|
|
165
|
+
const response = await send(
|
|
166
|
+
- new URL(`${channel}/${endpoint}`, resolveBase()),
|
|
167
|
+
+ resolveWebUrl(`${channel}/${endpoint}`),
|
|
168
|
+
{
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: { 'content-type': 'application/json' },
|
|
171
|
+
@@ -105,11 +113,6 @@ function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
|
|
172
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
-function resolveBase(): string {
|
|
176
|
+
- const location = (globalThis as { location?: { origin?: string } }).location
|
|
177
|
+
- return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
|
|
178
|
+
-}
|
|
179
|
+
-
|
|
180
|
+
function assertTarget(channel: string, endpoint: string): void {
|
|
181
|
+
const segments = endpoint.split('/')
|
|
182
|
+
if (!CHANNEL_PATTERN.test(channel)
|
|
183
|
+
diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts
|
|
184
|
+
index 3d218f31e6..4f717338a7 100644
|
|
185
|
+
--- a/packages/host/frontend-static/src/index.ts
|
|
186
|
+
+++ b/packages/host/frontend-static/src/index.ts
|
|
187
|
+
@@ -119,7 +119,10 @@ export function apply(ctx: Context, config: Config): void {
|
|
188
|
+
// served form anchors them at the site root ahead of every URL-bearing tag.
|
|
189
|
+
const renderIndex = async (): Promise<string> => {
|
|
190
|
+
const body = ctx.webServer.renderIndex(await readFile(distIndex, 'utf8'))
|
|
191
|
+
- return body.replace(/<head(?:\s[^>]*)?>/i, open => `${open}<base href="/">`)
|
|
192
|
+
+ const href = `${ctx.webServer.basePath}/`
|
|
193
|
+
+ return body
|
|
194
|
+
+ .replace(/<head(?:\s[^>]*)?>/i, open => `${open}<base href="${href}">`)
|
|
195
|
+
+ .replaceAll('src="/plugins/', 'src="plugins/')
|
|
196
|
+
}
|
|
197
|
+
ctx.effect(() => ctx.webServer.registerFallback(async (req, res) => {
|
|
198
|
+
// Non-GET/HEAD without a matching named route is 405 (fallback-only
|
|
199
|
+
diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts
|
|
200
|
+
index bc41100de9..205f170baa 100644
|
|
201
|
+
--- a/packages/host/webserver/src/index.ts
|
|
202
|
+
+++ b/packages/host/webserver/src/index.ts
|
|
203
|
+
@@ -61,6 +61,8 @@ export interface Config {
|
|
204
|
+
host: '127.0.0.1' | '0.0.0.0'
|
|
205
|
+
/** Listen port; zero requests an OS-assigned port. */
|
|
206
|
+
port: number
|
|
207
|
+
+ /** External URL prefix; empty means site root, otherwise an absolute path without a trailing slash. */
|
|
208
|
+
+ basePath?: string
|
|
209
|
+
/** Response compression for socket-backed HTTP requests. @default 'none' */
|
|
210
|
+
compression?: 'none' | 'gzip'
|
|
211
|
+
/** Gzip DEFLATE level from 0 through 9. @default 1 */
|
|
212
|
+
@@ -69,6 +71,20 @@ export interface Config {
|
|
213
|
+
compressionThresholdBytes?: number
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
+/** Validate and canonicalize the external reverse-proxy mount prefix. */
|
|
217
|
+
+export function normalizeWebBasePath(value: string | undefined): string {
|
|
218
|
+
+ const candidate = value?.trim() ?? ''
|
|
219
|
+
+ if (candidate === '' || candidate === '/') return ''
|
|
220
|
+
+ if (!candidate.startsWith('/') || candidate.endsWith('/') || candidate.includes('?') || candidate.includes('#')) {
|
|
221
|
+
+ throw new Error('webserver: basePath must be empty or an absolute path without query, fragment, or trailing slash')
|
|
222
|
+
+ }
|
|
223
|
+
+ const segments = candidate.slice(1).split('/')
|
|
224
|
+
+ if (segments.some(segment => segment === '' || segment === '.' || segment === '..')) {
|
|
225
|
+
+ throw new Error('webserver: basePath must contain only non-empty path segments and no dot segments')
|
|
226
|
+
+ }
|
|
227
|
+
+ return candidate
|
|
228
|
+
+}
|
|
229
|
+
+
|
|
230
|
+
const DEFAULT_COMPRESSION = 'none' as const
|
|
231
|
+
const DEFAULT_COMPRESSION_LEVEL = 1
|
|
232
|
+
const DEFAULT_COMPRESSION_THRESHOLD_BYTES = 1024
|
|
233
|
+
@@ -125,6 +141,7 @@ export class WebServer extends Service {
|
|
234
|
+
static Config: z<Config> = z.object({
|
|
235
|
+
host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),
|
|
236
|
+
port: z.natural().max(65535).required(),
|
|
237
|
+
+ basePath: z.string().default(''),
|
|
238
|
+
compression: z.union([z.const('none'), z.const('gzip')]).default(DEFAULT_COMPRESSION),
|
|
239
|
+
compressionLevel: z.number().step(1).min(0).max(9).default(DEFAULT_COMPRESSION_LEVEL),
|
|
240
|
+
compressionThresholdBytes: z.natural().default(DEFAULT_COMPRESSION_THRESHOLD_BYTES),
|
|
241
|
+
@@ -138,11 +155,13 @@ export class WebServer extends Service {
|
|
242
|
+
private fallback: WebRoute['handler'] | undefined
|
|
243
|
+
private server!: Server
|
|
244
|
+
private listenedPort!: number
|
|
245
|
+
+ private readonly mountedBasePath: string
|
|
246
|
+
private readonly gzip: NodeMiddleware | undefined
|
|
247
|
+
|
|
248
|
+
constructor(ctx: Context, private config: Config) {
|
|
249
|
+
super(ctx, 'webServer')
|
|
250
|
+
const resolved = config as ResolvedConfig
|
|
251
|
+
+ this.mountedBasePath = normalizeWebBasePath(config.basePath)
|
|
252
|
+
this.gzip = resolved.compression === 'gzip' ? createGzipMiddleware(resolved) : undefined
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@@ -156,6 +175,11 @@ export class WebServer extends Service {
|
|
256
|
+
return this.config.host
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
+ /** External reverse-proxy mount prefix; empty means the site root. */
|
|
260
|
+
+ get basePath(): string {
|
|
261
|
+
+ return this.mountedBasePath
|
|
262
|
+
+ }
|
|
263
|
+
+
|
|
264
|
+
/**
|
|
265
|
+
* Register a named route. Duplicate (kind, path) throws — route patterns are
|
|
266
|
+
* a composition-level contract, so a collision is a misconfiguration.
|
|
267
|
+
@@ -240,6 +264,13 @@ export class WebServer extends Service {
|
|
268
|
+
// client dropping mid-body). Per-request failures log and answer 400 —
|
|
269
|
+
// never a process exit.
|
|
270
|
+
this.server = createServer((req, res) => {
|
|
271
|
+
+ const logicalUrl = this.logicalUrl(req.url ?? '/')
|
|
272
|
+
+ if (logicalUrl === null) {
|
|
273
|
+
+ res.writeHead(404)
|
|
274
|
+
+ res.end()
|
|
275
|
+
+ return
|
|
276
|
+
+ }
|
|
277
|
+
+ req.url = logicalUrl
|
|
278
|
+
const next = (): void => {
|
|
279
|
+
void handle(req, res).catch((err: unknown) => {
|
|
280
|
+
this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err)))
|
|
281
|
+
@@ -266,8 +297,13 @@ export class WebServer extends Service {
|
|
282
|
+
})
|
|
283
|
+
let route: WebUpgradeRoute | undefined
|
|
284
|
+
try {
|
|
285
|
+
- /* v8 ignore next -- node:http always sets url on server requests. */
|
|
286
|
+
- route = this.upgrades.get(new URL(req.url ?? '/', 'http://x').pathname)
|
|
287
|
+
+ const logicalUrl = this.logicalUrl(req.url ?? '/')
|
|
288
|
+
+ if (logicalUrl === null) {
|
|
289
|
+
+ socket.destroy()
|
|
290
|
+
+ return
|
|
291
|
+
+ }
|
|
292
|
+
+ req.url = logicalUrl
|
|
293
|
+
+ route = this.upgrades.get(new URL(logicalUrl, 'http://x').pathname)
|
|
294
|
+
} catch (error) {
|
|
295
|
+
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
|
|
296
|
+
socket.destroy()
|
|
297
|
+
@@ -314,6 +350,16 @@ export class WebServer extends Service {
|
|
298
|
+
}, 'webServer.listen')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
+ /** Strip the external mount prefix while retaining query text. */
|
|
302
|
+
+ private logicalUrl(rawUrl: string): string | null {
|
|
303
|
+
+ const parsed = new URL(rawUrl, 'http://x')
|
|
304
|
+
+ const basePath = this.mountedBasePath
|
|
305
|
+
+ if (basePath === '') return `${parsed.pathname}${parsed.search}`
|
|
306
|
+
+ if (parsed.pathname === basePath) return `/${parsed.search}`
|
|
307
|
+
+ if (!parsed.pathname.startsWith(`${basePath}/`)) return null
|
|
308
|
+
+ return `${parsed.pathname.slice(basePath.length)}${parsed.search}`
|
|
309
|
+
+ }
|
|
310
|
+
+
|
|
311
|
+
/** Longest-prefix-wins over the prefix table after an exact-table miss. */
|
|
312
|
+
private match(pathname: string): WebRoute | undefined {
|
|
313
|
+
const exact = this.exact.get(pathname)
|
|
314
|
+
diff --git a/packages/session-query/session-log-export/src/client/controller.ts b/packages/session-query/session-log-export/src/client/controller.ts
|
|
315
|
+
index f7f01cceb9..f69be84de1 100644
|
|
316
|
+
--- a/packages/session-query/session-log-export/src/client/controller.ts
|
|
317
|
+
+++ b/packages/session-query/session-log-export/src/client/controller.ts
|
|
318
|
+
@@ -44,10 +44,9 @@ export function downloadUrl(url: string, filename: string): void {
|
|
319
|
+
anchor.click()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
-/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */
|
|
323
|
+
+/** Resolve the browser document base. */
|
|
324
|
+
function hostBase(): string {
|
|
325
|
+
- const origin = (globalThis as { location?: { origin?: string } }).location?.origin
|
|
326
|
+
- return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal'
|
|
327
|
+
+ return document.baseURI
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function messageOf(error: unknown): string {
|
|
331
|
+
@@ -112,7 +111,7 @@ export class SessionLogDownloadController {
|
|
332
|
+
private async run(sessionId: SessionId, signal: AbortSignal): Promise<void> {
|
|
333
|
+
this.publish(sessionId, { open: true, status: 'downloading', error: null })
|
|
334
|
+
try {
|
|
335
|
+
- const url = new URL('/api/session.export', hostBase())
|
|
336
|
+
+ const url = new URL('api/session.export', hostBase())
|
|
337
|
+
url.searchParams.set('sessionId', sessionId)
|
|
338
|
+
url.searchParams.set('includeDescendants', 'true')
|
|
339
|
+
const response = await this.fetcher(url, { method: 'HEAD', signal })
|