@@ -179,6 +179,9 @@ export const DEFAULT_PRETTY = ${prettyDefault ? 'true' : 'false'};
179179const 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