@preprio/toolkit 0.6.0-alpha.6 → 0.6.0-alpha.8

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.
Files changed (52) hide show
  1. package/README.md +138 -52
  2. package/dist/astro/{chunk-MWRA3732.js → chunk-R2YPGDVU.js} +1 -1
  3. package/dist/astro/graphql.cjs +58 -10
  4. package/dist/astro/graphql.d.cts +33 -38
  5. package/dist/astro/graphql.d.ts +33 -38
  6. package/dist/astro/graphql.js +59 -11
  7. package/dist/astro/index.cjs +1 -1
  8. package/dist/astro/index.js +1 -1
  9. package/dist/{chunk-Y7DWVOOY.js → chunk-2QGUP7VQ.js} +1 -1
  10. package/dist/{chunk-NSQOGI5K.js → chunk-ATPB7GEC.js} +1 -1
  11. package/dist/{chunk-W6AVX6FI.js → chunk-OY7OKO6Q.js} +52 -5
  12. package/dist/{chunk-OQGEL4TV.js → chunk-T7ESBVBI.js} +1 -1
  13. package/dist/graphql/codegen.cjs +51 -4
  14. package/dist/graphql/codegen.js +3 -3
  15. package/dist/graphql.cjs +89 -10
  16. package/dist/graphql.d.cts +69 -39
  17. package/dist/graphql.d.ts +69 -39
  18. package/dist/graphql.js +38 -9
  19. package/dist/index.cjs +1 -1
  20. package/dist/index.d.cts +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +3 -3
  23. package/dist/nextjs/{chunk-PIQF5ZI5.js → chunk-O2SBSBDG.js} +58 -53
  24. package/dist/nextjs/components.cjs +205 -236
  25. package/dist/nextjs/components.d.cts +27 -22
  26. package/dist/nextjs/components.d.ts +27 -22
  27. package/dist/nextjs/components.js +198 -231
  28. package/dist/nextjs/graphql.cjs +418 -508
  29. package/dist/nextjs/graphql.d.cts +131 -135
  30. package/dist/nextjs/graphql.d.ts +131 -135
  31. package/dist/nextjs/graphql.js +353 -455
  32. package/dist/nextjs/index.cjs +114 -54
  33. package/dist/nextjs/index.d.cts +59 -33
  34. package/dist/nextjs/index.d.ts +59 -33
  35. package/dist/nextjs/index.js +108 -5
  36. package/dist/nuxt/{chunk-PF6GVWHV.js → chunk-SX3MSVPK.js} +1 -1
  37. package/dist/nuxt/graphql.cjs +58 -10
  38. package/dist/nuxt/graphql.d.cts +33 -38
  39. package/dist/nuxt/graphql.d.ts +33 -38
  40. package/dist/nuxt/graphql.js +59 -11
  41. package/dist/nuxt/index.cjs +1 -1
  42. package/dist/nuxt/index.js +1 -1
  43. package/dist/react/components.cjs +1 -1
  44. package/dist/react/components.js +1 -1
  45. package/dist/sveltekit/{chunk-5K7HWMIF.js → chunk-UOASSHZO.js} +1 -1
  46. package/dist/sveltekit/graphql.cjs +58 -10
  47. package/dist/sveltekit/graphql.d.cts +33 -38
  48. package/dist/sveltekit/graphql.d.ts +33 -38
  49. package/dist/sveltekit/graphql.js +59 -11
  50. package/dist/sveltekit/index.cjs +1 -1
  51. package/dist/sveltekit/index.js +1 -1
  52. package/package.json +1 -1
@@ -20,13 +20,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/nextjs/index.ts
21
21
  var nextjs_exports = {};
22
22
  __export(nextjs_exports, {
23
- PREPR_BUCKET_COOKIE: () => PREPR_BUCKET_COOKIE,
24
23
  PreprError: () => PreprError,
25
24
  PreprPersonalizationSwitch: () => import_components.PreprPersonalizationSwitch,
26
25
  PreprToolbar: () => import_components.PreprToolbar,
27
26
  PreprTrackingPixel: () => import_components.PreprTrackingPixel,
28
27
  createPreprDraftRoute: () => createPreprDraftRoute,
29
28
  createPreprMiddleware: () => createPreprMiddleware,
29
+ createPreprWebhookRoute: () => createPreprWebhookRoute,
30
30
  extractAccessToken: () => extractAccessToken,
31
31
  getActiveSegment: () => getActiveSegment,
32
32
  getActiveVariant: () => getActiveVariant,
@@ -35,7 +35,6 @@ __export(nextjs_exports, {
35
35
  getToolbarProps: () => getToolbarProps,
36
36
  isPersonalizedResponse: () => isPersonalizedResponse,
37
37
  preprDraftRedirect: () => preprDraftRedirect,
38
- resolvePreprBucket: () => resolvePreprBucket,
39
38
  validatePreprToken: () => validatePreprToken
40
39
  });
41
40
  module.exports = __toCommonJS(nextjs_exports);
@@ -44,7 +43,7 @@ module.exports = __toCommonJS(nextjs_exports);
44
43
  var import_server = require("next/server");
45
44
 
46
45
  // src/version.ts
47
- var VERSION = "0.6.0-alpha.6";
46
+ var VERSION = "0.6.0-alpha.8";
48
47
 
49
48
  // src/core/constants.ts
50
49
  var COOKIE_PREVIEW_MODE = "Prepr-Preview-Mode";
@@ -470,55 +469,6 @@ function isPersonalizedResponse(source) {
470
469
  return PERSONALIZED_VARY.test(headers2.get("vary") ?? "");
471
470
  }
472
471
 
473
- // src/nextjs/bucket.ts
474
- var PREPR_BUCKET_COOKIE = "prepr_ab_bucket";
475
- var isBucket = (value) => value === "A" || value === "B";
476
- function setBucketCookie(request, response, bucket) {
477
- response.cookies.set(PREPR_BUCKET_COOKIE, bucket, {
478
- path: "/",
479
- sameSite: "lax",
480
- // Readable on purpose: PreprStack's inline switch reads it before first
481
- // paint. It holds only "A" or "B".
482
- httpOnly: false,
483
- secure: request.nextUrl.protocol === "https:",
484
- maxAge: 60 * 60 * 24 * 365
485
- });
486
- }
487
- async function resolvePreprBucket(request, response, options) {
488
- const stored = request.cookies.get(PREPR_BUCKET_COOKIE)?.value;
489
- const forced = response.headers.get("x-middleware-request-prepr-abtesting") ?? request.cookies.get(COOKIE_VARIANT)?.value;
490
- if (options.preview && isBucket(forced)) {
491
- if (forced !== stored) setBucketCookie(request, response, forced);
492
- return forced;
493
- }
494
- if (isBucket(stored)) return stored;
495
- if (options.onlyIf) {
496
- const needed = await Promise.resolve().then(options.onlyIf).catch(() => true);
497
- if (!needed) return "A";
498
- }
499
- const customerId = request.cookies.get(COOKIE_UID)?.value ?? response.cookies.get(COOKIE_UID)?.value;
500
- if (!customerId) return "A";
501
- try {
502
- const res = await fetch(options.url, {
503
- method: "POST",
504
- headers: {
505
- "Content-Type": "application/json",
506
- "Prepr-Customer-Id": customerId
507
- },
508
- body: JSON.stringify({ query: "{ __typename }" }),
509
- cache: "no-store",
510
- signal: AbortSignal.timeout(options.timeoutMs ?? 1500)
511
- });
512
- const bucket = res.headers.get("x-prepr-customer-bucket");
513
- if (isBucket(bucket)) {
514
- setBucketCookie(request, response, bucket);
515
- return bucket;
516
- }
517
- } catch {
518
- }
519
- return "A";
520
- }
521
-
522
472
  // src/nextjs/draft.ts
523
473
  var import_headers2 = require("next/headers");
524
474
  var import_navigation = require("next/navigation");
@@ -569,17 +519,128 @@ function createPreprDraftRoute(options) {
569
519
  };
570
520
  }
571
521
 
522
+ // src/nextjs/webhook.ts
523
+ var import_cache = require("next/cache");
524
+
525
+ // src/core/graphql/cache-tags.ts
526
+ var PREPR_ANY_ITEM_TAG = "prepr:any";
527
+ var PREPR_SCHEMA_TAG = "prepr:schema";
528
+ var preprItemTag = (id) => `prepr:${id}`;
529
+ var NOT_ALPHANUMERIC = /[^a-z0-9]/g;
530
+ var preprModelTag = (model) => `prepr:model:${model.toLowerCase().replace(NOT_ALPHANUMERIC, "")}`;
531
+
532
+ // src/nextjs/webhook.ts
533
+ var CONTENT_EVENTS = /* @__PURE__ */ new Set([
534
+ "content_item.published",
535
+ "content_item.unpublished",
536
+ "content_item.deleted",
537
+ "content_item.invalidated",
538
+ "asset.changed",
539
+ "asset.deleted"
540
+ ]);
541
+ var MODEL_EVENTS = /* @__PURE__ */ new Set([
542
+ "content_item.published",
543
+ "content_item.unpublished",
544
+ "content_item.deleted"
545
+ ]);
546
+ var MAX_AGE_MS = 5 * 60 * 1e3;
547
+ var HEX_SIGNATURE = /^[0-9a-f]{64}$/i;
548
+ var BASE64_SIGNATURE = /^[A-Za-z0-9+/]{43}=$/;
549
+ var SIGNATURE_PREFIX = /^sha256=/i;
550
+ var MAX_ID_LENGTH = 200;
551
+ var text = (body, status) => new Response(body, { status, headers: { "Content-Type": "text/plain" } });
552
+ function signatureBytes(header) {
553
+ const value = header.trim().replace(SIGNATURE_PREFIX, "");
554
+ if (HEX_SIGNATURE.test(value)) {
555
+ const bytes = new Uint8Array(32);
556
+ for (let i = 0; i < 32; i++) {
557
+ bytes[i] = parseInt(value.slice(i * 2, i * 2 + 2), 16);
558
+ }
559
+ return bytes;
560
+ }
561
+ if (BASE64_SIGNATURE.test(value)) {
562
+ return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
563
+ }
564
+ return null;
565
+ }
566
+ async function verify(secret, signature, body) {
567
+ const key = await crypto.subtle.importKey(
568
+ "raw",
569
+ new TextEncoder().encode(secret),
570
+ { name: "HMAC", hash: "SHA-256" },
571
+ false,
572
+ ["verify"]
573
+ );
574
+ return crypto.subtle.verify("HMAC", key, signature, body);
575
+ }
576
+ function parse(body) {
577
+ try {
578
+ const value = JSON.parse(new TextDecoder().decode(body));
579
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
580
+ } catch {
581
+ return null;
582
+ }
583
+ }
584
+ function eventTime(value) {
585
+ if (typeof value === "number") return value * 1e3;
586
+ if (typeof value === "string") return Date.parse(value);
587
+ return NaN;
588
+ }
589
+ function field(value, key) {
590
+ return typeof value === "object" && value !== null ? value[key] : void 0;
591
+ }
592
+ var usable = (value) => typeof value === "string" && value !== "" && value.length <= MAX_ID_LENGTH;
593
+ function tagsFor(event) {
594
+ const name = event.event;
595
+ if (name === "schema.changed") return [PREPR_SCHEMA_TAG];
596
+ if (typeof name !== "string" || !CONTENT_EVENTS.has(name)) return [];
597
+ const tags = [PREPR_ANY_ITEM_TAG];
598
+ const id = field(event.payload, "id");
599
+ if (usable(id)) tags.push(preprItemTag(id));
600
+ const model = field(field(event.payload, "model"), "body_singular");
601
+ if (MODEL_EVENTS.has(name) && usable(model)) tags.push(preprModelTag(model));
602
+ return tags;
603
+ }
604
+ function createPreprWebhookRoute(options) {
605
+ const { profile = { expire: 0 } } = options;
606
+ return async (request) => {
607
+ const { secret } = options;
608
+ if (!secret) return text("Webhook secret not configured", 500);
609
+ const signature = signatureBytes(
610
+ request.headers.get("prepr-signature") ?? ""
611
+ );
612
+ let body;
613
+ try {
614
+ body = await request.arrayBuffer();
615
+ } catch {
616
+ return text("Unreadable body", 400);
617
+ }
618
+ if (!signature || !await verify(secret, signature, body)) {
619
+ return text("Invalid signature", 401);
620
+ }
621
+ const event = parse(body);
622
+ if (!event) return text("Invalid body", 400);
623
+ const sentAt = eventTime(event.created_on);
624
+ if (Number.isFinite(sentAt) && Math.abs(Date.now() - sentAt) > MAX_AGE_MS) {
625
+ return text("Expired", 401);
626
+ }
627
+ const tags = tagsFor(event);
628
+ for (const tag of tags) (0, import_cache.revalidateTag)(tag, profile);
629
+ return Response.json({ revalidated: tags });
630
+ };
631
+ }
632
+
572
633
  // src/nextjs/index.ts
573
634
  var import_components = require("./components.cjs");
574
635
  // Annotate the CommonJS export names for ESM import in node:
575
636
  0 && (module.exports = {
576
- PREPR_BUCKET_COOKIE,
577
637
  PreprError,
578
638
  PreprPersonalizationSwitch,
579
639
  PreprToolbar,
580
640
  PreprTrackingPixel,
581
641
  createPreprDraftRoute,
582
642
  createPreprMiddleware,
643
+ createPreprWebhookRoute,
583
644
  extractAccessToken,
584
645
  getActiveSegment,
585
646
  getActiveVariant,
@@ -588,6 +649,5 @@ var import_components = require("./components.cjs");
588
649
  getToolbarProps,
589
650
  isPersonalizedResponse,
590
651
  preprDraftRedirect,
591
- resolvePreprBucket,
592
652
  validatePreprToken
593
653
  });
@@ -1,7 +1,6 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { a as PreprFeatures, P as PreprHeaders, c as PreprToolbarProps, d as PreprToolbarComponentProps } from './server-BL-sqZvu.cjs';
3
3
  export { b as PreprError, e as extractAccessToken, i as isPersonalizedResponse, v as validatePreprToken } from './server-BL-sqZvu.cjs';
4
- import { ReactElement } from 'react';
5
4
 
6
5
  interface PreprMiddlewareOptions {
7
6
  /** Enable preview-mode headers (segment/AB overrides, preview bar). */
@@ -45,26 +44,6 @@ declare function getPreprHeaders(): Promise<PreprHeaders>;
45
44
  */
46
45
  declare function getToolbarProps(token: string, features?: PreprFeatures): Promise<PreprToolbarProps>;
47
46
 
48
- /** Cookie holding the visitor's A/B bucket, as Prepr assigned it. */
49
- declare const PREPR_BUCKET_COOKIE = "prepr_ab_bucket";
50
- type PreprBucket = 'A' | 'B';
51
- interface ResolvePreprBucketOptions {
52
- /** Full Prepr GraphQL URL. */
53
- url: string;
54
- /** Preview mode: the toolbar's A/B override wins. */
55
- preview?: boolean;
56
- /** Abort the one-time lookup after this many ms. Default 1500. */
57
- timeoutMs?: number;
58
- /**
59
- * Look the bucket up only when this resolves to true, typically "does this
60
- * page have an A/B test": `() => prepr.hasAbTests(PageDocument, { slug })`.
61
- * Only called for visitors without a bucket cookie, so visitors on pages
62
- * without A/B tests never wait for the lookup.
63
- */
64
- onlyIf?: () => boolean | Promise<boolean>;
65
- }
66
- declare function resolvePreprBucket(request: NextRequest, response: NextResponse, options: ResolvePreprBucketOptions): Promise<PreprBucket>;
67
-
68
47
  interface PreprDraftOptions {
69
48
  /** Whether this deployment serves preview content. Outside it, both helpers do nothing. */
70
49
  preview: boolean;
@@ -101,6 +80,47 @@ declare function preprDraftRedirect(request: NextRequest, options: PreprDraftOpt
101
80
  */
102
81
  declare function createPreprDraftRoute(options: Pick<PreprDraftOptions, 'preview'>): (request: Request) => Promise<Response>;
103
82
 
83
+ /** How a revalidated page refreshes, as Next.js's `revalidateTag` takes it. */
84
+ type PreprRevalidateProfile = string | {
85
+ expire?: number;
86
+ };
87
+ interface PreprWebhookOptions {
88
+ /**
89
+ * The webhook's ID, shown in Prepr under Settings → Webhooks. Prepr signs
90
+ * each request with it (HMAC-SHA256 of the body, in the `Prepr-Signature`
91
+ * header). Keep it in an environment variable.
92
+ */
93
+ secret: string | undefined;
94
+ /**
95
+ * Passed to `revalidateTag`. The default `{ expire: 0 }` shows a publish
96
+ * on the very next request; `'max'` serves the old page once more while
97
+ * the new one renders in the background.
98
+ */
99
+ profile?: PreprRevalidateProfile;
100
+ }
101
+ /**
102
+ * Route handler for a Prepr webhook that refreshes only the cached pages
103
+ * showing the changed item: those tagged by `query(..., { tagItems: true })`
104
+ * or `preprCacheTags`. On publish, unpublish and delete it also revalidates
105
+ * the item's `preprModelTag`, so list pages and lookups that found nothing
106
+ * pick up new and removed items. `schema.changed` refreshes the schema the
107
+ * client rewrites queries with.
108
+ *
109
+ * Other events (draft edits, new assets, visitors, segments) are
110
+ * acknowledged and revalidate nothing, so the webhook can subscribe to
111
+ * everything.
112
+ *
113
+ * Requests without a valid `Prepr-Signature` or older than five minutes get
114
+ * 401; without `secret` every request gets 500.
115
+ *
116
+ * @example
117
+ * // app/api/prepr/webhook/route.ts
118
+ * export const POST = createPreprWebhookRoute({
119
+ * secret: process.env.PREPR_WEBHOOK_SECRET,
120
+ * });
121
+ */
122
+ declare function createPreprWebhookRoute(options: PreprWebhookOptions): (request: Request) => Promise<Response>;
123
+
104
124
  /**
105
125
  * Loader + typed facade for Prepr's CDN tracking pixel, so consumers don't have
106
126
  * to paste a `<script>` tag. The pixel itself lives on the CDN; this only sets
@@ -154,16 +174,22 @@ declare function PreprTrackingPixel({ id, config, }: PreprTrackingPixelProps): n
154
174
  * `createPreprPreview` mounts imperatively.
155
175
  */
156
176
  declare function PreprToolbar({ options, ...props }: PreprToolbarComponentProps): null;
157
- interface PreprPersonalizationSwitchProps {
158
- /**
159
- * Keep segment decisions fresh in the background. Default `true`. Leave it
160
- * on for the instance in your root layout; `PreprStack` renders its own
161
- * with `false`.
162
- */
163
- refresh?: boolean;
164
- /** Same as `createPreprProxy`'s `freshFor`. Default 10 000 ms. */
165
- freshFor?: number;
166
- }
167
- declare function PreprPersonalizationSwitch({ refresh, freshFor, }: PreprPersonalizationSwitchProps): ReactElement | null;
177
+ /**
178
+ * Gets each client-side navigation's decision in before the click: when the
179
+ * visitor hovers over a same-site link (or presses it, on touch devices), it
180
+ * asks `createPreprProxy` which variants they get on that page. Clicks are
181
+ * never held: the new page paints with those variants when the answer is in,
182
+ * and keeps its personalized sections hidden until then otherwise. Pages
183
+ * without personalization answer with no groups and are not asked again for
184
+ * an hour.
185
+ *
186
+ * Mount it once in the root layout, so links on pages without a
187
+ * `PreprStack` are covered too. Links to other sites and links that open a
188
+ * new tab or download are left alone.
189
+ *
190
+ * @example
191
+ * <PreprPersonalizationSwitch />
192
+ */
193
+ declare function PreprPersonalizationSwitch(): null;
168
194
 
169
- export { PREPR_BUCKET_COOKIE, type PreprBucket, type PreprDraftOptions, type PreprMiddlewareOptions, PreprPersonalizationSwitch, type PreprPersonalizationSwitchProps, PreprToolbar, PreprTrackingPixel, type PreprTrackingPixelProps, type ResolvePreprBucketOptions, createPreprDraftRoute, createPreprMiddleware, getActiveSegment, getActiveVariant, getPreprHeaders, getPreprUUID, getToolbarProps, preprDraftRedirect, resolvePreprBucket };
195
+ export { type PreprDraftOptions, type PreprMiddlewareOptions, PreprPersonalizationSwitch, PreprToolbar, PreprTrackingPixel, type PreprTrackingPixelProps, type PreprWebhookOptions, createPreprDraftRoute, createPreprMiddleware, createPreprWebhookRoute, getActiveSegment, getActiveVariant, getPreprHeaders, getPreprUUID, getToolbarProps, preprDraftRedirect };
@@ -1,7 +1,6 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import { a as PreprFeatures, P as PreprHeaders, c as PreprToolbarProps, d as PreprToolbarComponentProps } from './server-BL-sqZvu.js';
3
3
  export { b as PreprError, e as extractAccessToken, i as isPersonalizedResponse, v as validatePreprToken } from './server-BL-sqZvu.js';
4
- import { ReactElement } from 'react';
5
4
 
6
5
  interface PreprMiddlewareOptions {
7
6
  /** Enable preview-mode headers (segment/AB overrides, preview bar). */
@@ -45,26 +44,6 @@ declare function getPreprHeaders(): Promise<PreprHeaders>;
45
44
  */
46
45
  declare function getToolbarProps(token: string, features?: PreprFeatures): Promise<PreprToolbarProps>;
47
46
 
48
- /** Cookie holding the visitor's A/B bucket, as Prepr assigned it. */
49
- declare const PREPR_BUCKET_COOKIE = "prepr_ab_bucket";
50
- type PreprBucket = 'A' | 'B';
51
- interface ResolvePreprBucketOptions {
52
- /** Full Prepr GraphQL URL. */
53
- url: string;
54
- /** Preview mode: the toolbar's A/B override wins. */
55
- preview?: boolean;
56
- /** Abort the one-time lookup after this many ms. Default 1500. */
57
- timeoutMs?: number;
58
- /**
59
- * Look the bucket up only when this resolves to true, typically "does this
60
- * page have an A/B test": `() => prepr.hasAbTests(PageDocument, { slug })`.
61
- * Only called for visitors without a bucket cookie, so visitors on pages
62
- * without A/B tests never wait for the lookup.
63
- */
64
- onlyIf?: () => boolean | Promise<boolean>;
65
- }
66
- declare function resolvePreprBucket(request: NextRequest, response: NextResponse, options: ResolvePreprBucketOptions): Promise<PreprBucket>;
67
-
68
47
  interface PreprDraftOptions {
69
48
  /** Whether this deployment serves preview content. Outside it, both helpers do nothing. */
70
49
  preview: boolean;
@@ -101,6 +80,47 @@ declare function preprDraftRedirect(request: NextRequest, options: PreprDraftOpt
101
80
  */
102
81
  declare function createPreprDraftRoute(options: Pick<PreprDraftOptions, 'preview'>): (request: Request) => Promise<Response>;
103
82
 
83
+ /** How a revalidated page refreshes, as Next.js's `revalidateTag` takes it. */
84
+ type PreprRevalidateProfile = string | {
85
+ expire?: number;
86
+ };
87
+ interface PreprWebhookOptions {
88
+ /**
89
+ * The webhook's ID, shown in Prepr under Settings → Webhooks. Prepr signs
90
+ * each request with it (HMAC-SHA256 of the body, in the `Prepr-Signature`
91
+ * header). Keep it in an environment variable.
92
+ */
93
+ secret: string | undefined;
94
+ /**
95
+ * Passed to `revalidateTag`. The default `{ expire: 0 }` shows a publish
96
+ * on the very next request; `'max'` serves the old page once more while
97
+ * the new one renders in the background.
98
+ */
99
+ profile?: PreprRevalidateProfile;
100
+ }
101
+ /**
102
+ * Route handler for a Prepr webhook that refreshes only the cached pages
103
+ * showing the changed item: those tagged by `query(..., { tagItems: true })`
104
+ * or `preprCacheTags`. On publish, unpublish and delete it also revalidates
105
+ * the item's `preprModelTag`, so list pages and lookups that found nothing
106
+ * pick up new and removed items. `schema.changed` refreshes the schema the
107
+ * client rewrites queries with.
108
+ *
109
+ * Other events (draft edits, new assets, visitors, segments) are
110
+ * acknowledged and revalidate nothing, so the webhook can subscribe to
111
+ * everything.
112
+ *
113
+ * Requests without a valid `Prepr-Signature` or older than five minutes get
114
+ * 401; without `secret` every request gets 500.
115
+ *
116
+ * @example
117
+ * // app/api/prepr/webhook/route.ts
118
+ * export const POST = createPreprWebhookRoute({
119
+ * secret: process.env.PREPR_WEBHOOK_SECRET,
120
+ * });
121
+ */
122
+ declare function createPreprWebhookRoute(options: PreprWebhookOptions): (request: Request) => Promise<Response>;
123
+
104
124
  /**
105
125
  * Loader + typed facade for Prepr's CDN tracking pixel, so consumers don't have
106
126
  * to paste a `<script>` tag. The pixel itself lives on the CDN; this only sets
@@ -154,16 +174,22 @@ declare function PreprTrackingPixel({ id, config, }: PreprTrackingPixelProps): n
154
174
  * `createPreprPreview` mounts imperatively.
155
175
  */
156
176
  declare function PreprToolbar({ options, ...props }: PreprToolbarComponentProps): null;
157
- interface PreprPersonalizationSwitchProps {
158
- /**
159
- * Keep segment decisions fresh in the background. Default `true`. Leave it
160
- * on for the instance in your root layout; `PreprStack` renders its own
161
- * with `false`.
162
- */
163
- refresh?: boolean;
164
- /** Same as `createPreprProxy`'s `freshFor`. Default 10 000 ms. */
165
- freshFor?: number;
166
- }
167
- declare function PreprPersonalizationSwitch({ refresh, freshFor, }: PreprPersonalizationSwitchProps): ReactElement | null;
177
+ /**
178
+ * Gets each client-side navigation's decision in before the click: when the
179
+ * visitor hovers over a same-site link (or presses it, on touch devices), it
180
+ * asks `createPreprProxy` which variants they get on that page. Clicks are
181
+ * never held: the new page paints with those variants when the answer is in,
182
+ * and keeps its personalized sections hidden until then otherwise. Pages
183
+ * without personalization answer with no groups and are not asked again for
184
+ * an hour.
185
+ *
186
+ * Mount it once in the root layout, so links on pages without a
187
+ * `PreprStack` are covered too. Links to other sites and links that open a
188
+ * new tab or download are left alone.
189
+ *
190
+ * @example
191
+ * <PreprPersonalizationSwitch />
192
+ */
193
+ declare function PreprPersonalizationSwitch(): null;
168
194
 
169
- export { PREPR_BUCKET_COOKIE, type PreprBucket, type PreprDraftOptions, type PreprMiddlewareOptions, PreprPersonalizationSwitch, type PreprPersonalizationSwitchProps, PreprToolbar, PreprTrackingPixel, type PreprTrackingPixelProps, type ResolvePreprBucketOptions, createPreprDraftRoute, createPreprMiddleware, getActiveSegment, getActiveVariant, getPreprHeaders, getPreprUUID, getToolbarProps, preprDraftRedirect, resolvePreprBucket };
195
+ export { type PreprDraftOptions, type PreprMiddlewareOptions, PreprPersonalizationSwitch, PreprToolbar, PreprTrackingPixel, type PreprTrackingPixelProps, type PreprWebhookOptions, createPreprDraftRoute, createPreprMiddleware, createPreprWebhookRoute, getActiveSegment, getActiveVariant, getPreprHeaders, getPreprUUID, getToolbarProps, preprDraftRedirect };
@@ -1,5 +1,6 @@
1
1
  import {
2
- PREPR_BUCKET_COOKIE,
2
+ PREPR_ANY_ITEM_TAG,
3
+ PREPR_SCHEMA_TAG,
3
4
  PreprError,
4
5
  createPreprDraftRoute,
5
6
  createPreprMiddleware,
@@ -11,9 +12,112 @@ import {
11
12
  getToolbarProps,
12
13
  isPersonalizedResponse,
13
14
  preprDraftRedirect,
14
- resolvePreprBucket,
15
+ preprItemTag,
16
+ preprModelTag,
15
17
  validatePreprToken
16
- } from "./chunk-PIQF5ZI5.js";
18
+ } from "./chunk-O2SBSBDG.js";
19
+
20
+ // src/nextjs/webhook.ts
21
+ import { revalidateTag } from "next/cache";
22
+ var CONTENT_EVENTS = /* @__PURE__ */ new Set([
23
+ "content_item.published",
24
+ "content_item.unpublished",
25
+ "content_item.deleted",
26
+ "content_item.invalidated",
27
+ "asset.changed",
28
+ "asset.deleted"
29
+ ]);
30
+ var MODEL_EVENTS = /* @__PURE__ */ new Set([
31
+ "content_item.published",
32
+ "content_item.unpublished",
33
+ "content_item.deleted"
34
+ ]);
35
+ var MAX_AGE_MS = 5 * 60 * 1e3;
36
+ var HEX_SIGNATURE = /^[0-9a-f]{64}$/i;
37
+ var BASE64_SIGNATURE = /^[A-Za-z0-9+/]{43}=$/;
38
+ var SIGNATURE_PREFIX = /^sha256=/i;
39
+ var MAX_ID_LENGTH = 200;
40
+ var text = (body, status) => new Response(body, { status, headers: { "Content-Type": "text/plain" } });
41
+ function signatureBytes(header) {
42
+ const value = header.trim().replace(SIGNATURE_PREFIX, "");
43
+ if (HEX_SIGNATURE.test(value)) {
44
+ const bytes = new Uint8Array(32);
45
+ for (let i = 0; i < 32; i++) {
46
+ bytes[i] = parseInt(value.slice(i * 2, i * 2 + 2), 16);
47
+ }
48
+ return bytes;
49
+ }
50
+ if (BASE64_SIGNATURE.test(value)) {
51
+ return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
52
+ }
53
+ return null;
54
+ }
55
+ async function verify(secret, signature, body) {
56
+ const key = await crypto.subtle.importKey(
57
+ "raw",
58
+ new TextEncoder().encode(secret),
59
+ { name: "HMAC", hash: "SHA-256" },
60
+ false,
61
+ ["verify"]
62
+ );
63
+ return crypto.subtle.verify("HMAC", key, signature, body);
64
+ }
65
+ function parse(body) {
66
+ try {
67
+ const value = JSON.parse(new TextDecoder().decode(body));
68
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ function eventTime(value) {
74
+ if (typeof value === "number") return value * 1e3;
75
+ if (typeof value === "string") return Date.parse(value);
76
+ return NaN;
77
+ }
78
+ function field(value, key) {
79
+ return typeof value === "object" && value !== null ? value[key] : void 0;
80
+ }
81
+ var usable = (value) => typeof value === "string" && value !== "" && value.length <= MAX_ID_LENGTH;
82
+ function tagsFor(event) {
83
+ const name = event.event;
84
+ if (name === "schema.changed") return [PREPR_SCHEMA_TAG];
85
+ if (typeof name !== "string" || !CONTENT_EVENTS.has(name)) return [];
86
+ const tags = [PREPR_ANY_ITEM_TAG];
87
+ const id = field(event.payload, "id");
88
+ if (usable(id)) tags.push(preprItemTag(id));
89
+ const model = field(field(event.payload, "model"), "body_singular");
90
+ if (MODEL_EVENTS.has(name) && usable(model)) tags.push(preprModelTag(model));
91
+ return tags;
92
+ }
93
+ function createPreprWebhookRoute(options) {
94
+ const { profile = { expire: 0 } } = options;
95
+ return async (request) => {
96
+ const { secret } = options;
97
+ if (!secret) return text("Webhook secret not configured", 500);
98
+ const signature = signatureBytes(
99
+ request.headers.get("prepr-signature") ?? ""
100
+ );
101
+ let body;
102
+ try {
103
+ body = await request.arrayBuffer();
104
+ } catch {
105
+ return text("Unreadable body", 400);
106
+ }
107
+ if (!signature || !await verify(secret, signature, body)) {
108
+ return text("Invalid signature", 401);
109
+ }
110
+ const event = parse(body);
111
+ if (!event) return text("Invalid body", 400);
112
+ const sentAt = eventTime(event.created_on);
113
+ if (Number.isFinite(sentAt) && Math.abs(Date.now() - sentAt) > MAX_AGE_MS) {
114
+ return text("Expired", 401);
115
+ }
116
+ const tags = tagsFor(event);
117
+ for (const tag of tags) revalidateTag(tag, profile);
118
+ return Response.json({ revalidated: tags });
119
+ };
120
+ }
17
121
 
18
122
  // src/nextjs/index.ts
19
123
  import {
@@ -22,13 +126,13 @@ import {
22
126
  PreprTrackingPixel
23
127
  } from "./components.js";
24
128
  export {
25
- PREPR_BUCKET_COOKIE,
26
129
  PreprError,
27
130
  PreprPersonalizationSwitch,
28
131
  PreprToolbar,
29
132
  PreprTrackingPixel,
30
133
  createPreprDraftRoute,
31
134
  createPreprMiddleware,
135
+ createPreprWebhookRoute,
32
136
  extractAccessToken,
33
137
  getActiveSegment,
34
138
  getActiveVariant,
@@ -37,6 +141,5 @@ export {
37
141
  getToolbarProps,
38
142
  isPersonalizedResponse,
39
143
  preprDraftRedirect,
40
- resolvePreprBucket,
41
144
  validatePreprToken
42
145
  };
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var VERSION = "0.6.0-alpha.6";
2
+ var VERSION = "0.6.0-alpha.8";
3
3
 
4
4
  // src/core/constants.ts
5
5
  var COOKIE_PREVIEW_MODE = "Prepr-Preview-Mode";