authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 16:37:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 16:37:46-07:00
log5754121c23f73201d47a96feb97eca7b96334a2a
tree41aeea924b2334a4ce889e4e8454e970f232a64a
parent741377ce5fb1578916659cbb93fd403b26dbeb62
signature Signed by PGP key B5690EEEBB952194

fix(server): environment-hosted browser tabs behave like a normal browser (#16963)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

29 files changed, 1202 insertions(+), 44 deletions(-)

apps/server/src/orchestration-v2/EffectOutbox.ts+5
...@@ -95,6 +95,10 @@ export const OrchestrationEffectRequestV2 = Schema.Union([...@@ -95,6 +95,10 @@ export const OrchestrationEffectRequestV2 = Schema.Union([
95 Schema.Struct({95 Schema.Struct({
96 type: Schema.Literal("terminal.cleanup"),96 type: Schema.Literal("terminal.cleanup"),
97 }),97 }),
98 /** Closes a deleted thread's preview sessions, ending their server browser tabs. */
99 Schema.Struct({
100 type: Schema.Literal("preview.cleanup"),
101 }),
98 Schema.Struct({102 Schema.Struct({
99 type: Schema.Literal("attachment.cleanup"),103 type: Schema.Literal("attachment.cleanup"),
100 attachmentIds: Schema.Array(Schema.String),104 attachmentIds: Schema.Array(Schema.String),
...@@ -120,6 +124,7 @@ export const REPLAY_SAFE_EFFECT_TYPES_AFTER_PROCESS_LOSS = [...@@ -120,6 +124,7 @@ export const REPLAY_SAFE_EFFECT_TYPES_AFTER_PROCESS_LOSS = [
120 "provider-thread.rollback",124 "provider-thread.rollback",
121 "checkpoint.capture",125 "checkpoint.capture",
122 "terminal.cleanup",126 "terminal.cleanup",
127 "preview.cleanup",
123 "attachment.cleanup",128 "attachment.cleanup",
124 "thread-title.generate",129 "thread-title.generate",
125 "delegated-tasks.stop",130 "delegated-tasks.stop",
apps/server/src/orchestration-v2/EffectWorker.ts+11
...@@ -439,6 +439,17 @@ export const layerExecutor: Layer.Layer<...@@ -439,6 +439,17 @@ export const layerExecutor: Layer.Layer<
439 }),439 }),
440 ),440 ),
441 );441 );
442 case "preview.cleanup":
443 return resourceCleanup.cleanupPreviews(effect.threadId).pipe(
444 Effect.mapError(
445 (cause) =>
446 new OrchestrationEffectExecutionError({
447 effectId: effect.id,
448 effectType: effect.request.type,
449 cause,
450 }),
451 ),
452 );
442 case "attachment.cleanup":453 case "attachment.cleanup":
443 return resourceCleanup.cleanupAttachments(effect.request.attachmentIds).pipe(454 return resourceCleanup.cleanupAttachments(effect.request.attachmentIds).pipe(
444 Effect.mapError(455 Effect.mapError(
apps/server/src/orchestration-v2/ResourceCleanupService.ts+15-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { ThreadId } from "@t3tools/contracts";
1import * as Context from "effect/Context";2import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
...@@ -6,12 +7,13 @@ import * as Schema from "effect/Schema";...@@ -6,12 +7,13 @@ import * as Schema from "effect/Schema";
67
7import { resolveAttachmentPathById } from "../attachmentStore.ts";8import { resolveAttachmentPathById } from "../attachmentStore.ts";
8import * as ServerConfig from "../config.ts";9import * as ServerConfig from "../config.ts";
10import * as PreviewManager from "../preview/Manager.ts";
9import * as TerminalManager from "../terminal/Manager.ts";11import * as TerminalManager from "../terminal/Manager.ts";
1012
11export class ResourceCleanupError extends Schema.TaggedError<ResourceCleanupError>()(13export class ResourceCleanupError extends Schema.TaggedError<ResourceCleanupError>()(
12 "ResourceCleanupError",14 "ResourceCleanupError",
13 {15 {
14 operation: Schema.Literals(["terminal", "attachment"]),16 operation: Schema.Literals(["terminal", "preview", "attachment"]),
15 threadId: Schema.optional(Schema.String),17 threadId: Schema.optional(Schema.String),
16 attachmentId: Schema.optional(Schema.String),18 attachmentId: Schema.optional(Schema.String),
17 cause: Schema.Defect(),19 cause: Schema.Defect(),
...@@ -20,12 +22,15 @@ export class ResourceCleanupError extends Schema.TaggedError<ResourceCleanupErro...@@ -20,12 +22,15 @@ export class ResourceCleanupError extends Schema.TaggedError<ResourceCleanupErro
2022
21export class ResourceCleanupService extends Context.Reference<{23export class ResourceCleanupService extends Context.Reference<{
22 readonly cleanupTerminals: (threadId: string) => Effect.Effect<void, ResourceCleanupError>;24 readonly cleanupTerminals: (threadId: string) => Effect.Effect<void, ResourceCleanupError>;
25 /** Closes every preview session of the thread; server browser tabs end with them. */
26 readonly cleanupPreviews: (threadId: string) => Effect.Effect<void, ResourceCleanupError>;
23 readonly cleanupAttachments: (27 readonly cleanupAttachments: (
24 attachmentIds: ReadonlyArray<string>,28 attachmentIds: ReadonlyArray<string>,
25 ) => Effect.Effect<void, ResourceCleanupError>;29 ) => Effect.Effect<void, ResourceCleanupError>;
26}>("t3/orchestration-v2/ResourceCleanupService", {30}>("t3/orchestration-v2/ResourceCleanupService", {
27 defaultValue: () => ({31 defaultValue: () => ({
28 cleanupTerminals: () => Effect.void,32 cleanupTerminals: () => Effect.void,
33 cleanupPreviews: () => Effect.void,
29 cleanupAttachments: () => Effect.void,34 cleanupAttachments: () => Effect.void,
30 }),35 }),
31}) {}36}) {}
...@@ -34,6 +39,7 @@ export const layer = Layer.effect(...@@ -34,6 +39,7 @@ export const layer = Layer.effect(
34 ResourceCleanupService,39 ResourceCleanupService,
35 Effect.gen(function* () {40 Effect.gen(function* () {
36 const terminals = yield* TerminalManager.TerminalManager;41 const terminals = yield* TerminalManager.TerminalManager;
42 const previews = yield* PreviewManager.PreviewManager;
37 const fileSystem = yield* FileSystem.FileSystem;43 const fileSystem = yield* FileSystem.FileSystem;
38 const config = yield* ServerConfig.ServerConfig;44 const config = yield* ServerConfig.ServerConfig;
39 return {45 return {
...@@ -45,6 +51,14 @@ export const layer = Layer.effect(...@@ -45,6 +51,14 @@ export const layer = Layer.effect(
45 (cause) => new ResourceCleanupError({ operation: "terminal", threadId, cause }),51 (cause) => new ResourceCleanupError({ operation: "terminal", threadId, cause }),
46 ),52 ),
47 ),53 ),
54 cleanupPreviews: (threadId: string) =>
55 previews
56 .close({ threadId: ThreadId.make(threadId) })
57 .pipe(
58 Effect.mapError(
59 (cause) => new ResourceCleanupError({ operation: "preview", threadId, cause }),
60 ),
61 ),
48 cleanupAttachments: (attachmentIds: ReadonlyArray<string>) =>62 cleanupAttachments: (attachmentIds: ReadonlyArray<string>) =>
49 Effect.forEach(63 Effect.forEach(
50 attachmentIds,64 attachmentIds,
apps/server/src/orchestration-v2/ThreadDeletion.test.ts+1
...@@ -291,6 +291,7 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio...@@ -291,6 +291,7 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio
291 revokeMcpCredential: true,291 revokeMcpCredential: true,
292 },292 },
293 { type: "terminal.cleanup" },293 { type: "terminal.cleanup" },
294 { type: "preview.cleanup" },
294 { type: "attachment.cleanup", attachmentIds: ["shared_file"] },295 { type: "attachment.cleanup", attachmentIds: ["shared_file"] },
295 ],296 ],
296 );297 );
apps/server/src/orchestration-v2/ThreadDeletion.ts+6
...@@ -215,6 +215,12 @@ export const planThreadDeletion = Effect.fn("ThreadDeletion.planThreadDeletion")...@@ -215,6 +215,12 @@ export const planThreadDeletion = Effect.fn("ThreadDeletion.planThreadDeletion")
215 threadId: command.threadId,215 threadId: command.threadId,
216 request: { type: "terminal.cleanup" },216 request: { type: "terminal.cleanup" },
217 });217 });
218 effects.push({
219 id: `effect:${command.commandId}:preview.cleanup`,
220 commandId: command.commandId,
221 threadId: command.threadId,
222 request: { type: "preview.cleanup" },
223 });
218 const attachmentIds = Array.from(new Set(input.attachmentIds));224 const attachmentIds = Array.from(new Set(input.attachmentIds));
219 if (attachmentIds.length > 0) {225 if (attachmentIds.length > 0) {
220 effects.push({226 effects.push({
apps/server/src/preview/DesktopBrowserChannel.ts+7
...@@ -54,6 +54,8 @@ export class DesktopBrowserChannel extends Context.Service<...@@ -54,6 +54,8 @@ export class DesktopBrowserChannel extends Context.Service<
54 readonly awaitAttached: (key: DesktopTabKey, timeout: Duration.Input) => Effect.Effect<boolean>;54 readonly awaitAttached: (key: DesktopTabKey, timeout: Duration.Input) => Effect.Effect<boolean>;
55 /** Desktop tabs as they detach. */55 /** Desktop tabs as they detach. */
56 readonly detached: Stream.Stream<DesktopTabKey>;56 readonly detached: Stream.Stream<DesktopTabKey>;
57 /** Desktop tabs as they attach, including a tab coming back after its DevTools close. */
58 readonly attached: Stream.Stream<DesktopTabKey>;
57 readonly isAttached: (key: DesktopTabKey) => Effect.Effect<boolean>;59 readonly isAttached: (key: DesktopTabKey) => Effect.Effect<boolean>;
58 /**60 /**
59 * A one-connection CDP endpoint for an attached tab. Closing the scope61 * A one-connection CDP endpoint for an attached tab. Closing the scope
...@@ -83,6 +85,7 @@ const make = Effect.gen(function* () {...@@ -83,6 +85,7 @@ const make = Effect.gen(function* () {
83 available: false,85 available: false,
84 awaitAttached: () => Effect.succeed(false),86 awaitAttached: () => Effect.succeed(false),
85 detached: Stream.empty,87 detached: Stream.empty,
88 attached: Stream.empty,
86 isAttached: () => Effect.succeed(false),89 isAttached: () => Effect.succeed(false),
87 endpoint: () => Effect.die("No desktop app is attached to this server."),90 endpoint: () => Effect.die("No desktop app is attached to this server."),
88 pointer: () => Effect.void,91 pointer: () => Effect.void,
...@@ -216,6 +219,10 @@ const make = Effect.gen(function* () {...@@ -216,6 +219,10 @@ const make = Effect.gen(function* () {
216 Stream.filter((change) => !change.attached),219 Stream.filter((change) => !change.attached),
217 Stream.map((change) => change.key),220 Stream.map((change) => change.key),
218 ),221 ),
222 attached: Stream.fromPubSub(changes).pipe(
223 Stream.filter((change) => change.attached),
224 Stream.map((change) => change.key),
225 ),
219 isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))),226 isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))),
220 endpoint,227 endpoint,
221 pointer: (key, pointer) => command({ type: "pointer", ...key, ...pointer }),228 pointer: (key, pointer) => command({ type: "pointer", ...key, ...pointer }),
apps/server/src/preview/Manager.ts+3
...@@ -317,6 +317,9 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -317,6 +317,9 @@ export const make = Effect.gen(function* PreviewManagerMake() {
317 title: input.navStatus.title,317 title: input.navStatus.title,
318 code: input.navStatus.code,318 code: input.navStatus.code,
319 description: input.navStatus.description,319 description: input.navStatus.description,
320 ...(input.navStatus.download === undefined
321 ? {}
322 : { download: input.navStatus.download }),
320 }323 }
321 : {324 : {
322 type: "navigated",325 type: "navigated",
apps/server/src/preview/PortScanner.test.ts+114
...@@ -14,6 +14,7 @@ import * as Duration from "effect/Duration";...@@ -14,6 +14,7 @@ import * as Duration from "effect/Duration";
14import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
15import * as Exit from "effect/Exit";15import * as Exit from "effect/Exit";
16import * as Fiber from "effect/Fiber";16import * as Fiber from "effect/Fiber";
17import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";18import * as Layer from "effect/Layer";
18import * as PlatformError from "effect/PlatformError";19import * as PlatformError from "effect/PlatformError";
19import * as Scope from "effect/Scope";20import * as Scope from "effect/Scope";
...@@ -53,13 +54,18 @@ const layerTestIntegrationNet = Layer.succeed(Net.NetService, {...@@ -53,13 +54,18 @@ const layerTestIntegrationNet = Layer.succeed(Net.NetService, {
53 findAvailablePort: (preferred) => Effect.succeed(preferred),54 findAvailablePort: (preferred) => Effect.succeed(preferred),
54});55});
5556
57/** A host without `/proc`, so a missing `lsof` falls back to common ports. */
58const layerNoProc = FileSystem.layerNoop({});
59
56const layerProbeFailure = (60const layerProbeFailure = (
57 run: ProcessRunner.ProcessRunner["Service"]["run"],61 run: ProcessRunner.ProcessRunner["Service"]["run"],
58 fetch: typeof globalThis.fetch = globalThis.fetch,62 fetch: typeof globalThis.fetch = globalThis.fetch,
63 fileSystem: Layer.Layer<FileSystem.FileSystem> = layerNoProc,
59) =>64) =>
60 PortScanner.layer.pipe(65 PortScanner.layer.pipe(
61 Layer.provide(66 Layer.provide(
62 Layer.mergeAll(67 Layer.mergeAll(
68 fileSystem,
63 Layer.succeed(ProcessRunner.ProcessRunner, { run }),69 Layer.succeed(ProcessRunner.ProcessRunner, { run }),
64 Layer.succeed(Net.NetService, {70 Layer.succeed(Net.NetService, {
65 canListenOnHost: () => Effect.succeed(true),71 canListenOnHost: () => Effect.succeed(true),
...@@ -77,6 +83,7 @@ const layerProbeFailure = (...@@ -77,6 +83,7 @@ const layerProbeFailure = (
77const layerTestPortDiscovery = PortScanner.layer.pipe(83const layerTestPortDiscovery = PortScanner.layer.pipe(
78 Layer.provide(84 Layer.provide(
79 Layer.mergeAll(85 Layer.mergeAll(
86 layerNoProc,
80 layerTestProcessRunner,87 layerTestProcessRunner,
81 layerTestIntegrationNet,88 layerTestIntegrationNet,
82 Layer.succeed(HostProcessPlatform, "win32"),89 Layer.succeed(HostProcessPlatform, "win32"),
...@@ -94,6 +101,7 @@ const layerLsofScanner = (input: {...@@ -94,6 +101,7 @@ const layerLsofScanner = (input: {
94 PortScanner.layer.pipe(101 PortScanner.layer.pipe(
95 Layer.provide(102 Layer.provide(
96 Layer.mergeAll(103 Layer.mergeAll(
104 layerNoProc,
97 Layer.succeed(ProcessRunner.ProcessRunner, {105 Layer.succeed(ProcessRunner.ProcessRunner, {
98 run: () =>106 run: () =>
99 Effect.succeed({107 Effect.succeed({
...@@ -671,6 +679,112 @@ effectIt.effect("does not swallow process probe defects", () =>...@@ -671,6 +679,112 @@ effectIt.effect("does not swallow process probe defects", () =>
671 }),679 }),
672);680);
673681
682// /proc/net/tcp: 127.0.0.1:8765 and 0.0.0.0:22 listening, an established
683// connection, and a non-loopback listener; /proc/net/tcp6 adds [::]:3001.
684const PROC_NET_TCP = ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
685 0: 0100007F:223D 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5001 1 0000000000000000 100 0 0 10 0
686 1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 5002 1 0000000000000000 100 0 0 10 0
687 2: 0100007F:223D 0100007F:C350 01 00000000:00000000 00:00000000 00000000 1000 0 5003 1 0000000000000000 20 4 30 10 -1
688 3: 0A00000F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5005 1 0000000000000000 100 0 0 10 0
689`;
690const PROC_NET_TCP6 = ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
691 0: 00000000000000000000000000000000:0BB9 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5004 1 0000000000000000 100 0 0 10 0
692`;
693
694effectIt.effect(
695 "reads Linux listeners from /proc when lsof is missing, and stops spawning it",
696 () => {
697 let lsofSpawns = 0;
698 const fdWalks: string[] = [];
699 const procFiles: Record<string, string> = {
700 "/proc/net/tcp": PROC_NET_TCP,
701 "/proc/net/tcp6": PROC_NET_TCP6,
702 "/proc/4242/comm": "python3\n",
703 };
704 const procFds: Record<string, Record<string, string>> = {
705 "4242": { "0": "/dev/null", "3": "socket:[5001]" },
706 "77": { "5": "socket:[9999]" },
707 };
708 const missing = FileSystem.makeNoop({});
709 const fileSystem = FileSystem.layerNoop({
710 readFileString: (path) => {
711 const content = procFiles[path];
712 return content === undefined ? missing.readFileString(path) : Effect.succeed(content);
713 },
714 readDirectory: (path) => {
715 if (path === "/proc") return Effect.succeed(["self", "77", "4242"]);
716 const pid = /^\/proc\/(\d+)\/fd$/.exec(path)?.[1];
717 const fds = pid === undefined ? undefined : procFds[pid];
718 if (pid === undefined || fds === undefined) return missing.readDirectory(path);
719 fdWalks.push(pid);
720 return Effect.succeed(Object.keys(fds));
721 },
722 readLink: (path) => {
723 const [, pid, fd] = /^\/proc\/(\d+)\/fd\/(\d+)$/.exec(path) ?? [];
724 const target = pid && fd ? procFds[pid]?.[fd] : undefined;
725 return target === undefined ? missing.readLink(path) : Effect.succeed(target);
726 },
727 });
728 const fetchFn = (() =>
729 Promise.resolve(
730 new Response("hello", { headers: { "content-type": "text/html" } }),
731 )) as typeof globalThis.fetch;
732 const layer = layerProbeFailure(
733 (input) => {
734 lsofSpawns += 1;
735 return processProbeFailure(input);
736 },
737 fetchFn,
738 fileSystem,
739 );
740
741 return Effect.gen(function* () {
742 const scanner = yield* PortScanner.PortDiscovery;
743 const first = yield* scanner.scan();
744 expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([
745 { port: 22, pid: null, processName: null },
746 { port: 3001, pid: null, processName: null },
747 { port: 8765, pid: 4242, processName: "python3" },
748 ]);
749 yield* scanner.scan();
750 expect(lsofSpawns).toBe(1);
751 // Ports 22 and 3001 have no readable owner, so the second scan walks
752 // again; it stops at 77, which holds no listener, once nothing is left.
753 expect(fdWalks).toEqual(["77", "4242", "77", "4242"]);
754 yield* scanner.scan();
755 yield* scanner.scan();
756 // Unresolved owners are retried a bounded number of times.
757 expect(fdWalks).toEqual(["77", "4242", "77", "4242", "77", "4242"]);
758 }).pipe(Effect.provide(layer));
759 },
760);
761
762effectIt.effect("keeps spawning lsof after a spawn failure that is not a missing command", () => {
763 let lsofSpawns = 0;
764 const layer = layerProbeFailure((input) => {
765 lsofSpawns += 1;
766 return Effect.fail(
767 new ProcessRunner.ProcessSpawnError({
768 command: input.command,
769 argumentCount: input.args.length,
770 cwd: input.cwd,
771 cause: PlatformError.systemError({
772 _tag: "Unknown",
773 module: "ChildProcess",
774 method: "spawn",
775 description: "EAGAIN",
776 }),
777 }),
778 );
779 });
780 return Effect.gen(function* () {
781 const scanner = yield* PortScanner.PortDiscovery;
782 yield* scanner.scan();
783 yield* scanner.scan();
784 expect(lsofSpawns).toBe(2);
785 }).pipe(Effect.provide(layer));
786});
787
674effectIt.effect("does not swallow process probe interruption", () =>788effectIt.effect("does not swallow process probe interruption", () =>
675 Effect.gen(function* () {789 Effect.gen(function* () {
676 const layer = layerProbeFailure(() => Effect.interrupt);790 const layer = layerProbeFailure(() => Effect.interrupt);
apps/server/src/preview/PortScanner.ts+157-4
...@@ -5,8 +5,11 @@...@@ -5,8 +5,11 @@
5 * stable line-prefixed field format; this is the only `lsof` flag set we rely5 * stable line-prefixed field format; this is the only `lsof` flag set we rely
6 * on).6 * on).
7 *7 *
8 * Windows / lsof missing: checks a curated list of common dev ports through8 * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and
9 * the shared Net service.9 * finds their processes through `/proc/<pid>/fd`.
10 *
11 * Windows listener failures, or neither source: checks a curated list of
12 * common dev ports through the shared Net service.
10 *13 *
11 * Listening ports are published only after a bounded HTTP(S) probe finds a14 * Listening ports are published only after a bounded HTTP(S) probe finds a
12 * successful HTML document or a redirect to one.15 * successful HTML document or a redirect to one.
...@@ -30,8 +33,10 @@ import * as Clock from "effect/Clock";...@@ -30,8 +33,10 @@ import * as Clock from "effect/Clock";
30import * as Context from "effect/Context";33import * as Context from "effect/Context";
31import * as Duration from "effect/Duration";34import * as Duration from "effect/Duration";
32import * as Effect from "effect/Effect";35import * as Effect from "effect/Effect";
36import * as FileSystem from "effect/FileSystem";
33import * as Layer from "effect/Layer";37import * as Layer from "effect/Layer";
34import * as Option from "effect/Option";38import * as Option from "effect/Option";
39import * as PlatformError from "effect/PlatformError";
35import * as Ref from "effect/Ref";40import * as Ref from "effect/Ref";
36import * as Schedule from "effect/Schedule";41import * as Schedule from "effect/Schedule";
37import * as Scope from "effect/Scope";42import * as Scope from "effect/Scope";
...@@ -72,6 +77,10 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([...@@ -72,6 +77,10 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([
7277
73const POLL_INTERVAL = Duration.seconds(3);78const POLL_INTERVAL = Duration.seconds(3);
74const LSOF_TIMEOUT_MS = 5_000;79const LSOF_TIMEOUT_MS = 5_000;
80/** File descriptors one `/proc` walk may read before it stops looking for socket owners. */
81const PROC_FD_WALK_LIMIT = 50_000;
82/** Scans that retry the fd walk for a listening socket whose owner was not found. */
83const SOCKET_OWNER_ATTEMPTS = 3;
75const WINDOWS_LISTENER_TIMEOUT_MS = 5_000;84const WINDOWS_LISTENER_TIMEOUT_MS = 5_000;
76const WEB_PROBE_TIMEOUT = Duration.seconds(1);85const WEB_PROBE_TIMEOUT = Duration.seconds(1);
77const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15));86const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15));
...@@ -265,6 +274,42 @@ const parseWindowsListenerOutput = (...@@ -265,6 +274,42 @@ const parseWindowsListenerOutput = (
265 return [...seen.values()].toSorted((left, right) => left.port - right.port);274 return [...seen.values()].toSorted((left, right) => left.port - right.port);
266};275};
267276
277/** `/proc/net/tcp` state for a listening socket. */
278const PROC_TCP_LISTEN = "0A";
279
280/**
281 * Whether a `/proc/net/tcp{,6}` address is one the preview can reach on
282 * loopback: the wildcard or a loopback address, as `lsof` parsing accepts.
283 * Each 32-bit word of the hex address is little-endian.
284 */
285const procLocalAddress = (hex: string): boolean => {
286 if (hex.length === 8) {
287 return hex === "00000000" || hex.endsWith("7F");
288 }
289 if (hex.length !== 32) return false;
290 if (hex === "0".repeat(32) || hex === `${"0".repeat(24)}01000000`) return true;
291 // IPv4-mapped (::ffff:a.b.c.d): wildcard or 127.0.0.0/8.
292 return hex.startsWith(`${"0".repeat(16)}FFFF0000`) && procLocalAddress(hex.slice(24));
293};
294
295/** Listening loopback ports in `/proc/net/tcp{,6}` content, with their socket inodes. */
296const parseProcNetTcp = (
297 raw: string,
298): ReadonlyArray<{ readonly port: number; readonly inode: string }> => {
299 const listeners: Array<{ port: number; inode: string }> = [];
300 for (const line of raw.split("\n").slice(1)) {
301 const fields = line.trim().split(/\s+/);
302 const [address, portHex] = fields[1]?.split(":") ?? [];
303 if (fields[3] !== PROC_TCP_LISTEN || !address || !portHex) continue;
304 if (!procLocalAddress(address.toUpperCase())) continue;
305 const port = Number.parseInt(portHex, 16);
306 const inode = fields[9];
307 if (!Number.isInteger(port) || port <= 0 || port >= 65536 || !inode) continue;
308 listeners.push({ port, inode });
309 }
310 return listeners;
311};
312
268const serversEqual = (313const serversEqual = (
269 left: ReadonlyArray<DiscoveredLocalServer>,314 left: ReadonlyArray<DiscoveredLocalServer>,
270 right: ReadonlyArray<DiscoveredLocalServer>,315 right: ReadonlyArray<DiscoveredLocalServer>,
...@@ -289,10 +334,14 @@ const serversEqual = (...@@ -289,10 +334,14 @@ const serversEqual = (
289 return true;334 return true;
290};335};
291336
337const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean =>
338 PlatformError.isPlatformError(error.cause) && error.cause.reason._tag === "NotFound";
339
292/** @public Service construction is part of the canonical Effect module API. */340/** @public Service construction is part of the canonical Effect module API. */
293export const make = Effect.gen(function* PortDiscoveryMake() {341export const make = Effect.gen(function* PortDiscoveryMake() {
294 const net = yield* Net.NetService;342 const net = yield* Net.NetService;
295 const processRunner = yield* ProcessRunner.ProcessRunner;343 const processRunner = yield* ProcessRunner.ProcessRunner;
344 const fileSystem = yield* FileSystem.FileSystem;
296 const hostPlatform = yield* HostProcessPlatform;345 const hostPlatform = yield* HostProcessPlatform;
297 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);346 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
298 const stateRef = yield* Ref.make<ScannerState>({347 const stateRef = yield* Ref.make<ScannerState>({
...@@ -302,6 +351,101 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -302,6 +351,101 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
302 });351 });
303 const webProbeCacheRef = yield* Ref.make<ReadonlyMap<string, WebProbeCacheEntry>>(new Map());352 const webProbeCacheRef = yield* Ref.make<ReadonlyMap<string, WebProbeCacheEntry>>(new Map());
304 const scanSemaphore = yield* Semaphore.make(1);353 const scanSemaphore = yield* Semaphore.make(1);
354 const lsofMissingRef = yield* Ref.make(false);
355 /** Confirmed owners of listening socket inodes. */
356 const socketOwnersRef = yield* Ref.make<
357 ReadonlyMap<string, { readonly pid: number; readonly processName: string | null }>
358 >(new Map());
359 /** Fd walks that found no owner, per socket inode; retried up to `SOCKET_OWNER_ATTEMPTS`. */
360 const socketOwnerMissesRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map());
361
362 /** Maps socket inodes to the processes holding them, by `/proc/<pid>/fd`. */
363 const findSocketOwners = Effect.fn("PortDiscovery.findSocketOwners")(function* (
364 inodes: ReadonlySet<string>,
365 ) {
366 const owners = new Map<string, { pid: number; processName: string | null }>();
367 const pids = (yield* fileSystem
368 .readDirectory("/proc")
369 .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []))).filter((entry) =>
370 /^\d+$/.test(entry),
371 );
372 let budget = PROC_FD_WALK_LIMIT;
373 for (const pidText of pids) {
374 if (owners.size === inodes.size || budget <= 0) break;
375 // Other users' fds are unreadable; their ports still list, without a pid.
376 const fds = yield* fileSystem
377 .readDirectory(`/proc/${pidText}/fd`)
378 .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));
379 const walked = fds.slice(0, budget);
380 budget -= walked.length;
381 const links = yield* Effect.forEach(
382 walked,
383 (fd) =>
384 fileSystem.readLink(`/proc/${pidText}/fd/${fd}`).pipe(Effect.orElseSucceed(() => "")),
385 { concurrency: 16 },
386 );
387 const held = links.flatMap((link) => /^socket:\[(\d+)\]$/.exec(link)?.[1] ?? []);
388 const matched = held.filter((inode) => inodes.has(inode) && !owners.has(inode));
389 if (matched.length === 0) continue;
390 const processName = yield* fileSystem.readFileString(`/proc/${pidText}/comm`).pipe(
391 Effect.map((name) => name.trim() || null),
392 Effect.orElseSucceed(() => null),
393 );
394 for (const inode of matched) owners.set(inode, { pid: Number(pidText), processName });
395 }
396 return owners;
397 });
398
399 /**
400 * Linux listeners from `/proc/net/tcp{,6}`, for hosts without `lsof`. Null
401 * when neither file is readable, so discovery falls back to common ports.
402 */
403 const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* (
404 terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner>,
405 ) {
406 if (hostPlatform !== "linux") return null;
407 const tables = yield* Effect.forEach(["/proc/net/tcp", "/proc/net/tcp6"], (path) =>
408 fileSystem.readFileString(path).pipe(Effect.option),
409 );
410 if (tables.every(Option.isNone)) return null;
411 const listeners = tables.flatMap((table) =>
412 Option.isSome(table) ? parseProcNetTcp(table.value) : [],
413 );
414 const known = yield* Ref.get(socketOwnersRef);
415 const knownMisses = yield* Ref.get(socketOwnerMissesRef);
416 const inodes = new Set(listeners.map((listener) => listener.inode));
417 // Listeners rarely change, so the fd walk runs only for sockets without a
418 // confirmed owner, and gives up on one after a few walks miss it (another
419 // user's process, or one that exited mid-walk).
420 const unseen = new Set(
421 [...inodes].filter(
422 (inode) => !known.has(inode) && (knownMisses.get(inode) ?? 0) < SOCKET_OWNER_ATTEMPTS,
423 ),
424 );
425 const found = unseen.size === 0 ? new Map() : yield* findSocketOwners(unseen);
426 const owners = new Map([...known, ...found].filter(([inode]) => inodes.has(inode)));
427 const misses = new Map([...knownMisses].filter(([inode]) => inodes.has(inode)));
428 for (const inode of unseen) {
429 if (!found.has(inode)) misses.set(inode, (misses.get(inode) ?? 0) + 1);
430 }
431 yield* Ref.set(socketOwnersRef, owners);
432 yield* Ref.set(socketOwnerMissesRef, misses);
433 const seen = new Map<number, DiscoveredLocalServer>();
434 for (const { port, inode } of listeners) {
435 const owner = owners.get(inode) ?? null;
436 const existing = seen.get(port);
437 if (existing && (existing.pid !== null || owner === null)) continue;
438 seen.set(port, {
439 host: "localhost",
440 port,
441 url: `http://localhost:${port}`,
442 processName: owner?.processName ?? null,
443 pid: owner?.pid ?? null,
444 terminal: owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null),
445 });
446 }
447 return [...seen.values()].toSorted((left, right) => left.port - right.port);
448 });
305449
306 const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () {450 const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () {
307 const results = yield* Effect.forEach(451 const results = yield* Effect.forEach(
...@@ -514,6 +658,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -514,6 +658,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
514 return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls);658 return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls);
515 }659 }
516 const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");660 const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");
661 if (yield* Ref.get(lsofMissingRef)) {
662 const fromProc = yield* scanProcListeners(terminalByProcessId);
663 return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls);
664 }
517 const lsofResult = yield* processRunner665 const lsofResult = yield* processRunner
518 .run({666 .run({
519 command: "lsof",667 command: "lsof",
...@@ -525,7 +673,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -525,7 +673,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
525 .pipe(673 .pipe(
526 Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)),674 Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)),
527 Effect.catchTags({675 Effect.catchTags({
528 ProcessSpawnError: recoverLsofProbeFailure,676 // A missing lsof stays missing; later scans skip straight to the fallback.
677 ProcessSpawnError: (error) =>
678 (isCommandNotFound(error) ? Ref.set(lsofMissingRef, true) : Effect.void).pipe(
679 Effect.andThen(recoverLsofProbeFailure(error)),
680 ),
529 ProcessStdinError: recoverLsofProbeFailure,681 ProcessStdinError: recoverLsofProbeFailure,
530 ProcessOutputLimitError: recoverLsofProbeFailure,682 ProcessOutputLimitError: recoverLsofProbeFailure,
531 ProcessReadError: recoverLsofProbeFailure,683 ProcessReadError: recoverLsofProbeFailure,
...@@ -533,7 +685,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -533,7 +685,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
533 }),685 }),
534 );686 );
535 if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls);687 if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls);
536 return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls);688 const fromProc = yield* scanProcListeners(terminalByProcessId);
689 return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls);
537 });690 });
538691
539 const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")(692 const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")(
apps/server/src/preview/PreviewBrowserHost.test.ts+31-5
...@@ -20,7 +20,11 @@ const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000)...@@ -20,7 +20,11 @@ const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000)
20const SANDBOX_ABORT =20const SANDBOX_ABORT =
21 "[1005/163106.285700:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:129] No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux distro that has disabled unprivileged user namespaces with AppArmor, see https://chromium.googlesource.com/";21 "[1005/163106.285700:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:129] No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux distro that has disabled unprivileged user namespaces with AppArmor, see https://chromium.googlesource.com/";
2222
23const lddReporting = (stdout: string) => {23// What the real loader prints when the browser's `--version` cannot start it.
24const LOADER_ERROR =
25 "chrome-headless-shell: error while loading shared libraries: libglib-2.0.so.0: cannot open shared object file: No such file or directory";
26
27const hostReporting = (output: { readonly version: string; readonly ldd: string }) => {
24 const commands: Array<string> = [];28 const commands: Array<string> = [];
25 const spawner = ChildProcessSpawner.make((command) =>29 const spawner = ChildProcessSpawner.make((command) =>
26 Effect.sync(() => {30 Effect.sync(() => {
...@@ -36,9 +40,9 @@ const lddReporting = (stdout: string) => {...@@ -36,9 +40,9 @@ const lddReporting = (stdout: string) => {
36 kill: () => Effect.void,40 kill: () => Effect.void,
37 unref: Effect.succeed(Effect.void),41 unref: Effect.succeed(Effect.void),
38 stdin: Sink.drain,42 stdin: Sink.drain,
39 stdout: Stream.make(new TextEncoder().encode(stdout)),43 stdout: Stream.empty,
40 stderr: Stream.empty,44 stderr: Stream.empty,
41 all: Stream.empty,45 all: Stream.make(new TextEncoder().encode(name === "ldd" ? output.ldd : output.version)),
42 getInputFd: () => Sink.drain,46 getInputFd: () => Sink.drain,
43 getOutputFd: () => Stream.empty,47 getOutputFd: () => Stream.empty,
44 });48 });
...@@ -47,8 +51,16 @@ const lddReporting = (stdout: string) => {...@@ -47,8 +51,16 @@ const lddReporting = (stdout: string) => {
47 return { spawner, commands };51 return { spawner, commands };
48};52};
4953
50const diagnose = (input: { platform: NodeJS.Platform; output: string; ldd: string }) => {54const diagnose = (input: {
51 const { spawner, commands } = lddReporting(input.ldd);55 platform: NodeJS.Platform;
56 output: string;
57 ldd: string;
58 version?: string;
59}) => {
60 const { spawner, commands } = hostReporting({
61 ldd: input.ldd,
62 version: input.version ?? LOADER_ERROR,
63 });
52 return PreviewBrowserHost.diagnoseLaunchFailure({64 return PreviewBrowserHost.diagnoseLaunchFailure({
53 executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell",65 executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell",
54 output: input.output,66 output: input.output,
...@@ -89,12 +101,26 @@ describe("diagnoseLaunchFailure", () => {...@@ -89,12 +101,26 @@ describe("diagnoseLaunchFailure", () => {
89 }),101 }),
90 );102 );
91103
104 it.effect("trusts the browser's own start over ldd", () =>
105 Effect.gen(function* () {
106 // NixOS: ldd lists libraries that nix-ld still provides to the real loader.
107 const { error } = yield* diagnose({
108 platform: "linux",
109 output: "crashed",
110 ldd: LDD_IN_BARE_DEBIAN,
111 version: "Google Chrome for Testing 154.0.8037.92\n",
112 });
113 expect(error).toBeUndefined();
114 }),
115 );
116
92 it.effect("leaves other failures alone", () =>117 it.effect("leaves other failures alone", () =>
93 Effect.gen(function* () {118 Effect.gen(function* () {
94 const linux = yield* diagnose({119 const linux = yield* diagnose({
95 platform: "linux",120 platform: "linux",
96 output: "crashed",121 output: "crashed",
97 ldd: "\tlibc.so.6 => /lib/libc.so.6\n",122 ldd: "\tlibc.so.6 => /lib/libc.so.6\n",
123 version: "Google Chrome for Testing 154.0.8037.92\n",
98 });124 });
99 expect(linux.error).toBeUndefined();125 expect(linux.error).toBeUndefined();
100 const mac = yield* diagnose({ platform: "darwin", output: "crashed", ldd: "" });126 const mac = yield* diagnose({ platform: "darwin", output: "crashed", ldd: "" });
apps/server/src/preview/PreviewBrowserHost.ts+23-10
...@@ -90,11 +90,13 @@ export class PreviewBrowserLibrariesError extends Schema.TaggedError<PreviewBrow...@@ -90,11 +90,13 @@ export class PreviewBrowserLibrariesError extends Schema.TaggedError<PreviewBrow
90export type PreviewBrowserHostError = PreviewBrowserSandboxError | PreviewBrowserLibrariesError;90export type PreviewBrowserHostError = PreviewBrowserSandboxError | PreviewBrowserLibrariesError;
9191
92const MISSING_LIBRARY = /^\s*(\S+) => not found$/gm;92const MISSING_LIBRARY = /^\s*(\S+) => not found$/gm;
93/** glibc's loader abort, naming the first library it could not load. */
94const LOADER_ERROR = /error while loading shared libraries: ([^:\s]+)/;
9395
94/**96/**
95 * After a launch fails, names the host setup it is missing: the sandbox, from97 * After a launch fails, names the host setup it is missing: the sandbox, from
96 * Chrome's own abort message, or shared libraries, from `ldd`. Undefined when98 * Chrome's own abort message, or shared libraries the loader refuses. Undefined
97 * neither explains it. Linux only; other hosts never need either.99 * when neither explains it. Linux only; other hosts never need either.
98 */100 */
99export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunchFailure")(101export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunchFailure")(
100 function* (input: {102 function* (input: {
...@@ -114,18 +116,29 @@ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunc...@@ -114,18 +116,29 @@ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunc
114 },116 },
115);117);
116118
117/** Shared libraries the loader cannot find for `executable`, by `ldd`. */119/**
120 * Shared libraries the loader cannot find for `executable`. The browser's own
121 * `--version` decides whether any are missing; `ldd` only lists them after the
122 * real loader fails, because it can report libraries a host provides another
123 * way (NixOS's nix-ld).
124 */
118export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries")(function* (125export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries")(function* (
119 executable: string,126 executable: string,
120) {127) {
121 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;128 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
122 const report = yield* spawner129 const run = (command: string, args: ReadonlyArray<string>) =>
123 .string(ChildProcess.make("ldd", [executable], { stdin: "ignore", stderr: "ignore" }))130 spawner
124 .pipe(131 .string(ChildProcess.make(command, args, { stdin: "ignore" }), { includeStderr: true })
125 Effect.timeout("5 seconds"),132 .pipe(
126 Effect.orElseSucceed(() => ""),133 Effect.timeout("5 seconds"),
127 );134 Effect.orElseSucceed(() => ""),
128 return [...report.matchAll(MISSING_LIBRARY)].map((match) => match[1]!);135 );
136 const loaderError = LOADER_ERROR.exec(yield* run(executable, ["--version"]))?.[1];
137 if (loaderError === undefined) return [];
138 const listed = [...(yield* run("ldd", [executable])).matchAll(MISSING_LIBRARY)].map(
139 (match) => match[1]!,
140 );
141 return listed.length > 0 ? listed : [loaderError];
129});142});
130143
131/**144/**
apps/server/src/preview/ServerBrowser.test.ts+254-1
...@@ -16,6 +16,7 @@ import * as Fiber from "effect/Fiber";...@@ -16,6 +16,7 @@ import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";16import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";18import * as Option from "effect/Option";
19import * as PubSub from "effect/PubSub";
19import * as Queue from "effect/Queue";20import * as Queue from "effect/Queue";
20import * as Stream from "effect/Stream";21import * as Stream from "effect/Stream";
21import type { BrowserContext, Page } from "playwright-core";22import type { BrowserContext, Page } from "playwright-core";
...@@ -31,6 +32,7 @@ import * as PreviewBrowser from "./PreviewBrowser.ts";...@@ -31,6 +32,7 @@ import * as PreviewBrowser from "./PreviewBrowser.ts";
3132
32// Keep the manager, broker, ownership, refs, and viewer paths real; replace Chromium I/O only.33// Keep the manager, broker, ownership, refs, and viewer paths real; replace Chromium I/O only.
33vi.mock("./ServerBrowserContexts.ts", () => ({34vi.mock("./ServerBrowserContexts.ts", () => ({
35 presentAsChrome: async () => {},
34 ServerBrowserContexts: class {36 ServerBrowserContexts: class {
35 private readonly onClose: ((context: BrowserContext) => void) | undefined;37 private readonly onClose: ((context: BrowserContext) => void) | undefined;
36 constructor(options: { onContextClose?: (context: BrowserContext) => void }) {38 constructor(options: { onContextClose?: (context: BrowserContext) => void }) {
...@@ -153,7 +155,7 @@ let clipboardBinding: ClipboardBinding | null = null;...@@ -153,7 +155,7 @@ let clipboardBinding: ClipboardBinding | null = null;
153let contextFailure: Error | null = null;155let contextFailure: Error | null = null;
154/** Server tabs the fake desktop renders, and the endpoints the server connected to. */156/** Server tabs the fake desktop renders, and the endpoints the server connected to. */
155let desktopRendersNext = false;157let desktopRendersNext = false;
156/** Pages the fake desktop takes back; the channel's detached stream emits them. */158/** Pages the fake desktop takes back or returns; the channel's streams emit them. */
157const desktopDetaches = new NodeEvents.EventEmitter();159const desktopDetaches = new NodeEvents.EventEmitter();
158const desktopTabs = new Set<string>();160const desktopTabs = new Set<string>();
159const desktopRenders = (tabId: string) => {161const desktopRenders = (tabId: string) => {
...@@ -209,6 +211,17 @@ const dependencies = Layer.mergeAll(...@@ -209,6 +211,17 @@ const dependencies = Layer.mergeAll(
209 (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)),211 (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)),
210 ),212 ),
211 ),213 ),
214 attached: Stream.callback<{ threadId: string; tabId: string }>((queue) =>
215 Effect.acquireRelease(
216 Effect.sync(() => {
217 const onAttach = (key: { threadId: string; tabId: string }) =>
218 Queue.offerUnsafe(queue, key);
219 desktopDetaches.on("attach", onAttach);
220 return onAttach;
221 }),
222 (onAttach) => Effect.sync(() => desktopDetaches.off("attach", onAttach)),
223 ),
224 ),
212 isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)),225 isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)),
213 endpoint: (key) =>226 endpoint: (key) =>
214 Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () =>227 Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () =>
...@@ -726,6 +739,225 @@ it.live("a popup becomes the agent's own tab and keeps its opener page", () =>...@@ -726,6 +739,225 @@ it.live("a popup becomes the agent's own tab and keeps its opener page", () =>
726 ).pipe(Effect.provide(layer)),739 ).pipe(Effect.provide(layer)),
727);740);
728741
742it.live("a tab opened to a file the browser cannot show reports the file to download", () =>
743 Effect.scoped(
744 Effect.gen(function* () {
745 const browser = yield* ServerBrowser.ServerBrowser;
746 const manager = yield* Manager.PreviewManager;
747 const events = yield* manager.subscribeEvents;
748 yield* Effect.yieldNow;
749 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
750 const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true));
751 const page = contexts[0]!.page;
752 const pdf = "https://example.com/paper.pdf";
753 const request = { url: () => pdf, method: () => "GET", isNavigationRequest: () => true };
754 // What Chromium reports when a navigation turns into a download.
755 page.emit("requestfailed", {
756 ...request,
757 frame: () => page,
758 failure: () => ({ errorText: "net::ERR_ABORTED" }),
759 });
760 // Stands in for Chromium, which writes the file itself.
761 const saved = yield* Queue.unbounded<string>();
762 const written = Promise.withResolvers<void>();
763 page.emit("download", {
764 failure: async () => null,
765 saveAs: (path: string) => {
766 Queue.offerUnsafe(saved, path);
767 return written.promise;
768 },
769 suggestedFilename: () => "paper.pdf",
770 url: () => pdf,
771 });
772 const fs = yield* FileSystem.FileSystem;
773 yield* fs.writeFileString(yield* Queue.take(saved), "%PDF");
774 written.resolve();
775 let status = yield* PubSub.take(events);
776 while (status.type !== "failed") status = yield* PubSub.take(events);
777 expect(status).toMatchObject({ url: pdf, download: { fileName: "paper.pdf" } });
778 // The tab shows the file, so no separate download toast is offered.
779 expect((yield* Queue.clear(viewer.output)).some((item) => item._tag === "download")).toBe(
780 false,
781 );
782 }),
783 ).pipe(Effect.provide(layer)),
784);
785
786it.live("a file a blank tab opened is offered to download if the tab moves on while it saves", () =>
787 Effect.scoped(
788 Effect.gen(function* () {
789 const browser = yield* ServerBrowser.ServerBrowser;
790 const manager = yield* Manager.PreviewManager;
791 yield* Effect.yieldNow;
792 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
793 const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true));
794 yield* viewer.input({ type: "takeControl" });
795 const page = contexts[0]!.page;
796 const pdf = "https://example.com/paper.pdf";
797 const navigation = {
798 url: () => pdf,
799 method: () => "GET",
800 isNavigationRequest: () => true,
801 frame: () => page,
802 failure: () => ({ errorText: "net::ERR_ABORTED" }),
803 };
804 page.emit("request", navigation);
805 page.emit("requestfailed", navigation);
806 const saved = yield* Queue.unbounded<string>();
807 const written = Promise.withResolvers<void>();
808 page.emit("download", {
809 failure: async () => null,
810 saveAs: (path: string) => {
811 Queue.offerUnsafe(saved, path);
812 return written.promise;
813 },
814 suggestedFilename: () => "paper.pdf",
815 url: () => pdf,
816 });
817 const fs = yield* FileSystem.FileSystem;
818 yield* fs.writeFileString(yield* Queue.take(saved), "%PDF");
819 // The person navigates elsewhere while the file is still being written.
820 page.emit("request", {
821 url: () => "https://example.com/next",
822 method: () => "GET",
823 isNavigationRequest: () => true,
824 frame: () => page,
825 });
826 written.resolve();
827 let offered = yield* Queue.take(viewer.output);
828 while (offered._tag !== "download") offered = yield* Queue.take(viewer.output);
829 expect(offered).toMatchObject({ _tag: "download", fileName: "paper.pdf" });
830 }),
831 ).pipe(Effect.provide(layer)),
832);
833
834it.live("a download from a superseded navigation leaves the newer navigation loading", () =>
835 Effect.scoped(
836 Effect.gen(function* () {
837 const { broker, tabId } = yield* ready;
838 const page = contexts[0]!.page;
839 const pdf = "https://example.com/paper.pdf";
840 const navigation = {
841 url: () => pdf,
842 method: () => "GET",
843 isNavigationRequest: () => true,
844 frame: () => page,
845 failure: () => ({ errorText: "net::ERR_ABORTED" }),
846 };
847 page.emit("request", navigation);
848 page.emit("requestfailed", navigation);
849 const saved = yield* Queue.unbounded<string>();
850 const written = Promise.withResolvers<void>();
851 page.emit("download", {
852 failure: async () => null,
853 saveAs: (path: string) => {
854 Queue.offerUnsafe(saved, path);
855 return written.promise;
856 },
857 suggestedFilename: () => "paper.pdf",
858 url: () => pdf,
859 });
860 const fs = yield* FileSystem.FileSystem;
861 yield* fs.writeFileString(yield* Queue.take(saved), "%PDF");
862 // The person navigates elsewhere while the file is still being written.
863 page.emit("request", {
864 url: () => "https://example.com/next",
865 method: () => "GET",
866 isNavigationRequest: () => true,
867 frame: () => page,
868 });
869 written.resolve();
870 const status = () =>
871 broker.invoke<PreviewAutomationStatus>({ scope, tabId, operation: "status", input: {} });
872 while ((yield* status()).downloads?.length !== 1) yield* Effect.sleep("5 millis");
873 expect((yield* status()).loading).toBe(true);
874 }),
875 ).pipe(Effect.provide(layer)),
876);
877
878it.live(
879 "a late failure from a request that predates tracking leaves a newer navigation loading",
880 () =>
881 Effect.scoped(
882 Effect.gen(function* () {
883 const { broker, tabId } = yield* ready;
884 const page = contexts[0]!.page;
885 const navigation = (url: string) => ({
886 url: () => url,
887 method: () => "GET",
888 isNavigationRequest: () => true,
889 frame: () => page,
890 failure: () => ({ errorText: "net::ERR_CONNECTION_REFUSED" }),
891 });
892 page.emit("request", navigation("https://example.com/next"));
893 // A popup's first request can start before the tab listens for requests.
894 page.emit("requestfailed", navigation("https://example.com/first"));
895 const status = yield* broker.invoke<PreviewAutomationStatus>({
896 scope,
897 tabId,
898 operation: "status",
899 input: {},
900 });
901 expect(status.loading).toBe(true);
902 }),
903 ).pipe(Effect.provide(layer)),
904);
905
906it.live("an aborted navigation that no download explains stops loading", () =>
907 Effect.scoped(
908 Effect.gen(function* () {
909 const { broker, tabId } = yield* ready;
910 const page = contexts[0]!.page;
911 const navigation = {
912 url: () => "https://example.com/cancelled",
913 method: () => "GET",
914 isNavigationRequest: () => true,
915 frame: () => page,
916 failure: () => ({ errorText: "net::ERR_ABORTED" }),
917 };
918 const status = () =>
919 broker.invoke<PreviewAutomationStatus>({ scope, tabId, operation: "status", input: {} });
920 vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
921 try {
922 page.emit("request", navigation);
923 page.emit("requestfailed", navigation);
924 expect((yield* status()).loading).toBe(true);
925 vi.advanceTimersByTime(5_000);
926 } finally {
927 vi.useRealTimers();
928 }
929 expect((yield* status()).loading).toBe(false);
930 }),
931 ).pipe(Effect.provide(layer)),
932);
933
934it.live("a popup from a person's click is shown to that person, with its opener kept", () =>
935 Effect.scoped(
936 Effect.gen(function* () {
937 const browser = yield* ServerBrowser.ServerBrowser;
938 const manager = yield* Manager.PreviewManager;
939 yield* Effect.yieldNow;
940 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
941 const watcher = yield* browser.attachViewer(viewerInput(opened.tabId, false));
942 const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true));
943 yield* viewer.input({ type: "takeControl" });
944 const opener = contexts[0]!.page;
945 const popup = makeContext();
946 opener.emit("popup", popup.page);
947 let shown = yield* Queue.take(viewer.output);
948 while (shown._tag !== "popup") shown = yield* Queue.take(viewer.output);
949 const sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions;
950 expect(sessions.map((session) => session.tabId)).toEqual(
951 expect.arrayContaining([opened.tabId, shown.tabId]),
952 );
953 expect((yield* Queue.clear(watcher.output)).some((item) => item._tag === "popup")).toBe(
954 false,
955 );
956 expect(opener.close).not.toHaveBeenCalled();
957 }),
958 ).pipe(Effect.provide(layer)),
959);
960
729it.live("closing a tab while a viewer is still opening it does not leave its page behind", () =>961it.live("closing a tab while a viewer is still opening it does not leave its page behind", () =>
730 Effect.scoped(962 Effect.scoped(
731 Effect.gen(function* () {963 Effect.gen(function* () {
...@@ -1090,3 +1322,24 @@ it.live("a desktop page the desktop takes back reconnects instead of closing", (...@@ -1090,3 +1322,24 @@ it.live("a desktop page the desktop takes back reconnects instead of closing", (
1090 }),1322 }),
1091 ).pipe(Effect.provide(layer)),1323 ).pipe(Effect.provide(layer)),
1092);1324);
1325
1326it.live("a desktop page that comes back reconnects without waiting for a viewer", () =>
1327 Effect.scoped(
1328 Effect.gen(function* () {
1329 const manager = yield* Manager.PreviewManager;
1330 yield* ServerBrowser.ServerBrowser;
1331 yield* Effect.yieldNow;
1332 desktopRendersNext = true;
1333 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
1334 while (desktopConnections.length === 0) yield* Effect.yieldNow;
1335 while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow;
1336 // DevTools opened, then closed: the desktop withdraws the page and returns it.
1337 desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId });
1338 while (releasedDesktopTabs.length === 0) yield* Effect.yieldNow;
1339 desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId: opened.tabId });
1340 // Without a viewer or agent, the server drives the page again, so its URL keeps reaching clients.
1341 while (desktopConnections.length < 2) yield* Effect.yieldNow;
1342 expect(desktopConnections).toHaveLength(2);
1343 }),
1344 ).pipe(Effect.provide(layer)),
1345);
apps/server/src/preview/ServerBrowser.ts+141-7
...@@ -36,7 +36,11 @@ import {...@@ -36,7 +36,11 @@ import {
36 SERVER_BROWSER_AUTOMATION_CLIENT_ID,36 SERVER_BROWSER_AUTOMATION_CLIENT_ID,
37 type PreviewAppearancePreference,37 type PreviewAppearancePreference,
38} from "@t3tools/contracts";38} from "@t3tools/contracts";
39import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";39import {
40 HostProcessArchitecture,
41 HostProcessEnvironment,
42 HostProcessPlatform,
43} from "@t3tools/shared/hostProcess";
40import { normalizePreviewUrl } from "@t3tools/shared/preview";44import { normalizePreviewUrl } from "@t3tools/shared/preview";
41import { resolvePreviewViewport } from "@t3tools/shared/previewViewport";45import { resolvePreviewViewport } from "@t3tools/shared/previewViewport";
42import * as NodeCrypto from "node:crypto";46import * as NodeCrypto from "node:crypto";
...@@ -73,7 +77,7 @@ import * as PreviewManager from "./Manager.ts";...@@ -73,7 +77,7 @@ import * as PreviewManager from "./Manager.ts";
73import * as ServerBrowserPage from "./ServerBrowserPage.ts";77import * as ServerBrowserPage from "./ServerBrowserPage.ts";
74import * as PreviewBrowser from "./PreviewBrowser.ts";78import * as PreviewBrowser from "./PreviewBrowser.ts";
75import * as PreviewBrowserHost from "./PreviewBrowserHost.ts";79import * as PreviewBrowserHost from "./PreviewBrowserHost.ts";
76import { ServerBrowserContexts } from "./ServerBrowserContexts.ts";80import { presentAsChrome, ServerBrowserContexts } from "./ServerBrowserContexts.ts";
77import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts";81import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts";
7882
79const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID;83const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID;
...@@ -185,6 +189,8 @@ export type ServerBrowserViewerOutput =...@@ -185,6 +189,8 @@ export type ServerBrowserViewerOutput =
185 readonly accept: string;189 readonly accept: string;
186 }190 }
187 | { readonly _tag: "fileChooserClosed"; readonly id: string }191 | { readonly _tag: "fileChooserClosed"; readonly id: string }
192 /** The page this viewer controls opened a new tab; the viewer shows it, as a browser would. */
193 | { readonly _tag: "popup"; readonly tabId: string }
188 | {194 | {
189 readonly _tag: "download";195 readonly _tag: "download";
190 readonly id: string;196 readonly id: string;
...@@ -311,6 +317,17 @@ interface ServerTab {...@@ -311,6 +317,17 @@ interface ServerTab {
311 closing: boolean;317 closing: boolean;
312 recording: Recording | null;318 recording: Recording | null;
313 initialNavigation: Promise<void> | null;319 initialNavigation: Promise<void> | null;
320 /** Counts main-frame navigation requests, so late events can tell they were superseded. */
321 navigationGeneration: number;
322 /**
323 * The latest main-frame navigation, when it aborted, to tell a navigation's
324 * download from a page's. The timer settles it if no download follows.
325 */
326 abortedNavigation: {
327 readonly url: string;
328 readonly generation: number;
329 readonly timer: ReturnType<typeof setTimeout>;
330 } | null;
314 /** The latest queued start, so a stop can find a recording still starting. */331 /** The latest queued start, so a stop can find a recording still starting. */
315 recordingStart: Promise<Recording> | null;332 recordingStart: Promise<Recording> | null;
316 /** Serializes captures and recording start/stop. */333 /** Serializes captures and recording start/stop. */
...@@ -336,6 +353,8 @@ const AGENT_TAB_IDLE_MS = 30 * 60 * 1000;...@@ -336,6 +353,8 @@ const AGENT_TAB_IDLE_MS = 30 * 60 * 1000;
336const IDLE_SWEEP_INTERVAL = "1 minute";353const IDLE_SWEEP_INTERVAL = "1 minute";
337354
338const DOWNLOAD_LIMIT = 20;355const DOWNLOAD_LIMIT = 20;
356/** How long an aborted navigation waits for the download that usually explains it. */
357const ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS = 5_000;
339const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024;358const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024;
340359
341const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`;360const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`;
...@@ -435,6 +454,7 @@ const CLIPBOARD_SCRIPT = `(() => {...@@ -435,6 +454,7 @@ const CLIPBOARD_SCRIPT = `(() => {
435454
436const make = Effect.gen(function* () {455const make = Effect.gen(function* () {
437 const config = yield* ServerConfig.ServerConfig;456 const config = yield* ServerConfig.ServerConfig;
457 const host = { platform: yield* HostProcessPlatform, arch: yield* HostProcessArchitecture };
438 const manager = yield* PreviewManager.PreviewManager;458 const manager = yield* PreviewManager.PreviewManager;
439 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;459 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
440 const environment = yield* ServerEnvironment.ServerEnvironment;460 const environment = yield* ServerEnvironment.ServerEnvironment;
...@@ -646,6 +666,7 @@ const make = Effect.gen(function* () {...@@ -646,6 +666,7 @@ const make = Effect.gen(function* () {
646 if (tabs.get(key) !== tab) return;666 if (tabs.get(key) !== tab) return;
647 tabs.delete(key);667 tabs.delete(key);
648 tab.closing = true;668 tab.closing = true;
669 clearAbortedNavigation(tab);
649 // A desktop page outlives the connection unless its session closed with it.670 // A desktop page outlives the connection unless its session closed with it.
650 const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone";671 const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone";
651 for (const viewer of tab.viewers) viewer.push({ _tag: end });672 for (const viewer of tab.viewers) viewer.push({ _tag: end });
...@@ -723,6 +744,9 @@ const make = Effect.gen(function* () {...@@ -723,6 +744,9 @@ const make = Effect.gen(function* () {
723 if (!desktop) await prepareContext(context);744 if (!desktop) await prepareContext(context);
724 const page = adopted?.page ?? desktop?.page ?? (await context.newPage());745 const page = adopted?.page ?? desktop?.page ?? (await context.newPage());
725 const cdp = await context.newCDPSession(page);746 const cdp = await context.newCDPSession(page);
747 // The desktop's page is already a normal browser. A popup's first request
748 // went out before it became a tab; everything after presents as Chrome.
749 if (!desktop) await presentAsChrome(cdp, host);
726 page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS);750 page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS);
727 page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);751 page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
728 const control = new SessionControl(snapshot.automationOwner ?? null, () =>752 const control = new SessionControl(snapshot.automationOwner ?? null, () =>
...@@ -755,6 +779,8 @@ const make = Effect.gen(function* () {...@@ -755,6 +779,8 @@ const make = Effect.gen(function* () {
755 recording: null,779 recording: null,
756 recordingStart: null,780 recordingStart: null,
757 initialNavigation: null,781 initialNavigation: null,
782 navigationGeneration: 0,
783 abortedNavigation: null,
758 captureLock: Promise.resolve(),784 captureLock: Promise.resolve(),
759 capturing: 0,785 capturing: 0,
760 };786 };
...@@ -764,8 +790,11 @@ const make = Effect.gen(function* () {...@@ -764,8 +790,11 @@ const make = Effect.gen(function* () {
764 await applyRendering(tab, snapshot);790 await applyRendering(tab, snapshot);
765 const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) =>791 const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) =>
766 request.isNavigationRequest() && request.frame() === page.mainFrame();792 request.isNavigationRequest() && request.frame() === page.mainFrame();
793 const navigationGenerations = new WeakMap<object, number>();
767 page.on("request", (request) => {794 page.on("request", (request) => {
768 if (!isMainNavigation(request)) return;795 if (!isMainNavigation(request)) return;
796 navigationGenerations.set(request, ++tab.navigationGeneration);
797 clearAbortedNavigation(tab);
769 tab.loading = true;798 tab.loading = true;
770 report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" });799 report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" });
771 });800 });
...@@ -787,7 +816,28 @@ const make = Effect.gen(function* () {...@@ -787,7 +816,28 @@ const make = Effect.gen(function* () {
787 errorText,816 errorText,
788 timestamp: new Date().toISOString(),817 timestamp: new Date().toISOString(),
789 });818 });
790 if (!isMainNavigation(request) || errorText.includes("ERR_ABORTED")) return;819 if (!isMainNavigation(request)) return;
820 // A navigation a newer one replaced leaves the status to the newer one. A
821 // popup's first request can predate the listener, so it is untracked and
822 // superseded by any tracked one.
823 const generation = navigationGenerations.get(request) ?? 0;
824 if (generation !== tab.navigationGeneration) return;
825 // An aborted navigation is usually a download; its `download` event settles the status.
826 if (errorText.includes("ERR_ABORTED")) {
827 const url = request.url();
828 const navigationGeneration = tab.navigationGeneration;
829 clearAbortedNavigation(tab);
830 tab.abortedNavigation = {
831 url,
832 generation: navigationGeneration,
833 timer: setTimeout(() => {
834 if (tab.abortedNavigation?.generation !== navigationGeneration) return;
835 tab.abortedNavigation = null;
836 if (!tab.closing) settleAbortedNavigation(tab, navigationGeneration, url, undefined);
837 }, ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS),
838 };
839 return;
840 }
791 tab.loading = false;841 tab.loading = false;
792 const { code, description } = ServerBrowserPage.parseNetError(errorText);842 const { code, description } = ServerBrowserPage.parseNetError(errorText);
793 report(tab, {843 report(tab, {
...@@ -854,9 +904,46 @@ const make = Effect.gen(function* () {...@@ -854,9 +904,46 @@ const make = Effect.gen(function* () {
854 NodeCrypto.createHash("sha256").update(tabKey(tab.threadId, tab.tabId)).digest("hex"),904 NodeCrypto.createHash("sha256").update(tabKey(tab.threadId, tab.tabId)).digest("hex"),
855 );905 );
856906
907 const clearAbortedNavigation = (tab: ServerTab) => {
908 if (tab.abortedNavigation !== null) clearTimeout(tab.abortedNavigation.timer);
909 tab.abortedNavigation = null;
910 };
911
912 /**
913 * Settles the status of a navigation that aborted, usually into a download.
914 * A tab opened straight to the file has no page to show, so it reports the
915 * file with its download; a page that linked to it keeps showing itself.
916 */
917 const settleAbortedNavigation = (
918 tab: ServerTab,
919 generation: number,
920 url: string,
921 offered: { readonly id: string; readonly fileName: string } | undefined,
922 ) => {
923 // A newer navigation owns the status.
924 if (tab.navigationGeneration !== generation) return;
925 tab.loading = false;
926 if (tab.page.url() !== "about:blank") {
927 void reportLoaded(tab);
928 return;
929 }
930 report(tab, {
931 _tag: "LoadFailed",
932 url: url.slice(0, 2048),
933 title: "",
934 code: -3,
935 description: "ERR_ABORTED",
936 ...(offered === undefined ? {} : { download: offered }),
937 });
938 };
939
857 const saveDownload = async (tab: ServerTab, download: Download) => {940 const saveDownload = async (tab: ServerTab, download: Download) => {
858 const id = NodeCrypto.randomUUID();941 const id = NodeCrypto.randomUUID();
859 const path = NodePath.join(downloadDir(tab), id);942 const path = NodePath.join(downloadDir(tab), id);
943 const navigation =
944 download.url() === tab.abortedNavigation?.url ? tab.abortedNavigation.generation : null;
945 if (navigation !== null) clearAbortedNavigation(tab);
946 let offered: { readonly id: string; readonly fileName: string } | undefined;
860 try {947 try {
861 if (await download.failure()) return;948 if (await download.failure()) return;
862 await NodeFSP.mkdir(downloadDir(tab), { recursive: true });949 await NodeFSP.mkdir(downloadDir(tab), { recursive: true });
...@@ -878,6 +965,12 @@ const make = Effect.gen(function* () {...@@ -878,6 +965,12 @@ const make = Effect.gen(function* () {
878 for (const evicted of tab.downloads.splice(0, tab.downloads.length - DOWNLOAD_LIMIT)) {965 for (const evicted of tab.downloads.splice(0, tab.downloads.length - DOWNLOAD_LIMIT)) {
879 await NodeFSP.rm(evicted.path, { force: true }).catch(constVoid);966 await NodeFSP.rm(evicted.path, { force: true }).catch(constVoid);
880 }967 }
968 offered = { id, fileName: saved.fileName };
969 // A tab whose own address was the file shows it in place (see
970 // settleAbortedNavigation), unless it navigated on while the file saved.
971 const shownInTab =
972 navigation === tab.navigationGeneration && tab.page.url() === "about:blank";
973 if (shownInTab) return;
881 // Only the person driving the page gets the file offered; agents read it from status.974 // Only the person driving the page gets the file offered; agents read it from status.
882 const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller);975 const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller);
883 controller?.push({976 controller?.push({
...@@ -889,6 +982,10 @@ const make = Effect.gen(function* () {...@@ -889,6 +982,10 @@ const make = Effect.gen(function* () {
889 } catch (cause) {982 } catch (cause) {
890 await NodeFSP.rm(path, { force: true }).catch(constVoid);983 await NodeFSP.rm(path, { force: true }).catch(constVoid);
891 runFork(Effect.logWarning("server preview download failed", { cause }));984 runFork(Effect.logWarning("server preview download failed", { cause }));
985 } finally {
986 if (navigation !== null && !tab.closing) {
987 settleAbortedNavigation(tab, navigation, download.url(), offered);
988 }
892 }989 }
893 };990 };
894991
...@@ -1009,6 +1106,10 @@ const make = Effect.gen(function* () {...@@ -1009,6 +1106,10 @@ const make = Effect.gen(function* () {
1009 return;1106 return;
1010 }1107 }
1011 const url = popup.url();1108 const url = popup.url();
1109 // Captured now: the person whose click opened the popup gets switched to it.
1110 const controller = [...opener.viewers].find(
1111 (viewer) => viewer.id === opener.control.controller,
1112 );
1012 await Effect.runPromise(1113 await Effect.runPromise(
1013 manager.open({1114 manager.open({
1014 threadId: opener.threadId,1115 threadId: opener.threadId,
...@@ -1025,9 +1126,16 @@ const make = Effect.gen(function* () {...@@ -1025,9 +1126,16 @@ const make = Effect.gen(function* () {
1025 openerTabId: opener.tabId,1126 openerTabId: opener.tabId,
1026 }),1127 }),
1027 }),1128 }),
1028 ).catch(async () => {1129 ).then(
1029 await popup.close().catch(constVoid);1130 (snapshot) => {
1030 });1131 // The opener stays open behind it, so `window.opener` can still report back.
1132 if (opener.control.agentId === null)
1133 controller?.push({ _tag: "popup", tabId: snapshot.tabId });
1134 },
1135 async () => {
1136 await popup.close().catch(constVoid);
1137 },
1138 );
1031 };1139 };
10321140
1033 const ensureTab = (snapshot: PreviewSessionSnapshot): Promise<ServerTab> => {1141 const ensureTab = (snapshot: PreviewSessionSnapshot): Promise<ServerTab> => {
...@@ -1187,6 +1295,12 @@ const make = Effect.gen(function* () {...@@ -1187,6 +1295,12 @@ const make = Effect.gen(function* () {
1187 }1295 }
1188 await navigation.catch((cause: unknown) => {1296 await navigation.catch((cause: unknown) => {
1189 const message = cause instanceof Error ? cause.message : String(cause);1297 const message = cause instanceof Error ? cause.message : String(cause);
1298 if (message.includes("Download is starting")) {
1299 throw new ServerBrowserPage.ServerBrowserOperationError(
1300 "PreviewAutomationExecutionError",
1301 `${url} is a file this browser cannot show, so it was downloaded instead. preview_status lists it under downloads.`,
1302 );
1303 }
1190 if (/ERR_[A-Z_]+/.test(message)) {1304 if (/ERR_[A-Z_]+/.test(message)) {
1191 throw new ServerBrowserPage.ServerBrowserOperationError(1305 throw new ServerBrowserPage.ServerBrowserOperationError(
1192 "PreviewAutomationExecutionError",1306 "PreviewAutomationExecutionError",
...@@ -1693,7 +1807,11 @@ const make = Effect.gen(function* () {...@@ -1693,7 +1807,11 @@ const make = Effect.gen(function* () {
1693 ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput),1807 ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput),
1694 );1808 );
1695 case "evaluate":1809 case "evaluate":
1696 return ServerBrowserPage.evaluate(tab.cdp, input as PreviewAutomationEvaluateInput);1810 return ServerBrowserPage.evaluate(
1811 tab.cdp,
1812 input as PreviewAutomationEvaluateInput,
1813 request.timeoutMs,
1814 );
1697 case "waitFor":1815 case "waitFor":
1698 return ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput);1816 return ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput);
1699 case "recordingStart": {1817 case "recordingStart": {
...@@ -2142,6 +2260,22 @@ const make = Effect.gen(function* () {...@@ -2142,6 +2260,22 @@ const make = Effect.gen(function* () {
2142 ),2260 ),
2143 Effect.forkScoped,2261 Effect.forkScoped,
2144 );2262 );
2263 // The page came back, for example after its DevTools closed. Reconnect now
2264 // rather than on the next viewer or agent, or the tab's URL and title stop
2265 // reaching every client in the meantime.
2266 yield* desktopChannel.attached.pipe(
2267 Stream.runForEach((key) =>
2268 Effect.gen(function* () {
2269 if (tabs.has(tabKey(key.threadId, key.tabId))) return;
2270 const { sessions } = yield* manager.list({ threadId: ThreadId.make(key.threadId) });
2271 const snapshot = sessions.find(
2272 (session) => session.tabId === key.tabId && session.runtime === "server",
2273 );
2274 if (snapshot) yield* Effect.promise(() => ensureTab(snapshot).catch(constVoid));
2275 }).pipe(Effect.ignore),
2276 ),
2277 Effect.forkScoped,
2278 );
2145 yield* Effect.sync(closeIdleAgentTabs).pipe(2279 yield* Effect.sync(closeIdleAgentTabs).pipe(
2146 Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)),2280 Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)),
2147 Effect.forkScoped,2281 Effect.forkScoped,
apps/server/src/preview/ServerBrowserContexts.ts+64-2
...@@ -3,8 +3,9 @@ import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";...@@ -3,8 +3,9 @@ import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
3import { constVoid } from "effect/Function";3import { constVoid } from "effect/Function";
4import * as NodeFSP from "node:fs/promises";4import * as NodeFSP from "node:fs/promises";
5import * as NodeModule from "node:module";5import * as NodeModule from "node:module";
6import * as NodeOS from "node:os";
6import * as NodePath from "node:path";7import * as NodePath from "node:path";
7import type { Browser, BrowserContext } from "playwright-core";8import type { Browser, BrowserContext, CDPSession } from "playwright-core";
89
9import { sandboxDisabled } from "./PreviewBrowserHost.ts";10import { sandboxDisabled } from "./PreviewBrowserHost.ts";
1011
...@@ -23,6 +24,61 @@ interface Options {...@@ -23,6 +24,61 @@ interface Options {
23 readonly onContextClose?: (context: BrowserContext) => void;24 readonly onContextClose?: (context: BrowserContext) => void;
24}25}
2526
27const UA_PLATFORMS: Partial<Record<NodeJS.Platform, string>> = {
28 darwin: "macOS",
29 linux: "Linux",
30 win32: "Windows",
31};
32
33/**
34 * The headless shell's identity as the Chrome it is built from: its user agent
35 * without `HeadlessChrome`, and client hints whose brands agree with it. Sites
36 * block a user agent that disagrees with `Sec-CH-UA`, so both change together.
37 */
38const chromeIdentity = (
39 headlessUserAgent: string,
40 host: { readonly platform: NodeJS.Platform; readonly arch: string; readonly release: string },
41) => {
42 const version = /HeadlessChrome\/((\d+)[\d.]*)/.exec(headlessUserAgent);
43 const fullVersion = version?.[1];
44 const major = version?.[2];
45 if (!version || !fullVersion || !major) return null;
46 // Chrome's GREASE brand and order for this major version, as the shell reports them.
47 const brands = (names: (version: string) => string) => [
48 { brand: "Chromium", version: names(major) },
49 { brand: "Google Chrome", version: names(major) },
50 { brand: "Not A(Brand", version: names("99") },
51 ];
52 return {
53 // Chrome reduces its user agent to the major version.
54 userAgent: headlessUserAgent.replace(version[0], `Chrome/${major}.0.0.0`),
55 userAgentMetadata: {
56 brands: brands((value) => value),
57 fullVersionList: brands((value) => (value === major ? fullVersion : `${value}.0.0.0`)),
58 platform: UA_PLATFORMS[host.platform] ?? "Unknown",
59 platformVersion: host.platform === "linux" ? host.release : "",
60 architecture: host.arch.startsWith("arm") ? "arm" : "x86",
61 bitness: host.arch.endsWith("64") ? "64" : "32",
62 model: "",
63 mobile: false,
64 wow64: false,
65 },
66 };
67};
68
69/**
70 * Presents a headless page as plain Chrome. Applies to the page, its frames,
71 * and its workers; a popup gets its own call once it becomes a tab.
72 */
73export const presentAsChrome = async (
74 cdp: CDPSession,
75 host: { readonly platform: NodeJS.Platform; readonly arch: string },
76) => {
77 const { userAgent } = await cdp.send("Browser.getVersion");
78 const identity = chromeIdentity(userAgent, { ...host, release: NodeOS.release() });
79 if (identity) await cdp.send("Emulation.setUserAgentOverride", identity);
80};
81
26/** Persistent human profiles keep their storage; isolated agents share a browser, never a context. */82/** Persistent human profiles keep their storage; isolated agents share a browser, never a context. */
27export class ServerBrowserContexts {83export class ServerBrowserContexts {
28 private readonly options: Options;84 private readonly options: Options;
...@@ -42,7 +98,13 @@ export class ServerBrowserContexts {...@@ -42,7 +98,13 @@ export class ServerBrowserContexts {
42 return {98 return {
43 executablePath,99 executablePath,
44 env,100 env,
45 args: ["--disable-gpu", "--force-device-scale-factor=2"],101 // People drive these tabs, so they must not announce automation (navigator.webdriver).
102 args: [
103 "--disable-gpu",
104 "--force-device-scale-factor=2",
105 "--disable-blink-features=AutomationControlled",
106 ],
107 ignoreDefaultArgs: ["--enable-automation"],
46 headless: true,108 headless: true,
47 // Only an explicit operator opt-out disables sandboxing. Launch errors never do.109 // Only an explicit operator opt-out disables sandboxing. Launch errors never do.
48 chromiumSandbox: !sandboxDisabled(env),110 chromiumSandbox: !sandboxDisabled(env),
apps/server/src/preview/ServerBrowserPage.test.ts+31
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7} from "playwright-core";7} from "playwright-core";
8import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test";8import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test";
99
10import { presentAsChrome } from "./ServerBrowserContexts.ts";
10import * as ServerBrowserPage from "./ServerBrowserPage.ts";11import * as ServerBrowserPage from "./ServerBrowserPage.ts";
1112
12describe("server browser element refs", () => {13describe("server browser element refs", () => {
...@@ -245,4 +246,34 @@ describe("server browser element refs", () => {...@@ -245,4 +246,34 @@ describe("server browser element refs", () => {
245 ]);246 ]);
246 expect(await page.evaluate("window.seen")).toEqual(["click", "hover", "drop"]);247 expect(await page.evaluate("window.seen")).toEqual(["click", "hover", "drop"]);
247 });248 });
249
250 it("presents a headless page as Chrome, with client hints that agree", async () => {
251 await presentAsChrome(cdp, { platform: "linux", arch: "x64" });
252 // userAgentData exists only in secure contexts; https comes from a route.
253 let secChUa: string | undefined;
254 await page.route("https://example.test/", (route) => {
255 secChUa = route.request().headers()["sec-ch-ua"];
256 return route.fulfill({ contentType: "text/html", body: "<p>hi</p>" });
257 });
258 await page.goto("https://example.test/");
259 const identity = (await page.evaluate(`(async () => ({
260 userAgent: navigator.userAgent,
261 brands: navigator.userAgentData.brands.map((brand) => brand.brand),
262 full: (await navigator.userAgentData.getHighEntropyValues(["fullVersionList"]))
263 .fullVersionList.map((brand) => brand.brand),
264 }))()`)) as { userAgent: string; brands: string[]; full: string[] };
265 expect(identity.userAgent).not.toContain("Headless");
266 expect(identity.userAgent).toMatch(/ Chrome\/\d+\.0\.0\.0 /);
267 expect(identity.brands).toContain("Google Chrome");
268 expect([...identity.brands, ...identity.full].join()).not.toContain("Headless");
269 expect(secChUa).toContain('"Google Chrome"');
270 expect(secChUa).not.toContain("Headless");
271 });
272
273 it("stops an evaluation at its deadline so the page answers the next one", async () => {
274 await expect(
275 ServerBrowserPage.evaluate(cdp, { expression: "for (;;) {}" }, 200),
276 ).rejects.toMatchObject({ tag: "PreviewAutomationTimeoutError" });
277 expect(await ServerBrowserPage.evaluate(cdp, { expression: "1 + 1" }, 2_000)).toBe(2);
278 });
248});279});
apps/server/src/preview/ServerBrowserPage.ts+26-2
...@@ -410,12 +410,36 @@ export const scroll = async (page: Page, input: PreviewAutomationScrollInput) =>...@@ -410,12 +410,36 @@ export const scroll = async (page: Page, input: PreviewAutomationScrollInput) =>
410 await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta);410 await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta);
411};411};
412412
413export const evaluate = async (cdp: CDPSession, input: PreviewAutomationEvaluateInput) => {413/**
414 const result = await cdp.send("Runtime.evaluate", {414 * Runs an agent's expression, bounded by its request's deadline. Past it, the
415 * page's script is terminated and the call fails, so the tab's control queue
416 * frees for the next request instead of waiting on the script.
417 */
418export const evaluate = async (
419 cdp: CDPSession,
420 input: PreviewAutomationEvaluateInput,
421 timeoutMs: number,
422) => {
423 let timer: ReturnType<typeof setTimeout> | undefined;
424 const expired = new Promise<never>((_, reject) => {
425 timer = setTimeout(() => {
426 // Stops a busy script; an awaited promise is abandoned and settles unread.
427 void cdp.send("Runtime.terminateExecution").catch(constVoid);
428 reject(
429 new ServerBrowserOperationError(
430 "PreviewAutomationTimeoutError",
431 `Evaluation did not finish within ${timeoutMs}ms and was stopped.`,
432 ),
433 );
434 }, timeoutMs);
435 });
436 const evaluation = cdp.send("Runtime.evaluate", {
415 expression: input.expression,437 expression: input.expression,
416 awaitPromise: input.awaitPromise ?? true,438 awaitPromise: input.awaitPromise ?? true,
417 returnByValue: input.returnByValue ?? true,439 returnByValue: input.returnByValue ?? true,
418 });440 });
441 void evaluation.catch(constVoid);
442 const result = await Promise.race([evaluation, expired]).finally(() => clearTimeout(timer));
419 if (result.exceptionDetails) {443 if (result.exceptionDetails) {
420 throw new ServerBrowserOperationError(444 throw new ServerBrowserOperationError(
421 "PreviewAutomationExecutionError",445 "PreviewAutomationExecutionError",
apps/server/src/preview/ServerBrowserStream.ts+1
...@@ -156,6 +156,7 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) =>...@@ -156,6 +156,7 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) =>
156 case "download":156 case "download":
157 case "fileChooser":157 case "fileChooser":
158 case "fileChooserClosed":158 case "fileChooserClosed":
159 case "popup":
159 case "pointer":160 case "pointer":
160 case "probe": {161 case "probe": {
161 const { _tag: type, ...data } = output;162 const { _tag: type, ...data } = output;
apps/server/src/project/ProjectService.deletion.test.ts+14-4
...@@ -197,9 +197,13 @@ it.effect("retries a partial project deletion without repeating child events or...@@ -197,9 +197,13 @@ it.effect("retries a partial project deletion without repeating child events or
197 assert.lengthOf(partialEvents, 1);197 assert.lengthOf(partialEvents, 1);
198 assert.equal(partialEvents[0]?.stream_id, firstThreadId);198 assert.equal(partialEvents[0]?.stream_id, firstThreadId);
199 assert.equal(partialEvents[0]?.event_type, "thread.deleted");199 assert.equal(partialEvents[0]?.event_type, "thread.deleted");
200 assert.lengthOf(partialCleanup, 1);200 assert.deepEqual(
201 assert.equal(partialCleanup[0]?.thread_id, firstThreadId);201 partialCleanup.map((effect) => [effect.thread_id, effect.effect_type]),
202 assert.equal(partialCleanup[0]?.effect_type, "terminal.cleanup");202 [
203 [firstThreadId, "preview.cleanup"],
204 [firstThreadId, "terminal.cleanup"],
205 ],
206 );
203207
204 const deletedProject = yield* service.delete(input);208 const deletedProject = yield* service.delete(input);
205 assert.isNotNull(deletedProject.deletedAt);209 assert.isNotNull(deletedProject.deletedAt);
...@@ -220,7 +224,7 @@ it.effect("retries a partial project deletion without repeating child events or...@@ -220,7 +224,7 @@ it.effect("retries a partial project deletion without repeating child events or
220 assert.deepEqual(finalEvents[0], partialEvents[0]);224 assert.deepEqual(finalEvents[0], partialEvents[0]);
221 assert.equal(finalEvents[2]?.command_id, commandId);225 assert.equal(finalEvents[2]?.command_id, commandId);
222 const finalCleanup = yield* readCleanup;226 const finalCleanup = yield* readCleanup;
223 assert.lengthOf(finalCleanup, 2);227 assert.lengthOf(finalCleanup, 4);
224 assert.deepEqual(228 assert.deepEqual(
225 finalCleanup.filter((effect) => effect.thread_id === firstThreadId),229 finalCleanup.filter((effect) => effect.thread_id === firstThreadId),
226 partialCleanup,230 partialCleanup,
...@@ -230,6 +234,12 @@ it.effect("retries a partial project deletion without repeating child events or...@@ -230,6 +234,12 @@ it.effect("retries a partial project deletion without repeating child events or
230 assert.deepEqual(234 assert.deepEqual(
231 finalCleanup.filter((effect) => effect.thread_id === threadId),235 finalCleanup.filter((effect) => effect.thread_id === threadId),
232 [236 [
237 {
238 effect_id: `effect:${expectedCommandId}:preview.cleanup`,
239 thread_id: threadId,
240 command_id: expectedCommandId,
241 effect_type: "preview.cleanup",
242 },
233 {243 {
234 effect_id: `effect:${expectedCommandId}:terminal.cleanup`,244 effect_id: `effect:${expectedCommandId}:terminal.cleanup`,
235 thread_id: threadId,245 thread_id: threadId,
apps/web/src/browser/ServerBrowserSurface.tsx+5
...@@ -153,6 +153,8 @@ export function ServerBrowserSurface(props: {...@@ -153,6 +153,8 @@ export function ServerBrowserSurface(props: {
153 readonly onFirstFrame?: () => void;153 readonly onFirstFrame?: () => void;
154 readonly onViewport?: (viewport: PreviewStreamViewport) => void;154 readonly onViewport?: (viewport: PreviewStreamViewport) => void;
155 readonly onControl?: (control: PreviewStreamControl | null) => void;155 readonly onControl?: (control: PreviewStreamControl | null) => void;
156 /** The page opened a new tab from this viewer's click; the viewer should switch to it. */
157 readonly onPopup?: (tabId: string) => void;
156 readonly className?: string;158 readonly className?: string;
157 readonly ref?: Ref<ServerBrowserHandle>;159 readonly ref?: Ref<ServerBrowserHandle>;
158}) {160}) {
...@@ -166,6 +168,7 @@ export function ServerBrowserSurface(props: {...@@ -166,6 +168,7 @@ export function ServerBrowserSurface(props: {
166 onFirstFrame,168 onFirstFrame,
167 onViewport,169 onViewport,
168 onControl,170 onControl,
171 onPopup,
169 className,172 className,
170 ref,173 ref,
171 } = props;174 } = props;
...@@ -218,6 +221,7 @@ export function ServerBrowserSurface(props: {...@@ -218,6 +221,7 @@ export function ServerBrowserSurface(props: {
218 onViewport?.(viewport),221 onViewport?.(viewport),
219 );222 );
220 const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next));223 const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next));
224 const popupOpened = useEffectEvent((popupTabId: string) => onPopup?.(popupTabId));
221 // Frame cap in device px, fixed per socket. It grows with the surface and225 // Frame cap in device px, fixed per socket. It grows with the surface and
222 // never shrinks, so only outgrowing it reconnects.226 // never shrinks, so only outgrowing it reconnects.
223 const [cap, setCap] = useState<{ width: number; height: number } | null>(null);227 const [cap, setCap] = useState<{ width: number; height: number } | null>(null);
...@@ -397,6 +401,7 @@ export function ServerBrowserSurface(props: {...@@ -397,6 +401,7 @@ export function ServerBrowserSurface(props: {
397 },401 },
398 onClipboard: (text) => void copyPageText(text),402 onClipboard: (text) => void copyPageText(text),
399 onDownload: offerDownload,403 onDownload: offerDownload,
404 onPopup: popupOpened,
400 onFileChooser: setFileChooser,405 onFileChooser: setFileChooser,
401 onViewport: (viewport) => {406 onViewport: (viewport) => {
402 viewportRef.current = viewport;407 viewportRef.current = viewport;
apps/web/src/components/preview/PreviewUnreachable.tsx+44
...@@ -13,6 +13,50 @@ interface Props {...@@ -13,6 +13,50 @@ interface Props {
13 onReload: () => void;13 onReload: () => void;
14}14}
1515
16/**
17 * A server tab's address was a file its browser cannot show (a PDF), so the
18 * browser downloaded it. Offers the address in the user's own browser, or the
19 * file the server already saved.
20 */
21export function PreviewFileNotShown({
22 url,
23 fileName,
24 downloadUrl,
25 onOpen,
26}: {
27 url: string;
28 fileName: string;
29 downloadUrl: string | null;
30 onOpen: () => void;
31}) {
32 const host = safeHost(url) ?? url;
33 return (
34 <div className="scrollbar-gutter-both relative flex h-full min-h-0 w-full overflow-y-auto bg-background">
35 <div className="mx-auto flex w-full max-w-xl flex-1 flex-col px-8 py-12 sm:py-16">
36 <ErrorIcon className="mb-6 size-12 text-muted-foreground/70" />
37 <h1 className="mb-3 text-2xl font-semibold leading-tight text-foreground">
38 This file can&rsquo;t be shown here
39 </h1>
40 <p className="text-sm leading-relaxed text-muted-foreground">
41 <span className="font-semibold text-foreground">{fileName}</span> from {host} was
42 downloaded instead.
43 </p>
44 <div className="mt-auto flex items-center gap-2 pt-8">
45 <div className="flex-1" />
46 <Button type="button" variant="outline" size="sm" onClick={onOpen}>
47 Open in browser
48 </Button>
49 {downloadUrl ? (
50 <Button type="button" size="sm" render={<a href={downloadUrl} download={fileName} />}>
51 Download
52 </Button>
53 ) : null}
54 </div>
55 </div>
56 </div>
57 );
58}
59
16/** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */60/** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */
17export function PreviewUnreachable({ url, code, description, onReload }: Props) {61export function PreviewUnreachable({ url, code, description, onReload }: Props) {
18 const [showDetails, setShowDetails] = useState(false);62 const [showDetails, setShowDetails] = useState(false);
apps/web/src/components/preview/PreviewView.tsx+45-7
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1"use client";1"use client";
22
3import { scopedThreadKey } from "@t3tools/client-runtime/environment";3import { scopedThreadKey } from "@t3tools/client-runtime/environment";
4import { previewStreamDownloadUrl } from "@t3tools/client-runtime/preview/server-browser-stream";
4import {5import {
5 isAtomCommandInterrupted,6 isAtomCommandInterrupted,
6 squashAtomCommandFailure,7 squashAtomCommandFailure,
...@@ -16,7 +17,7 @@ import {...@@ -16,7 +17,7 @@ import {
16 PREVIEW_ZOOM_LEVELS,17 PREVIEW_ZOOM_LEVELS,
17 type PreviewAdjustInput,18 type PreviewAdjustInput,
18} from "@t3tools/contracts";19} from "@t3tools/contracts";
19import { normalizePreviewUrl } from "@t3tools/shared/preview";20import { normalizePreviewUrl, resolveAddressBarInput } from "@t3tools/shared/preview";
20import { useCallback, useEffect, useRef, useState } from "react";21import { useCallback, useEffect, useRef, useState } from "react";
2122
22import {23import {
...@@ -37,6 +38,7 @@ import {...@@ -37,6 +38,7 @@ import {
37import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";38import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
38import { useEnvironmentHttpBaseUrl } from "~/state/environments";39import { useEnvironmentHttpBaseUrl } from "~/state/environments";
39import { previewEnvironment } from "~/state/preview";40import { previewEnvironment } from "~/state/preview";
41import { usePreviewStreamAccess } from "~/state/previewStream";
40import { useAtomCommand } from "~/state/use-atom-command";42import { useAtomCommand } from "~/state/use-atom-command";
41import {43import {
42 browserMiniPlayerSource,44 browserMiniPlayerSource,
...@@ -49,6 +51,7 @@ import { useRightPanelStore } from "~/rightPanelStore";...@@ -49,6 +51,7 @@ import { useRightPanelStore } from "~/rightPanelStore";
49import { previewBridge } from "./previewBridge";51import { previewBridge } from "./previewBridge";
50import { subscribePreviewAction } from "./previewActionBus";52import { subscribePreviewAction } from "./previewActionBus";
51import { openPreviewSession } from "./openPreviewSession";53import { openPreviewSession } from "./openPreviewSession";
54import { showPreviewPopup } from "./showPreviewPopup";
52import { PreviewChromeRow } from "./PreviewChromeRow";55import { PreviewChromeRow } from "./PreviewChromeRow";
53import { PreviewEmptyState } from "./PreviewEmptyState";56import { PreviewEmptyState } from "./PreviewEmptyState";
54import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu";57import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu";
...@@ -61,7 +64,7 @@ import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar";...@@ -61,7 +64,7 @@ import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar";
61import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout";64import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout";
62import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";65import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
63import { BrowserSettingsReadError } from "~/browser/openFileInPreview";66import { BrowserSettingsReadError } from "~/browser/openFileInPreview";
64import { PreviewUnreachable } from "./PreviewUnreachable";67import { PreviewFileNotShown, PreviewUnreachable } from "./PreviewUnreachable";
65import { revealInFileExplorerLabel } from "./fileExplorerLabel";68import { revealInFileExplorerLabel } from "./fileExplorerLabel";
66import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";69import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
67import { Badge } from "~/components/ui/badge";70import { Badge } from "~/components/ui/badge";
...@@ -103,6 +106,31 @@ function previewProfileName(...@@ -103,6 +106,31 @@ function previewProfileName(
103106
104const localApi = typeof window === "undefined" ? null : ensureLocalApi();107const localApi = typeof window === "undefined" ? null : ensureLocalApi();
105108
109/** Resolves stream access only while a server tab shows a file it downloaded. */
110function ServerTabFileNotShown(props: {
111 readonly threadRef: ScopedThreadRef;
112 readonly tabId: string;
113 readonly url: string;
114 readonly download: { readonly id: string; readonly fileName: string };
115}) {
116 const access = usePreviewStreamAccess(props.threadRef.environmentId);
117 return (
118 <PreviewFileNotShown
119 url={props.url}
120 fileName={props.download.fileName}
121 downloadUrl={
122 access
123 ? previewStreamDownloadUrl(
124 { access, threadId: props.threadRef.threadId, tabId: props.tabId },
125 props.download.id,
126 )
127 : null
128 }
129 onOpen={() => void localApi?.shell.openExternal(props.url).catch(() => undefined)}
130 />
131 );
132}
133
106/**134/**
107 * Single-tab preview surface: chrome row on top, one webview below, empty135 * Single-tab preview surface: chrome row on top, one webview below, empty
108 * state when no session exists for the thread.136 * state when no session exists for the thread.
...@@ -259,12 +287,12 @@ export function PreviewView({...@@ -259,12 +287,12 @@ export function PreviewView({
259 const handleSubmitUrl = useCallback(287 const handleSubmitUrl = useCallback(
260 async (next: string) => {288 async (next: string) => {
261 try {289 try {
262 const normalized = normalizePreviewUrl(next);290 const resolved = resolveAddressBarInput(next);
263 if (await navigateToResolvedUrl(normalized)) {291 if (await navigateToResolvedUrl(resolved)) {
264 recordVisitForThread(threadRef, normalized);292 recordVisitForThread(threadRef, resolved);
265 }293 }
266 } catch {294 } catch {
267 // Server-side `failed` event renders the unreachable view.295 // Only empty input or an unsupported scheme lands here; the bar keeps the text.
268 }296 }
269 },297 },
270 [navigateToResolvedUrl, threadRef],298 [navigateToResolvedUrl, threadRef],
...@@ -995,6 +1023,7 @@ export function PreviewView({...@@ -995,6 +1023,7 @@ export function PreviewView({
995 onControl={(control) =>1023 onControl={(control) =>
996 setServerControlledTabId(control?.controller === "you" ? runtimeTabId : null)1024 setServerControlledTabId(control?.controller === "you" ? runtimeTabId : null)
997 }1025 }
1026 onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "panel")}
998 // Stays connected under the empty state so a URL picked there reaches the page.1027 // Stays connected under the empty state so a URL picked there reaches the page.
999 className={cn(1028 className={cn(
1000 "absolute inset-0 h-full w-full",1029 "absolute inset-0 h-full w-full",
...@@ -1043,7 +1072,16 @@ export function PreviewView({...@@ -1043,7 +1072,16 @@ export function PreviewView({
1043 controller={controller}1072 controller={controller}
1044 />1073 />
1045 ) : null}1074 ) : null}
1046 {navStatus._tag === "LoadFailed" ? (1075 {navStatus._tag === "LoadFailed" && navStatus.download ? (
1076 <div className="absolute inset-0 z-10 bg-background">
1077 <ServerTabFileNotShown
1078 threadRef={threadRef}
1079 tabId={snapshot?.tabId ?? ""}
1080 url={navStatus.url}
1081 download={navStatus.download}
1082 />
1083 </div>
1084 ) : navStatus._tag === "LoadFailed" ? (
1047 <div className="absolute inset-0 z-10 bg-background">1085 <div className="absolute inset-0 z-10 bg-background">
1048 <PreviewUnreachable1086 <PreviewUnreachable
1049 url={navStatus.url}1087 url={navStatus.url}
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+2
...@@ -49,6 +49,7 @@ import { DeviceStreamView } from "../device/DeviceStreamView";...@@ -49,6 +49,7 @@ import { DeviceStreamView } from "../device/DeviceStreamView";
49import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream";49import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream";
50import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream";50import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream";
51import { previewBridge } from "./previewBridge";51import { previewBridge } from "./previewBridge";
52import { showPreviewPopup } from "./showPreviewPopup";
52import {53import {
53 clampPreviewMiniPlayerPosition,54 clampPreviewMiniPlayerPosition,
54 NO_PREVIEW_MINI_PLAYER_OBSTACLES,55 NO_PREVIEW_MINI_PLAYER_OBSTACLES,
...@@ -226,6 +227,7 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly...@@ -226,6 +227,7 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly
226 followSize={false}227 followSize={false}
227 controlPosition="bottom"228 controlPosition="bottom"
228 onViewport={setStreamViewport}229 onViewport={setStreamViewport}
230 onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "floating")}
229 className="size-full"231 className="size-full"
230 />232 />
231 </div>233 </div>
apps/web/src/components/preview/showPreviewPopup.ts created+24
...@@ -0,0 +1,24 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2
3import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
4import { whenPreviewTabKnown } from "~/previewStateStore";
5import { useRightPanelStore } from "~/rightPanelStore";
6
7/**
8 * Switches the viewer to a tab the page they drive just opened, as a browser
9 * does for a link click or `window.open`. A floating opener floats the popup;
10 * otherwise it becomes the panel's active tab. The opener stays open.
11 */
12export function showPreviewPopup(
13 threadRef: ScopedThreadRef,
14 popupTabId: string,
15 from: "panel" | "floating",
16): void {
17 whenPreviewTabKnown(threadRef, popupTabId, () => {
18 if (from === "floating") {
19 usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(popupTabId));
20 } else {
21 useRightPanelStore.getState().openBrowser(threadRef, popupTabId);
22 }
23 });
24}
apps/web/src/hooks/useThreadActions.ts+3
...@@ -50,6 +50,7 @@ import {...@@ -50,6 +50,7 @@ import {
50 readThreadShells,50 readThreadShells,
51} from "../state/entities";51} from "../state/entities";
52import { useUiStateStore } from "../uiStateStore";52import { useUiStateStore } from "../uiStateStore";
53import { clearThreadPreviewState } from "../previewStateStore";
53import { useTerminalUiStateStore } from "../terminalUiStateStore";54import { useTerminalUiStateStore } from "../terminalUiStateStore";
54import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";55import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
55import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";56import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
...@@ -449,6 +450,7 @@ export function useThreadActions() {...@@ -449,6 +450,7 @@ export function useThreadActions() {
449 });450 });
450 if (result._tag === "Success") {451 if (result._tag === "Success") {
451 refreshArchivedThreadsForEnvironment(target.environmentId);452 refreshArchivedThreadsForEnvironment(target.environmentId);
453 clearThreadPreviewState(target);
452 }454 }
453 return result;455 return result;
454 }456 }
...@@ -555,6 +557,7 @@ export function useThreadActions() {...@@ -555,6 +557,7 @@ export function useThreadActions() {
555 threadRef,557 threadRef,
556 );558 );
557 clearTerminalUiState(threadRef);559 clearTerminalUiState(threadRef);
560 clearThreadPreviewState(threadRef);
558561
559 if (shouldNavigateToFallback) {562 if (shouldNavigateToFallback) {
560 const fallbackThread = fallbackThreadId563 const fallbackThread = fallbackThreadId
apps/web/src/previewStateStore.ts+40
...@@ -214,6 +214,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven...@@ -214,6 +214,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven
214 title: event.title,214 title: event.title,
215 code: event.code,215 code: event.code,
216 description: event.description,216 description: event.description,
217 ...(event.download === undefined ? {} : { download: event.download }),
217 },218 },
218 updatedAt: event.createdAt,219 updatedAt: event.createdAt,
219 };220 };
...@@ -457,6 +458,35 @@ export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void {...@@ -457,6 +458,35 @@ export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void {
457 });458 });
458}459}
459460
461/**
462 * Runs `action` once the thread's preview state has the tab, which a popup's
463 * `opened` event may deliver after the stream that announced it. Gives up
464 * after `timeoutMs`. Returns a cancel function.
465 */
466export function whenPreviewTabKnown(
467 ref: ScopedThreadRef,
468 tabId: string,
469 action: () => void,
470 timeoutMs = 5_000,
471): () => void {
472 const atom = previewStateAtom(scopedThreadKey(ref));
473 if (appAtomRegistry.get(atom).sessions[tabId]) {
474 action();
475 return () => {};
476 }
477 const stop = () => {
478 clearTimeout(timer);
479 unsubscribe();
480 };
481 const unsubscribe = appAtomRegistry.subscribe(atom, (state) => {
482 if (!state.sessions[tabId]) return;
483 stop();
484 action();
485 });
486 const timer = setTimeout(stop, timeoutMs);
487 return stop;
488}
489
460export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void {490export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void {
461 if (url.trim().length === 0) return;491 if (url.trim().length === 0) return;
462 updateThreadPreviewState(ref, (current) => ({492 updateThreadPreviewState(ref, (current) => ({
...@@ -470,6 +500,16 @@ export function isPreviewSupportedInRuntime(): boolean {...@@ -470,6 +500,16 @@ export function isPreviewSupportedInRuntime(): boolean {
470 return Boolean(window.desktopBridge?.preview);500 return Boolean(window.desktopBridge?.preview);
471}501}
472502
503/**
504 * Forgets a deleted thread's previews. The server closes their sessions too,
505 * but the desktop host keeps a page for every session held here.
506 */
507export function clearThreadPreviewState(ref: ScopedThreadRef): void {
508 updateThreadPreviewState(ref, (current) =>
509 Object.keys(current.sessions).length === 0 ? current : EMPTY_THREAD_PREVIEW_STATE,
510 );
511}
512
473export function resetPreviewStateForTests(): void {513export function resetPreviewStateForTests(): void {
474 for (const threadKey of changedPreviewThreadKeys) {514 for (const threadKey of changedPreviewThreadKeys) {
475 appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE);515 appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE);
packages/client-runtime/src/preview/serverBrowserStream.ts+7-1
...@@ -139,7 +139,8 @@ export async function uploadPreviewStreamFiles(...@@ -139,7 +139,8 @@ export async function uploadPreviewStreamFiles(
139 if (!response.ok) throw new Error((await response.text()) || "The upload was refused.");139 if (!response.ok) throw new Error((await response.text()) || "The upload was refused.");
140}140}
141141
142const previewStreamDownloadUrl = (142/** A file a server tab downloaded, served with the stream's own access. */
143export const previewStreamDownloadUrl = (
143 target: Pick<PreviewStreamTarget, "access" | "threadId" | "tabId">,144 target: Pick<PreviewStreamTarget, "access" | "threadId" | "tabId">,
144 id: string,145 id: string,
145): string =>146): string =>
...@@ -202,6 +203,8 @@ export interface PreviewStreamEvents {...@@ -202,6 +203,8 @@ export interface PreviewStreamEvents {
202 readonly onClipboard?: (text: string) => void;203 readonly onClipboard?: (text: string) => void;
203 /** A file the page downloaded while this viewer had control. */204 /** A file the page downloaded while this viewer had control. */
204 readonly onDownload?: (download: PreviewStreamDownload) => void;205 readonly onDownload?: (download: PreviewStreamDownload) => void;
206 /** The page this viewer controls opened a new tab (a link or `window.open`). */
207 readonly onPopup?: (tabId: string) => void;
205 /** The page opened a file picker (`null` once answered or replaced). */208 /** The page opened a file picker (`null` once answered or replaced). */
206 readonly onFileChooser?: (chooser: PreviewStreamFileChooser | null) => void;209 readonly onFileChooser?: (chooser: PreviewStreamFileChooser | null) => void;
207 /** Input sent while disconnected is dropped. */210 /** Input sent while disconnected is dropped. */
...@@ -286,6 +289,7 @@ export function createPreviewStreamClient(...@@ -286,6 +289,7 @@ export function createPreviewStreamClient(
286 accept,289 accept,
287 phase,290 phase,
288 sequence,291 sequence,
292 tabId,
289 } = message as Record<string, unknown>;293 } = message as Record<string, unknown>;
290 if (294 if (
291 type === "fileChooser" &&295 type === "fileChooser" &&
...@@ -312,6 +316,8 @@ export function createPreviewStreamClient(...@@ -312,6 +316,8 @@ export function createPreviewStreamClient(
312 typeof sequence === "number"316 typeof sequence === "number"
313 ) {317 ) {
314 events.onPointer?.({ phase, x, y, sequence });318 events.onPointer?.({ phase, x, y, sequence });
319 } else if (type === "popup" && typeof tabId === "string") {
320 events.onPopup?.(tabId);
315 } else if (type === "clipboard" && typeof text === "string") {321 } else if (type === "clipboard" && typeof text === "string") {
316 events.onClipboard?.(text);322 events.onClipboard?.(text);
317 } else if (323 } else if (
packages/contracts/src/preview.ts+10
...@@ -143,6 +143,9 @@ export type PreviewAppearancePreference = typeof PreviewAppearancePreference.Typ...@@ -143,6 +143,9 @@ export type PreviewAppearancePreference = typeof PreviewAppearancePreference.Typ
143143
144export const DEFAULT_PREVIEW_APPEARANCE: PreviewAppearancePreference = "system";144export const DEFAULT_PREVIEW_APPEARANCE: PreviewAppearancePreference = "system";
145145
146/** A file a server tab downloaded, served by the tab's download route by this id. */
147const PreviewFileDownload = Schema.Struct({ id: Schema.String, fileName: Schema.String });
148
146export const PreviewNavStatus = Schema.Union([149export const PreviewNavStatus = Schema.Union([
147 Schema.TaggedStruct("Idle", {}),150 Schema.TaggedStruct("Idle", {}),
148 Schema.TaggedStruct("Loading", {151 Schema.TaggedStruct("Loading", {
...@@ -158,6 +161,12 @@ export const PreviewNavStatus = Schema.Union([...@@ -158,6 +161,12 @@ export const PreviewNavStatus = Schema.Union([
158 title: Title,161 title: Title,
159 code: Schema.Int,162 code: Schema.Int,
160 description: Schema.String,163 description: Schema.String,
164 /**
165 * Set when the address was a file the browser downloaded instead of a page
166 * it could show (a PDF in a server tab). The server tab's download route
167 * serves it by this id.
168 */
169 download: Schema.optional(PreviewFileDownload),
161 }),170 }),
162]);171]);
163export type PreviewNavStatus = typeof PreviewNavStatus.Type;172export type PreviewNavStatus = typeof PreviewNavStatus.Type;
...@@ -347,6 +356,7 @@ const PreviewFailedEvent = Schema.Struct({...@@ -347,6 +356,7 @@ const PreviewFailedEvent = Schema.Struct({
347 title: Title,356 title: Title,
348 code: Schema.Int,357 code: Schema.Int,
349 description: Schema.String,358 description: Schema.String,
359 download: Schema.optional(PreviewFileDownload),
350});360});
351361
352const PreviewClosedEvent = Schema.Struct({362const PreviewClosedEvent = Schema.Struct({
packages/shared/src/preview.test.ts+50
...@@ -5,8 +5,58 @@ import {...@@ -5,8 +5,58 @@ import {
5 newPreviewTabId,5 newPreviewTabId,
6 normalizePreviewUrl,6 normalizePreviewUrl,
7 PreviewUrlNormalizationError,7 PreviewUrlNormalizationError,
8 resolveAddressBarInput,
8} from "./preview.ts";9} from "./preview.ts";
910
11describe("resolveAddressBarInput", () => {
12 it("opens what users type as addresses", () => {
13 expect(resolveAddressBarInput("localhost:5173")).toBe("http://localhost:5173/");
14 expect(resolveAddressBarInput("127.0.0.1:3000/path")).toBe("http://127.0.0.1:3000/path");
15 expect(resolveAddressBarInput("my-box.tailnet.ts.net")).toBe("https://my-box.tailnet.ts.net/");
16 expect(resolveAddressBarInput("cnn.com")).toBe("https://cnn.com/");
17 expect(resolveAddressBarInput("devbox:8080")).toBe("https://devbox:8080/");
18 expect(resolveAddressBarInput(" https://example.com/a b ")).toBe("https://example.com/a%20b");
19 expect(resolveAddressBarInput("example.com:8080")).toBe("https://example.com:8080/");
20 expect(resolveAddressBarInput("[::1]:3000")).toBe("http://[::1]:3000/");
21 expect(resolveAddressBarInput("192.168.1.5:3000")).toBe("https://192.168.1.5:3000/");
22 });
23
24 it("searches for text that is not an address", () => {
25 expect(resolveAddressBarInput("weather")).toBe("https://duckduckgo.com/?q=weather");
26 expect(resolveAddressBarInput("how to center a div")).toBe(
27 "https://duckduckgo.com/?q=how%20to%20center%20a%20div",
28 );
29 expect(resolveAddressBarInput("what is cnn.com")).toBe(
30 "https://duckduckgo.com/?q=what%20is%20cnn.com",
31 );
32 expect(resolveAddressBarInput("what is https://example.com")).toBe(
33 "https://duckduckgo.com/?q=what%20is%20https%3A%2F%2Fexample.com",
34 );
35 expect(resolveAddressBarInput("what is 10:30")).toBe(
36 "https://duckduckgo.com/?q=what%20is%2010%3A30",
37 );
38 expect(resolveAddressBarInput("note: buy milk")).toBe(
39 "https://duckduckgo.com/?q=note%3A%20buy%20milk",
40 );
41 });
42
43 it("still rejects empty input and unsupported schemes", () => {
44 expect(() => resolveAddressBarInput(" ")).toThrow(PreviewUrlNormalizationError);
45 expect(() => resolveAddressBarInput("ftp://example.com")).toThrow(PreviewUrlNormalizationError);
46 expect(() => resolveAddressBarInput("mailto:alice@example.com")).toThrow(
47 PreviewUrlNormalizationError,
48 );
49 expect(() => resolveAddressBarInput("data:text/plain,hello")).toThrow(
50 PreviewUrlNormalizationError,
51 );
52 // A known scheme with a numeric payload is not a host and port.
53 for (const input of ["ftp:21", "tel:5551234"]) {
54 expect(() => resolveAddressBarInput(input)).toThrow(PreviewUrlNormalizationError);
55 }
56 expect(resolveAddressBarInput("devbox:8080")).toBe("https://devbox:8080/");
57 });
58});
59
10describe("newPreviewTabId", () => {60describe("newPreviewTabId", () => {
11 it("returns a unique tab id every call", () => {61 it("returns a unique tab id every call", () => {
12 const a = newPreviewTabId();62 const a = newPreviewTabId();
packages/shared/src/preview.ts+68
...@@ -57,6 +57,74 @@ function previewUrlProtocol(rawUrl: string): string | undefined {...@@ -57,6 +57,74 @@ function previewUrlProtocol(rawUrl: string): string | undefined {
57 return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":");57 return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":");
58}58}
5959
60const SEARCH_URL = "https://duckduckgo.com/?q=";
61/** A bare host that is always an address: localhost, an IPv4 literal, or a bracketed IPv6 one. */
62const ADDRESS_HOST_PATTERN = /^(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-f:.]+\])$/i;
63const BARE_HOST_PORT_PATTERN = /^[^/?#:@]+:\d+(?:[/?#]|$)/;
64/**
65 * Registered schemes that are never a bare host, so `ftp:21` or `tel:5551234`
66 * is rejected rather than read as host and port.
67 */
68const KNOWN_NON_WEB_SCHEMES: ReadonlySet<string> = new Set([
69 "about:",
70 "blob:",
71 "chrome:",
72 "data:",
73 "file:",
74 "ftp:",
75 "javascript:",
76 "mailto:",
77 "sms:",
78 "ssh:",
79 "tel:",
80 "view-source:",
81 "ws:",
82 "wss:",
83]);
84
85/**
86 * Turns what a user typed in an address bar into the URL to open: an address
87 * as `normalizePreviewUrl` reads it, or else a web search for the text. Text
88 * is an address when it has a scheme, or no spaces and a host that has a dot,
89 * a port, or is localhost or an IP. Throws only for empty input or an
90 * explicit unsupported scheme (`ftp://`, `mailto:`, `data:`).
91 */
92export function resolveAddressBarInput(rawInput: string): string {
93 const trimmed = rawInput.trim();
94 if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) || trimmed.length === 0)
95 return normalizePreviewUrl(trimmed);
96 if (!/\s/.test(trimmed)) {
97 // `mailto:x` or `data:...` is an explicit scheme; `example.com:8080` or
98 // `devbox:8080` is a bare host and port. `normalizePreviewUrl` would read
99 // `mailto:alice@example.com` as credentials on a bare host, so reject here.
100 const protocol = previewUrlProtocol(trimmed);
101 if (
102 protocol !== undefined &&
103 (KNOWN_NON_WEB_SCHEMES.has(protocol) || !BARE_HOST_PORT_PATTERN.test(trimmed))
104 ) {
105 if (protocol !== "http:" && protocol !== "https:") {
106 throw new PreviewUrlNormalizationError({
107 inputLength: rawInput.length,
108 reason: "unsupported-protocol",
109 protocol,
110 });
111 }
112 return normalizePreviewUrl(trimmed);
113 }
114 const authority = trimmed.split(/[/?#]/, 1)[0] ?? "";
115 const host = authority.replace(/^[^@]*@/, "").replace(/:\d+$/, "");
116 const hasPort = host.length < authority.replace(/^[^@]*@/, "").length;
117 if (hasPort || ADDRESS_HOST_PATTERN.test(host) || /^[^.]+(?:\.[^.]+)+\.?$/.test(host)) {
118 try {
119 return normalizePreviewUrl(trimmed);
120 } catch {
121 // Not a usable address after all; search for it.
122 }
123 }
124 }
125 return `${SEARCH_URL}${encodeURIComponent(trimmed)}`;
126}
127
60/**128/**
61 * Normalise a free-form URL string into a fully-qualified `http(s)://` URL.129 * Normalise a free-form URL string into a fully-qualified `http(s)://` URL.
62 *130 *