diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..2d53e036
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,35 @@
+# App
+VITE_CONVEX_URL=
+VITE_CONVEX_SITE_URL=
+VITE_CLERK_PUBLISHABLE_KEY=
+
+# Convex / Clerk
+CONVEX_DEPLOYMENT=
+CONVEX_DEPLOY_KEY=
+CLERK_SECRET_KEY=
+CLERK_JWT_ISSUER_DOMAIN=
+
+# Mux
+MUX_TOKEN_ID=
+MUX_TOKEN_SECRET=
+MUX_WEBHOOK_SECRET=
+MUX_SIGNING_KEY=
+MUX_PRIVATE_KEY=
+# Legacy Mux signing aliases, only needed if the app is configured to use them.
+# MUX_SIGNING_KEY_ID=
+# MUX_SIGNING_PRIVATE_KEY=
+
+# Stripe
+STRIPE_SECRET_KEY=
+STRIPE_WEBHOOK_SECRET=
+STRIPE_PRICE_BASIC_MONTHLY=
+STRIPE_PRICE_PRO_MONTHLY=
+
+# Railway object storage
+RAILWAY_ACCESS_KEY_ID=
+RAILWAY_SECRET_ACCESS_KEY=
+RAILWAY_ENDPOINT=
+RAILWAY_PUBLIC_URL=
+RAILWAY_PUBLIC_URL_INCLUDE_BUCKET=true
+RAILWAY_BUCKET_NAME=videos
+RAILWAY_REGION=us-east-1
diff --git a/.gitignore b/.gitignore
index 94b327d9..1c806a2a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,6 +34,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env.example
# vercel
.vercel
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000..4bc5e1d9
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,9 @@
+{
+ "window.titleBarStyle": "custom",
+ "workbench.colorCustomizations": {
+ "titleBar.activeBackground": "#2d5a2d",
+ "titleBar.activeForeground": "#ffffff",
+ "titleBar.inactiveBackground": "#3a6a3a",
+ "titleBar.inactiveForeground": "#ffffff"
+ }
+}
diff --git a/app/routes/dashboard/-useVideoUploadManager.ts b/app/routes/dashboard/-useVideoUploadManager.ts
deleted file mode 100644
index cfb760d4..00000000
--- a/app/routes/dashboard/-useVideoUploadManager.ts
+++ /dev/null
@@ -1,200 +0,0 @@
-import { useAction, useMutation } from "convex/react";
-import { useCallback, useState } from "react";
-import { api } from "@convex/_generated/api";
-import { Id } from "@convex/_generated/dataModel";
-import type { UploadStatus } from "@/components/upload/UploadProgress";
-
-export interface ManagedUploadItem {
- id: string;
- projectId: Id<"projects">;
- file: File;
- videoId?: Id<"videos">;
- progress: number;
- status: UploadStatus;
- error?: string;
- bytesPerSecond?: number;
- estimatedSecondsRemaining?: number | null;
- abortController?: AbortController;
-}
-
-function createUploadId() {
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
- return crypto.randomUUID();
- }
- return Math.random().toString(36).slice(2);
-}
-
-export function useVideoUploadManager() {
- const createVideo = useMutation(api.videos.create);
- const getUploadUrl = useAction(api.videoActions.getUploadUrl);
- const markUploadComplete = useAction(api.videoActions.markUploadComplete);
- const markUploadFailed = useAction(api.videoActions.markUploadFailed);
- const [uploads, setUploads] = useState([]);
-
- const uploadFilesToProject = useCallback(
- async (projectId: Id<"projects">, files: File[]) => {
- for (const file of files) {
- const uploadId = createUploadId();
- const title = file.name.replace(/\.[^/.]+$/, "");
- const abortController = new AbortController();
-
- setUploads((prev) => [
- ...prev,
- {
- id: uploadId,
- projectId,
- file,
- progress: 0,
- status: "pending",
- abortController,
- },
- ]);
-
- let createdVideoId: Id<"videos"> | undefined;
-
- try {
- createdVideoId = await createVideo({
- projectId,
- title,
- fileSize: file.size,
- contentType: file.type || "video/mp4",
- });
-
- setUploads((prev) =>
- prev.map((upload) =>
- upload.id === uploadId
- ? { ...upload, videoId: createdVideoId, status: "uploading" }
- : upload,
- ),
- );
-
- const { url } = await getUploadUrl({
- videoId: createdVideoId,
- filename: file.name,
- fileSize: file.size,
- contentType: file.type || "video/mp4",
- });
-
- await new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest();
- let lastTime = Date.now();
- let lastLoaded = 0;
- const recentSpeeds: number[] = [];
-
- xhr.upload.addEventListener("progress", (event) => {
- if (!event.lengthComputable) return;
-
- const percentage = Math.round((event.loaded / event.total) * 100);
- const now = Date.now();
- const timeDelta = (now - lastTime) / 1000;
- const bytesDelta = event.loaded - lastLoaded;
-
- if (timeDelta > 0.1) {
- const speed = bytesDelta / timeDelta;
- recentSpeeds.push(speed);
- if (recentSpeeds.length > 5) recentSpeeds.shift();
- lastTime = now;
- lastLoaded = event.loaded;
- }
-
- const avgSpeed =
- recentSpeeds.length > 0
- ? recentSpeeds.reduce((sum, speed) => sum + speed, 0) /
- recentSpeeds.length
- : 0;
- const remaining = event.total - event.loaded;
- const eta = avgSpeed > 0 ? Math.ceil(remaining / avgSpeed) : null;
-
- setUploads((prev) =>
- prev.map((upload) =>
- upload.id === uploadId
- ? {
- ...upload,
- progress: percentage,
- bytesPerSecond: avgSpeed,
- estimatedSecondsRemaining: eta,
- }
- : upload,
- ),
- );
- });
-
- xhr.addEventListener("load", () => {
- if (xhr.status >= 200 && xhr.status < 300) {
- resolve();
- return;
- }
- reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
- });
-
- xhr.addEventListener("error", () => {
- reject(new Error("Upload failed: Network error"));
- });
-
- xhr.addEventListener("abort", () => {
- reject(new Error("Upload cancelled"));
- });
-
- abortController.signal.addEventListener("abort", () => {
- xhr.abort();
- });
-
- xhr.open("PUT", url);
- xhr.setRequestHeader("Content-Type", file.type || "video/mp4");
- xhr.send(file);
- });
-
- await markUploadComplete({ videoId: createdVideoId });
-
- setUploads((prev) =>
- prev.map((upload) =>
- upload.id === uploadId
- ? { ...upload, status: "complete", progress: 100 }
- : upload,
- ),
- );
-
- setTimeout(() => {
- setUploads((prev) => prev.filter((upload) => upload.id !== uploadId));
- }, 3000);
- } catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : "Upload failed";
-
- setUploads((prev) =>
- prev.map((upload) =>
- upload.id === uploadId
- ? { ...upload, status: "error", error: errorMessage }
- : upload,
- ),
- );
-
- if (createdVideoId) {
- markUploadFailed({ videoId: createdVideoId }).catch(console.error);
- }
- }
- }
- },
- [createVideo, getUploadUrl, markUploadComplete, markUploadFailed],
- );
-
- const cancelUpload = useCallback(
- (uploadId: string) => {
- const upload = uploads.find((item) => item.id === uploadId);
- if (upload?.abortController) {
- upload.abortController.abort();
- }
- if (upload?.videoId) {
- markUploadFailed({ videoId: upload.videoId }).catch(console.error);
- }
- setUploads((prev) => prev.filter((item) => item.id !== uploadId));
- },
- [uploads, markUploadFailed],
- );
-
- return {
- uploads,
- uploadFilesToProject,
- cancelUpload,
- };
-}
diff --git a/bun.lock b/bun.lock
index 60b97dd1..1266cf4c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -22,6 +22,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@stripe/stripe-js": "^8.7.0",
+ "@t3-oss/env-core": "^0.13.11",
"@tanstack/react-router": "^1.160.2",
"@tanstack/react-router-devtools": "^1.160.2",
"@tanstack/react-start": "^1.160.2",
@@ -598,6 +599,8 @@
"@stripe/stripe-js": ["@stripe/stripe-js@8.7.0", "", {}, "sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ=="],
+ "@t3-oss/env-core": ["@t3-oss/env-core@0.13.11", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ=="],
+
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
diff --git a/convex/auth.config.ts b/convex/auth.config.ts
index 6caa3b7a..808c5e15 100644
--- a/convex/auth.config.ts
+++ b/convex/auth.config.ts
@@ -1,7 +1,9 @@
+import { env } from "./env";
+
export default {
providers: [
{
- domain: process.env.CLERK_JWT_ISSUER_DOMAIN,
+ domain: env.CLERK_JWT_ISSUER_DOMAIN,
applicationID: "convex",
},
],
diff --git a/convex/billingHelpers.ts b/convex/billingHelpers.ts
index 6c85aecf..4839958e 100644
--- a/convex/billingHelpers.ts
+++ b/convex/billingHelpers.ts
@@ -1,10 +1,12 @@
import { components } from "./_generated/api";
import { Id } from "./_generated/dataModel";
import { MutationCtx, QueryCtx } from "./_generated/server";
+import { env, getStripePriceIdForEnvVar } from "./env";
export type TeamPlan = "basic" | "pro";
const GIBIBYTE = 1024 ** 3;
+const TEBIBYTE = 1024 ** 4;
export const TEAM_PLAN_MONTHLY_PRICE_USD: Record = {
basic: 5,
@@ -13,13 +15,33 @@ export const TEAM_PLAN_MONTHLY_PRICE_USD: Record = {
export const TEAM_PLAN_STORAGE_LIMIT_BYTES: Record = {
basic: 100 * GIBIBYTE,
- pro: 1024 * GIBIBYTE,
+ pro: TEBIBYTE,
+};
+
+export const TEAM_PLAN_MAX_FILE_SIZE_BYTES: Record = {
+ basic: 10 * GIBIBYTE,
+ pro: 50 * GIBIBYTE,
};
function hasText(value: string | undefined | null): value is string {
return typeof value === "string" && value.trim().length > 0;
}
+export function formatPlanLabel(plan: TeamPlan) {
+ return plan === "basic" ? "Basic" : "Pro";
+}
+
+export function formatBytesForBilling(bytes: number) {
+ const safeBytes = Number.isFinite(bytes) ? Math.max(0, bytes) : 0;
+ if (safeBytes >= TEBIBYTE) {
+ const value = safeBytes / TEBIBYTE;
+ return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} TB`;
+ }
+
+ const value = safeBytes / GIBIBYTE;
+ return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} GB`;
+}
+
export function normalizeStoredTeamPlan(plan: string): TeamPlan {
if (plan === "pro" || plan === "team") return "pro";
return "basic";
@@ -30,8 +52,8 @@ export function resolvePlanFromStripePriceId(
): TeamPlan | null {
if (!hasText(stripePriceId)) return null;
- const basicPriceId = process.env.STRIPE_PRICE_BASIC_MONTHLY;
- const proPriceId = process.env.STRIPE_PRICE_PRO_MONTHLY;
+ const basicPriceId = env.STRIPE_PRICE_BASIC_MONTHLY;
+ const proPriceId = env.STRIPE_PRICE_PRO_MONTHLY;
if (hasText(basicPriceId) && stripePriceId === basicPriceId) return "basic";
if (hasText(proPriceId) && stripePriceId === proPriceId) return "pro";
@@ -41,11 +63,7 @@ export function resolvePlanFromStripePriceId(
export function getStripePriceIdForPlan(plan: TeamPlan): string {
const variableName =
plan === "basic" ? "STRIPE_PRICE_BASIC_MONTHLY" : "STRIPE_PRICE_PRO_MONTHLY";
- const value = process.env[variableName];
- if (!hasText(value)) {
- throw new Error(`${variableName} is not configured`);
- }
- return value;
+ return getStripePriceIdForEnvVar(variableName);
}
export function hasActiveTeamSubscriptionStatus(
@@ -137,8 +155,13 @@ export async function assertTeamCanStoreBytes(
const requestedBytes = Number.isFinite(incomingBytes) ? Math.max(0, incomingBytes) : 0;
if (storageUsedBytes + requestedBytes > storageLimitBytes) {
+ const planLabel = formatPlanLabel(state.plan);
+ const actionCopy =
+ state.plan === "basic"
+ ? "Upgrade to Pro or delete old videos to free up space."
+ : "Delete old videos to free up space.";
throw new Error(
- `Storage limit reached for the ${state.plan} plan. Upgrade to continue uploading.`,
+ `This upload would exceed your team's ${planLabel} plan storage limit. You're using ${formatBytesForBilling(storageUsedBytes)} of ${formatBytesForBilling(storageLimitBytes)}, and this file is ${formatBytesForBilling(requestedBytes)}. ${actionCopy}`,
);
}
@@ -148,3 +171,33 @@ export async function assertTeamCanStoreBytes(
storageLimitBytes,
};
}
+
+export async function assertTeamCanUploadFileBytes(
+ ctx: BillingCtx,
+ teamId: Id<"teams">,
+ fileSizeBytes: number,
+) {
+ const state = await assertTeamHasActiveSubscription(ctx, teamId);
+ const requestedBytes = Number.isFinite(fileSizeBytes)
+ ? Math.max(0, fileSizeBytes)
+ : 0;
+ const maxFileSizeBytes = TEAM_PLAN_MAX_FILE_SIZE_BYTES[state.plan];
+
+ if (requestedBytes > maxFileSizeBytes) {
+ const planLabel = formatPlanLabel(state.plan);
+ const maxFileSizeLabel = formatBytesForBilling(maxFileSizeBytes);
+ const upgradeCopy =
+ state.plan === "basic"
+ ? " Upgrade to Pro for files up to 50 GB."
+ : "";
+
+ throw new Error(
+ `This file is too large for the ${planLabel} plan. ${planLabel} supports files up to ${maxFileSizeLabel}.${upgradeCopy}`,
+ );
+ }
+
+ return {
+ ...state,
+ maxFileSizeBytes,
+ };
+}
diff --git a/convex/env.ts b/convex/env.ts
new file mode 100644
index 00000000..2ff470aa
--- /dev/null
+++ b/convex/env.ts
@@ -0,0 +1,97 @@
+import { createEnv } from "@t3-oss/env-core";
+import { z } from "zod";
+
+const requiredString = z.string().min(1);
+
+const server = {
+ CLERK_JWT_ISSUER_DOMAIN: requiredString,
+ MUX_TOKEN_ID: requiredString,
+ MUX_TOKEN_SECRET: requiredString,
+ MUX_WEBHOOK_SECRET: requiredString,
+ MUX_SIGNING_KEY: requiredString.optional(),
+ MUX_PRIVATE_KEY: requiredString.optional(),
+ MUX_SIGNING_KEY_ID: requiredString.optional(),
+ MUX_SIGNING_PRIVATE_KEY: requiredString.optional(),
+ RAILWAY_ACCESS_KEY_ID: requiredString,
+ RAILWAY_SECRET_ACCESS_KEY: requiredString,
+ RAILWAY_ENDPOINT: requiredString.url(),
+ RAILWAY_PUBLIC_URL: requiredString.url().optional(),
+ RAILWAY_PUBLIC_URL_INCLUDE_BUCKET: z.enum(["true", "false"]).default("true"),
+ RAILWAY_BUCKET_NAME: requiredString.default("videos"),
+ RAILWAY_REGION: requiredString.default("us-east-1"),
+ STRIPE_PRICE_BASIC_MONTHLY: requiredString,
+ STRIPE_PRICE_PRO_MONTHLY: requiredString,
+};
+
+function requireEnvValue(name: string, value: string | undefined) {
+ if (!value) {
+ throw new Error(`Missing required environment variable: ${name}`);
+ }
+ return value;
+}
+
+function requireFirstEnvValue(
+ primaryName: string,
+ primaryValue: string | undefined,
+ legacyName: string,
+ legacyValue: string | undefined,
+) {
+ if (primaryValue) return primaryValue;
+ if (legacyValue) return legacyValue;
+ throw new Error(
+ `Missing required environment variable: ${primaryName} (or legacy ${legacyName})`,
+ );
+}
+
+export const env = createEnv({
+ server,
+ runtimeEnvStrict: {
+ CLERK_JWT_ISSUER_DOMAIN: process.env.CLERK_JWT_ISSUER_DOMAIN,
+ MUX_TOKEN_ID: process.env.MUX_TOKEN_ID,
+ MUX_TOKEN_SECRET: process.env.MUX_TOKEN_SECRET,
+ MUX_WEBHOOK_SECRET: process.env.MUX_WEBHOOK_SECRET,
+ MUX_SIGNING_KEY: process.env.MUX_SIGNING_KEY,
+ MUX_PRIVATE_KEY: process.env.MUX_PRIVATE_KEY,
+ MUX_SIGNING_KEY_ID: process.env.MUX_SIGNING_KEY_ID,
+ MUX_SIGNING_PRIVATE_KEY: process.env.MUX_SIGNING_PRIVATE_KEY,
+ RAILWAY_ACCESS_KEY_ID: process.env.RAILWAY_ACCESS_KEY_ID,
+ RAILWAY_SECRET_ACCESS_KEY: process.env.RAILWAY_SECRET_ACCESS_KEY,
+ RAILWAY_ENDPOINT: process.env.RAILWAY_ENDPOINT,
+ RAILWAY_PUBLIC_URL: process.env.RAILWAY_PUBLIC_URL,
+ RAILWAY_PUBLIC_URL_INCLUDE_BUCKET:
+ process.env.RAILWAY_PUBLIC_URL_INCLUDE_BUCKET,
+ RAILWAY_BUCKET_NAME: process.env.RAILWAY_BUCKET_NAME,
+ RAILWAY_REGION: process.env.RAILWAY_REGION,
+ STRIPE_PRICE_BASIC_MONTHLY: process.env.STRIPE_PRICE_BASIC_MONTHLY,
+ STRIPE_PRICE_PRO_MONTHLY: process.env.STRIPE_PRICE_PRO_MONTHLY,
+ },
+ emptyStringAsUndefined: true,
+});
+
+export function getMuxSigningKey() {
+ return requireFirstEnvValue(
+ "MUX_SIGNING_KEY",
+ env.MUX_SIGNING_KEY,
+ "MUX_SIGNING_KEY_ID",
+ env.MUX_SIGNING_KEY_ID,
+ );
+}
+
+export function getMuxPrivateKey() {
+ return requireFirstEnvValue(
+ "MUX_PRIVATE_KEY",
+ env.MUX_PRIVATE_KEY,
+ "MUX_SIGNING_PRIVATE_KEY",
+ env.MUX_SIGNING_PRIVATE_KEY,
+ );
+}
+
+export function getRailwayPublicUrl() {
+ return env.RAILWAY_PUBLIC_URL ?? env.RAILWAY_ENDPOINT;
+}
+
+export function getStripePriceIdForEnvVar(
+ variableName: "STRIPE_PRICE_BASIC_MONTHLY" | "STRIPE_PRICE_PRO_MONTHLY",
+) {
+ return requireEnvValue(variableName, env[variableName]);
+}
diff --git a/convex/mux.ts b/convex/mux.ts
index 99383bee..a2a03107 100644
--- a/convex/mux.ts
+++ b/convex/mux.ts
@@ -1,51 +1,17 @@
"use node";
import Mux from "@mux/mux-node";
-
-function requireEnv(name: string): string {
- const value = process.env[name];
- if (!value) {
- throw new Error(`Missing required environment variable: ${name}`);
- }
- return value;
-}
-
-function readEnv(...names: string[]): string | null {
- for (const name of names) {
- const value = process.env[name];
- if (value) {
- return value;
- }
- }
- return null;
-}
+import { env, getMuxPrivateKey, getMuxSigningKey } from "./env";
function normalizePrivateKey(value: string): string {
return value.includes("\\n") ? value.replace(/\\n/g, "\n") : value;
}
function getMuxJwtCredentials(): { keyId: string; keySecret: string } {
- const keyId = readEnv(
- "MUX_SIGNING_KEY",
- "MUX_SIGNING_KEY_ID",
- );
- if (!keyId) {
- throw new Error(
- "Missing required environment variable: MUX_SIGNING_KEY (or legacy MUX_SIGNING_KEY_ID)",
- );
- }
-
- const keySecret = readEnv(
- "MUX_PRIVATE_KEY",
- "MUX_SIGNING_PRIVATE_KEY",
- );
- if (!keySecret) {
- throw new Error(
- "Missing required environment variable: MUX_PRIVATE_KEY (or legacy MUX_SIGNING_PRIVATE_KEY)",
- );
- }
-
- return { keyId, keySecret: normalizePrivateKey(keySecret) };
+ return {
+ keyId: getMuxSigningKey(),
+ keySecret: normalizePrivateKey(getMuxPrivateKey()),
+ };
}
let cachedMux: Mux | null = null;
@@ -54,8 +20,8 @@ export function getMuxClient(): Mux {
if (cachedMux) return cachedMux;
cachedMux = new Mux({
- tokenId: requireEnv("MUX_TOKEN_ID"),
- tokenSecret: requireEnv("MUX_TOKEN_SECRET"),
+ tokenId: env.MUX_TOKEN_ID,
+ tokenSecret: env.MUX_TOKEN_SECRET,
});
return cachedMux;
@@ -148,9 +114,7 @@ export function verifyMuxWebhookSignature(rawBody: string, signature: string | n
}
const mux = getMuxClient();
- const webhookSecret = requireEnv("MUX_WEBHOOK_SECRET");
-
mux.webhooks.verifySignature(rawBody, {
"mux-signature": signature,
- }, webhookSecret);
+ }, env.MUX_WEBHOOK_SECRET);
}
diff --git a/convex/s3.ts b/convex/s3.ts
index 3970d471..8923527a 100644
--- a/convex/s3.ts
+++ b/convex/s3.ts
@@ -1,17 +1,14 @@
import { S3Client } from "@aws-sdk/client-s3";
+import { env, getRailwayPublicUrl } from "./env";
-export const BUCKET_NAME = process.env.RAILWAY_BUCKET_NAME || "videos";
+export const BUCKET_NAME = env.RAILWAY_BUCKET_NAME;
function getBasePublicUrl(): string {
- const baseUrl = process.env.RAILWAY_PUBLIC_URL || process.env.RAILWAY_ENDPOINT;
- if (!baseUrl) {
- throw new Error("Missing RAILWAY_PUBLIC_URL or RAILWAY_ENDPOINT for bucket URLs");
- }
- return baseUrl;
+ return getRailwayPublicUrl();
}
export function buildPublicUrl(key: string): string {
- const includeBucket = process.env.RAILWAY_PUBLIC_URL_INCLUDE_BUCKET !== "false";
+ const includeBucket = env.RAILWAY_PUBLIC_URL_INCLUDE_BUCKET !== "false";
const url = new URL(getBasePublicUrl());
const basePath = url.pathname.endsWith("/")
? url.pathname.slice(0, -1)
@@ -22,19 +19,12 @@ export function buildPublicUrl(key: string): string {
}
export function getS3Client(): S3Client {
- const accessKeyId = process.env.RAILWAY_ACCESS_KEY_ID;
- const secretAccessKey = process.env.RAILWAY_SECRET_ACCESS_KEY;
-
- if (!accessKeyId || !secretAccessKey) {
- throw new Error("Missing Railway S3 credentials");
- }
-
return new S3Client({
- region: process.env.RAILWAY_REGION || "us-east-1",
- endpoint: process.env.RAILWAY_ENDPOINT,
+ region: env.RAILWAY_REGION,
+ endpoint: env.RAILWAY_ENDPOINT,
credentials: {
- accessKeyId,
- secretAccessKey,
+ accessKeyId: env.RAILWAY_ACCESS_KEY_ID,
+ secretAccessKey: env.RAILWAY_SECRET_ACCESS_KEY,
},
forcePathStyle: true,
});
diff --git a/convex/schema.ts b/convex/schema.ts
index 4b2de9e1..4ed933ae 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -88,6 +88,9 @@ export default defineSchema({
thumbnailUrl: v.optional(v.string()),
fileSize: v.optional(v.number()),
contentType: v.optional(v.string()),
+ multipartUploadId: v.optional(v.string()),
+ uploadPartSizeBytes: v.optional(v.number()),
+ uploadTotalParts: v.optional(v.number()),
uploadError: v.optional(v.string()),
status: v.union(
v.literal("uploading"),
diff --git a/convex/videoActions.ts b/convex/videoActions.ts
index 57a27daa..c0675a0c 100644
--- a/convex/videoActions.ts
+++ b/convex/videoActions.ts
@@ -1,10 +1,13 @@
"use node";
import {
+ AbortMultipartUploadCommand,
+ CompleteMultipartUploadCommand,
+ CreateMultipartUploadCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
- PutObjectCommand,
+ UploadPartCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { v } from "convex/values";
@@ -20,8 +23,10 @@ import {
} from "./mux";
import { BUCKET_NAME, getS3Client } from "./s3";
-const GIBIBYTE = 1024 ** 3;
-const MAX_PRESIGNED_PUT_FILE_SIZE_BYTES = 5 * GIBIBYTE;
+const MEBIBYTE = 1024 ** 2;
+const DEFAULT_MULTIPART_PART_SIZE_BYTES = 16 * MEBIBYTE;
+const MAX_MULTIPART_PARTS = 10_000;
+const MAX_PART_URLS_PER_REQUEST = 32;
const ALLOWED_UPLOAD_CONTENT_TYPES = new Set([
"video/mp4",
"video/quicktime",
@@ -152,10 +157,6 @@ function validateUploadRequestOrThrow(args: { fileSize: number; contentType: str
throw new Error("Video file size must be greater than zero.");
}
- if (args.fileSize > MAX_PRESIGNED_PUT_FILE_SIZE_BYTES) {
- throw new Error("Video file is too large for direct upload.");
- }
-
const normalizedContentType = normalizeContentType(args.contentType);
if (!isAllowedUploadContentType(normalizedContentType)) {
throw new Error("Unsupported video format. Allowed: mp4, mov, webm, mkv.");
@@ -164,16 +165,122 @@ function validateUploadRequestOrThrow(args: { fileSize: number; contentType: str
return normalizedContentType;
}
+function buildUploadKey(videoId: Id<"videos">, filename: string) {
+ const ext = getExtensionFromKey(filename);
+ return `videos/${videoId}/${Date.now()}.${ext}`;
+}
+
+function getMultipartPartSizeBytes(fileSize: number) {
+ const minimumSizeForPartCount = Math.ceil(fileSize / MAX_MULTIPART_PARTS);
+ const targetPartSize = Math.max(
+ DEFAULT_MULTIPART_PART_SIZE_BYTES,
+ minimumSizeForPartCount,
+ );
+ return Math.ceil(targetPartSize / MEBIBYTE) * MEBIBYTE;
+}
+
+function validatePartNumberOrThrow(partNumber: number) {
+ if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > MAX_MULTIPART_PARTS) {
+ throw new Error("Invalid multipart upload part number.");
+ }
+ return partNumber;
+}
+
+function validatePartNumbersOrThrow(partNumbers: number[]) {
+ if (partNumbers.length === 0) {
+ throw new Error("At least one multipart upload part number is required.");
+ }
+
+ if (partNumbers.length > MAX_PART_URLS_PER_REQUEST) {
+ throw new Error("Too many multipart upload parts requested at once.");
+ }
+
+ const seenPartNumbers = new Set();
+ return partNumbers.map((partNumber) => {
+ const normalizedPartNumber = validatePartNumberOrThrow(partNumber);
+ if (seenPartNumbers.has(normalizedPartNumber)) {
+ throw new Error("Duplicate multipart upload part number.");
+ }
+ seenPartNumbers.add(normalizedPartNumber);
+ return normalizedPartNumber;
+ });
+}
+
+function normalizeCompletedPartsOrThrow(
+ parts: Array<{ partNumber: number; etag: string }>,
+ expectedPartCount?: number,
+) {
+ if (parts.length === 0) {
+ throw new Error("At least one completed multipart upload part is required.");
+ }
+
+ if (expectedPartCount !== undefined && parts.length !== expectedPartCount) {
+ throw new Error("Multipart upload completed with the wrong number of parts.");
+ }
+
+ const seenPartNumbers = new Set();
+ return [...parts]
+ .map((part) => {
+ const partNumber = validatePartNumberOrThrow(part.partNumber);
+ if (expectedPartCount !== undefined && partNumber > expectedPartCount) {
+ throw new Error("Multipart upload completed with an unexpected part number.");
+ }
+ const etag = part.etag.trim();
+ if (!etag) {
+ throw new Error(`Missing multipart upload ETag for part ${partNumber}.`);
+ }
+ if (seenPartNumbers.has(partNumber)) {
+ throw new Error("Duplicate completed multipart upload part.");
+ }
+ seenPartNumbers.add(partNumber);
+ return { partNumber, etag };
+ })
+ .sort((a, b) => a.partNumber - b.partNumber);
+}
+
+async function requireMatchingUploadKey(
+ ctx: ActionCtx,
+ params: {
+ videoId: Id<"videos">;
+ key: string;
+ multipartUploadId?: string;
+ },
+) {
+ const video = await ctx.runQuery(api.videos.getVideoForPlayback, {
+ videoId: params.videoId,
+ });
+
+ if (!video?.s3Key) {
+ throw new Error("Upload session not found for this video.");
+ }
+
+ if (video.s3Key !== params.key) {
+ throw new Error("Upload session no longer matches this video.");
+ }
+
+ if (
+ params.multipartUploadId !== undefined &&
+ video.multipartUploadId !== params.multipartUploadId
+ ) {
+ throw new Error("Multipart upload session no longer matches this video.");
+ }
+
+ return video;
+}
+
function shouldDeleteUploadedObjectOnFailure(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
+ const message = error.message.toLowerCase();
return (
- error.message.includes("Unsupported video format") ||
- error.message.includes("Video file is too large") ||
- error.message.includes("Uploaded video file not found") ||
- error.message.includes("Storage limit reached")
+ message.includes("unsupported video format") ||
+ message.includes("file is too large") ||
+ message.includes("uploaded video file not found") ||
+ message.includes("uploaded video file size did not match") ||
+ message.includes("multipart upload completed with") ||
+ message.includes("storage limit")
);
}
@@ -233,7 +340,7 @@ async function ensurePublicPlaybackId(
return resolvedPlaybackId;
}
-export const getUploadUrl = action({
+export const createMultipartUpload = action({
args: {
videoId: v.id("videos"),
filename: v.string(),
@@ -241,8 +348,10 @@ export const getUploadUrl = action({
contentType: v.string(),
},
returns: v.object({
- url: v.string(),
- uploadId: v.string(),
+ key: v.string(),
+ multipartUploadId: v.string(),
+ partSizeBytes: v.number(),
+ totalParts: v.number(),
}),
handler: async (ctx, args) => {
await requireVideoMemberAccess(ctx, args.videoId);
@@ -251,24 +360,173 @@ export const getUploadUrl = action({
contentType: args.contentType,
});
+ const key = buildUploadKey(args.videoId, args.filename);
+ const partSizeBytes = getMultipartPartSizeBytes(args.fileSize);
+ const totalParts = Math.max(1, Math.ceil(args.fileSize / partSizeBytes));
const s3 = getS3Client();
- const ext = getExtensionFromKey(args.filename);
- const key = `videos/${args.videoId}/${Date.now()}.${ext}`;
- const command = new PutObjectCommand({
- Bucket: BUCKET_NAME,
- Key: key,
- ContentType: normalizedContentType,
- });
- const url = await getSignedUrl(s3, command, { expiresIn: 3600 });
+ const result = await s3.send(
+ new CreateMultipartUploadCommand({
+ Bucket: BUCKET_NAME,
+ Key: key,
+ ContentType: normalizedContentType,
+ }),
+ );
+
+ if (!result.UploadId) {
+ throw new Error("Could not create multipart upload session.");
+ }
await ctx.runMutation(internal.videos.setUploadInfo, {
videoId: args.videoId,
s3Key: key,
fileSize: args.fileSize,
contentType: normalizedContentType,
+ multipartUploadId: result.UploadId,
+ uploadPartSizeBytes: partSizeBytes,
+ uploadTotalParts: totalParts,
});
- return { url, uploadId: key };
+ return {
+ key,
+ multipartUploadId: result.UploadId,
+ partSizeBytes,
+ totalParts,
+ };
+ },
+});
+
+export const getMultipartUploadPartUrls = action({
+ args: {
+ videoId: v.id("videos"),
+ key: v.string(),
+ multipartUploadId: v.string(),
+ partNumbers: v.array(v.number()),
+ },
+ returns: v.object({
+ parts: v.array(
+ v.object({
+ partNumber: v.number(),
+ url: v.string(),
+ }),
+ ),
+ }),
+ handler: async (ctx, args) => {
+ await requireVideoMemberAccess(ctx, args.videoId);
+ const video = await requireMatchingUploadKey(ctx, {
+ videoId: args.videoId,
+ key: args.key,
+ multipartUploadId: args.multipartUploadId,
+ });
+ const normalizedPartNumbers = validatePartNumbersOrThrow(args.partNumbers);
+ const expectedTotalParts = video.uploadTotalParts;
+ if (
+ expectedTotalParts !== undefined &&
+ normalizedPartNumbers.some((partNumber) => partNumber > expectedTotalParts)
+ ) {
+ throw new Error("Requested multipart upload part is outside this upload session.");
+ }
+
+ const s3 = getS3Client();
+ const parts = await Promise.all(
+ normalizedPartNumbers.map(async (partNumber) => ({
+ partNumber,
+ url: await getSignedUrl(
+ s3,
+ new UploadPartCommand({
+ Bucket: BUCKET_NAME,
+ Key: args.key,
+ UploadId: args.multipartUploadId,
+ PartNumber: partNumber,
+ }),
+ { expiresIn: 3600 },
+ ),
+ })),
+ );
+
+ return { parts };
+ },
+});
+
+export const completeMultipartUpload = action({
+ args: {
+ videoId: v.id("videos"),
+ key: v.string(),
+ multipartUploadId: v.string(),
+ parts: v.array(
+ v.object({
+ partNumber: v.number(),
+ etag: v.string(),
+ }),
+ ),
+ },
+ returns: v.object({
+ success: v.boolean(),
+ }),
+ handler: async (ctx, args) => {
+ await requireVideoMemberAccess(ctx, args.videoId);
+ const video = await requireMatchingUploadKey(ctx, {
+ videoId: args.videoId,
+ key: args.key,
+ multipartUploadId: args.multipartUploadId,
+ });
+
+ const completedParts = normalizeCompletedPartsOrThrow(
+ args.parts,
+ video.uploadTotalParts,
+ );
+ const s3 = getS3Client();
+ await s3.send(
+ new CompleteMultipartUploadCommand({
+ Bucket: BUCKET_NAME,
+ Key: args.key,
+ UploadId: args.multipartUploadId,
+ MultipartUpload: {
+ Parts: completedParts.map((part) => ({
+ PartNumber: part.partNumber,
+ ETag: part.etag,
+ })),
+ },
+ }),
+ );
+
+ return { success: true };
+ },
+});
+
+export const abortMultipartUpload = action({
+ args: {
+ videoId: v.id("videos"),
+ key: v.string(),
+ multipartUploadId: v.string(),
+ },
+ returns: v.object({
+ success: v.boolean(),
+ }),
+ handler: async (ctx, args) => {
+ await requireVideoMemberAccess(ctx, args.videoId);
+ await requireMatchingUploadKey(ctx, {
+ videoId: args.videoId,
+ key: args.key,
+ multipartUploadId: args.multipartUploadId,
+ });
+
+ const s3 = getS3Client();
+ try {
+ await s3.send(
+ new AbortMultipartUploadCommand({
+ Bucket: BUCKET_NAME,
+ Key: args.key,
+ UploadId: args.multipartUploadId,
+ }),
+ );
+ } catch (error) {
+ if (error instanceof Error && error.name === "NoSuchUpload") {
+ return { success: true };
+ }
+ throw error;
+ }
+
+ return { success: true };
},
});
@@ -307,8 +565,12 @@ export const markUploadComplete = action({
throw new Error("Uploaded video file not found or empty.");
}
const contentLength = contentLengthRaw;
- if (contentLength > MAX_PRESIGNED_PUT_FILE_SIZE_BYTES) {
- throw new Error("Video file is too large for direct upload.");
+ if (
+ typeof video.fileSize === "number" &&
+ Number.isFinite(video.fileSize) &&
+ contentLength !== video.fileSize
+ ) {
+ throw new Error("Uploaded video file size did not match the requested upload.");
}
const normalizedContentType = normalizeContentType(
diff --git a/convex/videos.ts b/convex/videos.ts
index 81377db1..df99e86c 100644
--- a/convex/videos.ts
+++ b/convex/videos.ts
@@ -4,7 +4,10 @@ import { identityName, requireProjectAccess, requireVideoAccess } from "./auth";
import { Id } from "./_generated/dataModel";
import { generateUniqueToken } from "./security";
import { resolveActiveShareGrant } from "./shareAccess";
-import { assertTeamCanStoreBytes } from "./billingHelpers";
+import {
+ assertTeamCanStoreBytes,
+ assertTeamCanUploadFileBytes,
+} from "./billingHelpers";
const workflowStatusValidator = v.union(
v.literal("review"),
@@ -59,7 +62,9 @@ export const create = mutation({
},
handler: async (ctx, args) => {
const { user, project } = await requireProjectAccess(ctx, args.projectId, "member");
- await assertTeamCanStoreBytes(ctx, project.teamId, args.fileSize ?? 0);
+ const fileSize = args.fileSize ?? 0;
+ await assertTeamCanUploadFileBytes(ctx, project.teamId, fileSize);
+ await assertTeamCanStoreBytes(ctx, project.teamId, fileSize);
const publicId = await generatePublicId(ctx);
const videoId = await ctx.db.insert("videos", {
@@ -327,6 +332,9 @@ export const setUploadInfo = internalMutation({
s3Key: v.string(),
fileSize: v.number(),
contentType: v.string(),
+ multipartUploadId: v.string(),
+ uploadPartSizeBytes: v.number(),
+ uploadTotalParts: v.number(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.videoId, {
@@ -340,6 +348,9 @@ export const setUploadInfo = internalMutation({
uploadError: undefined,
fileSize: args.fileSize,
contentType: args.contentType,
+ multipartUploadId: args.multipartUploadId,
+ uploadPartSizeBytes: args.uploadPartSizeBytes,
+ uploadTotalParts: args.uploadTotalParts,
status: "uploading",
});
},
@@ -369,6 +380,8 @@ export const reconcileUploadedObjectMetadata = internalMutation({
const actualSize = Number.isFinite(args.fileSize) ? Math.max(0, args.fileSize) : 0;
const sizeDelta = actualSize - declaredSize;
+ await assertTeamCanUploadFileBytes(ctx, project.teamId, actualSize);
+
if (sizeDelta > 0) {
await assertTeamCanStoreBytes(ctx, project.teamId, sizeDelta);
}
@@ -376,6 +389,9 @@ export const reconcileUploadedObjectMetadata = internalMutation({
await ctx.db.patch(args.videoId, {
fileSize: actualSize,
contentType: args.contentType,
+ multipartUploadId: undefined,
+ uploadPartSizeBytes: undefined,
+ uploadTotalParts: undefined,
});
},
});
@@ -423,6 +439,9 @@ export const markAsFailed = internalMutation({
await ctx.db.patch(args.videoId, {
muxAssetStatus: "errored",
uploadError: args.uploadError,
+ multipartUploadId: undefined,
+ uploadPartSizeBytes: undefined,
+ uploadTotalParts: undefined,
status: "failed",
});
},
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 3af53606..54031109 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -7,7 +7,7 @@ export default tseslint.config(js.configs.recommended, ...tseslint.configs.recom
".next/**",
"build/**",
"dist/**",
- "app/routeTree.gen.ts",
+ "src/app/routeTree.gen.ts",
"convex/_generated/**",
"coverage/**",
],
diff --git a/package.json b/package.json
index fd84bc65..b5ea534c 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"build": "vite build",
"build:vercel": "bunx convex deploy --cmd 'bun run build' --cmd-url-env-var-name VITE_CONVEX_URL",
"start": "vite preview --port 5296",
- "lint": "eslint app src convex --ignore-pattern 'convex/_generated/**'",
+ "lint": "eslint src convex --ignore-pattern 'convex/_generated/**'",
"typecheck": "tsc --noEmit",
"typecheck:convex": "bunx convex typecheck",
"generate:og": "bun run scripts/generate-og.tsx"
@@ -32,6 +32,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@stripe/stripe-js": "^8.7.0",
+ "@t3-oss/env-core": "^0.13.11",
"@tanstack/react-router": "^1.160.2",
"@tanstack/react-router-devtools": "^1.160.2",
"@tanstack/react-start": "^1.160.2",
diff --git a/plans/multipart-plan.md b/plans/multipart-plan.md
new file mode 100644
index 00000000..e7727f13
--- /dev/null
+++ b/plans/multipart-plan.md
@@ -0,0 +1,386 @@
+# Multipart Upload + Plan-Aware Limits Stack
+
+## Status
+
+- [x] Stack 1 — Base PR: multipart upload foundation
+- [ ] Stack 2 — Follow-up PR: plan-aware limits + better upload errors
+- [ ] Stack 3 — Final PR: copy and UI updates
+
+## Objective
+
+Roll out larger uploads in the correct order:
+
+1. **quietly add multipart upload support first** so the app can actually handle files above the current single-request ceiling
+2. **layer plan-aware validation and better error messages on top** once the transport is capable
+3. **finish with copy/UI updates** so the product accurately communicates the new limits
+
+This sequencing avoids promising 10GB / 50GB uploads before the upload pipeline can really support them.
+
+---
+
+## Why this order
+
+The current upload flow uses a single presigned S3 `PUT` from the browser:
+
+- `convex/videoActions.ts` issues one upload URL
+- `app/routes/dashboard/-useVideoUploadManager.ts` uploads the whole file with one `XMLHttpRequest`
+- `convex/videoActions.ts` currently enforces a hard cap of `5 * GIBIBYTE`
+
+That means the app cannot truthfully support 10GB or 50GB uploads until the transport changes to multipart upload.
+
+So the stack should be:
+
+1. **Capability first** — multipart upload foundation
+2. **Correctness second** — plan-aware size limits + better error messages
+3. **Communication last** — pricing/settings/home/upload copy updates
+
+---
+
+## Stack 1 — Base PR: multipart upload foundation
+
+**Status:** Implemented on the branch.
+
+### Landed changes
+
+- Replaced the dashboard uploader's single-request direct upload flow with S3 multipart upload.
+- Added multipart Convex actions in `convex/videoActions.ts`:
+ - `createMultipartUpload`
+ - `getMultipartUploadPartUrls`
+ - `completeMultipartUpload`
+ - `abortMultipartUpload`
+- Updated `app/routes/dashboard/-useVideoUploadManager.ts` to:
+ - split files into parts
+ - upload parts with bounded parallelism
+ - batch presigned part URL requests
+ - aggregate progress and ETA across multipart uploads
+ - abort in-flight uploads and clean up multipart sessions on failure/cancel
+- Removed the old 5 GiB direct-upload bottleneck from the main upload path and from post-upload verification.
+- Kept the existing `markUploadComplete` handoff into S3 verification and Mux ingest.
+
+## Goal
+
+Replace the current single-request direct upload flow with S3 multipart upload so files larger than 5 GiB are technically possible.
+
+This PR should be mostly infrastructural and intentionally light on visible product changes.
+
+## Scope
+
+### Backend
+
+Primary file:
+- `convex/videoActions.ts`
+
+Add multipart actions for the upload lifecycle:
+
+1. `createMultipartUpload`
+ - verify member access to the video/project
+ - create the S3 multipart upload
+ - return enough metadata for the client to upload parts
+ - persist upload metadata if needed for cleanup/completion
+
+2. `getMultipartUploadPartUrl`
+ - presign an `UploadPart` request for a specific part number
+ - validate the caller can continue the upload
+
+3. `completeMultipartUpload`
+ - accept uploaded part metadata from the client
+ - complete the multipart upload in S3
+ - return success metadata to the client
+
+4. `abortMultipartUpload`
+ - allow cancellation and cleanup of incomplete multipart uploads
+ - should be safe to call on cancellation/error paths
+
+Keep existing post-upload processing logic:
+- `markUploadComplete` should remain the handoff into:
+ - object existence verification
+ - metadata reconciliation
+ - transition to `processing`
+ - Mux ingest kickoff
+
+### Frontend
+
+Primary file:
+- `app/routes/dashboard/-useVideoUploadManager.ts`
+
+Replace the single XHR `PUT` flow with multipart upload logic:
+
+1. split each file into chunks
+2. request part URLs from the backend
+3. upload parts directly to S3
+4. collect `ETag` values per part
+5. complete the multipart upload
+6. call `markUploadComplete`
+
+### Upload behavior requirements
+
+- preserve current upload tray UX
+- preserve cancellation support
+- preserve progress percentages and ETA
+- support aggregate progress across parts
+- use limited parallelism for performance
+- avoid obvious waterfalls where possible
+
+### Suggested implementation details
+
+- chunk size: something in the ~8–16 MiB range
+- concurrency: small fixed parallelism, e.g. 3–5 parts at a time
+- retries: optional for this base PR, but at least structure the code so retries can be added without rewriting the uploader
+- cancellation:
+ - abort in-flight browser requests
+ - call `abortMultipartUpload`
+ - mark the upload failed/cleaned up consistently
+
+## Non-goals for this PR
+
+- no new pricing or marketing copy
+- no new user-facing plan messaging
+- no visible 10GB / 50GB promise yet
+- no attempt to perfect all upload error copy yet
+
+## Acceptance criteria
+
+1. uploads no longer depend on a single presigned `PUT`
+2. the old 5 GiB direct-upload bottleneck is removed from the main upload path
+3. upload progress and cancellation still work in the dashboard upload tray
+4. completed uploads still flow into the existing S3 verification + Mux ingestion path
+5. small uploads continue to work reliably with no UX regression
+
+## Likely files touched
+
+- `convex/videoActions.ts`
+- `app/routes/dashboard/-useVideoUploadManager.ts`
+- possibly `convex/videos.ts` if extra upload metadata needs to be stored
+- possibly `convex/schema.ts` if multipart state must be persisted
+
+---
+
+## Stack 2 — Follow-up PR: plan-aware limits + better upload errors
+
+## Goal
+
+Now that multipart exists, enforce the requested per-plan file-size limits and return much better upload errors for the two important failure classes:
+
+1. **file too large for the plan**
+2. **file would push the team over its storage allowance**
+
+## Requested product behavior
+
+- Basic plan: **max file size 10GB**
+- Pro plan: **max file size 50GB**
+
+This PR should make those limits real in application logic.
+
+## Scope
+
+### Billing/domain constants
+
+Primary file:
+- `convex/billingHelpers.ts`
+
+Add a new source of truth for per-file upload limits:
+
+- `TEAM_PLAN_MAX_FILE_SIZE_BYTES`
+ - `basic: 10 * GIBIBYTE`
+ - `pro: 50 * GIBIBYTE`
+
+Keep existing storage limits as separate concerns:
+- `TEAM_PLAN_STORAGE_LIMIT_BYTES`
+ - basic storage remains 100GB
+ - pro storage remains 1TB
+
+Consider adding small shared helpers for:
+- formatting bytes into readable GB/TB strings
+- formatting plan labels consistently
+
+### Plan-aware file size validation
+
+Primary file:
+- `convex/videos.ts`
+
+In `create(...)`:
+- validate the incoming file size against the team’s plan-specific max file size before upload begins
+- continue to validate storage availability with `assertTeamCanStoreBytes(...)`
+- fail early so the user doesn’t start a huge upload only to be rejected later
+
+### Final backend sanity check after upload
+
+Primary file:
+- `convex/videoActions.ts`
+
+In the post-upload verification path:
+- verify the uploaded object actually exists
+- verify the final object size is valid
+- verify the uploaded object is still within the team’s per-file plan limit
+- keep content-type validation in place
+
+This ensures the client is not the only layer enforcing limits.
+
+### Better storage-limit errors
+
+Primary file:
+- `convex/billingHelpers.ts`
+
+Improve `assertTeamCanStoreBytes(...)` to produce richer, actionable messages.
+
+Instead of a generic message like:
+- `Storage limit reached for the basic plan. Upgrade to continue uploading.`
+
+Prefer messages that include:
+- plan name
+- current storage usage
+- storage limit
+- incoming file size
+- recommended action
+
+Example target:
+- `This upload would exceed your team's Basic plan storage limit. You're using 96 GB of 100 GB, and this file is 8 GB. Upgrade to Pro or delete old videos to free up space.`
+
+### Better “file too large” errors
+
+Return plan-aware messages for file size failures.
+
+Example targets:
+
+Basic:
+- `This file is too large for the Basic plan. Basic supports files up to 10 GB. Upgrade to Pro for files up to 50 GB.`
+
+Pro:
+- `This file is too large for the Pro plan. Pro supports files up to 50 GB.`
+
+### Frontend error handling
+
+Primary file:
+- `app/routes/dashboard/-useVideoUploadManager.ts`
+
+Improve the upload manager so upload tray errors are clearer and less raw.
+
+Potential improvements:
+- normalize backend errors into cleaner display text when needed
+- optionally add client-side prechecks before starting multipart upload for instant feedback
+- keep inline upload errors attached to the relevant upload item
+
+Client-side prechecks are useful for convenience, but backend validation remains the source of truth.
+
+## Acceptance criteria
+
+1. Basic users cannot upload files over 10GB
+2. Pro users cannot upload files over 50GB
+3. users get a clearly different error for:
+ - per-file plan max violation
+ - total storage limit violation
+4. post-upload verification still rejects invalid uploads even if the client misbehaves
+5. upload tray surfaces actionable errors instead of generic failures
+
+## Likely files touched
+
+- `convex/billingHelpers.ts`
+- `convex/videos.ts`
+- `convex/videoActions.ts`
+- `app/routes/dashboard/-useVideoUploadManager.ts`
+- maybe `src/components/upload/UploadProgress.tsx` if tiny copy polish is needed
+
+---
+
+## Stack 3 — Final PR: copy and UI updates
+
+## Goal
+
+Update visible plan copy so the product accurately communicates the new per-file upload limits and aligns with the backend behavior.
+
+This PR should mostly be presentation and messaging.
+
+## Scope
+
+### Billing/settings UI
+
+Primary file:
+- `app/routes/dashboard/-settings.tsx`
+
+Update plan cards/details to show both:
+- storage included
+- max file size per plan
+
+Target messaging:
+- Basic: `100GB storage` + `Max file size 10GB`
+- Pro: `1TB storage` + `Max file size 50GB`
+
+### Pricing page
+
+Primary file:
+- `app/routes/-pricing.tsx`
+
+Update pricing cards to include the new per-file max.
+
+### Homepage pricing section
+
+Primary file:
+- `app/routes/-home.tsx`
+
+Update the homepage pricing cards / structured copy so it also reflects the new file-size limits.
+
+### Optional upload entry-point hints
+
+Possible file:
+- `src/components/upload/DropZone.tsx`
+
+Optional lightweight UX improvement:
+- show supported formats more explicitly
+- optionally mention that max file size depends on plan, or show the concrete limit if team billing context is available
+
+This should stay small and not create extra complexity unless the data is already easy to access.
+
+## Acceptance criteria
+
+1. settings/billing UI reflects the new per-file limits
+2. pricing page reflects the new per-file limits
+3. homepage pricing copy reflects the new per-file limits
+4. no visible copy conflicts remain between product marketing and actual app behavior
+
+## Likely files touched
+
+- `app/routes/dashboard/-settings.tsx`
+- `app/routes/-pricing.tsx`
+- `app/routes/-home.tsx`
+- optionally `src/components/upload/DropZone.tsx`
+
+---
+
+## Cross-stack notes
+
+## Data and terminology
+
+The codebase currently uses binary units:
+- `GIBIBYTE = 1024 ** 3`
+- `TEBIBYTE = 1024 ** 4`
+
+UI may still say `GB` / `TB` for simplicity, but implementation should remain internally consistent.
+
+## Important separation of concerns
+
+There are now two different kinds of size limits:
+
+1. **Per-file limit**
+ - Basic: 10GB
+ - Pro: 50GB
+
+2. **Total team storage limit**
+ - Basic: 100GB
+ - Pro: 1TB
+
+Error messages and UI copy should make these feel clearly different.
+
+## Rollout recommendation
+
+1. land **Stack 1** first with minimal product noise
+2. stack **Stack 2** on top so the new capability is enforced correctly
+3. stack **Stack 3** last so public and in-app copy only changes once the system is truly ready
+
+## Success criteria for the full stack
+
+1. uploads above 5 GiB are technically supported by the transport layer
+2. Basic can upload up to 10GB files
+3. Pro can upload up to 50GB files
+4. users get clear, actionable upload errors
+5. billing/pricing/home surfaces all reflect the same limits
+6. upload UX remains fast and cancelable with no major regression
diff --git a/app/app.css b/src/app/app.css
similarity index 100%
rename from app/app.css
rename to src/app/app.css
diff --git a/app/routeTree.gen.ts b/src/app/routeTree.gen.ts
similarity index 100%
rename from app/routeTree.gen.ts
rename to src/app/routeTree.gen.ts
diff --git a/app/router.tsx b/src/app/router.tsx
similarity index 100%
rename from app/router.tsx
rename to src/app/router.tsx
diff --git a/app/routes/-compare-frameio.tsx b/src/app/routes/-compare-frameio.tsx
similarity index 100%
rename from app/routes/-compare-frameio.tsx
rename to src/app/routes/-compare-frameio.tsx
diff --git a/app/routes/-compare-wipster.tsx b/src/app/routes/-compare-wipster.tsx
similarity index 100%
rename from app/routes/-compare-wipster.tsx
rename to src/app/routes/-compare-wipster.tsx
diff --git a/app/routes/-for-agencies.tsx b/src/app/routes/-for-agencies.tsx
similarity index 100%
rename from app/routes/-for-agencies.tsx
rename to src/app/routes/-for-agencies.tsx
diff --git a/app/routes/-for-video-editors.tsx b/src/app/routes/-for-video-editors.tsx
similarity index 100%
rename from app/routes/-for-video-editors.tsx
rename to src/app/routes/-for-video-editors.tsx
diff --git a/app/routes/-home.tsx b/src/app/routes/-home.tsx
similarity index 97%
rename from app/routes/-home.tsx
rename to src/app/routes/-home.tsx
index 7c1ffbe1..0a72265e 100644
--- a/app/routes/-home.tsx
+++ b/src/app/routes/-home.tsx
@@ -259,7 +259,8 @@ export default function Homepage() {
✓ Unlimited seats
✓ Unlimited projects
✓ Unlimited clients
- ✓ 100GB Storage
+ ✓ 100GB storage
+ ✓ 10GB max file size
Get Basic
@@ -278,7 +279,8 @@ export default function Homepage() {
✓ Unlimited seats
✓ Unlimited projects
✓ Unlimited clients
- ✓ 1TB Storage (Whoa)
+ ✓ 1TB storage
+ ✓ 50GB max file size
Get Pro
@@ -326,7 +328,7 @@ export default function Homepage() {
price: "5.00",
priceCurrency: "USD",
description:
- "Unlimited seats, unlimited projects, unlimited clients, 100GB storage",
+ "Unlimited seats, unlimited projects, unlimited clients, 100GB storage, 10GB max file size",
},
{
"@type": "Offer",
@@ -334,7 +336,7 @@ export default function Homepage() {
price: "25.00",
priceCurrency: "USD",
description:
- "Unlimited seats, unlimited projects, unlimited clients, 1TB storage",
+ "Unlimited seats, unlimited projects, unlimited clients, 1TB storage, 50GB max file size",
},
],
creator: {
diff --git a/app/routes/-invite.data.ts b/src/app/routes/-invite.data.ts
similarity index 100%
rename from app/routes/-invite.data.ts
rename to src/app/routes/-invite.data.ts
diff --git a/app/routes/-invite.tsx b/src/app/routes/-invite.tsx
similarity index 100%
rename from app/routes/-invite.tsx
rename to src/app/routes/-invite.tsx
diff --git a/app/routes/-pricing.tsx b/src/app/routes/-pricing.tsx
similarity index 94%
rename from app/routes/-pricing.tsx
rename to src/app/routes/-pricing.tsx
index 30ff30a4..eb8859de 100644
--- a/app/routes/-pricing.tsx
+++ b/src/app/routes/-pricing.tsx
@@ -48,7 +48,11 @@ export default function PricingPage() {
✓{" "}
- 100GB Storage
+ 100GB storage
+
+
+ ✓{" "}
+ 10GB max file size
@@ -91,8 +95,12 @@ export default function PricingPage() {
Unlimited clients
- ✓ 1TB
- Storage
+ ✓{" "}
+ 1TB storage
+
+
+ ✓{" "}
+ 50GB max file size
diff --git a/app/routes/-share.data.ts b/src/app/routes/-share.data.ts
similarity index 100%
rename from app/routes/-share.data.ts
rename to src/app/routes/-share.data.ts
diff --git a/app/routes/-share.tsx b/src/app/routes/-share.tsx
similarity index 100%
rename from app/routes/-share.tsx
rename to src/app/routes/-share.tsx
diff --git a/app/routes/-watch.data.ts b/src/app/routes/-watch.data.ts
similarity index 100%
rename from app/routes/-watch.data.ts
rename to src/app/routes/-watch.data.ts
diff --git a/app/routes/-watch.tsx b/src/app/routes/-watch.tsx
similarity index 100%
rename from app/routes/-watch.tsx
rename to src/app/routes/-watch.tsx
diff --git a/app/routes/__root.tsx b/src/app/routes/__root.tsx
similarity index 94%
rename from app/routes/__root.tsx
rename to src/app/routes/__root.tsx
index d2682276..e963aa8c 100644
--- a/app/routes/__root.tsx
+++ b/src/app/routes/__root.tsx
@@ -11,6 +11,7 @@ import { ConvexClientProvider } from "@/lib/convex";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ThemeProvider } from "@/components/theme/ThemeToggle";
import { NotFound } from "@/components/ui/NotFound";
+import { env } from "@/env/client";
import appCss from "../app.css?url";
export const Route = createRootRoute({
@@ -64,14 +65,8 @@ function RootComponent() {
}
function AppShell({ children }: { children: ReactNode }) {
- const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
-
- if (!publishableKey) {
- throw new Error("Missing VITE_CLERK_PUBLISHABLE_KEY");
- }
-
return (
-
+
{children}
);
diff --git a/app/routes/auth/-layout.tsx b/src/app/routes/auth/-layout.tsx
similarity index 100%
rename from app/routes/auth/-layout.tsx
rename to src/app/routes/auth/-layout.tsx
diff --git a/app/routes/auth/-sign-in.tsx b/src/app/routes/auth/-sign-in.tsx
similarity index 100%
rename from app/routes/auth/-sign-in.tsx
rename to src/app/routes/auth/-sign-in.tsx
diff --git a/app/routes/auth/-sign-up.tsx b/src/app/routes/auth/-sign-up.tsx
similarity index 100%
rename from app/routes/auth/-sign-up.tsx
rename to src/app/routes/auth/-sign-up.tsx
diff --git a/app/routes/compare.frameio.tsx b/src/app/routes/compare.frameio.tsx
similarity index 100%
rename from app/routes/compare.frameio.tsx
rename to src/app/routes/compare.frameio.tsx
diff --git a/app/routes/compare.wipster.tsx b/src/app/routes/compare.wipster.tsx
similarity index 100%
rename from app/routes/compare.wipster.tsx
rename to src/app/routes/compare.wipster.tsx
diff --git a/app/routes/dashboard/$teamSlug.$projectId.$videoId.tsx b/src/app/routes/dashboard/$teamSlug.$projectId.$videoId.tsx
similarity index 100%
rename from app/routes/dashboard/$teamSlug.$projectId.$videoId.tsx
rename to src/app/routes/dashboard/$teamSlug.$projectId.$videoId.tsx
diff --git a/app/routes/dashboard/$teamSlug.$projectId.index.tsx b/src/app/routes/dashboard/$teamSlug.$projectId.index.tsx
similarity index 100%
rename from app/routes/dashboard/$teamSlug.$projectId.index.tsx
rename to src/app/routes/dashboard/$teamSlug.$projectId.index.tsx
diff --git a/app/routes/dashboard/$teamSlug.$projectId.tsx b/src/app/routes/dashboard/$teamSlug.$projectId.tsx
similarity index 100%
rename from app/routes/dashboard/$teamSlug.$projectId.tsx
rename to src/app/routes/dashboard/$teamSlug.$projectId.tsx
diff --git a/app/routes/dashboard/$teamSlug.index.tsx b/src/app/routes/dashboard/$teamSlug.index.tsx
similarity index 100%
rename from app/routes/dashboard/$teamSlug.index.tsx
rename to src/app/routes/dashboard/$teamSlug.index.tsx
diff --git a/app/routes/dashboard/$teamSlug.settings.tsx b/src/app/routes/dashboard/$teamSlug.settings.tsx
similarity index 100%
rename from app/routes/dashboard/$teamSlug.settings.tsx
rename to src/app/routes/dashboard/$teamSlug.settings.tsx
diff --git a/app/routes/dashboard/$teamSlug.tsx b/src/app/routes/dashboard/$teamSlug.tsx
similarity index 100%
rename from app/routes/dashboard/$teamSlug.tsx
rename to src/app/routes/dashboard/$teamSlug.tsx
diff --git a/app/routes/dashboard/-index.data.ts b/src/app/routes/dashboard/-index.data.ts
similarity index 100%
rename from app/routes/dashboard/-index.data.ts
rename to src/app/routes/dashboard/-index.data.ts
diff --git a/app/routes/dashboard/-layout.tsx b/src/app/routes/dashboard/-layout.tsx
similarity index 93%
rename from app/routes/dashboard/-layout.tsx
rename to src/app/routes/dashboard/-layout.tsx
index 7af49d43..d71a29f0 100644
--- a/app/routes/dashboard/-layout.tsx
+++ b/src/app/routes/dashboard/-layout.tsx
@@ -1,23 +1,16 @@
import { useAuth } from "@clerk/tanstack-react-start";
-import { useConvex, useQuery } from "convex/react";
-import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
+import { useQuery } from "convex/react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "@convex/_generated/api";
import type { Id } from "@convex/_generated/dataModel";
import {
Outlet,
- Link,
useLocation,
useParams,
} from "@tanstack/react-router";
import { cn } from "@/lib/utils";
-import {
- dashboardHomePath,
- teamHomePath,
- teamSettingsPath,
-} from "@/lib/routes";
-import { useRoutePrewarmIntent } from "@/lib/useRoutePrewarmIntent";
import {
Dialog,
DialogContent,
@@ -26,9 +19,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { UploadProgress } from "@/components/upload/UploadProgress";
-import { prewarmDashboardIndex } from "./-index.data";
-import { prewarmSettings } from "./-settings.data";
-import { prewarmTeam } from "./-team.data";
import { useVideoUploadManager } from "./-useVideoUploadManager";
import { DashboardUploadProvider } from "@/lib/dashboardUploadContext";
@@ -52,7 +42,6 @@ export default function DashboardLayout() {
const location = useLocation();
const { pathname, searchStr } = location;
const params = useParams({ strict: false });
- const convex = useConvex();
const teamSlug =
typeof params.teamSlug === "string" ? params.teamSlug : undefined;
const routeProjectId =
@@ -65,8 +54,6 @@ export default function DashboardLayout() {
api.videos.getPublicIdByVideoId,
routeVideoId ? { videoId: routeVideoId } : "skip",
);
- const teamHome = teamSlug ? teamHomePath(teamSlug) : null;
- const settingsPath = teamSlug ? teamSettingsPath(teamSlug) : null;
const uploadTargets = useQuery(
api.projects.listUploadTargets,
teamSlug ? { teamSlug } : {},
diff --git a/app/routes/dashboard/-project.data.ts b/src/app/routes/dashboard/-project.data.ts
similarity index 100%
rename from app/routes/dashboard/-project.data.ts
rename to src/app/routes/dashboard/-project.data.ts
diff --git a/app/routes/dashboard/-project.tsx b/src/app/routes/dashboard/-project.tsx
similarity index 99%
rename from app/routes/dashboard/-project.tsx
rename to src/app/routes/dashboard/-project.tsx
index 6244016e..a88171ae 100644
--- a/app/routes/dashboard/-project.tsx
+++ b/src/app/routes/dashboard/-project.tsx
@@ -1,15 +1,13 @@
import { useAction, useConvex, useMutation, useQuery } from "convex/react";
import { api } from "@convex/_generated/api";
-import { Link, useLocation, useNavigate } from "@tanstack/react-router";
+import { useLocation, useNavigate } from "@tanstack/react-router";
import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
import { DropZone } from "@/components/upload/DropZone";
-import { UploadProgress } from "@/components/upload/UploadProgress";
import { UploadButton } from "@/components/upload/UploadButton";
import { formatDuration, formatRelativeTime } from "@/lib/utils";
import { triggerDownload } from "@/lib/download";
import {
- ArrowLeft,
Play,
MoreVertical,
Trash2,
@@ -136,8 +134,7 @@ export default function ProjectPage({
api.videoPresence.listProjectOnlineCounts,
resolvedProjectId ? { projectId: resolvedProjectId } : "skip",
);
- const { requestUpload, uploads } =
- useDashboardUploadContext();
+ const { requestUpload } = useDashboardUploadContext();
const deleteVideo = useMutation(api.videos.remove);
const updateVideoWorkflowStatus = useMutation(api.videos.updateWorkflowStatus);
const getDownloadUrl = useAction(api.videoActions.getDownloadUrl);
diff --git a/app/routes/dashboard/-routeDataContracts.test.ts b/src/app/routes/dashboard/-routeDataContracts.test.ts
similarity index 100%
rename from app/routes/dashboard/-routeDataContracts.test.ts
rename to src/app/routes/dashboard/-routeDataContracts.test.ts
diff --git a/app/routes/dashboard/-settings.data.ts b/src/app/routes/dashboard/-settings.data.ts
similarity index 100%
rename from app/routes/dashboard/-settings.data.ts
rename to src/app/routes/dashboard/-settings.data.ts
diff --git a/app/routes/dashboard/-settings.tsx b/src/app/routes/dashboard/-settings.tsx
similarity index 96%
rename from app/routes/dashboard/-settings.tsx
rename to src/app/routes/dashboard/-settings.tsx
index 85cb6bbf..6b980a3d 100644
--- a/app/routes/dashboard/-settings.tsx
+++ b/src/app/routes/dashboard/-settings.tsx
@@ -25,6 +25,7 @@ const BILLING_PLANS: Record<
label: string;
monthlyPriceUsd: number;
storageLimitBytes: number;
+ maxFileSizeBytes: number;
seats: string;
}
> = {
@@ -32,12 +33,14 @@ const BILLING_PLANS: Record<
label: "Basic",
monthlyPriceUsd: 5,
storageLimitBytes: 100 * GIBIBYTE,
+ maxFileSizeBytes: 10 * GIBIBYTE,
seats: "Unlimited",
},
pro: {
label: "Pro",
monthlyPriceUsd: 25,
storageLimitBytes: TEBIBYTE,
+ maxFileSizeBytes: 50 * GIBIBYTE,
seats: "Unlimited",
},
};
@@ -52,8 +55,13 @@ function normalizeTeamPlan(plan: string): BillingPlan {
}
function formatBytes(bytes: number): string {
- if (bytes >= TEBIBYTE) return `${(bytes / TEBIBYTE).toFixed(1)} TB`;
- return `${(bytes / GIBIBYTE).toFixed(1)} GB`;
+ if (bytes >= TEBIBYTE) {
+ const value = bytes / TEBIBYTE;
+ return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} TB`;
+ }
+
+ const value = bytes / GIBIBYTE;
+ return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)} GB`;
}
function formatUtcDateFromUnixSeconds(unixSeconds: number): string {
@@ -324,7 +332,7 @@ export default function TeamSettingsPage() {
{/* ── Stats strip ── */}
-
+
Plan
@@ -373,6 +381,14 @@ export default function TeamSettingsPage() {
{planConfig.seats}
+
+
+ Max file
+
+
+ {formatBytes(planConfig.maxFileSizeBytes)}
+
+
{/* ── Two-column: Plans + Members ── */}
@@ -425,6 +441,7 @@ export default function TeamSettingsPage() {
>
{config.seats} seats
{formatBytes(config.storageLimitBytes)} storage
+
{formatBytes(config.maxFileSizeBytes)} max file size
{isOwner && !hasActiveSubscription && (
- MP4, MOV, WebM supported
+ MP4, MOV, WebM, MKV supported
diff --git a/src/env/client.ts b/src/env/client.ts
new file mode 100644
index 00000000..cc54313e
--- /dev/null
+++ b/src/env/client.ts
@@ -0,0 +1,19 @@
+import { createEnv } from "@t3-oss/env-core";
+import { z } from "zod";
+
+const requiredString = z.string().min(1);
+
+export const env = createEnv({
+ clientPrefix: "VITE_",
+ client: {
+ VITE_CONVEX_URL: requiredString.url(),
+ VITE_CONVEX_SITE_URL: requiredString.url().optional(),
+ VITE_CLERK_PUBLISHABLE_KEY: requiredString,
+ },
+ runtimeEnvStrict: {
+ VITE_CONVEX_URL: import.meta.env.VITE_CONVEX_URL,
+ VITE_CONVEX_SITE_URL: import.meta.env.VITE_CONVEX_SITE_URL,
+ VITE_CLERK_PUBLISHABLE_KEY: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY,
+ },
+ emptyStringAsUndefined: true,
+});
diff --git a/src/lib/convex.tsx b/src/lib/convex.tsx
index 5db9a657..984cbfc2 100644
--- a/src/lib/convex.tsx
+++ b/src/lib/convex.tsx
@@ -4,14 +4,9 @@ import { ConvexReactClient } from "convex/react";
import { ConvexProviderWithClerk } from "convex/react-clerk";
import { useAuth } from "@clerk/tanstack-react-start";
import { ReactNode } from "react";
+import { env } from "@/env/client";
-const convexUrl = import.meta.env.VITE_CONVEX_URL;
-
-if (!convexUrl) {
- throw new Error("Missing VITE_CONVEX_URL");
-}
-
-const convex = new ConvexReactClient(convexUrl);
+const convex = new ConvexReactClient(env.VITE_CONVEX_URL);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return (
diff --git a/src/lib/convexRouteData.test.ts b/src/lib/convexRouteData.test.ts
index 4488e012..4845d13f 100644
--- a/src/lib/convexRouteData.test.ts
+++ b/src/lib/convexRouteData.test.ts
@@ -11,7 +11,7 @@ import {
prewarmSpecs,
resetPrewarmDedupeForTests,
} from "@/lib/convexRouteData";
-import { prewarmTeam } from "../../app/routes/dashboard/-team.data";
+import { prewarmTeam } from "@/app/routes/dashboard/-team.data";
test("prewarmSpecs dedupes within the dedupe window", () => {
resetPrewarmDedupeForTests();
diff --git a/vite.config.ts b/vite.config.ts
index 76c31921..89f6beb6 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -9,7 +9,7 @@ export default defineConfig({
projects: ["./tsconfig.json"],
}),
tanstackStart({
- srcDirectory: "app",
+ srcDirectory: "src/app",
spa: {
enabled: true,
maskPath: "/mono",