@ampless/runtime 1.0.0-alpha.13 → 1.0.0-alpha.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -34,10 +34,10 @@ export const ampless = createAmpless({
34
34
  })
35
35
  ```
36
36
 
37
- `app/site/[siteId]/` 以下のルートとディスパッチャーはワンライナーのファクトリー呼び出しになります:
37
+ `app/` 直下のルートとディスパッチャーはワンライナーのファクトリー呼び出しになります:
38
38
 
39
39
  ```ts
40
- // app/site/[siteId]/page.tsx
40
+ // app/page.tsx
41
41
  import { ampless } from '@/lib/ampless'
42
42
  import {
43
43
  createThemeHomeDispatcher,
@@ -50,7 +50,7 @@ export default createThemeHomeDispatcher(ampless)
50
50
  ```
51
51
 
52
52
  ```ts
53
- // app/site/[siteId]/og/[slug]/route.ts
53
+ // app/og/[slug]/route.ts
54
54
  import { ampless } from '@/lib/ampless'
55
55
  import { createOgRouteHandler } from '@ampless/runtime/routes'
56
56
 
@@ -59,17 +59,43 @@ export const dynamic = 'force-dynamic'
59
59
  export const GET = createOgRouteHandler(ampless)
60
60
  ```
61
61
 
62
- ミドルウェア:
62
+ ミドルウェア(proxy):
63
63
 
64
64
  ```ts
65
- // middleware.ts
65
+ // proxy.ts (Next.js 16 で middleware.ts から改名)
66
66
  import cmsConfig from './cms.config'
67
- import { createAmplessMiddleware, defaultMatcherConfig } from '@ampless/runtime/middleware'
67
+ import outputs from './amplify_outputs.json'
68
+ import { createAmplessMiddleware } from '@ampless/runtime/middleware'
68
69
 
69
- export const middleware = createAmplessMiddleware({ cmsConfig })
70
- export const config = defaultMatcherConfig
70
+ export const proxy = createAmplessMiddleware({
71
+ cmsConfig,
72
+ appsyncUrl: outputs.data.url,
73
+ apiKey: outputs.data.api_key!,
74
+ })
75
+
76
+ // matcher はインライン定義 — Next.js 16 の Turbopack は `export const config`
77
+ // が静的オブジェクトリテラルであることを要求します。
78
+ export const config = {
79
+ matcher: [
80
+ '/((?!admin|api|login|_next/static|_next/image|favicon\\.ico|amplify_outputs\\.json).*)',
81
+ ],
82
+ }
71
83
  ```
72
84
 
85
+ middleware はリクエストごとに AppSync から `post.format` /
86
+ `post.metadata` / `post.updatedAt` を取得(apiKey 認証)し、slug をキー
87
+ にした 200 エントリの LRU(60 秒 TTL、Lambda モジュールスコープ)で
88
+ キャッシュした上で、適切な内部ハンドラーにリクエストを書き換えます。
89
+
90
+ - 通常の投稿 → 書き換えなし、`app/[slug]/page.tsx` で配信
91
+ - `metadata.no_layout: true` HTML / `format: 'static'` → `/r/<slug>(/<path>)`、
92
+ `app/r/[slug]/[[...path]]/route.ts` で配信
93
+
94
+ また、`post.metadata.cache`(auto / deep / hot)+ `post.updatedAt` +
95
+ `cms.config.cache.{cooldownMs, freshTtlSeconds, deepTtlSeconds}` から
96
+ `Cache-Control` を算出してレスポンスに付与します。キャッシュ戦略の
97
+ 契約については `docs/CONTENT.md` を参照してください。
98
+
73
99
  ## サブパス
74
100
 
75
101
  - `@ampless/runtime` — `createAmpless`、ランタイム型、`renderBody`・`renderThemeCss`・フォーマットコンバーターの再エクスポート
package/README.md CHANGED
@@ -34,10 +34,10 @@ export const ampless = createAmpless({
34
34
  })
35
35
  ```
36
36
 
37
- Routes and dispatchers under `app/site/[siteId]/` become one-line factory invocations:
37
+ Routes and dispatchers under `app/` become one-line factory invocations:
38
38
 
39
39
  ```ts
40
- // app/site/[siteId]/page.tsx
40
+ // app/page.tsx
41
41
  import { ampless } from '@/lib/ampless'
42
42
  import {
43
43
  createThemeHomeDispatcher,
@@ -50,7 +50,7 @@ export default createThemeHomeDispatcher(ampless)
50
50
  ```
51
51
 
52
52
  ```ts
53
- // app/site/[siteId]/og/[slug]/route.ts
53
+ // app/og/[slug]/route.ts
54
54
  import { ampless } from '@/lib/ampless'
55
55
  import { createOgRouteHandler } from '@ampless/runtime/routes'
56
56
 
@@ -59,17 +59,43 @@ export const dynamic = 'force-dynamic'
59
59
  export const GET = createOgRouteHandler(ampless)
60
60
  ```
61
61
 
62
- Middleware:
62
+ Middleware (proxy):
63
63
 
64
64
  ```ts
65
- // middleware.ts
65
+ // proxy.ts (Next.js 16 rename of middleware.ts)
66
66
  import cmsConfig from './cms.config'
67
- import { createAmplessMiddleware, defaultMatcherConfig } from '@ampless/runtime/middleware'
67
+ import outputs from './amplify_outputs.json'
68
+ import { createAmplessMiddleware } from '@ampless/runtime/middleware'
68
69
 
69
- export const middleware = createAmplessMiddleware({ cmsConfig })
70
- export const config = defaultMatcherConfig
70
+ export const proxy = createAmplessMiddleware({
71
+ cmsConfig,
72
+ appsyncUrl: outputs.data.url,
73
+ apiKey: outputs.data.api_key!,
74
+ })
75
+
76
+ // Inline the matcher — Next.js 16's Turbopack requires
77
+ // `export const config` to be a static object literal.
78
+ export const config = {
79
+ matcher: [
80
+ '/((?!admin|api|login|_next/static|_next/image|favicon\\.ico|amplify_outputs\\.json).*)',
81
+ ],
82
+ }
71
83
  ```
72
84
 
85
+ The middleware fetches `post.format` / `post.metadata` / `post.updatedAt`
86
+ from AppSync (apiKey auth) on each request, caches the result in a
87
+ 200-entry LRU keyed by slug (60-second TTL, Lambda module scope), and
88
+ rewrites the request to the right internal handler:
89
+
90
+ - themed post → no rewrite, served by `app/[slug]/page.tsx`
91
+ - `metadata.no_layout: true` HTML / `format: 'static'` → `/r/<slug>(/<path>)`,
92
+ served by `app/r/[slug]/[[...path]]/route.ts`
93
+
94
+ It also computes `Cache-Control` from `post.metadata.cache` (auto /
95
+ deep / hot) + `post.updatedAt` + `cms.config.cache.{cooldownMs,
96
+ freshTtlSeconds, deepTtlSeconds}` and sets the header on the response.
97
+ See `docs/CONTENT.md` for the cache strategy contract.
98
+
73
99
  ## Sub-paths
74
100
 
75
101
  - `@ampless/runtime` — `createAmpless`, runtime types, and re-exports of `renderBody`, `renderThemeCss`, format converters
@@ -4,9 +4,7 @@ import { Ampless } from '../index.js';
4
4
  import 'ampless';
5
5
 
6
6
  interface Props$2 {
7
- params: Promise<{
8
- siteId: string;
9
- }>;
7
+ params: Promise<Record<string, never>>;
10
8
  }
11
9
  type ThemeHomeDispatcher = (props: Props$2) => Promise<ReactNode>;
12
10
  type ThemeHomeMetadata = (props: Props$2) => Promise<Metadata>;
@@ -21,39 +19,23 @@ declare function createThemeHomeMetadata(ampless: Ampless): ThemeHomeMetadata;
21
19
 
22
20
  interface Props$1 {
23
21
  params: Promise<{
24
- siteId: string;
25
22
  slug: string;
26
23
  }>;
27
24
  }
28
25
  type ThemePostDispatcher = (props: Props$1) => Promise<ReactNode>;
29
26
  type ThemePostMetadata = (props: Props$1) => Promise<Metadata>;
30
27
  /**
31
- * Post page dispatcher. Resolves the active theme and renders the
32
- * theme's `components.Post` server component.
33
- *
34
- * Before delegating, the dispatcher peeks at the post's data and
35
- * 308-redirects to `/_/<slug>` for both formats that need to bypass
36
- * the theme's post page:
37
- *
38
- * - `metadata.no_layout === true` — the body is its own complete
39
- * HTML document and ships as the entire response. The unified
40
- * route handler at `/_/<slug>` emits the body verbatim.
41
- * - `format === 'static'` — the body is a manifest pointing at a
42
- * bundle of files in S3. The unified route handler then 308s
43
- * again to `/_/<slug>/` (trailing slash) so relative paths inside
44
- * the bundle resolve correctly.
45
- *
46
- * Why the redirect lands on `/_/<slug>` rather than `/_/<slug>/` for
47
- * static posts: trailing-slash anchoring is the unified route
48
- * handler's responsibility — keeps the dispatcher format-agnostic
49
- * and the public URL pattern symmetric (no_layout and static both
50
- * settle on `/_/<slug>(/...)`). One extra 308 round-trip is cheap;
51
- * it deduplicates the trailing-slash branch into a single place.
28
+ * Themed post page dispatcher. Resolves the active theme and renders
29
+ * the theme's `components.Post` server component.
52
30
  *
53
- * The metadata peek is an extra AppSync call before theme resolve,
54
- * but it's the same query the theme's Post component would make
55
- * anyway; AppSync's query-level dedupe within a single request keeps
56
- * the wire cost flat in practice.
31
+ * Routing decision (no_layout HTML vs static bundle vs themed) lives
32
+ * in middleware. Middleware fetches `post.format` / `post.metadata` /
33
+ * `post.updatedAt` from AppSync once per slug (Lambda-memory LRU, 60s
34
+ * TTL) and rewrites the request to either this dispatcher (themed
35
+ * render) or the unified route handler at `/r/<slug>(/...)` (no_layout
36
+ * HTML or static bundle). That keeps the decision in one place and
37
+ * avoids the dispatcher's extra AppSync round-trip when the post is a
38
+ * themed render.
57
39
  *
58
40
  * If the theme doesn't declare a Post component at all, returns
59
41
  * Next.js's notFound() (404).
@@ -64,7 +46,6 @@ declare function createThemePostMetadata(ampless: Ampless): ThemePostMetadata;
64
46
 
65
47
  interface Props {
66
48
  params: Promise<{
67
- siteId: string;
68
49
  tag: string;
69
50
  }>;
70
51
  }
@@ -15,14 +15,9 @@ function createThemeHomeMetadata(ampless) {
15
15
  }
16
16
 
17
17
  // src/dispatchers/post.ts
18
- import { notFound, redirect } from "next/navigation";
18
+ import { notFound } from "next/navigation";
19
19
  function createThemePostDispatcher(ampless) {
20
20
  return async function SitePostDispatcher({ params }) {
21
- const { slug } = await params;
22
- const post = await ampless.getPublishedPost(slug);
23
- if (post?.metadata?.no_layout === true || post?.format === "static") {
24
- redirect(`/_/${slug}`);
25
- }
26
21
  const { module } = await ampless.resolveActiveTheme();
27
22
  const Post = module.components.Post;
28
23
  if (!Post) notFound();
package/dist/index.d.ts CHANGED
@@ -34,6 +34,12 @@ interface PublicPostShape {
34
34
  publishedAt?: string | null;
35
35
  tags?: Array<string | null> | null;
36
36
  metadata?: unknown;
37
+ /**
38
+ * ISO 8601 timestamp from DynamoDB's auto-managed `updatedAt`.
39
+ * Surfaced through the `PublicPost` projection so middleware can
40
+ * compute the `metadata.cache='auto'` cooldown.
41
+ */
42
+ updatedAt?: string | null;
37
43
  }
38
44
  interface PublicPostConnectionShape {
39
45
  items?: Array<PublicPostShape | null> | null;
package/dist/index.js CHANGED
@@ -37,7 +37,8 @@ function toCorePost(p) {
37
37
  status: p.status ?? "draft",
38
38
  publishedAt: p.publishedAt ?? void 0,
39
39
  tags: (p.tags ?? []).filter((t) => typeof t === "string"),
40
- metadata: decodeMetadata(p.metadata)
40
+ metadata: decodeMetadata(p.metadata),
41
+ updatedAt: p.updatedAt ?? void 0
41
42
  };
42
43
  }
43
44
  function createPostsApi(outputs) {
@@ -1,37 +1,47 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
- import { Config } from 'ampless';
2
+ import { Config, PostMetadata } from 'ampless';
3
3
 
4
+ declare const runtime = "nodejs";
4
5
  interface CreateMiddlewareOpts {
5
6
  cmsConfig: Config;
7
+ /** AppSync GraphQL endpoint URL (from `amplify_outputs.json` `data.url`). */
8
+ appsyncUrl: string;
9
+ /** Public API key for the AppSync endpoint (from `amplify_outputs.json` `data.api_key`). */
10
+ apiKey: string;
6
11
  }
7
- type MiddlewareFn = (request: NextRequest) => NextResponse;
12
+ type MiddlewareFn = (request: NextRequest) => Promise<NextResponse>;
13
+ declare function _resetFlagCache(): void;
14
+ /**
15
+ * Compute the `Cache-Control` header for a post response. The
16
+ * strategy lives on `post.metadata.cache`; absent (or 'auto') falls
17
+ * back to cooldown-by-edit-time logic. See `CacheStrategy` /
18
+ * `CacheConfig` in ampless types for the semantic contract.
19
+ */
20
+ declare function computeCacheControl(flags: {
21
+ metadata: PostMetadata | null;
22
+ updatedAt: string;
23
+ }, cmsConfig: Config): string;
8
24
  /**
9
25
  * Build the ampless public-site middleware. Performs:
10
26
  *
11
- * - `/path` `/site/default/path` internal rewrite
12
- * - `/_/<slug>(/...)` `/site/default/r/<slug>(/...)` rewrite for
13
- * the unified bare-HTML / static-bundle route
14
- * - `?previewTheme=<name>` `x-preview-theme` header forwarding
15
- *
16
- * Bare-HTML / static routing is data-driven: the theme post dispatcher
17
- * redirects `metadata.no_layout` and `format='static'` posts to
18
- * `/_/<slug>`. The middleware rewrites the public `_` prefix to the
19
- * routable internal folder name `r/`, because Next.js's App Router
20
- * excludes any path part starting with `_` during route discovery
21
- * (see `recursive-readdir` + `ignorePartFilter` in
22
- * `next/dist/build/route-discovery.js`). The browser URL stays
23
- * `/_/<slug>(/...)`; only the rewrite target uses `r/`.
24
- *
25
- * Single site per Amplify deployment: the rewritten internal path uses
26
- * the fixed `default` segment. The internal `/site/[siteId]/` routing
27
- * tree is retained as an implementation detail; a follow-up PR
28
- * flattens the URL structure further.
27
+ * - AppSync flag fetch (`format` / `metadata` / `updatedAt`) per
28
+ * slug, with a 200-entry LRU keyed by slug, 60s TTL. Hot slugs
29
+ * cost zero queries for the cache lifetime.
30
+ * - Rewrite to `/r/<slug>(/<path>)` when the post is no_layout HTML
31
+ * (`format=html` + `metadata.no_layout=true`) or a static bundle
32
+ * (`format='static'`). Themed posts (default) get no rewrite
33
+ * `app/[slug]/page.tsx` serves them directly.
34
+ * - `Cache-Control` header computed from `post.metadata.cache` +
35
+ * `post.updatedAt` + `cms.config.cache.*` and set on the response.
36
+ * - `?previewTheme` / `?previewColorScheme` `x-preview-theme` /
37
+ * `x-preview-color-scheme` header forwarding for the admin's
38
+ * iframe-based theme preview.
29
39
  */
30
- declare function createAmplessMiddleware(_opts: CreateMiddlewareOpts): MiddlewareFn;
40
+ declare function createAmplessMiddleware(opts: CreateMiddlewareOpts): MiddlewareFn;
31
41
  /**
32
42
  * Reference matcher config — admin / api / login / static assets /
33
43
  * amplify_outputs.json are excluded so middleware doesn't rewrite
34
- * legitimate non-blog routes into the public site tree.
44
+ * legitimate non-blog routes.
35
45
  *
36
46
  * **You can't re-export this directly.** Next.js 16's Turbopack
37
47
  * requires `export const config` in `proxy.ts` (or `middleware.ts`)
@@ -48,4 +58,4 @@ declare const defaultMatcherConfig: {
48
58
  matcher: string[];
49
59
  };
50
60
 
51
- export { type CreateMiddlewareOpts, type MiddlewareFn, createAmplessMiddleware, defaultMatcherConfig };
61
+ export { type CreateMiddlewareOpts, type MiddlewareFn, _resetFlagCache, computeCacheControl, createAmplessMiddleware, defaultMatcherConfig, runtime };
@@ -1,22 +1,190 @@
1
1
  // src/middleware.ts
2
2
  import { NextResponse } from "next/server";
3
- var INTERNAL_SITE_SEGMENT = "default";
4
- function createAmplessMiddleware(_opts) {
5
- return function middleware(request) {
6
- const url = request.nextUrl.clone();
7
- if (!url.pathname.startsWith("/site/")) {
8
- let tail = url.pathname === "/" ? "" : url.pathname;
9
- if (tail === "/_" || tail.startsWith("/_/")) {
10
- tail = `/r${tail.slice(2)}`;
3
+ var runtime = "nodejs";
4
+ var FLAG_CACHE_MAX = 200;
5
+ var FLAG_CACHE_TTL_MS = 6e4;
6
+ var FLAG_CACHE = /* @__PURE__ */ new Map();
7
+ function cacheGet(slug) {
8
+ const hit = FLAG_CACHE.get(slug);
9
+ if (!hit) return void 0;
10
+ if (hit.expires < Date.now()) {
11
+ FLAG_CACHE.delete(slug);
12
+ return void 0;
13
+ }
14
+ FLAG_CACHE.delete(slug);
15
+ FLAG_CACHE.set(slug, hit);
16
+ return hit.value;
17
+ }
18
+ function cacheSet(slug, value) {
19
+ if (FLAG_CACHE.size >= FLAG_CACHE_MAX) {
20
+ const oldest = FLAG_CACHE.keys().next().value;
21
+ if (oldest !== void 0) FLAG_CACHE.delete(oldest);
22
+ }
23
+ FLAG_CACHE.set(slug, { value, expires: Date.now() + FLAG_CACHE_TTL_MS });
24
+ }
25
+ function _resetFlagCache() {
26
+ FLAG_CACHE.clear();
27
+ }
28
+ async function fetchFlags(opts, slug) {
29
+ const query = `query MiddlewareFlags($slug: String!) {
30
+ getPublishedPost(slug: $slug) { format metadata updatedAt }
31
+ }`;
32
+ let res;
33
+ try {
34
+ res = await fetch(opts.appsyncUrl, {
35
+ method: "POST",
36
+ headers: {
37
+ "Content-Type": "application/json",
38
+ "x-api-key": opts.apiKey
39
+ },
40
+ body: JSON.stringify({ query, variables: { slug } })
41
+ });
42
+ } catch (err) {
43
+ console.error("[ampless-middleware] AppSync fetch failed", err);
44
+ return null;
45
+ }
46
+ if (!res.ok) {
47
+ console.error(
48
+ `[ampless-middleware] AppSync returned ${res.status} for slug=${slug}`
49
+ );
50
+ return null;
51
+ }
52
+ let body;
53
+ try {
54
+ body = await res.json();
55
+ } catch (err) {
56
+ console.error("[ampless-middleware] AppSync JSON parse failed", err);
57
+ return null;
58
+ }
59
+ if (body.errors && body.errors.length > 0) {
60
+ console.error(
61
+ "[ampless-middleware] AppSync errors",
62
+ body.errors.map((e) => e.message)
63
+ );
64
+ return null;
65
+ }
66
+ const post = body.data?.getPublishedPost;
67
+ if (!post) return null;
68
+ let metadata = null;
69
+ if (post.metadata) {
70
+ try {
71
+ const parsed = typeof post.metadata === "string" ? JSON.parse(post.metadata) : post.metadata;
72
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
73
+ metadata = parsed;
11
74
  }
12
- url.pathname = `/site/${INTERNAL_SITE_SEGMENT}${tail}`;
75
+ } catch (err) {
76
+ console.error(
77
+ `[ampless-middleware] metadata parse failed for slug=${slug}`,
78
+ err
79
+ );
80
+ metadata = null;
13
81
  }
82
+ }
83
+ return {
84
+ format: post.format ?? "markdown",
85
+ metadata,
86
+ updatedAt: post.updatedAt ?? ""
87
+ };
88
+ }
89
+ async function getCachedFlags(opts, slug) {
90
+ const cached = cacheGet(slug);
91
+ if (cached !== void 0) return cached;
92
+ const fresh = await fetchFlags(opts, slug);
93
+ cacheSet(slug, fresh);
94
+ return fresh;
95
+ }
96
+ var DEFAULT_COOLDOWN_MS = 60 * 60 * 1e3;
97
+ var DEFAULT_FRESH_TTL_SECONDS = 300;
98
+ var DEFAULT_DEEP_TTL_SECONDS = 60 * 60;
99
+ var NO_STORE = "public, max-age=0, must-revalidate, s-maxage=0";
100
+ function computeCacheControl(flags, cmsConfig) {
101
+ const cache = cmsConfig.cache ?? {};
102
+ const strategy = flags.metadata?.cache ?? "auto";
103
+ if (strategy === "hot") return NO_STORE;
104
+ if (strategy === "deep") {
105
+ const ttl = cache.deepTtlSeconds ?? DEFAULT_DEEP_TTL_SECONDS;
106
+ return `public, max-age=${ttl}, s-maxage=${ttl}`;
107
+ }
108
+ const cooldownMs = cache.cooldownMs ?? DEFAULT_COOLDOWN_MS;
109
+ const freshTtl = cache.freshTtlSeconds ?? DEFAULT_FRESH_TTL_SECONDS;
110
+ const updatedMs = flags.updatedAt ? Date.parse(flags.updatedAt) : NaN;
111
+ if (Number.isFinite(updatedMs)) {
112
+ const ageMs = Date.now() - updatedMs;
113
+ if (ageMs < cooldownMs) return NO_STORE;
114
+ }
115
+ return `public, max-age=${freshTtl}, s-maxage=${freshTtl}`;
116
+ }
117
+ var RESERVED_PREFIXES = /* @__PURE__ */ new Set([
118
+ "admin",
119
+ "api",
120
+ "login",
121
+ "_next",
122
+ "favicon.ico",
123
+ "robots.txt",
124
+ "amplify_outputs.json",
125
+ // route handler folders that live alongside `app/[slug]/page.tsx`
126
+ "feed.xml",
127
+ "sitemap.xml",
128
+ "og",
129
+ "tag",
130
+ // internal rewrite target — direct hits should not be middleware-driven
131
+ "r"
132
+ ]);
133
+ function createAmplessMiddleware(opts) {
134
+ return async function middleware(request) {
135
+ const url = request.nextUrl.clone();
14
136
  const previewTheme = url.searchParams.get("previewTheme");
15
137
  const previewColorScheme = url.searchParams.get("previewColorScheme");
16
138
  const requestHeaders = new Headers(request.headers);
17
139
  if (previewTheme) requestHeaders.set("x-preview-theme", previewTheme);
18
- if (previewColorScheme) requestHeaders.set("x-preview-color-scheme", previewColorScheme);
19
- return NextResponse.rewrite(url, { request: { headers: requestHeaders } });
140
+ if (previewColorScheme) {
141
+ requestHeaders.set("x-preview-color-scheme", previewColorScheme);
142
+ }
143
+ const passthrough = () => NextResponse.next({ request: { headers: requestHeaders } });
144
+ const segments = url.pathname.split("/").filter(Boolean);
145
+ if (segments.length === 0) {
146
+ return passthrough();
147
+ }
148
+ const slug = segments[0];
149
+ if (RESERVED_PREFIXES.has(slug)) {
150
+ return passthrough();
151
+ }
152
+ const restPath = segments.slice(1);
153
+ const flags = await getCachedFlags(opts, slug);
154
+ if (!flags) {
155
+ if (restPath.length > 0) {
156
+ return new NextResponse("Not Found", { status: 404 });
157
+ }
158
+ return passthrough();
159
+ }
160
+ const cacheControl = computeCacheControl(flags, opts.cmsConfig);
161
+ let response;
162
+ if (restPath.length === 0) {
163
+ if (flags.format === "html" && flags.metadata?.no_layout === true) {
164
+ url.pathname = `/r/${slug}`;
165
+ response = NextResponse.rewrite(url, {
166
+ request: { headers: requestHeaders }
167
+ });
168
+ } else if (flags.format === "static") {
169
+ url.pathname = `/r/${slug}`;
170
+ response = NextResponse.rewrite(url, {
171
+ request: { headers: requestHeaders }
172
+ });
173
+ } else {
174
+ response = passthrough();
175
+ }
176
+ } else {
177
+ if (flags.format === "static") {
178
+ url.pathname = `/r/${slug}/${restPath.join("/")}`;
179
+ response = NextResponse.rewrite(url, {
180
+ request: { headers: requestHeaders }
181
+ });
182
+ } else {
183
+ return new NextResponse("Not Found", { status: 404 });
184
+ }
185
+ }
186
+ response.headers.set("Cache-Control", cacheControl);
187
+ return response;
20
188
  };
21
189
  }
22
190
  var defaultMatcherConfig = {
@@ -25,6 +193,9 @@ var defaultMatcherConfig = {
25
193
  ]
26
194
  };
27
195
  export {
196
+ _resetFlagCache,
197
+ computeCacheControl,
28
198
  createAmplessMiddleware,
29
- defaultMatcherConfig
199
+ defaultMatcherConfig,
200
+ runtime
30
201
  };
@@ -4,7 +4,6 @@ import 'next';
4
4
 
5
5
  interface Ctx$3 {
6
6
  params: Promise<{
7
- siteId: string;
8
7
  slug: string;
9
8
  }>;
10
9
  }
@@ -12,9 +11,7 @@ type OgRouteHandler = (req: Request, ctx: Ctx$3) => Promise<Response>;
12
11
  declare function createOgRouteHandler(ampless: Ampless): OgRouteHandler;
13
12
 
14
13
  interface Ctx$2 {
15
- params: Promise<{
16
- siteId: string;
17
- }>;
14
+ params: Promise<Record<string, never>>;
18
15
  }
19
16
  type SitemapRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
20
17
  /**
@@ -25,9 +22,7 @@ type SitemapRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
25
22
  declare function createSitemapRouteHandler(ampless: Ampless): SitemapRouteHandler;
26
23
 
27
24
  interface Ctx$1 {
28
- params: Promise<{
29
- siteId: string;
30
- }>;
25
+ params: Promise<Record<string, never>>;
31
26
  }
32
27
  type FeedRouteHandler = (req: Request, ctx: Ctx$1) => Promise<Response>;
33
28
  /**
@@ -45,42 +40,46 @@ interface Ctx {
45
40
  }
46
41
  type UnderscoreRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
47
42
  /**
48
- * Unified `/_/<slug>(/...)` route handler.
43
+ * Unified internal route handler for no_layout HTML and static bundle
44
+ * posts. Mounted at `app/r/[slug]/[[...path]]/route.ts`; reached via
45
+ * middleware rewrite from the public `/<slug>(/<path>)` URL, never
46
+ * directly.
49
47
  *
50
- * The public URL prefix `/_/` is reserved for two related cases that
51
- * both need to bypass the theme's post page:
48
+ * Two cases that both need to bypass the themed post page:
52
49
  *
53
50
  * 1. `format: 'html'` posts with `metadata.no_layout === true` — the
54
51
  * body is its own complete HTML document and ships as the entire
55
- * response. URL: `/_/<slug>` (no trailing slash, no extra path).
52
+ * response. Reached via middleware rewrite of `/<slug>`.
56
53
  * 2. `format: 'static'` posts — the body is a manifest describing a
57
54
  * bundle of files in S3 at `public/static/<slug>/`. The bundle's
58
- * entrypoint is served at `/_/<slug>/`, every internal file at
59
- * `/_/<slug>/<relative-path>`.
55
+ * entrypoint is served at `/<slug>/`, every internal file at
56
+ * `/<slug>/<relative-path>` (middleware rewrites both to
57
+ * `/r/<slug>/...`).
60
58
  *
61
59
  * Routing model:
62
- * - File location: `app/site/[siteId]/r/[slug]/[[...path]]/route.ts`.
63
- * The literal folder is `r/` (not `_/`) because Next.js's App
64
- * Router skips any path part starting with `_` during route
65
- * discovery (see `recursive-readdir` + `ignorePartFilter` in
66
- * next/dist/build/route-discovery.js). Middleware rewrites the
67
- * public `/_/` prefix to `/r/` internally; the public URL stays
68
- * `/_/<slug>(/...)`.
60
+ * - File location: `app/r/[slug]/[[...path]]/route.ts`. The literal
61
+ * folder is `r/` (not `_/` etc.) because Next.js's App Router
62
+ * skips any path part starting with `_` during route discovery
63
+ * (see `recursive-readdir` + `ignorePartFilter` in
64
+ * next/dist/build/route-discovery.js). The `r/` prefix is an
65
+ * internal filesystem detail the public URL stays `/<slug>(/...)`.
69
66
  * - `params.path` is `undefined` (or `[]`) for single-segment
70
- * requests `/_/<slug>`, an array of remaining segments otherwise.
67
+ * requests, an array of remaining segments otherwise.
71
68
  *
72
- * Trailing-slash responsibility lives here, not in the dispatcher:
73
- * the dispatcher redirects `format='static'` posts to `/_/<slug>`,
74
- * and this handler then 308-redirects to `/_/<slug>/` to anchor
75
- * relative paths inside the bundle. Same anchoring reason as the
76
- * legacy static route's behaviour — `<img src="img.png">` must resolve
77
- * to `/_/<slug>/img.png`, not the site root.
69
+ * Trailing-slash responsibility lives here. For static bundles the
70
+ * handler 308-redirects `/<slug>` to `/<slug>/` so relative asset
71
+ * paths inside the bundle resolve correctly `<img src="img.png">`
72
+ * must resolve to `/<slug>/img.png`, not the site root.
78
73
  *
79
74
  * Trust model: no_layout HTML bodies are emitted verbatim, same trust
80
75
  * shape as `format: 'html'` post bodies on the regular path. Static
81
76
  * bundle assets are served via short-lived S3 presigned URLs; the
82
77
  * bucket stays private. See docs/architecture/04-access-layer-mcp.md
83
78
  * §"editor の信頼モデル".
79
+ *
80
+ * Cache-Control: deliberately omitted from the responses here.
81
+ * Middleware computes the strategy from `metadata.cache` +
82
+ * `post.updatedAt` and sets the header on the rewritten response.
84
83
  */
85
84
  declare function createUnderscoreRouteHandler(ampless: Ampless): UnderscoreRouteHandler;
86
85
 
@@ -85,8 +85,9 @@ function createUnderscoreRouteHandler(ampless) {
85
85
  return new Response(ampless.renderBody(post), {
86
86
  status: 200,
87
87
  headers: {
88
- "Content-Type": "text/html; charset=utf-8",
89
- "Cache-Control": "public, max-age=300"
88
+ "Content-Type": "text/html; charset=utf-8"
89
+ // Cache-Control set by middleware (cache strategy depends on
90
+ // post.metadata.cache + post.updatedAt).
90
91
  }
91
92
  });
92
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "1.0.0-alpha.13",
3
+ "version": "1.0.0-alpha.15",
4
4
  "description": "Public-side runtime for ampless: post fetching, theme dispatch, middleware, public route handlers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,8 +49,8 @@
49
49
  "lucide-react": "^1.16.0",
50
50
  "marked": "^14.1.4",
51
51
  "tailwind-merge": "^3.6.0",
52
- "ampless": "1.0.0-alpha.9",
53
- "@ampless/plugin-og-image": "0.2.0-alpha.9"
52
+ "ampless": "1.0.0-alpha.11",
53
+ "@ampless/plugin-og-image": "0.2.0-alpha.11"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "next": "^15 || ^16",