Skip to content

Commit 62215aa

Browse files
committed
feat(cf-adapter): add route-level build, R2 JSON serving and cache purge
1 parent caeca52 commit 62215aa

2 files changed

Lines changed: 144 additions & 5 deletions

File tree

example/cloudflare/wrangler.example.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,9 @@ id = "REPLACE_ME_KV_NAMESPACE_ID"
1212

1313
[vars]
1414
STATIK_BUILD_TOKEN = "REPLACE_ME_STATIK_BUILD_TOKEN"
15-
STATIK_SRC = "src-api" # optional: lets the CLI auto-detect
15+
STATIK_SRC = "src-api" # optional: lets the CLI auto-detect
16+
17+
# API access
18+
STATIK_ALLOWED_ORIGINS = "https://statikapi.dev,https://example.com"
19+
STATIK_API_REQUIRE_AUTH = "false" # or "true"
20+
STATIK_API_AUTH_TOKEN = "super-secret-api-token"

packages/adapter-cloudflare/src/node/bundle.js

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,45 @@ const WORKER_RUNTIME_JS = `
202202
return route.replace(/^\\//,'').split('/');
203203
}
204204
205+
function parseAllowedOrigins(env) {
206+
const raw = env.STATIK_ALLOWED_ORIGINS || '';
207+
return raw
208+
.split(',')
209+
.map((s) => s.trim())
210+
.filter(Boolean);
211+
}
212+
213+
function isOriginAllowed(origin, env) {
214+
if (!origin) return true; // server-to-server, curl, etc.
215+
const allowed = parseAllowedOrigins(env);
216+
if (!allowed.length) return true; // no restriction configured
217+
return allowed.includes(origin);
218+
}
219+
220+
function isReadAuthorized(req, env) {
221+
const requireAuth = (env.STATIK_API_REQUIRE_AUTH || 'false').toLowerCase() === 'true';
222+
if (!requireAuth) return true;
223+
224+
const auth = req.headers.get('authorization') || '';
225+
const token = env.STATIK_API_AUTH_TOKEN || '';
226+
if (!token) return false;
227+
228+
const want = 'Bearer ' + token;
229+
return auth === want;
230+
}
231+
232+
function corsHeaders(origin, extra = {}) {
233+
const headers = {
234+
'content-type': 'application/json; charset=utf-8',
235+
...extra,
236+
};
237+
if (origin) {
238+
headers['access-control-allow-origin'] = origin;
239+
headers['vary'] = 'origin';
240+
}
241+
return headers;
242+
}
243+
205244
function matchPattern(patternRoute, concreteRoute) {
206245
const pSegs = splitRoute(patternRoute);
207246
const cSegs = splitRoute(concreteRoute);
@@ -305,8 +344,10 @@ const WORKER_RUNTIME_JS = `
305344
}
306345
307346
function r2Key(projectId, concreteRoute) {
308-
const suffix = concreteRoute === '/' ? '' : concreteRoute.replace(/^\\//, '') + '/';
309-
return \`\${projectId}/\${suffix}index.json\`;
347+
// concreteRoute: '/demo' => key 'projectId/demo/index.json'
348+
// concreteRoute: '/' => key 'projectId/index.json'
349+
const suffix = concreteRoute === '/' ? '/index.json' : concreteRoute + '/index.json';
350+
return \`\${projectId}\${suffix}\`;
310351
}
311352
312353
async function writeJsonToR2(env, key, text, extraMeta = {}) {
@@ -323,11 +364,84 @@ const WORKER_RUNTIME_JS = `
323364
if (!m) return [];
324365
try { return JSON.parse(m); } catch { return []; }
325366
}
367+
326368
async function writeManifest(env, projectId, list) {
327369
const k = \`\${projectId}::manifest\`;
328370
await env.STATIK_MANIFEST.put(k, JSON.stringify(list));
329371
}
330372
373+
function r2KeyFromPath(projectId, pathname) {
374+
// pathname like '/demo/index.json' -> 'projectId/demo/index.json'
375+
// or '/' -> 'projectId/index.json' if you ever map root
376+
const clean = pathname === '/' ? '/index.json' : pathname;
377+
return \`\${projectId}\${clean}\`;
378+
}
379+
380+
async function handleRead(req, env) {
381+
const url = new URL(req.url);
382+
const origin = req.headers.get('origin') || '';
383+
const projectId = url.searchParams.get('projectId') || 'default';
384+
385+
if (!isOriginAllowed(origin, env)) {
386+
return new Response('forbidden', { status: 403 });
387+
}
388+
389+
if (!isReadAuthorized(req, env)) {
390+
return new Response(
391+
JSON.stringify({ ok: false, error: 'unauthorized' }),
392+
{ status: 401, headers: corsHeaders(origin) },
393+
);
394+
}
395+
396+
const key = r2KeyFromPath(projectId, url.pathname);
397+
const obj = await env.STATIK_BUCKET.get(key);
398+
399+
if (!obj) {
400+
return new Response(
401+
JSON.stringify({ ok: false, error: 'not_found' }),
402+
{ status: 404, headers: corsHeaders(origin) },
403+
);
404+
}
405+
406+
const body = await obj.text();
407+
408+
return new Response(body, {
409+
headers: corsHeaders(origin, {
410+
// cache at edge, but easy to invalidate
411+
'cache-control': 'public, max-age=0, s-maxage=600',
412+
}),
413+
});
414+
}
415+
416+
async function purgeCacheForPath(env, origin, path) {
417+
// Minimal: purge Worker cache for this path on this colo.
418+
// (Global purge via CF API can be added later using env.STATIK_CF_API_TOKEN / STATIK_CF_ZONE_ID.)
419+
try {
420+
const url = origin + path;
421+
await caches.default.delete(new Request(url, { method: 'GET' }));
422+
} catch (err) {
423+
// swallow errors; cache purge is best-effort
424+
console.warn('purgeCacheForPath error', err);
425+
}
426+
}
427+
428+
async function handleOptions(req, env) {
429+
const origin = req.headers.get('origin') || '';
430+
if (!isOriginAllowed(origin, env)) {
431+
return new Response('forbidden', { status: 403 });
432+
}
433+
434+
return new Response(null, {
435+
headers: {
436+
...(origin ? { 'access-control-allow-origin': origin } : {}),
437+
'access-control-allow-methods': 'GET, OPTIONS, POST',
438+
'access-control-allow-headers': 'Content-Type, Authorization',
439+
'access-control-max-age': '86400',
440+
'vary': 'origin',
441+
},
442+
});
443+
}
444+
331445
async function handleBuildRoute(req, env) {
332446
const auth = req.headers.get('authorization') || '';
333447
const want = 'Bearer ' + (env.STATIK_BUILD_TOKEN || '');
@@ -382,6 +496,12 @@ const WORKER_RUNTIME_JS = `
382496
383497
await writeManifest(env, projectId, next);
384498
499+
// per-route cache purge (worker cache) for the JSON path
500+
const url = new URL(req.url);
501+
const origin = url.origin;
502+
const jsonPath = route === '/' ? '/index.json' : route + '/index.json';
503+
await purgeCacheForPath(env, origin, jsonPath);
504+
385505
const resBody = {
386506
ok: true,
387507
route,
@@ -420,6 +540,12 @@ const WORKER_RUNTIME_JS = `
420540
const etag = await digestETag(text);
421541
await writeJsonToR2(env, key, text, { route: r.concreteRoute, etag });
422542
543+
// per-route purge for the concrete JSON path
544+
const url = new URL(req.url);
545+
const origin = url.origin;
546+
const jsonPath = r.concreteRoute === '/' ? '/index.json' : r.concreteRoute + '/index.json';
547+
await purgeCacheForPath(env, origin, jsonPath);
548+
423549
files++;
424550
written += (new TextEncoder().encode(text)).length;
425551
man.push({
@@ -443,6 +569,11 @@ const WORKER_RUNTIME_JS = `
443569
async fetch(req, env) {
444570
const url = new URL(req.url);
445571
572+
// CORS preflight
573+
if (req.method === 'OPTIONS') {
574+
return handleOptions(req, env);
575+
}
576+
446577
if (req.method === 'POST' && url.pathname === '/__statikapi/build/route') {
447578
return handleBuildRoute(req, env);
448579
}
@@ -456,8 +587,11 @@ const WORKER_RUNTIME_JS = `
456587
return new Response(JSON.stringify(list), { headers: { 'content-type': 'application/json' } });
457588
}
458589
459-
// optional: you could later add a read endpoint that serves from R2
460-
// if (req.method === 'GET') { ... fetch from env.STATIK_BUCKET.get(...) }
590+
// Public API: serve JSON directly from R2
591+
if (req.method === 'GET') {
592+
// e.g. /demo/index.json -> projectId/demo/index.json
593+
return handleRead(req, env);
594+
}
461595
462596
return new Response('Not found', { status: 404 });
463597
}

0 commit comments

Comments
 (0)