Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,202 changes: 2,006 additions & 196 deletions apps/desktop/src/preview/Manager.test.ts

Large diffs are not rendered by default.

755 changes: 708 additions & 47 deletions apps/desktop/src/preview/Manager.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions apps/web/src/browser/browserTargetResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ vi.mock("~/state/session", () => ({ readPreparedConnection }));
describe("browser target resolver", () => {
beforeEach(() => readPreparedConnection.mockReset());

it.each([
"0.0.0.0",
"devbox",
"devbox.localhost",
"::",
"::ffff:192.168.1.20",
"[::ffff:c0a8:114]",
])("treats %s as a private network host", async (host) => {
const { isPrivateNetworkHost } = await import("./browserTargetResolver");
expect(isPrivateNetworkHost(host)).toBe(true);
});

it("does not treat a public IPv4-mapped IPv6 address as private", async () => {
const { isPrivateNetworkHost } = await import("./browserTargetResolver");
expect(isPrivateNetworkHost("::ffff:808:808")).toBe(false);
});

it("maps environment ports onto a private network host", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" });
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
Expand Down
44 changes: 32 additions & 12 deletions apps/web/src/browser/browserTargetResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,48 @@ const parseIpv4Address = (host: string): readonly number[] | null => {
: null;
};

const parseIpv4MappedIpv6Address = (host: string): readonly number[] | null => {
const normalized = normalizeHostname(host);
if (!normalized.startsWith("::ffff:")) return null;
const suffix = normalized.slice("::ffff:".length);
const dotted = parseIpv4Address(suffix);
if (dotted) return dotted;
const hextets = suffix.split(":");
if (hextets.length !== 2 || hextets.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null;
const high = Number.parseInt(hextets[0]!, 16);
const low = Number.parseInt(hextets[1]!, 16);
return [high >>> 8, high & 0xff, low >>> 8, low & 0xff];
};

const isPrivateIpv4Address = (parts: readonly number[]): boolean =>
parts[0] === 0 ||
parts[0] === 10 ||
parts[0] === 127 ||
(parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) ||
(parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
(parts[0] === 169 && parts[1] === 254);

export const isLocalLoopbackHost = (host: string): boolean => {
const normalized = normalizeHostname(host);
if (normalized === "localhost" || normalized === "::1") return true;
return parseIpv4Address(normalized)?.[0] === 127;
};

const isPrivateNetworkHost = (host: string): boolean => {
export const isPrivateNetworkHost = (host: string): boolean => {
const normalized = normalizeHostname(host);
if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) {
if (
normalized === "::" ||
isLocalLoopbackHost(normalized) ||
normalized.endsWith(".localhost") ||
normalized.endsWith(".local") ||
(!normalized.includes(".") && !normalized.includes(":"))
) {
return true;
}
if (normalized.endsWith(".ts.net")) return true;
const parts = parseIpv4Address(normalized);
if (parts) {
return (
parts[0] === 10 ||
(parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) ||
(parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
(parts[0] === 169 && parts[1] === 254)
);
}
const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
if (parts) return isPrivateIpv4Address(parts);
const firstIpv6Token = normalized.split(":", 1)[0] ?? "";
if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false;
const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16);
Expand Down
125 changes: 125 additions & 0 deletions apps/web/src/browserFaviconLogic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vite-plus/test";

import {
BROWSER_FAVICON_MAX_ENTRIES,
type BrowserFaviconEntry,
evictExcessFavicons,
faviconKey,
isStorableFaviconDataUrl,
migratePersistedBrowserFaviconState,
} from "./browserFaviconLogic";

function entry(updatedAt = 0): BrowserFaviconEntry {
return { dataUrl: "data:image/png;base64,AAAA", updatedAt };
}

describe("faviconKey", () => {
it("combines project key with the canonical origin", () => {
expect(faviconKey("proj-a", "http://myapp.test:3000/admin?x=1", null)).toBe(
"proj-a http://myapp.test:3000",
);
});

it("collapses loopback hosts and the environment host to the same key", () => {
const viaResolvedHost = faviconKey("proj-a", "http://192.168.64.2:3000/", "192.168.64.2");
const viaLocalhost = faviconKey("proj-a", "http://localhost:3000/", "192.168.64.2");
expect(viaResolvedHost).not.toBeNull();
expect(viaResolvedHost).toBe(viaLocalhost);
});

it("collapses loopback even when there is no connected environment", () => {
const a = faviconKey("proj-a", "http://localhost:3000/", null);
const b = faviconKey("proj-a", "http://127.0.0.1:3000/", null);
expect(a).not.toBeNull();
expect(a).toBe(b);
});

it("does not collapse an unrelated remote host or a different LAN device", () => {
const remote = faviconKey("proj-a", "http://example.com:3000/", "192.168.64.2");
const otherLan = faviconKey("proj-a", "http://192.168.1.50:3000/", "192.168.64.2");
const local = faviconKey("proj-a", "http://localhost:3000/", "192.168.64.2");
expect(remote).not.toBeNull();
expect(otherLan).not.toBeNull();
expect(remote).not.toBe(otherLan);
expect(remote).not.toBe(local);
expect(otherLan).not.toBe(local);
});

it("separates ports, schemes, and projects", () => {
expect(faviconKey("proj-a", "http://localhost:3000/", null)).not.toBe(
faviconKey("proj-a", "http://localhost:5173/", null),
);
expect(faviconKey("proj-a", "http://myapp.test/", null)).not.toBe(
faviconKey("proj-a", "https://myapp.test/", null),
);
expect(faviconKey("proj-a", "http://localhost:3000/", null)).not.toBe(
faviconKey("proj-b", "http://localhost:3000/", null),
);
});

it("rejects non-http(s) and unparseable urls", () => {
expect(faviconKey("proj-a", "ftp://example.com/", null)).toBeNull();
expect(faviconKey("proj-a", "not a url", null)).toBeNull();
expect(faviconKey("", "http://localhost:3000/", null)).toBeNull();
});
});

describe("isStorableFaviconDataUrl", () => {
it("accepts image data urls within the cap", () => {
expect(isStorableFaviconDataUrl("data:image/png;base64,AAAA")).toBe(true);
expect(isStorableFaviconDataUrl("data:image/svg+xml;base64,AAAA")).toBe(true);
expect(isStorableFaviconDataUrl("data:image/x-icon;base64,AAAA")).toBe(true);
});

it("rejects non-image data urls, other schemes, and oversized values", () => {
expect(isStorableFaviconDataUrl("data:text/html;base64,AAAA")).toBe(false);
expect(isStorableFaviconDataUrl("data:image/svg+xml,<svg></svg>")).toBe(false);
expect(isStorableFaviconDataUrl("http://example.com/favicon.ico")).toBe(false);
expect(isStorableFaviconDataUrl(42)).toBe(false);
expect(isStorableFaviconDataUrl(`data:image/png;base64,${"A".repeat(8192)}`)).toBe(false);
});

it("rejects data urls with no payload", () => {
expect(isStorableFaviconDataUrl("data:image/x-icon;base64,")).toBe(false);
expect(isStorableFaviconDataUrl("data:image/png;base64,")).toBe(false);
expect(isStorableFaviconDataUrl("data:image/png;base64, ")).toBe(false);
expect(isStorableFaviconDataUrl("data:image/png;base64,%%%%")).toBe(false);
expect(isStorableFaviconDataUrl("data:image/png;base64,AAA\n")).toBe(false);
expect(isStorableFaviconDataUrl("data:image/png")).toBe(false);
});
});

describe("evictExcessFavicons", () => {
it("keeps the most recently updated entries when over the cap", () => {
const byKey = Object.fromEntries(
Array.from({ length: BROWSER_FAVICON_MAX_ENTRIES + 2 }, (_, i) => [`k-${i}`, entry(i)]),
);
const next = evictExcessFavicons(byKey);
expect(Object.keys(next)).toHaveLength(BROWSER_FAVICON_MAX_ENTRIES);
expect(next["k-0"]).toBeUndefined();
expect(next["k-1"]).toBeUndefined();
expect(next[`k-${BROWSER_FAVICON_MAX_ENTRIES + 1}`]).toBeDefined();
});
});

describe("migratePersistedBrowserFaviconState", () => {
it("returns empty state for junk payloads", () => {
expect(migratePersistedBrowserFaviconState(null)).toEqual({ byKey: {} });
expect(migratePersistedBrowserFaviconState("nope")).toEqual({ byKey: {} });
expect(migratePersistedBrowserFaviconState({ byKey: 42 })).toEqual({ byKey: {} });
});

it("drops entries with invalid data urls or timestamps", () => {
const migrated = migratePersistedBrowserFaviconState({
byKey: {
good: { dataUrl: "data:image/png;base64,AAAA", updatedAt: 10 },
badScheme: { dataUrl: "http://example.com/i.ico", updatedAt: 10 },
badTime: { dataUrl: "data:image/png;base64,AAAA", updatedAt: Number.NaN },
notAnObject: "junk",
},
});
expect(migrated.byKey).toEqual({
good: { dataUrl: "data:image/png;base64,AAAA", updatedAt: 10 },
});
});
});
77 changes: 77 additions & 0 deletions apps/web/src/browserFaviconLogic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts";

import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver";

export type BrowserFaviconEntry = { dataUrl: string; updatedAt: number };

export const BROWSER_FAVICON_MAX_DATA_URL_LENGTH = FAVICON_DATA_URL_MAX_LENGTH;
export const BROWSER_FAVICON_MAX_ENTRIES = 40;

export function faviconKey(
projectRefKey: string,
url: string,
environmentHostname: string | null,
): string | null {
if (projectRefKey.length === 0) return null;
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
const host = normalizeHostname(parsed.hostname);
const collapsesToLocal =
isLocalLoopbackHost(host) ||
host === "0.0.0.0" ||
(environmentHostname !== null && host === normalizeHostname(environmentHostname));
const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
const canonicalHost = collapsesToLocal ? "local" : host;
return `${projectRefKey} ${parsed.protocol}//${canonicalHost}:${port}`;
Comment thread
cursor[bot] marked this conversation as resolved.
} catch {
return null;
}
}

export function isStorableFaviconDataUrl(value: unknown): value is string {
if (
typeof value !== "string" ||
!/^data:image\/[a-z0-9.+-]+;base64,/i.test(value) ||
value.length > BROWSER_FAVICON_MAX_DATA_URL_LENGTH
) {
return false;
}
const commaIndex = value.indexOf(",");
if (commaIndex === -1) return false;
const payload = value.slice(commaIndex + 1);
return (
payload.length > 0 &&
payload.length % 4 !== 1 &&
!/[^a-z0-9+/=]/i.test(payload) &&
/^[a-z0-9+/]*={0,2}$/i.test(payload)
);
}

export function evictExcessFavicons(
byKey: Record<string, BrowserFaviconEntry>,
): Record<string, BrowserFaviconEntry> {
const keys = Object.keys(byKey);
if (keys.length <= BROWSER_FAVICON_MAX_ENTRIES) return byKey;
const kept = keys
.toSorted((a, b) => (byKey[b]?.updatedAt ?? 0) - (byKey[a]?.updatedAt ?? 0))
.slice(0, BROWSER_FAVICON_MAX_ENTRIES);
return Object.fromEntries(kept.map((key) => [key, byKey[key] as BrowserFaviconEntry]));
}

export function migratePersistedBrowserFaviconState(persistedState: unknown): {
byKey: Record<string, BrowserFaviconEntry>;
} {
if (!persistedState || typeof persistedState !== "object") return { byKey: {} };
const raw = "byKey" in persistedState ? (persistedState as { byKey?: unknown }).byKey : null;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byKey: {} };
const byKey: Record<string, BrowserFaviconEntry> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (!value || typeof value !== "object") continue;
const { dataUrl, updatedAt } = value as Record<string, unknown>;
if (!isStorableFaviconDataUrl(dataUrl)) continue;
if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt)) continue;
byKey[key] = { dataUrl, updatedAt };
}
return { byKey: evictExcessFavicons(byKey) };
}
Loading
Loading