@ampless/runtime 0.2.0-alpha.9 → 1.0.0-alpha.14

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.
@@ -1,30 +1,189 @@
1
1
  // src/middleware.ts
2
2
  import { NextResponse } from "next/server";
3
- import { resolveSiteId, isMultiSite } from "ampless";
4
- function createAmplessMiddleware({ cmsConfig }) {
5
- const MULTI_SITE = isMultiSite(cmsConfig);
6
- return function middleware(request) {
7
- const host = (request.headers.get("host") ?? "").split(":")[0];
8
- const siteId = resolveSiteId(host ?? "", cmsConfig);
9
- if (!siteId) {
10
- return new NextResponse("Site not found", { status: 404 });
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;
74
+ }
75
+ } catch (err) {
76
+ console.error(
77
+ `[ampless-middleware] metadata parse failed for slug=${slug}`,
78
+ err
79
+ );
80
+ metadata = null;
11
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) {
12
135
  const url = request.nextUrl.clone();
13
- if (!url.pathname.startsWith("/site/")) {
14
- const tail = url.pathname === "/" ? "" : url.pathname;
15
- url.pathname = `/site/${siteId}${tail}`;
16
- }
17
136
  const previewTheme = url.searchParams.get("previewTheme");
18
137
  const previewColorScheme = url.searchParams.get("previewColorScheme");
19
138
  const requestHeaders = new Headers(request.headers);
20
- requestHeaders.set("x-site-id", siteId);
21
139
  if (previewTheme) requestHeaders.set("x-preview-theme", previewTheme);
22
- if (previewColorScheme) requestHeaders.set("x-preview-color-scheme", previewColorScheme);
23
- const response = NextResponse.rewrite(url, { request: { headers: requestHeaders } });
24
- response.headers.set("x-site-id", siteId);
25
- if (MULTI_SITE) {
26
- response.headers.set("Cache-Control", "private, no-store");
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
+ }
27
185
  }
186
+ response.headers.set("Cache-Control", cacheControl);
28
187
  return response;
29
188
  };
30
189
  }
@@ -34,6 +193,9 @@ var defaultMatcherConfig = {
34
193
  ]
35
194
  };
36
195
  export {
196
+ _resetFlagCache,
197
+ computeCacheControl,
37
198
  createAmplessMiddleware,
38
- defaultMatcherConfig
199
+ defaultMatcherConfig,
200
+ runtime
39
201
  };
@@ -2,112 +2,85 @@ import { Ampless } from '../index.js';
2
2
  import 'ampless';
3
3
  import 'next';
4
4
 
5
- interface Ctx$4 {
5
+ interface Ctx$3 {
6
6
  params: Promise<{
7
- siteId: string;
8
7
  slug: string;
9
8
  }>;
10
9
  }
11
- type OgRouteHandler = (req: Request, ctx: Ctx$4) => Promise<Response>;
10
+ type OgRouteHandler = (req: Request, ctx: Ctx$3) => Promise<Response>;
12
11
  declare function createOgRouteHandler(ampless: Ampless): OgRouteHandler;
13
12
 
14
- interface Ctx$3 {
15
- params: Promise<{
16
- siteId: string;
17
- }>;
13
+ interface Ctx$2 {
14
+ params: Promise<Record<string, never>>;
18
15
  }
19
- type SitemapRouteHandler = (req: Request, ctx: Ctx$3) => Promise<Response>;
16
+ type SitemapRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
20
17
  /**
21
- * Sitemap route delegate. Looks up the active theme for the request's
22
- * siteId and forwards to whichever `routes.sitemap` handler the theme
23
- * provides. Themes without a sitemap handler return 404.
18
+ * Sitemap route delegate. Looks up the active theme and forwards to
19
+ * whichever `routes.sitemap` handler the theme provides. Themes
20
+ * without a sitemap handler return 404.
24
21
  */
25
22
  declare function createSitemapRouteHandler(ampless: Ampless): SitemapRouteHandler;
26
23
 
27
- interface Ctx$2 {
28
- params: Promise<{
29
- siteId: string;
30
- }>;
24
+ interface Ctx$1 {
25
+ params: Promise<Record<string, never>>;
31
26
  }
32
- type FeedRouteHandler = (req: Request, ctx: Ctx$2) => Promise<Response>;
27
+ type FeedRouteHandler = (req: Request, ctx: Ctx$1) => Promise<Response>;
33
28
  /**
34
- * Feed route delegate. Looks up the active theme for the request's
35
- * siteId and forwards to whichever `routes.feed` handler the theme
36
- * provides. Themes without a feed handler return 404.
29
+ * Feed route delegate. Looks up the active theme and forwards to
30
+ * whichever `routes.feed` handler the theme provides. Themes without
31
+ * a feed handler return 404.
37
32
  */
38
33
  declare function createFeedRouteHandler(ampless: Ampless): FeedRouteHandler;
39
34
 
40
- interface Ctx$1 {
35
+ interface Ctx {
41
36
  params: Promise<{
42
- siteId: string;
43
37
  slug: string;
38
+ path?: string[];
44
39
  }>;
45
40
  }
46
- type RawRouteHandler = (req: Request, ctx: Ctx$1) => Promise<Response>;
41
+ type UnderscoreRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
47
42
  /**
48
- * Bare HTML route. Returns the published post's rendered body as the
49
- * entire HTTP response — no Next.js root layout, no theme chrome.
50
- *
51
- * Reached via the data-driven dispatcher path: the theme post page
52
- * checks `post.metadata.no_layout` and redirects to `/raw/<slug>` when
53
- * set. Posts without that flag never hit this route in practice; the
54
- * handler additionally enforces the same check here so a direct
55
- * `/raw/<slug>` request can't bypass the theme for a post that wasn't
56
- * marked no-layout (would otherwise surface the body without any
57
- * chrome the editor didn't intend).
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.
58
47
  *
59
- * Why this is a route handler (not a page): Next.js's root layout
60
- * always emits `<html>` / `<head>` / `<body>`, which means a normal
61
- * page can't replace the document. A `route.ts` returns a Response
62
- * directly and bypasses React rendering entirely.
48
+ * Two cases that both need to bypass the themed post page:
63
49
  *
64
- * Format pairing:
65
- * - `format: 'html'` with a full `<!DOCTYPE html>...</html>` body
66
- * the body lands in the response unchanged. Best fit for
67
- * custom landing pages with their own `<head>`, styles, scripts.
68
- * - `format: 'tiptap'` / `'markdown'` renderBody returns an
69
- * HTML fragment. Browsers render fragments fine, but the page
70
- * ships without `<!DOCTYPE>` / `<head>` / `<title>`. If you care
71
- * about those, use `format: 'html'`.
50
+ * 1. `format: 'html'` posts with `metadata.no_layout === true` — the
51
+ * body is its own complete HTML document and ships as the entire
52
+ * response. Reached via middleware rewrite of `/<slug>`.
53
+ * 2. `format: 'static'` posts the body is a manifest describing a
54
+ * bundle of files in S3 at `public/static/<slug>/`. The bundle's
55
+ * entrypoint is served at `/<slug>/`, every internal file at
56
+ * `/<slug>/<relative-path>` (middleware rewrites both to
57
+ * `/r/<slug>/...`).
72
58
  *
73
- * Trust model: the response body is the editor's content verbatim,
74
- * with no sanitization. Same trust assumption as `format: 'html'`
75
- * post bodies on the regular path see
76
- * docs/architecture/04-access-layer-mcp.md §"editor の信頼モデル".
77
- */
78
- declare function createRawRouteHandler(ampless: Ampless): RawRouteHandler;
79
-
80
- interface Ctx {
81
- params: Promise<{
82
- siteId: string;
83
- path: string[];
84
- }>;
85
- }
86
- type StaticRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
87
- /**
88
- * Catch-all route for static bundles. Lives at
89
- * `app/site/[siteId]/[...path]/route.ts` and serves every file
90
- * inside a `format: 'static'` post's bundle. The first segment of
91
- * `path` is the post slug; the rest is the relative path inside the
92
- * bundle (or empty, in which case the manifest's entrypoint is used).
59
+ * Routing model:
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>(/...)`.
66
+ * - `params.path` is `undefined` (or `[]`) for single-segment
67
+ * requests, an array of remaining segments otherwise.
93
68
  *
94
- * Routing precedence: Next.js prefers the more specific
95
- * `[siteId]/[slug]/page.tsx` for single-segment URLs, so this catch-all
96
- * only fires when path.length >= 2. The post dispatcher detects
97
- * `format === 'static'` and 308-redirects to `/<slug>/<entrypoint>`
98
- * so the catch-all picks up from there.
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.
99
73
  *
100
- * Single-segment requests (`/<siteId>/<slug>`) that somehow do reach
101
- * here are 308-redirected to add a trailing slash, which is what makes
102
- * relative paths in the bundle resolve correctly in the browser.
74
+ * Trust model: no_layout HTML bodies are emitted verbatim, same trust
75
+ * shape as `format: 'html'` post bodies on the regular path. Static
76
+ * bundle assets are served via short-lived S3 presigned URLs; the
77
+ * bucket stays private. See docs/architecture/04-access-layer-mcp.md
78
+ * §"editor の信頼モデル".
103
79
  *
104
- * Implementation: generate a short-lived presigned URL for the S3
105
- * object and 302-redirect to it. Same model as `/api/media/[...path]`
106
- * keeps the bucket private while letting CloudFront cache the 302
107
- * for the Cache-Control window. The presigned URL itself expires in
108
- * an hour; the 302 cache is 5 minutes so re-issued presigns stay
109
- * fresh.
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.
110
83
  */
111
- declare function createStaticRouteHandler(ampless: Ampless): StaticRouteHandler;
84
+ declare function createUnderscoreRouteHandler(ampless: Ampless): UnderscoreRouteHandler;
112
85
 
113
- export { type FeedRouteHandler, type OgRouteHandler, type RawRouteHandler, type SitemapRouteHandler, type StaticRouteHandler, createFeedRouteHandler, createOgRouteHandler, createRawRouteHandler, createSitemapRouteHandler, createStaticRouteHandler };
86
+ export { type FeedRouteHandler, type OgRouteHandler, type SitemapRouteHandler, type UnderscoreRouteHandler, createFeedRouteHandler, createOgRouteHandler, createSitemapRouteHandler, createUnderscoreRouteHandler };
@@ -6,13 +6,13 @@ function hasOgImage(p) {
6
6
  }
7
7
  function createOgRouteHandler(ampless) {
8
8
  return async function GET(_req, { params }) {
9
- const { siteId, slug } = await params;
9
+ const { slug } = await params;
10
10
  const cleanSlug = slug.replace(/\.png$/, "");
11
- const post = await ampless.getPublishedPost(cleanSlug, { siteId });
11
+ const post = await ampless.getPublishedPost(cleanSlug);
12
12
  if (!post) return new Response("not found", { status: 404 });
13
13
  const plugin = (ampless.cmsConfig.plugins ?? []).find(hasOgImage);
14
14
  if (!plugin) return new Response("og not configured", { status: 404 });
15
- const settings = await ampless.loadSiteSettings(siteId);
15
+ const settings = await ampless.loadSiteSettings();
16
16
  const fonts = await Promise.all(
17
17
  plugin.ogImage.fonts.map(async (f) => ({
18
18
  name: f.name,
@@ -43,122 +43,128 @@ function createOgRouteHandler(ampless) {
43
43
 
44
44
  // src/routes/sitemap.ts
45
45
  function createSitemapRouteHandler(ampless) {
46
- return async function GET(request, { params }) {
47
- const { siteId } = await params;
48
- const { module } = await ampless.resolveActiveTheme(siteId);
46
+ return async function GET(request) {
47
+ const { module } = await ampless.resolveActiveTheme();
49
48
  const handler = module.routes?.sitemap;
50
49
  if (!handler) {
51
50
  return new Response("sitemap not implemented for this theme", { status: 404 });
52
51
  }
53
- return handler({ siteId, request });
52
+ return handler({ request });
54
53
  };
55
54
  }
56
55
 
57
56
  // src/routes/feed.ts
58
57
  function createFeedRouteHandler(ampless) {
59
- return async function GET(request, { params }) {
60
- const { siteId } = await params;
61
- const { module } = await ampless.resolveActiveTheme(siteId);
58
+ return async function GET(request) {
59
+ const { module } = await ampless.resolveActiveTheme();
62
60
  const handler = module.routes?.feed;
63
61
  if (!handler) {
64
62
  return new Response("feed not implemented for this theme", { status: 404 });
65
63
  }
66
- return handler({ siteId, request });
64
+ return handler({ request });
67
65
  };
68
66
  }
69
67
 
70
- // src/routes/raw.ts
71
- function createRawRouteHandler(ampless) {
72
- return async function GET(_request, { params }) {
73
- const { siteId, slug } = await params;
74
- const post = await ampless.getPublishedPost(slug, { siteId });
75
- if (!post) {
76
- return new Response("Not Found", { status: 404 });
77
- }
78
- if (post.metadata?.no_layout !== true) {
79
- return new Response("Not Found", { status: 404 });
80
- }
81
- return new Response(ampless.renderBody(post), {
82
- status: 200,
83
- headers: {
84
- "Content-Type": "text/html; charset=utf-8",
85
- "Cache-Control": "public, max-age=300"
86
- }
87
- });
88
- };
89
- }
90
-
91
- // src/routes/static.ts
68
+ // src/routes/underscore.ts
92
69
  import { createServerRunner } from "@aws-amplify/adapter-nextjs";
93
70
  import { cookies } from "next/headers";
94
71
  import { getUrl } from "aws-amplify/storage/server";
95
- var FALLBACK_MIME = {
96
- ".html": "text/html; charset=utf-8",
97
- ".htm": "text/html; charset=utf-8"
98
- };
99
- function createStaticRouteHandler(ampless) {
72
+ function createUnderscoreRouteHandler(ampless) {
100
73
  const { runWithAmplifyServerContext } = createServerRunner({
101
74
  config: ampless.outputs
102
75
  });
103
76
  return async function GET(request, { params }) {
104
- const { siteId, path } = await params;
105
- if (!path || path.length === 0) {
77
+ const { slug, path } = await params;
78
+ const restSegments = path ?? [];
79
+ const post = await ampless.getPublishedPost(slug);
80
+ if (!post) {
81
+ return new Response("Not Found", { status: 404 });
82
+ }
83
+ if (restSegments.length === 0) {
84
+ if (post.format === "html" && post.metadata?.no_layout === true) {
85
+ return new Response(ampless.renderBody(post), {
86
+ status: 200,
87
+ headers: {
88
+ "Content-Type": "text/html; charset=utf-8"
89
+ // Cache-Control set by middleware (cache strategy depends on
90
+ // post.metadata.cache + post.updatedAt).
91
+ }
92
+ });
93
+ }
94
+ if (post.format === "static") {
95
+ const url = new URL(request.url);
96
+ if (!url.pathname.endsWith("/")) {
97
+ const next = new URL(url);
98
+ next.pathname = `${url.pathname}/`;
99
+ return Response.redirect(next.toString(), 308);
100
+ }
101
+ const body2 = post.body ?? null;
102
+ const entrypoint = typeof body2?.entrypoint === "string" && body2.entrypoint ? body2.entrypoint : "index.html";
103
+ const presignedUrl2 = await signStaticAsset({
104
+ runWithAmplifyServerContext,
105
+ slug,
106
+ rest: entrypoint
107
+ });
108
+ if (!presignedUrl2) {
109
+ return new Response("Not Found", { status: 404 });
110
+ }
111
+ return Response.redirect(presignedUrl2, 302);
112
+ }
113
+ return new Response("Not Found", { status: 404 });
114
+ }
115
+ if (post.format !== "static") {
106
116
  return new Response("Not Found", { status: 404 });
107
117
  }
108
- const slug = path[0];
109
- const restSegments = path.slice(1);
110
118
  if (restSegments.some(
111
119
  (s) => !s || s === "." || s === ".." || s.includes("/") || s.includes("\\") || s.includes("\0")
112
120
  )) {
113
121
  return new Response("Invalid path", { status: 400 });
114
122
  }
115
- const post = await ampless.getPublishedPost(slug, { siteId });
116
- if (!post || post.format !== "static") {
117
- return new Response("Not Found", { status: 404 });
118
- }
119
123
  const body = post.body ?? null;
120
- const entrypoint = typeof body?.entrypoint === "string" && body.entrypoint ? body.entrypoint : "index.html";
121
124
  const fileList = Array.isArray(body?.files) ? body.files : [];
122
- let rest = restSegments.join("/");
123
- if (rest === "") {
124
- const url = new URL(request.url);
125
- if (!url.pathname.endsWith("/")) {
126
- const next = new URL(url);
127
- next.pathname = `${url.pathname}/`;
128
- return Response.redirect(next.toString(), 308);
129
- }
130
- rest = entrypoint;
131
- }
125
+ const rest = restSegments.join("/");
132
126
  if (fileList.length > 0 && !fileList.includes(rest)) {
133
127
  return new Response("Not Found", { status: 404 });
134
128
  }
135
- const objectPath = `public/static/${siteId}/${slug}/${rest}`;
136
- try {
137
- const presignedUrl = await runWithAmplifyServerContext({
138
- nextServerContext: { cookies },
139
- operation: async (amplifyContext) => {
140
- const result = await getUrl(amplifyContext, {
141
- path: objectPath,
142
- options: { expiresIn: 60 * 60 }
143
- });
144
- return result.url.toString();
145
- }
146
- });
147
- return Response.redirect(presignedUrl, 302);
148
- } catch {
149
- const ext = rest.slice(rest.lastIndexOf("."));
150
- const fallback = FALLBACK_MIME[ext] ?? "text/plain; charset=utf-8";
151
- return new Response("Not Found", {
152
- status: 404,
153
- headers: { "Content-Type": fallback }
154
- });
129
+ const presignedUrl = await signStaticAsset({
130
+ runWithAmplifyServerContext,
131
+ slug,
132
+ rest
133
+ });
134
+ if (!presignedUrl) {
135
+ return new Response("Not Found", { status: 404 });
155
136
  }
137
+ return Response.redirect(presignedUrl, 302);
156
138
  };
157
139
  }
140
+ async function signStaticAsset({
141
+ runWithAmplifyServerContext,
142
+ slug,
143
+ rest
144
+ }) {
145
+ const objectPath = `public/static/${slug}/${rest}`;
146
+ try {
147
+ return await runWithAmplifyServerContext({
148
+ nextServerContext: { cookies },
149
+ operation: async (amplifyContext) => {
150
+ const result = await getUrl(amplifyContext, {
151
+ path: objectPath,
152
+ options: { expiresIn: 60 * 60 }
153
+ });
154
+ return result.url.toString();
155
+ }
156
+ });
157
+ } catch (err) {
158
+ console.error(
159
+ `[underscore-route] presign failed for ${objectPath}:`,
160
+ err
161
+ );
162
+ return null;
163
+ }
164
+ }
158
165
  export {
159
166
  createFeedRouteHandler,
160
167
  createOgRouteHandler,
161
- createRawRouteHandler,
162
168
  createSitemapRouteHandler,
163
- createStaticRouteHandler
169
+ createUnderscoreRouteHandler
164
170
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "0.2.0-alpha.9",
3
+ "version": "1.0.0-alpha.14",
4
4
  "description": "Public-side runtime for ampless: post fetching, theme dispatch, middleware, public route handlers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,9 +47,10 @@
47
47
  "class-variance-authority": "^0.7.1",
48
48
  "clsx": "^2.1.1",
49
49
  "lucide-react": "^1.16.0",
50
+ "marked": "^14.1.4",
50
51
  "tailwind-merge": "^3.6.0",
51
- "ampless": "0.2.0-alpha.6",
52
- "@ampless/plugin-og-image": "0.2.0-alpha.6"
52
+ "ampless": "1.0.0-alpha.10",
53
+ "@ampless/plugin-og-image": "0.2.0-alpha.10"
53
54
  },
54
55
  "peerDependencies": {
55
56
  "next": "^15 || ^16",