Skip to content

Commit bf25361

Browse files
riderxclaude
andauthored
Fix bundle preview URLs to serve through Cloudflare Workers (#1359)
* fix: fix bundle preview URLs to serve through Cloudflare Workers - Move preview endpoint to Cloudflare Workers API instead of Supabase Edge Functions for proper HTML serving - Add getObject function to S3 utility for fetching file content via presigned URL - Implement MIME type detection to ensure correct Content-Type headers (fixes R2 text/html → text/plain rewrite issue) - Support common file path prefixes (www/, public/, dist/) for manifest lookups - Update frontend to use defaultApiHost (Cloudflare Workers) instead of Supabase URL - Both Cloudflare Workers and Supabase paths now proxy file content instead of redirecting, preserving relative URLs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix: add ATTACHMENT_BUCKET to Bindings type for TypeScript 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix: resolve R2Bucket type incompatibility Remove explicit R2Bucket type import from @cloudflare/workers-types to use global R2Bucket type which is compatible with RetryBucket. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent c09eccf commit bf25361

File tree

5 files changed

+141
-15
lines changed

5 files changed

+141
-15
lines changed

cloudflare_workers/api/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { app as devices_priv } from '../../supabase/functions/_backend/private/d
88
import { app as events } from '../../supabase/functions/_backend/private/events.ts'
99
import { app as log_as } from '../../supabase/functions/_backend/private/log_as.ts'
1010
import { app as plans } from '../../supabase/functions/_backend/private/plans.ts'
11+
import { app as preview } from '../../supabase/functions/_backend/private/preview.ts'
1112
import { app as publicStats } from '../../supabase/functions/_backend/private/public_stats.ts'
1213
import { app as stats_priv } from '../../supabase/functions/_backend/private/stats.ts'
1314
import { app as storeTop } from '../../supabase/functions/_backend/private/store_top.ts'
@@ -77,6 +78,7 @@ appPrivate.route('/stripe_portal', stripe_portal)
7778
appPrivate.route('/delete_failed_version', deleted_failed_version)
7879
appPrivate.route('/create_device', create_device)
7980
appPrivate.route('/events', events)
81+
appPrivate.route('/preview', preview)
8082

8183
// Triggers
8284
const functionNameTriggers = 'triggers'

src/components/BundlePreviewFrame.vue

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useRoute } from 'vue-router'
66
import IconExpand from '~icons/lucide/expand'
77
import IconMinimize from '~icons/lucide/minimize-2'
88
import IconSmartphone from '~icons/lucide/smartphone'
9-
import { useSupabase } from '~/services/supabase'
9+
import { defaultApiHost, useSupabase } from '~/services/supabase'
1010
1111
const props = defineProps<{
1212
appId: string
@@ -75,11 +75,10 @@ const currentDevice = computed(() => devices[selectedDevice.value])
7575
7676
// Build the preview URL with auth token
7777
const previewUrl = computed(() => {
78-
const baseUrl = import.meta.env.VITE_SUPABASE_URL || ''
79-
// The preview endpoint is at /functions/v1/private/preview/:app_id/:version_id/
78+
// Use Cloudflare Workers API for preview (supports streaming and proper file serving)
8079
// Pass token as query param since iframes can't send headers
8180
const tokenParam = accessToken.value ? `?token=${accessToken.value}` : ''
82-
return `${baseUrl}/functions/v1/private/preview/${props.appId}/${props.versionId}/${tokenParam}`
81+
return `${defaultApiHost}/private/preview/${props.appId}/${props.versionId}/${tokenParam}`
8382
})
8483
8584
// Build URL for QR code (includes fullscreen param)

supabase/functions/_backend/private/preview.ts

Lines changed: 115 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,45 @@
11
import type { Context } from 'hono'
22
import type { MiddlewareKeyVariables } from '../utils/hono.ts'
3+
import { getRuntimeKey } from 'hono/adapter'
34
import { Hono } from 'hono/tiny'
5+
import { DEFAULT_RETRY_PARAMS, RetryBucket } from '../tus/retry.ts'
46
import { simpleError, useCors } from '../utils/hono.ts'
57
import { cloudlog } from '../utils/logging.ts'
68
import { s3 } from '../utils/s3.ts'
79
import { hasAppRight, supabaseAdmin } from '../utils/supabase.ts'
810

11+
// MIME type mapping for common file extensions
12+
const MIME_TYPES: Record<string, string> = {
13+
html: 'text/html',
14+
htm: 'text/html',
15+
css: 'text/css',
16+
js: 'application/javascript',
17+
mjs: 'application/javascript',
18+
json: 'application/json',
19+
png: 'image/png',
20+
jpg: 'image/jpeg',
21+
jpeg: 'image/jpeg',
22+
gif: 'image/gif',
23+
svg: 'image/svg+xml',
24+
ico: 'image/x-icon',
25+
webp: 'image/webp',
26+
woff: 'font/woff',
27+
woff2: 'font/woff2',
28+
ttf: 'font/ttf',
29+
eot: 'application/vnd.ms-fontobject',
30+
otf: 'font/otf',
31+
map: 'application/json',
32+
txt: 'text/plain',
33+
xml: 'application/xml',
34+
webmanifest: 'application/manifest+json',
35+
wasm: 'application/wasm',
36+
}
37+
38+
function getContentType(filePath: string): string {
39+
const ext = filePath.split('.').pop()?.toLowerCase() || ''
40+
return MIME_TYPES[ext] || 'application/octet-stream'
41+
}
42+
943
export const app = new Hono<MiddlewareKeyVariables>()
1044

1145
app.use('/*', useCors)
@@ -80,29 +114,99 @@ async function handlePreview(c: Context<MiddlewareKeyVariables>) {
80114
return simpleError('no_manifest', 'Bundle has no manifest and cannot be previewed')
81115
}
82116

83-
// Look up the file in manifest
84-
const { data: manifestEntry, error: manifestError } = await supabaseAdmin(c)
117+
// Look up the file in manifest - try exact match first, then with common prefixes
118+
let manifestEntry: { s3_path: string, file_name: string } | null = null
119+
120+
// Try exact match first
121+
const { data: exactMatch, error: exactError } = await supabaseAdmin(c)
85122
.from('manifest')
86123
.select('s3_path, file_name')
87124
.eq('app_version_id', versionId)
88125
.eq('file_name', filePath)
89126
.single()
90127

91-
if (manifestError || !manifestEntry) {
128+
if (!exactError && exactMatch) {
129+
manifestEntry = exactMatch
130+
}
131+
else {
132+
// Try with common prefixes (www/, public/, dist/)
133+
const prefixesToTry = ['www/', 'public/', 'dist/', '']
134+
for (const prefix of prefixesToTry) {
135+
const tryPath = prefix + filePath
136+
if (tryPath === filePath)
137+
continue // Already tried exact match
138+
139+
const { data: prefixMatch, error: prefixError } = await supabaseAdmin(c)
140+
.from('manifest')
141+
.select('s3_path, file_name')
142+
.eq('app_version_id', versionId)
143+
.eq('file_name', tryPath)
144+
.single()
145+
146+
if (!prefixError && prefixMatch) {
147+
manifestEntry = prefixMatch
148+
cloudlog({ requestId: c.get('requestId'), message: 'found file with prefix', originalPath: filePath, foundPath: tryPath })
149+
break
150+
}
151+
}
152+
}
153+
154+
if (!manifestEntry) {
92155
cloudlog({ requestId: c.get('requestId'), message: 'file not found in manifest', filePath, versionId })
93156
return simpleError('file_not_found', 'File not found in bundle', { filePath })
94157
}
95158

96-
// Generate a time-limited signed URL for this file (expires in 1 hour)
97-
// This is more secure than redirecting to the unauthenticated files endpoint
98-
const PREVIEW_URL_EXPIRY_SECONDS = 3600
159+
// Serve the file - use R2 bucket directly in Cloudflare Workers, or fetch via presigned URL in Supabase
99160
try {
100-
const signedUrl = await s3.getSignedUrl(c, manifestEntry.s3_path, PREVIEW_URL_EXPIRY_SECONDS)
101-
cloudlog({ requestId: c.get('requestId'), message: 'generated signed preview URL', filePath })
102-
return c.redirect(signedUrl, 302)
161+
if (getRuntimeKey() === 'workerd') {
162+
// Cloudflare Workers - use R2 bucket directly
163+
const bucket = c.env.ATTACHMENT_BUCKET
164+
if (!bucket) {
165+
cloudlog({ requestId: c.get('requestId'), message: 'preview bucket is null' })
166+
return simpleError('bucket_not_configured', 'Storage bucket not configured')
167+
}
168+
169+
const object = await new RetryBucket(bucket, DEFAULT_RETRY_PARAMS).get(manifestEntry.s3_path)
170+
if (!object) {
171+
cloudlog({ requestId: c.get('requestId'), message: 'file not found in R2', s3_path: manifestEntry.s3_path })
172+
return simpleError('file_not_found', 'File not found in storage', { filePath })
173+
}
174+
175+
// Use our own MIME type detection - R2 rewrites text/html to text/plain without custom domains
176+
const contentType = getContentType(filePath)
177+
const headers = new Headers()
178+
headers.set('Content-Type', contentType)
179+
headers.set('etag', object.httpEtag)
180+
headers.set('Cache-Control', 'private, max-age=3600')
181+
headers.set('X-Content-Type-Options', 'nosniff')
182+
183+
cloudlog({ requestId: c.get('requestId'), message: 'serving preview file from R2', filePath, contentType })
184+
return new Response(object.body, { headers })
185+
}
186+
else {
187+
// Supabase Edge Functions - fetch via presigned URL
188+
const response = await s3.getObject(c, manifestEntry.s3_path)
189+
if (!response) {
190+
cloudlog({ requestId: c.get('requestId'), message: 'failed to fetch file from S3', s3_path: manifestEntry.s3_path })
191+
return simpleError('file_fetch_failed', 'Failed to fetch file')
192+
}
193+
194+
// Use our MIME type detection based on file extension
195+
const contentType = getContentType(filePath)
196+
cloudlog({ requestId: c.get('requestId'), message: 'serving preview file via S3', filePath, contentType })
197+
198+
return new Response(response.body, {
199+
status: 200,
200+
headers: {
201+
'Content-Type': contentType,
202+
'Cache-Control': 'private, max-age=3600',
203+
'X-Content-Type-Options': 'nosniff',
204+
},
205+
})
206+
}
103207
}
104208
catch (error) {
105-
cloudlog({ requestId: c.get('requestId'), message: 'failed to generate signed URL', error, s3_path: manifestEntry.s3_path })
106-
return simpleError('signed_url_failed', 'Failed to generate preview URL')
209+
cloudlog({ requestId: c.get('requestId'), message: 'failed to serve preview file', error, s3_path: manifestEntry.s3_path })
210+
return simpleError('preview_failed', 'Failed to serve preview file')
107211
}
108212
}

supabase/functions/_backend/utils/cloudflare.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type Bindings = {
2424
HYPERDRIVE_CAPGO_PS_AS: Hyperdrive // Add Hyperdrive binding
2525
HYPERDRIVE_CAPGO_PS_NA: Hyperdrive // Add Hyperdrive binding
2626
ATTACHMENT_UPLOAD_HANDLER: DurableObjectNamespace
27+
ATTACHMENT_BUCKET: R2Bucket
2728
}
2829

2930
const TRACK_DEVICE_USAGE_CACHE_PATH = '/.track-device-usage-cache'

supabase/functions/_backend/utils/s3.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,30 @@ async function getSize(c: Context, fileId: string) {
124124
}
125125
}
126126

127+
async function getObject(c: Context, fileId: string): Promise<Response | null> {
128+
const client = initS3(c)
129+
try {
130+
const url = await client.getPresignedUrl('GET', fileId, {
131+
expirySeconds: 60,
132+
})
133+
const response = await fetch(url)
134+
if (!response.ok) {
135+
cloudlog({ requestId: c.get('requestId'), message: 'getObject failed', fileId, status: response.status })
136+
return null
137+
}
138+
return response
139+
}
140+
catch (error) {
141+
cloudlog({ requestId: c.get('requestId'), message: 'getObject error', fileId, error })
142+
return null
143+
}
144+
}
145+
127146
export const s3 = {
128147
getSize,
129148
deleteObject,
130149
checkIfExist,
131150
getSignedUrl,
132151
getUploadUrl,
152+
getObject,
133153
}

0 commit comments

Comments
 (0)