@avocadostudio-ai/site-sdk 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/register.js +23 -1
- package/dist/draft-fetch.d.ts +9 -10
- package/dist/draft-fetch.js +71 -5
- package/dist/draft-fetch.test.d.ts +1 -0
- package/dist/draft-fetch.test.js +87 -0
- package/dist/next-config.test.js +104 -2
- package/dist/proxy.d.ts +39 -2
- package/dist/proxy.js +28 -2
- package/dist/proxy.test.js +51 -0
- package/next-config.d.ts +13 -3
- package/next-config.mjs +132 -22
- package/package.json +5 -5
package/dist/cli/register.js
CHANGED
|
@@ -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: {
|
|
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());
|
package/dist/draft-fetch.d.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import type { PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
|
|
2
2
|
export declare function getOrchestratorUrl(): string | null;
|
|
3
|
-
|
|
3
|
+
type DraftFetchOptions = {
|
|
4
4
|
timeoutMs?: number;
|
|
5
5
|
orchestratorUrl?: string;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export declare function
|
|
12
|
-
|
|
13
|
-
|
|
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 {};
|
package/dist/draft-fetch.js
CHANGED
|
@@ -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, {
|
|
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
|
+
});
|
package/dist/next-config.test.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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
|
|
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}`;
|
package/dist/proxy.test.js
CHANGED
|
@@ -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,
|
|
40
|
-
* Avocado's native and provider dependencies stay external to the server build
|
|
41
|
-
*
|
|
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
|
-
/**
|
|
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(
|
|
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,
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
|
301
|
-
*
|
|
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
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.1",
|
|
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/
|
|
111
|
-
"@avocadostudio-ai/
|
|
112
|
-
"@avocadostudio-ai/
|
|
110
|
+
"@avocadostudio-ai/shared": "0.2.1",
|
|
111
|
+
"@avocadostudio-ai/blocks": "0.2.1",
|
|
112
|
+
"@avocadostudio-ai/preview-adapter": "0.2.1"
|
|
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.
|
|
119
|
+
"@avocadostudio-ai/orchestrator-core": "^0.2.1"
|
|
120
120
|
},
|
|
121
121
|
"peerDependenciesMeta": {
|
|
122
122
|
"@avocadostudio-ai/orchestrator-core": {
|