Skip to content

Commit 3790b30

Browse files
riderxclaude
andauthored
feat: implement subdomain-based bundle preview (#1362)
* feat: implement subdomain-based bundle preview for assets to work correctly Add wildcard subdomain routes (*.preview.capgo.app) to serve bundle previews from root path, allowing all assets (JS, CSS, etc.) to work correctly without path rewriting. New preview_subdomain handler uses cookie-based auth to persist tokens across asset requests. Format: {app_id}-{version_id}.preview.capgo.app 🤖 Generated with Claude Code Co-Authored-By: Claude Haiku 4.5 <[email protected]> * refactor: consolidate preview handlers into single subdomain-based file Remove redundant path-based preview handler and keep only the subdomain approach. Rename preview_subdomain.ts to preview.ts for simplicity. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix: correct import order in preview.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> --------- Co-authored-by: Claude Haiku 4.5 <[email protected]>
1 parent 91ba747 commit 3790b30

File tree

3 files changed

+111
-18
lines changed

3 files changed

+111
-18
lines changed

cloudflare_workers/files/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,24 @@ export { AttachmentUploadHandler, UploadHandler } from '../../supabase/functions
1212
const functionName = 'files'
1313
const app = createHono(functionName, version, env.SENTRY_DSN)
1414

15+
// Check if request is from a preview subdomain (*.preview[.env].capgo.app)
16+
function isPreviewSubdomain(hostname: string): boolean {
17+
return /^[^.]+\.preview(?:\.[^.]+)?\.(?:capgo\.app|usecapgo\.com)$/.test(hostname)
18+
}
19+
20+
// Middleware to route preview subdomain requests
21+
app.use('/*', async (c, next) => {
22+
const hostname = c.req.header('host') || ''
23+
if (isPreviewSubdomain(hostname)) {
24+
// Route all requests from preview subdomains to the subdomain handler
25+
return preview.fetch(c.req.raw, c.env, c.executionCtx)
26+
}
27+
return next()
28+
})
29+
1530
// Files API
1631
app.route('/files', files)
1732
app.route('/ok', ok)
18-
app.route('/preview', preview)
1933

2034
// TODO: remove deprecated path when all users have been migrated
2135
app.route('/private/download_link', download_link)

cloudflare_workers/files/wrangler.jsonc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@
4848
"pattern": "files.capgo.app",
4949
"custom_domain": true
5050
},
51+
// Wildcard subdomain for bundle preview - serves files from root so assets work
52+
// Format: {app_id}-{version_id}.preview.capgo.app
53+
{
54+
"pattern": "*.preview.capgo.app",
55+
"zone_name": "capgo.app"
56+
},
5157
// No special domain to ease clients who allow specific domains only
5258
{
5359
"pattern": "api.capgo.app/files*",
@@ -140,6 +146,10 @@
140146
}
141147
],
142148
"routes": [
149+
{
150+
"pattern": "*.preview.preprod.capgo.app",
151+
"zone_name": "capgo.app"
152+
},
143153
{
144154
"pattern": "api.preprod.capgo.app/files*",
145155
"zone_name": "capgo.app"
@@ -188,6 +198,10 @@
188198
"enabled": true
189199
},
190200
"routes": [
201+
{
202+
"pattern": "*.preview.dev.capgo.app",
203+
"zone_name": "capgo.app"
204+
},
191205
{
192206
"pattern": "api.dev.capgo.app/files*",
193207
"zone_name": "capgo.app"

supabase/functions/_backend/files/preview.ts

Lines changed: 82 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Context } from 'hono'
22
import type { MiddlewareKeyVariables } from '../utils/hono.ts'
33
import { getRuntimeKey } from 'hono/adapter'
4+
import { getCookie, setCookie } from 'hono/cookie'
45
import { Hono } from 'hono/tiny'
56
import { simpleError, useCors } from '../utils/hono.ts'
67
import { cloudlog } from '../utils/logging.ts'
@@ -39,32 +40,72 @@ function getContentType(filePath: string): string {
3940
return MIME_TYPES[ext] || 'application/octet-stream'
4041
}
4142

43+
// Cookie name for storing the auth token
44+
const TOKEN_COOKIE_NAME = 'capgo_preview_token'
45+
46+
// Parse subdomain format: {app_id_with_dots_as_underscores}-{version_id}.preview[.env].capgo.app
47+
// Example: ee__forgr__capacitor_go-222063.preview.capgo.app
48+
function parsePreviewSubdomain(hostname: string): { appId: string, versionId: number } | null {
49+
// Match pattern: {something}.preview[.optional-env].capgo.app or usecapgo.com
50+
const match = hostname.match(/^([^.]+)\.preview(?:\.[^.]+)?\.(?:capgo\.app|usecapgo\.com)$/)
51+
if (!match)
52+
return null
53+
54+
const subdomain = match[1]
55+
// Split by last hyphen to get app_id and version_id
56+
// app_id has dots replaced with double underscores
57+
const lastHyphen = subdomain.lastIndexOf('-')
58+
if (lastHyphen === -1)
59+
return null
60+
61+
const appIdEncoded = subdomain.substring(0, lastHyphen)
62+
const versionIdStr = subdomain.substring(lastHyphen + 1)
63+
64+
// Decode app_id: replace __ with .
65+
const appId = appIdEncoded.replace(/__/g, '.')
66+
const versionId = Number.parseInt(versionIdStr, 10)
67+
68+
if (!appId || Number.isNaN(versionId))
69+
return null
70+
71+
return { appId, versionId }
72+
}
73+
4274
export const app = new Hono<MiddlewareKeyVariables>()
4375

4476
app.use('/*', useCors)
4577

46-
// GET /preview/:app_id/:version_id or GET /preview/:app_id/:version_id/*filepath
47-
// Note: We don't use middlewareAuth here because iframes can't send headers
48-
// Instead, we accept the token from either Authorization header or query param
49-
app.get('/:app_id/:version_id', handlePreview)
50-
app.get('/:app_id/:version_id/*', handlePreview)
78+
// Handle all requests from subdomain - files are served from root
79+
app.get('/*', handlePreviewSubdomain)
80+
81+
async function handlePreviewSubdomain(c: Context<MiddlewareKeyVariables>) {
82+
const hostname = c.req.header('host') || ''
83+
const parsed = parsePreviewSubdomain(hostname)
5184

52-
async function handlePreview(c: Context<MiddlewareKeyVariables>) {
53-
const appId = c.req.param('app_id')
54-
const versionId = Number(c.req.param('version_id'))
55-
// Get the file path from the wildcard - default to index.html
56-
const rawFilePath = c.req.path.split(`/${appId}/${versionId}/`)[1] || 'index.html'
57-
const filePath = decodeURIComponent(rawFilePath)
85+
if (!parsed) {
86+
cloudlog({ requestId: c.get('requestId'), message: 'invalid preview subdomain', hostname })
87+
return simpleError('invalid_subdomain', 'Invalid preview subdomain format. Expected: {app_id}-{version_id}.preview.capgo.app')
88+
}
89+
90+
const { appId, versionId } = parsed
91+
92+
// Get the file path from the request path - default to index.html
93+
let filePath = c.req.path.slice(1) || 'index.html' // Remove leading slash
94+
filePath = decodeURIComponent(filePath)
95+
// Remove query string if present
96+
if (filePath.includes('?'))
97+
filePath = filePath.split('?')[0]
5898

59-
cloudlog({ requestId: c.get('requestId'), message: 'preview request', appId, versionId, filePath })
99+
cloudlog({ requestId: c.get('requestId'), message: 'preview subdomain request', hostname, appId, versionId, filePath })
60100

61-
// Accept token from Authorization header OR query param (for iframe support)
101+
// Accept token from: query param (first request), cookie (subsequent requests), or Authorization header
62102
const authorization = c.req.header('authorization')
63103
const tokenFromQuery = c.req.query('token')
64-
const token = authorization?.split('Bearer ')[1] || tokenFromQuery
104+
const tokenFromCookie = getCookie(c, TOKEN_COOKIE_NAME)
105+
const token = authorization?.split('Bearer ')[1] || tokenFromQuery || tokenFromCookie
65106

66107
if (!token)
67-
return simpleError('cannot_find_authorization', 'Cannot find authorization. Pass token as query param or Authorization header.')
108+
return simpleError('cannot_find_authorization', 'Cannot find authorization. Pass token as query param on first request.')
68109

69110
const { data: auth, error: authError } = await supabaseAdmin(c).auth.getUser(token)
70111
if (authError || !auth?.user?.id)
@@ -156,7 +197,6 @@ async function handlePreview(c: Context<MiddlewareKeyVariables>) {
156197
}
157198

158199
// Preview only works on Cloudflare Workers where the R2 bucket is available.
159-
// Supabase Edge Functions cannot serve HTML files properly due to platform limitations.
160200
if (getRuntimeKey() !== 'workerd') {
161201
cloudlog({ requestId: c.get('requestId'), message: 'preview not supported on Supabase Edge Functions' })
162202
return simpleError('preview_not_supported', 'Preview is not supported on Supabase Edge Functions. This feature requires Cloudflare Workers with R2 bucket access.')
@@ -183,11 +223,36 @@ async function handlePreview(c: Context<MiddlewareKeyVariables>) {
183223
headers.set('Cache-Control', 'private, max-age=3600')
184224
headers.set('X-Content-Type-Options', 'nosniff')
185225

186-
cloudlog({ requestId: c.get('requestId'), message: 'serving preview file from R2', filePath, contentType })
226+
cloudlog({ requestId: c.get('requestId'), message: 'serving preview file from R2 (subdomain)', filePath, contentType })
227+
228+
// If token came from query param, set it in a cookie for subsequent requests
229+
// This allows assets to load without needing the token in every URL
230+
if (tokenFromQuery && !tokenFromCookie) {
231+
// Set cookie with same-site strict for security, httpOnly to prevent JS access
232+
// Path=/ so it works for all paths in this subdomain
233+
setCookie(c, TOKEN_COOKIE_NAME, token, {
234+
path: '/',
235+
httpOnly: true,
236+
secure: true,
237+
sameSite: 'Strict',
238+
maxAge: 3600, // 1 hour, matches cache control
239+
})
240+
}
241+
187242
return new Response(object.body, { headers })
188243
}
189244
catch (error) {
190245
cloudlog({ requestId: c.get('requestId'), message: 'failed to serve preview file', error, s3_path: manifestEntry.s3_path })
191246
return simpleError('preview_failed', 'Failed to serve preview file')
192247
}
193248
}
249+
250+
// Export helper for generating preview URLs
251+
export function generatePreviewUrl(appId: string, versionId: number, env: 'prod' | 'preprod' | 'dev' = 'prod'): string {
252+
// Encode app_id: replace . with __
253+
const encodedAppId = appId.replace(/\./g, '__')
254+
const subdomain = `${encodedAppId}-${versionId}`
255+
256+
const envPrefix = env === 'prod' ? '' : `.${env}`
257+
return `https://${subdomain}.preview${envPrefix}.capgo.app`
258+
}

0 commit comments

Comments
 (0)