Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions src/cloudflare/snippets/01_path_gateway_to_subdomain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,35 @@
// /ipfs/{cid}[/path][?query] -> {base32-cidv1}.ipfs.{host}[/path][?query]
// /ipns/{peerid}[/path][?query] -> {base36-cidv1}.ipns.{host}[/path][?query]
// /ipns/{domain}[/path][?query] -> {dnslink-encoded}.ipns.{host}[/path][?query]
//
// Edge cache: the transformation is fully deterministic per source URL
// (CIDs are content-addressed, peer IDs and DNSLink labels are decoded and
// re-encoded by a fixed algorithm), so we cache the 301 at the Cloudflare
// edge keyed by the source URL. Subsequent hits skip CID parsing and the
// multibase work entirely.
//
// Strip __cf_* query params before computing the cache key and the redirect
// Location. Visitors that just passed a Managed Challenge arrive on a URL
// carrying a single-use challenge token; without stripping, each per-visitor
// URL becomes its own cache entry (no shared hit) and the token propagates
// onto the subdomain where snippet 02 has to clean it up with another hop.
// Prefix-matching the whole __cf_* namespace stays robust to Cloudflare-
// internal additions inside that namespace.

import { toSubdomain } from './subdomain.ts'

const CF_QUERY_PARAM_PREFIX = '__cf_'

export default {
/**
* main fetch handler
*
* @param {Request} request
* @param {unknown} env
* @param {ExecutionContext} ctx
* @returns {Promise<Response>}
*/
async fetch (request: Request): Promise<Response> {
async fetch (request: Request, env: unknown, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url)
const path = url.pathname

Expand All @@ -33,22 +51,48 @@ export default {
return fetch(request)
}

const remainingPath = '/' + segments.slice(3).join('/')
// Strip __cf_* params before anything that touches the cache or the
// redirect target. Mirrors the hygiene applied in snippet 02.
for (const key of [...url.searchParams.keys()]) {
if (key.startsWith(CF_QUERY_PARAM_PREFIX)) {
url.searchParams.delete(key)
}
}

// Use the stripped URL as the cache key. Default Cache API behavior keys
// by request URL, which gives unique-per-source-URL entries for free.
// `caches.default` is the Cloudflare-specific per-datacenter cache; the
// cast bridges the gap with the standard lib.dom CacheStorage shape that
// tsc resolves to first.
const cacheKey = url.toString()
const cache = (caches as unknown as { default: Cache }).default
const cached = await cache.match(cacheKey)
if (cached != null) {
return cached
}

const subdomain = toSubdomain(identifier, namespace)

if (subdomain == null) {
return fetch(request)
}

const remainingPath = '/' + segments.slice(3).join('/')
const redirectUrl = `${url.protocol}//${subdomain}.${namespace}.${url.host}${remainingPath}${url.search}`

return new Response(null, {
const response = new Response(null, {
status: 301,
headers: {
Location: redirectUrl,
'Cache-Control': 'public, max-age=31536000, immutable'
}
})

// Cache write runs after the response is sent. The Cache API honors
// Cache-Control on the stored response, so the edge TTL matches the
// 1-year immutable browser TTL set above.
ctx.waitUntil(cache.put(cacheKey, response.clone()))

return response
}
}
49 changes: 45 additions & 4 deletions src/cloudflare/snippets/02_shared_sw_installer_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,51 @@
// HTML and versioned JS/CSS share the same TTL on purpose: a stale HTML
// entry referencing fresh JS (or vice versa) could mismatch and break
// the installer.
//
// Cache hardening: cache only 2xx. The origin _redirects rule rewrites
// every SPA route to a 200, so any 3xx, 4xx, or 5xx response here is
// anomalous. The 4xx/5xx exclusion also keeps Cloudflare's own Managed
// Challenge response (403 with cf-mitigated: challenge) out of the
// normalized installer cache.
//
// Strip __cf_* query params at the edge: after a visitor passes a Managed
// Challenge, Cloudflare lands them on a URL carrying a single-use token
// (__cf_chl_tk, __cf_chl_f_tk, __cf_chl_rt_tk). If that token survives
// into the SW installer reload, Cloudflare sees it replayed and issues a
// fresh challenge, looping forever. We strip every __cf_* param and 302
// to the clean URL, so the browser address bar never carries challenge
// state. The prefix match covers today's names and any future Cloudflare
// additions in the same namespace.

const EDGE_CACHE_TTL_S = 86400 // 24h
const CF_QUERY_PARAM_PREFIX = '__cf_'

export default {
async fetch (request: Request): Promise<Response> {
const url = new URL(request.url)

// Strip __cf_* params before any cache lookup so the per-visitor URL
// never becomes a cache key and the address bar never persists a
// single-use token.
let strippedCfParam = false
for (const key of [...url.searchParams.keys()]) {
if (key.startsWith(CF_QUERY_PARAM_PREFIX)) {
url.searchParams.delete(key)
strippedCfParam = true
}
}
if (strippedCfParam) {
return new Response(null, {
status: 302,
headers: {
Location: url.toString(),
// The source URL holds a single-use, per-visitor token. Do not
// cache the redirect anywhere.
'Cache-Control': 'no-store'
}
})
}

if (url.pathname.startsWith('/ipfs-sw-')) {
const baseDomain = url.hostname.split('.').slice(-2).join('.')
const cacheKey = `https://${baseDomain}${url.pathname}`
Expand All @@ -49,10 +87,13 @@ export default {
cacheEverything: true,
cacheKey,
cacheTtlByStatus: {
'200-399': EDGE_CACHE_TTL_S,
// Do not cache errors. A transient failure must not be stuck
// in cache for the full TTL.
'400-599': 0
'200-299': EDGE_CACHE_TTL_S,
// Cache only 2xx. Origin returns 200 for every SPA route via
// the _redirects rewrite, so 3xx here is anomalous. The
// 4xx/5xx exclusion also keeps Cloudflare's Managed Challenge
// response (403 with cf-mitigated: challenge) out of this
// cache key.
'300-599': 0
}
}
})
Expand Down
74 changes: 71 additions & 3 deletions test/cloudflare/snippets/01_path_gateway_to_subdomain.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@ import type { SinonSandbox } from 'sinon'
// The handler calls fetch(request) when it decides not to redirect.
const PASSTHROUGH = Symbol('passthrough')

// In-memory caches.default stub. Keyed by cacheKey string (URL).
let edgeCache: Map<string, Response>
// Captured ctx for assertions. ctx.waitUntil is a no-op that resolves its
// argument immediately, matching real runtime semantics closely enough.
let ctx: ExecutionContext

// Call the real snippet handler and return the Location header or null on passthrough.
async function fetchRedirect (inputUrl: string): Promise<string | undefined | null> {
const resp = await handler.fetch(new Request(inputUrl))
const resp = await handler.fetch(new Request(inputUrl), {}, ctx)

// @ts-expect-error custom property added by tests
if (resp[PASSTHROUGH] != null) {
Expand All @@ -24,20 +30,43 @@ describe('01_path_gateway_to_subdomain', () => {
let sandbox: SinonSandbox

beforeEach(() => {
edgeCache = new Map()
ctx = {
waitUntil: (p: Promise<unknown>) => { void p },
passThroughOnException: () => {}
} as ExecutionContext

sandbox = Sinon.createSandbox()
sandbox.replace(globalThis, 'fetch', async () => {
return Object.assign(new Response(null, { status: 200 }), {
[PASSTHROUGH]: true
})
})
// Stub caches.default with a Map-backed implementation. The snippet
// only uses match() and put(); other methods are unused. `caches` does
// not exist as a Node global by default, so use sandbox.define to add
// it (sandbox.replace would fail on a non-existent property).
sandbox.define(globalThis, 'caches', {
default: {
match: async (key: Request | string) => {
const k = typeof key === 'string' ? key : key.url
const cached = edgeCache.get(k)
return cached != null ? cached.clone() : undefined
},
put: async (key: Request | string, value: Response) => {
const k = typeof key === 'string' ? key : key.url
edgeCache.set(k, value)
}
}
} as unknown as CacheStorage)
})

afterEach(() => {
sandbox.restore()
})

it('/ipfs/ with CIDv0 redirects to base32 CIDv1 subdomain', async () => {
const resp = await handler.fetch(new Request('https://dweb.link/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR/path?q=1'))
const resp = await handler.fetch(new Request('https://dweb.link/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR/path?q=1'), {}, ctx)
expect(resp.status).to.equal(301)
expect(resp.headers.get('Location')).to.equal('https://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi.ipfs.dweb.link/path?q=1')
expect(resp.headers.get('Cache-Control')).to.equal('public, max-age=31536000, immutable')
Expand All @@ -53,7 +82,7 @@ describe('01_path_gateway_to_subdomain', () => {

it('/ipns/ with base36 key redirects to subdomain', async () => {
const key = 'k51qzi5uqu5dlxjl6owpco0tn82bed1444cng351cnc48odwnr7e9pmx4nmmkh'
const resp = await handler.fetch(new Request(`https://dweb.link/ipns/${key}/file`))
const resp = await handler.fetch(new Request(`https://dweb.link/ipns/${key}/file`), {}, ctx)
expect(resp.status).to.equal(301)
expect(resp.headers.get('Location')).to.equal(`https://${key}.ipns.dweb.link/file`)
expect(resp.headers.get('Cache-Control')).to.equal('public, max-age=31536000, immutable')
Expand Down Expand Up @@ -164,4 +193,43 @@ describe('01_path_gateway_to_subdomain', () => {
`https://${key}.ipns.dweb.link/path`
)
})

describe('edge cache', () => {
it('on cache hit, returns the cached response and does not re-compute', async () => {
const cid = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi'
const url = `https://dweb.link/ipfs/${cid}/file`

// First call: miss. Populates the cache.
const first = await handler.fetch(new Request(url), {}, ctx)
expect(first.status).to.equal(301)
expect(edgeCache.has(url)).to.equal(true)

// Replace the cached entry with a sentinel response to prove the
// second call returns it (i.e. did not re-run the redirect logic).
const sentinel = new Response(null, { status: 301, headers: { Location: 'https://sentinel.example/' } })
edgeCache.set(url, sentinel)

const second = await handler.fetch(new Request(url), {}, ctx)
expect(second.headers.get('Location')).to.equal('https://sentinel.example/')
})

it('strips __cf_* from cache key and redirect Location', async () => {
const cid = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi'
const resp = await handler.fetch(new Request(`https://dweb.link/ipfs/${cid}/?filename=foo.txt&__cf_chl_tk=tok`), {}, ctx)
const location = new URL(resp.headers.get('Location') ?? '')
expect(location.searchParams.has('__cf_chl_tk')).to.equal(false)
expect(location.searchParams.get('filename')).to.equal('foo.txt')

// The cache key is the stripped URL, so per-visitor tokens collapse
// onto a single shared entry.
expect([...edgeCache.keys()]).to.deep.equal([`https://dweb.link/ipfs/${cid}/?filename=foo.txt`])
})

it('pass-through cases do not touch the cache', async () => {
await handler.fetch(new Request('https://dweb.link/ipfs/not-a-cid'), {}, ctx)
await handler.fetch(new Request('https://dweb.link/ipfs/'), {}, ctx)
await handler.fetch(new Request('https://dweb.link/other/path'), {}, ctx)
expect(edgeCache.size).to.equal(0)
})
})
})
Loading
Loading