@elytracms/next 0.0.93 → 0.0.94
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/dist/index.d.ts +226 -1
- package/dist/index.js +200 -8
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { Clock } from '@elytracms/core/determinism';
|
|
|
13
13
|
import { ResolvingMetadata, Metadata, ResolvedMetadata, MetadataRoute } from 'next';
|
|
14
14
|
import { RevalidationEvent } from '@elytracms/core/revalidation';
|
|
15
15
|
export { REVALIDATE_SIGNATURE_HEADER, RevalidationEvent, RevalidationEventKind, revalidationEventSchema, signRevalidationBody, signedRevalidationRequest } from '@elytracms/core/revalidation';
|
|
16
|
+
import { IngestPayload } from '@elytracms/core/ingest';
|
|
16
17
|
import { ImageLoader } from 'next/image';
|
|
17
18
|
import { HostImageRenderer } from './host-image.js';
|
|
18
19
|
export { z } from 'zod';
|
|
@@ -378,6 +379,25 @@ interface CanvasWrapContext extends CanvasRequestInfo {
|
|
|
378
379
|
*/
|
|
379
380
|
result: ResolvePageResult;
|
|
380
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* EC-618 — what the route's `analytics` slot is told about the page it renders on: the
|
|
384
|
+
* resolved document (the content model, not the URL — slug changes and locale paths then
|
|
385
|
+
* attribute correctly), the locale it rendered in, the request path and the status. Plain
|
|
386
|
+
* serializable values, so the slot may be a client component.
|
|
387
|
+
*/
|
|
388
|
+
interface CanvasBeaconProps {
|
|
389
|
+
/** The request path as served (including any locale prefix). */
|
|
390
|
+
path: string;
|
|
391
|
+
/** 200: the route renders the slot only on a rendered page (a 404 never reaches it). */
|
|
392
|
+
status: 200;
|
|
393
|
+
/** The locale the page rendered in. */
|
|
394
|
+
locale: string;
|
|
395
|
+
/** The resolved document's collection and id — absent when the URL resolved to no document. */
|
|
396
|
+
collection?: string;
|
|
397
|
+
documentId?: string;
|
|
398
|
+
}
|
|
399
|
+
/** PURE core (EC-618): the slot's props from a render outcome's resolution and the request info. */
|
|
400
|
+
declare function canvasBeaconProps(result: ResolvePageResult, info: CanvasRequestInfo): CanvasBeaconProps;
|
|
381
401
|
type CanvasPageOutcome = {
|
|
382
402
|
kind: 'redirect';
|
|
383
403
|
target: string;
|
|
@@ -729,6 +749,21 @@ interface CanvasRouteOptions {
|
|
|
729
749
|
* the default.
|
|
730
750
|
*/
|
|
731
751
|
wrap?: (children: ReactNode, ctx: CanvasWrapContext) => ReactNode;
|
|
752
|
+
/**
|
|
753
|
+
* The ANALYTICS slot (EC-618): a component the route renders inside every `render`
|
|
754
|
+
* outcome, AFTER the page (and outside `wrap`, so a wrapper cannot drop it), with the
|
|
755
|
+
* resolved document, the locale, the path and the status ({@link CanvasBeaconProps}).
|
|
756
|
+
* Published perspective only — a draft preview is an editor at work, not a visit.
|
|
757
|
+
*
|
|
758
|
+
* The engine supplies the slot and nothing else: its value is the beacon of the
|
|
759
|
+
* analytics addon, `@elytracms/analytics/next` — **unpublished, pending counsel's
|
|
760
|
+
* verdict on its consent-free claim** (EC-618) — or a host's own component. Absent, the
|
|
761
|
+
* route renders exactly what it rendered before, and sends nothing.
|
|
762
|
+
*
|
|
763
|
+
* A not-found page never reaches this slot (`notFound()` renders the host's
|
|
764
|
+
* `not-found.tsx`), so a 404 beacon lives in that file.
|
|
765
|
+
*/
|
|
766
|
+
analytics?: ComponentType<CanvasBeaconProps>;
|
|
732
767
|
}
|
|
733
768
|
interface CanvasRouteProps {
|
|
734
769
|
params: Promise<{
|
|
@@ -1058,6 +1093,177 @@ interface RevalidateRouteHandlers {
|
|
|
1058
1093
|
*/
|
|
1059
1094
|
declare function createRevalidateRoute(options: RevalidateRouteOptions): RevalidateRouteHandlers;
|
|
1060
1095
|
|
|
1096
|
+
/**
|
|
1097
|
+
* EC-617 — the pure decision core of the PUBLIC INGEST route (`createIngestRoute`, the
|
|
1098
|
+
* Next wrapper in `ingest.ts`): no Next imports, no network, an injected clock — the
|
|
1099
|
+
* house "pure core + thin wrapper" shape of `evaluateRevalidateRequest` /
|
|
1100
|
+
* `createRevalidateRoute`.
|
|
1101
|
+
*
|
|
1102
|
+
* The route is a site's one public write path (ruling EC-565 § 6c). It runs, in order:
|
|
1103
|
+
*
|
|
1104
|
+
* 1. **method** — `POST` only (405);
|
|
1105
|
+
* 2. **configured** — the server-held `INGEST_SECRET` and the receiver URL are set (503
|
|
1106
|
+
* otherwise: the route stays closed until deliberately configured, never a no-op);
|
|
1107
|
+
* 3. **same-origin** — a browser request from another origin (`Origin` / `Sec-Fetch-Site`)
|
|
1108
|
+
* is refused (403). The route emits no CORS headers and reads no cookie;
|
|
1109
|
+
* 4. **rate limit** — a fixed window per CLIENT KEY (429, `Retry-After`);
|
|
1110
|
+
* 5. **size** — the raw body capped at `maxBodyBytes` (413), checked before parsing;
|
|
1111
|
+
* 6. **shape** — JSON in the ingest envelope (400, with the issues);
|
|
1112
|
+
* 7. **bot gate** — the host's own check, an injected seam (403, `{ gate: 'bot' }`): the
|
|
1113
|
+
* engine supplies the slot, never the vendor;
|
|
1114
|
+
* 8. **enrichment** (EC-618) — an optional seam an ADDON fills (the analytics addon derives
|
|
1115
|
+
* a country and a visitor's sketch coordinates here, where the request is): it returns the
|
|
1116
|
+
* payload to forward, which is re-validated against the envelope and the receiver's cap
|
|
1117
|
+
* (500 — the site's own fault — when it throws or returns something invalid). Absent, the
|
|
1118
|
+
* route is exactly what it was;
|
|
1119
|
+
*
|
|
1120
|
+
* then the wrapper signs the payload with the server-held secret and forwards it to the
|
|
1121
|
+
* one Convex receiver, relaying its answer. Only the PAYLOAD is forwarded — never a request
|
|
1122
|
+
* header — so whatever an enrichment reads from the request (the client address, the user
|
|
1123
|
+
* agent, a geo header) stays in this request's memory; only what it derives travels.
|
|
1124
|
+
*/
|
|
1125
|
+
/** The answer the core gives the wrapper. */
|
|
1126
|
+
type IngestEvaluation = {
|
|
1127
|
+
ok: true;
|
|
1128
|
+
payload: IngestPayload;
|
|
1129
|
+
} | {
|
|
1130
|
+
ok: false;
|
|
1131
|
+
status: 400 | 403 | 405 | 413 | 429 | 500 | 503;
|
|
1132
|
+
body: Record<string, unknown>;
|
|
1133
|
+
headers?: Record<string, string>;
|
|
1134
|
+
};
|
|
1135
|
+
/** What the host's bot gate answers (a Turnstile / hCaptcha / honeypot check the HOST implements). */
|
|
1136
|
+
type BotGateVerdict = {
|
|
1137
|
+
allow: true;
|
|
1138
|
+
} | {
|
|
1139
|
+
allow: false;
|
|
1140
|
+
reason: string;
|
|
1141
|
+
};
|
|
1142
|
+
/**
|
|
1143
|
+
* The bot-gate seam: called with the request and the parsed payload, AFTER the cheap
|
|
1144
|
+
* checks (so a flood never reaches a paid verification API). Absent → no gate.
|
|
1145
|
+
*/
|
|
1146
|
+
type IngestBotGate = (input: {
|
|
1147
|
+
request: Request;
|
|
1148
|
+
payload: IngestPayload;
|
|
1149
|
+
}) => BotGateVerdict | Promise<BotGateVerdict>;
|
|
1150
|
+
/**
|
|
1151
|
+
* EC-618 — the enrichment seam: called with the request and the parsed payload LAST (after
|
|
1152
|
+
* the bot gate), it returns the payload the route signs and forwards. The engine supplies
|
|
1153
|
+
* the slot, never the code: an addon fills it (`@elytracms/analytics/next`'s
|
|
1154
|
+
* `analyticsEnrichment`), and a host may compose its own. Whatever it reads from the
|
|
1155
|
+
* request is held for this request only — the route forwards the returned payload and
|
|
1156
|
+
* nothing else. Absent → no enrichment, the payload forwarded as parsed.
|
|
1157
|
+
*/
|
|
1158
|
+
type IngestEnrich = (input: {
|
|
1159
|
+
request: Request;
|
|
1160
|
+
payload: IngestPayload;
|
|
1161
|
+
}) => IngestPayload | Promise<IngestPayload>;
|
|
1162
|
+
/**
|
|
1163
|
+
* The fixed-window limiter — the AI route's pattern (EC-602), keyed per client. Holds,
|
|
1164
|
+
* in memory only, one `{ windowStart, count }` per key seen in the current window; expired
|
|
1165
|
+
* windows are dropped as the clock moves, so the map is bounded by the clients of one
|
|
1166
|
+
* window. Each server instance holds its own windows (a serverless platform runs several),
|
|
1167
|
+
* so the limit is per instance — the same documented limitation as the AI route.
|
|
1168
|
+
*/
|
|
1169
|
+
interface FixedWindowLimiter {
|
|
1170
|
+
/** Count one request for `key`; `retryAfterSeconds` when it is over the limit. */
|
|
1171
|
+
hit(key: string): {
|
|
1172
|
+
allowed: true;
|
|
1173
|
+
} | {
|
|
1174
|
+
allowed: false;
|
|
1175
|
+
retryAfterSeconds: number;
|
|
1176
|
+
};
|
|
1177
|
+
/** How many keys the limiter currently holds (for the bound test). */
|
|
1178
|
+
size(): number;
|
|
1179
|
+
}
|
|
1180
|
+
declare function createFixedWindowLimiter(options: {
|
|
1181
|
+
limit: number;
|
|
1182
|
+
windowMs?: number;
|
|
1183
|
+
now?: () => number;
|
|
1184
|
+
}): FixedWindowLimiter;
|
|
1185
|
+
/**
|
|
1186
|
+
* The DEFAULT client key: the first hop of `x-forwarded-for` (what Vercel and most proxies
|
|
1187
|
+
* set), else `x-real-ip`, else one shared `unknown` bucket. An IP address is personal
|
|
1188
|
+
* data: it is held ONLY as a key of the in-memory window above, for at most one window,
|
|
1189
|
+
* and is never stored, logged or forwarded to the receiver. A host that wants a different
|
|
1190
|
+
* key (a hashed one, a session id) passes `clientKey`.
|
|
1191
|
+
*/
|
|
1192
|
+
declare function defaultIngestClientKey(request: Request): string;
|
|
1193
|
+
/** Is this a cross-origin BROWSER request? (A request with no browser headers is not judged here.) */
|
|
1194
|
+
declare function isCrossOrigin(request: Request): boolean;
|
|
1195
|
+
interface IngestEvaluationInput {
|
|
1196
|
+
request: Request;
|
|
1197
|
+
/** The raw body, read by the wrapper up to `maxBodyBytes + 1` bytes (`undefined` when over). */
|
|
1198
|
+
rawBody: string | undefined;
|
|
1199
|
+
configured: boolean;
|
|
1200
|
+
limiter: FixedWindowLimiter;
|
|
1201
|
+
clientKey: (request: Request) => string;
|
|
1202
|
+
botGate?: IngestBotGate;
|
|
1203
|
+
enrich?: IngestEnrich;
|
|
1204
|
+
}
|
|
1205
|
+
/** Evaluate one request (see the module header for the order). */
|
|
1206
|
+
declare function evaluateIngestRequest(input: IngestEvaluationInput): Promise<IngestEvaluation>;
|
|
1207
|
+
/**
|
|
1208
|
+
* Read a request body as text, at most `maxBytes` (counted in UTF-8 bytes). `undefined`
|
|
1209
|
+
* when it is longer — the over-size body is never buffered whole. A declared
|
|
1210
|
+
* `Content-Length` over the cap is refused before a byte is read.
|
|
1211
|
+
*/
|
|
1212
|
+
declare function readCappedBody(request: Request, maxBytes?: number): Promise<string | undefined>;
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* EC-617 — the PUBLIC INGEST route: a site's one write path into its project (form
|
|
1216
|
+
* submissions, analytics events, upload URLs), mounted SAME-ORIGIN in the site's own
|
|
1217
|
+
* App Router:
|
|
1218
|
+
*
|
|
1219
|
+
* ```ts
|
|
1220
|
+
* // frontend/app/api/ingest/route.ts — through the delivery kit
|
|
1221
|
+
* export const { POST } = elytra.ingestRoute()
|
|
1222
|
+
* ```
|
|
1223
|
+
*
|
|
1224
|
+
* The decisions are the pure core's (`evaluateIngestRequest`: method, configured,
|
|
1225
|
+
* same-origin, rate limit, size, shape, the host's bot gate); this wrapper then SIGNS the
|
|
1226
|
+
* payload with the server-held `INGEST_SECRET` (it never reaches a browser) and forwards it
|
|
1227
|
+
* to the deployment's ONE receiver (`<deployment>.convex.site/elytra/ingest`), relaying the
|
|
1228
|
+
* receiver's answer. A receiver that rejects the signature is a SITE misconfiguration
|
|
1229
|
+
* (the two secrets differ), answered 502 with that sentence — never a 401 a visitor could
|
|
1230
|
+
* read as their fault.
|
|
1231
|
+
*
|
|
1232
|
+
* Uploads leave same-origin by design: an `upload` payload asks the receiver for ONE
|
|
1233
|
+
* short-lived storage URL (the request rides this route's gate, limiter and bot gate), and
|
|
1234
|
+
* the browser then sends the bytes straight to that URL; the submission that follows claims
|
|
1235
|
+
* the stored file, which is checked against what the upload request declared.
|
|
1236
|
+
*/
|
|
1237
|
+
interface IngestRouteOptions {
|
|
1238
|
+
/** The server-held HMAC secret (= the deployment's `ELYTRA_INGEST_SECRET`). Unset → 503. */
|
|
1239
|
+
secret: string | undefined;
|
|
1240
|
+
/** The deployment's HTTP origin (`https://<name>.convex.site`); the receiver path is appended. Unset → 503. */
|
|
1241
|
+
siteUrl: string | undefined;
|
|
1242
|
+
/** Cap on the raw JSON body, ≤ the receiver's `INGEST_MAX_BODY_BYTES` (default). */
|
|
1243
|
+
maxBodyBytes?: number;
|
|
1244
|
+
/** Requests per client key per minute (default 30). */
|
|
1245
|
+
maxRequestsPerMinute?: number;
|
|
1246
|
+
/** The rate limiter's key for a request (default: the client IP, in memory only — see `defaultIngestClientKey`). */
|
|
1247
|
+
clientKey?: (request: Request) => string;
|
|
1248
|
+
/** The host's bot check (Turnstile, hCaptcha, a honeypot …). The engine supplies the slot, never the vendor. */
|
|
1249
|
+
botGate?: IngestBotGate;
|
|
1250
|
+
/**
|
|
1251
|
+
* EC-618 — an addon's enrichment, run last: it returns the payload to sign and forward
|
|
1252
|
+
* (the analytics addon adds a country and the visitor's sketch coordinates, derived from
|
|
1253
|
+
* the request here and never forwarded raw). Absent → the payload is forwarded as parsed.
|
|
1254
|
+
*/
|
|
1255
|
+
enrich?: IngestEnrich;
|
|
1256
|
+
/** Injected clock (tests). */
|
|
1257
|
+
now?: () => number;
|
|
1258
|
+
/** Injected transport (tests). */
|
|
1259
|
+
fetchImpl?: typeof fetch;
|
|
1260
|
+
}
|
|
1261
|
+
interface IngestRouteHandlers {
|
|
1262
|
+
POST(request: Request): Promise<Response>;
|
|
1263
|
+
}
|
|
1264
|
+
/** Create the ingest route handler (see the module header). */
|
|
1265
|
+
declare function createIngestRoute(options: IngestRouteOptions): IngestRouteHandlers;
|
|
1266
|
+
|
|
1061
1267
|
/**
|
|
1062
1268
|
* Image delivery for the embedded runtime (EC-152).
|
|
1063
1269
|
*
|
|
@@ -1253,6 +1459,8 @@ declare function elytraHostImage(options?: ElytraHostImageOptions): HostImageRen
|
|
|
1253
1459
|
* | | equal the Convex deployment's own env var) |
|
|
1254
1460
|
* | `REVALIDATE_SECRET` | Instant-publish webhook HMAC secret (must equal the |
|
|
1255
1461
|
* | | deployment's `CANVAS_REVALIDATE_SECRET`) |
|
|
1462
|
+
* | `INGEST_SECRET` | Public-ingest HMAC secret (EC-617; must equal the |
|
|
1463
|
+
* | | deployment's `ELYTRA_INGEST_SECRET`) |
|
|
1256
1464
|
*
|
|
1257
1465
|
* EC-579 retired `STUDIO_ORIGIN` (the CORS origin of the studio's deleted browser
|
|
1258
1466
|
* dispatcher): the one emitter is the Convex deployment, server to server.
|
|
@@ -1263,12 +1471,14 @@ declare const ELYTRA_HOST_ENV_KEYS: {
|
|
|
1263
1471
|
readonly projectId: "ELYTRA_PROJECT_ID";
|
|
1264
1472
|
readonly draftToken: "DRAFT_PREVIEW_TOKEN";
|
|
1265
1473
|
readonly revalidateSecret: "REVALIDATE_SECRET";
|
|
1474
|
+
readonly ingestSecret: "INGEST_SECRET";
|
|
1266
1475
|
};
|
|
1267
1476
|
interface ElytraHostEnv {
|
|
1268
1477
|
convexUrl?: string;
|
|
1269
1478
|
projectId?: string;
|
|
1270
1479
|
draftToken?: string;
|
|
1271
1480
|
revalidateSecret?: string;
|
|
1481
|
+
ingestSecret?: string;
|
|
1272
1482
|
}
|
|
1273
1483
|
/**
|
|
1274
1484
|
* Resolve the kit's env bag from a raw env record (`process.env` by default at
|
|
@@ -1585,6 +1795,12 @@ interface HostCanvasRouteOptions {
|
|
|
1585
1795
|
* invalidation — a disagreement would drop nothing, silently.
|
|
1586
1796
|
*/
|
|
1587
1797
|
published?: CanvasRouteOptions['published'];
|
|
1798
|
+
/**
|
|
1799
|
+
* EC-618 — the analytics slot (see `CanvasRouteOptions.analytics`): its value is an
|
|
1800
|
+
* addon's beacon (`@elytracms/analytics/next`, unpublished pending counsel). Absent →
|
|
1801
|
+
* nothing renders and nothing is sent.
|
|
1802
|
+
*/
|
|
1803
|
+
analytics?: CanvasRouteOptions['analytics'];
|
|
1588
1804
|
}
|
|
1589
1805
|
interface LoadGlobalsOptions {
|
|
1590
1806
|
/** Perspective of the chrome read. Default `'published'` (live is published-only). */
|
|
@@ -1705,6 +1921,15 @@ interface ElytraHost<Collections extends readonly CollectionDef[] = readonly Col
|
|
|
1705
1921
|
previewRoute(): PreviewRouteHandlers;
|
|
1706
1922
|
/** The draft-mode DISABLE route (no token — it only reduces visibility). */
|
|
1707
1923
|
previewDisableRoute(): PreviewRouteHandlers;
|
|
1924
|
+
/**
|
|
1925
|
+
* EC-617 — the PUBLIC INGEST route for `app/api/ingest/route.ts`: the site's one write path
|
|
1926
|
+
* (form submissions, analytics events, upload URLs), signed with `INGEST_SECRET` and
|
|
1927
|
+
* forwarded to this deployment's receiver (its HTTP origin derived from
|
|
1928
|
+
* `ELYTRA_CONVEX_URL`; pass `siteUrl` for a self-hosted backend). Unset secret or no
|
|
1929
|
+
* backend = 503, closed. The rate limit, the client key and the bot gate are the host's to
|
|
1930
|
+
* tune — the engine supplies the slots, never a vendor.
|
|
1931
|
+
*/
|
|
1932
|
+
ingestRoute(options?: Partial<Omit<IngestRouteOptions, 'secret'>>): IngestRouteHandlers;
|
|
1708
1933
|
/**
|
|
1709
1934
|
* Does the page for this catch-all `slug` OPEN full-bleed? Manifest-driven
|
|
1710
1935
|
* (`presentation.fullBleed`, EC-368) and draft-mode aware — the host lays out
|
|
@@ -1870,4 +2095,4 @@ declare function evaluatePreviewRequest(requestUrl: string | URL, expectedToken:
|
|
|
1870
2095
|
*/
|
|
1871
2096
|
declare const PACKAGE = "@elytracms/next";
|
|
1872
2097
|
|
|
1873
|
-
export { type AssetImageLoaderOptions, type BakedListing, CANVAS_VALIDATION_SLUG, type CanvasCacheKey, type CanvasContentRequest, type CanvasPageOutcome, CanvasRenderer, type CanvasRendererProps, type CanvasRequestInfo, type CanvasRoute, type CanvasRouteOptions, type CanvasRouteProps, type CanvasSitemapOptions, type CanvasSitemapResult, type CanvasWrapContext, type ContentSnapshot, type ContentSnapshotOptions, type ContentSource, type CreateCanvasSitemapOptions, type DeliveryPresentation, ELYTRA_HOST_ENV_KEYS, type ElytraContentMode, type ElytraHost, type ElytraHostEnv, type ElytraHostImageOptions, type ElytraHostOptions, ElytraImage, type ElytraImageAsset, type ElytraImageProps, type GlobalsConventions, type HostCanvasRouteOptions, type HostComponent, type HostComponents, type LiveContentOptions, type LoadGlobalsOptions, type NextImageOptions, PACKAGE, type PreviewEvaluation, type PreviewRouteHandlers, type PreviewRouteOptions, type ResolveCanvasPageOptions, type ResolveGlobalsOptions, type ResolvedLink, type ResolvedNavEntry, type ResolvedNavLink, type RevalidateEvaluation, type RevalidateRouteHandlers, type RevalidateRouteOptions, type RevalidationProject, type SeoMetadataContext, type SiteGlobals, type SitemapSkippedRoute, type StaticContentData, assembleLiveSource, bodyItemsOf, canvasSitemapEntries, collectionCacheTag, createAssetImageLoader, createCanvasRoute, createCanvasSitemap, createContentSnapshot, createElytraHost, createPreviewDisableRoute, createPreviewRoute, createRevalidateRoute, createStaticContentSource, defineHostComponents, docCacheTag, elytraHostImage, evaluatePreviewRequest, evaluateRevalidateRequest, firstBlockFullBleed, loadLiveContent, mergeCacheTags, mergeMetadataOverParent, nextCacheTags, openGraphLocale, pathFromSlug, projectSweepTag, requestPerspective, resolveCanvasPage, resolveGlobals, resolveHostEnv, revalidationTags, routeCacheTag, safeRedirectTarget, scopeCacheTag, seoContextOf, seoMetadata, seoObjectOf, withNextImage };
|
|
2098
|
+
export { type AssetImageLoaderOptions, type BakedListing, type BotGateVerdict, CANVAS_VALIDATION_SLUG, type CanvasBeaconProps, type CanvasCacheKey, type CanvasContentRequest, type CanvasPageOutcome, CanvasRenderer, type CanvasRendererProps, type CanvasRequestInfo, type CanvasRoute, type CanvasRouteOptions, type CanvasRouteProps, type CanvasSitemapOptions, type CanvasSitemapResult, type CanvasWrapContext, type ContentSnapshot, type ContentSnapshotOptions, type ContentSource, type CreateCanvasSitemapOptions, type DeliveryPresentation, ELYTRA_HOST_ENV_KEYS, type ElytraContentMode, type ElytraHost, type ElytraHostEnv, type ElytraHostImageOptions, type ElytraHostOptions, ElytraImage, type ElytraImageAsset, type ElytraImageProps, type FixedWindowLimiter, type GlobalsConventions, type HostCanvasRouteOptions, type HostComponent, type HostComponents, type IngestBotGate, type IngestEnrich, type IngestEvaluation, type IngestRouteHandlers, type IngestRouteOptions, type LiveContentOptions, type LoadGlobalsOptions, type NextImageOptions, PACKAGE, type PreviewEvaluation, type PreviewRouteHandlers, type PreviewRouteOptions, type ResolveCanvasPageOptions, type ResolveGlobalsOptions, type ResolvedLink, type ResolvedNavEntry, type ResolvedNavLink, type RevalidateEvaluation, type RevalidateRouteHandlers, type RevalidateRouteOptions, type RevalidationProject, type SeoMetadataContext, type SiteGlobals, type SitemapSkippedRoute, type StaticContentData, assembleLiveSource, bodyItemsOf, canvasBeaconProps, canvasSitemapEntries, collectionCacheTag, createAssetImageLoader, createCanvasRoute, createCanvasSitemap, createContentSnapshot, createElytraHost, createFixedWindowLimiter, createIngestRoute, createPreviewDisableRoute, createPreviewRoute, createRevalidateRoute, createStaticContentSource, defaultIngestClientKey, defineHostComponents, docCacheTag, elytraHostImage, evaluateIngestRequest, evaluatePreviewRequest, evaluateRevalidateRequest, firstBlockFullBleed, isCrossOrigin, loadLiveContent, mergeCacheTags, mergeMetadataOverParent, nextCacheTags, openGraphLocale, pathFromSlug, projectSweepTag, readCappedBody, requestPerspective, resolveCanvasPage, resolveGlobals, resolveHostEnv, revalidationTags, routeCacheTag, safeRedirectTarget, scopeCacheTag, seoContextOf, seoMetadata, seoObjectOf, withNextImage };
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { registerComponents, createRenderContext, renderDocumentBodyItems, documentBodyItems, serializeListingQuery, documentBodyRichTextValues, documentBodyCompositionNodes, richTextEmbedsInValue, collectReferencedAssetIds, collectRichTextRefsInValue, collectReferencedRelations, collectReferencedListings, collectReferencedRichTextLinks, listingRequestOf } from '@elytracms/core/runtime-renderer';
|
|
2
2
|
import { isBlockSequenceValueShape } from '@elytracms/core/rich-text';
|
|
3
|
-
import { jsx, Fragment } from 'react/jsx-runtime';
|
|
3
|
+
import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
|
|
4
4
|
import { cacheTags, isDocumentHistory, resolvedAsset, parseDeliveredDocuments, createContentClient, createContentContext } from '@elytracms/core/content';
|
|
5
5
|
import { DEFAULT_SEO_FIELD, parseLocaleConfig, documentKey, liveRedirectRecords, SEO_SUBFIELDS, splitLocalePath, DRAFT_PERSPECTIVE, splitPath, localeUrlPath, variantOf, DRAFT_VARIANT, createRouter, presentationBodyFields, redirectRecordSchema } from '@elytracms/core/cms-core';
|
|
6
6
|
export { DRAFT_PERSPECTIVE, documentSchema, localeConfigSchema, localeUrlPath, parseLocaleConfig, redirectRecordSchema, resolveLocale, routeRecordSchema, splitLocalePath, textDirection, variantOf } from '@elytracms/core/cms-core';
|
|
7
|
+
import { redirectRecordKind } from '@elytracms/core/persistence';
|
|
7
8
|
import { revalidateTag, cacheTag } from 'next/cache';
|
|
8
9
|
import { draftMode } from 'next/headers';
|
|
9
10
|
import { redirect, notFound, permanentRedirect } from 'next/navigation';
|
|
10
11
|
import { COMPOSITE_KEY_SEPARATOR } from '@elytracms/core/project-graph';
|
|
11
12
|
import { verifyRevalidationSignature, readRevalidationBody, REVALIDATE_SIGNATURE_HEADER } from '@elytracms/core/revalidation';
|
|
12
13
|
export { REVALIDATE_SIGNATURE_HEADER, revalidationEventSchema, signRevalidationBody, signedRevalidationRequest } from '@elytracms/core/revalidation';
|
|
14
|
+
import { INGEST_MAX_BODY_BYTES, readIngestPayload, INGEST_RECEIVER_PATH, signedIngestRequest, convexSiteUrl } from '@elytracms/core/ingest';
|
|
13
15
|
import NextImage from 'next/image';
|
|
14
16
|
import { normalizeImageSource } from '@elytracms/components';
|
|
15
17
|
import { projectFrontendUrl, resolveProject } from '@elytracms/core/studio-core';
|
|
@@ -173,7 +175,7 @@ async function createContentSnapshot(adapter, projectId, options) {
|
|
|
173
175
|
const documentRecords = await adapter.cms.listDocuments(projectId);
|
|
174
176
|
const versionRecords = await adapter.cms.listAllDocumentVersions(projectId);
|
|
175
177
|
const assetRecords = await adapter.assets.listAssets(projectId);
|
|
176
|
-
const redirectRecords = await adapter.
|
|
178
|
+
const redirectRecords = await adapter.records(redirectRecordKind).list(projectId);
|
|
177
179
|
const redirects = liveRedirectRecords(redirectRecords);
|
|
178
180
|
const versionsByKey = /* @__PURE__ */ new Map();
|
|
179
181
|
for (const record2 of versionRecords) {
|
|
@@ -283,6 +285,15 @@ function canvasWrapContext(result, info) {
|
|
|
283
285
|
result
|
|
284
286
|
};
|
|
285
287
|
}
|
|
288
|
+
function canvasBeaconProps(result, info) {
|
|
289
|
+
const document = result.document;
|
|
290
|
+
return {
|
|
291
|
+
path: info.url,
|
|
292
|
+
status: 200,
|
|
293
|
+
locale: result.tags.locale,
|
|
294
|
+
...document ? { collection: document.collection, documentId: document.id } : {}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
286
297
|
function pathFromSlug(slug) {
|
|
287
298
|
return "/" + (slug ?? []).join("/");
|
|
288
299
|
}
|
|
@@ -590,8 +601,13 @@ function createCanvasRoute(options) {
|
|
|
590
601
|
presentation: outcome.presentation
|
|
591
602
|
}
|
|
592
603
|
);
|
|
593
|
-
|
|
594
|
-
|
|
604
|
+
const page = options.wrap ? options.wrap(rendered, canvasWrapContext(outcome.result, { url, perspective })) : rendered;
|
|
605
|
+
const Beacon = options.analytics;
|
|
606
|
+
if (!Beacon || perspective !== "published") return page;
|
|
607
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
608
|
+
page,
|
|
609
|
+
/* @__PURE__ */ jsx(Beacon, { ...canvasBeaconProps(outcome.result, { url}) })
|
|
610
|
+
] });
|
|
595
611
|
}
|
|
596
612
|
}
|
|
597
613
|
};
|
|
@@ -758,6 +774,177 @@ function createRevalidateRoute(options) {
|
|
|
758
774
|
}
|
|
759
775
|
};
|
|
760
776
|
}
|
|
777
|
+
function createFixedWindowLimiter(options) {
|
|
778
|
+
const windowMs = options.windowMs ?? 6e4;
|
|
779
|
+
const now = options.now ?? Date.now;
|
|
780
|
+
const windows = /* @__PURE__ */ new Map();
|
|
781
|
+
return {
|
|
782
|
+
hit(key) {
|
|
783
|
+
const at = now();
|
|
784
|
+
for (const [held, window2] of windows) if (at - window2.windowStart >= windowMs) windows.delete(held);
|
|
785
|
+
const window = windows.get(key);
|
|
786
|
+
if (!window) {
|
|
787
|
+
windows.set(key, { windowStart: at, count: 1 });
|
|
788
|
+
return { allowed: true };
|
|
789
|
+
}
|
|
790
|
+
if (window.count >= options.limit) {
|
|
791
|
+
return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((window.windowStart + windowMs - at) / 1e3)) };
|
|
792
|
+
}
|
|
793
|
+
window.count += 1;
|
|
794
|
+
return { allowed: true };
|
|
795
|
+
},
|
|
796
|
+
size: () => windows.size
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
function defaultIngestClientKey(request) {
|
|
800
|
+
const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
801
|
+
if (forwarded) return forwarded;
|
|
802
|
+
const real = request.headers.get("x-real-ip")?.trim();
|
|
803
|
+
return real || "unknown";
|
|
804
|
+
}
|
|
805
|
+
function isCrossOrigin(request) {
|
|
806
|
+
const site = request.headers.get("sec-fetch-site");
|
|
807
|
+
if (site && site !== "same-origin" && site !== "none") return true;
|
|
808
|
+
const origin = request.headers.get("origin");
|
|
809
|
+
if (!origin) return false;
|
|
810
|
+
try {
|
|
811
|
+
return new URL(origin).origin !== new URL(request.url).origin;
|
|
812
|
+
} catch {
|
|
813
|
+
return true;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
async function evaluateIngestRequest(input) {
|
|
817
|
+
const { request } = input;
|
|
818
|
+
if (request.method !== "POST") {
|
|
819
|
+
return { ok: false, status: 405, body: { error: "Only POST is accepted." }, headers: { allow: "POST" } };
|
|
820
|
+
}
|
|
821
|
+
if (!input.configured) {
|
|
822
|
+
return { ok: false, status: 503, body: { error: "Ingest is not configured on this site." } };
|
|
823
|
+
}
|
|
824
|
+
if (isCrossOrigin(request)) {
|
|
825
|
+
return { ok: false, status: 403, body: { error: "Cross-origin requests are not accepted." } };
|
|
826
|
+
}
|
|
827
|
+
const limited = input.limiter.hit(input.clientKey(request));
|
|
828
|
+
if (!limited.allowed) {
|
|
829
|
+
return {
|
|
830
|
+
ok: false,
|
|
831
|
+
status: 429,
|
|
832
|
+
body: { error: "Too many requests \u2014 try again shortly." },
|
|
833
|
+
headers: { "retry-after": String(limited.retryAfterSeconds) }
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
if (input.rawBody === void 0) {
|
|
837
|
+
return { ok: false, status: 413, body: { error: "The payload is too large." } };
|
|
838
|
+
}
|
|
839
|
+
const read = readIngestPayload(input.rawBody);
|
|
840
|
+
if (!read.ok) {
|
|
841
|
+
return { ok: false, status: 400, body: { error: read.message, ...read.issues ? { issues: read.issues } : {} } };
|
|
842
|
+
}
|
|
843
|
+
if (input.botGate) {
|
|
844
|
+
const verdict = await input.botGate({ request, payload: read.value });
|
|
845
|
+
if (!verdict.allow) {
|
|
846
|
+
return { ok: false, status: 403, body: { error: "Refused by the bot gate.", gate: "bot", reason: verdict.reason } };
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (!input.enrich) return { ok: true, payload: read.value };
|
|
850
|
+
return enrichPayload(input.enrich, request, read.value);
|
|
851
|
+
}
|
|
852
|
+
async function enrichPayload(enrich, request, payload) {
|
|
853
|
+
let enriched;
|
|
854
|
+
try {
|
|
855
|
+
enriched = await enrich({ request, payload });
|
|
856
|
+
} catch (error) {
|
|
857
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
858
|
+
return { ok: false, status: 500, body: { error: `The site's ingest enrichment failed: ${message}` } };
|
|
859
|
+
}
|
|
860
|
+
const body = JSON.stringify(enriched ?? null);
|
|
861
|
+
const reread = readIngestPayload(body);
|
|
862
|
+
if (!reread.ok) {
|
|
863
|
+
return {
|
|
864
|
+
ok: false,
|
|
865
|
+
status: 500,
|
|
866
|
+
body: { error: "The site's ingest enrichment returned an invalid payload.", ...reread.issues ? { issues: reread.issues } : {} }
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
if (new TextEncoder().encode(body).byteLength > INGEST_MAX_BODY_BYTES) {
|
|
870
|
+
return { ok: false, status: 500, body: { error: "The site's ingest enrichment grew the payload past the receiver's cap." } };
|
|
871
|
+
}
|
|
872
|
+
return { ok: true, payload: reread.value };
|
|
873
|
+
}
|
|
874
|
+
async function readCappedBody(request, maxBytes = INGEST_MAX_BODY_BYTES) {
|
|
875
|
+
const declared = Number(request.headers.get("content-length"));
|
|
876
|
+
if (Number.isFinite(declared) && declared > maxBytes) return void 0;
|
|
877
|
+
if (!request.body) return "";
|
|
878
|
+
const reader = request.body.getReader();
|
|
879
|
+
const chunks = [];
|
|
880
|
+
let total = 0;
|
|
881
|
+
for (; ; ) {
|
|
882
|
+
const { done, value } = await reader.read();
|
|
883
|
+
if (done) break;
|
|
884
|
+
total += value.byteLength;
|
|
885
|
+
if (total > maxBytes) {
|
|
886
|
+
await reader.cancel().catch(() => void 0);
|
|
887
|
+
return void 0;
|
|
888
|
+
}
|
|
889
|
+
chunks.push(value);
|
|
890
|
+
}
|
|
891
|
+
const bytes = new Uint8Array(total);
|
|
892
|
+
let offset = 0;
|
|
893
|
+
for (const chunk of chunks) {
|
|
894
|
+
bytes.set(chunk, offset);
|
|
895
|
+
offset += chunk.byteLength;
|
|
896
|
+
}
|
|
897
|
+
return new TextDecoder().decode(bytes);
|
|
898
|
+
}
|
|
899
|
+
var NO_STORE = { "cache-control": "no-store" };
|
|
900
|
+
function createIngestRoute(options) {
|
|
901
|
+
const maxBodyBytes = options.maxBodyBytes ?? INGEST_MAX_BODY_BYTES;
|
|
902
|
+
if (maxBodyBytes > INGEST_MAX_BODY_BYTES || maxBodyBytes <= 0) {
|
|
903
|
+
throw new Error(`createIngestRoute: maxBodyBytes must be between 1 and ${INGEST_MAX_BODY_BYTES} (the receiver's cap).`);
|
|
904
|
+
}
|
|
905
|
+
const limiter = createFixedWindowLimiter({
|
|
906
|
+
limit: options.maxRequestsPerMinute ?? 30,
|
|
907
|
+
...options.now ? { now: options.now } : {}
|
|
908
|
+
});
|
|
909
|
+
const clientKey = options.clientKey ?? defaultIngestClientKey;
|
|
910
|
+
const receiver = options.siteUrl ? `${options.siteUrl.replace(/\/+$/, "")}${INGEST_RECEIVER_PATH}` : void 0;
|
|
911
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
912
|
+
return {
|
|
913
|
+
async POST(request) {
|
|
914
|
+
const rawBody = request.method === "POST" ? await readCappedBody(request, maxBodyBytes) : "";
|
|
915
|
+
const evaluation = await evaluateIngestRequest({
|
|
916
|
+
request,
|
|
917
|
+
rawBody,
|
|
918
|
+
configured: options.secret !== void 0 && options.secret.length > 0 && receiver !== void 0,
|
|
919
|
+
limiter,
|
|
920
|
+
clientKey,
|
|
921
|
+
...options.botGate ? { botGate: options.botGate } : {},
|
|
922
|
+
...options.enrich ? { enrich: options.enrich } : {}
|
|
923
|
+
});
|
|
924
|
+
if (!evaluation.ok) {
|
|
925
|
+
return Response.json(evaluation.body, { status: evaluation.status, headers: { ...NO_STORE, ...evaluation.headers } });
|
|
926
|
+
}
|
|
927
|
+
const signed = await signedIngestRequest(evaluation.payload, options.secret);
|
|
928
|
+
let answer;
|
|
929
|
+
try {
|
|
930
|
+
answer = await fetchImpl(receiver, { method: "POST", headers: signed.headers, body: signed.body });
|
|
931
|
+
} catch {
|
|
932
|
+
return Response.json({ error: "The ingest receiver could not be reached." }, { status: 502, headers: NO_STORE });
|
|
933
|
+
}
|
|
934
|
+
if (answer.status === 401) {
|
|
935
|
+
return Response.json(
|
|
936
|
+
{ error: "The ingest receiver refused this site's signature \u2014 INGEST_SECRET must equal the deployment's ELYTRA_INGEST_SECRET." },
|
|
937
|
+
{ status: 502, headers: NO_STORE }
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
const text = await answer.text();
|
|
941
|
+
return new Response(text, {
|
|
942
|
+
status: answer.status,
|
|
943
|
+
headers: { ...NO_STORE, "content-type": answer.headers.get("content-type") ?? "application/json" }
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
}
|
|
761
948
|
var LQIP_MAX_EDGE = 24;
|
|
762
949
|
function fitLqipLongEdge(width, height) {
|
|
763
950
|
const scale = Math.min(1, LQIP_MAX_EDGE / Math.max(width, height));
|
|
@@ -866,7 +1053,8 @@ var ELYTRA_HOST_ENV_KEYS = {
|
|
|
866
1053
|
convexUrl: "ELYTRA_CONVEX_URL",
|
|
867
1054
|
projectId: "ELYTRA_PROJECT_ID",
|
|
868
1055
|
draftToken: "DRAFT_PREVIEW_TOKEN",
|
|
869
|
-
revalidateSecret: "REVALIDATE_SECRET"
|
|
1056
|
+
revalidateSecret: "REVALIDATE_SECRET",
|
|
1057
|
+
ingestSecret: "INGEST_SECRET"
|
|
870
1058
|
};
|
|
871
1059
|
function cleaned(value) {
|
|
872
1060
|
const trimmed = value?.trim();
|
|
@@ -1136,8 +1324,6 @@ function createPreviewDisableRoute() {
|
|
|
1136
1324
|
}
|
|
1137
1325
|
};
|
|
1138
1326
|
}
|
|
1139
|
-
|
|
1140
|
-
// src/host-kit.ts
|
|
1141
1327
|
function resolveHostProject(entry) {
|
|
1142
1328
|
const result = resolveProject(entry);
|
|
1143
1329
|
if (result.ok) return result.project;
|
|
@@ -1238,6 +1424,11 @@ function createElytraHost(options) {
|
|
|
1238
1424
|
});
|
|
1239
1425
|
const previewRoute = () => createPreviewRoute({ token: env.draftToken });
|
|
1240
1426
|
const previewDisableRoute = () => createPreviewDisableRoute();
|
|
1427
|
+
const ingestRoute = (overrides = {}) => createIngestRoute({
|
|
1428
|
+
...overrides,
|
|
1429
|
+
secret: env.ingestSecret,
|
|
1430
|
+
siteUrl: "siteUrl" in overrides ? overrides.siteUrl : convexSiteUrl(env.convexUrl)
|
|
1431
|
+
});
|
|
1241
1432
|
const pageOpensFullBleed = async (slug) => {
|
|
1242
1433
|
const perspective = await requestPerspective();
|
|
1243
1434
|
const source = await loadContent({ perspective });
|
|
@@ -1263,11 +1454,12 @@ function createElytraHost(options) {
|
|
|
1263
1454
|
revalidateRoute,
|
|
1264
1455
|
previewRoute,
|
|
1265
1456
|
previewDisableRoute,
|
|
1457
|
+
ingestRoute,
|
|
1266
1458
|
pageOpensFullBleed
|
|
1267
1459
|
};
|
|
1268
1460
|
}
|
|
1269
1461
|
var PACKAGE = "@elytracms/next";
|
|
1270
1462
|
|
|
1271
|
-
export { CANVAS_VALIDATION_SLUG, CanvasRenderer, ELYTRA_HOST_ENV_KEYS, ElytraImage, PACKAGE, assembleLiveSource, bodyItemsOf, canvasSitemapEntries, collectionCacheTag, createAssetImageLoader, createCanvasRoute, createCanvasSitemap, createContentSnapshot, createElytraHost, createPreviewDisableRoute, createPreviewRoute, createRevalidateRoute, createStaticContentSource, defineHostComponents, docCacheTag, elytraHostImage, evaluatePreviewRequest, evaluateRevalidateRequest, firstBlockFullBleed, loadLiveContent, mergeCacheTags, mergeMetadataOverParent, nextCacheTags, openGraphLocale, pathFromSlug, projectSweepTag, requestPerspective, resolveCanvasPage, resolveGlobals, resolveHostEnv, revalidationTags, routeCacheTag, safeRedirectTarget, scopeCacheTag, seoContextOf, seoMetadata, seoObjectOf, withNextImage };
|
|
1463
|
+
export { CANVAS_VALIDATION_SLUG, CanvasRenderer, ELYTRA_HOST_ENV_KEYS, ElytraImage, PACKAGE, assembleLiveSource, bodyItemsOf, canvasBeaconProps, canvasSitemapEntries, collectionCacheTag, createAssetImageLoader, createCanvasRoute, createCanvasSitemap, createContentSnapshot, createElytraHost, createFixedWindowLimiter, createIngestRoute, createPreviewDisableRoute, createPreviewRoute, createRevalidateRoute, createStaticContentSource, defaultIngestClientKey, defineHostComponents, docCacheTag, elytraHostImage, evaluateIngestRequest, evaluatePreviewRequest, evaluateRevalidateRequest, firstBlockFullBleed, isCrossOrigin, loadLiveContent, mergeCacheTags, mergeMetadataOverParent, nextCacheTags, openGraphLocale, pathFromSlug, projectSweepTag, readCappedBody, requestPerspective, resolveCanvasPage, resolveGlobals, resolveHostEnv, revalidationTags, routeCacheTag, safeRedirectTarget, scopeCacheTag, seoContextOf, seoMetadata, seoObjectOf, withNextImage };
|
|
1272
1464
|
//# sourceMappingURL=index.js.map
|
|
1273
1465
|
//# sourceMappingURL=index.js.map
|