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
87 changes: 86 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,90 @@ jobs:
fail_on_unmatched_files: true
token: ${{ steps.app_token.outputs.token }}

deploy_web:
name: Deploy hosted web app
needs: [preflight, release]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 10
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }}
T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }}
T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }}
VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.ref }}

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json

- name: Install release tooling dependencies
run: bun install --frozen-lockfile --filter=@t3tools/scripts --filter=@t3tools/web

- name: Align package versions to release version
run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"

- name: Refresh release lockfile
run: bun install --lockfile-only --ignore-scripts

- name: Deploy and alias channel
shell: bash
run: |
set -euo pipefail

if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then
echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2
exit 1
fi

router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}"
latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}"
nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}"

if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then
channel_domain="$latest_domain"
channel_name="latest"
else
channel_domain="$nightly_domain"
channel_name="nightly"
fi

vercel_scope_args=()
if [[ -n "${VERCEL_TEAM_SLUG:-}" ]]; then
vercel_scope_args=(--scope "$VERCEL_TEAM_SLUG")
fi

echo "Deploying hosted web app for $channel_name channel."
deployment_url="$(
bunx vercel@53.1.1 deploy apps/web \
--prod \
--skip-domain \
--yes \
--token "$VERCEL_TOKEN" \
"${vercel_scope_args[@]}" \
--build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \
--build-env "VITE_HOSTED_APP_URL=$router_url" \
--build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"
)"

echo "Aliasing $deployment_url to $channel_domain."
bunx vercel@53.1.1 alias set "$deployment_url" "$channel_domain" \
--token "$VERCEL_TOKEN" \
"${vercel_scope_args[@]}"

finalize:
name: Finalize release
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }}
Expand Down Expand Up @@ -595,8 +679,9 @@ jobs:
always() && !cancelled() &&
needs.preflight.result == 'success' &&
needs.release.result == 'success' &&
needs.deploy_web.result == 'success' &&
(needs.finalize.result == 'success' || needs.finalize.result == 'skipped')
needs: [preflight, release, finalize]
needs: [preflight, release, deploy_web, finalize]
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 10
steps:
Expand Down
94 changes: 94 additions & 0 deletions apps/web/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
HOSTED_WEB_CHANNEL_COOKIE,
isRouterHost,
normalizeChannel,
parseCookieValue,
selectChannel,
} from "./middleware";

function request(path: string, cookie?: string): Request {
return new Request(`https://app.t3.codes${path}`, {
headers: cookie ? { cookie } : undefined,
});
}

describe("hosted web channel middleware", () => {
it("normalizes latest and nightly channel names", () => {
expect(normalizeChannel("latest")).toBe("latest");
expect(normalizeChannel("nightly")).toBe("nightly");
expect(normalizeChannel("mytube")).toBeNull();
expect(normalizeChannel("unknown")).toBeNull();
});

it("matches the configured router host without a port", () => {
expect(isRouterHost("app.t3.codes:443", "app.t3.codes")).toBe(true);
expect(isRouterHost("app.t3.codes", "app.t3.codes:443")).toBe(true);
expect(isRouterHost("latest.app.t3.codes", "app.t3.codes")).toBe(false);
});

it("reads the selected channel from cookies", () => {
expect(
selectChannel(request("/settings", `theme=dark; ${HOSTED_WEB_CHANNEL_COOKIE}=nightly`)),
).toEqual({
channel: "nightly",
setCookie: false,
nextPath: "/settings",
});
});

it("defaults invalid or missing channel cookies to latest", () => {
expect(selectChannel(request("/threads", `${HOSTED_WEB_CHANNEL_COOKIE}=bad`))).toEqual({
channel: "latest",
setCookie: false,
nextPath: "/threads",
});
});

it("handles channel opt-in requests with an internal next path only", () => {
expect(selectChannel(request("/__t3code/channel?channel=nightly&next=/pair"))).toEqual({
channel: "nightly",
setCookie: true,
nextPath: "/pair",
});

expect(
selectChannel(request("/__t3code/channel?channel=latest&next=https://evil.example")),
).toEqual({
channel: "latest",
setCookie: true,
nextPath: "/",
});

expect(selectChannel(request("/__t3code/channel?channel=latest&next=/\\evil.example"))).toEqual(
{
channel: "latest",
setCookie: true,
nextPath: "/",
},
);

expect(
selectChannel(request("/__t3code/channel?channel=latest&next=/settings%3Adebug")),
).toEqual({
channel: "latest",
setCookie: true,
nextPath: "/",
});

expect(
selectChannel(request("/__t3code/channel?channel=latest&next=/settings%0Adebug")),
).toEqual({
channel: "latest",
setCookie: true,
nextPath: "/",
});
});

it("parses cookie values by exact name", () => {
expect(parseCookieValue("other=value; t3code_web_channel=nightly", "t3code_web_channel")).toBe(
"nightly",
);
expect(parseCookieValue("x-t3code_web_channel=nightly", "t3code_web_channel")).toBeNull();
});
});
155 changes: 155 additions & 0 deletions apps/web/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { next, rewrite } from "@vercel/functions";

export type HostedWebChannel = "latest" | "nightly";

export const HOSTED_WEB_CHANNEL_COOKIE = "t3code_web_channel";

const DEFAULT_ROUTER_HOST = "app.t3.codes";
const DEFAULT_CHANNEL_ORIGINS = {
latest: "https://latest.app.t3.codes",
nightly: "https://nightly.app.t3.codes",
} as const satisfies Record<HostedWebChannel, string>;

export interface ChannelRouterConfig {
readonly routerHost: string;
readonly channelOrigins: Record<HostedWebChannel, string>;
}

export interface ChannelSelection {
readonly channel: HostedWebChannel;
readonly setCookie: boolean;
readonly nextPath: string;
}

function envValue(name: string): string | undefined {
const value = process.env[name]?.trim();
return value ? value : undefined;
}

export function readChannelRouterConfig(): ChannelRouterConfig {
return {
routerHost: envValue("T3CODE_WEB_ROUTER_HOST") ?? DEFAULT_ROUTER_HOST,
channelOrigins: {
latest: envValue("T3CODE_WEB_LATEST_ORIGIN") ?? DEFAULT_CHANNEL_ORIGINS.latest,
nightly: envValue("T3CODE_WEB_NIGHTLY_ORIGIN") ?? DEFAULT_CHANNEL_ORIGINS.nightly,
},
};
}

export function normalizeChannel(value: string | null | undefined): HostedWebChannel | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "latest") return "latest";
if (normalized === "nightly") return "nightly";
return null;
}

export function parseCookieValue(cookieHeader: string | null, name: string): string | null {
if (!cookieHeader) return null;

for (const segment of cookieHeader.split(";")) {
const [rawKey, ...rawValue] = segment.split("=");
if (rawKey?.trim() !== name) continue;
return rawValue.join("=").trim() || null;
}

return null;
}

function normalizeHost(value: string | null): string | null {
const host = value?.split(":")[0]?.trim().toLowerCase();
return host ? host : null;
}

export function isRouterHost(hostHeader: string | null, routerHost: string): boolean {
const host = normalizeHost(hostHeader);
const router = normalizeHost(routerHost);
return host !== null && host === router;
}

function hasControlCharacter(value: string): boolean {
for (const char of value) {
const code = char.charCodeAt(0);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}

function safeNextPath(value: string | null): string {
if (
!value?.startsWith("/") ||
value.startsWith("//") ||
value.includes("\\") ||
value.includes(":") ||
hasControlCharacter(value)
) {
return "/";
}

return value;
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

export function selectChannel(request: Request): ChannelSelection {
const url = new URL(request.url);

if (url.pathname === "/__t3code/channel") {
return {
channel: normalizeChannel(url.searchParams.get("channel")) ?? "latest",
setCookie: true,
nextPath: safeNextPath(url.searchParams.get("next")),
};
}

return {
channel:
normalizeChannel(
parseCookieValue(request.headers.get("cookie"), HOSTED_WEB_CHANNEL_COOKIE),
) ?? "latest",
setCookie: false,
nextPath: `${url.pathname}${url.search}`,
};
}

function channelCookie(channel: HostedWebChannel): string {
return [
`${HOSTED_WEB_CHANNEL_COOKIE}=${channel}`,
"Path=/",
"Max-Age=31536000",
"HttpOnly",
"Secure",
"SameSite=Lax",
].join("; ");
}

function buildRewriteUrl(request: Request, origin: string): URL {
const requestUrl = new URL(request.url);
const target = new URL(origin);
target.pathname = requestUrl.pathname;
target.search = requestUrl.search;
target.hash = "";
return target;
}

export const config = {
matcher: "/:path*",
};

export default function middleware(request: Request): Response {
const routerConfig = readChannelRouterConfig();
if (!isRouterHost(request.headers.get("host"), routerConfig.routerHost)) {
return next();
}

const selection = selectChannel(request);

if (selection.setCookie) {
return new Response(null, {
status: 302,
headers: {
Location: selection.nextPath,
"Set-Cookie": channelCookie(selection.channel),
},
});
}

return rewrite(buildRewriteUrl(request, routerConfig.channelOrigins[selection.channel]));
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@types/babel__core": "^7.20.5",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vercel/functions": "^3.5.0",
"@vitejs/plugin-react": "^6.0.0",
"@vitest/browser-playwright": "^4.0.18",
"babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/branding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,22 @@ describe("branding", () => {
expect(branding.APP_STAGE_LABEL).toBe("Nightly");
expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)");
});

it("normalizes hosted app channel metadata", async () => {
vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "nightly");

const branding = await import("./branding");

expect(branding.HOSTED_APP_CHANNEL).toBe("nightly");
expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Nightly");
});

it("ignores unknown hosted app channels", async () => {
vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "preview");

const branding = await import("./branding");

expect(branding.HOSTED_APP_CHANNEL).toBeNull();
expect(branding.HOSTED_APP_CHANNEL_LABEL).toBeNull();
});
});
5 changes: 5 additions & 0 deletions apps/web/src/branding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ function readInjectedDesktopAppBranding(): DesktopAppBranding | null {
}

const injectedDesktopAppBranding = readInjectedDesktopAppBranding();
const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase();

export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code";
export const APP_STAGE_LABEL =
injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha");
export const APP_DISPLAY_NAME =
injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`;
export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0";
export const HOSTED_APP_CHANNEL =
hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null;
export const HOSTED_APP_CHANNEL_LABEL =
HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null;
Comment thread
cursor[bot] marked this conversation as resolved.
Loading
Loading