@@ -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