Skip to content

Commit 0811a77

Browse files
committed
adapter-cf: drop projectId and add STATIK_PRETTY_ROUTES support for R2 JSON serving
1 parent 62215aa commit 0811a77

2 files changed

Lines changed: 116 additions & 48 deletions

File tree

example/cloudflare/wrangler.example.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ STATIK_SRC = "src-api" # optional: lets the CLI auto-detect
1717
# API access
1818
STATIK_ALLOWED_ORIGINS = "https://statikapi.dev,https://example.com"
1919
STATIK_API_REQUIRE_AUTH = "false" # or "true"
20-
STATIK_API_AUTH_TOKEN = "super-secret-api-token"
20+
STATIK_API_AUTH_TOKEN = "super-secret-api-token"
21+
STATIK_PRETTY_ROUTES = "true"

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

Lines changed: 114 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ export const DEFAULT_PRETTY = ${prettyDefault ? 'true' : 'false'};
179179
const WORKER_RUNTIME_JS = `
180180
import { REGISTRY, DEFAULT_PRETTY } from './entry.mjs';
181181
182+
// -------------------------
183+
// Helpers
184+
// -------------------------
182185
function isPlainObject(x){ return Object.prototype.toString.call(x) === '[object Object]'; }
183186
function assertSerializable(v, seen = new Set()) {
184187
const t = typeof v;
@@ -202,6 +205,13 @@ const WORKER_RUNTIME_JS = `
202205
return route.replace(/^\\//,'').split('/');
203206
}
204207
208+
// -------------------------
209+
// Config helpers
210+
// -------------------------
211+
function isPrettyRoutes(env) {
212+
return (env.STATIK_PRETTY_ROUTES || 'true').toLowerCase() === 'true';
213+
}
214+
205215
function parseAllowedOrigins(env) {
206216
const raw = env.STATIK_ALLOWED_ORIGINS || '';
207217
return raw
@@ -241,6 +251,9 @@ const WORKER_RUNTIME_JS = `
241251
return headers;
242252
}
243253
254+
// -------------------------
255+
// Routing helpers
256+
// -------------------------
244257
function matchPattern(patternRoute, concreteRoute) {
245258
const pSegs = splitRoute(patternRoute);
246259
const cSegs = splitRoute(concreteRoute);
@@ -328,9 +341,6 @@ const WORKER_RUNTIME_JS = `
328341
const { route, params } = concreteFromPattern(patternSegs, entry);
329342
out.push({ ...r, concreteRoute: route, params });
330343
}
331-
} else {
332-
// no paths(): skip dynamic/catchall
333-
// (We still include a "skip" marker if needed by summary)
334344
}
335345
}
336346
return out;
@@ -343,44 +353,82 @@ const WORKER_RUNTIME_JS = `
343353
return '"' + hex + '"';
344354
}
345355
346-
function r2Key(projectId, concreteRoute) {
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}\`;
356+
// -------------------------
357+
// R2 key mapping
358+
// -------------------------
359+
360+
// From concrete route ('/', '/posts', '/users/1') to canonical R2 key.
361+
function r2KeyForRoute(concreteRoute) {
362+
if (concreteRoute === '/') return 'index.json';
363+
const clean = concreteRoute.replace(/^\\/+/, '');
364+
return clean + '/index.json';
365+
}
366+
367+
// From request path ('/', '/posts', '/posts/index.json') to R2 key,
368+
// obeying STATIK_PRETTY_ROUTES.
369+
function r2KeyFromRequestPath(pathname, env) {
370+
const pretty = isPrettyRoutes(env);
371+
372+
if (!pathname || pathname === '/') {
373+
return 'index.json';
374+
}
375+
376+
let p = pathname;
377+
if (p.startsWith('/')) p = p.slice(1);
378+
379+
// If user explicitly asks for .json, always respect it
380+
if (p.endsWith('.json')) {
381+
return p;
382+
}
383+
384+
// Pretty mode: '/posts' -> 'posts/index.json'
385+
if (pretty) {
386+
return p + '/index.json';
387+
}
388+
389+
// Strict index mode: only '/foo/index.json' works
390+
// '/foo' without .json is not valid
391+
return null;
392+
}
393+
394+
// All public paths that should map to a given route
395+
// (for cache purge, etc.)
396+
function publicPathsForRoute(route, env) {
397+
const pretty = isPrettyRoutes(env);
398+
if (route === '/') {
399+
return pretty ? ['/', '/index.json'] : ['/index.json'];
400+
}
401+
const base = route; // e.g. '/posts', '/users/1'
402+
const jsonPath = base + '/index.json';
403+
return pretty ? [base, jsonPath] : [jsonPath];
351404
}
352405
353406
async function writeJsonToR2(env, key, text, extraMeta = {}) {
354407
const httpMetadata = {
355408
contentType: 'application/json; charset=utf-8',
356-
cacheControl: 'public, max-age=0, s-maxage=31536000'
409+
cacheControl: 'public, max-age=0, s-maxage=31536000',
357410
};
358411
await env.STATIK_BUCKET.put(key, text, { httpMetadata, customMetadata: extraMeta });
359412
}
360413
361-
async function readManifest(env, projectId) {
362-
const k = \`\${projectId}::manifest\`;
363-
const m = await env.STATIK_MANIFEST.get(k);
414+
const MANIFEST_KEY = 'manifest';
415+
416+
async function readManifest(env) {
417+
const m = await env.STATIK_MANIFEST.get(MANIFEST_KEY);
364418
if (!m) return [];
365419
try { return JSON.parse(m); } catch { return []; }
366420
}
367421
368-
async function writeManifest(env, projectId, list) {
369-
const k = \`\${projectId}::manifest\`;
370-
await env.STATIK_MANIFEST.put(k, JSON.stringify(list));
371-
}
372-
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}\`;
422+
async function writeManifest(env, list) {
423+
await env.STATIK_MANIFEST.put(MANIFEST_KEY, JSON.stringify(list));
378424
}
379425
426+
// -------------------------
427+
// Read: GET JSON from R2
428+
// -------------------------
380429
async function handleRead(req, env) {
381430
const url = new URL(req.url);
382431
const origin = req.headers.get('origin') || '';
383-
const projectId = url.searchParams.get('projectId') || 'default';
384432
385433
if (!isOriginAllowed(origin, env)) {
386434
return new Response('forbidden', { status: 403 });
@@ -393,7 +441,14 @@ const WORKER_RUNTIME_JS = `
393441
);
394442
}
395443
396-
const key = r2KeyFromPath(projectId, url.pathname);
444+
const key = r2KeyFromRequestPath(url.pathname, env);
445+
if (!key) {
446+
return new Response(
447+
JSON.stringify({ ok: false, error: 'not_found' }),
448+
{ status: 404, headers: corsHeaders(origin) },
449+
);
450+
}
451+
397452
const obj = await env.STATIK_BUCKET.get(key);
398453
399454
if (!obj) {
@@ -407,24 +462,27 @@ const WORKER_RUNTIME_JS = `
407462
408463
return new Response(body, {
409464
headers: corsHeaders(origin, {
410-
// cache at edge, but easy to invalidate
411465
'cache-control': 'public, max-age=0, s-maxage=600',
412466
}),
413467
});
414468
}
415469
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.)
470+
// -------------------------
471+
// Cache purge helpers
472+
// -------------------------
473+
async function purgeCacheForPath(origin, path) {
419474
try {
420475
const url = origin + path;
421476
await caches.default.delete(new Request(url, { method: 'GET' }));
422477
} catch (err) {
423-
// swallow errors; cache purge is best-effort
478+
// best-effort
424479
console.warn('purgeCacheForPath error', err);
425480
}
426481
}
427482
483+
// -------------------------
484+
// CORS preflight
485+
// -------------------------
428486
async function handleOptions(req, env) {
429487
const origin = req.headers.get('origin') || '';
430488
if (!isOriginAllowed(origin, env)) {
@@ -442,6 +500,9 @@ const WORKER_RUNTIME_JS = `
442500
});
443501
}
444502
503+
// -------------------------
504+
// Build single route
505+
// -------------------------
445506
async function handleBuildRoute(req, env) {
446507
const auth = req.headers.get('authorization') || '';
447508
const want = 'Bearer ' + (env.STATIK_BUILD_TOKEN || '');
@@ -450,7 +511,6 @@ const WORKER_RUNTIME_JS = `
450511
}
451512
452513
const body = await req.json().catch(() => ({}));
453-
const projectId = body.projectId || 'default';
454514
const pretty = body.pretty ?? DEFAULT_PRETTY;
455515
const route = body.route;
456516
@@ -476,13 +536,13 @@ const WORKER_RUNTIME_JS = `
476536
assertSerializable(value);
477537
478538
const text = pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value);
479-
const key = r2Key(projectId, route);
539+
const key = r2KeyForRoute(route);
480540
const etag = await digestETag(text);
481541
482542
await writeJsonToR2(env, key, text, { route, etag });
483543
484544
// update manifest: remove old entry for this route, then add fresh one
485-
const existing = await readManifest(env, projectId);
545+
const existing = await readManifest(env);
486546
const next = (existing || []).filter((e) => e.route !== route);
487547
const bytes = (new TextEncoder().encode(text)).length;
488548
@@ -494,13 +554,14 @@ const WORKER_RUNTIME_JS = `
494554
hash: etag.replace(/"/g, ''),
495555
});
496556
497-
await writeManifest(env, projectId, next);
557+
await writeManifest(env, next);
498558
499-
// per-route cache purge (worker cache) for the JSON path
559+
// per-route cache purge (worker cache) for all public paths of this route
500560
const url = new URL(req.url);
501561
const origin = url.origin;
502-
const jsonPath = route === '/' ? '/index.json' : route + '/index.json';
503-
await purgeCacheForPath(env, origin, jsonPath);
562+
for (const p of publicPathsForRoute(route, env)) {
563+
await purgeCacheForPath(origin, p);
564+
}
504565
505566
const resBody = {
506567
ok: true,
@@ -514,14 +575,16 @@ const WORKER_RUNTIME_JS = `
514575
});
515576
}
516577
578+
// -------------------------
579+
// Build all routes
580+
// -------------------------
517581
async function handleBuild(req, env) {
518582
const auth = req.headers.get('authorization') || '';
519583
const want = 'Bearer ' + (env.STATIK_BUILD_TOKEN || '');
520584
if (!env.STATIK_BUILD_TOKEN || auth !== want) {
521585
return new Response('unauthorized', { status: 401 });
522586
}
523587
const body = await req.json().catch(() => ({}));
524-
const projectId = body.projectId || 'default';
525588
const pretty = body.pretty ?? DEFAULT_PRETTY;
526589
527590
const t0 = Date.now();
@@ -530,21 +593,23 @@ const WORKER_RUNTIME_JS = `
530593
const expanded = await expandAllRoutes(REGISTRY);
531594
const man = [];
532595
596+
const url = new URL(req.url);
597+
const origin = url.origin;
598+
533599
for (const r of expanded) {
534600
if (!r.concreteRoute) { skipped++; continue; }
535601
const ctx = { params: r.params || {}, env };
536602
const value = await r.mod.data(ctx);
537603
assertSerializable(value);
538604
const text = pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value);
539-
const key = r2Key(projectId, r.concreteRoute);
605+
const key = r2KeyForRoute(r.concreteRoute);
540606
const etag = await digestETag(text);
541607
await writeJsonToR2(env, key, text, { route: r.concreteRoute, etag });
542608
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);
609+
// per-route purge for all public paths of this route
610+
for (const p of publicPathsForRoute(r.concreteRoute, env)) {
611+
await purgeCacheForPath(origin, p);
612+
}
548613
549614
files++;
550615
written += (new TextEncoder().encode(text)).length;
@@ -557,14 +622,17 @@ const WORKER_RUNTIME_JS = `
557622
});
558623
}
559624
560-
await writeManifest(env, projectId, man);
625+
await writeManifest(env, man);
561626
562627
const ms = Date.now() - t0;
563628
return new Response(JSON.stringify({ ok: true, files, bytes: written, skipped, ms }), {
564629
headers: { 'content-type': 'application/json' }
565630
});
566631
}
567632
633+
// -------------------------
634+
// Entry
635+
// -------------------------
568636
export default {
569637
async fetch(req, env) {
570638
const url = new URL(req.url);
@@ -581,15 +649,14 @@ const WORKER_RUNTIME_JS = `
581649
if (req.method === 'POST' && url.pathname === '/__statikapi/build') {
582650
return handleBuild(req, env);
583651
}
652+
584653
if (req.method === 'GET' && url.pathname === '/__statikapi/manifest') {
585-
const projectId = url.searchParams.get('projectId') || 'default';
586-
const list = await readManifest(env, projectId);
654+
const list = await readManifest(env);
587655
return new Response(JSON.stringify(list), { headers: { 'content-type': 'application/json' } });
588656
}
589657
590658
// Public API: serve JSON directly from R2
591659
if (req.method === 'GET') {
592-
// e.g. /demo/index.json -> projectId/demo/index.json
593660
return handleRead(req, env);
594661
}
595662

0 commit comments

Comments
 (0)