From f3cf719f6e62cba8b9c24fd50aa34ccf52ff6fd5 Mon Sep 17 00:00:00 2001 From: "xan.lol" Date: Sat, 8 Aug 2026 03:15:59 -0700 Subject: [PATCH] Cloudflare Pages dynamic embeds --- docs/cloudflare-embed-routing.md | 83 +++++ functions/__embed/profile/[handleOrDID].ts | 1 + .../profile/[handleOrDID]/post/[rkey].ts | 1 + functions/_shared/embed-cache.test.ts | 17 + functions/_shared/embed-cache.ts | 122 +++++++ functions/_shared/post-embed.test.ts | 72 ++++ functions/_shared/post-embed.ts | 309 ++++++++++++++++++ .../profile-embed.ts} | 90 +++-- .../profile/[handleOrDID]/post/[rkey].ts | 225 ------------- rspack.config.ts | 3 + web/_routes.json | 5 + 11 files changed, 670 insertions(+), 258 deletions(-) create mode 100644 docs/cloudflare-embed-routing.md create mode 100644 functions/__embed/profile/[handleOrDID].ts create mode 100644 functions/__embed/profile/[handleOrDID]/post/[rkey].ts create mode 100644 functions/_shared/embed-cache.test.ts create mode 100644 functions/_shared/embed-cache.ts create mode 100644 functions/_shared/post-embed.test.ts create mode 100644 functions/_shared/post-embed.ts rename functions/{profile/[handleOrDID].ts => _shared/profile-embed.ts} (63%) delete mode 100644 functions/profile/[handleOrDID]/post/[rkey].ts create mode 100644 web/_routes.json diff --git a/docs/cloudflare-embed-routing.md b/docs/cloudflare-embed-routing.md new file mode 100644 index 000000000..a97f400d6 --- /dev/null +++ b/docs/cloudflare-embed-routing.md @@ -0,0 +1,83 @@ +# Cloudflare embed routing + +The Pages Functions bundle is intentionally limited by `web/_routes.json` to +URLs below `/__embed/profile/`. Normal `/profile/...` navigation is served by +Pages as a static SPA request and does not count as a Workers request. + +The public URL still needs Open Graph metadata when a social service fetches a +shared link. Configure one zone-level **URL Rewrite Transform Rule** before +deploying these routes. + +## Transform Rule + +In **Cloudflare Dashboard > witchsky.app > Rules > Overview**, create a **URL +Rewrite Rule** named `Route link preview bots to embed functions`. + +Use this custom filter expression: + +```text +http.host eq "witchsky.app" +and http.request.method in {"GET" "HEAD"} +and starts_with(http.request.uri.path, "/profile/") +and ( + lower(http.user_agent) contains "bluesky cardyb" + or lower(http.user_agent) contains "discordbot" + or lower(http.user_agent) contains "facebookexternalhit" + or lower(http.user_agent) contains "linkedinbot" + or lower(http.user_agent) contains "mastodon" + or lower(http.user_agent) contains "skypeuripreview" + or lower(http.user_agent) contains "slackbot" + or lower(http.user_agent) contains "telegrambot" + or lower(http.user_agent) contains "twitterbot" + or lower(http.user_agent) contains "whatsapp" +) +``` + +Set **Path > Rewrite to > Dynamic** to: + +```text +concat("/__embed", http.request.uri.path) +``` + +Preserve the query string. The rewrite is internal, so visitors and link +preview services continue to see the original `/profile/...` URL. + +This uses no regular expressions and is available within the Transform Rules +allowance on Cloudflare's Free plan. + +To prevent anyone from invoking the internal route directly, add a WAF custom +rule with action **Block** and this expression: + +```text +starts_with(raw.http.request.uri.path, "/__embed/") +``` + +`raw.http.request.uri.path` remains the visitor's original path. It is +`/profile/...` for a request changed by the Transform Rule, but `/__embed/...` +for someone attempting to call the Function route directly. + +## Rollout and verification + +Create the Transform Rule first, then deploy the Pages project. After deploy: + +```sh +# Browser navigation: static SPA response; should not appear in Functions metrics. +curl -I -A 'Mozilla/5.0' https://witchsky.app/profile/ + +# Link preview: rewritten to the cached Pages Function response. +curl -I -A 'Twitterbot/1.0' https://witchsky.app/profile/ +``` + +Verify the crawler response contains the Open Graph tags with a GET request. +Repeat it in the same region and check `X-Witchsky-Embed-Cache`: the first +response should be `MISS` and a warmed response should be `HIT`. + +In **Workers & Pages > witchsky > Functions Metrics**, only crawler requests +should remain. Cache hits still invoke a Pages Function, but they avoid the +Bluesky API call and HTML rendering. Profile previews are cached for one hour; +immutable post previews are cached for one day. The route split is what removes +ordinary browser requests from the daily Workers request quota. + +If Functions ever exhaust the free daily quota, set the Pages project to +**Fail open** under **Settings > Runtime** so static application pages continue +to work. diff --git a/functions/__embed/profile/[handleOrDID].ts b/functions/__embed/profile/[handleOrDID].ts new file mode 100644 index 000000000..57da30476 --- /dev/null +++ b/functions/__embed/profile/[handleOrDID].ts @@ -0,0 +1 @@ +export {handleProfileEmbed as onRequest} from '../../_shared/profile-embed.ts' diff --git a/functions/__embed/profile/[handleOrDID]/post/[rkey].ts b/functions/__embed/profile/[handleOrDID]/post/[rkey].ts new file mode 100644 index 000000000..a09fa5b46 --- /dev/null +++ b/functions/__embed/profile/[handleOrDID]/post/[rkey].ts @@ -0,0 +1 @@ +export {handlePostEmbed as onRequest} from '../../../../_shared/post-embed.ts' diff --git a/functions/_shared/embed-cache.test.ts b/functions/_shared/embed-cache.test.ts new file mode 100644 index 000000000..f98395125 --- /dev/null +++ b/functions/_shared/embed-cache.test.ts @@ -0,0 +1,17 @@ +import {getPublicUrl} from './embed-cache' + +describe('getPublicUrl', () => { + it('removes the internal crawler prefix and tracking parameters', () => { + expect( + getPublicUrl( + 'https://witchsky.app/__embed/profile/alice.test/post/abc?utm_source=x#fragment', + ), + ).toBe('https://witchsky.app/profile/alice.test/post/abc') + }) + + it('leaves a public path public', () => { + expect(getPublicUrl('https://witchsky.app/profile/alice.test')).toBe( + 'https://witchsky.app/profile/alice.test', + ) + }) +}) diff --git a/functions/_shared/embed-cache.ts b/functions/_shared/embed-cache.ts new file mode 100644 index 000000000..a1a3ed2bc --- /dev/null +++ b/functions/_shared/embed-cache.ts @@ -0,0 +1,122 @@ +const INTERNAL_EMBED_PREFIX = '/__embed' +const EMBED_CACHE_VERSION = 'v1' + +const PROFILE_CACHE_SECONDS = 60 * 60 +const POST_CACHE_SECONDS = 24 * 60 * 60 +const STALE_CACHE_SECONDS = 7 * 24 * 60 * 60 + +type ExecutionContext = { + waitUntil(promise: Promise): void +} + +/** + * Transform Rules send crawlers to /__embed while keeping the shared URL in + * the address bar. Strip that internal prefix from canonical metadata and the + * cache key. + */ +export function getPublicUrl(requestUrl: string): string { + const url = new URL(requestUrl) + + if (url.pathname.startsWith(`${INTERNAL_EMBED_PREFIX}/`)) { + url.pathname = url.pathname.slice(INTERNAL_EMBED_PREFIX.length) + } + + // Tracking parameters do not change a profile or post preview. + url.search = '' + url.hash = '' + return url.toString() +} + +function getCacheKey(publicUrl: string): Request { + const cacheUrl = new URL(publicUrl) + cacheUrl.searchParams.set('__witchsky_embed_cache', EMBED_CACHE_VERSION) + return new Request(cacheUrl, {method: 'GET'}) +} + +function withCacheHeaders(response: Response, publicUrl: string): Response { + const headers = new Headers(response.headers) + + if (headers.get('Cache-Control')?.includes('no-store')) { + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) + } + + const cacheSeconds = new URL(publicUrl).pathname.includes('/post/') + ? POST_CACHE_SECONDS + : PROFILE_CACHE_SECONDS + + // Cache API only honors Cache-Control and does not support stale directives. + headers.set('Cache-Control', `public, max-age=${cacheSeconds}`) + headers.set( + 'Cloudflare-CDN-Cache-Control', + `public, max-age=${cacheSeconds}, stale-while-revalidate=${STALE_CACHE_SECONDS}, stale-if-error=${STALE_CACHE_SECONDS}`, + ) + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} + +function withoutBody(response: Response): Response { + return new Response(null, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) +} + +function withCacheStatus(response: Response, status: 'HIT' | 'MISS'): Response { + const headers = new Headers(response.headers) + headers.set('X-Witchsky-Embed-Cache', status) + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} + +export async function renderCachedEmbed({ + request, + executionContext, + render, +}: { + request: Request + executionContext: ExecutionContext + render: (publicUrl: string) => Promise +}): Promise { + const publicUrl = getPublicUrl(request.url) + + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response('Method not allowed', { + status: 405, + headers: {Allow: 'GET, HEAD'}, + }) + } + + const cacheKey = getCacheKey(publicUrl) + const cache = caches.default + const cached = await cache.match(cacheKey) + + if (cached) { + const response = withCacheStatus(cached, 'HIT') + return request.method === 'HEAD' ? withoutBody(response) : response + } + + const rendered = withCacheHeaders(await render(publicUrl), publicUrl) + + if ( + rendered.ok && + !rendered.headers.get('Cache-Control')?.includes('no-store') + ) { + executionContext.waitUntil(cache.put(cacheKey, rendered.clone())) + } + + const response = withCacheStatus(rendered, 'MISS') + return request.method === 'HEAD' ? withoutBody(response) : response +} diff --git a/functions/_shared/post-embed.test.ts b/functions/_shared/post-embed.test.ts new file mode 100644 index 000000000..50d015c9e --- /dev/null +++ b/functions/_shared/post-embed.test.ts @@ -0,0 +1,72 @@ +import {expandPostTextRich} from './post-embed' + +const author = { + did: 'did:plc:alice', + displayName: 'Alice', + handle: 'alice.test', +} + +describe('expandPostTextRich', () => { + it('expands shortened links using UTF-8 facet offsets', () => { + const text = 'Emoji 👋 example.com/long... done' + const segment = 'example.com/long...' + const byteStart = new TextEncoder().encode( + text.slice(0, text.indexOf(segment)), + ).length + const byteEnd = byteStart + new TextEncoder().encode(segment).length + + expect( + expandPostTextRich({ + $type: 'app.bsky.feed.defs#threadViewPost', + post: { + author, + indexedAt: '2026-08-08T00:00:00.000Z', + record: { + text, + facets: [ + { + index: {byteStart, byteEnd}, + features: [ + { + $type: 'app.bsky.richtext.facet#link', + uri: 'https://example.com/longer-destination', + }, + ], + }, + ], + }, + }, + }), + ).toBe('Emoji 👋 https://example.com/longer-destination done') + }) + + it('includes reply and quoted-post context', () => { + expect( + expandPostTextRich({ + $type: 'app.bsky.feed.defs#threadViewPost', + parent: { + $type: 'app.bsky.feed.defs#threadViewPost', + post: { + author: {...author, displayName: 'Parent'}, + indexedAt: '2026-08-08T00:00:00.000Z', + }, + }, + post: { + author, + embed: { + $type: 'app.bsky.embed.record#view', + record: { + $type: 'app.bsky.embed.record#viewRecord', + author: {...author, displayName: 'Quoted'}, + value: {text: 'quoted text'}, + }, + }, + indexedAt: '2026-08-08T00:00:00.000Z', + record: {text: 'reply text'}, + }, + }), + ).toBe( + 'â†Šī¸ reply to Parent (@alice.test):\n\nreply text\n\nâ†˜ī¸ quoting Quoted (@alice.test):\n\nquoted text', + ) + }) +}) diff --git a/functions/_shared/post-embed.ts b/functions/_shared/post-embed.ts new file mode 100644 index 000000000..bfdc3c6d2 --- /dev/null +++ b/functions/_shared/post-embed.ts @@ -0,0 +1,309 @@ +import {renderCachedEmbed} from './embed-cache.ts' +import {html, type ProfileView, renderHandleString} from './profile-embed.ts' + +type Facet = { + features?: Array<{$type?: string; uri?: string}> + index?: {byteEnd?: number; byteStart?: number} +} + +type Embed = { + $type?: string + external?: {uri?: string} + images?: Array<{thumb?: string}> + record?: Embed | ViewRecord + thumbnail?: string +} + +type ViewRecord = { + $type?: string + author: ProfileView + value?: {text?: string} +} + +type Thread = { + $type?: string + parent?: Thread + post: { + author: ProfileView + embed?: Embed + indexedAt: string + record?: {facets?: Facet[]; text?: string} + } +} + +const TYPE = { + external: 'app.bsky.embed.external#view', + images: 'app.bsky.embed.images#view', + record: 'app.bsky.embed.record#view', + recordWithMedia: 'app.bsky.embed.recordWithMedia#view', + thread: 'app.bsky.feed.defs#threadViewPost', + viewRecord: 'app.bsky.embed.record#viewRecord', +} + +function isType( + value: T | undefined, + type: string, +): value is T { + return value?.$type === type +} + +function expandFacetedLinks(text: string, facets: Facet[]): string { + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const bytes = encoder.encode(text) + const sortedFacets = [...facets].sort( + (a, b) => (a.index?.byteStart ?? 0) - (b.index?.byteStart ?? 0), + ) + + let result = '' + let cursor = 0 + + for (const facet of sortedFacets) { + const start = facet.index?.byteStart + const end = facet.index?.byteEnd + if ( + typeof start !== 'number' || + typeof end !== 'number' || + start < cursor || + end < start || + end > bytes.length + ) { + continue + } + + result += decoder.decode(bytes.slice(cursor, start)) + const segmentText = decoder.decode(bytes.slice(start, end)) + const link = facet.features?.find( + feature => + feature.$type === 'app.bsky.richtext.facet#link' && feature.uri, + ) + + result += + link?.uri && + segmentText.endsWith('...') && + link.uri.includes(segmentText.slice(0, -3)) + ? link.uri + : segmentText + cursor = end + } + + result += decoder.decode(bytes.slice(cursor)) + return result +} + +export function expandPostTextRich(postView: Thread): string { + if (!postView.post) { + return '' + } + + const post = postView.post + const record = post.record + const embed = post.embed + const originalText = typeof record?.text === 'string' ? record.text : '' + const facets = record?.facets + + let expandedText = originalText + + // Use RichText to process facets if they exist + if (originalText && facets && facets.length > 0) { + try { + expandedText = expandFacetedLinks(originalText, facets) + } catch (error) { + console.error('Error processing RichText segments:', error) + // Fallback to original text on error + expandedText = originalText + } + } + + // Append external link URL if present and not already in text + if (isType(embed, TYPE.external) && embed?.external?.uri) { + const externalUri = embed.external.uri + if (!expandedText.includes(externalUri)) { + expandedText = expandedText + ? `${expandedText}\n${externalUri}` + : externalUri + } + } + + // Append placeholder for quote posts or other record embeds + if (isType(embed, TYPE.record) || isType(embed, TYPE.recordWithMedia)) { + const recordEmbed = embed?.record + const embeddedRecord = + recordEmbed && 'record' in recordEmbed + ? (recordEmbed.record ?? recordEmbed) + : recordEmbed + if (isType(embeddedRecord, TYPE.viewRecord)) { + const viewRecord = embeddedRecord as ViewRecord + const quote = `â†˜ī¸ quoting ${renderHandleString( + viewRecord.author, + )}:\n\n${viewRecord.value?.text ?? ''}` + expandedText = expandedText ? `${expandedText}\n\n${quote}` : quote + } else { + const placeholder = '[quote/embed]' + if (!expandedText.includes(placeholder)) { + expandedText = expandedText + ? `${expandedText}\n\n${placeholder}` + : placeholder + } + } + } + + // prepend reply header + const parent = postView.parent + if (isType(parent, TYPE.thread)) { + const header = `â†Šī¸ reply to ${renderHandleString(parent.post.author)}:` + expandedText = expandedText ? `${header}\n\n${expandedText}` : header + } + + return expandedText +} + +class HeadHandler { + thread: Thread + url: string + postTextString: string + constructor(thread: Thread, url: string, postTextString: string) { + this.thread = thread + this.url = url + this.postTextString = postTextString + } + async element(element) { + const author = this.thread.post.author + + const postText = + this.postTextString.length > 0 + ? html` + + + ` + : '' + + const embed = this.thread.post.embed + + const embedElems = !embed + ? '' + : isType(embed, TYPE.images) && embed.images?.length + ? html`${embed.images + .filter(image => image.thumb) + .map( + image => + html``, + )} + ` + : // TODO: in the future, embed videos + 'thumbnail' in embed && embed.thumbnail + ? html` + + + ` + : html`` + + element.append( + html` + + + + + + ${postText} ${embedElems} + + + + `, + {html: true}, + ) + } +} + +class TitleHandler { + thread: Thread + constructor(thread: Thread) { + this.thread = thread + } + async element(element) { + element.setInnerContent(renderHandleString(this.thread.post.author)) + } +} + +class NoscriptHandler { + thread: Thread + postTextString: string + constructor(thread: Thread, postTextString: string) { + this.thread = thread + this.postTextString = postTextString + } + async element(element) { + element.append( + html` +
+

Post

+

+ ${this.thread.post.author.displayName ?? ''} +

+

${this.thread.post.author.handle}

+

${this.thread.post.author.did}

+

${this.postTextString}

+

${this.thread.post.indexedAt}

+
+ `, + {html: true}, + ) + } +} + +export async function handlePostEmbed(context) { + const {request, env} = context + const {handleOrDID, rkey}: {handleOrDID: string; rkey: string} = + context.params + + return renderCachedEmbed({ + request, + executionContext: context, + async render(publicUrl) { + const origin = new URL(publicUrl).origin + const base = env.ASSETS.fetch(new URL('/', origin)) + + try { + const apiUrl = new URL( + 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread', + ) + apiUrl.searchParams.set( + 'uri', + `at://${handleOrDID}/app.bsky.feed.post/${rkey}`, + ) + apiUrl.searchParams.set('depth', '1') + apiUrl.searchParams.set('parentHeight', '1') + + const apiResponse = await fetch(apiUrl, { + headers: {Accept: 'application/json'}, + }) + if (!apiResponse.ok) { + throw new Error(`getPostThread returned ${apiResponse.status}`) + } + + const data = (await apiResponse.json()) as {thread?: Thread} + if (!isType(data.thread, TYPE.thread)) { + throw new Error('Expected a ThreadViewPost') + } + + const thread = data.thread + const postTextString = expandPostTextRich(thread) + return new HTMLRewriter() + .on(`head`, new HeadHandler(thread, publicUrl, postTextString)) + .on(`title`, new TitleHandler(thread)) + .on(`noscript`, new NoscriptHandler(thread, postTextString)) + .transform(await base) + } catch (error) { + console.error(error) + const baseResponse = await base + const response = new Response(baseResponse.body, baseResponse) + response.headers.set('Cache-Control', 'no-store') + return response + } + }, + }) +} diff --git a/functions/profile/[handleOrDID].ts b/functions/_shared/profile-embed.ts similarity index 63% rename from functions/profile/[handleOrDID].ts rename to functions/_shared/profile-embed.ts index cd43985d3..24ce44574 100644 --- a/functions/profile/[handleOrDID].ts +++ b/functions/_shared/profile-embed.ts @@ -1,8 +1,13 @@ -import {AtpAgent} from '@atproto/api' - -import {type AnyProfileView} from '#/types/bsky/profile' - -type PResp = Awaited> +import {renderCachedEmbed} from './embed-cache.ts' + +export type ProfileView = { + avatar?: string + banner?: string + description?: string + did: string + displayName?: string + handle: string +} // based on https://github.com/Janpot/escape-html-template-tag/blob/master/src/index.ts @@ -54,20 +59,20 @@ export function html(parts: TemplateStringsArray, ...subs: Sub[]) { return new HtmlSafeString(parts, subs) } -export const renderHandleString = (profile: AnyProfileView) => +export const renderHandleString = (profile: ProfileView) => profile.displayName ? `${profile.displayName} (@${profile.handle})` : `@${profile.handle}` class HeadHandler { - profile: PResp + profile: ProfileView url: string - constructor(profile: PResp, url: string) { + constructor(profile: ProfileView, url: string) { this.profile = profile this.url = url } async element(element) { - const view = this.profile.data + const view = this.profile const description = view.description ? html` @@ -103,22 +108,22 @@ class HeadHandler { } class TitleHandler { - profile: PResp - constructor(profile: PResp) { + profile: ProfileView + constructor(profile: ProfileView) { this.profile = profile } async element(element) { - element.setInnerContent(renderHandleString(this.profile.data)) + element.setInnerContent(renderHandleString(this.profile)) } } class NoscriptHandler { - profile: PResp - constructor(profile: PResp) { + profile: ProfileView + constructor(profile: ProfileView) { this.profile = profile } async element(element) { - const view = this.profile.data + const view = this.profile element.append( html` @@ -135,23 +140,42 @@ class NoscriptHandler { } } -export async function onRequest(context) { - const agent = new AtpAgent({service: 'https://public.api.bsky.app/'}) +export async function handleProfileEmbed(context) { const {request, env} = context - const origin = new URL(request.url).origin - - const base = env.ASSETS.fetch(new URL('/', origin)) - try { - const profile = await agent.getProfile({ - actor: context.params.handleOrDID, - }) - return new HTMLRewriter() - .on(`head`, new HeadHandler(profile, request.url)) - .on(`title`, new TitleHandler(profile)) - .on(`noscript`, new NoscriptHandler(profile)) - .transform(await base) - } catch (e) { - console.error(e) - return await base - } + + return renderCachedEmbed({ + request, + executionContext: context, + async render(publicUrl) { + const origin = new URL(publicUrl).origin + const base = env.ASSETS.fetch(new URL('/', origin)) + + try { + const apiUrl = new URL( + 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile', + ) + apiUrl.searchParams.set('actor', context.params.handleOrDID) + + const apiResponse = await fetch(apiUrl, { + headers: {Accept: 'application/json'}, + }) + if (!apiResponse.ok) { + throw new Error(`getProfile returned ${apiResponse.status}`) + } + + const profile = (await apiResponse.json()) as ProfileView + return new HTMLRewriter() + .on(`head`, new HeadHandler(profile, publicUrl)) + .on(`title`, new TitleHandler(profile)) + .on(`noscript`, new NoscriptHandler(profile)) + .transform(await base) + } catch (error) { + console.error(error) + const baseResponse = await base + const response = new Response(baseResponse.body, baseResponse) + response.headers.set('Cache-Control', 'no-store') + return response + } + }, + }) } diff --git a/functions/profile/[handleOrDID]/post/[rkey].ts b/functions/profile/[handleOrDID]/post/[rkey].ts deleted file mode 100644 index a701e2aef..000000000 --- a/functions/profile/[handleOrDID]/post/[rkey].ts +++ /dev/null @@ -1,225 +0,0 @@ -import { - AppBskyEmbedExternal, - AppBskyEmbedImages, - AppBskyEmbedRecord, - AppBskyEmbedRecordWithMedia, - AppBskyFeedDefs, - AtpAgent, - type Facet, - RichText, -} from '@atproto/api' - -import {html, renderHandleString} from '../../[handleOrDID].ts' - -type Thread = AppBskyFeedDefs.ThreadViewPost - -export function expandPostTextRich( - postView: AppBskyFeedDefs.ThreadViewPost, -): string { - if ( - !postView.post || - AppBskyFeedDefs.isNotFoundPost(postView) || - AppBskyFeedDefs.isBlockedPost(postView) - ) { - return '' - } - - const post = postView.post - const record = post.record - const embed = post.embed - const originalText = typeof record?.text === 'string' ? record.text : '' - const facets = record?.facets as [Facet] | undefined - - let expandedText = originalText - - // Use RichText to process facets if they exist - if (originalText && facets && facets.length > 0) { - try { - const rt = new RichText({text: originalText, facets}) - const modifiedSegmentsText: string[] = [] - - for (const segment of rt.segments()) { - const link = segment.link - if ( - link && - segment.text.endsWith('...') && - link.uri.includes(segment.text.slice(0, -3)) - ) { - // Replace shortened text with full URI - modifiedSegmentsText.push(link.uri) - } else { - // Keep original segment text - modifiedSegmentsText.push(segment.text) - } - } - expandedText = modifiedSegmentsText.join('') - } catch (error) { - console.error('Error processing RichText segments:', error) - // Fallback to original text on error - expandedText = originalText - } - } - - // Append external link URL if present and not already in text - if (AppBskyEmbedExternal.isView(embed) && embed.external?.uri) { - const externalUri = embed.external.uri - if (!expandedText.includes(externalUri)) { - expandedText = expandedText - ? `${expandedText}\n${externalUri}` - : externalUri - } - } - - // Append placeholder for quote posts or other record embeds - if ( - AppBskyEmbedRecord.isView(embed) || - AppBskyEmbedRecordWithMedia.isView(embed) - ) { - // no idea why this is needed lol - const record = embed.record.record ?? embed.record - if (AppBskyEmbedRecord.isViewRecord(record)) { - const quote = `â†˜ī¸ quoting ${renderHandleString(record.author)}:\n\n${ - record.value.text - }` - expandedText = expandedText ? `${expandedText}\n\n${quote}` : quote - } else { - const placeholder = '[quote/embed]' - if (!expandedText.includes(placeholder)) { - expandedText = expandedText - ? `${expandedText}\n\n${placeholder}` - : placeholder - } - } - } - - // prepend reply header - if (AppBskyFeedDefs.isThreadViewPost(postView.parent)) { - const header = `â†Šī¸ reply to ${renderHandleString( - postView.parent.post.author, - )}:` - expandedText = expandedText ? `${header}\n\n${expandedText}` : header - } - - return expandedText -} - -class HeadHandler { - thread: Thread - url: string - postTextString: string - constructor(thread: Thread, url: string, postTextString: string) { - this.thread = thread - this.url = url - this.postTextString = postTextString - } - async element(element) { - const author = this.thread.post.author - - const postText = - this.postTextString.length > 0 - ? html` - - - ` - : '' - - const embed = this.thread.post.embed - - const embedElems = !embed - ? '' - : AppBskyEmbedImages.isView(embed) - ? html`${embed.images.map( - i => html``, - )} - ` - : // TODO: in the future, embed videos - 'thumbnail' in embed && embed.thumbnail - ? html` - - - ` - : html`` - - element.append( - html` - - - - - - ${postText} ${embedElems} - - - - `, - {html: true}, - ) - } -} - -class TitleHandler { - thread: Thread - constructor(thread: Thread) { - this.thread = thread - } - async element(element) { - element.setInnerContent(renderHandleString(this.thread.post.author)) - } -} - -class NoscriptHandler { - thread: Thread - postTextString: string - constructor(thread: Thread, postTextString: string) { - this.thread = thread - this.postTextString = postTextString - } - async element(element) { - element.append( - html` -
-

Post

-

- ${this.thread.post.author.displayName ?? ''} -

-

${this.thread.post.author.handle}

-

${this.thread.post.author.did}

-

${this.postTextString}

-

${this.thread.post.indexedAt}

-
- `, - {html: true}, - ) - } -} - -export async function onRequest(context) { - const agent = new AtpAgent({service: 'https://public.api.bsky.app/'}) - const {request, env} = context - const origin = new URL(request.url).origin - const {handleOrDID, rkey}: {handleOrDID: string; rkey: string} = - context.params - - const base = env.ASSETS.fetch(new URL('/', origin)) - try { - const {data} = await agent.getPostThread({ - uri: `at://${handleOrDID}/app.bsky.feed.post/${rkey}`, - depth: 1, - parentHeight: 1, - }) - if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) { - throw new Error('Expected a ThreadViewPost') - } - const postTextString = expandPostTextRich(data.thread) - return new HTMLRewriter() - .on(`head`, new HeadHandler(data.thread, request.url, postTextString)) - .on(`title`, new TitleHandler(data.thread)) - .on(`noscript`, new NoscriptHandler(data.thread, postTextString)) - .transform(await base) - } catch (e) { - console.error(e) - return await base - } -} diff --git a/rspack.config.ts b/rspack.config.ts index 2d8c55db7..75e454c40 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -341,6 +341,9 @@ module.exports = { }), new rspack.CopyRspackPlugin({ patterns: [ + // Only internal crawler routes should invoke Pages Functions. A + // Cloudflare Transform Rule rewrites link-preview bots to /__embed. + {from: 'web/_routes.json', to: '_routes.json'}, // Serve fonts at /static/fonts/ with stable names {from: 'web/static/fonts', to: 'static/fonts'}, // Serve the global stylesheet diff --git a/web/_routes.json b/web/_routes.json new file mode 100644 index 000000000..eb9935ba0 --- /dev/null +++ b/web/_routes.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "include": ["/__embed/profile/*"], + "exclude": [] +} -- 2.51.2