dextunnel 0.1.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.
Files changed (76) hide show
  1. package/LICENSE +211 -0
  2. package/README.md +112 -0
  3. package/SECURITY.md +27 -0
  4. package/SUPPORT.md +43 -0
  5. package/package.json +44 -0
  6. package/public/client-shared.js +1831 -0
  7. package/public/favicon.svg +11 -0
  8. package/public/host.html +29 -0
  9. package/public/host.js +2079 -0
  10. package/public/index.html +28 -0
  11. package/public/index.js +98 -0
  12. package/public/live-bridge-lifecycle.js +258 -0
  13. package/public/live-bridge-retry-state.js +61 -0
  14. package/public/live-selection-intent.js +79 -0
  15. package/public/remote-operator-state.js +316 -0
  16. package/public/remote.html +167 -0
  17. package/public/remote.js +3967 -0
  18. package/public/styles.css +2793 -0
  19. package/public/surface-view-state.js +89 -0
  20. package/public/voice-dictation.js +45 -0
  21. package/src/bin/desktop-rehydration-smoke.mjs +111 -0
  22. package/src/bin/dextunnel.mjs +41 -0
  23. package/src/bin/doctor.mjs +48 -0
  24. package/src/bin/launch-attest.mjs +39 -0
  25. package/src/bin/launch-status.mjs +49 -0
  26. package/src/bin/mobile-link-proxy.mjs +221 -0
  27. package/src/bin/mobile-proof.mjs +164 -0
  28. package/src/bin/mobile-transport-smoke.mjs +200 -0
  29. package/src/bin/probe-codex-app-server-write.mjs +36 -0
  30. package/src/bin/probe-codex-app-server.mjs +30 -0
  31. package/src/lib/agent-room-context.mjs +54 -0
  32. package/src/lib/agent-room-runtime.mjs +355 -0
  33. package/src/lib/agent-room-service.mjs +335 -0
  34. package/src/lib/agent-room-state.mjs +406 -0
  35. package/src/lib/agent-room-store.mjs +71 -0
  36. package/src/lib/agent-room-text.mjs +48 -0
  37. package/src/lib/app-server-contract.mjs +66 -0
  38. package/src/lib/app-server-runtime.mjs +60 -0
  39. package/src/lib/attachment-service.mjs +119 -0
  40. package/src/lib/bridge-api-handler.mjs +719 -0
  41. package/src/lib/bridge-runtime-lifecycle.mjs +51 -0
  42. package/src/lib/bridge-status-builder.mjs +60 -0
  43. package/src/lib/codex-app-server-client.mjs +1511 -0
  44. package/src/lib/companion-state.mjs +453 -0
  45. package/src/lib/control-lease-service.mjs +180 -0
  46. package/src/lib/debug-harness-service.mjs +173 -0
  47. package/src/lib/desktop-integration.mjs +146 -0
  48. package/src/lib/desktop-rehydration-smoke.mjs +269 -0
  49. package/src/lib/dextunnel-cli.mjs +122 -0
  50. package/src/lib/discovery-docs.mjs +1321 -0
  51. package/src/lib/fake-codex-app-server-bridge.mjs +340 -0
  52. package/src/lib/install-preflight.mjs +373 -0
  53. package/src/lib/interaction-resolution-service.mjs +185 -0
  54. package/src/lib/interaction-state.mjs +360 -0
  55. package/src/lib/launch-release-bar.mjs +158 -0
  56. package/src/lib/live-control-state.mjs +107 -0
  57. package/src/lib/live-payload-builder.mjs +298 -0
  58. package/src/lib/live-selection-transition-state.mjs +49 -0
  59. package/src/lib/live-transcript-state.mjs +549 -0
  60. package/src/lib/mobile-network-profile.mjs +39 -0
  61. package/src/lib/mock-codex-adapter.mjs +62 -0
  62. package/src/lib/operator-diagnostics.mjs +82 -0
  63. package/src/lib/repo-changes-service.mjs +527 -0
  64. package/src/lib/runtime-config.mjs +106 -0
  65. package/src/lib/selection-state-service.mjs +214 -0
  66. package/src/lib/session-store.mjs +355 -0
  67. package/src/lib/shared-room-state.mjs +473 -0
  68. package/src/lib/shared-selection-state.mjs +40 -0
  69. package/src/lib/sse-hub.mjs +35 -0
  70. package/src/lib/static-surface-service.mjs +71 -0
  71. package/src/lib/surface-access.mjs +189 -0
  72. package/src/lib/surface-presence-service.mjs +118 -0
  73. package/src/lib/surface-request-guard.mjs +52 -0
  74. package/src/lib/thread-sync-state.mjs +536 -0
  75. package/src/lib/watcher-lifecycle.mjs +287 -0
  76. package/src/server.mjs +1446 -0
@@ -0,0 +1,473 @@
1
+ function isoAt(nowMs) {
2
+ return new Date(nowMs).toISOString();
3
+ }
4
+
5
+ export function normalizeSurfaceName(value) {
6
+ const nextSurface = String(value || "").trim().toLowerCase();
7
+ if (nextSurface === "host" || nextSurface === "agent" || nextSurface === "remote") {
8
+ return nextSurface;
9
+ }
10
+ return "remote";
11
+ }
12
+
13
+ function isWriterSurface(surface) {
14
+ const nextSurface = normalizeSurfaceName(surface);
15
+ return nextSurface === "remote" || nextSurface === "agent";
16
+ }
17
+
18
+ export function shortClientId(value) {
19
+ const normalized = String(value || "")
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9]+/g, "");
22
+ if (!normalized) {
23
+ return "";
24
+ }
25
+
26
+ return normalized.length > 4 ? normalized.slice(-4) : normalized;
27
+ }
28
+
29
+ export function surfaceActorLabel({ surface = "", clientId = null } = {}) {
30
+ const nextSurface = normalizeSurfaceName(surface);
31
+ const base =
32
+ nextSurface === "host"
33
+ ? "Host"
34
+ : nextSurface === "agent"
35
+ ? "Agent"
36
+ : "Remote";
37
+ const suffix = shortClientId(clientId);
38
+ return suffix ? `${base} ${suffix}` : base;
39
+ }
40
+
41
+ export function countSurfacePresence(surfacePresenceByClientId = {}, threadId, surface) {
42
+ const nextThreadId = String(threadId || "").trim();
43
+ const nextSurface = normalizeSurfaceName(surface);
44
+ if (!nextThreadId || !nextSurface) {
45
+ return 0;
46
+ }
47
+
48
+ let count = 0;
49
+ for (const presence of Object.values(surfacePresenceByClientId || {})) {
50
+ if (!presence?.threadId || presence.threadId !== nextThreadId) {
51
+ continue;
52
+ }
53
+
54
+ if (normalizeSurfaceName(presence.surface) !== nextSurface) {
55
+ continue;
56
+ }
57
+
58
+ count += 1;
59
+ }
60
+
61
+ return count;
62
+ }
63
+
64
+ export function surfacePresenceState(presence) {
65
+ if (!presence) {
66
+ return "detached";
67
+ }
68
+
69
+ if (presence.visible && presence.focused && presence.engaged) {
70
+ return "active";
71
+ }
72
+
73
+ if (presence.visible && presence.focused) {
74
+ return "open";
75
+ }
76
+
77
+ if (presence.visible) {
78
+ return "viewing";
79
+ }
80
+
81
+ return "background";
82
+ }
83
+
84
+ export function upsertSurfacePresence(surfacePresenceByClientId = {}, payload = {}, { updatedAt = isoAt(Date.now()) } = {}) {
85
+ const clientId = String(payload.clientId || "").trim();
86
+ const threadId = String(payload.threadId || "").trim();
87
+ if (!clientId || !threadId) {
88
+ return {
89
+ changed: false,
90
+ nextPresenceByClientId: surfacePresenceByClientId,
91
+ nextPresence: null,
92
+ previousPresence: null
93
+ };
94
+ }
95
+
96
+ const nextPresence = {
97
+ clientId,
98
+ engaged: Boolean(payload.engaged),
99
+ focused: Boolean(payload.focused),
100
+ label: normalizeSurfaceName(payload.surface),
101
+ surface: normalizeSurfaceName(payload.surface),
102
+ threadId,
103
+ updatedAt,
104
+ visible: payload.visible !== false
105
+ };
106
+ const previousPresence = surfacePresenceByClientId[clientId] || null;
107
+
108
+ const changed =
109
+ !previousPresence ||
110
+ previousPresence.surface !== nextPresence.surface ||
111
+ previousPresence.threadId !== nextPresence.threadId ||
112
+ previousPresence.visible !== nextPresence.visible ||
113
+ previousPresence.focused !== nextPresence.focused ||
114
+ previousPresence.engaged !== nextPresence.engaged;
115
+
116
+ if (!changed) {
117
+ return {
118
+ changed: false,
119
+ nextPresenceByClientId: surfacePresenceByClientId,
120
+ nextPresence,
121
+ previousPresence
122
+ };
123
+ }
124
+
125
+ return {
126
+ changed: true,
127
+ nextPresence,
128
+ nextPresenceByClientId: {
129
+ ...surfacePresenceByClientId,
130
+ [clientId]: nextPresence
131
+ },
132
+ previousPresence
133
+ };
134
+ }
135
+
136
+ export function removeSurfacePresence(surfacePresenceByClientId = {}, clientId) {
137
+ const id = String(clientId || "").trim();
138
+ const previousPresence = id ? surfacePresenceByClientId[id] || null : null;
139
+ if (!id || !previousPresence) {
140
+ return {
141
+ changed: false,
142
+ nextPresenceByClientId: surfacePresenceByClientId,
143
+ previousPresence: null
144
+ };
145
+ }
146
+
147
+ const nextPresenceByClientId = {
148
+ ...surfacePresenceByClientId
149
+ };
150
+ delete nextPresenceByClientId[id];
151
+
152
+ return {
153
+ changed: true,
154
+ nextPresenceByClientId,
155
+ previousPresence
156
+ };
157
+ }
158
+
159
+ export function applySurfacePresenceUpdate(
160
+ surfacePresenceByClientId = {},
161
+ payload = {},
162
+ { now = Date.now(), selectedThreadId = "" } = {}
163
+ ) {
164
+ const updatedAt = isoAt(now);
165
+ const clientId = String(payload.clientId || "").trim();
166
+ const previousPresence = clientId ? surfacePresenceByClientId[clientId] || null : null;
167
+ const previousThreadId = String(previousPresence?.threadId || "").trim();
168
+ const previousSurface = normalizeSurfaceName(previousPresence?.surface);
169
+ const previousCount =
170
+ previousThreadId && previousSurface
171
+ ? countSurfacePresence(surfacePresenceByClientId, previousThreadId, previousSurface)
172
+ : 0;
173
+ const nextThreadId = String(payload.threadId || selectedThreadId || "").trim();
174
+ const nextSurface = normalizeSurfaceName(payload.surface);
175
+ const nextCountBefore =
176
+ !payload.detach && nextThreadId && nextSurface
177
+ ? countSurfacePresence(surfacePresenceByClientId, nextThreadId, nextSurface)
178
+ : 0;
179
+
180
+ const updateResult = payload.detach
181
+ ? removeSurfacePresence(surfacePresenceByClientId, payload.clientId)
182
+ : upsertSurfacePresence(surfacePresenceByClientId, { ...payload, threadId: nextThreadId }, { updatedAt });
183
+
184
+ const events = [];
185
+
186
+ if (updateResult.changed) {
187
+ if (payload.detach) {
188
+ if (previousThreadId && previousSurface && previousCount === 1) {
189
+ events.push({
190
+ action: "detach",
191
+ at: updatedAt,
192
+ cause: "closed",
193
+ surface: previousSurface,
194
+ threadId: previousThreadId
195
+ });
196
+ }
197
+ } else {
198
+ if (
199
+ previousPresence &&
200
+ previousThreadId &&
201
+ previousSurface &&
202
+ (previousThreadId !== nextThreadId || previousSurface !== nextSurface) &&
203
+ previousCount === 1
204
+ ) {
205
+ events.push({
206
+ action: "detach",
207
+ at: updatedAt,
208
+ cause: "moved",
209
+ surface: previousSurface,
210
+ threadId: previousThreadId
211
+ });
212
+ }
213
+
214
+ if (nextThreadId && nextSurface) {
215
+ const nextCountAfter = countSurfacePresence(updateResult.nextPresenceByClientId, nextThreadId, nextSurface);
216
+ if (nextCountBefore === 0 && nextCountAfter > 0) {
217
+ events.push({
218
+ action: "attach",
219
+ at: updatedAt,
220
+ cause: previousPresence ? "moved" : "opened",
221
+ surface: nextSurface,
222
+ threadId: nextThreadId
223
+ });
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ return {
230
+ changed: updateResult.changed,
231
+ events,
232
+ nextPresenceByClientId: updateResult.nextPresenceByClientId,
233
+ nextPresence: updateResult.nextPresence || null,
234
+ previousPresence: updateResult.previousPresence || null
235
+ };
236
+ }
237
+
238
+ export function pruneStaleSurfacePresence(surfacePresenceByClientId = {}, { now = Date.now(), staleMs = 0 } = {}) {
239
+ const current = surfacePresenceByClientId || {};
240
+ let changed = false;
241
+ const nextPresenceByClientId = {};
242
+ const events = [];
243
+
244
+ for (const [clientId, presence] of Object.entries(current)) {
245
+ const updatedAtMs = new Date(presence.updatedAt || 0).getTime();
246
+ if (!updatedAtMs || now - updatedAtMs > staleMs) {
247
+ const threadId = String(presence?.threadId || "").trim();
248
+ const surface = normalizeSurfaceName(presence?.surface);
249
+ if (threadId && countSurfacePresence(current, threadId, surface) === 1) {
250
+ events.push({
251
+ action: "detach",
252
+ at: isoAt(now),
253
+ cause: "stale",
254
+ surface,
255
+ threadId
256
+ });
257
+ }
258
+ changed = true;
259
+ continue;
260
+ }
261
+
262
+ nextPresenceByClientId[clientId] = presence;
263
+ }
264
+
265
+ return {
266
+ changed,
267
+ events,
268
+ nextPresenceByClientId: changed ? nextPresenceByClientId : current
269
+ };
270
+ }
271
+
272
+ export function buildSelectedAttachments(surfacePresenceByClientId = {}, threadId = null) {
273
+ const nextThreadId = String(threadId || "").trim();
274
+ if (!nextThreadId) {
275
+ return [];
276
+ }
277
+
278
+ const grouped = new Map();
279
+ for (const presence of Object.values(surfacePresenceByClientId || {})) {
280
+ if (!presence?.threadId || presence.threadId !== nextThreadId) {
281
+ continue;
282
+ }
283
+
284
+ const surface = normalizeSurfaceName(presence.surface);
285
+ const current = grouped.get(surface);
286
+ if (!current) {
287
+ grouped.set(surface, {
288
+ count: 1,
289
+ label: surface,
290
+ state: surfacePresenceState(presence),
291
+ surface,
292
+ updatedAt: presence.updatedAt || null
293
+ });
294
+ continue;
295
+ }
296
+
297
+ const currentUpdatedAtMs = new Date(current.updatedAt || 0).getTime();
298
+ const nextUpdatedAtMs = new Date(presence.updatedAt || 0).getTime();
299
+ grouped.set(surface, {
300
+ ...current,
301
+ count: current.count + 1,
302
+ state: nextUpdatedAtMs >= currentUpdatedAtMs ? surfacePresenceState(presence) : current.state,
303
+ updatedAt: nextUpdatedAtMs >= currentUpdatedAtMs ? presence.updatedAt || null : current.updatedAt
304
+ });
305
+ }
306
+
307
+ return [...grouped.values()]
308
+ .sort((a, b) => {
309
+ const order = { remote: 10, host: 20 };
310
+ const delta = (order[a.surface] || 99) - (order[b.surface] || 99);
311
+ if (delta !== 0) {
312
+ return delta;
313
+ }
314
+ return String(a.label || "").localeCompare(String(b.label || ""));
315
+ })
316
+ .map(({ updatedAt: _updatedAt, ...attachment }) => attachment);
317
+ }
318
+
319
+ export function setControlLease({
320
+ clientId = null,
321
+ owner = "remote",
322
+ reason = "compose",
323
+ source = "remote",
324
+ threadId = null,
325
+ ttlMs,
326
+ now = Date.now()
327
+ } = {}) {
328
+ if (!threadId) {
329
+ throw new Error("No live session selected.");
330
+ }
331
+
332
+ return {
333
+ acquiredAt: isoAt(now),
334
+ expiresAt: isoAt(now + ttlMs),
335
+ ownerClientId: clientId ? String(clientId).trim() : null,
336
+ ownerLabel: surfaceActorLabel({ surface: owner || source, clientId }),
337
+ owner,
338
+ reason,
339
+ source,
340
+ threadId
341
+ };
342
+ }
343
+
344
+ export function getControlLeaseForThread(lease, threadId = null, { now = Date.now() } = {}) {
345
+ if (!lease) {
346
+ return null;
347
+ }
348
+
349
+ if (lease.expiresAt && new Date(lease.expiresAt).getTime() <= now) {
350
+ return null;
351
+ }
352
+
353
+ if (threadId && lease.threadId !== threadId) {
354
+ return null;
355
+ }
356
+
357
+ return lease;
358
+ }
359
+
360
+ export function clearControlLease(lease, { threadId = null, now = Date.now() } = {}) {
361
+ const current = getControlLeaseForThread(lease, null, { now });
362
+ if (!current) {
363
+ return null;
364
+ }
365
+
366
+ if (threadId && current.threadId !== threadId) {
367
+ return current;
368
+ }
369
+
370
+ return null;
371
+ }
372
+
373
+ export function renewControlLease({
374
+ lease,
375
+ clientId = null,
376
+ owner = null,
377
+ reason = null,
378
+ source = null,
379
+ threadId = null,
380
+ ttlMs,
381
+ now = Date.now()
382
+ } = {}) {
383
+ const current = getControlLeaseForThread(lease, threadId, { now });
384
+ if (!current) {
385
+ throw new Error("Remote control is not active.");
386
+ }
387
+
388
+ const nextClientId = clientId ? String(clientId).trim() : current.ownerClientId || null;
389
+ const nextOwner = owner || current.owner || "remote";
390
+ const nextSource = source || current.source || "remote";
391
+
392
+ return {
393
+ ...current,
394
+ expiresAt: isoAt(now + ttlMs),
395
+ ownerClientId: nextClientId,
396
+ ownerLabel: surfaceActorLabel({
397
+ surface: nextOwner || nextSource,
398
+ clientId: nextClientId
399
+ }),
400
+ owner: nextOwner,
401
+ reason: reason || current.reason || "compose",
402
+ source: nextSource
403
+ };
404
+ }
405
+
406
+ export function ensureControlActionAllowed({
407
+ action = "claim",
408
+ lease,
409
+ threadId,
410
+ source = "remote",
411
+ clientId = null,
412
+ now = Date.now()
413
+ } = {}) {
414
+ const current = getControlLeaseForThread(lease, threadId, { now });
415
+ const nextSource = normalizeSurfaceName(source);
416
+ if (!current || !isWriterSurface(nextSource)) {
417
+ return current;
418
+ }
419
+
420
+ const nextClientId = String(clientId || "").trim();
421
+ const currentOwner = normalizeSurfaceName(current.owner || current.source || "");
422
+ const currentOwnerLabel =
423
+ current.ownerLabel || surfaceActorLabel({ surface: current.owner || current.source, clientId: current.ownerClientId });
424
+
425
+ if (currentOwner === nextSource) {
426
+ if (nextClientId && current.ownerClientId && current.ownerClientId === nextClientId) {
427
+ return current;
428
+ }
429
+
430
+ throw new Error(`Another ${nextSource} surface currently holds control for this channel.`);
431
+ }
432
+
433
+ throw new Error(`${currentOwnerLabel} currently holds control for this channel.`);
434
+ }
435
+
436
+ export function ensureRemoteControlLease({
437
+ lease,
438
+ threadId,
439
+ source = "remote",
440
+ clientId = null,
441
+ ttlMs,
442
+ now = Date.now()
443
+ } = {}) {
444
+ ensureControlActionAllowed({
445
+ action: "renew",
446
+ lease,
447
+ threadId,
448
+ source,
449
+ clientId,
450
+ now
451
+ });
452
+ const current = getControlLeaseForThread(lease, threadId, { now });
453
+ const nextSource = normalizeSurfaceName(source);
454
+ if (!current || current.owner !== source) {
455
+ throw new Error(`Take control before sending from the ${nextSource}.`);
456
+ }
457
+
458
+ const nextClientId = String(clientId || "").trim();
459
+ if (nextClientId && current.ownerClientId && current.ownerClientId !== nextClientId) {
460
+ throw new Error(`Another ${nextSource} surface currently holds control for this channel.`);
461
+ }
462
+
463
+ return renewControlLease({
464
+ clientId: nextClientId || current.ownerClientId || null,
465
+ lease: current,
466
+ now,
467
+ owner: current.owner,
468
+ reason: current.reason || "compose",
469
+ source: current.source || source,
470
+ threadId,
471
+ ttlMs
472
+ });
473
+ }
@@ -0,0 +1,40 @@
1
+ export function applySharedSelectionState(
2
+ state = {},
3
+ {
4
+ cwd = null,
5
+ source = "remote",
6
+ threadId = null,
7
+ threads = []
8
+ } = {}
9
+ ) {
10
+ const previousThreadId = state.selectedThreadId || null;
11
+ const nextSelectedProjectCwd = cwd || state.selectedProjectCwd || "";
12
+
13
+ let nextSelectedThreadId = previousThreadId;
14
+ if (threadId) {
15
+ nextSelectedThreadId = threadId;
16
+ } else if (cwd) {
17
+ const nextThread = (threads || []).find((candidate) => candidate.cwd === cwd) || null;
18
+ nextSelectedThreadId = nextThread?.id || null;
19
+ }
20
+
21
+ const threadChanged = nextSelectedThreadId !== previousThreadId;
22
+
23
+ return {
24
+ nextState: {
25
+ selectedProjectCwd: nextSelectedProjectCwd,
26
+ selectedThreadId: nextSelectedThreadId,
27
+ selectionSource: source,
28
+ selectedThreadSnapshot: threadChanged ? null : state.selectedThreadSnapshot || null,
29
+ turnDiff:
30
+ state.turnDiff && state.turnDiff.threadId === nextSelectedThreadId
31
+ ? state.turnDiff
32
+ : null,
33
+ writeLock:
34
+ state.writeLock && state.writeLock.threadId === nextSelectedThreadId
35
+ ? state.writeLock
36
+ : null
37
+ },
38
+ threadChanged
39
+ };
40
+ }
@@ -0,0 +1,35 @@
1
+ export function createSseHub() {
2
+ const clients = new Set();
3
+
4
+ function writeEvent(res, event, payload) {
5
+ res.write(`event: ${event}\n`);
6
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
7
+ }
8
+
9
+ return {
10
+ broadcast(event, payload) {
11
+ for (const res of clients) {
12
+ writeEvent(res, event, payload);
13
+ }
14
+ },
15
+ close(res) {
16
+ clients.delete(res);
17
+ },
18
+ open(res, initialEvents = [], headers = {}) {
19
+ res.writeHead(200, {
20
+ "Cache-Control": "no-store",
21
+ Connection: "keep-alive",
22
+ "Content-Type": "text/event-stream",
23
+ ...headers
24
+ });
25
+
26
+ for (const entry of initialEvents) {
27
+ if (!entry?.event) {
28
+ continue;
29
+ }
30
+ writeEvent(res, entry.event, entry.payload);
31
+ }
32
+ clients.add(res);
33
+ }
34
+ };
35
+ }
@@ -0,0 +1,71 @@
1
+ import path from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ import { injectSurfaceBootstrap } from "./surface-access.mjs";
5
+ import { canServeSurfaceBootstrap } from "./surface-request-guard.mjs";
6
+
7
+ export function createStaticSurfaceService({
8
+ exposeHostSurface = false,
9
+ issueSurfaceBootstrap,
10
+ mimeTypes = {},
11
+ publicDir,
12
+ readFileFn = readFile,
13
+ sendJson
14
+ } = {}) {
15
+ if (!publicDir) {
16
+ throw new Error("createStaticSurfaceService requires a publicDir.");
17
+ }
18
+ if (typeof issueSurfaceBootstrap !== "function") {
19
+ throw new Error("createStaticSurfaceService requires issueSurfaceBootstrap.");
20
+ }
21
+ if (typeof sendJson !== "function") {
22
+ throw new Error("createStaticSurfaceService requires sendJson.");
23
+ }
24
+
25
+ async function serveStatic(req, res, pathname) {
26
+ const relativePath = pathname === "/" ? "remote.html" : pathname.slice(1);
27
+ const filePath = path.join(publicDir, relativePath);
28
+
29
+ try {
30
+ const ext = path.extname(filePath);
31
+ const shouldInjectBootstrap =
32
+ relativePath === "remote.html" || relativePath === "host.html";
33
+ if (
34
+ shouldInjectBootstrap &&
35
+ !canServeSurfaceBootstrap({
36
+ exposeHostSurface,
37
+ localAddress: req.socket?.localAddress || "",
38
+ pathname,
39
+ remoteAddress: req.socket?.remoteAddress || ""
40
+ })
41
+ ) {
42
+ sendJson(res, 403, {
43
+ error: "Host surface is restricted to loopback unless DEXTUNNEL_EXPOSE_HOST_SURFACE is enabled."
44
+ });
45
+ return;
46
+ }
47
+
48
+ const data = shouldInjectBootstrap
49
+ ? injectSurfaceBootstrap(
50
+ await readFileFn(filePath, "utf8"),
51
+ issueSurfaceBootstrap(relativePath === "host.html" ? "host" : "remote")
52
+ )
53
+ : await readFileFn(filePath);
54
+ res.writeHead(200, {
55
+ "Cache-Control": "no-store, max-age=0",
56
+ "Content-Type": mimeTypes[ext] || "application/octet-stream",
57
+ Pragma: "no-cache"
58
+ });
59
+ res.end(data);
60
+ } catch (error) {
61
+ sendJson(res, 404, {
62
+ error: `Not found: ${relativePath}`,
63
+ detail: error.message
64
+ });
65
+ }
66
+ }
67
+
68
+ return {
69
+ serveStatic
70
+ };
71
+ }