dsh-zen-remote 2.3.11 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -78
- package/README.md +94 -84
- package/README.zh-CN.md +90 -81
- package/bin/zen-remote.mjs +5 -0
- package/docs/interface.md +6 -6
- package/docs/remote-access.en.md +6 -4
- package/docs/remote-access.md +6 -4
- package/dsh-push.mjs +96 -10
- package/lib/adapters/dsh-better-sidebar/index.js +7 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/admin-routes.js +10 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/cli/args.js +251 -0
- package/lib/cli/args.js.map +1 -0
- package/lib/cli/bootstrap.js +449 -0
- package/lib/cli/bootstrap.js.map +1 -0
- package/lib/cli/command-setup.js +484 -0
- package/lib/cli/command-setup.js.map +1 -0
- package/lib/cli/commands-client.js +359 -0
- package/lib/cli/commands-client.js.map +1 -0
- package/lib/cli/commands-host.js +1108 -0
- package/lib/cli/commands-host.js.map +1 -0
- package/lib/cli/commands-status.js +279 -0
- package/lib/cli/commands-status.js.map +1 -0
- package/lib/cli/config-writer.js +334 -0
- package/lib/cli/config-writer.js.map +1 -0
- package/lib/cli/context.js +10 -0
- package/lib/cli/context.js.map +1 -0
- package/lib/cli/i18n.js +620 -0
- package/lib/cli/i18n.js.map +1 -0
- package/lib/cli/ipc-client.js +160 -0
- package/lib/cli/ipc-client.js.map +1 -0
- package/lib/cli/ipc-server.js +464 -0
- package/lib/cli/ipc-server.js.map +1 -0
- package/lib/cli/main.js +245 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/cli/qr-terminal.js +48 -0
- package/lib/cli/qr-terminal.js.map +1 -0
- package/lib/client-data/qr.js +286 -0
- package/lib/client-data/qr.js.map +1 -0
- package/lib/client-routes.js +308 -25
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1013 -229
- package/lib/config.js +50 -12
- package/lib/config.js.map +1 -1
- package/lib/debug-report.js +28 -1
- package/lib/debug-report.js.map +1 -1
- package/lib/fetch-route-intercept.js +334 -26
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +564 -363
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +1833 -280
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +36 -10
- package/lib/merge-streams.js +103 -11
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-route-intercept.js +93 -63
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/relay-access.js +8 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +133 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-pool.js +275 -0
- package/lib/relay-pool.js.map +1 -0
- package/lib/relay-server.js +269 -17
- package/lib/relay-server.js.map +1 -1
- package/lib/restart-fields.js +6 -4
- package/lib/restart-fields.js.map +1 -1
- package/lib/share-store.js +65 -27
- package/lib/share-store.js.map +1 -1
- package/lib/state-store.js +400 -0
- package/lib/state-store.js.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/cli/args.d.ts +78 -0
- package/lib/types/cli/args.d.ts.map +1 -0
- package/lib/types/cli/bootstrap.d.ts +170 -0
- package/lib/types/cli/bootstrap.d.ts.map +1 -0
- package/lib/types/cli/command-setup.d.ts +72 -0
- package/lib/types/cli/command-setup.d.ts.map +1 -0
- package/lib/types/cli/commands-client.d.ts +36 -0
- package/lib/types/cli/commands-client.d.ts.map +1 -0
- package/lib/types/cli/commands-host.d.ts +82 -0
- package/lib/types/cli/commands-host.d.ts.map +1 -0
- package/lib/types/cli/commands-status.d.ts +46 -0
- package/lib/types/cli/commands-status.d.ts.map +1 -0
- package/lib/types/cli/config-writer.d.ts +92 -0
- package/lib/types/cli/config-writer.d.ts.map +1 -0
- package/lib/types/cli/context.d.ts +39 -0
- package/lib/types/cli/context.d.ts.map +1 -0
- package/lib/types/cli/i18n.d.ts +32 -0
- package/lib/types/cli/i18n.d.ts.map +1 -0
- package/lib/types/cli/ipc-client.d.ts +83 -0
- package/lib/types/cli/ipc-client.d.ts.map +1 -0
- package/lib/types/cli/ipc-server.d.ts +113 -0
- package/lib/types/cli/ipc-server.d.ts.map +1 -0
- package/lib/types/cli/main.d.ts +57 -0
- package/lib/types/cli/main.d.ts.map +1 -0
- package/lib/types/cli/qr-terminal.d.ts +17 -0
- package/lib/types/cli/qr-terminal.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +4 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +47 -3
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/settings/settings-css.d.ts +1 -1
- package/lib/types/client/settings/settings-css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
- package/lib/types/client-data/model-group-side.d.ts +23 -2
- package/lib/types/client-data/model-group-side.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +47 -1
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +85 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +56 -1
- package/lib/types/client-data/wizard.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +25 -1
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +40 -14
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/debug-report.d.ts.map +1 -1
- package/lib/types/fetch-route-intercept.d.ts +66 -8
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +9 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +72 -10
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +79 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +25 -9
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts +3 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +23 -2
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-pool.d.ts +148 -0
- package/lib/types/relay-pool.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +30 -3
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/restart-fields.d.ts +5 -5
- package/lib/types/restart-fields.d.ts.map +1 -1
- package/lib/types/share-store.d.ts.map +1 -1
- package/lib/types/state-store.d.ts +179 -0
- package/lib/types/state-store.d.ts.map +1 -0
- package/lib/types/upload-limits.d.ts +15 -0
- package/lib/types/upload-limits.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +21 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/types/wizard/probe.d.ts.map +1 -1
- package/lib/types/wizard/proxy-snippets.d.ts +3 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/types/wizard/routes.d.ts +1 -0
- package/lib/types/wizard/routes.d.ts.map +1 -1
- package/lib/types/wizard/status.d.ts +9 -0
- package/lib/types/wizard/status.d.ts.map +1 -1
- package/lib/types/wizard/verify.d.ts +3 -0
- package/lib/types/wizard/verify.d.ts.map +1 -1
- package/lib/upload-limits.js +15 -0
- package/lib/upload-limits.js.map +1 -1
- package/lib/virtual-id.js +60 -0
- package/lib/virtual-id.js.map +1 -1
- package/lib/wizard/probe.js +46 -1
- package/lib/wizard/probe.js.map +1 -1
- package/lib/wizard/proxy-snippets.js +14 -1
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/lib/wizard/routes.js +23 -4
- package/lib/wizard/routes.js.map +1 -1
- package/lib/wizard/status.js +21 -0
- package/lib/wizard/status.js.map +1 -1
- package/lib/wizard/verify.js +31 -8
- package/lib/wizard/verify.js.map +1 -1
- package/package.json +8 -4
- package/pwa/icons/badge-96.png +0 -0
- package/pwa/icons/badge.svg +1 -0
- package/pwa/manifest.json +2 -1
- package/pwa/sw.js +3 -1
- package/scripts/check-debug-report.mjs +41 -1
- package/scripts/check-settings-form.mjs +14 -3
- package/scripts/check-settings-view.mjs +247 -0
- package/scripts/check-wizard-ui.mjs +37 -0
- package/src/adapters/dsh-better-sidebar/index.ts +7 -3
- package/src/admin-routes.ts +10 -2
- package/src/cli/args.ts +289 -0
- package/src/cli/bootstrap.ts +569 -0
- package/src/cli/command-setup.ts +540 -0
- package/src/cli/commands-client.ts +369 -0
- package/src/cli/commands-host.ts +1127 -0
- package/src/cli/commands-status.ts +296 -0
- package/src/cli/config-writer.ts +354 -0
- package/src/cli/context.ts +38 -0
- package/src/cli/i18n.ts +645 -0
- package/src/cli/ipc-client.ts +216 -0
- package/src/cli/ipc-server.ts +531 -0
- package/src/cli/main.ts +285 -0
- package/src/cli/qr-terminal.ts +49 -0
- package/src/client/RemoteStatusIcon.tsx +4 -1
- package/src/client/current-session-report.ts +28 -4
- package/src/client/effects/model-group-side.ts +19 -5
- package/src/client/effects/remote-session.ts +26 -6
- package/src/client/locales.ts +96 -6
- package/src/client/settings/SettingsSection.tsx +287 -92
- package/src/client/settings/settings-css.ts +18 -0
- package/src/client/styles/remote-session.css.ts +15 -1
- package/src/client/wizard/GatewayWizard.tsx +470 -109
- package/src/client/wizard/cards/ProxyCard.tsx +2 -1
- package/src/client/wizard/wizard-css.ts +23 -0
- package/src/client-data/model-group-side.ts +37 -10
- package/src/client-data/remote-status.ts +112 -17
- package/src/client-data/settings-form.ts +164 -8
- package/src/client-data/wizard.ts +112 -2
- package/src/client-routes.ts +334 -26
- package/src/config.ts +62 -16
- package/src/debug-report.ts +27 -1
- package/src/fetch-route-intercept.ts +381 -32
- package/src/index.ts +566 -367
- package/src/intercept.ts +1866 -286
- package/src/merge-streams.ts +155 -10
- package/src/plugin-route-intercept.ts +120 -71
- package/src/relay-access.ts +14 -3
- package/src/relay-client.ts +163 -5
- package/src/relay-pool.ts +387 -0
- package/src/relay-server.ts +312 -16
- package/src/restart-fields.ts +6 -4
- package/src/share-store.ts +59 -26
- package/src/state-store.ts +428 -0
- package/src/upload-limits.ts +16 -0
- package/src/virtual-id.ts +57 -0
- package/src/wizard/probe.ts +54 -2
- package/src/wizard/proxy-snippets.ts +17 -2
- package/src/wizard/routes.ts +24 -5
- package/src/wizard/status.ts +28 -0
- package/src/wizard/verify.ts +33 -11
- package/AGENTS.md +0 -185
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
- package/docs/adr/0002-relay-emulates-websocket.md +0 -8
package/src/debug-report.ts
CHANGED
|
@@ -71,6 +71,32 @@ export function assembleDebugReport(input: DebugReportInput): Record<string, unk
|
|
|
71
71
|
// (a bare IP must not reach the report), serverUrl its scheme+class+port.
|
|
72
72
|
if ('host' in input.configSummary) config.host = bindHostShape(input.configSummary.host)
|
|
73
73
|
config.serverUrl = serverUrlShape(input.configSummary.serverUrl)
|
|
74
|
+
const status = redactValue(input.statusBody, context) as Record<string, unknown>
|
|
75
|
+
// MS-5: the pool status body's per-server rows each carry a REAL address;
|
|
76
|
+
// every one gets the same shape treatment `config.serverUrl` has — the
|
|
77
|
+
// deep pass would already downgrade a well-formed URL's host, but the
|
|
78
|
+
// shaper's normalization is the contract (scheme-default port omitted,
|
|
79
|
+
// unset → 'unset') and every row must meet it, not merely survive.
|
|
80
|
+
// MS-5-fix: the rebuilt row's BASE is the already-redacted row from the
|
|
81
|
+
// deep pass above (redactValue maps arrays 1:1, so index i aligns) —
|
|
82
|
+
// spreading the RAW input here would silently restore every field the
|
|
83
|
+
// pass had just cleaned, and a hostile handshake-supplied serverName
|
|
84
|
+
// would leak paths and hostnames into the report. Only `serverUrl` is
|
|
85
|
+
// overwritten, and its shape is computed from the RAW value: the redacted
|
|
86
|
+
// address (`http://lan-ip:3080`) no longer parses into scheme/host/port.
|
|
87
|
+
// A non-object raw item keeps its redacted counterpart, never the raw one.
|
|
88
|
+
const rawServers = (input.statusBody as { servers?: unknown }).servers
|
|
89
|
+
if (Array.isArray(rawServers)) {
|
|
90
|
+
const redactedServers = Array.isArray(status.servers) ? status.servers : []
|
|
91
|
+
status.servers = rawServers.map((rawItem, index) => {
|
|
92
|
+
const redacted = redactedServers[index]
|
|
93
|
+
if (rawItem === null || typeof rawItem !== 'object' || Array.isArray(rawItem)) return redacted
|
|
94
|
+
const base = redacted !== null && typeof redacted === 'object' && !Array.isArray(redacted)
|
|
95
|
+
? (redacted as Record<string, unknown>)
|
|
96
|
+
: (redactValue(rawItem, context) as Record<string, unknown>)
|
|
97
|
+
return { ...base, serverUrl: serverUrlShape((rawItem as Record<string, unknown>).serverUrl) }
|
|
98
|
+
})
|
|
99
|
+
}
|
|
74
100
|
const report: Record<string, unknown> = {
|
|
75
101
|
_meta: {
|
|
76
102
|
generatedAt: redactText(input.meta.generatedAt, context),
|
|
@@ -81,7 +107,7 @@ export function assembleDebugReport(input: DebugReportInput): Record<string, unk
|
|
|
81
107
|
...(input.meta.profileName === undefined ? {} : { profile: redactText(input.meta.profileName, context) }),
|
|
82
108
|
},
|
|
83
109
|
config,
|
|
84
|
-
status
|
|
110
|
+
status,
|
|
85
111
|
...(input.gatewaySummary === undefined ? {} : { gateway: redactValue(input.gatewaySummary, context) }),
|
|
86
112
|
logs: input.logs.map((entry) => ({
|
|
87
113
|
t: entry.t,
|
|
@@ -49,7 +49,9 @@
|
|
|
49
49
|
import { RelayError } from './relay-client.js'
|
|
50
50
|
import type { RelayClient } from './relay-client.js'
|
|
51
51
|
import { fromVirtual } from './virtual-id.js'
|
|
52
|
+
import { restoreInvokeBinary } from './invoke-binary.js'
|
|
52
53
|
import { MAX_UPLOAD_BYTES } from './relay-server.js'
|
|
54
|
+
import { MAX_FILE_MEDIA_BYTES } from './upload-limits.js'
|
|
53
55
|
|
|
54
56
|
/** The host's upload route, verbatim from its registration (RT
|
|
55
57
|
* dsh-client-file-upload lib/index.js:73). */
|
|
@@ -59,19 +61,106 @@ export const FILE_UPLOAD_PATH = '/api/session/uploadFileBinary'
|
|
|
59
61
|
* lib/index.js:488). */
|
|
60
62
|
export const SESSION_EXPORT_PATH = '/api/session.export'
|
|
61
63
|
|
|
64
|
+
/** The host's media route (P55-2): how a Markdown image `` in a
|
|
65
|
+
* session body actually loads — the renderer resolves the path against the
|
|
66
|
+
* session cwd into an ABSOLUTE server path and the browser's `<img>` asks
|
|
67
|
+
* for `/api/file?path=<encoded>` natively (no `window.fetch`, no
|
|
68
|
+
* `sessionId`), landing in this very table (RT
|
|
69
|
+
* dsh-api-session-controller lib/types/media-references.js:
|
|
70
|
+
* `registerFetchRoute({ path: '/api/file', methods: ['GET','HEAD'],
|
|
71
|
+
* requestBody: 'buffered', … })`). */
|
|
72
|
+
export const FILE_MEDIA_PATH = '/api/file'
|
|
73
|
+
|
|
74
|
+
/** The byte cap one forwarded file may answer with — defined once in
|
|
75
|
+
* src/upload-limits.ts (the one home of every MiB-scale ceiling, per the
|
|
76
|
+
* upload unification and scripts/check-upload-limits.mjs), re-exported
|
|
77
|
+
* here for the consumers the route narrative belongs to. */
|
|
78
|
+
export { MAX_FILE_MEDIA_BYTES }
|
|
79
|
+
|
|
80
|
+
/** The response headers every forwarded file answer carries — the host's
|
|
81
|
+
* own /api/file set (RT lib/types/media-references.js BASE_HEADERS +
|
|
82
|
+
* NO_STORE), so a remote-served image meets exactly the sandbox the local
|
|
83
|
+
* route would have given it. */
|
|
84
|
+
const FILE_BASE_HEADERS: Record<string, string> = {
|
|
85
|
+
'Cache-Control': 'private, no-store',
|
|
86
|
+
'X-Content-Type-Options': 'nosniff',
|
|
87
|
+
'Content-Security-Policy': "sandbox; default-src 'none'",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Extension → Content-Type for the forwarded answers. A fixed table (the
|
|
91
|
+
* host routes through `mime-types`; the reply-body vocabulary fits a table)
|
|
92
|
+
* with the same conservative default. */
|
|
93
|
+
const FILE_MEDIA_TYPES: Readonly<Record<string, string>> = {
|
|
94
|
+
png: 'image/png',
|
|
95
|
+
jpg: 'image/jpeg',
|
|
96
|
+
jpeg: 'image/jpeg',
|
|
97
|
+
gif: 'image/gif',
|
|
98
|
+
webp: 'image/webp',
|
|
99
|
+
svg: 'image/svg+xml',
|
|
100
|
+
bmp: 'image/bmp',
|
|
101
|
+
ico: 'image/x-icon',
|
|
102
|
+
avif: 'image/avif',
|
|
103
|
+
pdf: 'application/pdf',
|
|
104
|
+
mp4: 'video/mp4',
|
|
105
|
+
webm: 'video/webm',
|
|
106
|
+
mov: 'video/quicktime',
|
|
107
|
+
mp3: 'audio/mpeg',
|
|
108
|
+
wav: 'audio/wav',
|
|
109
|
+
ogg: 'audio/ogg',
|
|
110
|
+
json: 'application/json',
|
|
111
|
+
txt: 'text/plain; charset=utf-8',
|
|
112
|
+
log: 'text/plain; charset=utf-8',
|
|
113
|
+
md: 'text/markdown; charset=utf-8',
|
|
114
|
+
html: 'text/html; charset=utf-8',
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The Content-Type one forwarded file answers with, read from its path
|
|
118
|
+
* extension (case-folded; no extension → the stream default). */
|
|
119
|
+
function mediaTypeOfPath(filePath: string): string {
|
|
120
|
+
const dot = filePath.lastIndexOf('.')
|
|
121
|
+
if (dot === -1) return 'application/octet-stream'
|
|
122
|
+
return FILE_MEDIA_TYPES[filePath.slice(dot + 1).toLowerCase()] ?? 'application/octet-stream'
|
|
123
|
+
}
|
|
124
|
+
|
|
62
125
|
/** The remote upload cap, the relay's own 500 MiB (relay-server.ts explains
|
|
63
126
|
* why the host has no number to mirror). Interpolated into the local
|
|
64
127
|
* oversize refusal so the card names the real limit. */
|
|
65
128
|
const UPLOAD_CAP_MIB = Math.floor(MAX_UPLOAD_BYTES / (1024 * 1024))
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
129
|
+
const FILE_MEDIA_CAP_MIB = Math.floor(MAX_FILE_MEDIA_BYTES / (1024 * 1024))
|
|
130
|
+
|
|
131
|
+
export type FetchRouteLang = 'zh' | 'en'
|
|
132
|
+
|
|
133
|
+
const FETCH_ROUTE_COPY = {
|
|
134
|
+
zh: {
|
|
135
|
+
oversize: `文件超过远程上传上限(${UPLOAD_CAP_MIB} MiB)`,
|
|
136
|
+
fileMediaOversize: `文件超过远程读取上限(${FILE_MEDIA_CAP_MIB} MiB)`,
|
|
137
|
+
offlineUpload: '服务端离线,远程会话暂时无法上传文件',
|
|
138
|
+
mismatch: '此远程会话属于其他主服务端',
|
|
139
|
+
interrupted: `上传中断,文件可能超过远程上传上限(${UPLOAD_CAP_MIB} MiB)或网络不稳定`,
|
|
140
|
+
exportUnsupported: '远程会话不支持导出',
|
|
141
|
+
offlineFile: '服务端离线,远程文件暂时无法读取',
|
|
142
|
+
fileFailed: '远程文件读取失败',
|
|
143
|
+
fileNotFound: '远程与本机都不存在该文件',
|
|
144
|
+
fileForbidden: '远程文件不可读取',
|
|
145
|
+
},
|
|
146
|
+
en: {
|
|
147
|
+
oversize: `File exceeds the remote upload limit (${UPLOAD_CAP_MIB} MiB)`,
|
|
148
|
+
fileMediaOversize: `File exceeds the remote read limit (${FILE_MEDIA_CAP_MIB} MiB)`,
|
|
149
|
+
offlineUpload: 'Server offline — cannot upload files to this remote session right now',
|
|
150
|
+
mismatch: 'This remote session belongs to another server',
|
|
151
|
+
interrupted: `Upload interrupted — the file may exceed the remote upload limit (${UPLOAD_CAP_MIB} MiB) or the connection dropped`,
|
|
152
|
+
exportUnsupported: 'Export is not supported for remote sessions',
|
|
153
|
+
offlineFile: 'Server offline — remote file cannot be read right now',
|
|
154
|
+
fileFailed: 'Failed to read remote file',
|
|
155
|
+
fileNotFound: 'File not found on the server or on this machine',
|
|
156
|
+
fileForbidden: 'Remote file is not readable',
|
|
157
|
+
},
|
|
158
|
+
} as const
|
|
159
|
+
|
|
160
|
+
function resolveFetchRouteLang(lang: FetchRouteLang | (() => FetchRouteLang) | undefined): FetchRouteLang {
|
|
161
|
+
const value = typeof lang === 'function' ? lang() : lang
|
|
162
|
+
return value === 'en' ? 'en' : 'zh'
|
|
163
|
+
}
|
|
75
164
|
|
|
76
165
|
/** Verdict of {@link checkFetchRouteShape}. STRUCTURAL only: `ok` answers
|
|
77
166
|
* "is this a connection service with a fetch-route table at all"; each
|
|
@@ -124,9 +213,14 @@ export function shouldFallbackInstall(state: {
|
|
|
124
213
|
* Check the RAW connection service instance against the shape the wrap
|
|
125
214
|
* depends on. Pure inspection — nothing is mutated, so a failed check
|
|
126
215
|
* leaves every route exactly as it was. Only the structural facts refuse;
|
|
127
|
-
* the
|
|
216
|
+
* the entries' facts are described in `notes` (they attach lazily).
|
|
217
|
+
*
|
|
218
|
+
* `probeFileMedia` (P55-2) adds the `/api/file` entry's note and — load-
|
|
219
|
+
* bearing for the legacy probe-count pins — its table READ. It defaults to
|
|
220
|
+
* false so a caller without a `resolveRemoteFileScope` (every pre-P55-2
|
|
221
|
+
* call site and test) touches exactly the two routes it always did.
|
|
128
222
|
*/
|
|
129
|
-
export function checkFetchRouteShape(connection: unknown): FetchRouteShapeCheck {
|
|
223
|
+
export function checkFetchRouteShape(connection: unknown, probeFileMedia = false): FetchRouteShapeCheck {
|
|
130
224
|
if (!isRecord(connection) || Array.isArray(connection)) {
|
|
131
225
|
return { ok: false, reasons: ['connection: not an object'] }
|
|
132
226
|
}
|
|
@@ -139,6 +233,7 @@ export function checkFetchRouteShape(connection: unknown): FetchRouteShapeCheck
|
|
|
139
233
|
notes: [
|
|
140
234
|
describeEntry(routes.get(FILE_UPLOAD_PATH), 'upload', ['POST'], 'streaming'),
|
|
141
235
|
describeEntry(routes.get(SESSION_EXPORT_PATH), 'export', ['GET', 'HEAD'], 'buffered'),
|
|
236
|
+
...(probeFileMedia ? [describeEntry(routes.get(FILE_MEDIA_PATH), 'file', ['GET', 'HEAD'], 'buffered')] : []),
|
|
142
237
|
],
|
|
143
238
|
}
|
|
144
239
|
}
|
|
@@ -172,7 +267,7 @@ export interface FetchRouteFailureRecord {
|
|
|
172
267
|
/** ISO timestamp of the moment the refusal was answered. */
|
|
173
268
|
time: string
|
|
174
269
|
/** Which wrapper answered it. */
|
|
175
|
-
route: 'upload' | 'export'
|
|
270
|
+
route: 'upload' | 'export' | 'file'
|
|
176
271
|
code: string
|
|
177
272
|
}
|
|
178
273
|
|
|
@@ -208,6 +303,24 @@ export interface FetchRouteInterceptDiagnostics {
|
|
|
208
303
|
exportBlocked: number
|
|
209
304
|
/** The most recent refusals, oldest first, capped. */
|
|
210
305
|
recentFailures: FetchRouteFailureRecord[]
|
|
306
|
+
// ---- the /api/file media wrap (P55-2) — present ONLY when the wiring
|
|
307
|
+
// handed a resolveRemoteFileScope over; absent otherwise, which is how the
|
|
308
|
+
// legacy diagnostics consumers (and tests) tell the two generations apart.
|
|
309
|
+
/** The media entry currently holds our wrapper. */
|
|
310
|
+
fileWrapped?: boolean
|
|
311
|
+
/** The media entry was absent at last check and the attach retry is still
|
|
312
|
+
* running. */
|
|
313
|
+
filePending?: boolean
|
|
314
|
+
/** The media entry was present but misshaped — attach refused for THIS
|
|
315
|
+
* route only; the reason is stable diagnostics text. */
|
|
316
|
+
fileAttachRefused?: string | undefined
|
|
317
|
+
/** Every media request that entered the wrapper, passthroughs included. */
|
|
318
|
+
fileCalls?: number
|
|
319
|
+
/** Media requests served through the relay. */
|
|
320
|
+
fileForwarded?: number
|
|
321
|
+
/** Media requests refused (offline, mismatch, relay/remote errors,
|
|
322
|
+
* oversize). */
|
|
323
|
+
fileRefused?: number
|
|
211
324
|
}
|
|
212
325
|
|
|
213
326
|
export interface InstallFetchRouteInterceptOptions {
|
|
@@ -215,11 +328,27 @@ export interface InstallFetchRouteInterceptOptions {
|
|
|
215
328
|
* (`ctx.connection[symbols.original]`) — the one whose `fetchRoutes` Map
|
|
216
329
|
* the shared handler reads per request. */
|
|
217
330
|
connection: object
|
|
218
|
-
/**
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
331
|
+
/** SINGLE-SERVER mode: the client relay client (T23a) the uploads travel
|
|
332
|
+
* through. Required together with {@link getServerId} unless
|
|
333
|
+
* {@link getRelayForServer} is given (MS-5's pool wiring). */
|
|
334
|
+
relay?: RelayClient
|
|
335
|
+
/** SINGLE-SERVER mode: the CURRENT handshake's server id, read live per
|
|
336
|
+
* call; `undefined` makes every remote upload a `remote-offline`. */
|
|
337
|
+
getServerId?: () => string | undefined
|
|
338
|
+
/** MS-4: the relay client serving ONE paired server, looked up by the
|
|
339
|
+
* virtual id's serverId half per upload (the connection pool's
|
|
340
|
+
* `getByServerId`). When given, routing reads it alone: `undefined` (a
|
|
341
|
+
* server the pool does not hold) answers `remote-mismatch`, a held client
|
|
342
|
+
* that is not online `remote-offline`. Absent — the single-server wiring —
|
|
343
|
+
* the upload goes through `relay` exactly when the id belongs to
|
|
344
|
+
* `getServerId()`, with the original verdict order. */
|
|
345
|
+
getRelayForServer?: (serverId: string) => RelayClient | undefined
|
|
346
|
+
/** P55-2: the intercept handle's session-attribution answer (live, so the
|
|
347
|
+
* wiring forwards `interceptHandle?.resolveRemoteFileScope`). Its PRESENCE
|
|
348
|
+
* is the media mount's switch: without it, `/api/file` is never probed,
|
|
349
|
+
* wrapped, or counted — the pre-P55-2 behavior, byte for byte (the
|
|
350
|
+
* legacy probe-count pins depend on this). */
|
|
351
|
+
resolveRemoteFileScope?: (path: string) => { serverId: string; sessionId: string } | undefined
|
|
223
352
|
/** Progress logging, wired to the context logger by index.ts. */
|
|
224
353
|
log?: (format: string, ...args: unknown[]) => void
|
|
225
354
|
/** The lazy-attach retry cadence (T51-fix): how often the table is
|
|
@@ -227,6 +356,9 @@ export interface InstallFetchRouteInterceptOptions {
|
|
|
227
356
|
* before giving up. Defaults 3000 ms × 10; tests shrink both. */
|
|
228
357
|
attachRetryMs?: number
|
|
229
358
|
attachAttempts?: number
|
|
359
|
+
/** Language for error messages (`'zh'` default, `'en'` English). Accepts a
|
|
360
|
+
* getter for live resolution. */
|
|
361
|
+
lang?: FetchRouteLang | (() => FetchRouteLang) | undefined
|
|
230
362
|
}
|
|
231
363
|
|
|
232
364
|
export interface FetchRouteInterceptHandle {
|
|
@@ -295,15 +427,31 @@ const LINK_DOWN_CODES: ReadonlySet<string> = new Set(['offline', 'unpaired', 're
|
|
|
295
427
|
* present-but-misshaped.
|
|
296
428
|
*/
|
|
297
429
|
export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOptions): FetchRouteInterceptHandle {
|
|
298
|
-
const { connection, relay, getServerId, log } = options
|
|
430
|
+
const { connection, relay, getServerId, getRelayForServer, log } = options
|
|
431
|
+
// Exactly one mode, like installIntercept: the pool face or the single
|
|
432
|
+
// row client — a wiring that passes neither would silently send every
|
|
433
|
+
// remote upload to remote-offline, so it is refused here instead.
|
|
434
|
+
if (getRelayForServer === undefined && (relay === undefined || getServerId === undefined)) {
|
|
435
|
+
throw new Error('installFetchRouteIntercept requires either `relay` + `getServerId` (single server) or `getRelayForServer`')
|
|
436
|
+
}
|
|
437
|
+
// The single-server facts, proven present by the guard above. The
|
|
438
|
+
// single-server code path reads THESE (never the optionals), so the old
|
|
439
|
+
// behavior stays byte-identical without a narrowing dance.
|
|
440
|
+
const singleRelay = relay as RelayClient
|
|
441
|
+
const singleGetServerId = getServerId as () => string | undefined
|
|
442
|
+
const resolveRemoteFileScope = options.resolveRemoteFileScope
|
|
443
|
+
// The media mount's switch (P55-2): its presence probes, wraps and counts
|
|
444
|
+
// `/api/file`; its absence keeps every pre-P55-2 behavior byte-identical —
|
|
445
|
+
// including the exact fetchRoutes probe counts the legacy tests pin.
|
|
446
|
+
const fileMediaEnabled = resolveRemoteFileScope !== undefined
|
|
299
447
|
const attachRetryMs = options.attachRetryMs ?? DEFAULT_ATTACH_RETRY_MS
|
|
300
448
|
const attachAttempts = options.attachAttempts ?? DEFAULT_ATTACH_ATTEMPTS
|
|
301
|
-
const shape = checkFetchRouteShape(connection)
|
|
449
|
+
const shape = checkFetchRouteShape(connection, fileMediaEnabled)
|
|
302
450
|
const routes = (connection as { fetchRoutes: Map<string, FetchRouteEntry> }).fetchRoutes
|
|
303
451
|
|
|
304
452
|
let installed = true
|
|
305
453
|
const failures: FetchRouteFailureRecord[] = []
|
|
306
|
-
const counters = { uploadCalls: 0, uploadForwarded: 0, uploadRefused: 0, exportBlocked: 0 }
|
|
454
|
+
const counters = { uploadCalls: 0, uploadForwarded: 0, uploadRefused: 0, exportBlocked: 0, fileCalls: 0, fileForwarded: 0, fileRefused: 0 }
|
|
307
455
|
|
|
308
456
|
// The per-route attach state. `undefined` entry = absent so far (the
|
|
309
457
|
// retry may still land it); a refusal string = present-but-misshaped,
|
|
@@ -314,17 +462,37 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
|
|
|
314
462
|
let exportEntry: FetchRouteEntry | undefined
|
|
315
463
|
let savedExport: FetchRouteEntry['fetch'] | undefined
|
|
316
464
|
let exportAttachRefused: string | undefined
|
|
465
|
+
let fileEntry: FetchRouteEntry | undefined
|
|
466
|
+
let savedFile: FetchRouteEntry['fetch'] | undefined
|
|
467
|
+
let fileAttachRefused: string | undefined
|
|
317
468
|
|
|
469
|
+
const copy = () => FETCH_ROUTE_COPY[resolveFetchRouteLang(options.lang)]
|
|
318
470
|
const recordFailure = (route: FetchRouteFailureRecord['route'], code: string): void => {
|
|
319
471
|
if (failures.length >= MAX_FAILURES) failures.shift()
|
|
320
472
|
failures.push({ time: new Date().toISOString(), route, code })
|
|
321
473
|
}
|
|
322
474
|
|
|
475
|
+
/** The relay client one virtual id's upload travels through, or the
|
|
476
|
+
* refusal. Single-server mode keeps the original order (offline before
|
|
477
|
+
* mismatch); pool mode (MS-4) asks the pool for the id's own server. */
|
|
478
|
+
const uploadRelayFor = (serverId: string): RelayClient | 'remote-offline' | 'remote-mismatch' => {
|
|
479
|
+
if (getRelayForServer !== undefined) {
|
|
480
|
+
const target = getRelayForServer(serverId)
|
|
481
|
+
if (target === undefined) return 'remote-mismatch'
|
|
482
|
+
return target.state === 'online' ? target : 'remote-offline'
|
|
483
|
+
}
|
|
484
|
+
const current = singleGetServerId()
|
|
485
|
+
if (singleRelay.state !== 'online' || current === undefined) return 'remote-offline'
|
|
486
|
+
if (serverId !== current) return 'remote-mismatch'
|
|
487
|
+
return singleRelay
|
|
488
|
+
}
|
|
489
|
+
|
|
323
490
|
const wrappedUpload = async (request: Request): Promise<Response> => {
|
|
324
491
|
counters.uploadCalls += 1
|
|
325
492
|
// The other half may have registered while we were installing: every
|
|
326
493
|
// real call re-probes a still-missing route before doing anything else.
|
|
327
494
|
if (installed && exportEntry === undefined && exportAttachRefused === undefined) attachExport()
|
|
495
|
+
if (installed && fileMediaEnabled && fileEntry === undefined && fileAttachRefused === undefined) attachFile()
|
|
328
496
|
const passthrough = (): Promise<Response> =>
|
|
329
497
|
(savedUpload as FetchRouteEntry['fetch']).call(uploadEntry, request)
|
|
330
498
|
// An uninstalled (chained-over) wrap is INERT, not absent.
|
|
@@ -354,27 +522,27 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
|
|
|
354
522
|
counters.uploadRefused += 1
|
|
355
523
|
recordFailure('upload', 'payload-too-large')
|
|
356
524
|
log?.('upload for %s refused locally: payload-too-large (%s declared)', sessionId, declaredLength)
|
|
357
|
-
return uploadFailure('payload-too-large',
|
|
525
|
+
return uploadFailure('payload-too-large', copy().oversize)
|
|
358
526
|
}
|
|
359
|
-
const
|
|
360
|
-
if (
|
|
527
|
+
const target = uploadRelayFor(parts.serverId)
|
|
528
|
+
if (target === 'remote-offline') {
|
|
361
529
|
counters.uploadRefused += 1
|
|
362
530
|
recordFailure('upload', 'remote-offline')
|
|
363
531
|
log?.('upload for %s refused: relay not online', sessionId)
|
|
364
|
-
return uploadFailure('remote-offline',
|
|
532
|
+
return uploadFailure('remote-offline', copy().offlineUpload)
|
|
365
533
|
}
|
|
366
|
-
if (
|
|
534
|
+
if (target === 'remote-mismatch') {
|
|
367
535
|
counters.uploadRefused += 1
|
|
368
536
|
recordFailure('upload', 'remote-mismatch')
|
|
369
537
|
log?.('upload for %s refused: belongs to another server', sessionId)
|
|
370
|
-
return uploadFailure('remote-mismatch',
|
|
538
|
+
return uploadFailure('remote-mismatch', copy().mismatch)
|
|
371
539
|
}
|
|
372
540
|
counters.uploadForwarded += 1
|
|
373
541
|
try {
|
|
374
542
|
// The request's own body stream rides to the server UNREAD by us; the
|
|
375
543
|
// browser's abort signal rides along so a cancelled picker closes the
|
|
376
544
|
// relay round-trip too.
|
|
377
|
-
const result = await
|
|
545
|
+
const result = await target.upload(
|
|
378
546
|
{ sessionId: parts.id, name: firstQuery(url, 'name'), body: request.body, bytes: declared },
|
|
379
547
|
request.signal,
|
|
380
548
|
)
|
|
@@ -400,9 +568,9 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
|
|
|
400
568
|
// judges the link — while a status-carrying `offline` is the relay
|
|
401
569
|
// CHAIN answering 502/503/504, which IS the gateway being down.
|
|
402
570
|
if (error.code === 'offline' && error.status === undefined) {
|
|
403
|
-
return uploadFailure('upload-interrupted',
|
|
571
|
+
return uploadFailure('upload-interrupted', copy().interrupted)
|
|
404
572
|
}
|
|
405
|
-
return uploadFailure('remote-offline',
|
|
573
|
+
return uploadFailure('remote-offline', copy().offlineUpload)
|
|
406
574
|
}
|
|
407
575
|
// A business answer — the relay's own refusal (`not-shared`,
|
|
408
576
|
// `payload-too-large`, …) or the upstream route's failure envelope —
|
|
@@ -415,6 +583,7 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
|
|
|
415
583
|
|
|
416
584
|
const wrappedExport = async (request: Request): Promise<Response> => {
|
|
417
585
|
if (installed && uploadEntry === undefined && uploadAttachRefused === undefined) attachUpload()
|
|
586
|
+
if (installed && fileMediaEnabled && fileEntry === undefined && fileAttachRefused === undefined) attachFile()
|
|
418
587
|
const passthrough = (): Promise<Response> =>
|
|
419
588
|
savedExport !== undefined ? savedExport.call(exportEntry, request) : Promise.resolve(new Response('not found', { status: 404 }))
|
|
420
589
|
if (!installed) return passthrough()
|
|
@@ -430,7 +599,144 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
|
|
|
430
599
|
if (parts === undefined) return passthrough()
|
|
431
600
|
counters.exportBlocked += 1
|
|
432
601
|
recordFailure('export', 'remote-unsupported')
|
|
433
|
-
return statusFailure(403, 'remote-unsupported',
|
|
602
|
+
return statusFailure(403, 'remote-unsupported', copy().exportUnsupported)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** One /api/file answer the relay could not serve, with the honest
|
|
606
|
+
* status. The `<img>` consumer reads the status alone (it renders no
|
|
607
|
+
* error body), so unlike the upload wrap these keep real statuses — a
|
|
608
|
+
* 200-with-envelope here would render a BROKEN image while pretending
|
|
609
|
+
* success. */
|
|
610
|
+
const fetchRemoteFile = async (
|
|
611
|
+
scope: { serverId: string; sessionId: string },
|
|
612
|
+
filePath: string,
|
|
613
|
+
request: Request,
|
|
614
|
+
passthrough: () => Promise<Response>,
|
|
615
|
+
): Promise<Response> => {
|
|
616
|
+
const target = uploadRelayFor(scope.serverId)
|
|
617
|
+
if (target === 'remote-offline') {
|
|
618
|
+
counters.fileRefused += 1
|
|
619
|
+
recordFailure('file', 'remote-offline')
|
|
620
|
+
return statusFailure(503, 'remote-offline', copy().offlineFile)
|
|
621
|
+
}
|
|
622
|
+
if (target === 'remote-mismatch') {
|
|
623
|
+
counters.fileRefused += 1
|
|
624
|
+
recordFailure('file', 'remote-mismatch')
|
|
625
|
+
return statusFailure(403, 'remote-mismatch', copy().mismatch)
|
|
626
|
+
}
|
|
627
|
+
if (typeof (target as { invoke?: unknown }).invoke !== 'function') {
|
|
628
|
+
counters.fileRefused += 1
|
|
629
|
+
recordFailure('file', 'internal')
|
|
630
|
+
return statusFailure(502, 'internal', copy().fileFailed)
|
|
631
|
+
}
|
|
632
|
+
const mediaType = mediaTypeOfPath(filePath)
|
|
633
|
+
try {
|
|
634
|
+
if (request.method === 'HEAD') {
|
|
635
|
+
// Size only: stat answers `bytes` (RT dsh-api-workspace-files
|
|
636
|
+
// lib/typert.remote-client.js — the result carries
|
|
637
|
+
// absolutePath/version and an OPTIONAL `bytes`).
|
|
638
|
+
const stat = await target.invoke('workspaceFiles', 'stat', { workspaceFileScopeId: scope.sessionId, path: filePath }, request.signal)
|
|
639
|
+
const size = isRecord(stat) && typeof stat.bytes === 'number' ? stat.bytes : undefined
|
|
640
|
+
if (size !== undefined && size > MAX_FILE_MEDIA_BYTES) {
|
|
641
|
+
counters.fileRefused += 1
|
|
642
|
+
return statusFailure(413, 'file-too-large', copy().fileMediaOversize)
|
|
643
|
+
}
|
|
644
|
+
return new Response(null, {
|
|
645
|
+
status: 200,
|
|
646
|
+
headers: {
|
|
647
|
+
...FILE_BASE_HEADERS,
|
|
648
|
+
'Content-Type': mediaType,
|
|
649
|
+
...(size !== undefined ? { 'Content-Length': String(size) } : {}),
|
|
650
|
+
},
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
// Whole file: readBytes answers a marker-encoded result
|
|
654
|
+
// (`data: {$zr-u8: base64}` on the JSON channel), restored here.
|
|
655
|
+
const raw = await target.invoke(
|
|
656
|
+
'workspaceFiles',
|
|
657
|
+
'readBytes',
|
|
658
|
+
{ workspaceFileScopeId: scope.sessionId, path: filePath, options: {} },
|
|
659
|
+
request.signal,
|
|
660
|
+
)
|
|
661
|
+
const restored = restoreInvokeBinary(raw)
|
|
662
|
+
const data = isRecord(restored) && restored.data instanceof Uint8Array ? (restored.data as Uint8Array) : undefined
|
|
663
|
+
if (data === undefined) {
|
|
664
|
+
counters.fileRefused += 1
|
|
665
|
+
recordFailure('file', 'internal')
|
|
666
|
+
return statusFailure(502, 'internal', copy().fileFailed)
|
|
667
|
+
}
|
|
668
|
+
if (data.byteLength > MAX_FILE_MEDIA_BYTES) {
|
|
669
|
+
counters.fileRefused += 1
|
|
670
|
+
return statusFailure(413, 'file-too-large', copy().fileMediaOversize)
|
|
671
|
+
}
|
|
672
|
+
return new Response(data.slice(), {
|
|
673
|
+
status: 200,
|
|
674
|
+
headers: { ...FILE_BASE_HEADERS, 'Content-Type': mediaType, 'Content-Length': String(data.byteLength) },
|
|
675
|
+
})
|
|
676
|
+
} catch (error) {
|
|
677
|
+
const code = error instanceof RelayError ? error.code : 'internal'
|
|
678
|
+
recordFailure('file', code)
|
|
679
|
+
log?.('file %s for %s failed through the relay: %s', filePath, scope.sessionId, code)
|
|
680
|
+
// The file the session cited may exist on BOTH machines (the reply
|
|
681
|
+
// quoted a path the sub-client also has, or the remote record is
|
|
682
|
+
// stale): a not-found answer falls back to the LOCAL route once —
|
|
683
|
+
// its 200 is adopted, anything else becomes our 404.
|
|
684
|
+
if (code === 'workspace-file/not-found' || code === 'FS_NOT_FOUND') {
|
|
685
|
+
const local = await passthrough()
|
|
686
|
+
if (local.status === 200) return local
|
|
687
|
+
counters.fileRefused += 1
|
|
688
|
+
return statusFailure(404, 'file-not-found', copy().fileNotFound)
|
|
689
|
+
}
|
|
690
|
+
counters.fileRefused += 1
|
|
691
|
+
if (code === 'workspace-file/too-large' || code === 'FS_TOO_LARGE') {
|
|
692
|
+
return statusFailure(413, 'file-too-large', copy().fileMediaOversize)
|
|
693
|
+
}
|
|
694
|
+
if (
|
|
695
|
+
code === 'workspace-file/not-regular-file' ||
|
|
696
|
+
code === 'FS_NOT_REGULAR_FILE' ||
|
|
697
|
+
code === 'FS_PERMISSION_DENIED' ||
|
|
698
|
+
code === 'FS_SANDBOX_DENIED' ||
|
|
699
|
+
code === 'not-shared'
|
|
700
|
+
) {
|
|
701
|
+
return statusFailure(403, code, copy().fileForbidden)
|
|
702
|
+
}
|
|
703
|
+
if (LINK_DOWN_CODES.has(code)) return statusFailure(503, code, copy().offlineFile)
|
|
704
|
+
return statusFailure(502, code, copy().fileFailed)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const wrappedFile = async (request: Request): Promise<Response> => {
|
|
709
|
+
counters.fileCalls += 1
|
|
710
|
+
// The other halves may have registered while we were installing (the
|
|
711
|
+
// same courtesy the upload and export wrappers pay each other).
|
|
712
|
+
if (installed && uploadEntry === undefined && uploadAttachRefused === undefined) attachUpload()
|
|
713
|
+
if (installed && exportEntry === undefined && exportAttachRefused === undefined) attachExport()
|
|
714
|
+
const passthrough = (): Promise<Response> =>
|
|
715
|
+
savedFile !== undefined ? savedFile.call(fileEntry, request) : Promise.resolve(new Response('not found', { status: 404 }))
|
|
716
|
+
// An uninstalled (chained-over) wrap is INERT, not absent.
|
|
717
|
+
if (!installed) return passthrough()
|
|
718
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return passthrough()
|
|
719
|
+
let url: URL
|
|
720
|
+
try {
|
|
721
|
+
url = new URL(request.url)
|
|
722
|
+
} catch {
|
|
723
|
+
return passthrough()
|
|
724
|
+
}
|
|
725
|
+
if (url.pathname !== FILE_MEDIA_PATH) return passthrough()
|
|
726
|
+
const filePath = firstQuery(url, 'path')
|
|
727
|
+
if (filePath === undefined || filePath === '' || filePath.includes('\0')) return passthrough()
|
|
728
|
+
// Which remote session owns this path — an explicit VIRTUAL sessionId
|
|
729
|
+
// in the URL decides on its own (forward-compat for callers that know
|
|
730
|
+
// their session); otherwise the intercept handle attributes by cwd and
|
|
731
|
+
// current-session. `undefined` = the LOCAL route's business.
|
|
732
|
+
const explicit = firstQuery(url, 'sessionId')
|
|
733
|
+
const explicitParts = explicit === undefined || explicit === '' ? undefined : fromVirtual(explicit)
|
|
734
|
+
const scope = explicitParts !== undefined
|
|
735
|
+
? { serverId: explicitParts.serverId, sessionId: explicitParts.id }
|
|
736
|
+
: resolveRemoteFileScope?.(filePath)
|
|
737
|
+
if (scope === undefined) return passthrough()
|
|
738
|
+
counters.fileForwarded += 1
|
|
739
|
+
return fetchRemoteFile(scope, filePath, request, passthrough)
|
|
434
740
|
}
|
|
435
741
|
|
|
436
742
|
// ---- the per-route attach (T51-fix) --------------------------------------
|
|
@@ -475,10 +781,33 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
|
|
|
475
781
|
return true
|
|
476
782
|
}
|
|
477
783
|
|
|
784
|
+
/** The media half of {@link attachUpload} (P55-2). Runs only when the
|
|
785
|
+
* wiring handed a `resolveRemoteFileScope` over — without it the route is
|
|
786
|
+
* never probed at all, which is what keeps the legacy installs (and the
|
|
787
|
+
* probe-count pins) byte-identical. */
|
|
788
|
+
const attachFile = (): boolean => {
|
|
789
|
+
if (!fileMediaEnabled || !installed) return true
|
|
790
|
+
if (fileEntry !== undefined) return true
|
|
791
|
+
const candidate = routes.get(FILE_MEDIA_PATH)
|
|
792
|
+
if (candidate === undefined) return false
|
|
793
|
+
const problem = entryProblem(candidate, ['GET', 'HEAD'], 'buffered')
|
|
794
|
+
if (problem !== undefined) {
|
|
795
|
+
fileAttachRefused = problem
|
|
796
|
+
log?.('file media route attach refused: %s', problem)
|
|
797
|
+
return true
|
|
798
|
+
}
|
|
799
|
+
fileEntry = candidate
|
|
800
|
+
savedFile = fileEntry.fetch
|
|
801
|
+
fileEntry.fetch = wrappedFile
|
|
802
|
+
log?.('file media route attached')
|
|
803
|
+
return true
|
|
804
|
+
}
|
|
805
|
+
|
|
478
806
|
// First attach, right now: whatever the table already held is wrapped
|
|
479
807
|
// before this returns.
|
|
480
808
|
attachUpload()
|
|
481
809
|
attachExport()
|
|
810
|
+
attachFile()
|
|
482
811
|
|
|
483
812
|
// The bounded retry for what was absent (T51-fix): a probe every
|
|
484
813
|
// attachRetryMs, at most attachAttempts of them, stopped early once
|
|
@@ -497,7 +826,9 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
|
|
|
497
826
|
// itself, so once a route's attach is refused it must not keep the
|
|
498
827
|
// bounded retry spinning idle past it — only a route that is still
|
|
499
828
|
// genuinely absent counts as pending. (Mirrors the `uploadPending` /
|
|
500
|
-
// `exportPending` diagnostics flags.)
|
|
829
|
+
// `exportPending` / `filePending` diagnostics flags.) The media clause
|
|
830
|
+
// exists only when the scope resolver was handed over (P55-2).
|
|
831
|
+
(fileMediaEnabled && fileEntry === undefined && fileAttachRefused === undefined) ||
|
|
501
832
|
(uploadEntry === undefined && uploadAttachRefused === undefined) ||
|
|
502
833
|
(exportEntry === undefined && exportAttachRefused === undefined)
|
|
503
834
|
if (stillPending() && attempts < attachAttempts) {
|
|
@@ -505,6 +836,7 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
|
|
|
505
836
|
attempts += 1
|
|
506
837
|
attachUpload()
|
|
507
838
|
attachExport()
|
|
839
|
+
attachFile()
|
|
508
840
|
if (!stillPending() || attempts >= attachAttempts) stopRetrying()
|
|
509
841
|
}, attachRetryMs)
|
|
510
842
|
if (typeof retryTimer.unref === 'function') retryTimer.unref()
|
|
@@ -520,6 +852,7 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
|
|
|
520
852
|
// original would silently drop ITS wrap. Ours goes inert instead.
|
|
521
853
|
if (uploadEntry !== undefined && uploadEntry.fetch === wrappedUpload) uploadEntry.fetch = savedUpload as FetchRouteEntry['fetch']
|
|
522
854
|
if (exportEntry !== undefined && exportEntry.fetch === wrappedExport) exportEntry.fetch = savedExport as FetchRouteEntry['fetch']
|
|
855
|
+
if (fileEntry !== undefined && fileEntry.fetch === wrappedFile) fileEntry.fetch = savedFile as FetchRouteEntry['fetch']
|
|
523
856
|
},
|
|
524
857
|
diagnostics(): FetchRouteInterceptDiagnostics {
|
|
525
858
|
return {
|
|
@@ -531,7 +864,23 @@ export function installFetchRouteIntercept(options: InstallFetchRouteInterceptOp
|
|
|
531
864
|
exportPending: installed && exportEntry === undefined && exportAttachRefused === undefined,
|
|
532
865
|
uploadAttachRefused,
|
|
533
866
|
exportAttachRefused,
|
|
534
|
-
|
|
867
|
+
// The upload/export counters spread verbatim; the file counters and
|
|
868
|
+
// flags stay ABSENT unless the media mount is enabled (P55-2), so a
|
|
869
|
+
// pre-P55-2 diagnostics consumer sees the exact old key set.
|
|
870
|
+
uploadCalls: counters.uploadCalls,
|
|
871
|
+
uploadForwarded: counters.uploadForwarded,
|
|
872
|
+
uploadRefused: counters.uploadRefused,
|
|
873
|
+
exportBlocked: counters.exportBlocked,
|
|
874
|
+
...(fileMediaEnabled
|
|
875
|
+
? {
|
|
876
|
+
fileWrapped: fileEntry !== undefined && fileEntry.fetch === wrappedFile,
|
|
877
|
+
filePending: installed && fileEntry === undefined && fileAttachRefused === undefined,
|
|
878
|
+
fileAttachRefused,
|
|
879
|
+
fileCalls: counters.fileCalls,
|
|
880
|
+
fileForwarded: counters.fileForwarded,
|
|
881
|
+
fileRefused: counters.fileRefused,
|
|
882
|
+
}
|
|
883
|
+
: {}),
|
|
535
884
|
recentFailures: [...failures],
|
|
536
885
|
}
|
|
537
886
|
},
|