@vantran-se/openchamber-web 2.0.4 → 2.0.6
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/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-BlzB2e0C.js} +1 -1
- package/dist/assets/DiffView-D7JQzQVZ.js +4 -0
- package/dist/assets/FilesView-DC2gadXM.js +17 -0
- package/dist/assets/{GitView-DwQwqDvu.js → GitView-Gzl0wbdd.js} +4 -4
- package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-CQdDIeco.js} +1 -1
- package/dist/assets/GuestSurfacePane-CrOhlIdt.js +1 -0
- package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-C3QZifYe.js} +1 -1
- package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-X27TCI3v.js} +1 -1
- package/dist/assets/MarkdownImageGallery-BlTWU9oH.js +1 -0
- package/dist/assets/MarkdownRendererImpl-Cno7QsJO.js +12 -0
- package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-Wr_UzNtt.js} +1 -1
- package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-D6pWS7cD.js} +1 -1
- package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-DDljFbJJ.js} +3 -3
- package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-LcfEbBPi.js} +4 -4
- package/dist/assets/PluginPane-DPWPLc2f.js +3 -0
- package/dist/assets/SettingsView-CFe9O3EM.js +30 -0
- package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DT1xhvgi.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-CBHXaIkB.js} +1 -1
- package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-B_ZX8ZbZ.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-D_HQzkXU.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-BUK1ujXg.js} +1 -1
- package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-H-tYOvhO.js} +2 -2
- package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-BoJGAwCP.js} +1 -1
- package/dist/assets/changesTree-CePHbFcj.js +1 -0
- package/dist/assets/commitAndPush-BzZrF8bK.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kYyVjXLu.js} +2 -2
- package/dist/assets/de-ByfQDcPj.js +10 -0
- package/dist/assets/es-DzNbjsQS.js +16 -0
- package/dist/assets/fr-B8AVLrnX.js +14 -0
- package/dist/assets/{index-OKDYjcMF.js → index-0-ON1L7L.js} +1 -1
- package/dist/assets/index-DUke1nme.css +1 -0
- package/dist/assets/index-Di6zUe5h.js +41 -0
- package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-Bgm_aLpu.js} +1 -1
- package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-D5GMgURH.js} +1 -1
- package/dist/assets/ja-1g6S0G19.js +16 -0
- package/dist/assets/ko-CxplvYkJ.js +16 -0
- package/dist/assets/{main-CGEvLMd2.js → main--Y98ZLsD.js} +2 -2
- package/dist/assets/main-GjXmVNpD.js +896 -0
- package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
- package/dist/assets/markdownCore-CVdUcVCi.js +7 -0
- package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-tTx5mpbd.js} +2 -2
- package/dist/assets/{mobile-C5KRTNOB.js → mobile-BmbiDWGb.js} +2 -2
- package/dist/assets/patchFileDiff-CV4iVQ8L.js +13 -0
- package/dist/assets/pl-f3QyqvIB.js +16 -0
- package/dist/assets/pt-BR-BsaoaiCK.js +16 -0
- package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-BnCdnDhv.js} +2 -2
- package/dist/assets/renderMobileApp-CKn8nbSW.js +25 -0
- package/dist/assets/runtimeEndpointReset-sCi88eed.js +27 -0
- package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
- package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-O5_xLmWt.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-XM4VefqN.js} +1 -1
- package/dist/assets/tr-BNFsa6nz.js +7 -0
- package/dist/assets/uk-CJ9y5ULA.js +16 -0
- package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-BN06mgHB.js} +160 -156
- package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Bb9Gjz1Q.js} +2 -2
- package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-Chk1cFUw.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-C2qMZiA7.js} +2 -2
- package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
- package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
- package/dist/assets/{web-components-BEo4-ISu.js → web-components-BQWjCn_U.js} +1 -1
- package/dist/assets/zh-CN-U0QjVlAA.js +16 -0
- package/dist/assets/zh-TW-J9PTJQJi.js +16 -0
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +4 -4
- package/server/index.js +82 -0
- package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
- package/server/lib/agent-tool/runtime.js +34 -15
- package/server/lib/agent-tool/runtime.test.js +15 -0
- package/server/lib/browser-control/DOCUMENTATION.md +14 -2
- package/server/lib/event-stream/global-hub.js +23 -1
- package/server/lib/event-stream/global-hub.test.js +35 -0
- package/server/lib/event-stream/global-ws-bridge.js +3 -1
- package/server/lib/event-stream/upstream-reader.js +3 -2
- package/server/lib/git/DOCUMENTATION.md +1 -1
- package/server/lib/git/service.js +22 -0
- package/server/lib/git/service.test.js +8 -0
- package/server/lib/guests/DOCUMENTATION.md +4 -3
- package/server/lib/guests/catalog.js +19 -1
- package/server/lib/guests/catalog.test.js +22 -0
- package/server/lib/guests/html-styles.js +5 -3
- package/server/lib/guests/html-styles.test.js +3 -1
- package/server/lib/guests/routes.js +11 -3
- package/server/lib/guests/service.js +5 -0
- package/server/lib/guests/surface.js +55 -9
- package/server/lib/guests/surface.test.js +56 -6
- package/server/lib/notifications/DOCUMENTATION.md +10 -0
- package/server/lib/notifications/emit-route.js +118 -0
- package/server/lib/notifications/emit-route.test.js +112 -0
- package/server/lib/openchamber-control/actions.js +16 -2
- package/server/lib/openchamber-control/service.js +23 -0
- package/server/lib/openchamber-control/service.test.js +31 -0
- package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
- package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
- package/server/lib/openchamber-sessions/routes.js +11 -0
- package/server/lib/openchamber-sessions/routes.test.js +18 -0
- package/server/lib/opencode/bootstrap-runtime.js +14 -1
- package/server/lib/opencode/compatibility.js +6 -3
- package/server/lib/opencode/compatibility.test.js +8 -0
- package/server/lib/opencode/config-entity-v2.test.js +27 -0
- package/server/lib/opencode/config-paths.test.js +12 -0
- package/server/lib/opencode/config-v2.d.ts +2 -0
- package/server/lib/opencode/config-v2.js +42 -0
- package/server/lib/opencode/config-v2.test.js +17 -0
- package/server/lib/opencode/core-routes.js +20 -4
- package/server/lib/opencode/core-routes.test.js +41 -0
- package/server/lib/opencode/env-runtime.js +1 -8
- package/server/lib/opencode/feature-routes-runtime.js +2 -1
- package/server/lib/opencode/lifecycle.js +30 -2
- package/server/lib/opencode/lifecycle.test.js +27 -0
- package/server/lib/opencode/managed-config-file.js +18 -6
- package/server/lib/opencode/managed-config-file.test.js +23 -7
- package/server/lib/opencode/mcp.js +7 -2
- package/server/lib/opencode/npm-registry-config.d.ts +1 -0
- package/server/lib/opencode/npm-registry-config.js +123 -0
- package/server/lib/opencode/npm-registry-config.test.js +167 -0
- package/server/lib/opencode/npm-registry.js +5 -6
- package/server/lib/opencode/npm-registry.test.js +30 -0
- package/server/lib/opencode/openchamber-routes.js +29 -11
- package/server/lib/opencode/openchamber-routes.test.js +18 -0
- package/server/lib/opencode/project-directory-runtime.js +8 -0
- package/server/lib/opencode/project-directory-runtime.test.js +13 -0
- package/server/lib/opencode/provider-source-route.test.js +59 -0
- package/server/lib/opencode/providers.js +3 -0
- package/server/lib/opencode/providers.test.js +62 -0
- package/server/lib/opencode/proxy.js +38 -2
- package/server/lib/opencode/routes-upgrade.test.js +1 -1
- package/server/lib/opencode/routes.js +36 -1
- package/server/lib/opencode/server-utils-runtime.js +5 -0
- package/server/lib/opencode/settings-helpers.js +10 -0
- package/server/lib/opencode/settings-helpers.test.js +2 -2
- package/server/lib/opencode/settings-registry.json +17 -0
- package/server/lib/opencode/settings-runtime.js +1 -0
- package/server/lib/opencode/shared.js +49 -4
- package/server/lib/opencode/shared.test.js +78 -1
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/skill-routes.js +28 -12
- package/server/lib/opencode/skill-routes.test.js +121 -1
- package/server/lib/opencode/upgrade-capability.js +0 -14
- package/server/lib/opencode/v1-migration-topup.js +21 -15
- package/server/lib/opencode/v1-migration-topup.test.js +14 -6
- package/server/lib/opencode/v2-install.d.ts +1 -0
- package/server/lib/opencode/v2-install.js +8 -7
- package/server/lib/opencode/v2-install.test.js +13 -4
- package/server/lib/opencode/watcher.js +3 -1
- package/server/lib/opencode/websearch-config.js +13 -1
- package/server/lib/quota/providers/kimi.js +1 -1
- package/server/lib/realtime-proxy.js +5 -1
- package/server/lib/realtime-proxy.test.js +29 -0
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +11 -0
- package/server/lib/session-assist/DOCUMENTATION.md +17 -5
- package/server/lib/session-assist/runtime.js +19 -2
- package/server/lib/session-assist/runtime.test.js +8 -0
- package/server/lib/session-goal/DOCUMENTATION.md +11 -0
- package/server/lib/small-model/DOCUMENTATION.md +11 -10
- package/server/lib/small-model/index.js +27 -32
- package/server/lib/small-model/index.test.js +28 -16
- package/server/lib/spaces/DOCUMENTATION.md +404 -32
- package/server/lib/spaces/code-in-bait.js +145 -6
- package/server/lib/spaces/code-in.js +10 -150
- package/server/lib/spaces/code-in.test.js +7 -83
- package/server/lib/spaces/code-out.js +1819 -0
- package/server/lib/spaces/code-out.test.js +3945 -0
- package/server/lib/spaces/code-transfer.js +173 -0
- package/server/lib/spaces/command-stream.test.js +181 -0
- package/server/lib/spaces/dispatcher.js +670 -0
- package/server/lib/spaces/dispatcher.test.js +582 -0
- package/server/lib/spaces/gatekeeper-channel.js +61 -7
- package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
- package/server/lib/spaces/gatekeeper-program.cjs +68 -14
- package/server/lib/spaces/gatekeeper-program.test.js +118 -3
- package/server/lib/spaces/hardening.js +20 -4
- package/server/lib/spaces/hardening.test.js +18 -3
- package/server/lib/spaces/host-git.js +7 -4
- package/server/lib/spaces/host-git.test.js +6 -2
- package/server/lib/spaces/host.js +310 -0
- package/server/lib/spaces/host.test.js +273 -0
- package/server/lib/spaces/journey.js +552 -0
- package/server/lib/spaces/journey.test.js +589 -0
- package/server/lib/spaces/layout.js +42 -8
- package/server/lib/spaces/manager.js +4 -3
- package/server/lib/spaces/manager.test.js +6 -0
- package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
- package/server/lib/spaces/places/contract-suite.js +36 -0
- package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
- package/server/lib/spaces/places/docker-live-support.js +27 -4
- package/server/lib/spaces/places/docker.js +33 -5
- package/server/lib/spaces/places/docker.test.js +42 -3
- package/server/lib/spaces/places/escape-suite.js +118 -14
- package/server/lib/spaces/places/fake-docker.js +3 -1
- package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
- package/server/lib/spaces/places/memory-place.js +24 -1
- package/server/lib/spaces/places/registry.js +1 -1
- package/server/lib/spaces/places/server.docker.live.test.js +69 -33
- package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
- package/server/lib/spaces/routes.js +216 -0
- package/server/lib/spaces/routes.test.js +213 -0
- package/server/lib/spaces/run-command.js +261 -8
- package/server/lib/spaces/run-command.test.js +193 -3
- package/server/lib/spaces/space-events.js +153 -0
- package/server/lib/spaces/space-events.test.js +160 -0
- package/server/lib/spaces/space-opencode.js +52 -0
- package/server/lib/spaces/space-opencode.test.js +41 -0
- package/server/lib/spaces/space-records.js +150 -0
- package/server/lib/spaces/space-records.test.js +113 -0
- package/server/lib/spaces/space-sessions.js +210 -0
- package/server/lib/spaces/space-sessions.test.js +161 -0
- package/server/lib/spaces/websocket.js +329 -0
- package/server/lib/spaces/websocket.test.js +345 -0
- package/server/lib/ui-auth/ui-auth.js +8 -2
- package/server/lib/ui-auth/ui-auth.test.js +29 -0
- package/server/opencode-proxy.test.js +113 -0
- package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
- package/dist/assets/FilesView-BeTtbsip.js +0 -17
- package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
- package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
- package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
- package/dist/assets/PluginPane-B39JEUL8.js +0 -3
- package/dist/assets/SettingsView-13B2_G-A.js +0 -29
- package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
- package/dist/assets/de-BtYnUgII.js +0 -10
- package/dist/assets/es-crP5qPye.js +0 -16
- package/dist/assets/fr-CLSRuALk.js +0 -14
- package/dist/assets/index-CLcM8HKx.css +0 -1
- package/dist/assets/index-CvLgnuNI.js +0 -41
- package/dist/assets/ja-SZ1-ucLV.js +0 -16
- package/dist/assets/ko-HthbT_Ve.js +0 -16
- package/dist/assets/main-BJklC7Cl.js +0 -896
- package/dist/assets/markdownCore-BzQuETm0.js +0 -5
- package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
- package/dist/assets/pl-C8eYfjL1.js +0 -16
- package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
- package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
- package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
- package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
- package/dist/assets/tr-B4IQ5t-T.js +0 -7
- package/dist/assets/uk-DCoXG6ve.js +0 -16
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
- package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
- package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
- package/dist/assets/zh-TW-C6oelpnL.js +0 -16
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
// The dispatcher: a thin layer in front of the host's routes that forwards a request addressed
|
|
2
|
+
// to a space to the OpenChamber server inside that space, over the place's `connect` channel.
|
|
3
|
+
//
|
|
4
|
+
// The client addresses the space. A request goes to a space only when its path starts with
|
|
5
|
+
// `/api/spaces/<id>/` and the id is in the label-derived list of spaces. The dispatcher reads the
|
|
6
|
+
// path and nothing else to decide that: no directory, no body, no session id. It runs after the
|
|
7
|
+
// host has authenticated the user, strips the prefix and the user's credentials, adds the
|
|
8
|
+
// space's own session, and streams the rest untouched, HTTP and SSE alike. WebSocket upgrades
|
|
9
|
+
// under the prefix are forwarded by `websocket.js`, which shares the rules and the session
|
|
10
|
+
// of this module; the merged session list and the event connection of a space use
|
|
11
|
+
// `requestInside` for their own requests to the server inside.
|
|
12
|
+
//
|
|
13
|
+
// Two guards only reject. A prefixed request whose directory lies outside that space's root is
|
|
14
|
+
// refused, and an unprefixed request whose directory lies under `/spaces/` is refused before
|
|
15
|
+
// any host route sees it. Neither guard routes anything.
|
|
16
|
+
//
|
|
17
|
+
// Everything that comes back from a space is untrusted. A response cannot set a cookie on the
|
|
18
|
+
// app's origin, and nothing from a space renders as a page under it.
|
|
19
|
+
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { pipeline } from 'node:stream';
|
|
23
|
+
|
|
24
|
+
import { writeSseChunkWithBackpressure } from '../opencode/proxy.js';
|
|
25
|
+
import { SpaceError } from './errors.js';
|
|
26
|
+
import { isSpaceId } from './labels.js';
|
|
27
|
+
import { SPACE_SERVER_HOST, SPACE_SERVER_PORT, spaceWorkPath } from './layout.js';
|
|
28
|
+
|
|
29
|
+
const SPACE_ROUTE_PREFIX = '/api/spaces/';
|
|
30
|
+
const SPACES_ROOT = '/spaces';
|
|
31
|
+
|
|
32
|
+
// How long the label-derived list of spaces is trusted, and how soon a miss may read it again.
|
|
33
|
+
// The list is authoritative state read from the runtime; the cache only keeps a burst of
|
|
34
|
+
// requests from running `docker ps` for each of them.
|
|
35
|
+
const LIST_TTL_MS = 2_000;
|
|
36
|
+
const LIST_MISS_RETRY_MS = 500;
|
|
37
|
+
|
|
38
|
+
// The session inside is renewed before the cookie the server inside gave expires.
|
|
39
|
+
const SESSION_RENEW_FRACTION = 0.9;
|
|
40
|
+
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
41
|
+
const LOGIN_TIMEOUT_MS = 30_000;
|
|
42
|
+
|
|
43
|
+
// One `docker exec` per socket, so the agent keeps a few per space and reuses them. A free
|
|
44
|
+
// socket is closed after four seconds, before the server inside closes its side at five,
|
|
45
|
+
// Node's default keep-alive timeout, so a request never lands on a socket the server is closing.
|
|
46
|
+
const MAX_SOCKETS_PER_SPACE = 8;
|
|
47
|
+
const FREE_SOCKET_TIMEOUT_MS = 4_000;
|
|
48
|
+
|
|
49
|
+
// How much of an error's text from the transport an answer may carry.
|
|
50
|
+
const ERROR_TEXT_CHARACTERS = 300;
|
|
51
|
+
|
|
52
|
+
// Route families that exist on the host only, never in a space: provider and login pages,
|
|
53
|
+
// settings, GitHub, Linear, voice, guests, project routes, and the host's own system routes.
|
|
54
|
+
// A prefixed request for one of them is refused, whatever the server inside would answer.
|
|
55
|
+
const HOST_ONLY_ROUTES = Object.freeze([
|
|
56
|
+
'/api/provider',
|
|
57
|
+
'/api/providers',
|
|
58
|
+
'/api/integration',
|
|
59
|
+
'/api/credential',
|
|
60
|
+
'/api/auth',
|
|
61
|
+
'/api/config/settings',
|
|
62
|
+
'/api/config/themes',
|
|
63
|
+
'/api/themes',
|
|
64
|
+
'/api/github',
|
|
65
|
+
'/api/linear',
|
|
66
|
+
'/api/voice',
|
|
67
|
+
'/api/tts',
|
|
68
|
+
'/api/dictation',
|
|
69
|
+
'/api/guests',
|
|
70
|
+
'/api/projects',
|
|
71
|
+
'/api/client-auth',
|
|
72
|
+
'/api/system',
|
|
73
|
+
'/api/push',
|
|
74
|
+
'/api/notifications',
|
|
75
|
+
'/api/openchamber/tunnel',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// Route families refused across the boundary: they would move something between the host and
|
|
79
|
+
// a space, or render a space's file as a page under the app's origin. Moving a session, the
|
|
80
|
+
// worktree and git-integrate actions, `/api/fs/serve` and `/api/preview/proxy`.
|
|
81
|
+
const REFUSED_ACROSS_BOUNDARY_ROUTES = Object.freeze([
|
|
82
|
+
'/api/git/worktrees',
|
|
83
|
+
'/api/git/worktree-type',
|
|
84
|
+
'/api/git/integrate',
|
|
85
|
+
'/api/fs/serve',
|
|
86
|
+
'/api/preview/proxy',
|
|
87
|
+
]);
|
|
88
|
+
const SESSION_MOVE_ROUTE = /^\/api\/session\/[^/]+\/move(?:\/|$)/;
|
|
89
|
+
|
|
90
|
+
// Request headers that never travel into a space: the user's credentials, hop-by-hop headers,
|
|
91
|
+
// where the request came from, and the encoding, which is asked for again as identity.
|
|
92
|
+
const DROPPED_REQUEST_HEADERS = new Set([
|
|
93
|
+
'cookie',
|
|
94
|
+
'authorization',
|
|
95
|
+
'proxy-authorization',
|
|
96
|
+
'host',
|
|
97
|
+
'connection',
|
|
98
|
+
'keep-alive',
|
|
99
|
+
'transfer-encoding',
|
|
100
|
+
'te',
|
|
101
|
+
'trailer',
|
|
102
|
+
'upgrade',
|
|
103
|
+
'expect',
|
|
104
|
+
'origin',
|
|
105
|
+
'referer',
|
|
106
|
+
'accept-encoding',
|
|
107
|
+
'proxy-connection',
|
|
108
|
+
]);
|
|
109
|
+
const DROPPED_REQUEST_HEADER_PREFIXES = ['x-forwarded-', 'forwarded', 'sec-websocket-'];
|
|
110
|
+
|
|
111
|
+
// The only response headers that come back from a space: what a client needs to read the body
|
|
112
|
+
// and cache it. An allowlist, because a header the space chooses can act on the app's origin:
|
|
113
|
+
// `Set-Cookie` sets a cookie there, `Location` sends the user's next request, credentials and
|
|
114
|
+
// body included, to a host route the agent names, `Clear-Site-Data` wipes the app's storage.
|
|
115
|
+
const FORWARDED_RESPONSE_HEADERS = new Set([
|
|
116
|
+
'content-type',
|
|
117
|
+
'content-length',
|
|
118
|
+
'content-disposition',
|
|
119
|
+
'content-range',
|
|
120
|
+
'accept-ranges',
|
|
121
|
+
'cache-control',
|
|
122
|
+
'expires',
|
|
123
|
+
'pragma',
|
|
124
|
+
'etag',
|
|
125
|
+
'last-modified',
|
|
126
|
+
'vary',
|
|
127
|
+
'date',
|
|
128
|
+
'x-accel-buffering',
|
|
129
|
+
'x-next-cursor',
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
// Query parameters that carry the user's credentials on browser-owned URLs.
|
|
133
|
+
const DROPPED_QUERY_PARAMETERS = ['oc_url_token', 'oc_client_token'];
|
|
134
|
+
|
|
135
|
+
// Content types a browser would render as a page, with the space's script in it, under the
|
|
136
|
+
// app's origin. A response from a space with one of these is served as plain text instead.
|
|
137
|
+
const PAGE_CONTENT_TYPES = /^\s*(?:text\/html|application\/xhtml\+xml|image\/svg\+xml|text\/xml|application\/xml|application\/pdf)\b/i;
|
|
138
|
+
// What `/api/fs/raw` from a space may be served as: an image a browser only draws, or a download.
|
|
139
|
+
const RAW_FILE_CONTENT_TYPES = /^\s*(?:image\/(?:png|jpeg|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon)|video\/[a-z0-9.+-]+|audio\/[a-z0-9.+-]+)\s*(?:;|$)/i;
|
|
140
|
+
const RAW_FILE_ROUTE = '/api/fs/raw';
|
|
141
|
+
const isRawFileRoute = (pathname) => pathname.toLowerCase() === RAW_FILE_ROUTE;
|
|
142
|
+
|
|
143
|
+
/** `/api/spaces/<id>/<rest>` as `{ spaceId, path }` with `path` being `/api/<rest>` inside, or null. */
|
|
144
|
+
export function parseSpaceRoute(pathname) {
|
|
145
|
+
if (!pathname.startsWith(SPACE_ROUTE_PREFIX)) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const rest = pathname.slice(SPACE_ROUTE_PREFIX.length);
|
|
149
|
+
const slash = rest.indexOf('/');
|
|
150
|
+
if (slash <= 0) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return { spaceId: rest.slice(0, slash), path: `/api${rest.slice(slash)}` };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Whether a request path is one the dispatcher owns. Body parsers leave such a request alone. */
|
|
157
|
+
export const isSpaceRequestPath = (pathname) => parseSpaceRoute(pathname) !== null;
|
|
158
|
+
|
|
159
|
+
// A directory as a client wrote it, normalised the way a POSIX path is, with no `..` left in it.
|
|
160
|
+
const normalizeDirectory = (value) => {
|
|
161
|
+
if (value.trim() === '') return null;
|
|
162
|
+
const normalized = path.posix.normalize(value.trim().replace(/\\/g, '/'));
|
|
163
|
+
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const isUnder = (directory, root) => directory === root || directory.startsWith(`${root}/`);
|
|
167
|
+
|
|
168
|
+
/** Whether a directory lies under `/spaces/`, where only a space's own paths live. */
|
|
169
|
+
export const isSpaceDirectory = (value) => {
|
|
170
|
+
const directory = normalizeDirectory(value);
|
|
171
|
+
return directory !== null && isUnder(directory, SPACES_ROOT);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** Whether a directory lies inside this space's root, `/spaces/<id>`. */
|
|
175
|
+
export const isDirectoryOfSpace = (value, spaceId) => {
|
|
176
|
+
const directory = normalizeDirectory(value);
|
|
177
|
+
return directory !== null && isUnder(directory, spaceWorkPath(spaceId));
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const matchesRoute = (pathname, route) => pathname === route || pathname.startsWith(`${route}/`);
|
|
181
|
+
|
|
182
|
+
/** The rule a path inside a space falls under: `host_only`, `refused_across_boundary` or null. */
|
|
183
|
+
export function classifySpacePath(pathname) {
|
|
184
|
+
// Express inside routes without regard to case, so the rules read the path the same way.
|
|
185
|
+
const lower = pathname.toLowerCase();
|
|
186
|
+
if (HOST_ONLY_ROUTES.some((route) => matchesRoute(lower, route))) return 'host_only';
|
|
187
|
+
if (REFUSED_ACROSS_BOUNDARY_ROUTES.some((route) => matchesRoute(lower, route)) || SESSION_MOVE_ROUTE.test(lower)) {
|
|
188
|
+
return 'refused_across_boundary';
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const safeDecode = (value) => {
|
|
194
|
+
try {
|
|
195
|
+
return decodeURIComponent(value);
|
|
196
|
+
} catch {
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// A percent-escape in the header: the SDK sends the directory URI-encoded on every request,
|
|
202
|
+
// with no marker, and OpenCode decodes it on its side, so the guards must read what OpenCode
|
|
203
|
+
// will. The marker still says so for the values the UI encodes because they are not Latin-1.
|
|
204
|
+
const PERCENT_ESCAPE = /%[0-9a-fA-F]{2}/;
|
|
205
|
+
|
|
206
|
+
/** Every directory a request names in its query or its directory header, as OpenCode reads them. */
|
|
207
|
+
export function requestedDirectories(req, url) {
|
|
208
|
+
const directories = [];
|
|
209
|
+
for (const key of ['directory', 'location[directory]']) {
|
|
210
|
+
for (const value of url.searchParams.getAll(key)) directories.push(value);
|
|
211
|
+
}
|
|
212
|
+
const header = req.headers['x-opencode-directory'];
|
|
213
|
+
if (header !== undefined && header.length > 0) {
|
|
214
|
+
const encoded = req.headers['x-opencode-directory-encoding'] === 'uri' || PERCENT_ESCAPE.test(header);
|
|
215
|
+
directories.push(encoded ? safeDecode(header) : header);
|
|
216
|
+
}
|
|
217
|
+
return directories;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const shortText = (text) => String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, ERROR_TEXT_CHARACTERS);
|
|
221
|
+
|
|
222
|
+
const answer = (res, status, code, message) => {
|
|
223
|
+
if (res.headersSent) {
|
|
224
|
+
res.destroy();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
res.status(status).json({ error: message, code });
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** The status and code an error of the transport or of a place gets on the way back to the client. */
|
|
231
|
+
export const describeFailure = (error) => {
|
|
232
|
+
const code = error instanceof SpaceError ? error.code : null;
|
|
233
|
+
switch (code) {
|
|
234
|
+
case 'space_not_found':
|
|
235
|
+
case 'space_not_ours':
|
|
236
|
+
case 'invalid_space_id':
|
|
237
|
+
return { status: 404, code: 'space_not_found', message: 'There is no such space.' };
|
|
238
|
+
case 'space_not_running':
|
|
239
|
+
case 'space_move_unfinished':
|
|
240
|
+
return { status: 503, code, message: shortText(error.message) };
|
|
241
|
+
case 'space_token_unreadable':
|
|
242
|
+
case 'space_setup_failed':
|
|
243
|
+
case 'space_login_failed':
|
|
244
|
+
return { status: 502, code, message: shortText(error.message) };
|
|
245
|
+
case 'space_request_body_consumed':
|
|
246
|
+
return { status: 500, code, message: shortText(error.message) };
|
|
247
|
+
case 'space_redirected':
|
|
248
|
+
return { status: 502, code, message: shortText(error.message) };
|
|
249
|
+
default:
|
|
250
|
+
return { status: 502, code: 'space_unreachable', message: `The server inside the space could not be reached: ${shortText(error?.message ?? error)}` };
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const parseSetCookie = (header) => {
|
|
255
|
+
const line = header?.[0] ?? '';
|
|
256
|
+
if (line.length === 0) return null;
|
|
257
|
+
const [pair, ...attributes] = line.split(';');
|
|
258
|
+
const equals = pair.indexOf('=');
|
|
259
|
+
if (equals <= 0) return null;
|
|
260
|
+
let ttlMs = DEFAULT_SESSION_TTL_MS;
|
|
261
|
+
for (const attribute of attributes) {
|
|
262
|
+
const [name, value] = attribute.trim().split('=');
|
|
263
|
+
if (name.toLowerCase() === 'max-age' && /^\d+$/.test(value ?? '')) ttlMs = Number(value) * 1000;
|
|
264
|
+
}
|
|
265
|
+
return { cookie: pair.trim(), ttlMs };
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const hasBody = (req) => 'content-length' in req.headers || 'transfer-encoding' in req.headers;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The headers a request carries into a space: the client's, without the user's credentials and
|
|
272
|
+
* the hop-by-hop ones, plus the space's session, the loopback host and identity encoding.
|
|
273
|
+
*/
|
|
274
|
+
export const forwardRequestHeaders = (requestHeaders, cookie) => {
|
|
275
|
+
const headers = {};
|
|
276
|
+
for (const [name, value] of Object.entries(requestHeaders)) {
|
|
277
|
+
const lower = name.toLowerCase();
|
|
278
|
+
if (DROPPED_REQUEST_HEADERS.has(lower) || DROPPED_REQUEST_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix))) continue;
|
|
279
|
+
if (value !== undefined) headers[lower] = value;
|
|
280
|
+
}
|
|
281
|
+
headers.host = `${SPACE_SERVER_HOST}:${SPACE_SERVER_PORT}`;
|
|
282
|
+
headers.cookie = cookie;
|
|
283
|
+
headers['accept-encoding'] = 'identity';
|
|
284
|
+
return headers;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/** Takes the user's URL credentials off a query before it travels. */
|
|
288
|
+
export const stripCredentialQuery = (url) => {
|
|
289
|
+
for (const parameter of DROPPED_QUERY_PARAMETERS) url.searchParams.delete(parameter);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* `transport` is `{ listSpaceIds(), connect(spaceId), readToken(spaceId) }`: the ids from the
|
|
294
|
+
* runtime's labels, the place's `connect`, and the token read back over `exec`. `now` and
|
|
295
|
+
* `logger` are injectable for the tests.
|
|
296
|
+
*/
|
|
297
|
+
export function createSpaceDispatcher({ transport, now = Date.now, logger = console }) {
|
|
298
|
+
const agents = new Map();
|
|
299
|
+
const tokens = new Map();
|
|
300
|
+
const sessions = new Map();
|
|
301
|
+
const logins = new Map();
|
|
302
|
+
let known = { ids: new Set(), readAt: -Infinity };
|
|
303
|
+
let listing = null;
|
|
304
|
+
|
|
305
|
+
const readSpaceIds = async () => {
|
|
306
|
+
if (!listing) {
|
|
307
|
+
listing = transport.listSpaceIds()
|
|
308
|
+
.then((ids) => { known = { ids: new Set(ids), readAt: now() }; })
|
|
309
|
+
.finally(() => { listing = null; });
|
|
310
|
+
}
|
|
311
|
+
await listing;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/** Whether the runtime's labels name this space, read again when the answer is stale or a miss. */
|
|
315
|
+
const isKnownSpace = async (spaceId) => {
|
|
316
|
+
const age = now() - known.readAt;
|
|
317
|
+
if (age >= LIST_TTL_MS || (!known.ids.has(spaceId) && age >= LIST_MISS_RETRY_MS)) {
|
|
318
|
+
await readSpaceIds();
|
|
319
|
+
}
|
|
320
|
+
return known.ids.has(spaceId);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const agentFor = (spaceId) => {
|
|
324
|
+
let agent = agents.get(spaceId);
|
|
325
|
+
if (!agent) {
|
|
326
|
+
agent = new http.Agent({ keepAlive: true, maxSockets: MAX_SOCKETS_PER_SPACE, timeout: FREE_SOCKET_TIMEOUT_MS });
|
|
327
|
+
agent.createConnection = (_options, callback) => {
|
|
328
|
+
transport.connect(spaceId).then((stream) => callback(null, stream), (error) => callback(error));
|
|
329
|
+
};
|
|
330
|
+
agents.set(spaceId, agent);
|
|
331
|
+
}
|
|
332
|
+
return agent;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const tokenFor = async (spaceId, fresh) => {
|
|
336
|
+
if (!fresh && tokens.has(spaceId)) return tokens.get(spaceId);
|
|
337
|
+
const token = await transport.readToken(spaceId);
|
|
338
|
+
tokens.set(spaceId, token);
|
|
339
|
+
return token;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/** One small request to the server inside, buffered, for the login. */
|
|
343
|
+
const sendInside = (spaceId, { method, path: requestPath, headers, body }) => new Promise((resolve, reject) => {
|
|
344
|
+
const request = http.request({
|
|
345
|
+
agent: agentFor(spaceId),
|
|
346
|
+
method,
|
|
347
|
+
path: requestPath,
|
|
348
|
+
timeout: LOGIN_TIMEOUT_MS,
|
|
349
|
+
headers: { ...headers, host: `${SPACE_SERVER_HOST}:${SPACE_SERVER_PORT}`, 'accept-encoding': 'identity', 'content-length': String(Buffer.byteLength(body)) },
|
|
350
|
+
}, (response) => {
|
|
351
|
+
// Only the status and the cookie matter; the body is drained and dropped.
|
|
352
|
+
response.resume();
|
|
353
|
+
response.on('end', () => resolve({ status: response.statusCode, headers: response.headers }));
|
|
354
|
+
response.on('error', reject);
|
|
355
|
+
});
|
|
356
|
+
request.on('timeout', () => request.destroy(new SpaceError('space_login_failed', 'The server inside the space did not answer the login in time')));
|
|
357
|
+
request.on('error', reject);
|
|
358
|
+
request.end(body);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
/** Logs in to the server inside with the space's token, once per space at a time. */
|
|
362
|
+
/** One login attempt with the token as it is remembered or freshly read. Null when refused. */
|
|
363
|
+
const attemptLogin = async (spaceId, fresh) => {
|
|
364
|
+
const token = await tokenFor(spaceId, fresh);
|
|
365
|
+
const response = await sendInside(spaceId, {
|
|
366
|
+
method: 'POST',
|
|
367
|
+
path: '/auth/session',
|
|
368
|
+
headers: { 'content-type': 'application/json' },
|
|
369
|
+
body: JSON.stringify({ password: token }),
|
|
370
|
+
});
|
|
371
|
+
const session = response.status === 200 ? parseSetCookie(response.headers['set-cookie']) : null;
|
|
372
|
+
return session ? { session, status: response.status } : { session: null, status: response.status };
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const logIn = (spaceId, { fresh }) => {
|
|
376
|
+
if (logins.has(spaceId)) return logins.get(spaceId);
|
|
377
|
+
const login = (async () => {
|
|
378
|
+
let attempt = await attemptLogin(spaceId, fresh);
|
|
379
|
+
if (!attempt.session && !fresh) {
|
|
380
|
+
// The remembered token may be stale: the agent can rewrite the file and the server inside
|
|
381
|
+
// can restart on the new one. One more read, one more login, and no more.
|
|
382
|
+
tokens.delete(spaceId);
|
|
383
|
+
attempt = await attemptLogin(spaceId, true);
|
|
384
|
+
}
|
|
385
|
+
if (!attempt.session) {
|
|
386
|
+
throw new SpaceError('space_login_failed', `The server inside the space did not accept the space's token (status ${attempt.status})`);
|
|
387
|
+
}
|
|
388
|
+
sessions.set(spaceId, { cookie: attempt.session.cookie, renewAt: now() + attempt.session.ttlMs * SESSION_RENEW_FRACTION });
|
|
389
|
+
return attempt.session.cookie;
|
|
390
|
+
})().finally(() => logins.delete(spaceId));
|
|
391
|
+
logins.set(spaceId, login);
|
|
392
|
+
return login;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const sessionFor = async (spaceId) => {
|
|
396
|
+
const session = sessions.get(spaceId);
|
|
397
|
+
if (session && now() < session.renewAt) return session.cookie;
|
|
398
|
+
// The token is read again only when a login with the remembered one was refused.
|
|
399
|
+
return logIn(spaceId, { fresh: false });
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const forgetSession = (spaceId) => { sessions.delete(spaceId); };
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* One request of the host's own to the server inside, for the session list and the event
|
|
406
|
+
* connection: the space must be known, the session is renewed once after a 401, and a request
|
|
407
|
+
* that met a dead pooled stream is sent once more. Resolves the response with its body still
|
|
408
|
+
* to be read; the caller ends or destroys it. Rejects with the place's or the transport's code.
|
|
409
|
+
*/
|
|
410
|
+
const requestInside = async (spaceId, { method = 'GET', path: requestPath, headers = {}, timeoutMs = 0 }) => {
|
|
411
|
+
if (!await isKnownSpace(spaceId)) throw new SpaceError('space_not_found', `There is no space ${spaceId}`);
|
|
412
|
+
const once = (cookie) => new Promise((resolve, reject) => {
|
|
413
|
+
const request = http.request({
|
|
414
|
+
agent: agentFor(spaceId),
|
|
415
|
+
method,
|
|
416
|
+
path: requestPath,
|
|
417
|
+
headers: { ...headers, host: `${SPACE_SERVER_HOST}:${SPACE_SERVER_PORT}`, cookie, 'accept-encoding': 'identity' },
|
|
418
|
+
...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
|
|
419
|
+
});
|
|
420
|
+
request.on('timeout', () => request.destroy(new SpaceError('space_unreachable', 'The server inside the space did not answer in time')));
|
|
421
|
+
request.on('response', resolve);
|
|
422
|
+
request.on('error', reject);
|
|
423
|
+
request.end();
|
|
424
|
+
});
|
|
425
|
+
let response;
|
|
426
|
+
try {
|
|
427
|
+
response = await once(await sessionFor(spaceId));
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (error?.code !== 'command_stream_failed') throw error;
|
|
430
|
+
response = await once(await sessionFor(spaceId));
|
|
431
|
+
}
|
|
432
|
+
if (response.statusCode !== 401) return response;
|
|
433
|
+
response.resume();
|
|
434
|
+
forgetSession(spaceId);
|
|
435
|
+
const renewed = await logIn(spaceId, { fresh: true });
|
|
436
|
+
const again = await once(renewed);
|
|
437
|
+
if (again.statusCode === 401) {
|
|
438
|
+
again.resume();
|
|
439
|
+
throw new SpaceError('space_login_failed', 'The server inside the space refused a session it had just issued');
|
|
440
|
+
}
|
|
441
|
+
return again;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/** Copies a response from inside onto the client's, with the origin rules applied. */
|
|
445
|
+
const applyResponseHeaders = (upstream, res, innerPath) => {
|
|
446
|
+
for (const [name, value] of Object.entries(upstream.headers)) {
|
|
447
|
+
if (!FORWARDED_RESPONSE_HEADERS.has(name) || value === undefined) continue;
|
|
448
|
+
res.setHeader(name, value);
|
|
449
|
+
}
|
|
450
|
+
res.setHeader('x-content-type-options', 'nosniff');
|
|
451
|
+
const contentType = String(upstream.headers['content-type'] ?? '');
|
|
452
|
+
if (isRawFileRoute(innerPath)) {
|
|
453
|
+
if (!RAW_FILE_CONTENT_TYPES.test(contentType)) {
|
|
454
|
+
res.setHeader('content-type', 'application/octet-stream');
|
|
455
|
+
res.setHeader('content-disposition', 'attachment');
|
|
456
|
+
}
|
|
457
|
+
} else if (contentType === '' || PAGE_CONTENT_TYPES.test(contentType)) {
|
|
458
|
+
// A missing type is what a browser sniffs on a navigation; `nosniff` does not stop that.
|
|
459
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const isEventStream = (upstream) => String(upstream.headers['content-type'] ?? '').toLowerCase().includes('text/event-stream');
|
|
464
|
+
|
|
465
|
+
/** Streams an event stream with backpressure, the way the host's own proxy does. */
|
|
466
|
+
const pipeEventStream = (upstream, res) => {
|
|
467
|
+
const abort = new AbortController();
|
|
468
|
+
let queue = Promise.resolve(true);
|
|
469
|
+
const enqueue = (chunk) => {
|
|
470
|
+
queue = queue.catch(() => false).then((canContinue) => (canContinue ? writeSseChunkWithBackpressure(res, chunk, abort.signal) : false));
|
|
471
|
+
return queue;
|
|
472
|
+
};
|
|
473
|
+
res.setHeader('cache-control', 'no-cache');
|
|
474
|
+
res.setHeader('connection', 'keep-alive');
|
|
475
|
+
res.setHeader('x-accel-buffering', 'no');
|
|
476
|
+
res.flushHeaders?.();
|
|
477
|
+
res.socket?.setNoDelay?.(true);
|
|
478
|
+
const stop = () => {
|
|
479
|
+
abort.abort();
|
|
480
|
+
upstream.destroy();
|
|
481
|
+
};
|
|
482
|
+
res.on('close', stop);
|
|
483
|
+
upstream.on('data', (chunk) => {
|
|
484
|
+
enqueue(chunk).then((canContinue) => { if (!canContinue) stop(); });
|
|
485
|
+
});
|
|
486
|
+
upstream.on('end', () => { queue.then(() => { if (!res.writableEnded) res.end(); }); });
|
|
487
|
+
upstream.on('error', () => { queue.then(() => { if (!res.writableEnded) res.end(); }); });
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Forwards one request to the space, once with the current session. Resolves `'done'` when
|
|
492
|
+
* an answer went to the client, or `'unauthorized'` when the server inside refused the
|
|
493
|
+
* session and `onUnauthorized` was `'report'`, with nothing sent to the client yet.
|
|
494
|
+
*/
|
|
495
|
+
const forwardOnce = (req, res, { spaceId, innerPath, search, cookie, onUnauthorized }) => new Promise((resolve, reject) => {
|
|
496
|
+
// A body that a parser on the host already read cannot be streamed again. That is a wiring
|
|
497
|
+
// fault, and it answers as one rather than waiting for bytes that never come.
|
|
498
|
+
if (hasBody(req) && req.readableEnded) {
|
|
499
|
+
reject(new SpaceError('space_request_body_consumed', 'The body of this request was read on the host before the dispatcher saw it'));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const upstreamRequest = http.request({
|
|
503
|
+
agent: agentFor(spaceId),
|
|
504
|
+
method: req.method,
|
|
505
|
+
path: `${innerPath}${search}`,
|
|
506
|
+
headers: forwardRequestHeaders(req.headers, cookie),
|
|
507
|
+
});
|
|
508
|
+
let settled = false;
|
|
509
|
+
const settle = (fn) => (value) => {
|
|
510
|
+
if (settled) return;
|
|
511
|
+
settled = true;
|
|
512
|
+
fn(value);
|
|
513
|
+
};
|
|
514
|
+
const finish = settle(resolve);
|
|
515
|
+
const fail = settle(reject);
|
|
516
|
+
// A client that leaves before its answer is complete takes the request inside with it. After
|
|
517
|
+
// a complete answer the socket is back in the agent's pool, and nothing is destroyed.
|
|
518
|
+
const onClientGone = () => { if (!res.writableFinished) upstreamRequest.destroy(); };
|
|
519
|
+
res.on('close', onClientGone);
|
|
520
|
+
|
|
521
|
+
upstreamRequest.on('response', (upstream) => {
|
|
522
|
+
if (upstream.statusCode === 401 && onUnauthorized === 'report') {
|
|
523
|
+
upstream.resume();
|
|
524
|
+
upstream.on('end', () => finish('unauthorized'));
|
|
525
|
+
upstream.on('error', () => finish('unauthorized'));
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
// A 304 answers a browser's own revalidation of a body it holds: no location, no body,
|
|
529
|
+
// nothing to follow, and the sidebar's list requests carry an ETag on every reload.
|
|
530
|
+
if (upstream.statusCode >= 300 && upstream.statusCode < 400 && upstream.statusCode !== 304) {
|
|
531
|
+
// A redirect from a space would send the user's next request wherever the agent says.
|
|
532
|
+
upstream.resume();
|
|
533
|
+
fail(new SpaceError('space_redirected', 'The server inside the space answered with a redirect, which is not followed'));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
res.status(upstream.statusCode);
|
|
537
|
+
applyResponseHeaders(upstream, res, innerPath);
|
|
538
|
+
if (isEventStream(upstream)) {
|
|
539
|
+
pipeEventStream(upstream, res);
|
|
540
|
+
finish('done');
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
pipeline(upstream, res, () => {});
|
|
544
|
+
finish('done');
|
|
545
|
+
});
|
|
546
|
+
upstreamRequest.on('error', (error) => {
|
|
547
|
+
if (res.destroyed) {
|
|
548
|
+
finish('done');
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
fail(error);
|
|
552
|
+
});
|
|
553
|
+
if (hasBody(req)) {
|
|
554
|
+
// Not `pipeline`: an error inside would destroy the client's request, and with it the
|
|
555
|
+
// connection the answer has to go on. The body flows; the client leaving ends the request inside.
|
|
556
|
+
req.pipe(upstreamRequest);
|
|
557
|
+
req.on('error', () => upstreamRequest.destroy());
|
|
558
|
+
} else {
|
|
559
|
+
upstreamRequest.end();
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const forward = async (req, res, spaceId, innerPath, search) => {
|
|
564
|
+
if (!await isKnownSpace(spaceId)) {
|
|
565
|
+
answer(res, 404, 'space_not_found', 'There is no such space.');
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
let cookie;
|
|
569
|
+
try {
|
|
570
|
+
cookie = await sessionFor(spaceId);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
const failure = describeFailure(error);
|
|
573
|
+
logger.warn?.(`[spaces] login to space ${spaceId} failed: ${failure.code}`);
|
|
574
|
+
answer(res, failure.status, failure.code, failure.message);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
try {
|
|
578
|
+
let outcome;
|
|
579
|
+
try {
|
|
580
|
+
outcome = await forwardOnce(req, res, { spaceId, innerPath, search, cookie, onUnauthorized: 'report' });
|
|
581
|
+
} catch (error) {
|
|
582
|
+
// A stream from the agent's pool can die between two requests: the server inside closed
|
|
583
|
+
// it, or the space stopped. A request without a body is sent once more on a fresh stream,
|
|
584
|
+
// which the place opens or refuses with its own code; a request with a body cannot be.
|
|
585
|
+
if (error?.code !== 'command_stream_failed' || hasBody(req) || res.headersSent) throw error;
|
|
586
|
+
outcome = await forwardOnce(req, res, { spaceId, innerPath, search, cookie, onUnauthorized: 'report' });
|
|
587
|
+
}
|
|
588
|
+
if (outcome !== 'unauthorized') return;
|
|
589
|
+
// The server inside refused the session. The remembered token may be stale as well, when
|
|
590
|
+
// the space was made again, so the login reads it once more.
|
|
591
|
+
forgetSession(spaceId);
|
|
592
|
+
const renewed = await logIn(spaceId, { fresh: true });
|
|
593
|
+
if (hasBody(req)) {
|
|
594
|
+
// The body went with the first attempt and cannot be sent again. The session is renewed
|
|
595
|
+
// now, so the next request goes through; this one says why it did not.
|
|
596
|
+
answer(res, 503, 'space_session_expired', 'The session with the server inside the space had expired and was renewed. Send the request again.');
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const again = await forwardOnce(req, res, { spaceId, innerPath, search, cookie: renewed, onUnauthorized: 'report' });
|
|
600
|
+
if (again === 'unauthorized') {
|
|
601
|
+
throw new SpaceError('space_login_failed', 'The server inside the space refused a session it had just issued');
|
|
602
|
+
}
|
|
603
|
+
} catch (error) {
|
|
604
|
+
const failure = describeFailure(error);
|
|
605
|
+
logger.warn?.(`[spaces] request to space ${spaceId} failed: ${failure.code}`);
|
|
606
|
+
answer(res, failure.status, failure.code, failure.message);
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The Express middleware. Mount it after the host has authenticated the request and before
|
|
612
|
+
* every route that reads a directory: it dispatches prefixed requests and guards the rest.
|
|
613
|
+
*/
|
|
614
|
+
const middleware = (req, res, next) => {
|
|
615
|
+
const url = new URL(req.originalUrl ?? req.url, 'http://localhost');
|
|
616
|
+
const route = parseSpaceRoute(url.pathname);
|
|
617
|
+
if (!route) {
|
|
618
|
+
// Guard two. A space's directory never runs on the host, whatever fallback would follow.
|
|
619
|
+
if (url.pathname.startsWith('/api/') && requestedDirectories(req, url).some(isSpaceDirectory)) {
|
|
620
|
+
answer(res, 400, 'space_directory_needs_prefix', 'A directory under /spaces/ belongs to an isolated space and is addressed as /api/spaces/<id>/... only.');
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
next();
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (!isSpaceId(route.spaceId)) {
|
|
627
|
+
answer(res, 404, 'space_not_found', 'There is no such space.');
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const rule = classifySpacePath(route.path);
|
|
631
|
+
if (rule === 'host_only') {
|
|
632
|
+
answer(res, 403, 'host_only_route', 'This route exists on the host only and is not forwarded to a space.');
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (rule === 'refused_across_boundary') {
|
|
636
|
+
answer(res, 403, 'refused_across_boundary', 'This action does not cross the boundary of an isolated space.');
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
// Guard one. A prefixed request names this space's directories or none.
|
|
640
|
+
if (requestedDirectories(req, url).some((directory) => !isDirectoryOfSpace(directory, route.spaceId))) {
|
|
641
|
+
answer(res, 400, 'directory_outside_space', `A request to space ${route.spaceId} may name a directory under ${spaceWorkPath(route.spaceId)} only.`);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
stripCredentialQuery(url);
|
|
645
|
+
forward(req, res, route.spaceId, route.path, url.search).catch((error) => {
|
|
646
|
+
const failure = describeFailure(error);
|
|
647
|
+
answer(res, failure.status, failure.code, failure.message);
|
|
648
|
+
});
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
/** Ends every connection into every space. */
|
|
652
|
+
const close = () => {
|
|
653
|
+
for (const agent of agents.values()) agent.destroy();
|
|
654
|
+
agents.clear();
|
|
655
|
+
sessions.clear();
|
|
656
|
+
tokens.clear();
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
/** What the WebSocket forwarder shares with the middleware: the list of spaces and the session inside. */
|
|
660
|
+
const spaceSessions = {
|
|
661
|
+
isKnownSpace,
|
|
662
|
+
cookieFor: sessionFor,
|
|
663
|
+
renew: (spaceId) => {
|
|
664
|
+
forgetSession(spaceId);
|
|
665
|
+
return logIn(spaceId, { fresh: true });
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
return { middleware, close, isSpaceDirectory, isSpaceRequestPath, requestInside, spaceSessions };
|
|
670
|
+
}
|