@preprio/toolkit 0.6.0-alpha.1 → 0.6.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -61
- package/dist/astro/{chunk-O7N6GPAH.js → chunk-CG2QBHNU.js} +1 -1
- package/dist/astro/graphql.cjs +58 -10
- package/dist/astro/graphql.d.cts +33 -38
- package/dist/astro/graphql.d.ts +33 -38
- package/dist/astro/graphql.js +59 -11
- package/dist/astro/index.cjs +1 -1
- package/dist/astro/index.js +1 -1
- package/dist/{chunk-VI6QZTBM.js → chunk-LIDUJWWJ.js} +1 -1
- package/dist/{chunk-TBKSBXGH.js → chunk-M3RBYJ5J.js} +52 -5
- package/dist/{chunk-3JLWZQ37.js → chunk-SF6TN4JR.js} +1 -1
- package/dist/{chunk-YWIKKWBR.js → chunk-TN6SNAR2.js} +1 -1
- package/dist/graphql/codegen.cjs +51 -4
- package/dist/graphql/codegen.js +3 -3
- package/dist/graphql.cjs +89 -10
- package/dist/graphql.d.cts +69 -39
- package/dist/graphql.d.ts +69 -39
- package/dist/graphql.js +38 -9
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -3
- package/dist/nextjs/chunk-B4GVZEOL.js +549 -0
- package/dist/nextjs/components.cjs +297 -81
- package/dist/nextjs/components.d.cts +26 -11
- package/dist/nextjs/components.d.ts +26 -11
- package/dist/nextjs/components.js +288 -73
- package/dist/nextjs/graphql.cjs +856 -266
- package/dist/nextjs/graphql.d.cts +184 -116
- package/dist/nextjs/graphql.d.ts +184 -116
- package/dist/nextjs/graphql.js +543 -266
- package/dist/nextjs/index.cjs +116 -54
- package/dist/nextjs/index.d.cts +61 -23
- package/dist/nextjs/index.d.ts +61 -23
- package/dist/nextjs/index.js +97 -299
- package/dist/nextjs/{server-CjpELYvq.d.ts → server-BL-sqZvu.d.cts} +1 -1
- package/dist/nextjs/{server-CjpELYvq.d.cts → server-BL-sqZvu.d.ts} +1 -1
- package/dist/nuxt/{chunk-IQDSBPDN.js → chunk-FMC3DP7N.js} +1 -1
- package/dist/nuxt/graphql.cjs +58 -10
- package/dist/nuxt/graphql.d.cts +33 -38
- package/dist/nuxt/graphql.d.ts +33 -38
- package/dist/nuxt/graphql.js +59 -11
- package/dist/nuxt/index.cjs +1 -1
- package/dist/nuxt/index.js +1 -1
- package/dist/react/components.cjs +1 -1
- package/dist/react/components.js +1 -1
- package/dist/sveltekit/{chunk-OKYZ6HPH.js → chunk-AJPKBAHO.js} +1 -1
- package/dist/sveltekit/graphql.cjs +58 -10
- package/dist/sveltekit/graphql.d.cts +33 -38
- package/dist/sveltekit/graphql.d.ts +33 -38
- package/dist/sveltekit/graphql.js +59 -11
- package/dist/sveltekit/index.cjs +1 -1
- package/dist/sveltekit/index.js +1 -1
- package/package.json +1 -1
- package/dist/nextjs/chunk-BQ6IGSH6.js +0 -231
package/README.md
CHANGED
|
@@ -225,7 +225,9 @@ Codegen's default client-preset output works as is; `documentMode: 'string'` wor
|
|
|
225
225
|
|
|
226
226
|
`personalized` is `true` when the queried fields contain segment variants or A/B tests, at any depth. Use it to decide whether a page can be served statically.
|
|
227
227
|
|
|
228
|
-
To serve pages from the cache until you publish, set `revalidate: false` and call `revalidateTag('prepr')` from a Prepr publish webhook. With `revalidate` unset, the installed Next version's fetch caching default applies.
|
|
228
|
+
To serve pages from the cache until you publish, set `revalidate: false` and call `revalidateTag('prepr')` from a Prepr publish webhook. With `revalidate` unset, the installed Next version's fetch caching default applies. With Cache Components, a webhook can refresh only the pages showing the changed item instead: see [Refreshing on publish](#refreshing-on-publish).
|
|
229
|
+
|
|
230
|
+
Tags you pass per query in `init.next.tags` are added to the client's `cacheTags`, never in place of them.
|
|
229
231
|
|
|
230
232
|
In preview, pass `preview: true`: queries skip the cache and stega markers stay in text for click-to-edit. They are always stripped from URL-like fields.
|
|
231
233
|
|
|
@@ -233,11 +235,11 @@ Custom headers go in two places. Client-level `headers` (`createPreprClient({ ur
|
|
|
233
235
|
|
|
234
236
|
#### Personalization with Cache Components
|
|
235
237
|
|
|
236
|
-
With [Cache Components](https://nextjs.org/docs/app/getting-started/cache-components) (`cacheComponents: true`), a personalized page is still served from the cache:
|
|
238
|
+
With [Cache Components](https://nextjs.org/docs/app/getting-started/cache-components) (`cacheComponents: true`), a personalized page is still served from the cache, whole: every variant of every A/B test and personalization group is in the prerendered page, and each page view's decision shows the visitor's variant before the page paints. Nothing streams, and no default variant shows first to be swapped out. You keep your own query and your own components.
|
|
237
239
|
|
|
238
240
|
**Do you need this?** Only if your pages are cached. A page that renders per request already gets this visitor's variants from `query(..., { personalize: true })`; render the result as is and skip this section.
|
|
239
241
|
|
|
240
|
-
A normal page needs
|
|
242
|
+
A normal page needs two changes, plus the [proxy](#the-proxy):
|
|
241
243
|
|
|
242
244
|
```tsx
|
|
243
245
|
import { PreprStack } from '@preprio/toolkit/nextjs/graphql';
|
|
@@ -261,14 +263,10 @@ export default async function Page({
|
|
|
261
263
|
const slug = (await params).slug?.join('/') || '/';
|
|
262
264
|
const data = await getPage(slug);
|
|
263
265
|
|
|
264
|
-
// 2.
|
|
265
|
-
const decisions = prepr.decide(PageDocument, { slug }, { from: data });
|
|
266
|
-
|
|
267
|
-
// 3. Render the list through PreprStack instead of directly.
|
|
266
|
+
// 2. Render the list through PreprStack instead of directly.
|
|
268
267
|
return (
|
|
269
268
|
<PreprStack
|
|
270
269
|
items={data.Page.content}
|
|
271
|
-
decisions={decisions}
|
|
272
270
|
render={(items, { index }) => <Sections items={items} offset={index} />}
|
|
273
271
|
/>
|
|
274
272
|
);
|
|
@@ -288,7 +286,6 @@ Because `render` runs per block, a position counted inside `Sections` (`items.ma
|
|
|
288
286
|
// After
|
|
289
287
|
<PreprStack
|
|
290
288
|
items={data.Page.content}
|
|
291
|
-
decisions={decisions}
|
|
292
289
|
render={(items, { index }) => <Sections items={items} offset={index} />}
|
|
293
290
|
/>
|
|
294
291
|
|
|
@@ -308,11 +305,11 @@ A group takes one position, whichever variant shows. A group that shows nothing
|
|
|
308
305
|
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
309
306
|
| One page-builder list (`content`, `sections`, `stack`) | One `PreprStack` around it. |
|
|
310
307
|
| Personalized lists inside sections (cards, buttons, models in a section) | Nothing. They are narrowed down with the section they sit in. |
|
|
311
|
-
| Several lists (hero, body, sidebar) | One `PreprStack` per list
|
|
308
|
+
| Several lists (hero, body, sidebar) | One `PreprStack` per list. |
|
|
312
309
|
| A personalized list outside any list you already wrap (e.g. `Page.buttons`) | Its own `PreprStack`. |
|
|
313
310
|
| Data used outside rendering (`generateMetadata`, JSON-LD) | `resolveVariants(data)` gives the default variants and A. |
|
|
314
311
|
| Impression tracking for Prepr's metrics | `trackVariants` on `PreprStack`; see [Impressions and conversions](#impressions-and-conversions). |
|
|
315
|
-
| No personalization on a page | Nothing to change. `PreprStack` renders it
|
|
312
|
+
| No personalization on a page | Nothing to change. `PreprStack` renders it as is and the proxy sends no request. |
|
|
316
313
|
|
|
317
314
|
##### Impressions and conversions
|
|
318
315
|
|
|
@@ -321,7 +318,7 @@ Prepr's tracking pixel counts impressions of an element carrying `data-prepr-var
|
|
|
321
318
|
Opt in with `trackVariants`, and `PreprStack` puts the key on the section each personalized and A/B variant renders:
|
|
322
319
|
|
|
323
320
|
```tsx
|
|
324
|
-
<PreprStack items={data.Page.content}
|
|
321
|
+
<PreprStack items={data.Page.content} trackVariants render={…} />
|
|
325
322
|
```
|
|
326
323
|
|
|
327
324
|
For variants nested inside a section (cards, buttons, models), and outside Next.js, spread `preprVariantProps(item)` on the element that shows the item:
|
|
@@ -335,62 +332,94 @@ Conversions stay your choice: put `data-prepr-variant-event` (or `data-prepr-var
|
|
|
335
332
|
- Keys are added after the page hydrates; the pixel picks them up then. Each item of a variant tags the element it renders, in order. A variant that renders a different number of elements than it has items (a section with several root elements, or one rendering nothing) tags only its first element.
|
|
336
333
|
- With `trackVariants`, each variant sits in a `display: contents` wrapper. It takes no box, but CSS child combinators (`>`) and `:first-child` from the stack's parent no longer reach the sections.
|
|
337
334
|
- An element that already has `data-prepr-variant-key` keeps it, so existing manual tagging keeps working.
|
|
338
|
-
-
|
|
339
|
-
|
|
340
|
-
```tsx
|
|
341
|
-
render={(items, { index, placeholder }) => (
|
|
342
|
-
<Sections items={items} offset={index} tagVariants={!placeholder} />
|
|
343
|
-
)}
|
|
344
|
-
```
|
|
335
|
+
- `trackVariants` tags only the variant that is shown, once it is shown. A section holding a personalized list (a logo strip whose logos vary) gets the key of the variant it shows. Do not tag stack sections yourself (with your own `_context { variant_key }` or `preprVariantProps`): that puts a key on every variant in the page, including the invisible default that holds its space while the decision is on its way, and Prepr's pixel counts any tagged element in view, invisible or not.
|
|
345
336
|
|
|
346
|
-
|
|
337
|
+
##### The proxy
|
|
347
338
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
`PreprStack` renders both variants of an A/B test in the static shell and shows the visitor's one before first paint; A is the default, also for crawlers. The visitor's bucket comes from a cookie set in your middleware. Prepr assigns the bucket, once per visitor, and only on pages that have an A/B test:
|
|
339
|
+
One call in `proxy.ts` gives `PreprStack` what it needs: the visitor's Prepr headers and customer id, and each page view's decision: the visitor's variant of every A/B test and personalization group on a page, in one Prepr request. Only the first load of a URL by a visitor waits for it; see [How each visitor gets their variant](#how-each-visitor-gets-their-variant). Tell it which query each URL renders; most sites route every page through one query:
|
|
351
340
|
|
|
352
341
|
```ts
|
|
353
342
|
// proxy.ts
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
343
|
+
import { createPreprProxy } from '@preprio/toolkit/nextjs/graphql';
|
|
344
|
+
|
|
345
|
+
const handlePrepr = createPreprProxy({
|
|
346
|
+
client: prepr,
|
|
347
|
+
// URL → the query that page renders with `variants: true`; null for non-CMS URLs.
|
|
348
|
+
page: (url) => ({
|
|
349
|
+
document: PageDocument,
|
|
350
|
+
variables: { slug: url.pathname },
|
|
351
|
+
}),
|
|
352
|
+
preview: process.env.PREPR_ENV === 'preview',
|
|
359
353
|
});
|
|
360
|
-
return response;
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
Sites without A/B tests leave this out. Under a strict Content-Security-Policy, pass `nonce` to `PreprStack`.
|
|
364
354
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
355
|
+
export function proxy(request: NextRequest) {
|
|
356
|
+
return handlePrepr(request); // add your own cookies or headers to this response
|
|
357
|
+
}
|
|
358
|
+
```
|
|
368
359
|
|
|
369
360
|
```tsx
|
|
370
|
-
|
|
361
|
+
// app/layout.tsx, once: decides every client-side navigation
|
|
362
|
+
import { PreprPersonalizationSwitch } from '@preprio/toolkit/nextjs';
|
|
363
|
+
|
|
364
|
+
<PreprPersonalizationSwitch />;
|
|
371
365
|
```
|
|
372
366
|
|
|
367
|
+
Deriving the decision query from `PageDocument` needs Prepr's schema, which a fresh server instance downloads before its first decision. Build the decision query ahead of time instead and pass it as `decision`: the codegen plugin (see [Using your own GraphQL client](#using-your-own-graphql-client)) writes it next to your query, and the proxy sends it as is.
|
|
368
|
+
|
|
373
369
|
```ts
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
{ slug },
|
|
380
|
-
|
|
381
|
-
// Only pages whose personalized content is above the fold need it.
|
|
382
|
-
onlyIf: () => slug === '/',
|
|
383
|
-
},
|
|
384
|
-
);
|
|
370
|
+
import { PageDecisionQuery } from './gql/prepr-queries';
|
|
371
|
+
|
|
372
|
+
page: (url) => ({
|
|
373
|
+
document: PageDocument,
|
|
374
|
+
decision: PageDecisionQuery, // no schema download at runtime
|
|
375
|
+
variables: { slug: url.pathname },
|
|
376
|
+
}),
|
|
385
377
|
```
|
|
386
378
|
|
|
387
|
-
|
|
379
|
+
API routes, `/_next/` and URLs whose last segment has a file extension (`/logo.png`, but also `/docs/v1.2`) are skipped. A decision request for any other URL `page` maps to a query sends one decision query; a page without groups gets an empty answer. Pass `onlyIf: (request) => boolean` to skip Prepr for some requests, such as crawlers, which then get the default variants. In preview it also sends the editor through the [draft route](#preview) first. `createPreprMiddleware` and `preprDraftRedirect` stay exported for proxies that need them separately; decision requests are answered by `createPreprProxy` only.
|
|
380
|
+
|
|
381
|
+
##### A/B tests
|
|
382
|
+
|
|
383
|
+
A/B tests go through the same decision as segment groups: both variants are in the prerendered page, hidden until the page view's decision shows the visitor's one. Prepr assigns a variant per experiment, from the visitor's customer id and that experiment's split, so the decision is the only reliable source; one stored bucket per visitor cannot follow a 20/80 and a 50/50 test at once. A is the default, also for crawlers and when Prepr cannot be asked. Pass `bucket` to `PreprStack` only when the route already knows it. Under a strict Content-Security-Policy, pass `nonce` to `PreprStack`.
|
|
384
|
+
|
|
385
|
+
##### How each visitor gets their variant
|
|
386
|
+
|
|
387
|
+
Every page view is decided, and its personalized sections paint with the visitor's variant. Decisions are kept per URL in the visitor's `prepr_decisions` cookie, which the proxy and the browser both read and write (see **Reuse** below); nothing is reused from a prefetch, the router cache or another URL's view.
|
|
388
|
+
|
|
389
|
+
- **Page loads.** The page is the same for every visitor and comes from the cache. When the cookie holds no decision for the URL, the proxy asks Prepr before sending it (one request, at most `timeoutMs`) and puts the answer in the cookie; otherwise the page passes straight through. An inline script at the start of each `PreprStack` reads the cookie and shows the visitor's variants before the page paints. So only a visitor's first load of each personalized URL waits for Prepr, on the server, where the browser keeps showing the previous page instead of an empty section. Pages without personalization are remembered by the proxy and never wait.
|
|
390
|
+
- **Client-side navigations.** When the visitor hovers over a link (or presses it, on touch devices), `PreprPersonalizationSwitch` asks the proxy for the target page's decision. A click whose decision is in, which hovering and the cookie mostly ensure, goes through at once; one without (a quick tap, a link reached by keyboard) waits for it, so the new page always paints with the visitor's variants. Links to pages without personalization never wait. Prefetches still bring the page itself; only the decision is fetched.
|
|
391
|
+
- **Never the default first.** Until a view's decision is in, its personalized sections are not shown: the default variant keeps its space, invisible, so the page does not move when the visitor's variant appears (only by any difference in height). The default variant only shows when it is the decision: for visitors in none of the segments, for requests `onlyIf` skips, and when Prepr does not answer within `timeoutMs` (default 1000). Navigations started from code to a URL without a decision ask for one when the page opens; its personalized sections appear when it arrives.
|
|
392
|
+
- **Reuse.** Back and Forward show a page as this tab last showed it, at once, without asking Prepr. Any other view of a URL the cookie holds a decision for shows it at once too: under 10 seconds old without asking again, since Prepr needs a few seconds to process a visitor's activity anyway; up to 30 minutes old while a new decision is asked for in the background. When that one differs, it changes only the sections still below the screen; a section the visitor can see, or has scrolled past, keeps its variant for that view, so nothing they are reading is swapped or moves under them. The next view shows the new decision. A page without personalization answers with no groups, which is trusted for an hour with no background check, so links to it cost nothing; when that page renders personalization by then (published since), it asks at once. A URL with other UTM parameters is another view; a failed answer is kept for 10 seconds and never replaces a real one.
|
|
393
|
+
- **"Right" means what Prepr knows when the visitor clicks.** Prepr assigns segments from the visitor's tracked activity. If it has not yet processed the pixel event of the previous page view, the decision cannot include it, and the visitor sees that segment from a later page view.
|
|
394
|
+
- **Costs.** The page carries the HTML of every variant, and an item with groups nested inside it once per combination of their variants. `render` gets `defaultVariant`: give `priority` images only to the default variant and `loading="lazy"` to the others, or the browser downloads every variant's hero image. Every page load with personalization, and every link hovered or pressed (also a press that turns into a scroll), is at most one decision request when this tab has no fresh decision for that URL, answered from Prepr's cache when other visitors had the same answer.
|
|
395
|
+
- Without JavaScript, visitors see the default variants.
|
|
396
|
+
|
|
397
|
+
##### Deploying
|
|
398
|
+
|
|
399
|
+
Pages are sent without waiting for Prepr; only the personalized sections wait for their decision, and most of that time is spent at Prepr, in two steps:
|
|
400
|
+
|
|
401
|
+
- **Who is the visitor?** For a visitor Prepr has not seen before, it first works out their segments, which takes a few hundred milliseconds on pages with segment groups. A returning visitor is resolved at once.
|
|
402
|
+
- **The answer.** Prepr's CDN caches each decision per page, segment set and A/B bucket, and shares it between visitors who get the same. A cached answer takes tens of milliseconds; working one out takes most of a second, once per page and combination until Prepr's cache drops it.
|
|
403
|
+
|
|
404
|
+
Under steady traffic most answers come from that cache. Your deployment adds the rest:
|
|
405
|
+
|
|
406
|
+
- **Region.** Run your functions, the proxy included, in the region closest to your visitors and to Prepr.
|
|
407
|
+
- **Keep connections open.** Node.js closes idle connections after a few seconds. On the Node.js runtime, give the client a `fetch` that keeps them longer; this affects only the client's own requests:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
import { Agent } from 'undici';
|
|
388
411
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
412
|
+
const keepAlive = new Agent({ keepAliveTimeout: 60_000 });
|
|
413
|
+
export const prepr = createPreprClient({
|
|
414
|
+
url: process.env.PREPR_GRAPHQL_URL!,
|
|
415
|
+
fetch: (input, init) =>
|
|
416
|
+
fetch(input, { ...init, dispatcher: keepAlive } as RequestInit),
|
|
417
|
+
});
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
- **Instances.** On Vercel, Fluid Compute reuses a function instance, and its open connections, across requests, so under steady traffic the connection stays warm. Each fresh instance pays the setup on its first request; keep instances warm with your platform's settings if your traffic is low.
|
|
421
|
+
- **No schema at runtime.** Pass the codegen plugin's decision query as `decision` (see [The proxy](#the-proxy)), so a fresh instance does not download Prepr's schema before its first decision.
|
|
422
|
+
- **Timeout.** When Prepr does not answer within `timeoutMs` (default 1000), the personalized sections show the default variants, with nothing swapped in later. It caps how long a first page load waits for Prepr.
|
|
394
423
|
|
|
395
424
|
##### Preview
|
|
396
425
|
|
|
@@ -405,17 +434,84 @@ const draft = preprDraftRedirect(request, { preview });
|
|
|
405
434
|
if (draft) return draft;
|
|
406
435
|
```
|
|
407
436
|
|
|
408
|
-
Outside preview both do nothing (the route answers 404). Keep the route out of your proxy `matcher`, or pass its path as `route` when it lives elsewhere. Inside the Prepr editor, browsers that refuse the draft cookie get the cached page instead, so keep a publish webhook
|
|
437
|
+
Outside preview both do nothing (the route answers 404). Keep the route out of your proxy `matcher`, or pass its path as `route` when it lives elsewhere. Inside the Prepr editor, browsers that refuse the draft cookie get the cached page instead, so keep a publish webhook (below) in place.
|
|
438
|
+
|
|
439
|
+
##### Refreshing on publish
|
|
440
|
+
|
|
441
|
+
`revalidateTag('prepr')` refreshes every Prepr page on every publish. To refresh only the pages that show the changed item, tag each cached page with its items and let a Prepr webhook revalidate the item's tag.
|
|
442
|
+
|
|
443
|
+
1. **Tag the page with its items.** Add `tagItems: true` to the query inside `'use cache'`. The entry gets a `prepr:<_id>` tag for every item in the result, at any depth; with `variants: true` that is every variant's items, so a change to any variant reaches the page.
|
|
444
|
+
|
|
445
|
+
```tsx
|
|
446
|
+
async function getPage(slug: string) {
|
|
447
|
+
'use cache';
|
|
448
|
+
const { data } = await prepr().query(
|
|
449
|
+
GetPageBySlugDocument,
|
|
450
|
+
{ slug },
|
|
451
|
+
{ variants: true, tagItems: true },
|
|
452
|
+
);
|
|
453
|
+
return data;
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
With your own client, call `cacheTag(...preprCacheTags(data))` inside `'use cache'` instead.
|
|
458
|
+
|
|
459
|
+
2. **New items are covered too.** A new item is in no cached result yet, so `tagItems` also tags two kinds of query with their model's `prepr:model:<model>` tag, which a publish of any item of that model revalidates:
|
|
460
|
+
- list queries (`Posts { items { … } }`), so a new post joins the list;
|
|
461
|
+
- lookups that found nothing (`Page(slug: "new")` returning `null`), so publishing that page replaces the cached "not found".
|
|
462
|
+
|
|
463
|
+
Other pages that depend on which items exist (a sitemap, a count) can add the tag themselves:
|
|
464
|
+
|
|
465
|
+
```ts
|
|
466
|
+
import { cacheTag } from 'next/cache';
|
|
467
|
+
import { preprModelTag } from '@preprio/toolkit/nextjs/graphql';
|
|
468
|
+
|
|
469
|
+
cacheTag(preprModelTag('Post')); // the model's name in Prepr or its GraphQL type
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
The model name is compared without case, spaces or punctuation, so `'Blog post'` (what webhooks send) and `BlogPost` (the GraphQL type) give the same tag. A model whose API ID was renamed away from its name does not match.
|
|
473
|
+
|
|
474
|
+
3. **Add the webhook route.**
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
// app/api/prepr/webhook/route.ts
|
|
478
|
+
import { createPreprWebhookRoute } from '@preprio/toolkit/nextjs';
|
|
479
|
+
|
|
480
|
+
export const POST = createPreprWebhookRoute({
|
|
481
|
+
secret: process.env.PREPR_WEBHOOK_SECRET,
|
|
482
|
+
});
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Keep the route out of your proxy `matcher`.
|
|
486
|
+
|
|
487
|
+
4. **Create the webhook in Prepr.** Under **Settings → Webhooks**, add `https://<your-site>/api/prepr/webhook` with the events `content_item.published`, `content_item.unpublished`, `content_item.deleted` and `content_item.invalidated`, plus `asset.changed`, `asset.deleted` and `schema.changed`. Other events are acknowledged and ignored, so subscribing to more does no harm. Save, then copy the webhook's **ID** into `PREPR_WEBHOOK_SECRET`: Prepr signs every request with it.
|
|
488
|
+
|
|
489
|
+
The route checks the `Prepr-Signature` header (an HMAC-SHA256 of the body, keyed with the webhook ID, as hex or base64) and answers 401 to a missing or wrong signature, or to a request older than five minutes. A valid event revalidates:
|
|
490
|
+
|
|
491
|
+
| Tag | When | Reaches |
|
|
492
|
+
| --------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
493
|
+
| `prepr:<id>` | `content_item.published`, `.unpublished`, `.deleted`, `.invalidated`; `asset.changed`, `.deleted` | pages showing that item or asset |
|
|
494
|
+
| `prepr:model:<model>` | `content_item.published`, `.unpublished`, `.deleted` | list queries, lookups that found nothing, pages tagged by you |
|
|
495
|
+
| `prepr:any` | the same events as `prepr:<id>` | pages with more items than Next.js's tag limit |
|
|
496
|
+
| `prepr:schema` | `schema.changed` | the schema the client rewrites queries with |
|
|
497
|
+
|
|
498
|
+
Draft edits (`content_item.changed`, `.created`), visitor and segment events revalidate nothing: they change no published page.
|
|
499
|
+
|
|
500
|
+
Revalidation uses `revalidateTag(tag, { expire: 0 })`, so the next request after a publish gets the new page. Pass `profile: 'max'` to serve the old page once more while the new one renders in the background.
|
|
501
|
+
|
|
502
|
+
Next.js accepts at most 128 tags per `cacheTag()` call. A page with more items is tagged `prepr:any` instead (`PREPR_ANY_ITEM_TAG`), so it refreshes on every event, as with `revalidateTag('prepr')`. The response lists the revalidated tags, which Prepr shows on the webhook's detail page.
|
|
503
|
+
|
|
504
|
+
`tagItems` makes that query skip the Data Cache: a Data Cache entry carries only the client's `cacheTags`, so after an item tag is revalidated, the `'use cache'` function would re-run against the stale response. The `'use cache'` entry is the cache. The client's `cacheTags` still apply to the entry, so `revalidateTag('prepr')` keeps refreshing everything. `tagItems` needs Next.js 16 and throws outside a `'use cache'` function.
|
|
409
505
|
|
|
410
506
|
<details>
|
|
411
507
|
<summary>How it works</summary>
|
|
412
508
|
|
|
413
509
|
- **`variants: true`** adds `personalize: false` to every personalizable field in your query, at any depth, and selects each item's `_context`. The client learns which fields are personalizable from one schema introspection request per client, kept in the Data Cache.
|
|
414
|
-
-
|
|
415
|
-
- **`PreprStack`** renders
|
|
416
|
-
- **A/B variants** are both in the HTML
|
|
510
|
+
- **Decisions** send your query cut down to the lists that hold segment groups, selecting only `_context`, with the visitor's headers and never cached. Prepr decides from everything the middleware forwards: customer profile, UTM parameters, visitor IP, HubSpot id. A page without segment groups makes no request. `prepr.decide()` does the same inside a render, for rendering variants yourself with `resolveVariants`; `PreprStack` does not need it.
|
|
511
|
+
- **`PreprStack`** renders every variant of each segment group, and each item with groups nested inside it once per combination of their variants, in `display: contents` wrappers that stay hidden until the view's decision marks one to show. A group with no default variant still gets its place.
|
|
512
|
+
- **A/B variants** are both in the HTML and picked by the same decision as segment groups. A hidden variant never becomes visible, so the tracking pixel records no impression for it.
|
|
417
513
|
- **`hasAbTests`** sends your query without its leaf fields and no visitor headers, so Prepr's CDN caches it, and reads the answer from the `Vary` header. Answers are reused for a minute.
|
|
418
|
-
- **Tags carry over.** The client's `'prepr'` fetch tag applies to the `'use cache'` entry, so `revalidateTag('prepr')` refreshes it without a `cacheTag()` call.
|
|
514
|
+
- **Tags carry over.** The client's `'prepr'` fetch tag applies to the `'use cache'` entry, so `revalidateTag('prepr')` refreshes it without a `cacheTag()` call. `tagItems` adds a tag per item; see [Refreshing on publish](#refreshing-on-publish).
|
|
419
515
|
- **Outside `'use cache'`, set `revalidate`** on the client (`false` or seconds). Otherwise a query outside both `'use cache'` and `<Suspense>` fails the build with "uncached data was accessed outside of `<Suspense>`".
|
|
420
516
|
|
|
421
517
|
</details>
|
|
@@ -473,7 +569,7 @@ const decisions = decisionQuery
|
|
|
473
569
|
const content = resolveVariants(page.Page.content, { decisions, bucket });
|
|
474
570
|
```
|
|
475
571
|
|
|
476
|
-
`visitorPreprHeaders` are the headers the middleware forwards (`getPreprHeaders()` in Next.js); `bucket` is the visitor's A/B bucket
|
|
572
|
+
`visitorPreprHeaders` are the headers the middleware forwards (`getPreprHeaders()` in Next.js); `bucket` is the visitor's A/B bucket, when you know it; leave it out and A/B tests use the decision too (see below).
|
|
477
573
|
|
|
478
574
|
With Apollo, parse the strings once and use them like any document:
|
|
479
575
|
|
|
@@ -518,7 +614,7 @@ import {
|
|
|
518
614
|
|
|
519
615
|
Only named queries with personalizable fields get exports; two queries with the same name fail the run. The generated decision query asks about every personalizable list. When you have the variants result at hand, `toDecisionQuery(query, schema, { from })` is smaller and markedly faster for visitors Prepr has not seen before.
|
|
520
616
|
|
|
521
|
-
A
|
|
617
|
+
A decision query without `from` also returns the visitor's variant of each A/B test, per experiment and its split; `from` limits it to the lists with segment groups. Otherwise pass a known bucket to `resolveVariants`.
|
|
522
618
|
|
|
523
619
|
### Image loader
|
|
524
620
|
|
|
@@ -915,7 +1011,7 @@ Each wrapper exposes the same five helpers, differing only in how they reach the
|
|
|
915
1011
|
| `cacheTags` (Next.js) | `['prepr']` | Tags on cacheable fetches. |
|
|
916
1012
|
| `revalidate` (Next.js) | unset | `next.revalidate` on cacheable fetches. |
|
|
917
1013
|
|
|
918
|
-
`query(document, variables?, options?)` takes a query string, a `TypedDocumentString` or a `DocumentNode`, and resolves to `{ data, personalized }`. Per-query options: `headers`, `init`, `fetch`, plus `personalize` (framework versions) and `event` (Nuxt, SvelteKit, Astro). A request carrying per-query headers or `init.headers` is `no-store`; client-level `headers` are not.
|
|
1014
|
+
`query(document, variables?, options?)` takes a query string, a `TypedDocumentString` or a `DocumentNode`, and resolves to `{ data, personalized }`. Per-query options: `headers`, `init`, `fetch`, plus `personalize` (framework versions), `tagItems` (Next.js 16, inside `'use cache'`) and `event` (Nuxt, SvelteKit, Astro). A request carrying per-query headers or `init.headers` is `no-store`; client-level `headers` are not.
|
|
919
1015
|
|
|
920
1016
|
Import `PreprError` from the same subpath as the client when checking `instanceof`: CommonJS entry points do not share classes. `error.code` works from anywhere.
|
|
921
1017
|
|
|
@@ -929,6 +1025,8 @@ For your own GraphQL client (see [Using your own GraphQL client](#using-your-own
|
|
|
929
1025
|
| `toVariantsQuery(query, schema)` | The variants query as a string: what `variants: true` sends. |
|
|
930
1026
|
| `toDecisionQuery(query, schema, { from?, topLevelOnly? })` | The per-visitor decision query as a string, or `null` when there is nothing to decide. |
|
|
931
1027
|
| `decisionsFrom(data)` | Decisions from a decision query's `data`, for `resolveVariants` or `PreprStack`. |
|
|
1028
|
+
| `preprCacheTags(data)` | `prepr:<_id>` for every item in a result, or `['prepr:any']` past 128 items. |
|
|
1029
|
+
| `preprModelTag(model)` | `prepr:model:<model>`, for list pages. |
|
|
932
1030
|
|
|
933
1031
|
`@preprio/toolkit/graphql/codegen` is a graphql-codegen plugin that writes `<Name>VariantsQuery` and `<Name>DecisionQuery` for every named query with personalizable fields.
|
|
934
1032
|
|
|
@@ -954,6 +1052,10 @@ The toolbar. Takes the props returned by `getToolbarProps`, and renders nothing
|
|
|
954
1052
|
<PreprToolbar {...toolbarProps} />
|
|
955
1053
|
```
|
|
956
1054
|
|
|
1055
|
+
#### `PreprPersonalizationSwitch`
|
|
1056
|
+
|
|
1057
|
+
From `@preprio/toolkit/nextjs`. Gets each client-side navigation's decision in before the click: when the visitor hovers over a same-site link (or presses it, on touch devices), it asks `createPreprProxy` for the target page's decision. A click whose decision is not in yet waits for it (see [How each visitor gets their variant](#how-each-visitor-gets-their-variant)). Mount it once in the root layout, so links on pages without a `PreprStack` are covered too. Links to other sites and links that open a new tab or download are left alone. The decision requests are answered by `createPreprProxy` only. Takes no props and renders nothing.
|
|
1058
|
+
|
|
957
1059
|
#### `PreprPreview`
|
|
958
1060
|
|
|
959
1061
|
From `@preprio/toolkit/react`. The framework-free equivalent of `PreprToolbar`: same preview runtime, with the router binding left to you. Renders nothing.
|
package/dist/astro/graphql.cjs
CHANGED
|
@@ -178,7 +178,10 @@ function decisionSelectionSet(set, typeName, path, ctx) {
|
|
|
178
178
|
path,
|
|
179
179
|
ctx
|
|
180
180
|
);
|
|
181
|
-
if (pruned)
|
|
181
|
+
if (!pruned) continue;
|
|
182
|
+
if (fragment.typeCondition.name.value === typeName) {
|
|
183
|
+
selections.push(...pruned.selections);
|
|
184
|
+
} else {
|
|
182
185
|
selections.push(
|
|
183
186
|
inlineFragment(fragment.typeCondition.name.value, [
|
|
184
187
|
...pruned.selections
|
|
@@ -188,13 +191,19 @@ function decisionSelectionSet(set, typeName, path, ctx) {
|
|
|
188
191
|
continue;
|
|
189
192
|
}
|
|
190
193
|
if (selection.kind === "InlineFragment") {
|
|
194
|
+
const condition = selection.typeCondition?.name.value ?? typeName;
|
|
191
195
|
const pruned = decisionSelectionSet(
|
|
192
196
|
selection.selectionSet,
|
|
193
|
-
|
|
197
|
+
condition,
|
|
194
198
|
path,
|
|
195
199
|
ctx
|
|
196
200
|
);
|
|
197
|
-
if (pruned)
|
|
201
|
+
if (!pruned) continue;
|
|
202
|
+
if (condition === typeName && !selection.directives?.length) {
|
|
203
|
+
selections.push(...pruned.selections);
|
|
204
|
+
} else {
|
|
205
|
+
selections.push({ ...selection, selectionSet: pruned });
|
|
206
|
+
}
|
|
198
207
|
continue;
|
|
199
208
|
}
|
|
200
209
|
if (!selection.selectionSet) continue;
|
|
@@ -268,20 +277,58 @@ function toDecisionDocument(document2, schema, groupPaths, options) {
|
|
|
268
277
|
const used = /* @__PURE__ */ new Set();
|
|
269
278
|
collectVariables(pruned, used);
|
|
270
279
|
collectVariables(operation.directives, used);
|
|
280
|
+
const shared = sharedContextFragment(schema);
|
|
271
281
|
return {
|
|
272
282
|
kind: "Document",
|
|
273
283
|
definitions: [
|
|
284
|
+
...shared ? [shared.definition] : [],
|
|
274
285
|
{
|
|
275
286
|
...operation,
|
|
276
287
|
name: operation.name && name(`${operation.name.value}Decision`),
|
|
277
288
|
variableDefinitions: (operation.variableDefinitions ?? []).filter(
|
|
278
289
|
(definition) => used.has(definition.variable.name.value)
|
|
279
290
|
),
|
|
280
|
-
selectionSet: pruned
|
|
291
|
+
selectionSet: shared ? shared.apply(pruned) : pruned
|
|
281
292
|
}
|
|
282
293
|
]
|
|
283
294
|
};
|
|
284
295
|
}
|
|
296
|
+
function sharedContextFragment(schema) {
|
|
297
|
+
const contextTypes = new Set(
|
|
298
|
+
Object.values(schema.types).flatMap(
|
|
299
|
+
(type) => type.hasContext ? [type.fields._context.type] : []
|
|
300
|
+
)
|
|
301
|
+
);
|
|
302
|
+
if (contextTypes.size !== 1) return null;
|
|
303
|
+
const [contextType] = contextTypes;
|
|
304
|
+
const fragmentName = "c";
|
|
305
|
+
const definition = {
|
|
306
|
+
kind: "FragmentDefinition",
|
|
307
|
+
name: name(fragmentName),
|
|
308
|
+
typeCondition: { kind: "NamedType", name: name(contextType) },
|
|
309
|
+
selectionSet: selectionSet(
|
|
310
|
+
["kind", "group_id", "variant_id"].map(
|
|
311
|
+
(field) => ({ kind: "Field", name: name(field) })
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
};
|
|
315
|
+
const spread = {
|
|
316
|
+
kind: "FragmentSpread",
|
|
317
|
+
name: name(fragmentName)
|
|
318
|
+
};
|
|
319
|
+
const apply = (set) => selectionSet(
|
|
320
|
+
set.selections.map((selection) => {
|
|
321
|
+
if (selection.kind === "Field" && selection.alias?.value === PREPR_CONTEXT_ALIAS) {
|
|
322
|
+
return { ...selection, selectionSet: selectionSet([spread]) };
|
|
323
|
+
}
|
|
324
|
+
return "selectionSet" in selection && selection.selectionSet ? {
|
|
325
|
+
...selection,
|
|
326
|
+
selectionSet: apply(selection.selectionSet)
|
|
327
|
+
} : selection;
|
|
328
|
+
})
|
|
329
|
+
);
|
|
330
|
+
return { definition, apply };
|
|
331
|
+
}
|
|
285
332
|
|
|
286
333
|
// src/core/variants/probe-document.ts
|
|
287
334
|
function probeSelectionSet(set, fragments) {
|
|
@@ -862,6 +909,7 @@ function createVaryProbe(run, options) {
|
|
|
862
909
|
}
|
|
863
910
|
|
|
864
911
|
// src/core/graphql/client.ts
|
|
912
|
+
var clientSchemas = /* @__PURE__ */ new WeakMap();
|
|
865
913
|
function isParseableUrl(url) {
|
|
866
914
|
try {
|
|
867
915
|
new URL(url);
|
|
@@ -898,7 +946,7 @@ function createPreprClient(options) {
|
|
|
898
946
|
));
|
|
899
947
|
const variantsDocuments = /* @__PURE__ */ new Map();
|
|
900
948
|
const decisionDocuments = /* @__PURE__ */ new Map();
|
|
901
|
-
|
|
949
|
+
const client = {
|
|
902
950
|
async query(document2, variables, queryOptions = {}) {
|
|
903
951
|
const { variants, ...rest } = queryOptions;
|
|
904
952
|
if (!variants) return run(document2, variables, rest);
|
|
@@ -910,10 +958,10 @@ function createPreprClient(options) {
|
|
|
910
958
|
return run(derived, variables, rest);
|
|
911
959
|
},
|
|
912
960
|
async decide(document2, variables, queryOptions = {}) {
|
|
913
|
-
const { from,
|
|
961
|
+
const { from, ...rest } = queryOptions;
|
|
914
962
|
const paths = from === void 0 ? void 0 : personalizationPaths(from);
|
|
915
963
|
if (paths && paths.size === 0) return /* @__PURE__ */ new Map();
|
|
916
|
-
const signature = paths ? [...paths].map(([path, types]) => `${path}:${[...types].sort().join(",")}`).sort().join("|") :
|
|
964
|
+
const signature = paths ? [...paths].map(([path, types]) => `${path}:${[...types].sort().join(",")}`).sort().join("|") : "*";
|
|
917
965
|
let byPaths = decisionDocuments.get(document2);
|
|
918
966
|
if (!byPaths) {
|
|
919
967
|
byPaths = /* @__PURE__ */ new Map();
|
|
@@ -921,9 +969,7 @@ function createPreprClient(options) {
|
|
|
921
969
|
}
|
|
922
970
|
let derived = byPaths.get(signature);
|
|
923
971
|
if (derived === void 0) {
|
|
924
|
-
derived = toDecisionDocument(document2, await loadSchema(), paths
|
|
925
|
-
topLevelOnly: !paths && topLevelOnly
|
|
926
|
-
});
|
|
972
|
+
derived = toDecisionDocument(document2, await loadSchema(), paths);
|
|
927
973
|
byPaths.set(signature, derived);
|
|
928
974
|
}
|
|
929
975
|
if (!derived) return /* @__PURE__ */ new Map();
|
|
@@ -937,6 +983,8 @@ function createPreprClient(options) {
|
|
|
937
983
|
(vary) => vary === null || SEGMENTS_VARY.test(vary)
|
|
938
984
|
)
|
|
939
985
|
};
|
|
986
|
+
clientSchemas.set(client, loadSchema);
|
|
987
|
+
return client;
|
|
940
988
|
}
|
|
941
989
|
|
|
942
990
|
// src/core/graphql/event-client.ts
|
package/dist/astro/graphql.d.cts
CHANGED
|
@@ -95,39 +95,6 @@ interface PreprVariantProps {
|
|
|
95
95
|
*/
|
|
96
96
|
declare function preprVariantProps(item: unknown): PreprVariantProps;
|
|
97
97
|
|
|
98
|
-
/**
|
|
99
|
-
* The schema facts the query rewrites need, from `loadPreprSchema`. Pass it
|
|
100
|
-
* along; its shape is not part of the public API.
|
|
101
|
-
*/
|
|
102
|
-
type PreprSchema = PreprSchemaInfo;
|
|
103
|
-
/**
|
|
104
|
-
* Your query, asking for every personalization and A/B variant: what
|
|
105
|
-
* `query(..., { variants: true })` sends, as a string for your own GraphQL
|
|
106
|
-
* client. The result is the same for every visitor, so cache it like any
|
|
107
|
-
* other content query, and render it with `resolveVariants` or `PreprStack`.
|
|
108
|
-
*
|
|
109
|
-
* A query without personalizable fields comes back unchanged (minified).
|
|
110
|
-
*/
|
|
111
|
-
declare function toVariantsQuery(document: PreprDocument<unknown, unknown> | string, schema: PreprSchema): string;
|
|
112
|
-
/**
|
|
113
|
-
* The per-visitor decision query for the same query: send it with the
|
|
114
|
-
* visitor's Prepr headers, never cached, and pass the response's `data` to
|
|
115
|
-
* `decisionsFrom`.
|
|
116
|
-
*
|
|
117
|
-
* Pass the variants result as `from` whenever you have it: the query then
|
|
118
|
-
* asks only about the lists that hold segment groups on this page, which is
|
|
119
|
-
* a few hundred bytes instead of the whole page and markedly faster for a
|
|
120
|
-
* visitor Prepr has not seen before. Without `from` it asks about every
|
|
121
|
-
* personalizable list. `topLevelOnly` stops at the first personalizable list
|
|
122
|
-
* on each path, for middleware that decides only the top of a stack.
|
|
123
|
-
*
|
|
124
|
-
* Null when there is nothing to decide: send no request then.
|
|
125
|
-
*/
|
|
126
|
-
declare function toDecisionQuery(document: PreprDocument<unknown, unknown> | string, schema: PreprSchema, options?: {
|
|
127
|
-
from?: unknown;
|
|
128
|
-
topLevelOnly?: boolean;
|
|
129
|
-
}): string | null;
|
|
130
|
-
|
|
131
98
|
/**
|
|
132
99
|
* A GraphQL document accepted by `query`: a plain string, graphql-codegen's
|
|
133
100
|
* `TypedDocumentString` (`documentMode: 'string'`), or any `DocumentNode`,
|
|
@@ -221,11 +188,6 @@ interface PreprDecideOptions extends Omit<PreprQueryOptions, 'variants'> {
|
|
|
221
188
|
* none, e.g. on pages with only A/B tests or no personalization.
|
|
222
189
|
*/
|
|
223
190
|
from?: unknown;
|
|
224
|
-
/**
|
|
225
|
-
* @internal Ask only about the first personalizable list on each path,
|
|
226
|
-
* without `from`: what middleware needs for groups at the top of a stack.
|
|
227
|
-
*/
|
|
228
|
-
topLevelOnly?: boolean;
|
|
229
191
|
}
|
|
230
192
|
interface PreprQueryResult<TResult> {
|
|
231
193
|
data: TResult;
|
|
@@ -236,6 +198,39 @@ interface PreprQueryResult<TResult> {
|
|
|
236
198
|
personalized: boolean;
|
|
237
199
|
}
|
|
238
200
|
|
|
201
|
+
/**
|
|
202
|
+
* The schema facts the query rewrites need, from `loadPreprSchema`. Pass it
|
|
203
|
+
* along; its shape is not part of the public API.
|
|
204
|
+
*/
|
|
205
|
+
type PreprSchema = PreprSchemaInfo;
|
|
206
|
+
/**
|
|
207
|
+
* Your query, asking for every personalization and A/B variant: what
|
|
208
|
+
* `query(..., { variants: true })` sends, as a string for your own GraphQL
|
|
209
|
+
* client. The result is the same for every visitor, so cache it like any
|
|
210
|
+
* other content query, and render it with `resolveVariants` or `PreprStack`.
|
|
211
|
+
*
|
|
212
|
+
* A query without personalizable fields comes back unchanged (minified).
|
|
213
|
+
*/
|
|
214
|
+
declare function toVariantsQuery(document: PreprDocument<unknown, unknown> | string, schema: PreprSchema): string;
|
|
215
|
+
/**
|
|
216
|
+
* The per-visitor decision query for the same query: send it with the
|
|
217
|
+
* visitor's Prepr headers, never cached, and pass the response's `data` to
|
|
218
|
+
* `decisionsFrom`.
|
|
219
|
+
*
|
|
220
|
+
* Pass the variants result as `from` whenever you have it: the query then
|
|
221
|
+
* asks only about the lists that hold segment groups on this page, which is
|
|
222
|
+
* a few hundred bytes instead of the whole page and markedly faster for a
|
|
223
|
+
* visitor Prepr has not seen before. Without `from` it asks about every
|
|
224
|
+
* personalizable list. `topLevelOnly` stops at the first personalizable list
|
|
225
|
+
* on each path, for middleware that decides only the top of a stack.
|
|
226
|
+
*
|
|
227
|
+
* Null when there is nothing to decide: send no request then.
|
|
228
|
+
*/
|
|
229
|
+
declare function toDecisionQuery(document: PreprDocument<unknown, unknown> | string, schema: PreprSchema, options?: {
|
|
230
|
+
from?: unknown;
|
|
231
|
+
topLevelOnly?: boolean;
|
|
232
|
+
}): string | null;
|
|
233
|
+
|
|
239
234
|
/**
|
|
240
235
|
* Query options for frameworks without an implicit request scope.
|
|
241
236
|
* `personalize: true` requires the request `event`; the compiler enforces it.
|