authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-08 22:57:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-08 22:57:16-07:00
log3c32bc8fd1f5970e65988e36937cb8e2921437f9
treec24c888016ba4469a7d38dd014fad5553c6aa62b
parent6efdf67e157a3c5e88b120dcf6857176534d4aa8
signature Signed by PGP key B5690EEEBB952194

[codex] Fix hosted channel bootstrap (#2613)

Co-authored-by: Julius Marminge <julius@macmini.local>

6 files changed, 74 insertions(+), 6 deletions(-)

apps/web/src/hostedPairing.test.ts+12
...@@ -79,4 +79,16 @@ describe("hostedPairing", () => {...@@ -79,4 +79,16 @@ describe("hostedPairing", () => {
79 vi.stubEnv("VITE_HTTP_URL", "https://backend.example.com");79 vi.stubEnv("VITE_HTTP_URL", "https://backend.example.com");
80 expect(isHostedStaticApp(new URL("https://preview.t3.codes/"))).toBe(false);80 expect(isHostedStaticApp(new URL("https://preview.t3.codes/"))).toBe(false);
81 });81 });
82
83 it("detects hosted channel aliases as static apps", () => {
84 vi.stubEnv("VITE_HOSTED_APP_URL", "https://app.t3.codes");
85 vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "nightly");
86 vi.stubEnv("VITE_HTTP_URL", "");
87 vi.stubEnv("VITE_WS_URL", "");
88
89 expect(isHostedStaticApp(new URL("https://nightly.app.t3.codes/"))).toBe(true);
90
91 vi.stubEnv("VITE_HTTP_URL", "https://backend.example.com");
92 expect(isHostedStaticApp(new URL("https://nightly.app.t3.codes/"))).toBe(false);
93 });
82});94});
apps/web/src/hostedPairing.ts+9
...@@ -18,6 +18,11 @@ function configuredBackendUrl(): string {...@@ -18,6 +18,11 @@ function configuredBackendUrl(): string {
18 return import.meta.env.VITE_HTTP_URL?.trim() || import.meta.env.VITE_WS_URL?.trim() || "";18 return import.meta.env.VITE_HTTP_URL?.trim() || import.meta.env.VITE_WS_URL?.trim() || "";
19}19}
2020
21function configuredHostedAppChannel(): HostedAppChannel | null {
22 const channel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase();
23 return channel === "latest" || channel === "nightly" ? channel : null;
24}
25
21function originFromUrl(value: string): string | null {26function originFromUrl(value: string): string | null {
22 try {27 try {
23 return new URL(value).origin;28 return new URL(value).origin;
...@@ -31,6 +36,10 @@ export function isHostedStaticApp(url: URL = new URL(window.location.href)): boo...@@ -31,6 +36,10 @@ export function isHostedStaticApp(url: URL = new URL(window.location.href)): boo
31 return false;36 return false;
32 }37 }
3338
39 if (configuredHostedAppChannel()) {
40 return true;
41 }
42
34 const hostedOrigin = originFromUrl(configuredHostedAppUrl());43 const hostedOrigin = originFromUrl(configuredHostedAppUrl());
35 return hostedOrigin !== null && url.origin === hostedOrigin;44 return hostedOrigin !== null && url.origin === hostedOrigin;
36}45}
apps/web/vercel.ts+1-1
...@@ -18,7 +18,7 @@ function channelCookie(channel: "latest" | "nightly"): string {...@@ -18,7 +18,7 @@ function channelCookie(channel: "latest" | "nightly"): string {
1818
19export const config: VercelConfig = {19export const config: VercelConfig = {
20 buildCommand:20 buildCommand:
21 "turbo build --filter @t3tools/web && bun ../../scripts/apply-web-brand-assets.ts production",21 'turbo build --filter @t3tools/web && bun ../../scripts/apply-web-brand-assets.ts --channel "${VITE_HOSTED_APP_CHANNEL:-latest}"',
22 git: {22 git: {
23 deploymentEnabled: false,23 deploymentEnabled: false,
24 },24 },
scripts/apply-web-brand-assets.ts+20-4
...@@ -6,11 +6,17 @@ import * as Effect from "effect/Effect";...@@ -6,11 +6,17 @@ import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as Path from "effect/Path";8import * as Path from "effect/Path";
9import { Argument, Command } from "effect/unstable/cli";9import { Argument, Command, Flag } from "effect/unstable/cli";
10import { resolveWebIconOverrides, type WebAssetBrand } from "./lib/brand-assets.ts";10import {
11 resolveWebAssetBrandForChannel,
12 resolveWebIconOverrides,
13 WEB_ASSET_CHANNELS,
14 type WebAssetBrand,
15} from "./lib/brand-assets.ts";
1116
12const WEB_ASSET_BRANDS = [17const WEB_ASSET_BRANDS = [
13 "development",18 "development",
19 "nightly",
14 "production",20 "production",
15] as const satisfies ReadonlyArray<WebAssetBrand>;21] as const satisfies ReadonlyArray<WebAssetBrand>;
1622
...@@ -38,15 +44,25 @@ export const applyWebBrandAssetsCommand = Command.make(...@@ -38,15 +44,25 @@ export const applyWebBrandAssetsCommand = Command.make(
38 {44 {
39 brand: Argument.choice("brand", WEB_ASSET_BRANDS).pipe(45 brand: Argument.choice("brand", WEB_ASSET_BRANDS).pipe(
40 Argument.withDescription("Asset brand to copy into the hosted web output directory."),46 Argument.withDescription("Asset brand to copy into the hosted web output directory."),
47 Argument.optional,
48 ),
49 channel: Flag.choice("channel", WEB_ASSET_CHANNELS).pipe(
50 Flag.withDescription("Hosted release channel to map to a web asset brand."),
51 Flag.optional,
41 ),52 ),
42 targetDirectory: Argument.string("target-directory").pipe(53 targetDirectory: Argument.string("target-directory").pipe(
43 Argument.withDescription("Output directory that contains the hosted web build assets."),54 Argument.withDescription("Output directory that contains the hosted web build assets."),
44 Argument.optional,55 Argument.optional,
45 ),56 ),
46 },57 },
47 ({ brand, targetDirectory }) =>58 ({ brand, channel, targetDirectory }) =>
48 applyWebBrandAssets(59 applyWebBrandAssets(
49 brand,60 Option.getOrElse(brand, () =>
61 Option.match(channel, {
62 onNone: () => "production" as const,
63 onSome: resolveWebAssetBrandForChannel,
64 }),
65 ),
50 Option.getOrElse(targetDirectory, () => "apps/web/dist"),66 Option.getOrElse(targetDirectory, () => "apps/web/dist"),
51 ),67 ),
52).pipe(Command.withDescription("Copy web brand assets into a built hosted web app."));68).pipe(Command.withDescription("Copy web brand assets into a built hosted web app."));
scripts/lib/brand-assets.test.ts+13
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 BRAND_ASSET_PATHS,4 BRAND_ASSET_PATHS,
5 DEVELOPMENT_ICON_OVERRIDES,5 DEVELOPMENT_ICON_OVERRIDES,
6 PUBLISH_ICON_OVERRIDES,6 PUBLISH_ICON_OVERRIDES,
7 resolveWebAssetBrandForChannel,
7 resolveWebIconOverrides,8 resolveWebIconOverrides,
8} from "./brand-assets.ts";9} from "./brand-assets.ts";
910
...@@ -42,4 +43,16 @@ describe("brand-assets", () => {...@@ -42,4 +43,16 @@ describe("brand-assets", () => {
42 targetRelativePath: "apps/web/dist/apple-touch-icon.png",43 targetRelativePath: "apps/web/dist/apple-touch-icon.png",
43 });44 });
44 });45 });
46
47 it("maps hosted nightly web assets to nightly icons", () => {
48 expect(resolveWebIconOverrides("nightly", "apps/web/dist")).toContainEqual({
49 sourceRelativePath: BRAND_ASSET_PATHS.nightlyWebFaviconIco,
50 targetRelativePath: "apps/web/dist/favicon.ico",
51 });
52 });
53
54 it("maps hosted release channels to web asset brands", () => {
55 expect(resolveWebAssetBrandForChannel("latest")).toBe("production");
56 expect(resolveWebAssetBrandForChannel("nightly")).toBe("nightly");
57 });
45});58});
scripts/lib/brand-assets.ts+19-1
...@@ -10,6 +10,10 @@ export const BRAND_ASSET_PATHS = {...@@ -10,6 +10,10 @@ export const BRAND_ASSET_PATHS = {
10 nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png",10 nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png",
11 nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png",11 nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png",
12 nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico",12 nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico",
13 nightlyWebFaviconIco: "assets/nightly/blueprint-web-favicon.ico",
14 nightlyWebFavicon16Png: "assets/nightly/blueprint-web-favicon-16x16.png",
15 nightlyWebFavicon32Png: "assets/nightly/blueprint-web-favicon-32x32.png",
16 nightlyWebAppleTouchIconPng: "assets/nightly/blueprint-web-apple-touch-180.png",
1317
14 developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png",18 developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png",
15 developmentWindowsIconIco: "assets/dev/blueprint-windows.ico",19 developmentWindowsIconIco: "assets/dev/blueprint-windows.ico",
...@@ -19,7 +23,15 @@ export const BRAND_ASSET_PATHS = {...@@ -19,7 +23,15 @@ export const BRAND_ASSET_PATHS = {
19 developmentWebAppleTouchIconPng: "assets/dev/blueprint-web-apple-touch-180.png",23 developmentWebAppleTouchIconPng: "assets/dev/blueprint-web-apple-touch-180.png",
20} as const;24} as const;
2125
22export type WebAssetBrand = "development" | "production";26export type WebAssetBrand = "development" | "nightly" | "production";
27
28export const WEB_ASSET_CHANNELS = ["latest", "nightly"] as const;
29
30export type WebAssetChannel = (typeof WEB_ASSET_CHANNELS)[number];
31
32export function resolveWebAssetBrandForChannel(channel: WebAssetChannel): WebAssetBrand {
33 return channel === "nightly" ? "nightly" : "production";
34}
2335
24export interface IconOverride {36export interface IconOverride {
25 readonly sourceRelativePath: string;37 readonly sourceRelativePath: string;
...@@ -40,6 +52,12 @@ const WEB_ICON_SOURCE_PATHS_BY_BRAND = {...@@ -40,6 +52,12 @@ const WEB_ICON_SOURCE_PATHS_BY_BRAND = {
40 favicon32Png: BRAND_ASSET_PATHS.developmentWebFavicon32Png,52 favicon32Png: BRAND_ASSET_PATHS.developmentWebFavicon32Png,
41 appleTouchIconPng: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng,53 appleTouchIconPng: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng,
42 },54 },
55 nightly: {
56 faviconIco: BRAND_ASSET_PATHS.nightlyWebFaviconIco,
57 favicon16Png: BRAND_ASSET_PATHS.nightlyWebFavicon16Png,
58 favicon32Png: BRAND_ASSET_PATHS.nightlyWebFavicon32Png,
59 appleTouchIconPng: BRAND_ASSET_PATHS.nightlyWebAppleTouchIconPng,
60 },
43 production: {61 production: {
44 faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco,62 faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco,
45 favicon16Png: BRAND_ASSET_PATHS.productionWebFavicon16Png,63 favicon16Png: BRAND_ASSET_PATHS.productionWebFavicon16Png,