@crombolo/next-integration 0.0.0-stage → 0.38.1

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.js ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * A Next.js App Router front end for a crombolo site (story 208).
3
+ *
4
+ * `createCrombolo()` wraps the typed Delivery API client for server
5
+ * components: answers kept in Next's cache and tagged by the content they hold,
6
+ * drafts in Draft Mode, a member's own reads, route lookup as a `Result`, and
7
+ * Next's `Metadata` from a page's search and sharing details.
8
+ * docs/delivery-api.md, "A Next.js front end".
9
+ */
10
+ import { Outcome, ok } from '@crombolo/common';
11
+ import { createDeliveryClient, DeliveryError, memoryConditionalStore, } from '@crombolo/delivery-client';
12
+ import { cookies, draftMode } from 'next/headers';
13
+ import { forbidden, notFound, permanentRedirect, redirect, unauthorized } from 'next/navigation';
14
+ import { PREVIEW_COOKIE, SESSION_COOKIE, sessionSecret, unseal, } from './session.js';
15
+ export { assertSafeLocation, isOk, isOutcome, Ok, Outcome, ok, Result, } from '@crombolo/common';
16
+ export { SITE_TAG, tagOf, tagsFor } from './cache.js';
17
+ /** An address for this path that never answers a page, to ask the site for its not-found page. */
18
+ const NOT_FOUND_PROBE = '/.crombolo/not-found';
19
+ const etagCopies = memoryConditionalStore();
20
+ export function createCrombolo(options) {
21
+ const base = {
22
+ baseUrl: options.url,
23
+ apiPath: options.apiPath,
24
+ apiKey: options.apiKey,
25
+ culture: options.culture,
26
+ startItem: options.startItem,
27
+ };
28
+ const uncached = (url, init) => fetch(url, { ...init, cache: 'no-store' });
29
+ const kept = createDeliveryClient({
30
+ ...base,
31
+ fetch: options.cache
32
+ ? async (url, init) => {
33
+ const answer = await options.cache.answer(url, Object.fromEntries(new Headers(init.headers)));
34
+ return new Response(answer.body || null, {
35
+ status: answer.status,
36
+ headers: answer.headers,
37
+ });
38
+ }
39
+ : uncached,
40
+ conditional: options.cache ? undefined : etagCopies,
41
+ });
42
+ const revalidated = createDeliveryClient({
43
+ ...base,
44
+ fetch: uncached,
45
+ conditional: etagCopies,
46
+ });
47
+ /** In Draft Mode, the editor's drafts and never a kept copy. */
48
+ const draftClient = async () => {
49
+ if (!(await draftMode()).isEnabled)
50
+ return undefined;
51
+ const token = (await cookies()).get(PREVIEW_COOKIE)?.value;
52
+ return createDeliveryClient({
53
+ ...base,
54
+ fetch: uncached,
55
+ preview: () => (token ? { token } : true),
56
+ });
57
+ };
58
+ const reads = (pick, fixed) => {
59
+ const call = (name) => async (...args) => {
60
+ const client = await pick();
61
+ return client[name](...args);
62
+ };
63
+ const page = async (path, request = {}) => {
64
+ const client = await pick();
65
+ try {
66
+ const answer = await client.route(path, {
67
+ host: request.host ?? options.host,
68
+ expand: request.expand,
69
+ seo: true,
70
+ });
71
+ if (answer.kind === 'page')
72
+ return ok(answer.item);
73
+ if (answer.kind === 'notFound')
74
+ return Outcome.notFound();
75
+ switch (answer.statusCode) {
76
+ case 301:
77
+ return Outcome.movedPermanentlyTo(answer.location);
78
+ case 303:
79
+ return Outcome.seeOther(answer.location);
80
+ case 307:
81
+ return Outcome.redirect(answer.location);
82
+ case 308:
83
+ return Outcome.permanentRedirect(answer.location);
84
+ default:
85
+ return Outcome.foundAt(answer.location);
86
+ }
87
+ }
88
+ catch (error) {
89
+ if (error instanceof DeliveryError && error.status === 401)
90
+ return Outcome.unauthorized();
91
+ if (error instanceof DeliveryError && error.status === 403)
92
+ return Outcome.forbidden();
93
+ throw error;
94
+ }
95
+ };
96
+ return {
97
+ get client() {
98
+ return fixed ?? kept;
99
+ },
100
+ page,
101
+ async resolvePage(path, request) {
102
+ const result = await page(path, request);
103
+ if (result.ok)
104
+ return result.value;
105
+ switch (result.status) {
106
+ case 301:
107
+ case 308:
108
+ return permanentRedirect(result.location);
109
+ case 302:
110
+ case 303:
111
+ case 307:
112
+ return redirect(result.location);
113
+ case 401:
114
+ return unauthorized();
115
+ case 403:
116
+ return forbidden();
117
+ default:
118
+ return notFound();
119
+ }
120
+ },
121
+ async notFoundPage(request = {}) {
122
+ const answer = await (await pick()).route(NOT_FOUND_PROBE, {
123
+ host: request.host ?? options.host,
124
+ seo: true,
125
+ });
126
+ return answer.kind === 'notFound' ? (answer.item ?? undefined) : undefined;
127
+ },
128
+ item: call('item'),
129
+ items: call('items'),
130
+ content: call('content'),
131
+ mediaItem: call('mediaItem'),
132
+ media: call('media'),
133
+ menu: call('menu'),
134
+ search: call('search'),
135
+ form: call('form'),
136
+ sitemap: call('sitemap'),
137
+ };
138
+ };
139
+ const tagged = options.revalidate === 'etag' ? revalidated : kept;
140
+ const crombolo = reads(async () => (await draftClient()) ?? tagged);
141
+ return {
142
+ ...crombolo,
143
+ options,
144
+ fresh: reads(async () => (await draftClient()) ?? revalidated, revalidated),
145
+ async member() {
146
+ const secret = sessionSecret(options.secret ?? process.env.CROMBOLO_NEXT_SECRET);
147
+ const session = await unseal((await cookies()).get(SESSION_COOKIE)?.value, secret);
148
+ // An expired token is refreshed by the middleware, which may set cookies; a page may not.
149
+ if (!session || session.expiresAt <= Date.now())
150
+ return undefined;
151
+ const preview = (await draftMode()).isEnabled
152
+ ? (await cookies()).get(PREVIEW_COOKIE)?.value
153
+ : undefined;
154
+ const client = createDeliveryClient({
155
+ ...base,
156
+ fetch: uncached,
157
+ memberToken: () => session.accessToken,
158
+ ...(preview ? { preview: () => ({ token: preview }) } : {}),
159
+ });
160
+ return { ...reads(async () => client, client), session };
161
+ },
162
+ metadata: (item, extra) => metadataOf(item, extra),
163
+ revalidate(keys) {
164
+ options.cache?.revalidate(keys);
165
+ },
166
+ };
167
+ }
168
+ /** Next's `Metadata` from a page's `seo` details; `extra` wins field by field. Pure. */
169
+ export function metadataOf(item, extra = {}) {
170
+ const seo = item?.seo;
171
+ if (!seo)
172
+ return { ...(item ? { title: item.name } : {}), ...extra };
173
+ const languages = Object.fromEntries(seo.alternates.map((a) => [a.culture, a.url]));
174
+ if (seo.xDefault)
175
+ languages['x-default'] = seo.xDefault;
176
+ const image = seo.image
177
+ ? [
178
+ {
179
+ url: seo.image.url,
180
+ ...(seo.image.width ? { width: seo.image.width } : {}),
181
+ ...(seo.image.height ? { height: seo.image.height } : {}),
182
+ },
183
+ ]
184
+ : undefined;
185
+ return {
186
+ title: { absolute: seo.title },
187
+ ...(seo.description ? { description: seo.description } : {}),
188
+ alternates: {
189
+ ...(seo.canonical ? { canonical: seo.canonical } : {}),
190
+ ...(Object.keys(languages).length > 0 ? { languages } : {}),
191
+ },
192
+ ...(seo.robots?.includes('noindex') ? { robots: { index: false } } : {}),
193
+ openGraph: {
194
+ title: seo.title,
195
+ ...(seo.description ? { description: seo.description } : {}),
196
+ ...(seo.canonical ? { url: seo.canonical } : {}),
197
+ siteName: seo.siteName,
198
+ type: 'website',
199
+ ...(image ? { images: image } : {}),
200
+ },
201
+ twitter: { card: image ? 'summary_large_image' : 'summary' },
202
+ ...extra,
203
+ };
204
+ }
205
+ export { decodeEntities, isExpanded, walkRichText } from '@crombolo/delivery-client';
@@ -0,0 +1,27 @@
1
+ import type { Crombolo } from './index.ts';
2
+ /** A fresh session's cookie for this request, or undefined when nothing needs doing. */
3
+ export declare function refreshedSession(crombolo: Crombolo, request: Request): Promise<{
4
+ value: string;
5
+ setCookie: string;
6
+ } | {
7
+ value: '';
8
+ setCookie: string;
9
+ } | undefined>;
10
+ export interface MiddlewareOptions {
11
+ /**
12
+ * Look each page address up before it renders, so a redirect keeps its
13
+ * status, a miss is a real 404 and a members-only page sends a visitor to
14
+ * sign in. Default true.
15
+ */
16
+ routes?: boolean;
17
+ /** A route of the app's that calls `notFound()`, rewritten to for a miss. */
18
+ notFoundPath?: string;
19
+ /** Where a visitor is sent to sign in, with `returnTo`. */
20
+ signInPath?: string;
21
+ }
22
+ /**
23
+ * The app's middleware (`proxy.ts` in Next.js 16): refreshes the member's
24
+ * session, then answers redirects, misses and members-only pages before a page
25
+ * renders.
26
+ */
27
+ export declare function cromboloMiddleware(crombolo: Crombolo, options?: MiddlewareOptions): (request: Request) => Promise<Response>;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * What runs before a page renders (story 208). A page cannot set cookies, so a
3
+ * member's session is refreshed here: an expired access token is swapped for a
4
+ * new one with the refresh token, and the page sees the new session at once.
5
+ * And a page cannot change its status once Partial Prerendering has sent it, so
6
+ * the CMS's redirects, misses and members-only refusals are answered here too.
7
+ *
8
+ * ```ts
9
+ * // web/middleware.ts (proxy.ts in Next.js 16)
10
+ * export default cromboloMiddleware(crombolo)
11
+ * ```
12
+ */
13
+ import { createDeliveryClient, DeliveryError, memoryConditionalStore, } from '@crombolo/delivery-client';
14
+ import { NextResponse } from 'next/server';
15
+ import { requestTokens } from './route-handlers.js';
16
+ import { cookie, PREVIEW_COOKIE, readCookie, SESSION_COOKIE, seal, sessionSecret, unseal, } from './session.js';
17
+ /** Refresh this long before the access token runs out, so a render never meets an expired one. */
18
+ const EARLY_MS = 30_000;
19
+ /** A fresh session's cookie for this request, or undefined when nothing needs doing. */
20
+ export async function refreshedSession(crombolo, request) {
21
+ const secret = sessionSecret(crombolo.options.secret ?? process.env.CROMBOLO_NEXT_SECRET);
22
+ const sealed = readCookie(request.headers.get('cookie'), SESSION_COOKIE);
23
+ if (!sealed)
24
+ return undefined;
25
+ const session = await unseal(sealed, secret);
26
+ const secure = new URL(request.url).protocol === 'https:';
27
+ const forget = {
28
+ value: '',
29
+ setCookie: cookie(SESSION_COOKIE, '', { secure, maxAge: 0 }),
30
+ };
31
+ if (!session)
32
+ return forget;
33
+ if (session.expiresAt - EARLY_MS > Date.now())
34
+ return undefined;
35
+ if (!session.refreshToken)
36
+ return forget;
37
+ const next = await requestTokens(crombolo, {
38
+ grant_type: 'refresh_token',
39
+ refresh_token: session.refreshToken,
40
+ });
41
+ if (!next)
42
+ return forget;
43
+ const value = await seal(next, secret);
44
+ return { value, setCookie: cookie(SESSION_COOKIE, value, { secure, maxAge: 60 * 60 * 24 * 30 }) };
45
+ }
46
+ /** Copies kept for the route lookups, revalidated with `If-None-Match` on every request. */
47
+ const routeCopies = memoryConditionalStore();
48
+ /** Whether a request is a page navigation the CMS answers, rather than an asset or an API call. */
49
+ function isPage(request, path) {
50
+ if (request.method !== 'GET' && request.method !== 'HEAD')
51
+ return false;
52
+ if (path.startsWith('/api/') || path.startsWith('/_next/') || path.startsWith('/media/'))
53
+ return false;
54
+ return !/\.[a-z0-9]+$/i.test(path);
55
+ }
56
+ /**
57
+ * What the CMS answers at this address, before the page renders. With Partial
58
+ * Prerendering a page's status is sent before its content streams, so a page
59
+ * cannot turn itself into a redirect or a 404 any more; this can.
60
+ */
61
+ async function routeAnswer(crombolo, request, options, token) {
62
+ const url = new URL(request.url);
63
+ if (!isPage(request, url.pathname) || readCookie(request.headers.get('cookie'), PREVIEW_COOKIE))
64
+ return undefined;
65
+ const client = createDeliveryClient({
66
+ baseUrl: crombolo.options.url,
67
+ apiPath: crombolo.options.apiPath,
68
+ apiKey: crombolo.options.apiKey,
69
+ culture: crombolo.options.culture,
70
+ startItem: crombolo.options.startItem,
71
+ conditional: routeCopies,
72
+ ...(token ? { memberToken: () => token } : {}),
73
+ fetch: (input, init) => fetch(input, { ...init, cache: 'no-store' }),
74
+ });
75
+ try {
76
+ const answer = await client.route(url.pathname, { host: crombolo.options.host });
77
+ if (answer.kind === 'page')
78
+ return undefined;
79
+ if (answer.kind === 'redirect') {
80
+ const target = new URL(answer.location, url);
81
+ if (!target.search)
82
+ target.search = url.search;
83
+ return NextResponse.redirect(target, answer.statusCode);
84
+ }
85
+ return NextResponse.rewrite(new URL(options.notFoundPath ?? '/crombolo/not-found', url), {
86
+ status: 404,
87
+ });
88
+ }
89
+ catch (error) {
90
+ if (error instanceof DeliveryError && error.status === 401) {
91
+ const signIn = new URL(options.signInPath ?? '/api/crombolo/sign-in', url);
92
+ signIn.searchParams.set('returnTo', `${url.pathname}${url.search}`);
93
+ return NextResponse.redirect(signIn, 307);
94
+ }
95
+ // A refusal for this member, or the CMS unreachable: the page has its say.
96
+ return undefined;
97
+ }
98
+ }
99
+ /**
100
+ * The app's middleware (`proxy.ts` in Next.js 16): refreshes the member's
101
+ * session, then answers redirects, misses and members-only pages before a page
102
+ * renders.
103
+ */
104
+ export function cromboloMiddleware(crombolo, options = {}) {
105
+ return async (request) => {
106
+ const refreshed = await refreshedSession(crombolo, request).catch(() => undefined);
107
+ let token;
108
+ if (options.routes !== false) {
109
+ const secret = crombolo.options.secret ?? process.env.CROMBOLO_NEXT_SECRET;
110
+ const sealed = refreshed
111
+ ? refreshed.value
112
+ : readCookie(request.headers.get('cookie'), SESSION_COOKIE);
113
+ const session = secret && secret.length >= 32 && sealed
114
+ ? await unseal(sealed, secret)
115
+ : undefined;
116
+ token = session && session.expiresAt > Date.now() ? session.accessToken : undefined;
117
+ const answered = await routeAnswer(crombolo, request, options, token);
118
+ if (answered) {
119
+ if (refreshed)
120
+ answered.headers.append('set-cookie', refreshed.setCookie);
121
+ return answered;
122
+ }
123
+ }
124
+ if (!refreshed)
125
+ return NextResponse.next();
126
+ const headers = new Headers(request.headers);
127
+ const others = (request.headers.get('cookie') ?? '')
128
+ .split(';')
129
+ .map((part) => part.trim())
130
+ .filter((part) => part && !part.startsWith(`${SESSION_COOKIE}=`));
131
+ if (refreshed.value)
132
+ others.push(`${SESSION_COOKIE}=${encodeURIComponent(refreshed.value)}`);
133
+ headers.set('cookie', others.join('; '));
134
+ const response = NextResponse.next({ request: { headers } });
135
+ response.headers.append('set-cookie', refreshed.setCookie);
136
+ return response;
137
+ };
138
+ }
@@ -0,0 +1,52 @@
1
+ import type { Crombolo } from './index.ts';
2
+ import { type MemberSession } from './session.ts';
3
+ /** Where member sign-in lives on the CMS. */
4
+ export declare const MEMBER_SECURITY_PATH = "/crombolo/delivery/api/v1/security/member";
5
+ /** A path on this app, or `/`: never another site, so a link cannot send anyone away. */
6
+ export declare function localPath(value: string | null | undefined): string;
7
+ /**
8
+ * `GET ?path=…&token=…`, what the Preview button opens: the token is checked
9
+ * against the CMS, Draft Mode is turned on, and the editor lands on the page.
10
+ */
11
+ export declare function previewHandler(crombolo: Crombolo): (request: Request) => Promise<Response>;
12
+ /** `GET ?path=…`: leaves Draft Mode and forgets the preview token. */
13
+ export declare function exitPreviewHandler(): (request: Request) => Promise<Response>;
14
+ /**
15
+ * `POST` from a CMS webhook with `payload = "crombolo"`: checks the shared
16
+ * secret header and clears what holds the item and each of its ancestors.
17
+ */
18
+ export declare function revalidateHandler(crombolo: Crombolo, options?: {
19
+ secret?: string;
20
+ header?: string;
21
+ }): (request: Request) => Promise<Response>;
22
+ /** Asks the CMS's token endpoint, by authorization code or refresh token. */
23
+ export declare function requestTokens(crombolo: Crombolo, form: Record<string, string>): Promise<MemberSession | undefined>;
24
+ export interface MemberHandlerOptions {
25
+ /** This app's callback address, registered in the CMS's `deliveryApi.memberAuthorization`. */
26
+ redirectUri: string;
27
+ /** Where the CMS sends the member after signing out; registered there too. */
28
+ postLogoutRedirectUri: string;
29
+ }
30
+ /** Sign-in, its callback, and sign-out, for a member of the site. */
31
+ export declare function memberHandlers(crombolo: Crombolo, options: MemberHandlerOptions): {
32
+ /** `GET ?returnTo=/path`: off to the CMS to sign in. */
33
+ signIn(request: Request): Promise<Response>;
34
+ /** `GET ?code=…&state=…`: the tokens, sealed into the session cookie. */
35
+ callback(request: Request): Promise<Response>;
36
+ /** `GET`: ends the member's tokens and their CMS session, then comes back. */
37
+ signOut(request: Request): Promise<Response>;
38
+ };
39
+ /**
40
+ * `GET /media/…`: the CMS's media file, sent with the member's token when one
41
+ * is signed in, so protected media reaches who may read it. Mount it at
42
+ * `web/app/media/[...path]/route.ts`.
43
+ */
44
+ export declare function mediaHandler(crombolo: Crombolo): (request: Request) => Promise<Response>;
45
+ /** Where the CMS streams its changes in development. */
46
+ export declare const CMS_LIVE_EVENTS_PATH = "/crombolo/dev/events";
47
+ /**
48
+ * `GET` for `<CromboloLiveReload />` in development: the CMS's change stream,
49
+ * and a `revalidated` event once this app has cleared what a webhook named, so
50
+ * a refresh after it shows the new content. 404 outside development.
51
+ */
52
+ export declare function liveReloadHandler(crombolo: Crombolo): (request: Request) => Promise<Response>;