@avocadostudio-ai/site-sdk 0.2.0 → 0.2.3

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 CHANGED
@@ -10,7 +10,66 @@ SDK for integrating any Next.js site with the Avocado Studio. Provides the contr
10
10
  npm install @avocadostudio-ai/site-sdk
11
11
  ```
12
12
 
13
- ### 2. Create the page component
13
+ ### 2. Wrap your Next config
14
+
15
+ ```ts
16
+ // next.config.ts
17
+ import { withAvocado } from "@avocadostudio-ai/site-sdk/next-config"
18
+
19
+ export default withAvocado({ /* your config */ })
20
+ ```
21
+
22
+ Not optional, and the failures it prevents all look like something else:
23
+
24
+ - **Native binaries and provider SDKs get bundled.** `better-sqlite3` and
25
+ `sharp` build fine and die loading their `.node` file on the first request;
26
+ Turbopack resolves `orchestrator-core`'s optional `await import(...)` peers
27
+ statically and fails the build over a package you deliberately never
28
+ installed. `serverExternalPackages` alone does not fix it — `transpilePackages`
29
+ overrides server externals for a *transitive* dependency. This sets both.
30
+ - **Generated images 500.** Avocado writes image URLs from hosts your site never
31
+ chose (Unsplash, the image models' blob storage), and `next/image` refuses any
32
+ host missing from `images.remotePatterns`.
33
+ - **`trailingSlash: true` makes the editor unreachable** — see below.
34
+
35
+ Pass `{ images: false }`, `{ serverExternals: false }` or
36
+ `{ trailingSlash: false }` as a second argument to manage any of them yourself.
37
+
38
+ #### If your site sets `trailingSlash: true`, read this
39
+
40
+ Next applies its trailing-slash 308 to `/api/*` as well, so
41
+ `/api/editor/blocks` answers `308 → /api/editor/blocks/`. `fetch` follows a 308.
42
+ **A CORS preflight does not** — a browser treats a redirect on `OPTIONS` as a
43
+ network failure — and the editor calls those routes from its own origin. Every
44
+ editor API call therefore fails before it is sent, and the browser reports a
45
+ generic CORS error that names nothing. Middleware cannot repair it either: the
46
+ redirect runs before middleware.
47
+
48
+ `withAvocado` sets `skipTrailingSlashRedirect: true` for you. **That half alone
49
+ is a regression** — it stops your site redirecting `/about` to `/about/`, on
50
+ every URL you have ever published. The SDK's proxy puts the redirect back for
51
+ page routes, and you have to turn it on:
52
+
53
+ ```ts
54
+ // proxy.ts (middleware.ts on Next 15)
55
+ import { createEditorProxy } from "@avocadostudio-ai/site-sdk/proxy"
56
+
57
+ export const proxy = createEditorProxy({ trailingSlash: true }).proxy
58
+
59
+ export const config = {
60
+ matcher: ["/((?!_next|preview-draft|api|favicon\\.ico|icon\\.svg|logos/|generated-images/|.*\\.).*)"],
61
+ }
62
+ ```
63
+
64
+ These two are a pair. Nothing checks that you set both, because the config
65
+ cannot see your proxy and the proxy cannot read the config.
66
+
67
+ > Do not hand-roll that redirect from `request.nextUrl.clone()`. NextURL
68
+ > normalises the trailing slash back *off* when it stringifies, so the 308
69
+ > points at the URL it is trying to leave and the browser gives up after five
70
+ > hops. `createEditorProxy` builds it from `request.url`.
71
+
72
+ ### 3. Create the page component
14
73
 
15
74
  ```tsx
16
75
  // app/[[...slug]]/page.tsx
@@ -38,7 +97,7 @@ Export `generateMetadata` too, or every page inherits the root layout's
38
97
  from the page and calls `notFound()` for an unknown slug, so add an
39
98
  `app/not-found.tsx` if you want your own chrome around the 404.
40
99
 
41
- ### 3. Create the editor API route
100
+ ### 4. Create the editor API route
42
101
 
43
102
  ```tsx
44
103
  // app/api/editor/[...path]/route.ts
@@ -55,14 +114,14 @@ export const { GET, POST, OPTIONS } = createEditorApiHandler({
55
114
  })
56
115
  ```
57
116
 
58
- ### 4. Add styles
117
+ ### 5. Add styles
59
118
 
60
119
  ```tsx
61
120
  // app/layout.tsx
62
121
  import "@avocadostudio-ai/blocks/styles.css"
63
122
  ```
64
123
 
65
- ### 5. Set environment variables
124
+ ### 6. Set environment variables
66
125
 
67
126
  ```env
68
127
  # Required
@@ -86,14 +145,34 @@ That's it. Your site now works with the AI editor.
86
145
 
87
146
  ## Exports
88
147
 
148
+ All seventeen, because the six this table used to list were not the six an
149
+ integration needs — `createOrchestrator` is the whole of library mode and was in
150
+ none of them.
151
+
89
152
  | Import | What it provides |
90
153
  |---|---|
91
154
  | `@avocadostudio-ai/site-sdk` | Types (`PageDoc`, `BlockInstance`), `buildSlug`, `renderBlocks` |
92
- | `@avocadostudio-ai/site-sdk/page` | `createSitePage` — page component factory |
93
- | `@avocadostudio-ai/site-sdk/routes` | `createEditorApiHandler` — API route factory |
94
- | `@avocadostudio-ai/site-sdk/draft` | `resolveEditorContext`, `fetchEditorPage` |
95
- | `@avocadostudio-ai/site-sdk/editor` | `renderBlocks`, `EditorOverlay` |
96
- | `@avocadostudio-ai/site-sdk/navigation` | `buildNavItems`, `buildSiteHeaderBlock` |
155
+ | `.../page` | `createSitePage` — page component factory |
156
+ | `.../routes` | `createEditorApiHandler` — the `/api/editor/*` route factory |
157
+ | `.../routes/core` | The same handlers without Next's `draftMode()`, for a non-Next host |
158
+ | `.../server` | **`createOrchestrator`**, `jsonFileAdapter`, `editorApiAdapter`, `resolveCapabilities`, and the `CmsAdapter` / `CreateOrchestratorConfig` types |
159
+ | `.../next-config` | `withAvocado`, `AVOCADO_IMAGE_HOSTS`, `AVOCADO_SERVER_EXTERNALS` |
160
+ | `.../proxy` | `createEditorProxy` — the Next 16 `proxy.ts` rewrite |
161
+ | `.../middleware` | `createEditorMiddleware` — the same thing under the Next 15 name |
162
+ | `.../matcher` | `buildEditorMatcher` — just the matcher string, no `next/server` import |
163
+ | `.../draft` | `resolveEditorContext`, `fetchEditorPage`, `fetchEditorSlugs`, `fetchEditorSiteConfig` |
164
+ | `.../draft/core` | The draft context without Next's cookie APIs |
165
+ | `.../editor` | `EditorOverlay`, `LivePreviewProvider`, `buildEditorQuerySuffix` |
166
+ | `.../editor-manifest` | `buildBlockManifest` — the built-in blocks as a manifest |
167
+ | `.../publish` | `diffPage`, `groupPatches`, `describeUnsupported` — the field-level publish walk |
168
+ | `.../publish-handlers/json-file` | A ready-made `onPublish` that writes a JSON file |
169
+ | `.../navigation` | `buildNavItems`, `buildSiteHeaderBlock` |
170
+ | `.../seo` | `buildPageMetadata` and the title/description derivation |
171
+
172
+ `useLivePreviewBlocks` — the hook that reads `LivePreviewProvider`, and the only
173
+ way to render a live draft through **your own** components rather than Avocado's
174
+ — is not here. It lives in `@avocadostudio-ai/preview-adapter`, which you have to
175
+ install directly.
97
176
 
98
177
  ## API Contract
99
178
 
@@ -107,6 +186,84 @@ Your site exposes these endpoints via `createEditorApiHandler`:
107
186
  | `/api/editor/pages` | GET | Return all published pages |
108
187
  | `/api/editor/publish` | POST | Receive pages from editor, persist to CMS |
109
188
 
189
+ ## Library mode mounts two handlers, not one
190
+
191
+ `createOrchestrator` is not a replacement for `createEditorApiHandler`. It is an
192
+ addition, and the editor does not work without both.
193
+
194
+ ```ts
195
+ // app/api/avocado/[[...path]]/route.ts — the orchestrator
196
+ import { createOrchestrator } from "@avocadostudio-ai/site-sdk/server"
197
+
198
+ const handler = createOrchestrator({ basePath: "/api/avocado", adapter: myAdapter })
199
+ export const GET = handler
200
+ export const POST = handler
201
+ export const OPTIONS = handler
202
+ ```
203
+
204
+ ```ts
205
+ // app/api/editor/[...path]/route.ts — still required
206
+ import { createEditorApiHandler } from "@avocadostudio-ai/site-sdk/routes"
207
+
208
+ export const { GET, POST, OPTIONS } = createEditorApiHandler({ getPages, getManifest })
209
+ ```
210
+
211
+ The editor UI asks the orchestrator for plans and operations, and asks **your
212
+ site's own origin** for `/api/editor/blocks` and `/api/editor/pages`. Mount only
213
+ the orchestrator and the editor loads, connects, and answers four requests with
214
+ `net::ERR_FAILED` in a console you have to open to see.
215
+
216
+ ## The adapter contract
217
+
218
+ `createOrchestrator({ adapter })` is the whole of library mode: SQLite is the
219
+ working copy for drafts, undo and chat state, and the adapter is your actual
220
+ content store. It is read on a cold session and written on publish.
221
+
222
+ ```ts
223
+ import type { CmsAdapter } from "@avocadostudio-ai/site-sdk/server"
224
+
225
+ const myAdapter: CmsAdapter = {
226
+ /** Short, DNS-safe. Used for telemetry and the bootstrap cache. */
227
+ id: "sanity",
228
+
229
+ /** Does `getPages` honour `options.perspective`? Silence means no. */
230
+ perspectives: Boolean(process.env.SANITY_API_READ_TOKEN),
231
+
232
+ /** Every page for this site. Failures are logged, not fatal — the session starts empty. */
233
+ async getPages(options) {
234
+ return fetchPages({ draft: options?.perspective === "draft" })
235
+ },
236
+
237
+ /** Optional. Absent means publish is a local no-op. */
238
+ async onPublish(pages, config, context) {
239
+ // context.published — what you last reported, when the orchestrator has it
240
+ // context.assets — base64 blobs from chat-generated images, for you to upload
241
+ return { ok: true, unsupported: [] }
242
+ },
243
+
244
+ /** Static, because /whoami must answer it with the CMS unreachable. */
245
+ capabilities: { createPage: false },
246
+ }
247
+ ```
248
+
249
+ Two parts of that are easy to get wrong:
250
+
251
+ **`perspectives` defaults to "no", and the other flags default to "yes".** That
252
+ asymmetry is deliberate: capabilities are permissions, where the safe answer is
253
+ to allow what nobody forbade; `perspectives` is an ability, where the safe answer
254
+ is not to claim one nobody implemented. An adapter wrongly believed to read
255
+ drafts makes the publish diff assert that unpublished work is live.
256
+
257
+ **`onPublish` should diff, not overwrite.** Every CMS read is a projection — an
258
+ asset reference flattened to a URL, rich text flattened to a string — and writing
259
+ the projection back destroys what it was projected from. `context.published` is
260
+ the baseline; `undefined` means *no baseline available*, never *the site was
261
+ empty*. See [Publishing back to a real CMS](#publishing-back-to-a-real-cms).
262
+
263
+ `CmsAdapter`, `CmsCapabilities`, `CmsPublishContext`, `CmsPerspective` and
264
+ `CreateOrchestratorConfig` are all exported from
265
+ `@avocadostudio-ai/site-sdk/server`.
266
+
110
267
  ## Library mode needs a credential
111
268
 
112
269
  `createOrchestrator()` mounts on your own domain and can edit and publish your
@@ -220,11 +377,11 @@ string, a document reference resolved to one language's href, rich text
220
377
  flattened to markdown. Writing the projection back replaces the reference with
221
378
  the flattening and destroys the document.
222
379
 
223
- So publish a **diff**, not a snapshot. `@ai-site-editor/site-sdk/publish` owns
380
+ So publish a **diff**, not a snapshot. `@avocadostudio-ai/site-sdk/publish` owns
224
381
  the walk:
225
382
 
226
383
  ```ts
227
- import { diffPage, groupPatches, describeUnsupported } from "@ai-site-editor/site-sdk/publish"
384
+ import { diffPage, groupPatches, describeUnsupported } from "@avocadostudio-ai/site-sdk/publish"
228
385
 
229
386
  const diff = diffPage({
230
387
  page,
@@ -281,6 +438,88 @@ absent after a restart that reloaded the draft from storage. Treat `undefined`
281
438
  as "no baseline available", never as "the site was empty": publishing every
282
439
  field on that assumption is the overwrite all of this exists to prevent.
283
440
 
441
+ ## Registering your own block schemas
442
+
443
+ The manifest at `/api/editor/blocks` tells the **editor** what exists. It is not
444
+ what validates an edit — that is the global block registry in
445
+ `@avocadostudio-ai/shared`, which starts out holding only Avocado's built-ins.
446
+ Ship a manifest and no registration and the editor looks completely wired up
447
+ until the first AI edit:
448
+
449
+ ```json
450
+ {"error":"Invalid props for PricingTable: Unknown block type: PricingTable","errorCode":"schema_violation"}
451
+ ```
452
+
453
+ ```ts
454
+ // lib/register-blocks.ts
455
+ import { registerBlock, z } from "@avocadostudio-ai/shared"
456
+
457
+ export function registerMyBlocks() {
458
+ registerBlock("PricingTable", {
459
+ schema: z.object({
460
+ title: z.string().min(1),
461
+ // A list needs BOTH halves — see the warning below.
462
+ tiers: z.array(z.object({
463
+ name: z.string().min(1),
464
+ price: z.string().min(1),
465
+ })).optional(),
466
+ }).catchall(z.unknown()),
467
+
468
+ meta: {
469
+ displayName: "Pricing Table",
470
+ fields: { title: { kind: "text" } },
471
+ listFields: {
472
+ tiers: { label: "Tiers", itemFields: { name: { kind: "text" }, price: { kind: "text" } } },
473
+ },
474
+ },
475
+ })
476
+ }
477
+ ```
478
+
479
+ Hand that function to whichever handler you mount — both re-run it after the
480
+ built-ins have registered, so your definitions land on top:
481
+
482
+ ```ts
483
+ createEditorApiHandler({ getPages, getManifest, registerBlocks: registerMyBlocks })
484
+ createOrchestrator({ adapter, registerBlocks: registerMyBlocks })
485
+ ```
486
+
487
+ Do **not** instead rely on a side-effect `import "@/lib/register-blocks"` placed
488
+ last in the file. Next's bundler does not reliably preserve module order across
489
+ the RSC, SSR and route-handler layers, so the built-in schemas sometimes
490
+ re-register on top of yours. The hook exists to replace that trick.
491
+
492
+ ### Import `z` from `@avocadostudio-ai/shared`, not from `zod`
493
+
494
+ `registerBlock` takes a `ZodObject`, and a Zod object is assignable only to one
495
+ built by the *same copy* of the library. Your own `import { z } from "zod"`
496
+ resolves to whatever your tree hoisted — on any site that also uses Sanity that
497
+ is zod 3 — and the mismatch reports as a structural type error listing methods
498
+ you have never called (`loose`, `safeExtend`, `exactPartial`, `def`, "and 21
499
+ more"), with nothing anywhere saying there are two copies of zod. Importing `z`
500
+ from `shared` gives you ours, and you need no direct `zod` dependency at all.
501
+
502
+ ### `schema` and `meta` are two halves that must agree
503
+
504
+ `meta.listFields` says how to *label* a list's rows. The props schema is what
505
+ says the list exists, and the property panel renders rows from the schema — so a
506
+ list named only in the meta shows no rows and no Add control, with no error
507
+ anywhere. Validation passes, operations apply, the preview renders, publishing
508
+ diffs the rows correctly. The only symptom is an absence in one panel.
509
+
510
+ That failure hides especially well behind `.catchall(z.unknown())`, which most
511
+ CMS integrations need (see [Publishing back to a real CMS](#publishing-back-to-a-real-cms),
512
+ which recommends keeping a `__source` snapshot in block props): the catchall
513
+ swallows the undeclared array as an unmodelled extra.
514
+
515
+ `registerBlock` now warns when it sees the contradiction:
516
+
517
+ ```
518
+ [avocado] PricingTable: meta.listFields declares "tiers" but the schema has no `tiers`.
519
+ The property panel renders list rows from the schema, so this list will show no rows
520
+ and no Add control. Declare it alongside the meta, e.g. tiers: z.array(z.object({ … })).optional()
521
+ ```
522
+
284
523
  ## Telling the orchestrator which blocks you render
285
524
 
286
525
  Importing anything from `@avocadostudio-ai/shared` registers Avocado's 18
@@ -309,6 +548,39 @@ Declaring a type you never registered is not silently dropped: it has no schema
309
548
  to describe, so it cannot reach the manifest, and the orchestrator logs a warning
310
549
  naming it the first time the manifest is served.
311
550
 
551
+ ## Making fields editable
552
+
553
+ The manifest tells the editor which blocks exist and what props they take. It
554
+ does not tell it **where on the page a prop is rendered**, and nothing can derive
555
+ that — so a site whose components carry no annotation gets block selection, the
556
+ badge, move and delete, and not one editable field.
557
+
558
+ Put `data-editable-target` on the DOM node that renders each prop:
559
+
560
+ ```tsx
561
+ <h1 data-editable-target="heading">{heading}</h1>
562
+ <p data-editable-target="subheading">{subheading}</p>
563
+
564
+ {cards.map((card, i) => (
565
+ <article key={card._key}>
566
+ <h3 data-editable-target={`cards[${i}].title`}>{card.title}</h3>
567
+ <img data-editable-target={`cards[${i}].imageUrl`} src={card.imageUrl} />
568
+ </article>
569
+ ))}
570
+ ```
571
+
572
+ The path grammar is the same one operations use: `heading` for a scalar,
573
+ `cards[0].title` for a list item's field, `cards[0].imageUrl` for its image.
574
+
575
+ This is the one part of an integration that **cannot** live in an integration
576
+ layer — it has to go inside your own components, one attribute per prop you want
577
+ editable. Budget for it: it is usually the largest single cost of adopting the
578
+ editor, and there is no way to add it from the outside.
579
+
580
+ Two optional siblings control the labels the overlay draws:
581
+ `data-editable-target-label` (the CSS `::before` tooltip) and
582
+ `data-editable-label` (the floating pill). Both default to the target path.
583
+
312
584
  ## Environment Variables
313
585
 
314
586
  | Variable | Required | Description |
@@ -67,6 +67,10 @@ function parseArgs(argv) {
67
67
  case "--preview-url":
68
68
  out.previewUrl = next();
69
69
  break;
70
+ case "--token":
71
+ case "--access-token":
72
+ out.token = next();
73
+ break;
70
74
  case "-h":
71
75
  case "--help":
72
76
  out.help = true;
@@ -98,6 +102,9 @@ OPTIONAL
98
102
  --session <string> Orchestrator session (default: dev)
99
103
  --purpose <string> One-line site description for AI context
100
104
  --preview-url <url> Preview URL (default: http://localhost:<port>)
105
+ --token <string> Orchestrator access token (default: $ORCHESTRATOR_ACCESS_TOKEN).
106
+ Required by any orchestrator that is credentialed —
107
+ which a library-mode mount must be in production.
101
108
  --cwd <path> Project directory (default: current working directory)
102
109
  -h, --help Show this help
103
110
 
@@ -218,6 +225,13 @@ async function main() {
218
225
  const port = args.port ?? detectPortFromPackageJson(pkg) ?? 3000;
219
226
  // Resolve orchestrator URL
220
227
  const orchestrator = (args.orchestrator ?? process.env.ORCHESTRATOR_URL ?? "http://localhost:4200").replace(/\/+$/, "");
228
+ /*
229
+ * A credentialed orchestrator refuses `/sites/register` like any other route,
230
+ * and this CLI had no way to present a token — so the documented path for
231
+ * "wire up your site with your own coding agent" ended at a 401 for exactly
232
+ * the deployments the docs insist on securing.
233
+ */
234
+ const accessToken = (args.token ?? process.env.ORCHESTRATOR_ACCESS_TOKEN ?? "").trim();
221
235
  // Resolve / generate the draft secret
222
236
  const envPath = join(cwd, ".env.local");
223
237
  const existingEnv = existsSync(envPath) ? parseEnvFile(readFileSync(envPath, "utf-8")) : {};
@@ -246,7 +260,10 @@ async function main() {
246
260
  try {
247
261
  response = await fetch(`${orchestrator}/sites/register`, {
248
262
  method: "POST",
249
- headers: { "content-type": "application/json" },
263
+ headers: {
264
+ "content-type": "application/json",
265
+ ...(accessToken ? { "x-access-token": accessToken } : {}),
266
+ },
250
267
  body: JSON.stringify({
251
268
  siteId,
252
269
  name,
@@ -272,6 +289,11 @@ async function main() {
272
289
  if (!response.ok) {
273
290
  const text = await response.text();
274
291
  process.stderr.write(`\nOrchestrator responded ${response.status}:\n ${text}\n`);
292
+ if (response.status === 401 || response.status === 403) {
293
+ process.stderr.write(accessToken
294
+ ? `\nA token was sent and rejected. Check it matches ORCHESTRATOR_ACCESS_TOKEN\non the orchestrator, or the password behind ACCESS_PASSWORD_HASH.\n`
295
+ : `\nThis orchestrator is credentialed and no token was sent.\nPass --token <value>, or set ORCHESTRATOR_ACCESS_TOKEN.\n`);
296
+ }
275
297
  process.exit(1);
276
298
  }
277
299
  const result = (await response.json());
@@ -1,14 +1,13 @@
1
1
  import type { PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
2
2
  export declare function getOrchestratorUrl(): string | null;
3
- export declare function fetchEditorPage(slug: string, session: string, siteId: string, options?: {
3
+ type DraftFetchOptions = {
4
4
  timeoutMs?: number;
5
5
  orchestratorUrl?: string;
6
- }): Promise<PageDoc | null>;
7
- export declare function fetchEditorSlugs(session: string, siteId: string, options?: {
8
- timeoutMs?: number;
9
- orchestratorUrl?: string;
10
- }): Promise<string[]>;
11
- export declare function fetchEditorSiteConfig(session: string, siteId: string, options?: {
12
- timeoutMs?: number;
13
- orchestratorUrl?: string;
14
- }): Promise<SiteConfig>;
6
+ accessToken?: string;
7
+ };
8
+ /** For tests: forget which refusals have already been reported. */
9
+ export declare function resetDraftFetchWarnings(): void;
10
+ export declare function fetchEditorPage(slug: string, session: string, siteId: string, options?: DraftFetchOptions): Promise<PageDoc | null>;
11
+ export declare function fetchEditorSlugs(session: string, siteId: string, options?: DraftFetchOptions): Promise<string[]>;
12
+ export declare function fetchEditorSiteConfig(session: string, siteId: string, options?: DraftFetchOptions): Promise<SiteConfig>;
13
+ export {};
@@ -7,6 +7,25 @@ export function getOrchestratorUrl() {
7
7
  return "http://127.0.0.1:4200";
8
8
  return null;
9
9
  }
10
+ /**
11
+ * The credential these reads present, if there is one.
12
+ *
13
+ * A library-mode orchestrator *must* be credentialed in production —
14
+ * `createOrchestrator` refuses every request under `NODE_ENV=production` with
15
+ * neither `ORCHESTRATOR_ACCESS_TOKEN` nor `ACCESS_PASSWORD_HASH` set. Until
16
+ * this existed, these three helpers sent no headers at all, so a site could not
17
+ * read its own drafts through the SDK its own docs told it to use.
18
+ *
19
+ * Read from the environment rather than passed in because the caller is a
20
+ * server component that has no session of its own. `ORCHESTRATOR_ACCESS_TOKEN`
21
+ * has no `NEXT_PUBLIC_` prefix, so it is `undefined` in a client bundle: if one
22
+ * of these ever gets pulled clientward the request degrades to no header
23
+ * instead of shipping the token to a browser.
24
+ */
25
+ function resolveAccessToken(explicit) {
26
+ const token = explicit?.trim() || process.env.ORCHESTRATOR_ACCESS_TOKEN?.trim();
27
+ return token || undefined;
28
+ }
10
29
  function buildCandidateBaseUrls(configuredBaseUrl) {
11
30
  const candidates = [configuredBaseUrl];
12
31
  try {
@@ -25,27 +44,64 @@ function buildCandidateBaseUrls(configuredBaseUrl) {
25
44
  }
26
45
  return candidates;
27
46
  }
28
- async function fetchWithTimeout(url, timeoutMs) {
47
+ async function fetchWithTimeout(url, timeoutMs, token) {
29
48
  const controller = new AbortController();
30
49
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
31
50
  try {
32
- const response = await fetch(url, { cache: "no-store", signal: controller.signal });
51
+ const response = await fetch(url, {
52
+ cache: "no-store",
53
+ signal: controller.signal,
54
+ ...(token ? { headers: { "x-access-token": token } } : {})
55
+ });
33
56
  return response;
34
57
  }
35
58
  finally {
36
59
  clearTimeout(timeoutId);
37
60
  }
38
61
  }
62
+ /**
63
+ * A refusal is not an absence, and the difference is the whole bug.
64
+ *
65
+ * These helpers run per page render, so a 401 that fell through to `return
66
+ * null` produced a preview showing *published* content — no error, no log line,
67
+ * and a page that looks right. It is the hardest failure in the stack to see.
68
+ *
69
+ * Retrying the next candidate base URL cannot help either: the same missing
70
+ * credential will be missing there too. So a refusal stops the loop and says so
71
+ * once, rather than being spent on latency and silence.
72
+ */
73
+ const REFUSAL_STATUSES = new Set([401, 403]);
74
+ const warned = new Set();
75
+ function warnRefused(fn, status, hadToken) {
76
+ const key = `${fn}:${status}:${hadToken}`;
77
+ if (warned.has(key))
78
+ return;
79
+ warned.add(key);
80
+ console.warn(`[site-sdk/draft] ${fn}: the orchestrator answered ${status}. ` +
81
+ (hadToken
82
+ ? "The token that was sent is not the one it accepts — check ORCHESTRATOR_ACCESS_TOKEN on both sides."
83
+ : "No credential was sent. Set ORCHESTRATOR_ACCESS_TOKEN, or pass `accessToken`.") +
84
+ " Drafts are unavailable, so this page is rendering published content.");
85
+ }
86
+ /** For tests: forget which refusals have already been reported. */
87
+ export function resetDraftFetchWarnings() {
88
+ warned.clear();
89
+ }
39
90
  export async function fetchEditorPage(slug, session, siteId, options) {
40
91
  const configuredBaseUrl = options?.orchestratorUrl ?? getOrchestratorUrl();
41
92
  if (!configuredBaseUrl)
42
93
  return null;
43
94
  const timeout = options?.timeoutMs ?? 5000;
95
+ const token = resolveAccessToken(options?.accessToken);
44
96
  const baseUrls = buildCandidateBaseUrls(configuredBaseUrl);
45
97
  for (const baseUrl of baseUrls) {
46
98
  try {
47
99
  const url = `${baseUrl}/draft/pages?session=${encodeURIComponent(session)}&siteId=${encodeURIComponent(siteId)}&slug=${encodeURIComponent(slug)}`;
48
- const res = await fetchWithTimeout(url, timeout);
100
+ const res = await fetchWithTimeout(url, timeout, token);
101
+ if (REFUSAL_STATUSES.has(res.status)) {
102
+ warnRefused("fetchEditorPage", res.status, Boolean(token));
103
+ return null;
104
+ }
49
105
  if (!res.ok)
50
106
  continue;
51
107
  const payload = (await res.json());
@@ -70,11 +126,16 @@ export async function fetchEditorSlugs(session, siteId, options) {
70
126
  if (!configuredBaseUrl)
71
127
  return [];
72
128
  const timeout = options?.timeoutMs ?? 5000;
129
+ const token = resolveAccessToken(options?.accessToken);
73
130
  const baseUrls = buildCandidateBaseUrls(configuredBaseUrl);
74
131
  for (const baseUrl of baseUrls) {
75
132
  try {
76
133
  const url = `${baseUrl}/draft/slugs?session=${encodeURIComponent(session)}&siteId=${encodeURIComponent(siteId)}`;
77
- const res = await fetchWithTimeout(url, timeout);
134
+ const res = await fetchWithTimeout(url, timeout, token);
135
+ if (REFUSAL_STATUSES.has(res.status)) {
136
+ warnRefused("fetchEditorSlugs", res.status, Boolean(token));
137
+ return [];
138
+ }
78
139
  if (!res.ok)
79
140
  continue;
80
141
  const payload = (await res.json());
@@ -95,11 +156,16 @@ export async function fetchEditorSiteConfig(session, siteId, options) {
95
156
  if (!configuredBaseUrl)
96
157
  return {};
97
158
  const timeout = options?.timeoutMs ?? 5000;
159
+ const token = resolveAccessToken(options?.accessToken);
98
160
  const baseUrls = buildCandidateBaseUrls(configuredBaseUrl);
99
161
  for (const baseUrl of baseUrls) {
100
162
  try {
101
163
  const url = `${baseUrl}/draft/site-config?session=${encodeURIComponent(session)}&siteId=${encodeURIComponent(siteId)}`;
102
- const res = await fetchWithTimeout(url, timeout);
164
+ const res = await fetchWithTimeout(url, timeout, token);
165
+ if (REFUSAL_STATUSES.has(res.status)) {
166
+ warnRefused("fetchEditorSiteConfig", res.status, Boolean(token));
167
+ return {};
168
+ }
103
169
  if (!res.ok)
104
170
  continue;
105
171
  const payload = (await res.json());
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ import { test, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { fetchEditorPage, fetchEditorSlugs, resetDraftFetchWarnings } from "./draft-fetch.js";
4
+ let calls = [];
5
+ let warnings = [];
6
+ const realFetch = globalThis.fetch;
7
+ const realWarn = console.warn;
8
+ /** Answer every request with `status`, recording what was asked and with what. */
9
+ function stubFetch(status, body = {}) {
10
+ globalThis.fetch = (async (input, init) => {
11
+ const headers = new Headers(init?.headers ?? {});
12
+ calls.push({ url: String(input), token: headers.get("x-access-token") });
13
+ return new Response(JSON.stringify(body), {
14
+ status,
15
+ headers: { "content-type": "application/json" }
16
+ });
17
+ });
18
+ }
19
+ beforeEach(() => {
20
+ calls = [];
21
+ warnings = [];
22
+ resetDraftFetchWarnings();
23
+ console.warn = (msg) => { warnings.push(String(msg)); };
24
+ delete process.env.ORCHESTRATOR_ACCESS_TOKEN;
25
+ });
26
+ afterEach(() => {
27
+ globalThis.fetch = realFetch;
28
+ console.warn = realWarn;
29
+ delete process.env.ORCHESTRATOR_ACCESS_TOKEN;
30
+ });
31
+ test("the configured access token travels with the request", async () => {
32
+ process.env.ORCHESTRATOR_ACCESS_TOKEN = "s3cret";
33
+ stubFetch(404);
34
+ await fetchEditorPage("/about", "dev", "site", { orchestratorUrl: "http://example.test" });
35
+ assert.equal(calls.length, 1);
36
+ assert.equal(calls[0].token, "s3cret");
37
+ });
38
+ test("an explicit token beats the environment", async () => {
39
+ process.env.ORCHESTRATOR_ACCESS_TOKEN = "from-env";
40
+ stubFetch(404);
41
+ await fetchEditorPage("/about", "dev", "site", {
42
+ orchestratorUrl: "http://example.test",
43
+ accessToken: "explicit"
44
+ });
45
+ assert.equal(calls[0].token, "explicit");
46
+ });
47
+ test("no credential configured sends no header, rather than an empty one", async () => {
48
+ stubFetch(404);
49
+ await fetchEditorPage("/about", "dev", "site", { orchestratorUrl: "http://example.test" });
50
+ assert.equal(calls[0].token, null);
51
+ });
52
+ test("a refusal stops the walk instead of retrying the same missing credential", async () => {
53
+ // `localhost` yields a second candidate (127.0.0.1); a 404 tries it, a 401
54
+ // must not — the credential that was missing is missing there too.
55
+ stubFetch(401, { error: "unauthorized" });
56
+ const page = await fetchEditorPage("/about", "dev", "site", {
57
+ orchestratorUrl: "http://localhost:4200"
58
+ });
59
+ assert.equal(page, null);
60
+ assert.equal(calls.length, 1, "a 401 must not be spent on the next candidate URL");
61
+ });
62
+ test("a refusal is reported, because otherwise the page just looks right", async () => {
63
+ stubFetch(401, { error: "unauthorized" });
64
+ await fetchEditorPage("/about", "dev", "site", { orchestratorUrl: "http://example.test" });
65
+ assert.equal(warnings.length, 1);
66
+ assert.match(warnings[0], /401/);
67
+ assert.match(warnings[0], /ORCHESTRATOR_ACCESS_TOKEN/);
68
+ assert.match(warnings[0], /published content/);
69
+ });
70
+ test("the same refusal is not reported once per rendered page", async () => {
71
+ stubFetch(401);
72
+ await fetchEditorPage("/a", "dev", "site", { orchestratorUrl: "http://example.test" });
73
+ await fetchEditorPage("/b", "dev", "site", { orchestratorUrl: "http://example.test" });
74
+ assert.equal(warnings.length, 1);
75
+ });
76
+ test("a 404 still falls through to the other candidate host", async () => {
77
+ stubFetch(404);
78
+ await fetchEditorPage("/about", "dev", "site", { orchestratorUrl: "http://localhost:4200" });
79
+ assert.equal(calls.length, 2, "the localhost/127.0.0.1 fallback must survive this change");
80
+ });
81
+ test("fetchEditorSlugs refuses the same way, and returns an empty list", async () => {
82
+ stubFetch(401);
83
+ const slugs = await fetchEditorSlugs("dev", "site", { orchestratorUrl: "http://localhost:4200" });
84
+ assert.deepEqual(slugs, []);
85
+ assert.equal(calls.length, 1);
86
+ assert.match(warnings[0], /fetchEditorSlugs/);
87
+ });
@@ -27,10 +27,41 @@ test("a package whose entry point is TypeScript needs transpiling", () => {
27
27
  assert.deepEqual(linkedAvocadoPackages(root), ["@avocadostudio-ai/shared"]);
28
28
  });
29
29
  test("a package installed from a registry does not", () => {
30
- // Published, `main` points at built JavaScript — listing it would be noise.
31
- const root = fixture({ "@avocadostudio-ai/shared": { main: "dist/index.js" } });
30
+ /*
31
+ * The shape every `@avocadostudio-ai` package actually has on npm, and the
32
+ * `types` field is the whole point of the fixture: `dist/index.d.ts` ends in
33
+ * `.ts`, so a naive TypeScript test matches it and transpiles the published
34
+ * package — which drags `orchestrator-core` into the bundle and fails the
35
+ * build on an optional peer.
36
+ *
37
+ * This test existed before that was found, with a fixture carrying `main`
38
+ * alone. It passed, on a package shape that does not occur on a registry.
39
+ */
40
+ const root = fixture({
41
+ "@avocadostudio-ai/shared": { main: "dist/index.js", types: "dist/index.d.ts" }
42
+ });
32
43
  assert.deepEqual(linkedAvocadoPackages(root), []);
33
44
  });
45
+ test("a declaration file in an exports map is not a reason to transpile either", () => {
46
+ // The same trap one level down: an `exports` map carries its own `types`.
47
+ const root = fixture({
48
+ "@avocadostudio-ai/richtext": {
49
+ main: "dist/index.js",
50
+ exports: {
51
+ ".": { types: "./dist/index.d.ts", import: "./dist/index.js" },
52
+ "./package.json": "./package.json"
53
+ }
54
+ }
55
+ });
56
+ assert.deepEqual(linkedAvocadoPackages(root), []);
57
+ });
58
+ test("a linked package is still caught when its types point at source", () => {
59
+ // A workspace checkout points both fields at `src/`; that must still count.
60
+ const root = fixture({
61
+ "@avocadostudio-ai/shared": { main: "src/index.ts", types: "src/index.ts" }
62
+ });
63
+ assert.deepEqual(linkedAvocadoPackages(root), ["@avocadostudio-ai/shared"]);
64
+ });
34
65
  test("a TypeScript entry hidden in an exports map still counts", () => {
35
66
  const root = fixture({
36
67
  "@avocadostudio-ai/blocks": {
@@ -239,6 +270,27 @@ test("serverExternals: false leaves both halves to the app", () => {
239
270
  const config = withAvocado({}, { cwd: EMPTY_DIR, serverExternals: false, env: NO_ENV });
240
271
  assert.equal(config.serverExternalPackages, undefined);
241
272
  assert.equal(config.webpack, undefined, "no hook is attached at all");
273
+ assert.equal(config.turbopack, undefined, "and nothing is declared on its behalf");
274
+ });
275
+ /*
276
+ * On Next 16 Turbopack is the default, and a config with a `webpack` key and no
277
+ * `turbopack` key fails the build outright — measured, on 16.3.4, against a
278
+ * project that installed the SDK from a tarball:
279
+ *
280
+ * ERROR: This build is using Turbopack, with a `webpack` config and no
281
+ * `turbopack` config.
282
+ *
283
+ * Since the hook above is attached whether or not the app asked for one, the
284
+ * empty Turbopack config has to travel with it.
285
+ */
286
+ test("attaching a webpack hook also declares the turbopack config Next 16 demands", () => {
287
+ const config = withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV });
288
+ assert.equal(typeof config.webpack, "function", "the hook is still attached");
289
+ assert.deepEqual(config.turbopack, {}, "an empty turbopack config must travel with it");
290
+ });
291
+ test("an app's own turbopack config is never overwritten", () => {
292
+ const config = withAvocado({ turbopack: { resolveAlias: { underscore: "lodash" } } }, { cwd: EMPTY_DIR, env: NO_ENV });
293
+ assert.deepEqual(config.turbopack, { resolveAlias: { underscore: "lodash" } });
242
294
  });
243
295
  test("externals are applied even when there is nothing to transpile", () => {
244
296
  // The transpile derivation returns early twice — on a config that already
@@ -247,7 +299,57 @@ test("externals are applied even when there is nothing to transpile", () => {
247
299
  const config = withAvocado({}, { cwd: "/nonexistent-path-for-this-test", env: NO_ENV });
248
300
  assert.ok(config.serverExternalPackages.includes("better-sqlite3"));
249
301
  });
302
+ /*
303
+ * Naming orchestrator-core's *dependencies* external does not stop Turbopack
304
+ * walking into orchestrator-core and resolving its `await import("googleapis")`
305
+ * statically. Measured on Next 16.3.4 against a real tarball install: the build
306
+ * fails on an optional peer the project never installed, with every provider
307
+ * SDK already in `serverExternalPackages`. Externalising the package itself is
308
+ * the fix, and it is only correct when the package arrived built.
309
+ */
310
+ test("a registry-installed orchestrator-core is externalised, not walked", () => {
311
+ const root = fixture({
312
+ "@avocadostudio-ai/orchestrator-core": { main: "dist/index.js", types: "dist/index.d.ts" }
313
+ });
314
+ const config = withAvocado({}, { cwd: root, env: NO_ENV });
315
+ assert.ok(config.serverExternalPackages.includes("@avocadostudio-ai/orchestrator-core"), "a built orchestrator-core must be external, or Turbopack resolves its optional peers");
316
+ });
317
+ test("a linked orchestrator-core is transpiled instead, never externalised", () => {
318
+ // Externalising a package whose `main` is `src/index.ts` hands Node a
319
+ // TypeScript file to require — a build error traded for a runtime crash.
320
+ const root = fixture({ "@avocadostudio-ai/orchestrator-core": { main: "src/index.ts" } });
321
+ const config = withAvocado({}, { cwd: root, env: NO_ENV });
322
+ assert.deepEqual(config.transpilePackages, ["@avocadostudio-ai/orchestrator-core"]);
323
+ assert.equal(config.serverExternalPackages.includes("@avocadostudio-ai/orchestrator-core"), false, "a linked checkout must be compiled with the app, not handed to Node raw");
324
+ });
250
325
  test("the exported list is what the helper actually applies", () => {
251
326
  const config = withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV });
252
327
  assert.deepEqual(config.serverExternalPackages, AVOCADO_SERVER_EXTERNALS);
253
328
  });
329
+ /*
330
+ * `trailingSlash: true` is the setting that made the editor unreachable. Next
331
+ * applies its 308 to `/api/*` too, and a browser will not follow a redirect on
332
+ * a CORS preflight, so every editor API call failed before it was sent — with
333
+ * no error naming the config line responsible.
334
+ */
335
+ test("a trailing-slash site stops redirecting, or the editor cannot reach it", () => {
336
+ const root = fixture({});
337
+ const config = withAvocado({ trailingSlash: true }, { cwd: root, silent: true });
338
+ assert.equal(config.skipTrailingSlashRedirect, true);
339
+ assert.equal(config.trailingSlash, true, "the app's own setting is not touched — only the redirect is");
340
+ });
341
+ test("a site without trailing slashes is left exactly as it was", () => {
342
+ const root = fixture({});
343
+ const config = withAvocado({}, { cwd: root, silent: true });
344
+ assert.equal(config.skipTrailingSlashRedirect, undefined, "the vast majority of sites never hit this, and must not inherit the workaround");
345
+ });
346
+ test("an app that already decided about the redirect keeps its decision", () => {
347
+ const root = fixture({});
348
+ const kept = withAvocado({ trailingSlash: true, skipTrailingSlashRedirect: false }, { cwd: root, silent: true });
349
+ assert.equal(kept.skipTrailingSlashRedirect, false, "a site that stated this has thought about it harder than a helper can");
350
+ });
351
+ test("trailingSlash: false leaves the whole thing to the app", () => {
352
+ const root = fixture({});
353
+ const config = withAvocado({ trailingSlash: true }, { cwd: root, silent: true, trailingSlash: false });
354
+ assert.equal(config.skipTrailingSlashRedirect, undefined);
355
+ });
package/dist/proxy.d.ts CHANGED
@@ -15,10 +15,47 @@ export type EditorProxyOptions = {
15
15
  */
16
16
  editorParam?: string;
17
17
  /**
18
- * Name of the Next.js draft-mode bypass cookie.
18
+ * Name of the Next.js draft-mode bypass cookie, or `false` to ignore cookies
19
+ * entirely and key the rewrite on {@link EditorProxyOptions.editorParam} alone.
20
+ *
21
+ * The cookie is how navigation *inside* the editor iframe stays in draft mode:
22
+ * a link click carries no `__editor=1`, so without it the second page a user
23
+ * visits renders published content.
24
+ *
25
+ * But the cookie is Next's own, and it is not Avocado's to claim. Any other
26
+ * feature that calls `draftMode().enable()` sets the same
27
+ * `__prerender_bypass` — Sanity's Presentation tool and Contentful's live
28
+ * preview both do — and every one of *their* preview requests then lands on
29
+ * Avocado's preview route. On a site that already had Draft Mode before it had
30
+ * Avocado, pass `draftCookie: false` and the two stop fighting over it.
31
+ *
19
32
  * @default "__prerender_bypass"
20
33
  */
21
- draftCookie?: string;
34
+ draftCookie?: string | false;
35
+ /**
36
+ * Set this to `true` on a site whose `next.config` sets `trailingSlash: true`.
37
+ *
38
+ * Such a site cannot talk to the editor until it *stops* letting Next issue
39
+ * the trailing-slash redirect. Next applies that 308 to `/api/*` as well, so
40
+ * `/api/editor/blocks` answers `308 → /api/editor/blocks/` — and while `fetch`
41
+ * follows a 308, a browser does **not** follow a redirect on a CORS preflight.
42
+ * The editor calls those routes from its own origin, so every editor API call
43
+ * fails before the request is made. A middleware rewrite cannot repair it
44
+ * either: Next's trailing-slash redirect runs *before* middleware.
45
+ *
46
+ * The fix is `skipTrailingSlashRedirect: true` — which `withAvocado` sets for
47
+ * you as soon as it sees `trailingSlash: true` — plus re-issuing the redirect
48
+ * by hand for everything that is not an API route. This flag is that second
49
+ * half, and the two must be turned on together: the config half alone stops a
50
+ * site redirecting to its canonical URLs.
51
+ *
52
+ * Only page paths are affected. The proxy's matcher already excludes `/api`,
53
+ * `_next` and anything with a file extension, which is exactly the set that
54
+ * should never have gained a trailing slash to begin with.
55
+ *
56
+ * @default false
57
+ */
58
+ trailingSlash?: boolean;
22
59
  };
23
60
  /**
24
61
  * Create a Next.js proxy function that rewrites editor/draft requests
package/dist/proxy.js CHANGED
@@ -34,10 +34,36 @@ export { DEFAULT_PREVIEW_ROUTE, buildEditorMatcher } from "./editor-matcher.js";
34
34
  export function createEditorProxy(options) {
35
35
  const previewRoute = options?.previewRoute ?? DEFAULT_PREVIEW_ROUTE;
36
36
  const editorParam = options?.editorParam ?? "__editor";
37
- const draftCookie = options?.draftCookie ?? "__prerender_bypass";
37
+ const draftCookie = options?.draftCookie === undefined ? "__prerender_bypass" : options.draftCookie;
38
+ const trailingSlash = options?.trailingSlash ?? false;
38
39
  function proxy(request) {
40
+ /*
41
+ * Before anything else, and deliberately: this stands in for a redirect
42
+ * Next would have issued before middleware ran, so a request that should
43
+ * never have been served at this URL must not be served at it here either.
44
+ * Rewriting first would answer `/about?__editor=1` with content the site
45
+ * publishes only at `/about/`.
46
+ */
47
+ if (trailingSlash) {
48
+ /*
49
+ * Built from `request.url`, not from `request.nextUrl.clone()`. NextURL
50
+ * normalises a trailing slash back *off* when it stringifies, so a
51
+ * redirect built from a clone points at the URL it is trying to leave —
52
+ * a redirect loop, and one that only a browser would ever have shown us.
53
+ * A plain URL does no normalising. It also keeps `basePath`, which
54
+ * `nextUrl.pathname` has already stripped.
55
+ */
56
+ const url = new URL(request.url);
57
+ if (url.pathname.length > 1 && !url.pathname.endsWith("/")) {
58
+ url.pathname = `${url.pathname}/`;
59
+ // 308, not 307: the method is preserved *and* the redirect is
60
+ // permanent, which is what Next's own trailing-slash redirect sends
61
+ // and what the site's existing search rankings were built on.
62
+ return NextResponse.redirect(url, 308);
63
+ }
64
+ }
39
65
  const isEditor = request.nextUrl.searchParams.get(editorParam) === "1";
40
- const hasDraftCookie = request.cookies.has(draftCookie);
66
+ const hasDraftCookie = draftCookie !== false && request.cookies.has(draftCookie);
41
67
  if (isEditor || hasDraftCookie) {
42
68
  const url = request.nextUrl.clone();
43
69
  url.pathname = `${previewRoute}${url.pathname}`;
@@ -70,3 +70,54 @@ test("the deprecated middleware entry point is the same rewrite under the old na
70
70
  test("DEFAULT_PREVIEW_ROUTE is what the factory actually defaults to", () => {
71
71
  assert.equal(rewriteOf(createEditorProxy().proxy(request("https://site.test/a?__editor=1"))), `https://site.test${DEFAULT_PREVIEW_ROUTE}/a?__editor=1`);
72
72
  });
73
+ /*
74
+ * `trailingSlash: true` and the editor could not coexist. Next applies its 308
75
+ * to `/api/*` too, and a browser will not follow a redirect on a CORS preflight,
76
+ * so every editor API call failed before it was sent. `withAvocado` turns the
77
+ * redirect off; these pin the half that puts it back.
78
+ */
79
+ const locationOf = (response) => response.headers.get("location");
80
+ test("a trailing-slash site gets back the redirect the config turned off", () => {
81
+ const { proxy } = createEditorProxy({ trailingSlash: true });
82
+ const response = proxy(request("https://site.test/about"));
83
+ assert.equal(response.status, 308, "308, like Next's own — the site's rankings were built on a permanent redirect");
84
+ assert.equal(locationOf(response), "https://site.test/about/");
85
+ });
86
+ test("the redirect keeps the query string, or the editor loses its own parameter", () => {
87
+ const { proxy } = createEditorProxy({ trailingSlash: true });
88
+ assert.equal(locationOf(proxy(request("https://site.test/about?__editor=1"))), "https://site.test/about/?__editor=1");
89
+ });
90
+ test("an already-canonical path is rewritten, not redirected into a loop", () => {
91
+ const { proxy } = createEditorProxy({ trailingSlash: true });
92
+ const response = proxy(request("https://site.test/about/?__editor=1"));
93
+ assert.equal(response.status, 200);
94
+ assert.equal(rewriteOf(response), "https://site.test/preview-draft/about/?__editor=1");
95
+ });
96
+ test("the root is already canonical — redirecting it would never terminate", () => {
97
+ const { proxy } = createEditorProxy({ trailingSlash: true });
98
+ const response = proxy(request("https://site.test/?__editor=1"));
99
+ assert.equal(response.status, 200, "`/` already ends in a slash; redirecting it is a loop");
100
+ // Asserted as a prefix: the rewrite target goes through NextURL, which
101
+ // normalises the trailing slash according to the app's own config.
102
+ assert.match(rewriteOf(response) ?? "", /^https:\/\/site\.test\/preview-draft/);
103
+ });
104
+ test("the redirect comes before the rewrite, so no page is served at a URL the site does not publish", () => {
105
+ const { proxy } = createEditorProxy({ trailingSlash: true });
106
+ const response = proxy(request("https://site.test/about?__editor=1"));
107
+ assert.equal(rewriteOf(response), null, "an unslashed editor URL must redirect first, not render");
108
+ });
109
+ test("a site that never asked for trailing slashes is never redirected", () => {
110
+ const { proxy } = createEditorProxy();
111
+ assert.equal(proxy(request("https://site.test/about")).status, 200);
112
+ assert.equal(locationOf(proxy(request("https://site.test/about"))), null);
113
+ });
114
+ /*
115
+ * `__prerender_bypass` is Next's cookie, not Avocado's. A site that already used
116
+ * Draft Mode for its CMS's own preview sent every one of those requests into
117
+ * Avocado's preview route.
118
+ */
119
+ test("draftCookie: false leaves Next's draft cookie to whoever else is using it", () => {
120
+ const { proxy } = createEditorProxy({ draftCookie: false });
121
+ assert.equal(rewriteOf(proxy(request("https://site.test/about", "__prerender_bypass=abc"))), null, "a Sanity or Contentful preview must not be hijacked into Avocado's route");
122
+ assert.equal(rewriteOf(proxy(request("https://site.test/about?__editor=1"))), "https://site.test/preview-draft/about?__editor=1", "the explicit editor parameter still works — that is the whole point of the opt-out");
123
+ });
package/next-config.d.ts CHANGED
@@ -36,15 +36,18 @@ export const AVOCADO_SERVER_EXTERNALS: string[]
36
36
 
37
37
  /**
38
38
  * Wrap a Next config so `transpilePackages` covers every linked Avocado package,
39
- * `images.remotePatterns` covers every host Avocado can serve an image from, and
40
- * Avocado's native and provider dependencies stay external to the server build.
41
- * Additive, and never throws.
39
+ * `images.remotePatterns` covers every host Avocado can serve an image from,
40
+ * Avocado's native and provider dependencies stay external to the server build,
41
+ * and a `trailingSlash: true` site stops redirecting the editor's API calls into
42
+ * a failed CORS preflight. Additive, and never throws.
42
43
  */
43
44
  export function withAvocado<
44
45
  T extends {
45
46
  transpilePackages?: string[]
46
47
  images?: { remotePatterns?: unknown[] }
47
48
  serverExternalPackages?: string[]
49
+ trailingSlash?: boolean
50
+ skipTrailingSlashRedirect?: boolean
48
51
  /** `null` is in Next's own type for this field, so the constraint admits it. */
49
52
  webpack?: ((config: any, context: any) => any) | null
50
53
  }
@@ -60,6 +63,13 @@ export function withAvocado<
60
63
  * yourself. Defaults to true.
61
64
  */
62
65
  serverExternals?: boolean
66
+ /**
67
+ * Set false to keep Next's trailing-slash redirect on a `trailingSlash: true`
68
+ * site — at the cost of the editor, whose API calls cannot survive a 308 on
69
+ * their CORS preflight. Defaults to true, and pairs with
70
+ * `createEditorProxy({ trailingSlash: true })`.
71
+ */
72
+ trailingSlash?: boolean
63
73
  /** Environment to read the orchestrator origin from. Defaults to `process.env`. */
64
74
  env?: Record<string, string | undefined>
65
75
  }
package/next-config.mjs CHANGED
@@ -54,6 +54,18 @@
54
54
  * dependency, so `sharp` reached through the transpiled `orchestrator-core` got
55
55
  * bundled anyway. The two options interact, this helper sets both, and it is the
56
56
  * only place that knows it has to.
57
+ *
58
+ * ## And the fourth
59
+ *
60
+ * `trailingSlash: true` makes Next answer `/api/editor/blocks` with a 308 to
61
+ * `/api/editor/blocks/`. `fetch` follows that; a CORS preflight does not — a
62
+ * browser treats a redirect on `OPTIONS` as a network failure — so on a site
63
+ * with trailing slashes every editor API call fails before it is sent, and
64
+ * nothing in the error says why. `skipTrailingSlashRedirect` is the only way
65
+ * out, because the redirect runs before middleware and cannot be intercepted.
66
+ * Turning it off is safe only because the SDK's own proxy puts the redirect back
67
+ * for page routes — see `createEditorProxy({ trailingSlash: true })`, which is
68
+ * the other half of this and is not optional.
57
69
  */
58
70
 
59
71
  import { existsSync, readdirSync, readFileSync } from "node:fs"
@@ -70,7 +82,28 @@ function readJson(file) {
70
82
  }
71
83
  }
72
84
 
73
- /** Does any entry point in this manifest resolve to TypeScript? */
85
+ /**
86
+ * An entry point that has to be compiled, as opposed to merely described.
87
+ *
88
+ * `.d.ts` ends in `.ts` and is not TypeScript that anything compiles — it is
89
+ * the type description of JavaScript that is already built. Every published
90
+ * package sets `types: "dist/index.d.ts"`, so a naive `/\.tsx?$/` matched all
91
+ * of them and this whole helper inverted on a registry install: it added the
92
+ * published packages to `transpilePackages`, and `transpilePackages` is
93
+ * precisely what drags `orchestrator-core` into the bundle and fails the build
94
+ * on an optional peer. The bug it exists to prevent was the bug it caused.
95
+ *
96
+ * The declaration test has to run against every string, not just `types` — an
97
+ * `exports` map carries its own `types` condition.
98
+ */
99
+ const DECLARATION_FILE = /\.d\.[cm]?tsx?$/
100
+ const TYPESCRIPT_FILE = /\.[cm]?tsx?$/
101
+
102
+ function isCompilableEntry(entry) {
103
+ return TYPESCRIPT_FILE.test(entry) && !DECLARATION_FILE.test(entry)
104
+ }
105
+
106
+ /** Does any entry point in this manifest resolve to TypeScript source? */
74
107
  function shipsTypeScript(pkg) {
75
108
  const seen = []
76
109
  const walk = (value) => {
@@ -80,7 +113,7 @@ function shipsTypeScript(pkg) {
80
113
  walk(pkg.main)
81
114
  walk(pkg.types)
82
115
  walk(pkg.exports)
83
- return seen.some((entry) => /\.tsx?$/.test(entry))
116
+ return seen.some(isCompilableEntry)
84
117
  }
85
118
 
86
119
  /** Every `node_modules` directory from `start` up to the filesystem root. */
@@ -232,6 +265,27 @@ export const AVOCADO_SERVER_EXTERNALS = [
232
265
  "@modelcontextprotocol/sdk",
233
266
  ]
234
267
 
268
+ /**
269
+ * Stop Next issuing the trailing-slash redirect, so the editor's API calls can
270
+ * reach the site at all.
271
+ *
272
+ * Only for an app that asked for `trailingSlash: true`; every other config is
273
+ * returned untouched. An app that already stated a `skipTrailingSlashRedirect`
274
+ * of its own — either value — keeps it, because a site that has thought about
275
+ * this has thought about it harder than a helper can.
276
+ *
277
+ * This half alone is a regression: it stops `/about` redirecting to `/about/`,
278
+ * which for a site that has published slashed URLs for years is an SEO change
279
+ * nobody asked for. `createEditorProxy({ trailingSlash: true })` re-issues that
280
+ * 308 for page routes, and the pair is the fix. They are documented together
281
+ * and neither is useful without the other.
282
+ */
283
+ function withAvocadoTrailingSlash(config) {
284
+ if (config.trailingSlash !== true) return config
285
+ if (config.skipTrailingSlashRedirect !== undefined) return config
286
+ return { ...config, skipTrailingSlashRedirect: true }
287
+ }
288
+
235
289
  /**
236
290
  * Mark every entry in `AVOCADO_SERVER_EXTERNALS` external to the server build,
237
291
  * both ways it has to be said.
@@ -246,22 +300,34 @@ export const AVOCADO_SERVER_EXTERNALS = [
246
300
  * Both halves are additive: an app's own externals and its own `webpack` hook
247
301
  * run first and keep whatever they did.
248
302
  *
249
- * The hook is attached even for an app that had none, which has one cosmetic
250
- * cost: Next warns "Webpack is configured while Turbopack is not" for a
251
- * Turbopack app that declares no `turbopack` key of its own. Under Turbopack the
252
- * hook is never called and `serverExternalPackages` is what carries the fix, so
253
- * the warning is about an inert function. An app that would rather not see it
254
- * can declare `turbopack: {}` or opt out with `{ serverExternals: false }`.
303
+ * The hook is attached even for an app that had none, and on Next 16 that is not
304
+ * cosmetic. Turbopack is the default there, and a config carrying a `webpack`
305
+ * key with no `turbopack` key is a **build error**, not a warning:
306
+ *
307
+ * ERROR: This build is using Turbopack, with a `webpack` config and no
308
+ * `turbopack` config. This may be a mistake.
309
+ *
310
+ * Next's own message names the remedy — an empty `turbopack` config — so that is
311
+ * what goes in whenever we are the ones adding the hook and the app declared no
312
+ * Turbopack config of its own. It is inert on Next 15 and it never overwrites an
313
+ * app's own `turbopack` key. Under Turbopack the webpack hook is never called
314
+ * and `serverExternalPackages` carries the fix; under webpack the hook is what
315
+ * holds. Declaring both is the only way to be right on both.
316
+ *
317
+ * An app that would rather manage all of this itself opts out with
318
+ * `{ serverExternals: false }`, which attaches nothing.
255
319
  */
256
- function withAvocadoServerExternals(config) {
320
+ function withAvocadoServerExternals(config, extra = []) {
321
+ const externals = [...AVOCADO_SERVER_EXTERNALS, ...extra]
257
322
  const declared = Array.isArray(config.serverExternalPackages) ? config.serverExternalPackages : []
258
- const missing = AVOCADO_SERVER_EXTERNALS.filter((name) => !declared.includes(name))
323
+ const missing = externals.filter((name) => !declared.includes(name))
259
324
 
260
325
  const appWebpack = typeof config.webpack === "function" ? config.webpack : null
261
326
 
262
327
  return {
263
328
  ...config,
264
329
  ...(missing.length > 0 ? { serverExternalPackages: [...declared, ...missing] } : {}),
330
+ ...(config.turbopack === undefined ? { turbopack: {} } : {}),
265
331
  webpack(webpackConfig, context) {
266
332
  const result = appWebpack ? appWebpack(webpackConfig, context) : webpackConfig
267
333
  if (!context?.isServer) return result
@@ -277,7 +343,7 @@ function withAvocadoServerExternals(config) {
277
343
  */
278
344
  ({ request }, callback) => {
279
345
  if (!request) return callback()
280
- for (const name of AVOCADO_SERVER_EXTERNALS) {
346
+ for (const name of externals) {
281
347
  if (request === name || request.startsWith(`${name}/`)) {
282
348
  return callback(null, `commonjs ${request}`)
283
349
  }
@@ -290,15 +356,46 @@ function withAvocadoServerExternals(config) {
290
356
  }
291
357
  }
292
358
 
359
+ /**
360
+ * `orchestrator-core`, when it arrived built rather than linked.
361
+ *
362
+ * Naming its *dependencies* external is not enough. Turbopack resolves a
363
+ * dynamic import statically, so as long as it walks into
364
+ * `orchestrator-core/dist` at all it meets `await import("googleapis")` and
365
+ * fails the build over an optional peer the site deliberately never installed —
366
+ * `serverExternalPackages` notwithstanding, because that governs what is
367
+ * bundled, not what is traversed. Externalising the package itself is what
368
+ * stops the walk, and it is the one fix that appears in no list and no
369
+ * document; it was found by building a registry install on Next 16.
370
+ *
371
+ * Only when it is *not* linked. A workspace checkout points `main` at
372
+ * `src/index.ts`, and externalising that hands Node a TypeScript file to
373
+ * require at runtime — trading a build error for a crash on the first request.
374
+ */
375
+ const ORCHESTRATOR_CORE = "@avocadostudio-ai/orchestrator-core"
376
+
377
+ function builtOrchestratorCore(from, linked) {
378
+ if (linked.includes(ORCHESTRATOR_CORE)) return []
379
+ for (const nodeModules of nodeModulesDirs(from)) {
380
+ if (existsSync(join(nodeModules, ORCHESTRATOR_CORE, "package.json"))) return [ORCHESTRATOR_CORE]
381
+ }
382
+ return []
383
+ }
384
+
293
385
  /**
294
386
  * Wrap a Next config so its `transpilePackages` covers every linked Avocado
295
387
  * package.
296
388
  *
297
389
  * Also merges Avocado's own image hosts into `images.remotePatterns`, since a
298
- * generated image comes from a host the site never chose, and marks Avocado's
390
+ * generated image comes from a host the site never chose, marks Avocado's
299
391
  * native and provider dependencies external to the server build, since neither
300
- * survives being bundled. Pass `{ images: false }` or
301
- * `{ serverExternals: false }` to manage either list yourself.
392
+ * survives being bundled, and on a site that sets `trailingSlash: true` —
393
+ * turns off the redirect that would otherwise make every editor API call fail
394
+ * its CORS preflight. Pass `{ images: false }`, `{ serverExternals: false }` or
395
+ * `{ trailingSlash: false }` to manage any of them yourself.
396
+ *
397
+ * `trailingSlash` is the one that needs a second step: the SDK's proxy has to
398
+ * re-issue the redirect it turns off. See `withAvocadoTrailingSlash`.
302
399
  *
303
400
  * Additive and total: whatever the app already listed is kept in the order it
304
401
  * wrote it, unrelated entries included, its own `webpack` hook still runs and
@@ -312,24 +409,37 @@ export function withAvocado(config = {}, options = {}) {
312
409
  silent = false,
313
410
  images = true,
314
411
  serverExternals = true,
412
+ trailingSlash = true,
315
413
  env = process.env,
316
414
  } = options
317
415
 
318
416
  /*
319
- * Applied before the `transpilePackages` derivation below, which has two
320
- * early returns of its own a config that already lists every linked package
321
- * still needs its externals.
417
+ * Resolved first because the externals depend on it: whether
418
+ * `orchestrator-core` has to be external is exactly the question of whether
419
+ * it is linked. A filesystem this cannot read leaves both lists empty, which
420
+ * is the same "never throw" contract as before — a helper that can break
421
+ * `next.config` is worse than the bug it fixes.
322
422
  */
323
- const withImages = images ? withAvocadoImages(config, env) : config
324
- const result = serverExternals ? withAvocadoServerExternals(withImages) : withImages
325
-
326
- let linked
423
+ let linked = null
327
424
  try {
328
425
  linked = linkedAvocadoPackages(cwd)
329
426
  } catch {
330
- return result
427
+ linked = null
331
428
  }
332
429
 
430
+ /*
431
+ * Applied before the `transpilePackages` derivation below, which has two
432
+ * early returns of its own — a config that already lists every linked package
433
+ * still needs its externals.
434
+ */
435
+ const base = trailingSlash ? withAvocadoTrailingSlash(config) : config
436
+ const withImages = images ? withAvocadoImages(base, env) : base
437
+ const result = serverExternals
438
+ ? withAvocadoServerExternals(withImages, linked === null ? [] : builtOrchestratorCore(cwd, linked))
439
+ : withImages
440
+
441
+ if (linked === null) return result
442
+
333
443
  const declared = Array.isArray(result.transpilePackages) ? result.transpilePackages : []
334
444
  const missing = linked.filter((name) => !declared.includes(name))
335
445
  if (missing.length === 0) return result
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/site-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -107,16 +107,16 @@
107
107
  ],
108
108
  "dependencies": {
109
109
  "zod": "^4.3.6",
110
- "@avocadostudio-ai/blocks": "0.2.0",
111
- "@avocadostudio-ai/preview-adapter": "0.2.0",
112
- "@avocadostudio-ai/shared": "0.2.0"
110
+ "@avocadostudio-ai/blocks": "0.2.3",
111
+ "@avocadostudio-ai/shared": "0.2.3",
112
+ "@avocadostudio-ai/preview-adapter": "0.2.3"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "next": ">=15.0.0",
116
116
  "react": ">=19.0.0",
117
117
  "react-dom": ">=19.0.0",
118
118
  "better-sqlite3": ">=12.0.0",
119
- "@avocadostudio-ai/orchestrator-core": "^0.2.0"
119
+ "@avocadostudio-ai/orchestrator-core": "^0.2.3"
120
120
  },
121
121
  "peerDependenciesMeta": {
122
122
  "@avocadostudio-ai/orchestrator-core": {
@@ -132,6 +132,17 @@
132
132
  "typescript": "^5.7.3"
133
133
  },
134
134
  "description": "SDK for adding Avocado Studio AI editing and live preview to a Next.js site",
135
+ "keywords": [
136
+ "avocado",
137
+ "avocado-studio",
138
+ "nextjs",
139
+ "cms",
140
+ "visual-editing",
141
+ "page-builder",
142
+ "ai",
143
+ "content-editor",
144
+ "draft-mode"
145
+ ],
135
146
  "license": "Apache-2.0",
136
147
  "homepage": "https://github.com/avocadostudio-ai/avocado/tree/main/packages/site-sdk#readme",
137
148
  "bugs": {