@ampless/runtime 1.0.0-alpha.21 → 1.0.0-alpha.23

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.
@@ -0,0 +1,123 @@
1
+ // src/stream-s3.ts
2
+ import { getProperties } from "aws-amplify/storage/server";
3
+ import { cookies } from "next/headers";
4
+ var DEFAULT_THRESHOLD_BYTES = 6 * 1024 * 1024;
5
+ var META_CACHE_MAX = 1e3;
6
+ var META_CACHE_TTL_MS = 5 * 6e4;
7
+ var META_CACHE = /* @__PURE__ */ new Map();
8
+ function metaCacheGet(key) {
9
+ const hit = META_CACHE.get(key);
10
+ if (!hit) return void 0;
11
+ if (hit.expires < Date.now()) {
12
+ META_CACHE.delete(key);
13
+ return void 0;
14
+ }
15
+ META_CACHE.delete(key);
16
+ META_CACHE.set(key, hit);
17
+ return hit.meta;
18
+ }
19
+ function metaCacheSet(key, meta) {
20
+ if (META_CACHE.size >= META_CACHE_MAX) {
21
+ const oldest = META_CACHE.keys().next().value;
22
+ if (oldest !== void 0) META_CACHE.delete(oldest);
23
+ }
24
+ META_CACHE.set(key, { meta, expires: Date.now() + META_CACHE_TTL_MS });
25
+ }
26
+ function _resetStreamS3Cache() {
27
+ META_CACHE.clear();
28
+ }
29
+ async function headViaAmplify(ctx, key) {
30
+ try {
31
+ const props = await getProperties(ctx, { path: key });
32
+ const size = typeof props.size === "number" ? props.size : 0;
33
+ const mimeType = props.contentType ?? "application/octet-stream";
34
+ const etag = props.eTag;
35
+ return { size, mimeType, etag };
36
+ } catch (err) {
37
+ console.error(`[stream-s3] getProperties failed for ${key}:`, err);
38
+ return null;
39
+ }
40
+ }
41
+ async function streamS3Object(amplifyServerContext, key, options) {
42
+ const threshold = options.thresholdBytes ?? DEFAULT_THRESHOLD_BYTES;
43
+ let meta = options.meta ?? null;
44
+ if (!meta) {
45
+ const cached = metaCacheGet(key);
46
+ if (cached) {
47
+ meta = cached;
48
+ } else if (options.headFallback) {
49
+ try {
50
+ meta = await options.headFallback();
51
+ } catch (err) {
52
+ console.error(`[stream-s3] headFallback threw for ${key}:`, err);
53
+ meta = null;
54
+ }
55
+ if (meta) metaCacheSet(key, meta);
56
+ }
57
+ }
58
+ if (!meta) {
59
+ const probed = await headViaAmplify(amplifyServerContext, key);
60
+ if (probed) {
61
+ metaCacheSet(key, probed);
62
+ meta = probed;
63
+ }
64
+ }
65
+ if (!meta) {
66
+ return new Response("Not Found", { status: 404 });
67
+ }
68
+ if (meta.size > threshold) {
69
+ const url = await options.presignedUrlFor(key).catch((err) => {
70
+ console.error(`[stream-s3] presign failed for ${key}:`, err);
71
+ return null;
72
+ });
73
+ if (!url) return new Response("Not Found", { status: 404 });
74
+ const headers2 = new Headers({ Location: url });
75
+ if (options.cacheControl) headers2.set("Cache-Control", options.cacheControl);
76
+ return new Response(null, { status: 302, headers: headers2 });
77
+ }
78
+ const presigned = await options.presignedUrlFor(key).catch((err) => {
79
+ console.error(`[stream-s3] presign failed for ${key}:`, err);
80
+ return null;
81
+ });
82
+ if (!presigned) return new Response("Not Found", { status: 404 });
83
+ let upstream;
84
+ try {
85
+ upstream = await fetch(presigned);
86
+ } catch (err) {
87
+ console.error(`[stream-s3] upstream fetch failed for ${key}:`, err);
88
+ return new Response("Bad Gateway", { status: 502 });
89
+ }
90
+ if (upstream.status === 404 || upstream.status === 403) {
91
+ return new Response("Not Found", { status: 404 });
92
+ }
93
+ if (!upstream.ok || !upstream.body) {
94
+ console.error(
95
+ `[stream-s3] upstream returned ${upstream.status} for ${key}`
96
+ );
97
+ return new Response("Bad Gateway", { status: 502 });
98
+ }
99
+ const headers = new Headers();
100
+ headers.set("Content-Type", meta.mimeType);
101
+ const contentLength = upstream.headers.get("Content-Length");
102
+ if (contentLength) {
103
+ headers.set("Content-Length", contentLength);
104
+ } else {
105
+ headers.set("Content-Length", String(meta.size));
106
+ }
107
+ const upstreamEtag = upstream.headers.get("ETag") ?? meta.etag;
108
+ if (upstreamEtag) headers.set("ETag", upstreamEtag);
109
+ if (options.cacheControl) headers.set("Cache-Control", options.cacheControl);
110
+ return new Response(upstream.body, { status: 200, headers });
111
+ }
112
+ async function streamS3ObjectWithRunner(runWithAmplifyServerContext, key, options) {
113
+ return runWithAmplifyServerContext({
114
+ nextServerContext: { cookies },
115
+ operation: (ctx) => streamS3Object(ctx, key, options)
116
+ });
117
+ }
118
+
119
+ export {
120
+ _resetStreamS3Cache,
121
+ streamS3Object,
122
+ streamS3ObjectWithRunner
123
+ };
@@ -2,6 +2,8 @@ import { ReactNode } from 'react';
2
2
  import { Metadata } from 'next';
3
3
  import { Ampless } from '../index.js';
4
4
  import 'ampless';
5
+ import 'aws-amplify/storage/server';
6
+ import 'next/headers';
5
7
 
6
8
  interface Props$2 {
7
9
  params: Promise<Record<string, never>>;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
- import { Post, ThemeModule, ThemeManifest, Config } from 'ampless';
1
+ import { Post, MediaMetadata, Config, ThemeModule, ThemeManifest } from 'ampless';
2
2
  export { Config, Post, ThemeManifest } from 'ampless';
3
3
  import { Metadata } from 'next';
4
+ import { ReactNode } from 'react';
5
+ import { getProperties } from 'aws-amplify/storage/server';
6
+ import { cookies } from 'next/headers';
4
7
 
5
8
  interface StorageOutput {
6
9
  bucket_name: string;
@@ -68,6 +71,30 @@ interface PostsApi {
68
71
  listPostsByTag(tag: string, opts?: ListPostsByTagOptions): Promise<ListPostsResult>;
69
72
  }
70
73
 
74
+ /** Public projection returned by the `getMediaBySrc` custom query. */
75
+ interface PublicMediaShape {
76
+ src: string;
77
+ size?: number | null;
78
+ mimeType?: string | null;
79
+ metadata?: unknown;
80
+ }
81
+ /** Decoded form handed to callers. */
82
+ interface ResolvedMedia {
83
+ src: string;
84
+ size: number | null;
85
+ mimeType: string | null;
86
+ metadata: MediaMetadata | null;
87
+ }
88
+ interface MediaApi {
89
+ /**
90
+ * Resolve the Media DynamoDB row for the given S3 key. Returns
91
+ * `null` when no row matches (orphan / legacy asset) or when the
92
+ * AppSync call fails — the caller should treat both the same and
93
+ * fall back to a HEAD lookup. Failures are logged.
94
+ */
95
+ getMediaBySrc(src: string): Promise<ResolvedMedia | null>;
96
+ }
97
+
71
98
  interface EffectiveSiteSettings {
72
99
  site: {
73
100
  name: string;
@@ -96,6 +123,20 @@ interface SeoApi {
96
123
  siteMetadata(): Promise<Metadata>;
97
124
  }
98
125
 
126
+ interface PluginHeadApi {
127
+ /** React children safe to drop into `<head>`. */
128
+ renderHead(): ReactNode;
129
+ /** React children safe to drop just before `</body>`. */
130
+ renderBodyEnd(): ReactNode;
131
+ }
132
+ /**
133
+ * Create the head/body renderer for a `Config`. The constructor-time
134
+ * pass logs a single dev warning when two plugins share an
135
+ * `instanceId ?? name`; everything else happens at render time so
136
+ * descriptors reflect per-request site config.
137
+ */
138
+ declare function createPluginHead(cmsConfig: Config): PluginHeadApi;
139
+
99
140
  interface ThemesRegistry {
100
141
  /** Map of theme name → loaded theme module. */
101
142
  themes: Record<string, ThemeModule>;
@@ -214,6 +255,75 @@ declare function tiptapToMarkdown(doc: unknown): string;
214
255
  */
215
256
  declare function htmlToMarkdown(html: string): string;
216
257
 
258
+ type AmplifyServerContext = Parameters<typeof getProperties>[0];
259
+ /** Metadata sufficient to choose between stream-back and 302 fallback. */
260
+ interface ResolvedAssetMeta {
261
+ size: number;
262
+ mimeType: string;
263
+ /** S3 ETag (when known). Passed through to the response so clients can revalidate. */
264
+ etag?: string;
265
+ }
266
+ interface StreamS3Options {
267
+ /**
268
+ * Override the Cache-Control header on the streamed response.
269
+ * Leave undefined to let the route's middleware overlay
270
+ * (`computeCacheControl`) decide — that's the right answer for
271
+ * static-post asset bytes, since the strategy lives on
272
+ * `post.metadata.cache`. Media uploads pass an immutable header
273
+ * because their S3 keys carry a timestamp prefix.
274
+ */
275
+ cacheControl?: string;
276
+ /** Default 6 MiB — matches Amplify SSR Lambda's buffered response cap. */
277
+ thresholdBytes?: number;
278
+ /**
279
+ * Caller-supplied metadata (from DynamoDB). Preferred path: when
280
+ * present, no HEAD round-trip is issued.
281
+ */
282
+ meta?: ResolvedAssetMeta;
283
+ /**
284
+ * Caller-provided HEAD substitute. Invoked only when `meta` is
285
+ * omitted; result is memoised in the in-process LRU so repeat
286
+ * misses don't all hit S3.
287
+ */
288
+ headFallback?: () => Promise<ResolvedAssetMeta | null>;
289
+ /**
290
+ * Required for the 302 fallback path (files larger than the
291
+ * threshold, or stream-fetch errors). Returns a presigned URL or
292
+ * null when the object cannot be signed.
293
+ */
294
+ presignedUrlFor: (key: string) => Promise<string | null>;
295
+ }
296
+ /**
297
+ * Exported for tests. Production callers should never need to
298
+ * touch the module-scope cache directly.
299
+ */
300
+ declare function _resetStreamS3Cache(): void;
301
+ /**
302
+ * Stream the S3 object at `key` back through the Lambda response.
303
+ * Returns a `Response` whose body is the object bytes (for small
304
+ * files) or a 302 redirect to a presigned URL (for files larger
305
+ * than `thresholdBytes`). Missing objects → 404.
306
+ *
307
+ * The Amplify server context is required so the helper can mint a
308
+ * presigned URL through the same auth context the caller already
309
+ * uses (Cognito identity / public role). No extra IAM grant is
310
+ * needed on the Lambda execution role.
311
+ */
312
+ declare function streamS3Object(amplifyServerContext: AmplifyServerContext, key: string, options: StreamS3Options): Promise<Response>;
313
+ /**
314
+ * Convenience wrapper that pulls in the standard Amplify SSR cookie
315
+ * adapter so route handlers don't have to duplicate the
316
+ * `runWithAmplifyServerContext` plumbing. Pass the runner from
317
+ * `createServerRunner({ config: outputs })` and the rest is the same
318
+ * shape as `streamS3Object`.
319
+ */
320
+ declare function streamS3ObjectWithRunner(runWithAmplifyServerContext: <T>(args: {
321
+ nextServerContext: {
322
+ cookies: typeof cookies;
323
+ };
324
+ operation: (ctx: AmplifyServerContext) => Promise<T>;
325
+ }) => Promise<T>, key: string, options: StreamS3Options): Promise<Response>;
326
+
217
327
  interface CreateAmplessOpts {
218
328
  outputs: AmplessOutputs;
219
329
  cmsConfig: Config;
@@ -223,6 +333,12 @@ interface Ampless {
223
333
  listPublishedPosts(opts?: ListPostsOptions): Promise<ListPostsResult>;
224
334
  getPublishedPost(slug: string): Promise<Post | null>;
225
335
  listPostsByTag(tag: string, opts?: ListPostsByTagOptions): Promise<ListPostsResult>;
336
+ /**
337
+ * Look up the Media DynamoDB row by S3 key. Returns `null` for
338
+ * orphan / legacy assets (which the media-proxy route falls back
339
+ * to a HEAD lookup for). Failures are logged and surface as null.
340
+ */
341
+ getMediaBySrc(src: string): Promise<ResolvedMedia | null>;
226
342
  loadSiteSettings(): Promise<EffectiveSiteSettings>;
227
343
  resolveActiveTheme(): Promise<ResolvedTheme>;
228
344
  /**
@@ -235,6 +351,8 @@ interface Ampless {
235
351
  loadThemeConfig(): Promise<EffectiveThemeConfig>;
236
352
  postMetadata(post: Post): Promise<Metadata>;
237
353
  siteMetadata(): Promise<Metadata>;
354
+ publicHead(): ReactNode;
355
+ publicBodyEnd(): ReactNode;
238
356
  renderBody(post: Post): string;
239
357
  renderThemeCss(cssVars: Record<string, string>): string;
240
358
  publicAssetUrl(key: string): string;
@@ -243,11 +361,13 @@ interface Ampless {
243
361
  readonly cmsConfig: Config;
244
362
  readonly themes: ThemesRegistry;
245
363
  readonly posts: PostsApi;
364
+ readonly media: MediaApi;
246
365
  readonly settings: SiteSettingsApi;
247
366
  readonly seo: SeoApi;
248
367
  readonly themeActive: ThemeActiveApi;
249
368
  readonly themeConfig: ThemeConfigApi;
250
369
  readonly storageApi: StorageApi;
370
+ readonly pluginHead: PluginHeadApi;
251
371
  }
252
372
  /**
253
373
  * Wire up the ampless runtime from user-supplied config blobs. The
@@ -258,4 +378,4 @@ interface Ampless {
258
378
  */
259
379
  declare function createAmpless(opts: CreateAmplessOpts): Ampless;
260
380
 
261
- export { type Ampless, type AmplessOutputs, COLOR_SCHEME_SETTING_KEY, type ColorScheme, type CreateAmplessOpts, DEFAULT_COLOR_SCHEME, type DataOutput, type EffectiveSiteSettings, type EffectiveThemeConfig, type ListPostsByTagOptions, type ListPostsOptions, type ListPostsResult, type PostsApi, type PublicPostConnectionShape, type PublicPostShape, type ResolvedTheme, type SeoApi, type SiteSettingsApi, type StorageOutput, type ThemeActiveApi, type ThemeConfigApi, type ThemesRegistry, createAmpless, htmlToMarkdown, markdownToHtml, renderBody, renderThemeCss, tiptapToHtml, tiptapToMarkdown, validateColorScheme };
381
+ export { type Ampless, type AmplessOutputs, COLOR_SCHEME_SETTING_KEY, type ColorScheme, type CreateAmplessOpts, DEFAULT_COLOR_SCHEME, type DataOutput, type EffectiveSiteSettings, type EffectiveThemeConfig, type ListPostsByTagOptions, type ListPostsOptions, type ListPostsResult, type MediaApi, type PluginHeadApi, type PostsApi, type PublicMediaShape, type PublicPostConnectionShape, type PublicPostShape, type ResolvedAssetMeta, type ResolvedMedia, type ResolvedTheme, type SeoApi, type SiteSettingsApi, type StorageOutput, type StreamS3Options, type ThemeActiveApi, type ThemeConfigApi, type ThemesRegistry, _resetStreamS3Cache, createAmpless, createPluginHead, htmlToMarkdown, markdownToHtml, renderBody, renderThemeCss, streamS3Object, streamS3ObjectWithRunner, tiptapToHtml, tiptapToMarkdown, validateColorScheme };
package/dist/index.js CHANGED
@@ -1,3 +1,9 @@
1
+ import {
2
+ _resetStreamS3Cache,
3
+ streamS3Object,
4
+ streamS3ObjectWithRunner
5
+ } from "./chunk-5BLMLMZU.js";
6
+
1
7
  // src/storage.ts
2
8
  import { formatPublicAssetUrl } from "ampless";
3
9
  function createStorage(outputs) {
@@ -81,6 +87,50 @@ function createPostsApi(outputs) {
81
87
  };
82
88
  }
83
89
 
90
+ // src/media.ts
91
+ import { cookies as cookies2 } from "next/headers";
92
+ import { generateServerClientUsingCookies as generateServerClientUsingCookies2 } from "@aws-amplify/adapter-nextjs/api";
93
+ import { decodeAwsJson as decodeAwsJson2 } from "ampless";
94
+ function decodeMediaMetadata(value) {
95
+ if (value === null || value === void 0) return null;
96
+ const parsed = decodeAwsJson2(value);
97
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
98
+ return parsed;
99
+ }
100
+ return null;
101
+ }
102
+ function createMediaApi(outputs) {
103
+ const client = generateServerClientUsingCookies2({
104
+ config: outputs,
105
+ cookies: cookies2,
106
+ authMode: "apiKey"
107
+ });
108
+ return {
109
+ async getMediaBySrc(src) {
110
+ try {
111
+ const { data, errors } = await client.queries.getMediaBySrc({ src });
112
+ if (errors && errors.length > 0) {
113
+ console.error(
114
+ `[media-api] getMediaBySrc errors for ${src}`,
115
+ errors.map((e) => e.message)
116
+ );
117
+ return null;
118
+ }
119
+ if (!data) return null;
120
+ return {
121
+ src: data.src,
122
+ size: data.size ?? null,
123
+ mimeType: data.mimeType ?? null,
124
+ metadata: decodeMediaMetadata(data.metadata)
125
+ };
126
+ } catch (err) {
127
+ console.error(`[media-api] getMediaBySrc threw for ${src}`, err);
128
+ return null;
129
+ }
130
+ }
131
+ };
132
+ }
133
+
84
134
  // src/site-settings.ts
85
135
  import { unflattenSettings } from "ampless";
86
136
  function createSiteSettings(cmsConfig, storage) {
@@ -175,6 +225,280 @@ function createSeo(cmsConfig, settingsApi) {
175
225
  };
176
226
  }
177
227
 
228
+ // src/plugin-head.ts
229
+ import {
230
+ Fragment,
231
+ createElement
232
+ } from "react";
233
+ function isPlugin2(p) {
234
+ return typeof p === "object" && p !== null && "apiVersion" in p;
235
+ }
236
+ var ALLOWED_ATTRS = /* @__PURE__ */ new Set([
237
+ "crossorigin",
238
+ "referrerpolicy",
239
+ "integrity",
240
+ "fetchpriority",
241
+ // `nonce` is intentionally NOT in the allowlist for Phase 1. CSP
242
+ // nonce propagation is scoped out of Phase 1 (see spec §7); attrs
243
+ // shouldn't let plugins smuggle nonces past the design decision.
244
+ // The CSP nonce RFP will reintroduce it through `cspNonce` on
245
+ // PluginPublicRenderContext + `inlineScript.nonce: 'auto'`, not via
246
+ // the `attrs` bag.
247
+ "loading",
248
+ // iframe lazy-loading
249
+ "sandbox",
250
+ // iframe sandbox attribute
251
+ "allow",
252
+ // iframe permissions policy
253
+ "allowfullscreen"
254
+ // iframe fullscreen
255
+ ]);
256
+ function isAllowedAttr(name) {
257
+ if (name.startsWith("data-")) return true;
258
+ return ALLOWED_ATTRS.has(name.toLowerCase());
259
+ }
260
+ function isSafeUrl(value) {
261
+ const trimmed = value.trim();
262
+ if (trimmed.length === 0) return false;
263
+ const schemeMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
264
+ if (!schemeMatch) return true;
265
+ const scheme = schemeMatch[1].toLowerCase();
266
+ return scheme === "http" || scheme === "https";
267
+ }
268
+ function isDev() {
269
+ const env = typeof process !== "undefined" && process.env ? process.env.NODE_ENV : void 0;
270
+ return env !== "production";
271
+ }
272
+ function warn(message) {
273
+ if (!isDev()) return;
274
+ console.warn(`[ampless plugin-head] ${message}`);
275
+ }
276
+ function applyAttrs(target, attrs, ownerLabel) {
277
+ if (!attrs) return;
278
+ for (const [k, v] of Object.entries(attrs)) {
279
+ if (!isAllowedAttr(k)) {
280
+ warn(
281
+ `${ownerLabel}: attr "${k}" not in allowlist (data-* / crossorigin / referrerpolicy / integrity / fetchpriority / loading / sandbox / allow / allowfullscreen). skipping.`
282
+ );
283
+ continue;
284
+ }
285
+ target[k] = v;
286
+ }
287
+ }
288
+ function renderHeadDescriptor(descriptor, pluginLabel, index) {
289
+ switch (descriptor.type) {
290
+ case "script": {
291
+ if (!isSafeUrl(descriptor.src)) {
292
+ warn(
293
+ `${pluginLabel}: script descriptor #${index} dropped \u2014 unsafe src "${descriptor.src}".`
294
+ );
295
+ return null;
296
+ }
297
+ const props = {
298
+ src: descriptor.src
299
+ };
300
+ if (descriptor.id) props.id = descriptor.id;
301
+ const hasAsync = typeof descriptor.async === "boolean";
302
+ const hasDefer = typeof descriptor.defer === "boolean";
303
+ if (hasAsync) props.async = descriptor.async;
304
+ if (hasDefer) props.defer = descriptor.defer;
305
+ if (!hasAsync && !hasDefer) {
306
+ if (descriptor.strategy === "lazyOnload") {
307
+ props.defer = true;
308
+ } else {
309
+ props.async = true;
310
+ }
311
+ }
312
+ applyAttrs(props, descriptor.attrs, `${pluginLabel} script#${descriptor.id ?? index}`);
313
+ return {
314
+ id: descriptor.id ?? null,
315
+ element: createElement("script", props)
316
+ };
317
+ }
318
+ case "inlineScript": {
319
+ if (!descriptor.id) {
320
+ warn(
321
+ `${pluginLabel}: inlineScript descriptor #${index} dropped \u2014 missing required "id".`
322
+ );
323
+ return null;
324
+ }
325
+ const props = {
326
+ id: descriptor.id,
327
+ dangerouslySetInnerHTML: { __html: descriptor.body }
328
+ };
329
+ return {
330
+ id: descriptor.id,
331
+ element: createElement("script", props)
332
+ };
333
+ }
334
+ case "meta": {
335
+ const props = { content: descriptor.content };
336
+ if (descriptor.name) props.name = descriptor.name;
337
+ if (descriptor.property) props.property = descriptor.property;
338
+ return {
339
+ // meta has no id channel in the descriptor. Don't derive a
340
+ // dedup id from name/property — multiple `<meta name=...>`
341
+ // entries with the same name are legitimate (e.g. theme-color
342
+ // media variants, and two plugins emitting overlapping names
343
+ // is a real case the runtime shouldn't silently collapse).
344
+ // Position-based React keys handle the stable-key requirement.
345
+ id: null,
346
+ element: createElement("meta", props)
347
+ };
348
+ }
349
+ case "link": {
350
+ if (!isSafeUrl(descriptor.href)) {
351
+ warn(
352
+ `${pluginLabel}: link descriptor #${index} dropped \u2014 unsafe href "${descriptor.href}".`
353
+ );
354
+ return null;
355
+ }
356
+ const props = {
357
+ rel: descriptor.rel,
358
+ href: descriptor.href
359
+ };
360
+ if (descriptor.as) props.as = descriptor.as;
361
+ if (descriptor.typeAttr) props.type = descriptor.typeAttr;
362
+ return {
363
+ id: null,
364
+ element: createElement("link", props)
365
+ };
366
+ }
367
+ case "noscript": {
368
+ const props = {
369
+ dangerouslySetInnerHTML: { __html: descriptor.html }
370
+ };
371
+ if (descriptor.id) props.id = descriptor.id;
372
+ return {
373
+ id: descriptor.id ?? null,
374
+ element: createElement("noscript", props)
375
+ };
376
+ }
377
+ }
378
+ }
379
+ function renderBodyDescriptor(descriptor, pluginLabel, index) {
380
+ if (descriptor.type === "iframe") {
381
+ if (!isSafeUrl(descriptor.src)) {
382
+ warn(
383
+ `${pluginLabel}: iframe descriptor #${index} dropped \u2014 unsafe src "${descriptor.src}".`
384
+ );
385
+ return null;
386
+ }
387
+ const props = {
388
+ src: descriptor.src
389
+ };
390
+ if (descriptor.id) props.id = descriptor.id;
391
+ if (descriptor.title) props.title = descriptor.title;
392
+ if (typeof descriptor.width === "number") props.width = descriptor.width;
393
+ if (typeof descriptor.height === "number") props.height = descriptor.height;
394
+ applyAttrs(props, descriptor.attrs, `${pluginLabel} iframe#${descriptor.id ?? index}`);
395
+ return {
396
+ id: descriptor.id ?? null,
397
+ element: createElement("iframe", props)
398
+ };
399
+ }
400
+ return renderHeadDescriptor(descriptor, pluginLabel, index);
401
+ }
402
+ function dedupeAndKey(entries) {
403
+ const lastIndexById = /* @__PURE__ */ new Map();
404
+ for (let i = 0; i < entries.length; i++) {
405
+ const id = entries[i].id;
406
+ if (id === null) continue;
407
+ if (lastIndexById.has(id)) {
408
+ warn(`duplicate descriptor id "${id}" \u2014 keeping the last occurrence.`);
409
+ }
410
+ lastIndexById.set(id, i);
411
+ }
412
+ const kept = [];
413
+ for (let i = 0; i < entries.length; i++) {
414
+ const { id, element } = entries[i];
415
+ if (id !== null && lastIndexById.get(id) !== i) continue;
416
+ const key = id ?? `__pos-${i}`;
417
+ const existingProps = element.props;
418
+ kept.push(createElement(element.type, { ...existingProps, key }));
419
+ }
420
+ return kept;
421
+ }
422
+ function collectFor(plugins, ctx, surface, renderOne) {
423
+ const entries = [];
424
+ for (const plugin of plugins) {
425
+ const factory = surface(plugin);
426
+ if (!factory) continue;
427
+ let descriptors;
428
+ try {
429
+ descriptors = factory.call(plugin, ctx) ?? [];
430
+ } catch (err) {
431
+ warn(
432
+ `plugin "${plugin.instanceId ?? plugin.name}" threw inside descriptor callback: ${err instanceof Error ? err.message : String(err)}`
433
+ );
434
+ continue;
435
+ }
436
+ const label = `plugin "${plugin.instanceId ?? plugin.name}"`;
437
+ for (let i = 0; i < descriptors.length; i++) {
438
+ const entry = renderOne(descriptors[i], label, i);
439
+ if (entry) entries.push(entry);
440
+ }
441
+ }
442
+ if (entries.length === 0) return null;
443
+ const keyed = dedupeAndKey(entries);
444
+ return createElement(Fragment, null, ...keyed);
445
+ }
446
+ function createPluginHead(cmsConfig) {
447
+ const plugins = (cmsConfig.plugins ?? []).filter(isPlugin2);
448
+ const seenNamespaces = /* @__PURE__ */ new Set();
449
+ for (const plugin of plugins) {
450
+ const ns = plugin.instanceId ?? plugin.name;
451
+ const label = plugin.instanceId ? `${plugin.name}#${plugin.instanceId}` : plugin.name;
452
+ if (seenNamespaces.has(ns)) {
453
+ warn(
454
+ `duplicate plugin namespace "${ns}" detected in cms.config.plugins. Set distinct \`instanceId\` on each instance to disambiguate.`
455
+ );
456
+ }
457
+ seenNamespaces.add(ns);
458
+ const caps = plugin.capabilities;
459
+ if (caps) {
460
+ if (caps.includes("publicHead") && !plugin.publicHead) {
461
+ warn(
462
+ `${label}: declares capability "publicHead" but no \`publicHead\` implementation. Drop the capability or add the function.`
463
+ );
464
+ }
465
+ if (caps.includes("publicBody") && !plugin.publicBodyEnd) {
466
+ warn(
467
+ `${label}: declares capability "publicBody" but no \`publicBodyEnd\` implementation. Drop the capability or add the function.`
468
+ );
469
+ }
470
+ if (plugin.publicHead && !caps.includes("publicHead")) {
471
+ warn(
472
+ `${label}: implements \`publicHead\` but "publicHead" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
473
+ );
474
+ }
475
+ if (plugin.publicBodyEnd && !caps.includes("publicBody")) {
476
+ warn(
477
+ `${label}: implements \`publicBodyEnd\` but "publicBody" is not in declared capabilities. Add it so admin UI / capability gates see the surface.`
478
+ );
479
+ }
480
+ }
481
+ }
482
+ return {
483
+ renderHead() {
484
+ return collectFor(
485
+ plugins,
486
+ { site: cmsConfig.site },
487
+ (p) => p.publicHead,
488
+ renderHeadDescriptor
489
+ );
490
+ },
491
+ renderBodyEnd() {
492
+ return collectFor(
493
+ plugins,
494
+ { site: cmsConfig.site },
495
+ (p) => p.publicBodyEnd,
496
+ renderBodyDescriptor
497
+ );
498
+ }
499
+ };
500
+ }
501
+
178
502
  // src/theme-active.ts
179
503
  import { headers } from "next/headers";
180
504
  function createThemeActive(registry, storage) {
@@ -604,20 +928,25 @@ function createAmpless(opts) {
604
928
  const { outputs, cmsConfig, themes } = opts;
605
929
  const storage = createStorage(outputs);
606
930
  const posts = createPostsApi(outputs);
931
+ const media = createMediaApi(outputs);
607
932
  const settings = createSiteSettings(cmsConfig, storage);
608
933
  const seo = createSeo(cmsConfig, settings);
934
+ const pluginHead = createPluginHead(cmsConfig);
609
935
  const themeActive = createThemeActive(themes, storage);
610
936
  const themeConfig = createThemeConfig(themeActive, storage);
611
937
  return {
612
938
  listPublishedPosts: (o) => posts.listPublishedPosts(o),
613
939
  getPublishedPost: (slug) => posts.getPublishedPost(slug),
614
940
  listPostsByTag: (tag, o) => posts.listPostsByTag(tag, o),
941
+ getMediaBySrc: (src) => media.getMediaBySrc(src),
615
942
  loadSiteSettings: () => settings.loadSiteSettings(),
616
943
  resolveActiveTheme: () => themeActive.resolveActiveTheme(),
617
944
  readStoredActiveThemeFresh: () => themeActive.readStoredActiveThemeFresh(),
618
945
  loadThemeConfig: () => themeConfig.loadThemeConfig(),
619
946
  postMetadata: (post) => seo.postMetadata(post),
620
947
  siteMetadata: () => seo.siteMetadata(),
948
+ publicHead: () => pluginHead.renderHead(),
949
+ publicBodyEnd: () => pluginHead.renderBodyEnd(),
621
950
  renderBody: (post) => renderBody(post),
622
951
  renderThemeCss: (cssVars) => renderThemeCss(cssVars),
623
952
  publicAssetUrl: (key) => storage.publicAssetUrl(key),
@@ -626,21 +955,27 @@ function createAmpless(opts) {
626
955
  cmsConfig,
627
956
  themes,
628
957
  posts,
958
+ media,
629
959
  settings,
630
960
  seo,
631
961
  themeActive,
632
962
  themeConfig,
633
- storageApi: storage
963
+ storageApi: storage,
964
+ pluginHead
634
965
  };
635
966
  }
636
967
  export {
637
968
  COLOR_SCHEME_SETTING_KEY,
638
969
  DEFAULT_COLOR_SCHEME,
970
+ _resetStreamS3Cache,
639
971
  createAmpless,
972
+ createPluginHead,
640
973
  htmlToMarkdown,
641
974
  markdownToHtml,
642
975
  renderBody,
643
976
  renderThemeCss,
977
+ streamS3Object,
978
+ streamS3ObjectWithRunner,
644
979
  tiptapToHtml,
645
980
  tiptapToMarkdown,
646
981
  validateColorScheme
@@ -1,6 +1,9 @@
1
1
  import { Ampless } from '../index.js';
2
2
  import 'ampless';
3
3
  import 'next';
4
+ import 'react';
5
+ import 'aws-amplify/storage/server';
6
+ import 'next/headers';
4
7
 
5
8
  interface Ctx$4 {
6
9
  params: Promise<{
@@ -84,15 +87,13 @@ type StaticRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
84
87
  * the bundle resolve correctly — `<img src="img.png">` must resolve
85
88
  * to `/<slug>/img.png`, not the site root.
86
89
  *
87
- * Trust model: assets are served via short-lived S3 presigned URLs;
88
- * the bucket stays private.
90
+ * Trust model: the bucket stays private. Small files (<=6 MB) are
91
+ * streamed back through the Lambda response so CloudFront caches
92
+ * them; larger files fall back to a short-lived S3 presigned redirect.
89
93
  *
90
94
  * Cache-Control: deliberately omitted from the responses here.
91
95
  * Middleware computes the strategy from `metadata.cache` +
92
- * `post.updatedAt` and sets it on the rewritten response. The 302
93
- * presigned redirects also rely on S3's own headers for the actual
94
- * asset bytes — adding a stale-while-revalidate window on the 302
95
- * would risk serving an expired presign to repeat visitors.
96
+ * `post.updatedAt` and overlays it on the rewritten response.
96
97
  */
97
98
  declare function createStaticRouteHandler(ampless: Ampless): StaticRouteHandler;
98
99
 
@@ -1,3 +1,7 @@
1
+ import {
2
+ streamS3Object
3
+ } from "../chunk-5BLMLMZU.js";
4
+
1
5
  // src/routes/og.ts
2
6
  import { ImageResponse } from "next/og";
3
7
  import { loadImageForOg } from "@ampless/plugin-og-image/load-image";
@@ -104,6 +108,8 @@ function createStaticRouteHandler(ampless) {
104
108
  if (post.format !== "static") {
105
109
  return new Response("Not Found", { status: 404 });
106
110
  }
111
+ const metadata = post.metadata;
112
+ const filesMeta = metadata && typeof metadata.files === "object" && metadata.files !== null ? metadata.files : void 0;
107
113
  if (restSegments.length === 0) {
108
114
  const url = new URL(request.url);
109
115
  if (!url.pathname.endsWith("/")) {
@@ -113,15 +119,12 @@ function createStaticRouteHandler(ampless) {
113
119
  }
114
120
  const body2 = post.body ?? null;
115
121
  const entrypoint = typeof body2?.entrypoint === "string" && body2.entrypoint ? body2.entrypoint : "index.html";
116
- const presignedUrl2 = await signStaticAsset({
122
+ return serveStaticAsset({
117
123
  runWithAmplifyServerContext,
118
124
  slug,
119
- rest: entrypoint
125
+ rest: entrypoint,
126
+ filesMeta
120
127
  });
121
- if (!presignedUrl2) {
122
- return new Response("Not Found", { status: 404 });
123
- }
124
- return Response.redirect(presignedUrl2, 302);
125
128
  }
126
129
  if (restSegments.some(
127
130
  (s) => !s || s === "." || s === ".." || s.includes("/") || s.includes("\\") || s.includes("\0")
@@ -134,39 +137,49 @@ function createStaticRouteHandler(ampless) {
134
137
  if (fileList.length > 0 && !fileList.includes(rest)) {
135
138
  return new Response("Not Found", { status: 404 });
136
139
  }
137
- const presignedUrl = await signStaticAsset({
140
+ return serveStaticAsset({
138
141
  runWithAmplifyServerContext,
139
142
  slug,
140
- rest
143
+ rest,
144
+ filesMeta
141
145
  });
142
- if (!presignedUrl) {
143
- return new Response("Not Found", { status: 404 });
144
- }
145
- return Response.redirect(presignedUrl, 302);
146
146
  };
147
147
  }
148
- async function signStaticAsset({
148
+ async function serveStaticAsset({
149
149
  runWithAmplifyServerContext,
150
150
  slug,
151
- rest
151
+ rest,
152
+ filesMeta
153
+ }) {
154
+ const key = `public/static/${slug}/${rest}`;
155
+ const persisted = filesMeta?.[rest];
156
+ const meta = persisted ? { size: persisted.size, mimeType: persisted.mimeType } : void 0;
157
+ const opts = {
158
+ meta,
159
+ presignedUrlFor: (k) => signStaticAsset({ runWithAmplifyServerContext, key: k })
160
+ };
161
+ return runWithAmplifyServerContext({
162
+ nextServerContext: { cookies },
163
+ operation: (ctx) => streamS3Object(ctx, key, opts)
164
+ });
165
+ }
166
+ async function signStaticAsset({
167
+ runWithAmplifyServerContext,
168
+ key
152
169
  }) {
153
- const objectPath = `public/static/${slug}/${rest}`;
154
170
  try {
155
171
  return await runWithAmplifyServerContext({
156
172
  nextServerContext: { cookies },
157
173
  operation: async (amplifyContext) => {
158
174
  const result = await getUrl(amplifyContext, {
159
- path: objectPath,
175
+ path: key,
160
176
  options: { expiresIn: 60 * 60 }
161
177
  });
162
178
  return result.url.toString();
163
179
  }
164
180
  });
165
181
  } catch (err) {
166
- console.error(
167
- `[static-route] presign failed for ${objectPath}:`,
168
- err
169
- );
182
+ console.error(`[static-route] presign failed for ${key}:`, err);
170
183
  return null;
171
184
  }
172
185
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "1.0.0-alpha.21",
3
+ "version": "1.0.0-alpha.23",
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": "^18.0.4",
51
51
  "tailwind-merge": "^3.6.0",
52
- "ampless": "1.0.0-alpha.15",
53
- "@ampless/plugin-og-image": "0.2.0-alpha.15"
52
+ "ampless": "1.0.0-alpha.17",
53
+ "@ampless/plugin-og-image": "0.2.0-alpha.17"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "next": "^15 || ^16",