@oar-sh/oar 0.9.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/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import http from 'http';
|
|
4
|
+
|
|
5
|
+
import { parsePreviewPath } from './preview-registry-service.mjs';
|
|
6
|
+
import { serveStaticPreview } from './preview-static-handler.mjs';
|
|
7
|
+
|
|
8
|
+
// The preview listener. A bare http.Server on its own loopback port whose
|
|
9
|
+
// handler has exactly two outcomes: proxy to a registered upstream, or an error
|
|
10
|
+
// page. The relay's express app, auth middleware, socket.io, static files and
|
|
11
|
+
// /api routes are NOT mounted here — preview traffic cannot reach relay
|
|
12
|
+
// functionality because none of it exists on this port.
|
|
13
|
+
|
|
14
|
+
// RFC 9110 §7.6.1: connection-specific headers must not be forwarded by a proxy.
|
|
15
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
16
|
+
'connection',
|
|
17
|
+
'keep-alive',
|
|
18
|
+
'proxy-authenticate',
|
|
19
|
+
'proxy-authorization',
|
|
20
|
+
'proxy-connection',
|
|
21
|
+
'te',
|
|
22
|
+
'trailer',
|
|
23
|
+
'transfer-encoding',
|
|
24
|
+
'upgrade',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// Response headers the preview lane owns and must not inherit from the upstream.
|
|
28
|
+
// Service-Worker-Allowed matters most: every preview shares one origin, so an
|
|
29
|
+
// upstream that widened its worker scope to "/" could intercept another
|
|
30
|
+
// preview's traffic.
|
|
31
|
+
const STRIPPED_RESPONSE_HEADERS = new Set([
|
|
32
|
+
'service-worker-allowed',
|
|
33
|
+
'x-robots-tag',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
37
|
+
const RELAY_COOKIE_NAMES = ['copilot_auth'];
|
|
38
|
+
|
|
39
|
+
function escapeHtml(value) {
|
|
40
|
+
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
|
41
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
42
|
+
}[ch]));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Removes relay credentials from a Cookie header. The preview app is a
|
|
47
|
+
* different origin, so a browser never sends these — but a hand-crafted request
|
|
48
|
+
* (curl, a webhook, a misconfigured front proxy) could, and the previewed app
|
|
49
|
+
* has no business seeing the relay's session token.
|
|
50
|
+
*/
|
|
51
|
+
export function stripRelayCookies(cookieHeader, cookieNames = RELAY_COOKIE_NAMES) {
|
|
52
|
+
const text = String(cookieHeader || '');
|
|
53
|
+
if (!text) return '';
|
|
54
|
+
const names = cookieNames.map((name) => String(name || '').toLowerCase());
|
|
55
|
+
const kept = text
|
|
56
|
+
.split(';')
|
|
57
|
+
.map((part) => part.trim())
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.filter((part) => {
|
|
60
|
+
const name = part.split('=')[0].trim().toLowerCase();
|
|
61
|
+
return !names.includes(name);
|
|
62
|
+
});
|
|
63
|
+
return kept.join('; ');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function sanitizeRequestHeaders(headers = {}, {
|
|
67
|
+
targetHost,
|
|
68
|
+
targetPort,
|
|
69
|
+
basePath,
|
|
70
|
+
forwardedHost = '',
|
|
71
|
+
forwardedProto = 'https',
|
|
72
|
+
forwardedFor = '',
|
|
73
|
+
cookieNames = RELAY_COOKIE_NAMES,
|
|
74
|
+
bearerTokens = [],
|
|
75
|
+
} = {}) {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [rawName, value] of Object.entries(headers)) {
|
|
78
|
+
const name = String(rawName).toLowerCase();
|
|
79
|
+
if (HOP_BY_HOP_HEADERS.has(name)) continue;
|
|
80
|
+
if (name === 'host') continue;
|
|
81
|
+
if (name === 'cookie') {
|
|
82
|
+
const cookie = stripRelayCookies(value, cookieNames);
|
|
83
|
+
if (cookie) out.cookie = cookie;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (name === 'authorization') {
|
|
87
|
+
// Only the relay's own bearer token is withheld; an app's Basic/Bearer
|
|
88
|
+
// auth is legitimate traffic and must pass through.
|
|
89
|
+
const presented = String(value || '').replace(/^Bearer\s+/i, '').trim();
|
|
90
|
+
if (presented && bearerTokens.some((token) => token && token === presented)) continue;
|
|
91
|
+
out.authorization = value;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
out[name] = value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Dev servers with host checks (Vite, webpack-dev-server) expect their own
|
|
98
|
+
// host, so the upstream Host is the target, and the public one travels in
|
|
99
|
+
// X-Forwarded-Host alongside the prefix the app needs for its base path.
|
|
100
|
+
out.host = `${targetHost}:${targetPort}`;
|
|
101
|
+
if (basePath) out['x-forwarded-prefix'] = basePath.replace(/\/$/, '');
|
|
102
|
+
if (forwardedHost) out['x-forwarded-host'] = forwardedHost;
|
|
103
|
+
out['x-forwarded-proto'] = forwardedProto;
|
|
104
|
+
if (forwardedFor) out['x-forwarded-for'] = forwardedFor;
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Rewrites an upstream Location so a server-side redirect lands back inside the
|
|
110
|
+
* preview prefix instead of at the preview origin's root.
|
|
111
|
+
*/
|
|
112
|
+
export function rewriteLocation(location, { basePath, targetHost, targetPort }) {
|
|
113
|
+
const value = String(location || '');
|
|
114
|
+
if (!value) return value;
|
|
115
|
+
const prefix = basePath.replace(/\/$/, '');
|
|
116
|
+
|
|
117
|
+
if (value.startsWith('//')) return value;
|
|
118
|
+
if (value.startsWith('/')) {
|
|
119
|
+
return value.startsWith(`${prefix}/`) || value === prefix ? value : `${prefix}${value}`;
|
|
120
|
+
}
|
|
121
|
+
// An absolute redirect back to the upstream's own address (dev servers emit
|
|
122
|
+
// these) is rewritten to the equivalent preview path; anything pointing
|
|
123
|
+
// elsewhere is left alone, since it is an intentional off-site redirect.
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = new URL(value);
|
|
127
|
+
} catch {
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
const sameUpstream = parsed.hostname === targetHost
|
|
131
|
+
&& Number(parsed.port || (parsed.protocol === 'https:' ? 443 : 80)) === Number(targetPort);
|
|
132
|
+
if (!sameUpstream) return value;
|
|
133
|
+
return `${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Scopes an upstream cookie to this preview. Previews share one origin, so
|
|
138
|
+
* without this a Path=/ cookie from one preview would be sent to every other
|
|
139
|
+
* one; Domain is dropped for the same reason. A cookie impersonating a relay
|
|
140
|
+
* credential is discarded outright.
|
|
141
|
+
*/
|
|
142
|
+
export function rewriteSetCookie(setCookieValue, { basePath, cookieNames = RELAY_COOKIE_NAMES } = {}) {
|
|
143
|
+
const value = String(setCookieValue || '');
|
|
144
|
+
if (!value) return null;
|
|
145
|
+
const name = value.split('=')[0].trim().toLowerCase();
|
|
146
|
+
if (cookieNames.map((entry) => String(entry).toLowerCase()).includes(name)) return null;
|
|
147
|
+
|
|
148
|
+
const parts = value.split(';').map((part) => part.trim()).filter(Boolean);
|
|
149
|
+
const kept = parts.filter((part, index) => {
|
|
150
|
+
if (index === 0) return true;
|
|
151
|
+
const attr = part.split('=')[0].trim().toLowerCase();
|
|
152
|
+
// __Host- cookies require Path=/ and no Domain; rewriting the path breaks
|
|
153
|
+
// that contract, so the prefix is applied and the __Host- guarantee is
|
|
154
|
+
// simply not available inside a shared preview origin.
|
|
155
|
+
return attr !== 'path' && attr !== 'domain';
|
|
156
|
+
});
|
|
157
|
+
kept.push(`Path=${basePath}`);
|
|
158
|
+
return kept.join('; ');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function sanitizeResponseHeaders(headers = {}, { basePath, targetHost, targetPort }) {
|
|
162
|
+
const out = {};
|
|
163
|
+
for (const [rawName, value] of Object.entries(headers)) {
|
|
164
|
+
const name = String(rawName).toLowerCase();
|
|
165
|
+
if (HOP_BY_HOP_HEADERS.has(name)) continue;
|
|
166
|
+
if (STRIPPED_RESPONSE_HEADERS.has(name)) continue;
|
|
167
|
+
if (name === 'location') {
|
|
168
|
+
out.location = rewriteLocation(value, { basePath, targetHost, targetPort });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (name === 'set-cookie') {
|
|
172
|
+
const cookies = (Array.isArray(value) ? value : [value])
|
|
173
|
+
.map((entry) => rewriteSetCookie(entry, { basePath }))
|
|
174
|
+
.filter(Boolean);
|
|
175
|
+
if (cookies.length) out['set-cookie'] = cookies;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
out[name] = value;
|
|
179
|
+
}
|
|
180
|
+
out['x-robots-tag'] = 'noindex, nofollow, noarchive';
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function errorPage({ title, detail }) {
|
|
185
|
+
return `<!doctype html>
|
|
186
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
187
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
188
|
+
<title>${escapeHtml(title)}</title>
|
|
189
|
+
<style>
|
|
190
|
+
body { font: 15px/1.55 system-ui, sans-serif; margin: 0; padding: 3rem 1.5rem;
|
|
191
|
+
background: #14161a; color: #e6e8ec; }
|
|
192
|
+
main { max-width: 34rem; margin: 0 auto; }
|
|
193
|
+
h1 { font-size: 1.25rem; margin: 0 0 .75rem; }
|
|
194
|
+
p { margin: 0 0 .75rem; color: #a8aebb; }
|
|
195
|
+
code { background: #1e2128; padding: .1rem .35rem; border-radius: 4px; color: #e6e8ec; }
|
|
196
|
+
</style></head>
|
|
197
|
+
<body><main><h1>${escapeHtml(title)}</h1><p>${detail}</p>
|
|
198
|
+
<p>Served by the copilot-remote preview lane.</p></main></body></html>`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sendErrorPage(res, status, { title, detail }) {
|
|
202
|
+
if (res.headersSent) {
|
|
203
|
+
try { res.end(); } catch {}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const body = errorPage({ title, detail });
|
|
207
|
+
res.writeHead(status, {
|
|
208
|
+
'content-type': 'text/html; charset=utf-8',
|
|
209
|
+
'content-length': Buffer.byteLength(body),
|
|
210
|
+
'cache-control': 'no-store',
|
|
211
|
+
'x-robots-tag': 'noindex, nofollow, noarchive',
|
|
212
|
+
});
|
|
213
|
+
res.end(body);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function clientIp(req) {
|
|
217
|
+
return String(req?.socket?.remoteAddress || '').replace(/^::ffff:/, '');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Connect-only timeout. A response timeout would kill SSE and long-poll, so the
|
|
222
|
+
* clock stops the moment the socket is connected.
|
|
223
|
+
*/
|
|
224
|
+
function guardConnectTimeout(upstreamReq, timeoutMs, onTimeout) {
|
|
225
|
+
upstreamReq.once('socket', (socket) => {
|
|
226
|
+
if (!socket.connecting) return;
|
|
227
|
+
const timer = setTimeout(() => {
|
|
228
|
+
onTimeout();
|
|
229
|
+
upstreamReq.destroy(Object.assign(new Error('Preview upstream connect timeout'), {
|
|
230
|
+
code: 'ETIMEDOUT',
|
|
231
|
+
}));
|
|
232
|
+
}, timeoutMs);
|
|
233
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
234
|
+
const clear = () => clearTimeout(timer);
|
|
235
|
+
socket.once('connect', clear);
|
|
236
|
+
socket.once('error', clear);
|
|
237
|
+
upstreamReq.once('error', clear);
|
|
238
|
+
upstreamReq.once('response', clear);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function createPreviewProxyServer({
|
|
243
|
+
registry,
|
|
244
|
+
logger = console,
|
|
245
|
+
httpImpl = http,
|
|
246
|
+
relayBearerTokens = [],
|
|
247
|
+
connectTimeoutMs = CONNECT_TIMEOUT_MS,
|
|
248
|
+
} = {}) {
|
|
249
|
+
const agent = new httpImpl.Agent({ keepAlive: true, maxSockets: 64 });
|
|
250
|
+
let server = null;
|
|
251
|
+
// First hit per token is always logged; after that at most one line a minute,
|
|
252
|
+
// so a busy dev server cannot flood the relay log.
|
|
253
|
+
const lastHitLogAt = new Map();
|
|
254
|
+
// Upgraded sockets are detached from the http.Server the moment the handshake
|
|
255
|
+
// completes, so neither close() nor closeAllConnections() can reach them. An
|
|
256
|
+
// open HMR connection would therefore hang shutdown forever unless the lane
|
|
257
|
+
// tracks the pairs itself.
|
|
258
|
+
const upgradedSockets = new Set();
|
|
259
|
+
|
|
260
|
+
function trackUpgradedSocket(socket) {
|
|
261
|
+
if (!socket) return;
|
|
262
|
+
upgradedSockets.add(socket);
|
|
263
|
+
const forget = () => upgradedSockets.delete(socket);
|
|
264
|
+
socket.once('close', forget);
|
|
265
|
+
socket.once('error', forget);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function logHit(entry, req) {
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
const last = lastHitLogAt.get(entry.token) || 0;
|
|
271
|
+
if (now - last < 60_000) return;
|
|
272
|
+
lastHitLogAt.set(entry.token, now);
|
|
273
|
+
logger?.log?.(`[preview] ${entry.token.slice(0, 8)} → :${entry.targetPort} ${req.method} ${req.url} from ${clientIp(req)}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function resolveRequest(req) {
|
|
277
|
+
const parsed = parsePreviewPath(req.url || '/');
|
|
278
|
+
if (!parsed) return { kind: 'not-found' };
|
|
279
|
+
const entry = registry.resolve(parsed.token);
|
|
280
|
+
if (!entry) return { kind: 'not-found' };
|
|
281
|
+
if (parsed.upstreamPath === null) {
|
|
282
|
+
return { kind: 'redirect', location: `${entry.basePath}${parsed.query}` };
|
|
283
|
+
}
|
|
284
|
+
return { kind: 'proxy', entry, upstreamPath: parsed.upstreamPath };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function handleRequest(req, res) {
|
|
288
|
+
const resolved = resolveRequest(req);
|
|
289
|
+
|
|
290
|
+
if (resolved.kind === 'not-found') {
|
|
291
|
+
sendErrorPage(res, 404, {
|
|
292
|
+
title: 'No such preview',
|
|
293
|
+
detail: 'This preview link is unknown or has been closed.',
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (resolved.kind === 'redirect') {
|
|
299
|
+
// Relative asset URLs resolve against the parent path without this.
|
|
300
|
+
res.writeHead(301, { location: resolved.location, 'cache-control': 'no-store' });
|
|
301
|
+
res.end();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const { entry, upstreamPath } = resolved;
|
|
306
|
+
registry.recordHit(entry.token);
|
|
307
|
+
logHit(entry, req);
|
|
308
|
+
|
|
309
|
+
// Static previews are answered in-process — no upstream, no forwarding.
|
|
310
|
+
if (entry.mode === 'static') {
|
|
311
|
+
serveStaticPreview(entry, upstreamPath, req, res, {
|
|
312
|
+
sendNotFound: (response) => sendErrorPage(response, 404, {
|
|
313
|
+
title: 'Not found',
|
|
314
|
+
detail: 'No such file in this preview.',
|
|
315
|
+
}),
|
|
316
|
+
});
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const headers = sanitizeRequestHeaders(req.headers, {
|
|
321
|
+
targetHost: entry.targetHost,
|
|
322
|
+
targetPort: entry.targetPort,
|
|
323
|
+
basePath: entry.basePath,
|
|
324
|
+
forwardedHost: String(req.headers.host || ''),
|
|
325
|
+
forwardedProto: String(req.headers['x-forwarded-proto'] || 'https'),
|
|
326
|
+
forwardedFor: clientIp(req),
|
|
327
|
+
bearerTokens: relayBearerTokens,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const upstreamReq = httpImpl.request({
|
|
331
|
+
host: entry.targetHost,
|
|
332
|
+
port: entry.targetPort,
|
|
333
|
+
method: req.method,
|
|
334
|
+
path: upstreamPath,
|
|
335
|
+
headers,
|
|
336
|
+
agent,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
let timedOut = false;
|
|
340
|
+
guardConnectTimeout(upstreamReq, connectTimeoutMs, () => { timedOut = true; });
|
|
341
|
+
|
|
342
|
+
upstreamReq.on('response', (upstreamRes) => {
|
|
343
|
+
const outHeaders = sanitizeResponseHeaders(upstreamRes.headers, {
|
|
344
|
+
basePath: entry.basePath,
|
|
345
|
+
targetHost: entry.targetHost,
|
|
346
|
+
targetPort: entry.targetPort,
|
|
347
|
+
});
|
|
348
|
+
res.writeHead(upstreamRes.statusCode || 502, outHeaders);
|
|
349
|
+
// 204/304 and HEAD carry no body; piping one would desync the connection.
|
|
350
|
+
if (req.method === 'HEAD' || upstreamRes.statusCode === 204 || upstreamRes.statusCode === 304) {
|
|
351
|
+
upstreamRes.resume();
|
|
352
|
+
res.end();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
upstreamRes.pipe(res);
|
|
356
|
+
upstreamRes.on('error', () => { try { res.destroy(); } catch {} });
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
upstreamReq.on('error', (error) => {
|
|
360
|
+
const refused = String(error?.code || '') === 'ECONNREFUSED';
|
|
361
|
+
const detail = refused || timedOut
|
|
362
|
+
? `Nothing is listening on <code>${escapeHtml(entry.targetHost)}:${entry.targetPort}</code>. The dev server behind this preview has probably exited — restart it on the same port and reload; this link stays valid.`
|
|
363
|
+
: `The upstream connection failed: <code>${escapeHtml(error?.code || error?.message || 'unknown error')}</code>.`;
|
|
364
|
+
sendErrorPage(res, 502, { title: 'Preview upstream unreachable', detail });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
req.on('aborted', () => { try { upstreamReq.destroy(); } catch {} });
|
|
368
|
+
res.on('close', () => { try { upstreamReq.destroy(); } catch {} });
|
|
369
|
+
req.pipe(upstreamReq);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// WebSocket upgrades (Vite HMR, an app's own socket) ride the same prefix.
|
|
373
|
+
function handleUpgrade(req, socket, head) {
|
|
374
|
+
const resolved = resolveRequest(req);
|
|
375
|
+
// Static previews have nothing to upgrade to; refuse like an unknown token.
|
|
376
|
+
if (resolved.kind !== 'proxy' || resolved.entry?.mode === 'static') {
|
|
377
|
+
try {
|
|
378
|
+
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
|
|
379
|
+
} catch {}
|
|
380
|
+
try { socket.destroy(); } catch {}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const { entry, upstreamPath } = resolved;
|
|
385
|
+
registry.recordHit(entry.token);
|
|
386
|
+
|
|
387
|
+
const headers = sanitizeRequestHeaders(req.headers, {
|
|
388
|
+
targetHost: entry.targetHost,
|
|
389
|
+
targetPort: entry.targetPort,
|
|
390
|
+
basePath: entry.basePath,
|
|
391
|
+
forwardedHost: String(req.headers.host || ''),
|
|
392
|
+
forwardedProto: String(req.headers['x-forwarded-proto'] || 'https'),
|
|
393
|
+
forwardedFor: clientIp(req),
|
|
394
|
+
bearerTokens: relayBearerTokens,
|
|
395
|
+
});
|
|
396
|
+
// The upgrade handshake needs exactly the hop-by-hop headers the proxy
|
|
397
|
+
// strips everywhere else, so they are restored for this one request.
|
|
398
|
+
headers.connection = 'Upgrade';
|
|
399
|
+
headers.upgrade = String(req.headers.upgrade || 'websocket');
|
|
400
|
+
|
|
401
|
+
const upstreamReq = httpImpl.request({
|
|
402
|
+
host: entry.targetHost,
|
|
403
|
+
port: entry.targetPort,
|
|
404
|
+
method: req.method,
|
|
405
|
+
path: upstreamPath,
|
|
406
|
+
headers,
|
|
407
|
+
agent: false,
|
|
408
|
+
});
|
|
409
|
+
guardConnectTimeout(upstreamReq, connectTimeoutMs, () => {});
|
|
410
|
+
|
|
411
|
+
upstreamReq.on('upgrade', (upstreamRes, upstreamSocket, upstreamHead) => {
|
|
412
|
+
const lines = [`HTTP/1.1 ${upstreamRes.statusCode} ${upstreamRes.statusMessage}`];
|
|
413
|
+
for (const [name, value] of Object.entries(upstreamRes.headers)) {
|
|
414
|
+
for (const entryValue of Array.isArray(value) ? value : [value]) {
|
|
415
|
+
lines.push(`${name}: ${entryValue}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
socket.write(`${lines.join('\r\n')}\r\n\r\n`);
|
|
420
|
+
} catch {
|
|
421
|
+
try { upstreamSocket.destroy(); } catch {}
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (upstreamHead?.length) socket.write(upstreamHead);
|
|
425
|
+
if (head?.length) upstreamSocket.write(head);
|
|
426
|
+
trackUpgradedSocket(socket);
|
|
427
|
+
trackUpgradedSocket(upstreamSocket);
|
|
428
|
+
upstreamSocket.on('error', () => { try { socket.destroy(); } catch {} });
|
|
429
|
+
socket.on('error', () => { try { upstreamSocket.destroy(); } catch {} });
|
|
430
|
+
upstreamSocket.pipe(socket);
|
|
431
|
+
socket.pipe(upstreamSocket);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// A dev server that answers an upgrade with a plain response (404 on an
|
|
435
|
+
// unknown HMR path) must not leave the client hanging.
|
|
436
|
+
upstreamReq.on('response', (upstreamRes) => {
|
|
437
|
+
try {
|
|
438
|
+
socket.write(`HTTP/1.1 ${upstreamRes.statusCode} ${upstreamRes.statusMessage}\r\nConnection: close\r\n\r\n`);
|
|
439
|
+
} catch {}
|
|
440
|
+
upstreamRes.resume();
|
|
441
|
+
try { socket.destroy(); } catch {}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
upstreamReq.on('error', () => {
|
|
445
|
+
try {
|
|
446
|
+
socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
|
|
447
|
+
} catch {}
|
|
448
|
+
try { socket.destroy(); } catch {}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
upstreamReq.end();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function start() {
|
|
455
|
+
const settings = registry.settings;
|
|
456
|
+
if (!settings?.enabled) return Promise.resolve(null);
|
|
457
|
+
return new Promise((resolve, reject) => {
|
|
458
|
+
server = httpImpl.createServer(handleRequest);
|
|
459
|
+
server.on('upgrade', handleUpgrade);
|
|
460
|
+
server.once('error', reject);
|
|
461
|
+
server.listen(settings.port, settings.bindHost, () => {
|
|
462
|
+
server.removeListener('error', reject);
|
|
463
|
+
const address = server.address();
|
|
464
|
+
logger?.log?.(`[preview] listener on http://${settings.bindHost}:${address?.port} → ${settings.publicBaseUrl}`);
|
|
465
|
+
resolve(address);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function stop() {
|
|
471
|
+
// The keep-alive agent pools upstream sockets, which keep the event loop
|
|
472
|
+
// alive long after the listener is closed — a shutdown that skipped this
|
|
473
|
+
// would hang the process instead of exiting.
|
|
474
|
+
try { agent.destroy(); } catch {}
|
|
475
|
+
lastHitLogAt.clear();
|
|
476
|
+
for (const socket of upgradedSockets) {
|
|
477
|
+
try { socket.destroy(); } catch {}
|
|
478
|
+
}
|
|
479
|
+
upgradedSockets.clear();
|
|
480
|
+
return new Promise((resolve) => {
|
|
481
|
+
if (!server) return resolve();
|
|
482
|
+
const current = server;
|
|
483
|
+
server = null;
|
|
484
|
+
current.close(() => resolve());
|
|
485
|
+
// Keep-alive sockets would otherwise hold the close open past shutdown.
|
|
486
|
+
try { current.closeAllConnections?.(); } catch {}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
start,
|
|
492
|
+
stop,
|
|
493
|
+
handleRequest,
|
|
494
|
+
handleUpgrade,
|
|
495
|
+
get address() { return server?.address?.() || null; },
|
|
496
|
+
get listening() { return Boolean(server?.listening); },
|
|
497
|
+
};
|
|
498
|
+
}
|