| author | |
| committer | |
| log | 5754121c23f73201d47a96feb97eca7b96334a2a |
| tree | 41aeea924b2334a4ce889e4e8454e970f232a64a |
| parent | 741377ce5fb1578916659cbb93fd403b26dbeb62 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 95 | Schema.Struct({ |
| 96 | 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 | 102 | Schema.Struct({ |
| 99 | 103 | type: Schema.Literal("attachment.cleanup"), |
| 100 | 104 | attachmentIds: Schema.Array(Schema.String), |
| ... | ... | @@ -120,6 +124,7 @@ export const REPLAY_SAFE_EFFECT_TYPES_AFTER_PROCESS_LOSS = [ |
| 120 | 124 | "provider-thread.rollback", |
| 121 | 125 | "checkpoint.capture", |
| 122 | 126 | "terminal.cleanup", |
| 127 | "preview.cleanup", | |
| 123 | 128 | "attachment.cleanup", |
| 124 | 129 | "thread-title.generate", |
| 125 | 130 | "delegated-tasks.stop", |
apps/server/src/orchestration-v2/EffectWorker.ts+11| ... | ... | @@ -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 | 453 | case "attachment.cleanup": |
| 443 | 454 | return resourceCleanup.cleanupAttachments(effect.request.attachmentIds).pipe( |
| 444 | 455 | Effect.mapError( |
apps/server/src/orchestration-v2/ResourceCleanupService.ts+15-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 1 | 2 | import * as Context from "effect/Context"; |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -6,12 +7,13 @@ import * as Schema from "effect/Schema"; |
| 6 | 7 | |
| 7 | 8 | import { resolveAttachmentPathById } from "../attachmentStore.ts"; |
| 8 | 9 | import * as ServerConfig from "../config.ts"; |
| 10 | import * as PreviewManager from "../preview/Manager.ts"; | |
| 9 | 11 | import * as TerminalManager from "../terminal/Manager.ts"; |
| 10 | 12 | |
| 11 | 13 | export class ResourceCleanupError extends Schema.TaggedError<ResourceCleanupError>()( |
| 12 | 14 | "ResourceCleanupError", |
| 13 | 15 | { |
| 14 | operation: Schema.Literals(["terminal", "attachment"]), | |
| 16 | operation: Schema.Literals(["terminal", "preview", "attachment"]), | |
| 15 | 17 | threadId: Schema.optional(Schema.String), |
| 16 | 18 | attachmentId: Schema.optional(Schema.String), |
| 17 | 19 | cause: Schema.Defect(), |
| ... | ... | @@ -20,12 +22,15 @@ export class ResourceCleanupError extends Schema.TaggedError<ResourceCleanupErro |
| 20 | 22 | |
| 21 | 23 | export class ResourceCleanupService extends Context.Reference<{ |
| 22 | 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 | 27 | readonly cleanupAttachments: ( |
| 24 | 28 | attachmentIds: ReadonlyArray<string>, |
| 25 | 29 | ) => Effect.Effect<void, ResourceCleanupError>; |
| 26 | 30 | }>("t3/orchestration-v2/ResourceCleanupService", { |
| 27 | 31 | defaultValue: () => ({ |
| 28 | 32 | cleanupTerminals: () => Effect.void, |
| 33 | cleanupPreviews: () => Effect.void, | |
| 29 | 34 | cleanupAttachments: () => Effect.void, |
| 30 | 35 | }), |
| 31 | 36 | }) {} |
| ... | ... | @@ -34,6 +39,7 @@ export const layer = Layer.effect( |
| 34 | 39 | ResourceCleanupService, |
| 35 | 40 | Effect.gen(function* () { |
| 36 | 41 | const terminals = yield* TerminalManager.TerminalManager; |
| 42 | const previews = yield* PreviewManager.PreviewManager; | |
| 37 | 43 | const fileSystem = yield* FileSystem.FileSystem; |
| 38 | 44 | const config = yield* ServerConfig.ServerConfig; |
| 39 | 45 | return { |
| ... | ... | @@ -45,6 +51,14 @@ export const layer = Layer.effect( |
| 45 | 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 | 62 | cleanupAttachments: (attachmentIds: ReadonlyArray<string>) => |
| 49 | 63 | Effect.forEach( |
| 50 | 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 | 291 | revokeMcpCredential: true, |
| 292 | 292 | }, |
| 293 | 293 | { type: "terminal.cleanup" }, |
| 294 | { type: "preview.cleanup" }, | |
| 294 | 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 | 215 | threadId: command.threadId, |
| 216 | 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 | 224 | const attachmentIds = Array.from(new Set(input.attachmentIds)); |
| 219 | 225 | if (attachmentIds.length > 0) { |
| 220 | 226 | effects.push({ |
apps/server/src/preview/DesktopBrowserChannel.ts+7| ... | ... | @@ -54,6 +54,8 @@ export class DesktopBrowserChannel extends Context.Service< |
| 54 | 54 | readonly awaitAttached: (key: DesktopTabKey, timeout: Duration.Input) => Effect.Effect<boolean>; |
| 55 | 55 | /** Desktop tabs as they detach. */ |
| 56 | 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 | 59 | readonly isAttached: (key: DesktopTabKey) => Effect.Effect<boolean>; |
| 58 | 60 | /** |
| 59 | 61 | * A one-connection CDP endpoint for an attached tab. Closing the scope |
| ... | ... | @@ -83,6 +85,7 @@ const make = Effect.gen(function* () { |
| 83 | 85 | available: false, |
| 84 | 86 | awaitAttached: () => Effect.succeed(false), |
| 85 | 87 | detached: Stream.empty, |
| 88 | attached: Stream.empty, | |
| 86 | 89 | isAttached: () => Effect.succeed(false), |
| 87 | 90 | endpoint: () => Effect.die("No desktop app is attached to this server."), |
| 88 | 91 | pointer: () => Effect.void, |
| ... | ... | @@ -216,6 +219,10 @@ const make = Effect.gen(function* () { |
| 216 | 219 | Stream.filter((change) => !change.attached), |
| 217 | 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 | 226 | isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))), |
| 220 | 227 | endpoint, |
| 221 | 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 | 317 | title: input.navStatus.title, |
| 318 | 318 | code: input.navStatus.code, |
| 319 | 319 | description: input.navStatus.description, |
| 320 | ...(input.navStatus.download === undefined | |
| 321 | ? {} | |
| 322 | : { download: input.navStatus.download }), | |
| 320 | 323 | } |
| 321 | 324 | : { |
| 322 | 325 | type: "navigated", |
apps/server/src/preview/PortScanner.test.ts+114| ... | ... | @@ -14,6 +14,7 @@ import * as Duration from "effect/Duration"; |
| 14 | 14 | import * as Effect from "effect/Effect"; |
| 15 | 15 | import * as Exit from "effect/Exit"; |
| 16 | 16 | import * as Fiber from "effect/Fiber"; |
| 17 | import * as FileSystem from "effect/FileSystem"; | |
| 17 | 18 | import * as Layer from "effect/Layer"; |
| 18 | 19 | import * as PlatformError from "effect/PlatformError"; |
| 19 | 20 | import * as Scope from "effect/Scope"; |
| ... | ... | @@ -53,13 +54,18 @@ const layerTestIntegrationNet = Layer.succeed(Net.NetService, { |
| 53 | 54 | findAvailablePort: (preferred) => Effect.succeed(preferred), |
| 54 | 55 | }); |
| 55 | 56 | |
| 57 | /** A host without `/proc`, so a missing `lsof` falls back to common ports. */ | |
| 58 | const layerNoProc = FileSystem.layerNoop({}); | |
| 59 | ||
| 56 | 60 | const layerProbeFailure = ( |
| 57 | 61 | run: ProcessRunner.ProcessRunner["Service"]["run"], |
| 58 | 62 | fetch: typeof globalThis.fetch = globalThis.fetch, |
| 63 | fileSystem: Layer.Layer<FileSystem.FileSystem> = layerNoProc, | |
| 59 | 64 | ) => |
| 60 | 65 | PortScanner.layer.pipe( |
| 61 | 66 | Layer.provide( |
| 62 | 67 | Layer.mergeAll( |
| 68 | fileSystem, | |
| 63 | 69 | Layer.succeed(ProcessRunner.ProcessRunner, { run }), |
| 64 | 70 | Layer.succeed(Net.NetService, { |
| 65 | 71 | canListenOnHost: () => Effect.succeed(true), |
| ... | ... | @@ -77,6 +83,7 @@ const layerProbeFailure = ( |
| 77 | 83 | const layerTestPortDiscovery = PortScanner.layer.pipe( |
| 78 | 84 | Layer.provide( |
| 79 | 85 | Layer.mergeAll( |
| 86 | layerNoProc, | |
| 80 | 87 | layerTestProcessRunner, |
| 81 | 88 | layerTestIntegrationNet, |
| 82 | 89 | Layer.succeed(HostProcessPlatform, "win32"), |
| ... | ... | @@ -94,6 +101,7 @@ const layerLsofScanner = (input: { |
| 94 | 101 | PortScanner.layer.pipe( |
| 95 | 102 | Layer.provide( |
| 96 | 103 | Layer.mergeAll( |
| 104 | layerNoProc, | |
| 97 | 105 | Layer.succeed(ProcessRunner.ProcessRunner, { |
| 98 | 106 | run: () => |
| 99 | 107 | Effect.succeed({ |
| ... | ... | @@ -671,6 +679,112 @@ effectIt.effect("does not swallow process probe defects", () => |
| 671 | 679 | }), |
| 672 | 680 | ); |
| 673 | 681 | |
| 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. | |
| 684 | const 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 | `; | |
| 690 | const 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 | ||
| 694 | effectIt.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 | ||
| 762 | effectIt.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 | ||
| 674 | 788 | effectIt.effect("does not swallow process probe interruption", () => |
| 675 | 789 | Effect.gen(function* () { |
| 676 | 790 | const layer = layerProbeFailure(() => Effect.interrupt); |
apps/server/src/preview/PortScanner.ts+157-4| ... | ... | @@ -5,8 +5,11 @@ |
| 5 | 5 | * stable line-prefixed field format; this is the only `lsof` flag set we rely |
| 6 | 6 | * on). |
| 7 | 7 | * |
| 8 | * Windows / lsof missing: checks a curated list of common dev ports through | |
| 9 | * the shared Net service. | |
| 8 | * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and | |
| 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 | 14 | * Listening ports are published only after a bounded HTTP(S) probe finds a |
| 12 | 15 | * successful HTML document or a redirect to one. |
| ... | ... | @@ -30,8 +33,10 @@ import * as Clock from "effect/Clock"; |
| 30 | 33 | import * as Context from "effect/Context"; |
| 31 | 34 | import * as Duration from "effect/Duration"; |
| 32 | 35 | import * as Effect from "effect/Effect"; |
| 36 | import * as FileSystem from "effect/FileSystem"; | |
| 33 | 37 | import * as Layer from "effect/Layer"; |
| 34 | 38 | import * as Option from "effect/Option"; |
| 39 | import * as PlatformError from "effect/PlatformError"; | |
| 35 | 40 | import * as Ref from "effect/Ref"; |
| 36 | 41 | import * as Schedule from "effect/Schedule"; |
| 37 | 42 | import * as Scope from "effect/Scope"; |
| ... | ... | @@ -72,6 +77,10 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ |
| 72 | 77 | |
| 73 | 78 | const POLL_INTERVAL = Duration.seconds(3); |
| 74 | 79 | const LSOF_TIMEOUT_MS = 5_000; |
| 80 | /** File descriptors one `/proc` walk may read before it stops looking for socket owners. */ | |
| 81 | const PROC_FD_WALK_LIMIT = 50_000; | |
| 82 | /** Scans that retry the fd walk for a listening socket whose owner was not found. */ | |
| 83 | const SOCKET_OWNER_ATTEMPTS = 3; | |
| 75 | 84 | const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; |
| 76 | 85 | const WEB_PROBE_TIMEOUT = Duration.seconds(1); |
| 77 | 86 | const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15)); |
| ... | ... | @@ -265,6 +274,42 @@ const parseWindowsListenerOutput = ( |
| 265 | 274 | return [...seen.values()].toSorted((left, right) => left.port - right.port); |
| 266 | 275 | }; |
| 267 | 276 | |
| 277 | /** `/proc/net/tcp` state for a listening socket. */ | |
| 278 | const 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 | */ | |
| 285 | const 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. */ | |
| 296 | const 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 | ||
| 268 | 313 | const serversEqual = ( |
| 269 | 314 | left: ReadonlyArray<DiscoveredLocalServer>, |
| 270 | 315 | right: ReadonlyArray<DiscoveredLocalServer>, |
| ... | ... | @@ -289,10 +334,14 @@ const serversEqual = ( |
| 289 | 334 | return true; |
| 290 | 335 | }; |
| 291 | 336 | |
| 337 | const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean => | |
| 338 | PlatformError.isPlatformError(error.cause) && error.cause.reason._tag === "NotFound"; | |
| 339 | ||
| 292 | 340 | /** @public Service construction is part of the canonical Effect module API. */ |
| 293 | 341 | export const make = Effect.gen(function* PortDiscoveryMake() { |
| 294 | 342 | const net = yield* Net.NetService; |
| 295 | 343 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 344 | const fileSystem = yield* FileSystem.FileSystem; | |
| 296 | 345 | const hostPlatform = yield* HostProcessPlatform; |
| 297 | 346 | const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); |
| 298 | 347 | const stateRef = yield* Ref.make<ScannerState>({ |
| ... | ... | @@ -302,6 +351,101 @@ export const make = Effect.gen(function* PortDiscoveryMake() { |
| 302 | 351 | }); |
| 303 | 352 | const webProbeCacheRef = yield* Ref.make<ReadonlyMap<string, WebProbeCacheEntry>>(new Map()); |
| 304 | 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 | }); | |
| 305 | 449 | |
| 306 | 450 | const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { |
| 307 | 451 | const results = yield* Effect.forEach( |
| ... | ... | @@ -514,6 +658,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { |
| 514 | 658 | return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); |
| 515 | 659 | } |
| 516 | 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 | 665 | const lsofResult = yield* processRunner |
| 518 | 666 | .run({ |
| 519 | 667 | command: "lsof", |
| ... | ... | @@ -525,7 +673,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() { |
| 525 | 673 | .pipe( |
| 526 | 674 | Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), |
| 527 | 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 | 681 | ProcessStdinError: recoverLsofProbeFailure, |
| 530 | 682 | ProcessOutputLimitError: recoverLsofProbeFailure, |
| 531 | 683 | ProcessReadError: recoverLsofProbeFailure, |
| ... | ... | @@ -533,7 +685,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { |
| 533 | 685 | }), |
| 534 | 686 | ); |
| 535 | 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 | }); |
| 538 | 691 | |
| 539 | 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 | 20 | const SANDBOX_ABORT = |
| 21 | 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/"; |
| 22 | 22 | |
| 23 | const lddReporting = (stdout: string) => { | |
| 23 | // What the real loader prints when the browser's `--version` cannot start it. | |
| 24 | const 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 | ||
| 27 | const hostReporting = (output: { readonly version: string; readonly ldd: string }) => { | |
| 24 | 28 | const commands: Array<string> = []; |
| 25 | 29 | const spawner = ChildProcessSpawner.make((command) => |
| 26 | 30 | Effect.sync(() => { |
| ... | ... | @@ -36,9 +40,9 @@ const lddReporting = (stdout: string) => { |
| 36 | 40 | kill: () => Effect.void, |
| 37 | 41 | unref: Effect.succeed(Effect.void), |
| 38 | 42 | stdin: Sink.drain, |
| 39 | stdout: Stream.make(new TextEncoder().encode(stdout)), | |
| 43 | stdout: Stream.empty, | |
| 40 | 44 | stderr: Stream.empty, |
| 41 | all: Stream.empty, | |
| 45 | all: Stream.make(new TextEncoder().encode(name === "ldd" ? output.ldd : output.version)), | |
| 42 | 46 | getInputFd: () => Sink.drain, |
| 43 | 47 | getOutputFd: () => Stream.empty, |
| 44 | 48 | }); |
| ... | ... | @@ -47,8 +51,16 @@ const lddReporting = (stdout: string) => { |
| 47 | 51 | return { spawner, commands }; |
| 48 | 52 | }; |
| 49 | 53 | |
| 50 | const diagnose = (input: { platform: NodeJS.Platform; output: string; ldd: string }) => { | |
| 51 | const { spawner, commands } = lddReporting(input.ldd); | |
| 54 | const diagnose = (input: { | |
| 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 | 64 | return PreviewBrowserHost.diagnoseLaunchFailure({ |
| 53 | 65 | executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell", |
| 54 | 66 | output: input.output, |
| ... | ... | @@ -89,12 +101,26 @@ describe("diagnoseLaunchFailure", () => { |
| 89 | 101 | }), |
| 90 | 102 | ); |
| 91 | 103 | |
| 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 | 117 | it.effect("leaves other failures alone", () => |
| 93 | 118 | Effect.gen(function* () { |
| 94 | 119 | const linux = yield* diagnose({ |
| 95 | 120 | platform: "linux", |
| 96 | 121 | output: "crashed", |
| 97 | 122 | ldd: "\tlibc.so.6 => /lib/libc.so.6\n", |
| 123 | version: "Google Chrome for Testing 154.0.8037.92\n", | |
| 98 | 124 | }); |
| 99 | 125 | expect(linux.error).toBeUndefined(); |
| 100 | 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 | 90 | export type PreviewBrowserHostError = PreviewBrowserSandboxError | PreviewBrowserLibrariesError; |
| 91 | 91 | |
| 92 | 92 | const MISSING_LIBRARY = /^\s*(\S+) => not found$/gm; |
| 93 | /** glibc's loader abort, naming the first library it could not load. */ | |
| 94 | const LOADER_ERROR = /error while loading shared libraries: ([^:\s]+)/; | |
| 93 | 95 | |
| 94 | 96 | /** |
| 95 | 97 | * 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 when | |
| 97 | * neither explains it. Linux only; other hosts never need either. | |
| 98 | * Chrome's own abort message, or shared libraries the loader refuses. Undefined | |
| 99 | * when neither explains it. Linux only; other hosts never need either. | |
| 98 | 100 | */ |
| 99 | 101 | export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunchFailure")( |
| 100 | 102 | function* (input: { |
| ... | ... | @@ -114,18 +116,29 @@ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunc |
| 114 | 116 | }, |
| 115 | 117 | ); |
| 116 | 118 | |
| 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 | */ | |
| 118 | 125 | export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries")(function* ( |
| 119 | 126 | executable: string, |
| 120 | 127 | ) { |
| 121 | 128 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 122 | const report = yield* spawner | |
| 123 | .string(ChildProcess.make("ldd", [executable], { stdin: "ignore", stderr: "ignore" })) | |
| 124 | .pipe( | |
| 125 | Effect.timeout("5 seconds"), | |
| 126 | Effect.orElseSucceed(() => ""), | |
| 127 | ); | |
| 128 | return [...report.matchAll(MISSING_LIBRARY)].map((match) => match[1]!); | |
| 129 | const run = (command: string, args: ReadonlyArray<string>) => | |
| 130 | spawner | |
| 131 | .string(ChildProcess.make(command, args, { stdin: "ignore" }), { includeStderr: true }) | |
| 132 | .pipe( | |
| 133 | Effect.timeout("5 seconds"), | |
| 134 | Effect.orElseSucceed(() => ""), | |
| 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 | }); |
| 130 | 143 | |
| 131 | 144 | /** |
apps/server/src/preview/ServerBrowser.test.ts+254-1| ... | ... | @@ -16,6 +16,7 @@ import * as Fiber from "effect/Fiber"; |
| 16 | 16 | import * as FileSystem from "effect/FileSystem"; |
| 17 | 17 | import * as Layer from "effect/Layer"; |
| 18 | 18 | import * as Option from "effect/Option"; |
| 19 | import * as PubSub from "effect/PubSub"; | |
| 19 | 20 | import * as Queue from "effect/Queue"; |
| 20 | 21 | import * as Stream from "effect/Stream"; |
| 21 | 22 | import type { BrowserContext, Page } from "playwright-core"; |
| ... | ... | @@ -31,6 +32,7 @@ import * as PreviewBrowser from "./PreviewBrowser.ts"; |
| 31 | 32 | |
| 32 | 33 | // Keep the manager, broker, ownership, refs, and viewer paths real; replace Chromium I/O only. |
| 33 | 34 | vi.mock("./ServerBrowserContexts.ts", () => ({ |
| 35 | presentAsChrome: async () => {}, | |
| 34 | 36 | ServerBrowserContexts: class { |
| 35 | 37 | private readonly onClose: ((context: BrowserContext) => void) | undefined; |
| 36 | 38 | constructor(options: { onContextClose?: (context: BrowserContext) => void }) { |
| ... | ... | @@ -153,7 +155,7 @@ let clipboardBinding: ClipboardBinding | null = null; |
| 153 | 155 | let contextFailure: Error | null = null; |
| 154 | 156 | /** Server tabs the fake desktop renders, and the endpoints the server connected to. */ |
| 155 | 157 | let 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. */ | |
| 157 | 159 | const desktopDetaches = new NodeEvents.EventEmitter(); |
| 158 | 160 | const desktopTabs = new Set<string>(); |
| 159 | 161 | const desktopRenders = (tabId: string) => { |
| ... | ... | @@ -209,6 +211,17 @@ const dependencies = Layer.mergeAll( |
| 209 | 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 | 225 | isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), |
| 213 | 226 | endpoint: (key) => |
| 214 | 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 | 739 | ).pipe(Effect.provide(layer)), |
| 727 | 740 | ); |
| 728 | 741 | |
| 742 | it.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 | ||
| 786 | it.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 | ||
| 834 | it.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 | ||
| 878 | it.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 | ||
| 906 | it.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 | ||
| 934 | it.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 | ||
| 729 | 961 | it.live("closing a tab while a viewer is still opening it does not leave its page behind", () => |
| 730 | 962 | Effect.scoped( |
| 731 | 963 | Effect.gen(function* () { |
| ... | ... | @@ -1090,3 +1322,24 @@ it.live("a desktop page the desktop takes back reconnects instead of closing", ( |
| 1090 | 1322 | }), |
| 1091 | 1323 | ).pipe(Effect.provide(layer)), |
| 1092 | 1324 | ); |
| 1325 | ||
| 1326 | it.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 | 36 | SERVER_BROWSER_AUTOMATION_CLIENT_ID, |
| 37 | 37 | type PreviewAppearancePreference, |
| 38 | 38 | } from "@t3tools/contracts"; |
| 39 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 39 | import { | |
| 40 | HostProcessArchitecture, | |
| 41 | HostProcessEnvironment, | |
| 42 | HostProcessPlatform, | |
| 43 | } from "@t3tools/shared/hostProcess"; | |
| 40 | 44 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 41 | 45 | import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; |
| 42 | 46 | import * as NodeCrypto from "node:crypto"; |
| ... | ... | @@ -73,7 +77,7 @@ import * as PreviewManager from "./Manager.ts"; |
| 73 | 77 | import * as ServerBrowserPage from "./ServerBrowserPage.ts"; |
| 74 | 78 | import * as PreviewBrowser from "./PreviewBrowser.ts"; |
| 75 | 79 | import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; |
| 76 | import { ServerBrowserContexts } from "./ServerBrowserContexts.ts"; | |
| 80 | import { presentAsChrome, ServerBrowserContexts } from "./ServerBrowserContexts.ts"; | |
| 77 | 81 | import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts"; |
| 78 | 82 | |
| 79 | 83 | const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID; |
| ... | ... | @@ -185,6 +189,8 @@ export type ServerBrowserViewerOutput = |
| 185 | 189 | readonly accept: string; |
| 186 | 190 | } |
| 187 | 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 | 195 | readonly _tag: "download"; |
| 190 | 196 | readonly id: string; |
| ... | ... | @@ -311,6 +317,17 @@ interface ServerTab { |
| 311 | 317 | closing: boolean; |
| 312 | 318 | recording: Recording | null; |
| 313 | 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 | 331 | /** The latest queued start, so a stop can find a recording still starting. */ |
| 315 | 332 | recordingStart: Promise<Recording> | null; |
| 316 | 333 | /** Serializes captures and recording start/stop. */ |
| ... | ... | @@ -336,6 +353,8 @@ const AGENT_TAB_IDLE_MS = 30 * 60 * 1000; |
| 336 | 353 | const IDLE_SWEEP_INTERVAL = "1 minute"; |
| 337 | 354 | |
| 338 | 355 | const DOWNLOAD_LIMIT = 20; |
| 356 | /** How long an aborted navigation waits for the download that usually explains it. */ | |
| 357 | const ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS = 5_000; | |
| 339 | 358 | const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024; |
| 340 | 359 | |
| 341 | 360 | const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`; |
| ... | ... | @@ -435,6 +454,7 @@ const CLIPBOARD_SCRIPT = `(() => { |
| 435 | 454 | |
| 436 | 455 | const make = Effect.gen(function* () { |
| 437 | 456 | const config = yield* ServerConfig.ServerConfig; |
| 457 | const host = { platform: yield* HostProcessPlatform, arch: yield* HostProcessArchitecture }; | |
| 438 | 458 | const manager = yield* PreviewManager.PreviewManager; |
| 439 | 459 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 440 | 460 | const environment = yield* ServerEnvironment.ServerEnvironment; |
| ... | ... | @@ -646,6 +666,7 @@ const make = Effect.gen(function* () { |
| 646 | 666 | if (tabs.get(key) !== tab) return; |
| 647 | 667 | tabs.delete(key); |
| 648 | 668 | tab.closing = true; |
| 669 | clearAbortedNavigation(tab); | |
| 649 | 670 | // A desktop page outlives the connection unless its session closed with it. |
| 650 | 671 | const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; |
| 651 | 672 | for (const viewer of tab.viewers) viewer.push({ _tag: end }); |
| ... | ... | @@ -723,6 +744,9 @@ const make = Effect.gen(function* () { |
| 723 | 744 | if (!desktop) await prepareContext(context); |
| 724 | 745 | const page = adopted?.page ?? desktop?.page ?? (await context.newPage()); |
| 725 | 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 | 750 | page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS); |
| 727 | 751 | page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS); |
| 728 | 752 | const control = new SessionControl(snapshot.automationOwner ?? null, () => |
| ... | ... | @@ -755,6 +779,8 @@ const make = Effect.gen(function* () { |
| 755 | 779 | recording: null, |
| 756 | 780 | recordingStart: null, |
| 757 | 781 | initialNavigation: null, |
| 782 | navigationGeneration: 0, | |
| 783 | abortedNavigation: null, | |
| 758 | 784 | captureLock: Promise.resolve(), |
| 759 | 785 | capturing: 0, |
| 760 | 786 | }; |
| ... | ... | @@ -764,8 +790,11 @@ const make = Effect.gen(function* () { |
| 764 | 790 | await applyRendering(tab, snapshot); |
| 765 | 791 | const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => |
| 766 | 792 | request.isNavigationRequest() && request.frame() === page.mainFrame(); |
| 793 | const navigationGenerations = new WeakMap<object, number>(); | |
| 767 | 794 | page.on("request", (request) => { |
| 768 | 795 | if (!isMainNavigation(request)) return; |
| 796 | navigationGenerations.set(request, ++tab.navigationGeneration); | |
| 797 | clearAbortedNavigation(tab); | |
| 769 | 798 | tab.loading = true; |
| 770 | 799 | report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" }); |
| 771 | 800 | }); |
| ... | ... | @@ -787,7 +816,28 @@ const make = Effect.gen(function* () { |
| 787 | 816 | errorText, |
| 788 | 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 | 841 | tab.loading = false; |
| 792 | 842 | const { code, description } = ServerBrowserPage.parseNetError(errorText); |
| 793 | 843 | report(tab, { |
| ... | ... | @@ -854,9 +904,46 @@ const make = Effect.gen(function* () { |
| 854 | 904 | NodeCrypto.createHash("sha256").update(tabKey(tab.threadId, tab.tabId)).digest("hex"), |
| 855 | 905 | ); |
| 856 | 906 | |
| 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 | 940 | const saveDownload = async (tab: ServerTab, download: Download) => { |
| 858 | 941 | const id = NodeCrypto.randomUUID(); |
| 859 | 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 | 947 | try { |
| 861 | 948 | if (await download.failure()) return; |
| 862 | 949 | await NodeFSP.mkdir(downloadDir(tab), { recursive: true }); |
| ... | ... | @@ -878,6 +965,12 @@ const make = Effect.gen(function* () { |
| 878 | 965 | for (const evicted of tab.downloads.splice(0, tab.downloads.length - DOWNLOAD_LIMIT)) { |
| 879 | 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 | 974 | // Only the person driving the page gets the file offered; agents read it from status. |
| 882 | 975 | const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller); |
| 883 | 976 | controller?.push({ |
| ... | ... | @@ -889,6 +982,10 @@ const make = Effect.gen(function* () { |
| 889 | 982 | } catch (cause) { |
| 890 | 983 | await NodeFSP.rm(path, { force: true }).catch(constVoid); |
| 891 | 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 | }; |
| 894 | 991 | |
| ... | ... | @@ -1009,6 +1106,10 @@ const make = Effect.gen(function* () { |
| 1009 | 1106 | return; |
| 1010 | 1107 | } |
| 1011 | 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 | 1113 | await Effect.runPromise( |
| 1013 | 1114 | manager.open({ |
| 1014 | 1115 | threadId: opener.threadId, |
| ... | ... | @@ -1025,9 +1126,16 @@ const make = Effect.gen(function* () { |
| 1025 | 1126 | openerTabId: opener.tabId, |
| 1026 | 1127 | }), |
| 1027 | 1128 | }), |
| 1028 | ).catch(async () => { | |
| 1029 | await popup.close().catch(constVoid); | |
| 1030 | }); | |
| 1129 | ).then( | |
| 1130 | (snapshot) => { | |
| 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 | }; |
| 1032 | 1140 | |
| 1033 | 1141 | const ensureTab = (snapshot: PreviewSessionSnapshot): Promise<ServerTab> => { |
| ... | ... | @@ -1187,6 +1295,12 @@ const make = Effect.gen(function* () { |
| 1187 | 1295 | } |
| 1188 | 1296 | await navigation.catch((cause: unknown) => { |
| 1189 | 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 | 1304 | if (/ERR_[A-Z_]+/.test(message)) { |
| 1191 | 1305 | throw new ServerBrowserPage.ServerBrowserOperationError( |
| 1192 | 1306 | "PreviewAutomationExecutionError", |
| ... | ... | @@ -1693,7 +1807,11 @@ const make = Effect.gen(function* () { |
| 1693 | 1807 | ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput), |
| 1694 | 1808 | ); |
| 1695 | 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 | 1815 | case "waitFor": |
| 1698 | 1816 | return ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput); |
| 1699 | 1817 | case "recordingStart": { |
| ... | ... | @@ -2142,6 +2260,22 @@ const make = Effect.gen(function* () { |
| 2142 | 2260 | ), |
| 2143 | 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 | 2279 | yield* Effect.sync(closeIdleAgentTabs).pipe( |
| 2146 | 2280 | Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)), |
| 2147 | 2281 | Effect.forkScoped, |
apps/server/src/preview/ServerBrowserContexts.ts+64-2| ... | ... | @@ -3,8 +3,9 @@ import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; |
| 3 | 3 | import { constVoid } from "effect/Function"; |
| 4 | 4 | import * as NodeFSP from "node:fs/promises"; |
| 5 | 5 | import * as NodeModule from "node:module"; |
| 6 | import * as NodeOS from "node:os"; | |
| 6 | 7 | import * as NodePath from "node:path"; |
| 7 | import type { Browser, BrowserContext } from "playwright-core"; | |
| 8 | import type { Browser, BrowserContext, CDPSession } from "playwright-core"; | |
| 8 | 9 | |
| 9 | 10 | import { sandboxDisabled } from "./PreviewBrowserHost.ts"; |
| 10 | 11 | |
| ... | ... | @@ -23,6 +24,61 @@ interface Options { |
| 23 | 24 | readonly onContextClose?: (context: BrowserContext) => void; |
| 24 | 25 | } |
| 25 | 26 | |
| 27 | const 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 | */ | |
| 38 | const 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 | */ | |
| 73 | export 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 | 82 | /** Persistent human profiles keep their storage; isolated agents share a browser, never a context. */ |
| 27 | 83 | export class ServerBrowserContexts { |
| 28 | 84 | private readonly options: Options; |
| ... | ... | @@ -42,7 +98,13 @@ export class ServerBrowserContexts { |
| 42 | 98 | return { |
| 43 | 99 | executablePath, |
| 44 | 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 | 108 | headless: true, |
| 47 | 109 | // Only an explicit operator opt-out disables sandboxing. Launch errors never do. |
| 48 | 110 | chromiumSandbox: !sandboxDisabled(env), |
apps/server/src/preview/ServerBrowserPage.test.ts+31| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | } from "playwright-core"; |
| 8 | 8 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; |
| 9 | 9 | |
| 10 | import { presentAsChrome } from "./ServerBrowserContexts.ts"; | |
| 10 | 11 | import * as ServerBrowserPage from "./ServerBrowserPage.ts"; |
| 11 | 12 | |
| 12 | 13 | describe("server browser element refs", () => { |
| ... | ... | @@ -245,4 +246,34 @@ describe("server browser element refs", () => { |
| 245 | 246 | ]); |
| 246 | 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 | 410 | await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta); |
| 411 | 411 | }; |
| 412 | 412 | |
| 413 | export const evaluate = async (cdp: CDPSession, input: PreviewAutomationEvaluateInput) => { | |
| 414 | const result = await cdp.send("Runtime.evaluate", { | |
| 413 | /** | |
| 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 | */ | |
| 418 | export 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 | 437 | expression: input.expression, |
| 416 | 438 | awaitPromise: input.awaitPromise ?? true, |
| 417 | 439 | returnByValue: input.returnByValue ?? true, |
| 418 | 440 | }); |
| 441 | void evaluation.catch(constVoid); | |
| 442 | const result = await Promise.race([evaluation, expired]).finally(() => clearTimeout(timer)); | |
| 419 | 443 | if (result.exceptionDetails) { |
| 420 | 444 | throw new ServerBrowserOperationError( |
| 421 | 445 | "PreviewAutomationExecutionError", |
apps/server/src/preview/ServerBrowserStream.ts+1| ... | ... | @@ -156,6 +156,7 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => |
| 156 | 156 | case "download": |
| 157 | 157 | case "fileChooser": |
| 158 | 158 | case "fileChooserClosed": |
| 159 | case "popup": | |
| 159 | 160 | case "pointer": |
| 160 | 161 | case "probe": { |
| 161 | 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 | 197 | assert.lengthOf(partialEvents, 1); |
| 198 | 198 | assert.equal(partialEvents[0]?.stream_id, firstThreadId); |
| 199 | 199 | assert.equal(partialEvents[0]?.event_type, "thread.deleted"); |
| 200 | assert.lengthOf(partialCleanup, 1); | |
| 201 | assert.equal(partialCleanup[0]?.thread_id, firstThreadId); | |
| 202 | assert.equal(partialCleanup[0]?.effect_type, "terminal.cleanup"); | |
| 200 | assert.deepEqual( | |
| 201 | partialCleanup.map((effect) => [effect.thread_id, effect.effect_type]), | |
| 202 | [ | |
| 203 | [firstThreadId, "preview.cleanup"], | |
| 204 | [firstThreadId, "terminal.cleanup"], | |
| 205 | ], | |
| 206 | ); | |
| 203 | 207 | |
| 204 | 208 | const deletedProject = yield* service.delete(input); |
| 205 | 209 | assert.isNotNull(deletedProject.deletedAt); |
| ... | ... | @@ -220,7 +224,7 @@ it.effect("retries a partial project deletion without repeating child events or |
| 220 | 224 | assert.deepEqual(finalEvents[0], partialEvents[0]); |
| 221 | 225 | assert.equal(finalEvents[2]?.command_id, commandId); |
| 222 | 226 | const finalCleanup = yield* readCleanup; |
| 223 | assert.lengthOf(finalCleanup, 2); | |
| 227 | assert.lengthOf(finalCleanup, 4); | |
| 224 | 228 | assert.deepEqual( |
| 225 | 229 | finalCleanup.filter((effect) => effect.thread_id === firstThreadId), |
| 226 | 230 | partialCleanup, |
| ... | ... | @@ -230,6 +234,12 @@ it.effect("retries a partial project deletion without repeating child events or |
| 230 | 234 | assert.deepEqual( |
| 231 | 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 | 244 | effect_id: `effect:${expectedCommandId}:terminal.cleanup`, |
| 235 | 245 | thread_id: threadId, |
apps/web/src/browser/ServerBrowserSurface.tsx+5| ... | ... | @@ -153,6 +153,8 @@ export function ServerBrowserSurface(props: { |
| 153 | 153 | readonly onFirstFrame?: () => void; |
| 154 | 154 | readonly onViewport?: (viewport: PreviewStreamViewport) => void; |
| 155 | 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 | 158 | readonly className?: string; |
| 157 | 159 | readonly ref?: Ref<ServerBrowserHandle>; |
| 158 | 160 | }) { |
| ... | ... | @@ -166,6 +168,7 @@ export function ServerBrowserSurface(props: { |
| 166 | 168 | onFirstFrame, |
| 167 | 169 | onViewport, |
| 168 | 170 | onControl, |
| 171 | onPopup, | |
| 169 | 172 | className, |
| 170 | 173 | ref, |
| 171 | 174 | } = props; |
| ... | ... | @@ -218,6 +221,7 @@ export function ServerBrowserSurface(props: { |
| 218 | 221 | onViewport?.(viewport), |
| 219 | 222 | ); |
| 220 | 223 | const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next)); |
| 224 | const popupOpened = useEffectEvent((popupTabId: string) => onPopup?.(popupTabId)); | |
| 221 | 225 | // Frame cap in device px, fixed per socket. It grows with the surface and |
| 222 | 226 | // never shrinks, so only outgrowing it reconnects. |
| 223 | 227 | const [cap, setCap] = useState<{ width: number; height: number } | null>(null); |
| ... | ... | @@ -397,6 +401,7 @@ export function ServerBrowserSurface(props: { |
| 397 | 401 | }, |
| 398 | 402 | onClipboard: (text) => void copyPageText(text), |
| 399 | 403 | onDownload: offerDownload, |
| 404 | onPopup: popupOpened, | |
| 400 | 405 | onFileChooser: setFileChooser, |
| 401 | 406 | onViewport: (viewport) => { |
| 402 | 407 | viewportRef.current = viewport; |
apps/web/src/components/preview/PreviewUnreachable.tsx+44| ... | ... | @@ -13,6 +13,50 @@ interface Props { |
| 13 | 13 | onReload: () => void; |
| 14 | 14 | } |
| 15 | 15 | |
| 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 | */ | |
| 21 | export 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 | 60 | /** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */ |
| 17 | 61 | export function PreviewUnreachable({ url, code, description, onReload }: Props) { |
| 18 | 62 | const [showDetails, setShowDetails] = useState(false); |
apps/web/src/components/preview/PreviewView.tsx+45-7| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 4 | import { previewStreamDownloadUrl } from "@t3tools/client-runtime/preview/server-browser-stream"; | |
| 4 | 5 | import { |
| 5 | 6 | isAtomCommandInterrupted, |
| 6 | 7 | squashAtomCommandFailure, |
| ... | ... | @@ -16,7 +17,7 @@ import { |
| 16 | 17 | PREVIEW_ZOOM_LEVELS, |
| 17 | 18 | type PreviewAdjustInput, |
| 18 | 19 | } from "@t3tools/contracts"; |
| 19 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | |
| 20 | import { normalizePreviewUrl, resolveAddressBarInput } from "@t3tools/shared/preview"; | |
| 20 | 21 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 21 | 22 | |
| 22 | 23 | import { |
| ... | ... | @@ -37,6 +38,7 @@ import { |
| 37 | 38 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 38 | 39 | import { useEnvironmentHttpBaseUrl } from "~/state/environments"; |
| 39 | 40 | import { previewEnvironment } from "~/state/preview"; |
| 41 | import { usePreviewStreamAccess } from "~/state/previewStream"; | |
| 40 | 42 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 41 | 43 | import { |
| 42 | 44 | browserMiniPlayerSource, |
| ... | ... | @@ -49,6 +51,7 @@ import { useRightPanelStore } from "~/rightPanelStore"; |
| 49 | 51 | import { previewBridge } from "./previewBridge"; |
| 50 | 52 | import { subscribePreviewAction } from "./previewActionBus"; |
| 51 | 53 | import { openPreviewSession } from "./openPreviewSession"; |
| 54 | import { showPreviewPopup } from "./showPreviewPopup"; | |
| 52 | 55 | import { PreviewChromeRow } from "./PreviewChromeRow"; |
| 53 | 56 | import { PreviewEmptyState } from "./PreviewEmptyState"; |
| 54 | 57 | import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu"; |
| ... | ... | @@ -61,7 +64,7 @@ import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar"; |
| 61 | 64 | import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout"; |
| 62 | 65 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; |
| 63 | 66 | import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; |
| 64 | import { PreviewUnreachable } from "./PreviewUnreachable"; | |
| 67 | import { PreviewFileNotShown, PreviewUnreachable } from "./PreviewUnreachable"; | |
| 65 | 68 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; |
| 66 | 69 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; |
| 67 | 70 | import { Badge } from "~/components/ui/badge"; |
| ... | ... | @@ -103,6 +106,31 @@ function previewProfileName( |
| 103 | 106 | |
| 104 | 107 | const localApi = typeof window === "undefined" ? null : ensureLocalApi(); |
| 105 | 108 | |
| 109 | /** Resolves stream access only while a server tab shows a file it downloaded. */ | |
| 110 | function 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 | 135 | * Single-tab preview surface: chrome row on top, one webview below, empty |
| 108 | 136 | * state when no session exists for the thread. |
| ... | ... | @@ -259,12 +287,12 @@ export function PreviewView({ |
| 259 | 287 | const handleSubmitUrl = useCallback( |
| 260 | 288 | async (next: string) => { |
| 261 | 289 | try { |
| 262 | const normalized = normalizePreviewUrl(next); | |
| 263 | if (await navigateToResolvedUrl(normalized)) { | |
| 264 | recordVisitForThread(threadRef, normalized); | |
| 290 | const resolved = resolveAddressBarInput(next); | |
| 291 | if (await navigateToResolvedUrl(resolved)) { | |
| 292 | recordVisitForThread(threadRef, resolved); | |
| 265 | 293 | } |
| 266 | 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 | 298 | [navigateToResolvedUrl, threadRef], |
| ... | ... | @@ -995,6 +1023,7 @@ export function PreviewView({ |
| 995 | 1023 | onControl={(control) => |
| 996 | 1024 | setServerControlledTabId(control?.controller === "you" ? runtimeTabId : null) |
| 997 | 1025 | } |
| 1026 | onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "panel")} | |
| 998 | 1027 | // Stays connected under the empty state so a URL picked there reaches the page. |
| 999 | 1028 | className={cn( |
| 1000 | 1029 | "absolute inset-0 h-full w-full", |
| ... | ... | @@ -1043,7 +1072,16 @@ export function PreviewView({ |
| 1043 | 1072 | controller={controller} |
| 1044 | 1073 | /> |
| 1045 | 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 | 1085 | <div className="absolute inset-0 z-10 bg-background"> |
| 1048 | 1086 | <PreviewUnreachable |
| 1049 | 1087 | url={navStatus.url} |
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+2| ... | ... | @@ -49,6 +49,7 @@ import { DeviceStreamView } from "../device/DeviceStreamView"; |
| 49 | 49 | import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream"; |
| 50 | 50 | import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream"; |
| 51 | 51 | import { previewBridge } from "./previewBridge"; |
| 52 | import { showPreviewPopup } from "./showPreviewPopup"; | |
| 52 | 53 | import { |
| 53 | 54 | clampPreviewMiniPlayerPosition, |
| 54 | 55 | NO_PREVIEW_MINI_PLAYER_OBSTACLES, |
| ... | ... | @@ -226,6 +227,7 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly |
| 226 | 227 | followSize={false} |
| 227 | 228 | controlPosition="bottom" |
| 228 | 229 | onViewport={setStreamViewport} |
| 230 | onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "floating")} | |
| 229 | 231 | className="size-full" |
| 230 | 232 | /> |
| 231 | 233 | </div> |
apps/web/src/components/preview/showPreviewPopup.ts created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | |
| 4 | import { whenPreviewTabKnown } from "~/previewStateStore"; | |
| 5 | import { 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 | */ | |
| 12 | export 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 | 50 | readThreadShells, |
| 51 | 51 | } from "../state/entities"; |
| 52 | 52 | import { useUiStateStore } from "../uiStateStore"; |
| 53 | import { clearThreadPreviewState } from "../previewStateStore"; | |
| 53 | 54 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 54 | 55 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; |
| 55 | 56 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| ... | ... | @@ -449,6 +450,7 @@ export function useThreadActions() { |
| 449 | 450 | }); |
| 450 | 451 | if (result._tag === "Success") { |
| 451 | 452 | refreshArchivedThreadsForEnvironment(target.environmentId); |
| 453 | clearThreadPreviewState(target); | |
| 452 | 454 | } |
| 453 | 455 | return result; |
| 454 | 456 | } |
| ... | ... | @@ -555,6 +557,7 @@ export function useThreadActions() { |
| 555 | 557 | threadRef, |
| 556 | 558 | ); |
| 557 | 559 | clearTerminalUiState(threadRef); |
| 560 | clearThreadPreviewState(threadRef); | |
| 558 | 561 | |
| 559 | 562 | if (shouldNavigateToFallback) { |
| 560 | 563 | const fallbackThread = fallbackThreadId |
apps/web/src/previewStateStore.ts+40| ... | ... | @@ -214,6 +214,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven |
| 214 | 214 | title: event.title, |
| 215 | 215 | code: event.code, |
| 216 | 216 | description: event.description, |
| 217 | ...(event.download === undefined ? {} : { download: event.download }), | |
| 217 | 218 | }, |
| 218 | 219 | updatedAt: event.createdAt, |
| 219 | 220 | }; |
| ... | ... | @@ -457,6 +458,35 @@ export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void { |
| 457 | 458 | }); |
| 458 | 459 | } |
| 459 | 460 | |
| 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 | */ | |
| 466 | export 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 | ||
| 460 | 490 | export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void { |
| 461 | 491 | if (url.trim().length === 0) return; |
| 462 | 492 | updateThreadPreviewState(ref, (current) => ({ |
| ... | ... | @@ -470,6 +500,16 @@ export function isPreviewSupportedInRuntime(): boolean { |
| 470 | 500 | return Boolean(window.desktopBridge?.preview); |
| 471 | 501 | } |
| 472 | 502 | |
| 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 | */ | |
| 507 | export function clearThreadPreviewState(ref: ScopedThreadRef): void { | |
| 508 | updateThreadPreviewState(ref, (current) => | |
| 509 | Object.keys(current.sessions).length === 0 ? current : EMPTY_THREAD_PREVIEW_STATE, | |
| 510 | ); | |
| 511 | } | |
| 512 | ||
| 473 | 513 | export function resetPreviewStateForTests(): void { |
| 474 | 514 | for (const threadKey of changedPreviewThreadKeys) { |
| 475 | 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 | 139 | if (!response.ok) throw new Error((await response.text()) || "The upload was refused."); |
| 140 | 140 | } |
| 141 | 141 | |
| 142 | const previewStreamDownloadUrl = ( | |
| 142 | /** A file a server tab downloaded, served with the stream's own access. */ | |
| 143 | export const previewStreamDownloadUrl = ( | |
| 143 | 144 | target: Pick<PreviewStreamTarget, "access" | "threadId" | "tabId">, |
| 144 | 145 | id: string, |
| 145 | 146 | ): string => |
| ... | ... | @@ -202,6 +203,8 @@ export interface PreviewStreamEvents { |
| 202 | 203 | readonly onClipboard?: (text: string) => void; |
| 203 | 204 | /** A file the page downloaded while this viewer had control. */ |
| 204 | 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 | 208 | /** The page opened a file picker (`null` once answered or replaced). */ |
| 206 | 209 | readonly onFileChooser?: (chooser: PreviewStreamFileChooser | null) => void; |
| 207 | 210 | /** Input sent while disconnected is dropped. */ |
| ... | ... | @@ -286,6 +289,7 @@ export function createPreviewStreamClient( |
| 286 | 289 | accept, |
| 287 | 290 | phase, |
| 288 | 291 | sequence, |
| 292 | tabId, | |
| 289 | 293 | } = message as Record<string, unknown>; |
| 290 | 294 | if ( |
| 291 | 295 | type === "fileChooser" && |
| ... | ... | @@ -312,6 +316,8 @@ export function createPreviewStreamClient( |
| 312 | 316 | typeof sequence === "number" |
| 313 | 317 | ) { |
| 314 | 318 | events.onPointer?.({ phase, x, y, sequence }); |
| 319 | } else if (type === "popup" && typeof tabId === "string") { | |
| 320 | events.onPopup?.(tabId); | |
| 315 | 321 | } else if (type === "clipboard" && typeof text === "string") { |
| 316 | 322 | events.onClipboard?.(text); |
| 317 | 323 | } else if ( |
packages/contracts/src/preview.ts+10| ... | ... | @@ -143,6 +143,9 @@ export type PreviewAppearancePreference = typeof PreviewAppearancePreference.Typ |
| 143 | 143 | |
| 144 | 144 | export const DEFAULT_PREVIEW_APPEARANCE: PreviewAppearancePreference = "system"; |
| 145 | 145 | |
| 146 | /** A file a server tab downloaded, served by the tab's download route by this id. */ | |
| 147 | const PreviewFileDownload = Schema.Struct({ id: Schema.String, fileName: Schema.String }); | |
| 148 | ||
| 146 | 149 | export const PreviewNavStatus = Schema.Union([ |
| 147 | 150 | Schema.TaggedStruct("Idle", {}), |
| 148 | 151 | Schema.TaggedStruct("Loading", { |
| ... | ... | @@ -158,6 +161,12 @@ export const PreviewNavStatus = Schema.Union([ |
| 158 | 161 | title: Title, |
| 159 | 162 | code: Schema.Int, |
| 160 | 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 | ]); |
| 163 | 172 | export type PreviewNavStatus = typeof PreviewNavStatus.Type; |
| ... | ... | @@ -347,6 +356,7 @@ const PreviewFailedEvent = Schema.Struct({ |
| 347 | 356 | title: Title, |
| 348 | 357 | code: Schema.Int, |
| 349 | 358 | description: Schema.String, |
| 359 | download: Schema.optional(PreviewFileDownload), | |
| 350 | 360 | }); |
| 351 | 361 | |
| 352 | 362 | const PreviewClosedEvent = Schema.Struct({ |
packages/shared/src/preview.test.ts+50| ... | ... | @@ -5,8 +5,58 @@ import { |
| 5 | 5 | newPreviewTabId, |
| 6 | 6 | normalizePreviewUrl, |
| 7 | 7 | PreviewUrlNormalizationError, |
| 8 | resolveAddressBarInput, | |
| 8 | 9 | } from "./preview.ts"; |
| 9 | 10 | |
| 11 | describe("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 | ||
| 10 | 60 | describe("newPreviewTabId", () => { |
| 11 | 61 | it("returns a unique tab id every call", () => { |
| 12 | 62 | const a = newPreviewTabId(); |
packages/shared/src/preview.ts+68| ... | ... | @@ -57,6 +57,74 @@ function previewUrlProtocol(rawUrl: string): string | undefined { |
| 57 | 57 | return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":"); |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | const SEARCH_URL = "https://duckduckgo.com/?q="; | |
| 61 | /** A bare host that is always an address: localhost, an IPv4 literal, or a bracketed IPv6 one. */ | |
| 62 | const ADDRESS_HOST_PATTERN = /^(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-f:.]+\])$/i; | |
| 63 | const 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 | */ | |
| 68 | const 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 | */ | |
| 92 | export 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 | 129 | * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. |
| 62 | 130 | * |