authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-27 13:50:11-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-27 15:21:31-08:00
logc7ae6b9471ab273e5b0c0ee700ae5c77ef69c330
tree8ef7074810a9a0dc04350d7306b7ad9036378b9c
parentbbcf9ba9db15ad02d98c417e3f5566ff2dedf4b7

Favicons + custom port during dev


8 files changed, 263 insertions(+), 7 deletions(-)

.test-favicon.mjs created+54
......@@ -0,0 +1,54 @@
1import path from "path";
2import fs from "fs";
3
4const FAVICON_CANDIDATES = [
5 "favicon.svg", "favicon.ico", "favicon.png",
6 "public/favicon.svg", "public/favicon.ico", "public/favicon.png",
7 "app/favicon.ico", "app/favicon.png", "app/icon.svg", "app/icon.png", "app/icon.ico",
8 "src/favicon.ico", "src/favicon.svg", "src/app/favicon.ico", "src/app/icon.svg", "src/app/icon.png",
9];
10
11const ICON_SOURCE_FILES = [
12 "index.html", "public/index.html",
13 "app/routes/__root.tsx", "src/routes/__root.tsx",
14 "app/root.tsx", "src/root.tsx", "src/index.html",
15];
16
17const LINK_ICON_HTML_RE = /<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i;
18const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i;
19
20function test(projectCwd) {
21 console.log("--- Testing:", projectCwd, "---");
22
23 for (const c of FAVICON_CANDIDATES) {
24 const full = path.join(projectCwd, c);
25 if (fs.existsSync(full) && fs.statSync(full).isFile()) {
26 console.log(" Phase 1 FOUND:", c);
27 return;
28 }
29 }
30 console.log(" Phase 1: no match");
31
32 for (const sf of ICON_SOURCE_FILES) {
33 const full = path.join(projectCwd, sf);
34 if (!fs.existsSync(full)) continue;
35 const content = fs.readFileSync(full, "utf8");
36 const href = content.match(LINK_ICON_HTML_RE)?.[1] || content.match(LINK_ICON_OBJ_RE)?.[1];
37 if (href) {
38 const clean = href.replace(/^\//, "");
39 const tryPaths = [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)];
40 for (const p of tryPaths) {
41 if (fs.existsSync(p)) {
42 console.log(" Phase 2 FOUND:", sf, "->", href, "->", p);
43 return;
44 }
45 }
46 console.log(" Phase 2 href found but file missing:", href);
47 }
48 }
49 console.log(" Phase 2: no match");
50}
51
52test("/Users/theo/Code/Work/ct/t3chat");
53test("/Users/theo/Code/Work/lawn");
54test(process.cwd());
README.md+1-1
......@@ -91,7 +91,7 @@ npx t3
9191
9292Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together.
9393
94- Default ports: server `3773`, web `5173`
94- Default ports: server `3773`, web `5733`
9595- Shifted ports: `base + offset` (offset is hashed from `T3CODE_DEV_INSTANCE`)
9696- Example: `T3CODE_DEV_INSTANCE=branch-a bun run dev:desktop`
9797
apps/desktop/scripts/dev-electron.mjs+1-1
......@@ -5,7 +5,7 @@ import waitOn from "wait-on";
55
66import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
77
8const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173);
8const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733);
99const devServerUrl = `http://localhost:${port}`;
1010
1111await waitOn({
apps/server/package.json+1-1
......@@ -10,7 +10,7 @@
1010 "type": "module",
1111 "main": "./dist/index.mjs",
1212 "scripts": {
13 "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5173} bun run src/index.ts",
13 "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5733} bun run src/index.ts",
1414 "build": "tsdown && node scripts/bundle-client.ts",
1515 "start": "node dist/index.mjs",
1616 "prepare": "effect-language-service patch",
apps/server/src/wsServer.ts+158-1
......@@ -230,8 +230,166 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
230230 });
231231 });
232232
233 // Well-known favicon paths checked in order.
234 const FAVICON_CANDIDATES = [
235 "favicon.svg",
236 "favicon.ico",
237 "favicon.png",
238 "public/favicon.svg",
239 "public/favicon.ico",
240 "public/favicon.png",
241 "app/favicon.ico",
242 "app/favicon.png",
243 "app/icon.svg",
244 "app/icon.png",
245 "app/icon.ico",
246 "src/favicon.ico",
247 "src/favicon.svg",
248 "src/app/favicon.ico",
249 "src/app/icon.svg",
250 "src/app/icon.png",
251 ];
252
253 // Files that may contain a <link rel="icon"> or icon metadata declaration.
254 const ICON_SOURCE_FILES = [
255 "index.html",
256 "public/index.html",
257 "app/routes/__root.tsx",
258 "src/routes/__root.tsx",
259 "app/root.tsx",
260 "src/root.tsx",
261 "src/index.html",
262 ];
263
264 // Extract icon href from HTML/TSX source content.
265 // Matches <link rel="icon" href="…"> or { rel: "icon", href: "…" } patterns.
266 const LINK_ICON_HTML_RE = /<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i;
267 const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i;
268 function extractIconHref(source: string): string | null {
269 const htmlMatch = source.match(LINK_ICON_HTML_RE);
270 if (htmlMatch?.[1]) return htmlMatch[1];
271 const objMatch = source.match(LINK_ICON_OBJ_RE);
272 if (objMatch?.[1]) return objMatch[1];
273 return null;
274 }
275
276 /**
277 * Resolve an icon href (e.g. "/grass-logo.svg") to a file on disk.
278 * For href starting with "/" we look in public/ first, then project root.
279 */
280 function resolveIconHref(projectCwd: string, href: string): string[] {
281 const clean = href.replace(/^\//, "");
282 return [
283 path.join(projectCwd, "public", clean),
284 path.join(projectCwd, clean),
285 ];
286 }
287
288 function serveFaviconFile(
289 filePath: string,
290 res: http.ServerResponse,
291 ): void {
292 const ext = path.extname(filePath);
293 const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
294 fs.readFile(filePath, (readErr, data) => {
295 if (readErr) {
296 res.writeHead(500, { "Content-Type": "text/plain" });
297 res.end("Read error");
298 return;
299 }
300 res.writeHead(200, {
301 "Content-Type": contentType,
302 "Cache-Control": "public, max-age=3600",
303 });
304 res.end(data);
305 });
306 }
307
233308 // HTTP server — serves static files or redirects to Vite dev server
234309 const httpServer = http.createServer((req, res) => {
310 const url = new URL(req.url ?? "/", `http://localhost:${port}`);
311
312 // Serve project favicons from their working directories.
313 // Handled before the dev redirect so it works in both modes.
314 if (url.pathname === "/api/project-favicon") {
315 const projectCwd = url.searchParams.get("cwd");
316 if (!projectCwd) {
317 res.writeHead(400, { "Content-Type": "text/plain" });
318 res.end("Missing cwd parameter");
319 return;
320 }
321
322 // Phase 1: Check well-known favicon paths.
323 const tryCandidates = (index: number) => {
324 if (index >= FAVICON_CANDIDATES.length) {
325 // Phase 2: Parse source files for icon link declarations.
326 trySourceFiles(0);
327 return;
328 }
329 const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!);
330 if (!candidate.startsWith(projectCwd + path.sep)) {
331 tryCandidates(index + 1);
332 return;
333 }
334 fs.stat(candidate, (err, stats) => {
335 if (err || !stats?.isFile()) {
336 tryCandidates(index + 1);
337 return;
338 }
339 serveFaviconFile(candidate, res);
340 });
341 };
342
343 // Phase 2: Read source/HTML files and extract icon href references.
344 const trySourceFiles = (index: number) => {
345 if (index >= ICON_SOURCE_FILES.length) {
346 res.writeHead(404, { "Content-Type": "text/plain" });
347 res.end("No favicon found");
348 return;
349 }
350 const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!);
351 fs.readFile(sourceFile, "utf8", (err, content) => {
352 if (err) {
353 trySourceFiles(index + 1);
354 return;
355 }
356 const href = extractIconHref(content);
357 if (!href) {
358 trySourceFiles(index + 1);
359 return;
360 }
361 const candidates = resolveIconHref(projectCwd, href);
362 tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1));
363 });
364 };
365
366 const tryResolvedPaths = (
367 paths: string[],
368 index: number,
369 onExhausted: () => void,
370 ) => {
371 if (index >= paths.length) {
372 onExhausted();
373 return;
374 }
375 const candidate = paths[index]!;
376 if (!candidate.startsWith(projectCwd + path.sep)) {
377 tryResolvedPaths(paths, index + 1, onExhausted);
378 return;
379 }
380 fs.stat(candidate, (err, stats) => {
381 if (err || !stats?.isFile()) {
382 tryResolvedPaths(paths, index + 1, onExhausted);
383 return;
384 }
385 serveFaviconFile(candidate, res);
386 });
387 };
388
389 tryCandidates(0);
390 return;
391 }
392
235393 // In dev mode, redirect to Vite dev server
236394 if (devUrl) {
237395 res.writeHead(302, { Location: devUrl.href });
......@@ -246,7 +404,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
246404 return;
247405 }
248406
249 const url = new URL(req.url ?? "/", `http://localhost:${port}`);
250407 let filePath = path.join(staticDir, url.pathname);
251408
252409 // SPA fallback: if no file extension and not found, serve index.html
apps/web/src/components/Sidebar.tsx+46-1
......@@ -1,4 +1,4 @@
1import { ChevronRightIcon, TerminalIcon } from "lucide-react";
1import { ChevronRightIcon, FolderIcon, TerminalIcon } from "lucide-react";
22import { useCallback, useEffect, useMemo, useState } from "react";
33import {
44 DEFAULT_MODEL,
......@@ -141,6 +141,50 @@ function T3Wordmark() {
141141 );
142142}
143143
144/**
145 * Derives the server's HTTP origin (scheme + host + port) from the same
146 * sources WsTransport uses, converting ws(s) to http(s).
147 */
148function getServerHttpOrigin(): string {
149 const bridgeUrl = window.desktopBridge?.getWsUrl();
150 const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
151 const wsUrl =
152 bridgeUrl && bridgeUrl.length > 0
153 ? bridgeUrl
154 : envUrl && envUrl.length > 0
155 ? envUrl
156 : `ws://${window.location.hostname}:${window.location.port}`;
157 // Parse to extract just the origin, dropping path/query (e.g. ?token=…)
158 const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
159 try {
160 return new URL(httpUrl).origin;
161 } catch {
162 return httpUrl;
163 }
164}
165
166const serverHttpOrigin = getServerHttpOrigin();
167
168function ProjectFavicon({ cwd }: { cwd: string }) {
169 const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");
170
171 const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`;
172
173 if (status === "error") {
174 return <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/50" />;
175 }
176
177 return (
178 <img
179 src={src}
180 alt=""
181 className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loading" ? "hidden" : ""}`}
182 onLoad={() => setStatus("loaded")}
183 onError={() => setStatus("error")}
184 />
185 );
186}
187
144188export default function Sidebar() {
145189 const { state, dispatch } = useStore();
146190 const navigate = useNavigate();
......@@ -573,6 +617,7 @@ export default function Sidebar() {
573617 project.expanded ? "rotate-90" : ""
574618 }`}
575619 />
620 <ProjectFavicon cwd={project.cwd} />
576621 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
577622 {project.name}
578623 </span>
apps/web/vite.config.ts+1-1
......@@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react";
33import { tanstackRouter } from "@tanstack/router-plugin/vite";
44import { defineConfig } from "vite";
55
6const port = Number(process.env.PORT ?? 5173);
6const port = Number(process.env.PORT ?? 5733);
77
88export default defineConfig({
99 plugins: [
scripts/dev-runner.mjs+1-1
......@@ -4,7 +4,7 @@ import path from "node:path";
44import { pathToFileURL } from "node:url";
55
66const BASE_SERVER_PORT = 3773;
7const BASE_WEB_PORT = 5173;
7const BASE_WEB_PORT = 5733;
88const MAX_HASH_OFFSET = 3000;
99export const DEFAULT_DEV_STATE_DIR = path.join(homedir(), ".t3", "dev");
1010const MODE_ARGS = {