@avocadostudio-ai/site-sdk 0.1.0
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/LICENSE +201 -0
- package/README.md +145 -0
- package/dist/cli/register.d.ts +33 -0
- package/dist/cli/register.js +315 -0
- package/dist/create-site-page.d.ts +95 -0
- package/dist/create-site-page.js +127 -0
- package/dist/draft-common.d.ts +4 -0
- package/dist/draft-common.js +17 -0
- package/dist/draft-context-core.d.ts +11 -0
- package/dist/draft-context-core.js +26 -0
- package/dist/draft-context.d.ts +8 -0
- package/dist/draft-context.js +14 -0
- package/dist/draft-fetch.d.ts +14 -0
- package/dist/draft-fetch.js +115 -0
- package/dist/draft-routes-core.d.ts +11 -0
- package/dist/draft-routes-core.js +41 -0
- package/dist/draft-routes.d.ts +2 -0
- package/dist/draft-routes.js +27 -0
- package/dist/draft.d.ts +3 -0
- package/dist/draft.js +6 -0
- package/dist/editor-api-handler.d.ts +59 -0
- package/dist/editor-api-handler.js +89 -0
- package/dist/editor-cors.d.ts +3 -0
- package/dist/editor-cors.js +31 -0
- package/dist/editor-manifest.d.ts +3 -0
- package/dist/editor-manifest.js +65 -0
- package/dist/editor-overlay-inner.d.ts +5 -0
- package/dist/editor-overlay-inner.js +7 -0
- package/dist/editor-overlay.d.ts +4 -0
- package/dist/editor-overlay.js +14 -0
- package/dist/editor-query.d.ts +2 -0
- package/dist/editor-query.js +16 -0
- package/dist/editor-routes.d.ts +35 -0
- package/dist/editor-routes.js +66 -0
- package/dist/editor.d.ts +20 -0
- package/dist/editor.js +22 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/integration-check.d.ts +5 -0
- package/dist/integration-check.js +29 -0
- package/dist/live-preview-blocks.d.ts +5 -0
- package/dist/live-preview-blocks.js +30 -0
- package/dist/manifest-utils.d.ts +17 -0
- package/dist/manifest-utils.js +44 -0
- package/dist/middleware.d.ts +37 -0
- package/dist/middleware.js +34 -0
- package/dist/navigation.d.ts +48 -0
- package/dist/navigation.js +95 -0
- package/dist/publish-handlers/json-file.d.ts +24 -0
- package/dist/publish-handlers/json-file.js +40 -0
- package/dist/publish-utils.d.ts +28 -0
- package/dist/publish-utils.js +92 -0
- package/dist/render-blocks.d.ts +4 -0
- package/dist/render-blocks.js +26 -0
- package/dist/revalidate-handler.d.ts +42 -0
- package/dist/revalidate-handler.js +77 -0
- package/dist/routes.d.ts +10 -0
- package/dist/routes.js +12 -0
- package/dist/server/orchestrator.d.ts +117 -0
- package/dist/server/orchestrator.js +733 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.js +1 -0
- package/package.json +104 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
// createOrchestrator — Web-standard handler that wraps @avocadostudio-ai/orchestrator-core.
|
|
2
|
+
//
|
|
3
|
+
// PoC scope (feat/library-mode):
|
|
4
|
+
// - POST /chat → JSON in / JSON out (planner pipeline, non-streaming)
|
|
5
|
+
// - POST /chat/stream → SSE response via ReadableStream
|
|
6
|
+
// - everything else → 405 with a helpful pointer
|
|
7
|
+
//
|
|
8
|
+
// What's deliberately out of scope here:
|
|
9
|
+
// - Audio transcribe (multipart) and the AI image-gen / Unsplash / gdrive
|
|
10
|
+
// picker sources (those live in apps/orchestrator)
|
|
11
|
+
// - All other route plugins (sites, sessions, history, jira, agent, etc.)
|
|
12
|
+
//
|
|
13
|
+
// In scope as of library mode: image upload to local disk (POST /image/upload
|
|
14
|
+
// + GET /generated-images/:fileName) — POC-grade storage, see config.imageDir.
|
|
15
|
+
//
|
|
16
|
+
// The point is to feel the friction at the brain ↔ Web-handler boundary for the
|
|
17
|
+
// hardest endpoint family (streaming chat). If that holds, porting the
|
|
18
|
+
// remaining ~13 route plugins is mechanical. See EVAL.md in orchestrator-core.
|
|
19
|
+
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
|
20
|
+
import { resolve, basename } from "node:path";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { operationSchema, blockManifestSchema, siteConfigSchema } from "@avocadostudio-ai/shared";
|
|
24
|
+
import { chatRequestBodySchema } from "@avocadostudio-ai/orchestrator-core/nlp/intent-detection.js";
|
|
25
|
+
import { applyOpsAtomically, pickFocusBlockId, pickUpdatedSlug, toErrorDetail, classifyGuardrailError } from "@avocadostudio-ai/orchestrator-core/ops/ops-engine.js";
|
|
26
|
+
import { runChatStream, formatSseFrame } from "@avocadostudio-ai/orchestrator-core/http/chat-stream.js";
|
|
27
|
+
import { ResumableStreamStore, runResumableChatStream, TooManyPendingStreamsError, isTerminalState } from "@avocadostudio-ai/orchestrator-core/http/chat-stream-resumable.js";
|
|
28
|
+
import { runChatPipeline } from "@avocadostudio-ai/orchestrator-core/chat/chat-pipeline.js";
|
|
29
|
+
import { createChatTelemetryStore } from "@avocadostudio-ai/orchestrator-core/telemetry/chat-telemetry.js";
|
|
30
|
+
import { createToolRuntime } from "@avocadostudio-ai/orchestrator-core/tools/runtime.js";
|
|
31
|
+
import { loadStateFromDisk, scopedSessionKey, getSessionPages, getPage, getSiteConfig, setSiteConfig, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, schedulePersistState } from "@avocadostudio-ai/orchestrator-core/state/session-state.js";
|
|
32
|
+
import { consoleLogger } from "@avocadostudio-ai/orchestrator-core/logger.js";
|
|
33
|
+
import { createCmsBootstrapCache } from "@avocadostudio-ai/orchestrator-core/cms/bootstrap.js";
|
|
34
|
+
const defaultModelLookup = () => ({
|
|
35
|
+
openai: {
|
|
36
|
+
fast: process.env.OPENAI_MODEL_FAST ?? "gpt-4o-mini",
|
|
37
|
+
balanced: process.env.OPENAI_MODEL_BALANCED ?? "gpt-4o",
|
|
38
|
+
reasoning: process.env.OPENAI_MODEL_REASONING ?? "o1",
|
|
39
|
+
codex: process.env.OPENAI_MODEL_CODEX ?? "o3"
|
|
40
|
+
},
|
|
41
|
+
anthropic: {
|
|
42
|
+
fast: process.env.ANTHROPIC_MODEL_FAST ?? "claude-haiku-4-5-20251001",
|
|
43
|
+
balanced: process.env.ANTHROPIC_MODEL_BALANCED ?? "claude-sonnet-5",
|
|
44
|
+
reasoning: process.env.ANTHROPIC_MODEL_REASONING ?? "claude-sonnet-5",
|
|
45
|
+
codex: process.env.ANTHROPIC_MODEL_CODEX ?? "claude-opus-4-8"
|
|
46
|
+
},
|
|
47
|
+
gemini: {
|
|
48
|
+
fast: process.env.GOOGLE_GENAI_MODEL_FAST ?? "gemini-2.5-flash",
|
|
49
|
+
balanced: process.env.GOOGLE_GENAI_MODEL_BALANCED ?? "gemini-2.5-flash",
|
|
50
|
+
reasoning: process.env.GOOGLE_GENAI_MODEL_REASONING ?? "gemini-2.5-pro",
|
|
51
|
+
codex: process.env.GOOGLE_GENAI_MODEL_CODEX ?? "gemini-2.5-pro"
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
const defaultProviders = () => [
|
|
55
|
+
...(process.env.OPENAI_API_KEY ? ["openai"] : []),
|
|
56
|
+
...(process.env.ANTHROPIC_API_KEY ? ["anthropic"] : []),
|
|
57
|
+
...(process.env.GOOGLE_GENAI_API_KEY ? ["gemini"] : [])
|
|
58
|
+
];
|
|
59
|
+
async function buildRuntime(config) {
|
|
60
|
+
const log = config.logger ?? consoleLogger();
|
|
61
|
+
// Register host-app block schemas BEFORE the planner ever validates ops.
|
|
62
|
+
// Done here rather than at module load so the host's overrides land on the
|
|
63
|
+
// shared globalThis registry after any transitive canonical re-registration
|
|
64
|
+
// from @avocadostudio-ai/shared has already fired.
|
|
65
|
+
if (config.registerBlocks) {
|
|
66
|
+
try {
|
|
67
|
+
config.registerBlocks();
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
log.warn({ err: err instanceof Error ? err.message : String(err) }, "registerBlocks() threw — continuing with whatever was already registered");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const chatTelemetry = createChatTelemetryStore({
|
|
74
|
+
filePath: process.env.CHAT_TELEMETRY_FILE ?? "./.data/chat-telemetry.ndjson",
|
|
75
|
+
limit: Number(process.env.CHAT_TELEMETRY_LIMIT ?? 500),
|
|
76
|
+
persistEnabled: !/^(0|false|no|off)$/i.test((process.env.CHAT_TELEMETRY_PERSIST ?? "1").trim()),
|
|
77
|
+
logger: log
|
|
78
|
+
});
|
|
79
|
+
const toolRuntime = await createToolRuntime({ logger: log });
|
|
80
|
+
if (config.builtinTools) {
|
|
81
|
+
// Dynamic import so the builtins module (and its image/ + googleapis +
|
|
82
|
+
// sharp transitive deps) isn't pulled into the bundle when the consumer
|
|
83
|
+
// doesn't opt in.
|
|
84
|
+
const { registerDefaultBuiltins } = await import("@avocadostudio-ai/orchestrator-core/tools/builtin-registrations.js");
|
|
85
|
+
registerDefaultBuiltins(toolRuntime.registry, Array.isArray(config.builtinTools) ? { include: config.builtinTools } : {});
|
|
86
|
+
}
|
|
87
|
+
const pipelineCtx = {
|
|
88
|
+
log,
|
|
89
|
+
chatTelemetry,
|
|
90
|
+
modelLookup: config.modelLookup ?? defaultModelLookup(),
|
|
91
|
+
availableProviders: config.availableProviders ?? defaultProviders(),
|
|
92
|
+
toolRuntime
|
|
93
|
+
};
|
|
94
|
+
const ready = (async () => {
|
|
95
|
+
await loadStateFromDisk(log);
|
|
96
|
+
await chatTelemetry.loadFromDisk();
|
|
97
|
+
})();
|
|
98
|
+
const resumableStore = new ResumableStreamStore();
|
|
99
|
+
const bootstrapCache = createCmsBootstrapCache();
|
|
100
|
+
return {
|
|
101
|
+
pipelineCtx,
|
|
102
|
+
ready,
|
|
103
|
+
resumableStore,
|
|
104
|
+
log,
|
|
105
|
+
adapter: config.adapter ?? null,
|
|
106
|
+
bootstrapCache
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const SSE_HEADERS = (origin) => ({
|
|
110
|
+
"content-type": "text/event-stream",
|
|
111
|
+
"cache-control": "no-cache, no-transform",
|
|
112
|
+
"connection": "keep-alive",
|
|
113
|
+
"x-accel-buffering": "no",
|
|
114
|
+
"access-control-allow-origin": origin,
|
|
115
|
+
"vary": "Origin"
|
|
116
|
+
});
|
|
117
|
+
function corsHeadersFor(request, config) {
|
|
118
|
+
if (config.corsOrigins === null)
|
|
119
|
+
return {};
|
|
120
|
+
const origin = request.headers.get("origin") ?? "*";
|
|
121
|
+
if (config.corsOrigins === undefined || config.corsOrigins === "*") {
|
|
122
|
+
return { "access-control-allow-origin": origin, "vary": "Origin" };
|
|
123
|
+
}
|
|
124
|
+
return config.corsOrigins.includes(origin)
|
|
125
|
+
? { "access-control-allow-origin": origin, "vary": "Origin" }
|
|
126
|
+
: {};
|
|
127
|
+
}
|
|
128
|
+
function jsonResponse(body, init = {}) {
|
|
129
|
+
return new Response(JSON.stringify(body), {
|
|
130
|
+
status: init.status ?? 200,
|
|
131
|
+
headers: { "content-type": "application/json", ...(init.cors ?? {}) }
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
135
|
+
// MIME ⇄ extension for the local image store. Kept narrow on purpose: only the
|
|
136
|
+
// formats a browser <img>/Next <Image> renders. Unknown types are rejected.
|
|
137
|
+
const MIME_TO_EXT = {
|
|
138
|
+
"image/png": "png",
|
|
139
|
+
"image/jpeg": "jpg",
|
|
140
|
+
"image/webp": "webp",
|
|
141
|
+
"image/gif": "gif",
|
|
142
|
+
"image/avif": "avif",
|
|
143
|
+
"image/svg+xml": "svg"
|
|
144
|
+
};
|
|
145
|
+
const EXT_TO_MIME = {
|
|
146
|
+
png: "image/png",
|
|
147
|
+
jpg: "image/jpeg",
|
|
148
|
+
jpeg: "image/jpeg",
|
|
149
|
+
webp: "image/webp",
|
|
150
|
+
gif: "image/gif",
|
|
151
|
+
avif: "image/avif",
|
|
152
|
+
svg: "image/svg+xml"
|
|
153
|
+
};
|
|
154
|
+
export { jsonFileAdapter, editorApiAdapter } from "@avocadostudio-ai/orchestrator-core/cms/index.js";
|
|
155
|
+
function stripBasePath(pathname, basePath) {
|
|
156
|
+
if (!basePath)
|
|
157
|
+
return pathname || "/";
|
|
158
|
+
if (pathname === basePath)
|
|
159
|
+
return "/";
|
|
160
|
+
if (pathname.startsWith(basePath + "/"))
|
|
161
|
+
return pathname.slice(basePath.length) || "/";
|
|
162
|
+
return pathname || "/";
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Build a Web-standard request handler that wraps the orchestrator brain.
|
|
166
|
+
*
|
|
167
|
+
* Usage in Next.js App Router (`app/api/avocado/[[...path]]/route.ts`):
|
|
168
|
+
*
|
|
169
|
+
* export const runtime = "nodejs"
|
|
170
|
+
* const handler = createOrchestrator()
|
|
171
|
+
* export const POST = handler
|
|
172
|
+
* export const OPTIONS = handler
|
|
173
|
+
*/
|
|
174
|
+
export function createOrchestrator(config = {}) {
|
|
175
|
+
const basePath = config.basePath ?? "/api/avocado";
|
|
176
|
+
// When an adapter is configured, force-scope sessions so the orchestrator's
|
|
177
|
+
// built-in demo-content seed path (triggered when a session key has no `::`)
|
|
178
|
+
// doesn't fire ahead of the adapter. Without this, the first chat turn ends
|
|
179
|
+
// up editing the bundled demo pages instead of the site's real content.
|
|
180
|
+
const effectiveSiteId = config.adapter ? (config.siteId ?? "library") : config.siteId;
|
|
181
|
+
const scope = (session, bodySiteId) => scopedSessionKey(session, effectiveSiteId ?? bodySiteId);
|
|
182
|
+
const imageDir = config.imageDir ?? resolve(process.cwd(), ".data/generated-images");
|
|
183
|
+
let runtimePromise = null;
|
|
184
|
+
const getRuntime = () => {
|
|
185
|
+
if (!runtimePromise)
|
|
186
|
+
runtimePromise = buildRuntime(config);
|
|
187
|
+
return runtimePromise;
|
|
188
|
+
};
|
|
189
|
+
const handler = async function handler(request) {
|
|
190
|
+
const url = new URL(request.url);
|
|
191
|
+
const path = stripBasePath(url.pathname, basePath);
|
|
192
|
+
const cors = corsHeadersFor(request, config);
|
|
193
|
+
if (request.method === "OPTIONS") {
|
|
194
|
+
return new Response(null, {
|
|
195
|
+
status: 204,
|
|
196
|
+
headers: {
|
|
197
|
+
...cors,
|
|
198
|
+
"access-control-allow-methods": "GET, POST, PUT, OPTIONS",
|
|
199
|
+
"access-control-allow-headers": "content-type"
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (request.method === "POST" && path === "/chat") {
|
|
204
|
+
const runtime = await getRuntime();
|
|
205
|
+
await runtime.ready;
|
|
206
|
+
let raw;
|
|
207
|
+
try {
|
|
208
|
+
raw = await request.json();
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
|
|
212
|
+
}
|
|
213
|
+
const parsed = chatRequestBodySchema.safeParse(raw);
|
|
214
|
+
if (!parsed.success)
|
|
215
|
+
return jsonResponse({ error: "invalid request body", details: parsed.error.issues }, { status: 400, cors });
|
|
216
|
+
const body = parsed.data;
|
|
217
|
+
const scopedSession = scope(body.session, body.siteId);
|
|
218
|
+
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
219
|
+
const result = await runChatPipeline(runtime.pipelineCtx, {
|
|
220
|
+
...body,
|
|
221
|
+
session: scopedSession
|
|
222
|
+
});
|
|
223
|
+
return jsonResponse(result.payload, { status: result.code, cors });
|
|
224
|
+
}
|
|
225
|
+
if (request.method === "POST" && path === "/chat/stream") {
|
|
226
|
+
const runtime = await getRuntime();
|
|
227
|
+
await runtime.ready;
|
|
228
|
+
let raw;
|
|
229
|
+
try {
|
|
230
|
+
raw = await request.json();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
|
|
234
|
+
}
|
|
235
|
+
const parsed = chatRequestBodySchema.safeParse(raw);
|
|
236
|
+
if (!parsed.success)
|
|
237
|
+
return jsonResponse({ error: "invalid request body", details: parsed.error.issues }, { status: 400, cors });
|
|
238
|
+
const body = parsed.data;
|
|
239
|
+
const scoped = { ...body, session: scope(body.session, body.siteId) };
|
|
240
|
+
await runtime.bootstrapCache.ensure(scoped.session, runtime.adapter, runtime.log);
|
|
241
|
+
const origin = request.headers.get("origin") ?? "*";
|
|
242
|
+
const encoder = new TextEncoder();
|
|
243
|
+
const stream = new ReadableStream({
|
|
244
|
+
async start(controller) {
|
|
245
|
+
const emit = (event) => {
|
|
246
|
+
try {
|
|
247
|
+
controller.enqueue(encoder.encode(formatSseFrame(event)));
|
|
248
|
+
}
|
|
249
|
+
catch { /* controller closed early (client disconnected) */ }
|
|
250
|
+
};
|
|
251
|
+
// SSE retry hint (60s) — matches the Fastify route's behavior.
|
|
252
|
+
try {
|
|
253
|
+
controller.enqueue(encoder.encode("retry: 60000\n\n"));
|
|
254
|
+
}
|
|
255
|
+
catch { /* */ }
|
|
256
|
+
try {
|
|
257
|
+
await runChatStream(runtime.pipelineCtx, scoped, { emit, signal: request.signal });
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
try {
|
|
261
|
+
controller.close();
|
|
262
|
+
}
|
|
263
|
+
catch { /* */ }
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
cancel() {
|
|
267
|
+
// Client disconnect — request.signal fires automatically, and
|
|
268
|
+
// runChatPipeline observes it. Nothing else to do here.
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
return new Response(stream, { status: 200, headers: SSE_HEADERS(origin) });
|
|
272
|
+
}
|
|
273
|
+
// ---- Resumable streaming triplet -----------------------------------
|
|
274
|
+
// POST /chat/start → allocate streamId
|
|
275
|
+
// GET /chat/stream → run the pipeline (or replay+subscribe on reconnect)
|
|
276
|
+
// POST /chat/cancel → abort an active run
|
|
277
|
+
if (request.method === "POST" && path === "/chat/start") {
|
|
278
|
+
const runtime = await getRuntime();
|
|
279
|
+
await runtime.ready;
|
|
280
|
+
let raw;
|
|
281
|
+
try {
|
|
282
|
+
raw = await request.json();
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
|
|
286
|
+
}
|
|
287
|
+
const parsed = chatRequestBodySchema.safeParse(raw);
|
|
288
|
+
if (!parsed.success)
|
|
289
|
+
return jsonResponse({ error: "invalid request body", details: parsed.error.issues }, { status: 400, cors });
|
|
290
|
+
const body = parsed.data;
|
|
291
|
+
if (!body.session)
|
|
292
|
+
return jsonResponse({ error: "session is required" }, { status: 400, cors });
|
|
293
|
+
const origin = request.headers.get("origin") ?? "*";
|
|
294
|
+
try {
|
|
295
|
+
const entry = runtime.resumableStore.allocate({
|
|
296
|
+
body,
|
|
297
|
+
session: body.session,
|
|
298
|
+
siteId: body.siteId ?? "",
|
|
299
|
+
origin
|
|
300
|
+
});
|
|
301
|
+
return jsonResponse({ streamId: entry.streamId }, { status: 200, cors });
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
if (err instanceof TooManyPendingStreamsError) {
|
|
305
|
+
return jsonResponse({ error: "Too many pending streams for this session" }, { status: 429, cors });
|
|
306
|
+
}
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (request.method === "POST" && path === "/chat/cancel") {
|
|
311
|
+
const runtime = await getRuntime();
|
|
312
|
+
let raw;
|
|
313
|
+
try {
|
|
314
|
+
raw = await request.json();
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
|
|
318
|
+
}
|
|
319
|
+
const body = (raw ?? {});
|
|
320
|
+
const result = runtime.resumableStore.cancel(body);
|
|
321
|
+
if (result.status === "not_found")
|
|
322
|
+
return jsonResponse({ status: "not_found" }, { status: 404, cors });
|
|
323
|
+
if (result.status === "already_terminal")
|
|
324
|
+
return jsonResponse({ status: "already_terminal" }, { status: 200, cors });
|
|
325
|
+
return jsonResponse({ status: "cancel_requested" }, { status: 200, cors });
|
|
326
|
+
}
|
|
327
|
+
if (request.method === "GET" && path === "/chat/stream") {
|
|
328
|
+
const runtime = await getRuntime();
|
|
329
|
+
await runtime.ready;
|
|
330
|
+
const streamId = url.searchParams.get("streamId");
|
|
331
|
+
const afterSeqRaw = url.searchParams.get("afterSeq");
|
|
332
|
+
const afterSeq = afterSeqRaw === null ? 0 : Number(afterSeqRaw) || 0;
|
|
333
|
+
const isReconnect = afterSeqRaw !== null;
|
|
334
|
+
const reqOrigin = request.headers.get("origin") ?? "*";
|
|
335
|
+
if (!streamId) {
|
|
336
|
+
return jsonResponse({ error: "streamId query param required (use POST /chat/start to allocate one)" }, { status: 400, cors });
|
|
337
|
+
}
|
|
338
|
+
const entry = runtime.resumableStore.get(streamId);
|
|
339
|
+
if (!entry)
|
|
340
|
+
return jsonResponse({ error: "Stream context expired or not found" }, { status: 410, cors });
|
|
341
|
+
// Origin check
|
|
342
|
+
if (entry.origin !== "*" && reqOrigin !== "*" && entry.origin !== reqOrigin) {
|
|
343
|
+
return jsonResponse({ error: "Origin mismatch" }, { status: 403, cors });
|
|
344
|
+
}
|
|
345
|
+
const encoder = new TextEncoder();
|
|
346
|
+
const streamOrigin = entry.origin !== "*" ? entry.origin : reqOrigin;
|
|
347
|
+
// Reconnect: replay + subscribe (don't kick off a new run)
|
|
348
|
+
if (isReconnect) {
|
|
349
|
+
const sseStream = new ReadableStream({
|
|
350
|
+
start(controller) {
|
|
351
|
+
try {
|
|
352
|
+
controller.enqueue(encoder.encode("retry: 60000\n\n"));
|
|
353
|
+
}
|
|
354
|
+
catch { /* */ }
|
|
355
|
+
const subscriber = {
|
|
356
|
+
emit: (envelope) => {
|
|
357
|
+
try {
|
|
358
|
+
controller.enqueue(encoder.encode(formatSseFrame(envelope)));
|
|
359
|
+
}
|
|
360
|
+
catch { /* */ }
|
|
361
|
+
},
|
|
362
|
+
close: () => { try {
|
|
363
|
+
controller.close();
|
|
364
|
+
}
|
|
365
|
+
catch { /* */ } }
|
|
366
|
+
};
|
|
367
|
+
const unsubscribe = runtime.resumableStore.subscribe(streamId, subscriber, afterSeq);
|
|
368
|
+
request.signal.addEventListener("abort", () => unsubscribe());
|
|
369
|
+
},
|
|
370
|
+
cancel() { }
|
|
371
|
+
});
|
|
372
|
+
return new Response(sseStream, { status: 200, headers: SSE_HEADERS(streamOrigin) });
|
|
373
|
+
}
|
|
374
|
+
// First connection: only valid for pending streams
|
|
375
|
+
if (entry.state === "active")
|
|
376
|
+
return jsonResponse({ error: "Pipeline already running" }, { status: 409, cors });
|
|
377
|
+
if (isTerminalState(entry.state))
|
|
378
|
+
return jsonResponse({ error: "Stream already completed" }, { status: 410, cors });
|
|
379
|
+
const scoped = { ...entry.body, session: scope(entry.body.session, entry.body.siteId) };
|
|
380
|
+
await runtime.bootstrapCache.ensure(scoped.session, runtime.adapter, runtime.log);
|
|
381
|
+
const sseStream = new ReadableStream({
|
|
382
|
+
async start(controller) {
|
|
383
|
+
try {
|
|
384
|
+
controller.enqueue(encoder.encode("retry: 60000\n\n"));
|
|
385
|
+
}
|
|
386
|
+
catch { /* */ }
|
|
387
|
+
const subscriber = {
|
|
388
|
+
emit: (envelope) => {
|
|
389
|
+
try {
|
|
390
|
+
controller.enqueue(encoder.encode(formatSseFrame(envelope)));
|
|
391
|
+
}
|
|
392
|
+
catch { /* */ }
|
|
393
|
+
},
|
|
394
|
+
close: () => { try {
|
|
395
|
+
controller.close();
|
|
396
|
+
}
|
|
397
|
+
catch { /* */ } }
|
|
398
|
+
};
|
|
399
|
+
// Subscribe BEFORE starting the pipeline so we don't miss early events.
|
|
400
|
+
const unsubscribe = runtime.resumableStore.subscribe(streamId, subscriber, 0);
|
|
401
|
+
request.signal.addEventListener("abort", () => unsubscribe());
|
|
402
|
+
await runResumableChatStream(runtime.pipelineCtx, runtime.resumableStore, streamId, scoped);
|
|
403
|
+
// closeAllSubscribers in runResumableChatStream closes the controller.
|
|
404
|
+
},
|
|
405
|
+
cancel() { }
|
|
406
|
+
});
|
|
407
|
+
return new Response(sseStream, { status: 200, headers: SSE_HEADERS(streamOrigin) });
|
|
408
|
+
}
|
|
409
|
+
// ---- Library-mode publish ------------------------------------------
|
|
410
|
+
// Minimal: takes the current draft pages for {session, siteId} and hands
|
|
411
|
+
// them to the configured adapter's onPublish() if present. No git/deploy
|
|
412
|
+
// wiring — that lives in apps/orchestrator. The adapter IS the destination.
|
|
413
|
+
if (request.method === "POST" && path === "/publish") {
|
|
414
|
+
const runtime = await getRuntime();
|
|
415
|
+
await runtime.ready;
|
|
416
|
+
let raw;
|
|
417
|
+
try {
|
|
418
|
+
raw = await request.json();
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
|
|
422
|
+
}
|
|
423
|
+
const body = (raw ?? {});
|
|
424
|
+
const scopedSession = scope(body.session, body.siteId);
|
|
425
|
+
const pages = getSessionPages(scopedSession);
|
|
426
|
+
if (!runtime.adapter?.onPublish) {
|
|
427
|
+
return jsonResponse({ ok: true, written: false, count: pages.length, reason: "adapter has no onPublish; publish is a no-op" }, { status: 200, cors });
|
|
428
|
+
}
|
|
429
|
+
const config = getSiteConfig(scopedSession);
|
|
430
|
+
const context = body.assets ? { assets: body.assets } : undefined;
|
|
431
|
+
try {
|
|
432
|
+
const result = await runtime.adapter.onPublish(pages, config, context);
|
|
433
|
+
if (result && typeof result === "object" && result.ok === false) {
|
|
434
|
+
runtime.log.warn({ session: scopedSession, adapter: runtime.adapter.id, error: result.error }, "library-publish: adapter.onPublish() returned not-ok");
|
|
435
|
+
return jsonResponse({ ok: false, written: false, count: pages.length, error: result.error ?? "adapter.onPublish returned not-ok" }, { status: 502, cors });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
catch (err) {
|
|
439
|
+
runtime.log.warn({ session: scopedSession, adapter: runtime.adapter.id, err: err instanceof Error ? err.stack ?? err.message : String(err) }, "library-publish: adapter.onPublish() threw");
|
|
440
|
+
return jsonResponse({ ok: false, error: err instanceof Error ? err.message : "adapter.onPublish failed" }, { status: 502, cors });
|
|
441
|
+
}
|
|
442
|
+
return jsonResponse({ ok: true, written: true, count: pages.length }, { status: 200, cors });
|
|
443
|
+
}
|
|
444
|
+
// The editor polls this on boot to populate its model selector and the
|
|
445
|
+
// planner-source badge. Without it, availableProviders stays empty and the
|
|
446
|
+
// selector falls back to its built-in default (OpenAI), hiding Claude even
|
|
447
|
+
// when only "anthropic" is configured.
|
|
448
|
+
if (request.method === "GET" && path === "/status/planner") {
|
|
449
|
+
const runtime = await getRuntime();
|
|
450
|
+
const providers = runtime.pipelineCtx.availableProviders;
|
|
451
|
+
const hasImageBackend = Boolean(process.env.OPENAI_API_KEY || process.env.GOOGLE_GENAI_API_KEY);
|
|
452
|
+
return jsonResponse({
|
|
453
|
+
plannerSource: providers[0] ?? "demo",
|
|
454
|
+
availableProviders: providers,
|
|
455
|
+
features: {
|
|
456
|
+
googleDrive: false,
|
|
457
|
+
unsplash: Boolean(process.env.UNSPLASH_ACCESS_KEY),
|
|
458
|
+
imageGenerate: hasImageBackend,
|
|
459
|
+
imageGenerateChat: hasImageBackend,
|
|
460
|
+
agentMode: false
|
|
461
|
+
}
|
|
462
|
+
}, { status: 200, cors });
|
|
463
|
+
}
|
|
464
|
+
// ---- Draft read + edit surface (the editor's property panel) -------
|
|
465
|
+
// The editor needs these to display and edit a site's draft content.
|
|
466
|
+
// Each seeds the draft from the adapter first (bootstrapCache.ensure is
|
|
467
|
+
// idempotent) so they work before any chat turn has run.
|
|
468
|
+
// The editor fetches the selected block's props from here (useBlockProps).
|
|
469
|
+
// Without it the property panel can never reach `ready` — it shows the
|
|
470
|
+
// block breadcrumb but no editable fields.
|
|
471
|
+
if (request.method === "GET" && path === "/draft/pages") {
|
|
472
|
+
const runtime = await getRuntime();
|
|
473
|
+
await runtime.ready;
|
|
474
|
+
const session = url.searchParams.get("session") ?? undefined;
|
|
475
|
+
const siteId = url.searchParams.get("siteId") ?? undefined;
|
|
476
|
+
const slug = url.searchParams.get("slug") ?? undefined;
|
|
477
|
+
if (!session || !slug) {
|
|
478
|
+
return jsonResponse({ error: "session and slug are required" }, { status: 400, cors });
|
|
479
|
+
}
|
|
480
|
+
const scopedSession = scope(session, siteId);
|
|
481
|
+
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
482
|
+
const page = getPage(scopedSession, slug);
|
|
483
|
+
if (!page)
|
|
484
|
+
return jsonResponse({ error: "not found" }, { status: 404, cors });
|
|
485
|
+
return jsonResponse(structuredClone(page), { status: 200, cors });
|
|
486
|
+
}
|
|
487
|
+
// Page list. Also flips the editor's `hasBootstrapped` gate — until this
|
|
488
|
+
// returns a non-empty list, the property panel never enables its fetch.
|
|
489
|
+
if (request.method === "GET" && path === "/draft/slugs") {
|
|
490
|
+
const runtime = await getRuntime();
|
|
491
|
+
await runtime.ready;
|
|
492
|
+
const session = url.searchParams.get("session") ?? undefined;
|
|
493
|
+
const siteId = url.searchParams.get("siteId") ?? undefined;
|
|
494
|
+
const scopedSession = scope(session, siteId);
|
|
495
|
+
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
496
|
+
const pages = getSessionPages(scopedSession);
|
|
497
|
+
return jsonResponse({
|
|
498
|
+
slugs: pages.map((p) => p.slug),
|
|
499
|
+
pages: pages.map((p) => ({
|
|
500
|
+
slug: p.slug,
|
|
501
|
+
title: p.title ?? "",
|
|
502
|
+
updatedAt: p.updatedAt ?? "",
|
|
503
|
+
blockCount: p.blocks?.length ?? 0
|
|
504
|
+
}))
|
|
505
|
+
}, { status: 200, cors });
|
|
506
|
+
}
|
|
507
|
+
// Editor bootstrap probe. In library mode the adapter is the source of
|
|
508
|
+
// truth (already seeded by ensure), so this is effectively a confirm — we
|
|
509
|
+
// don't clobber the draft with the posted pages.
|
|
510
|
+
if (request.method === "POST" && path === "/draft/bootstrap") {
|
|
511
|
+
const runtime = await getRuntime();
|
|
512
|
+
await runtime.ready;
|
|
513
|
+
let raw;
|
|
514
|
+
try {
|
|
515
|
+
raw = await request.json();
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
raw = {};
|
|
519
|
+
}
|
|
520
|
+
const body = (raw ?? {});
|
|
521
|
+
const scopedSession = scope(body.session, body.siteId);
|
|
522
|
+
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
523
|
+
const pages = getSessionPages(scopedSession);
|
|
524
|
+
return jsonResponse({ status: "bootstrapped", count: pages.length, slugs: pages.map((p) => p.slug) }, { status: 200, cors });
|
|
525
|
+
}
|
|
526
|
+
// Site config — drives the page-level nav-label + SEO fields shown in the
|
|
527
|
+
// property panel when no block is selected.
|
|
528
|
+
if (request.method === "GET" && path === "/draft/site-config") {
|
|
529
|
+
const runtime = await getRuntime();
|
|
530
|
+
await runtime.ready;
|
|
531
|
+
const session = url.searchParams.get("session") ?? undefined;
|
|
532
|
+
const siteId = url.searchParams.get("siteId") ?? undefined;
|
|
533
|
+
if (!session)
|
|
534
|
+
return jsonResponse({ error: "session is required" }, { status: 400, cors });
|
|
535
|
+
const scopedSession = scope(session, siteId);
|
|
536
|
+
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
537
|
+
return jsonResponse(getSiteConfig(scopedSession), { status: 200, cors });
|
|
538
|
+
}
|
|
539
|
+
if (request.method === "PUT" && path === "/draft/site-config") {
|
|
540
|
+
const runtime = await getRuntime();
|
|
541
|
+
await runtime.ready;
|
|
542
|
+
let raw;
|
|
543
|
+
try {
|
|
544
|
+
raw = await request.json();
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
|
|
548
|
+
}
|
|
549
|
+
const body = (raw ?? {});
|
|
550
|
+
if (!body.session)
|
|
551
|
+
return jsonResponse({ error: "session is required" }, { status: 400, cors });
|
|
552
|
+
const parsed = siteConfigSchema.safeParse(body.config);
|
|
553
|
+
if (!parsed.success)
|
|
554
|
+
return jsonResponse({ error: "invalid config", details: parsed.error.issues }, { status: 400, cors });
|
|
555
|
+
const scopedSession = scope(body.session, body.siteId);
|
|
556
|
+
setSiteConfig(scopedSession, parsed.data);
|
|
557
|
+
schedulePersistState(runtime.log);
|
|
558
|
+
return jsonResponse({ status: "ok", config: getSiteConfig(scopedSession) }, { status: 200, cors });
|
|
559
|
+
}
|
|
560
|
+
// Apply operations — the editor's property-panel field edits, page-meta
|
|
561
|
+
// edits, and structural edits all POST here. This is the write path that
|
|
562
|
+
// makes the draft (and therefore the live preview + publish) reflect edits.
|
|
563
|
+
if (request.method === "POST" && path === "/ops") {
|
|
564
|
+
const runtime = await getRuntime();
|
|
565
|
+
await runtime.ready;
|
|
566
|
+
let raw;
|
|
567
|
+
try {
|
|
568
|
+
raw = await request.json();
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
|
|
572
|
+
}
|
|
573
|
+
const body = (raw ?? {});
|
|
574
|
+
const scopedSession = scope(body.session, body.siteId);
|
|
575
|
+
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
576
|
+
const parsedOps = z.array(operationSchema).safeParse(body.ops);
|
|
577
|
+
if (!parsedOps.success)
|
|
578
|
+
return jsonResponse({ error: "invalid ops payload", details: parsedOps.error.issues }, { status: 400, cors });
|
|
579
|
+
if (parsedOps.data.length === 0)
|
|
580
|
+
return jsonResponse({ error: "ops must not be empty" }, { status: 400, cors });
|
|
581
|
+
let manifest;
|
|
582
|
+
if (body.componentsManifest) {
|
|
583
|
+
const payload = typeof body.componentsManifest === "string"
|
|
584
|
+
? (() => { try {
|
|
585
|
+
return JSON.parse(body.componentsManifest);
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
return "__invalid__";
|
|
589
|
+
} })()
|
|
590
|
+
: body.componentsManifest;
|
|
591
|
+
if (payload !== "__invalid__") {
|
|
592
|
+
const pm = blockManifestSchema.safeParse(payload);
|
|
593
|
+
if (pm.success)
|
|
594
|
+
manifest = pm.data;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// Snapshot touched pages for undo + verify they exist.
|
|
598
|
+
const snapshots = new Map();
|
|
599
|
+
const createPageSlugs = [];
|
|
600
|
+
for (const op of parsedOps.data) {
|
|
601
|
+
if (op.op === "create_page") {
|
|
602
|
+
createPageSlugs.push(op.page.slug);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (op.op === "update_site_config")
|
|
606
|
+
continue;
|
|
607
|
+
if (!("pageSlug" in op) || typeof op.pageSlug !== "string")
|
|
608
|
+
continue;
|
|
609
|
+
if (snapshots.has(op.pageSlug))
|
|
610
|
+
continue;
|
|
611
|
+
const current = getPage(scopedSession, op.pageSlug);
|
|
612
|
+
if (!current)
|
|
613
|
+
return jsonResponse({ error: `page not found: ${op.pageSlug}` }, { status: 404, cors });
|
|
614
|
+
snapshots.set(op.pageSlug, current);
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
await applyOpsAtomically(scopedSession, parsedOps.data, { componentsManifest: manifest });
|
|
618
|
+
for (const [slug, snapshot] of snapshots)
|
|
619
|
+
pushUndo(scopedSession, slug, snapshot);
|
|
620
|
+
for (const slug of createPageSlugs)
|
|
621
|
+
pushUndo(scopedSession, slug, null);
|
|
622
|
+
const firstSlugOp = parsedOps.data.find((op) => "pageSlug" in op && typeof op.pageSlug === "string");
|
|
623
|
+
const firstSlug = firstSlugOp && "pageSlug" in firstSlugOp && typeof firstSlugOp.pageSlug === "string" ? firstSlugOp.pageSlug : undefined;
|
|
624
|
+
const updatedSlug = firstSlug ? pickUpdatedSlug(scopedSession, firstSlug, parsedOps.data) : undefined;
|
|
625
|
+
if (firstSlug) {
|
|
626
|
+
pushRecentEdit(scopedSession, { slug: updatedSlug ?? firstSlug, summary: "Applied operations.", ops: parsedOps.data });
|
|
627
|
+
}
|
|
628
|
+
const previewVersion = bumpVersion(scopedSession);
|
|
629
|
+
const versionEntrySlug = updatedSlug ?? firstSlug ?? "/";
|
|
630
|
+
const versionSnapshot = getPage(scopedSession, versionEntrySlug);
|
|
631
|
+
pushVersionEntry(scopedSession, {
|
|
632
|
+
version: previewVersion,
|
|
633
|
+
slug: versionEntrySlug,
|
|
634
|
+
summary: "Applied operations.",
|
|
635
|
+
opTypes: parsedOps.data.map((op) => op.op),
|
|
636
|
+
opCount: parsedOps.data.length,
|
|
637
|
+
source: "direct",
|
|
638
|
+
snapshot: versionSnapshot ? structuredClone(versionSnapshot) : null
|
|
639
|
+
});
|
|
640
|
+
schedulePersistState(runtime.log);
|
|
641
|
+
return jsonResponse({
|
|
642
|
+
status: "applied",
|
|
643
|
+
summary: "Applied operations.",
|
|
644
|
+
changes: [],
|
|
645
|
+
previewVersion,
|
|
646
|
+
focusBlockId: pickFocusBlockId(parsedOps.data),
|
|
647
|
+
updatedSlug
|
|
648
|
+
}, { status: 200, cors });
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
const reason = toErrorDetail(error);
|
|
652
|
+
return jsonResponse({ error: reason, errorCode: classifyGuardrailError(reason) }, { status: 400, cors });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// ---- Image upload (local disk, POC-grade) --------------------------
|
|
656
|
+
// The editor's ImagePicker "Upload" tab POSTs a multipart form here and
|
|
657
|
+
// expects `{ url }` back. Bytes land in `imageDir`; the URL points at the
|
|
658
|
+
// sibling GET route below, relative to this handler's basePath so it
|
|
659
|
+
// resolves against the host site's own origin. No CDN / transforms — see
|
|
660
|
+
// docs/image-storage-options.md for the blob-backend swap.
|
|
661
|
+
if (request.method === "POST" && path === "/image/upload") {
|
|
662
|
+
let form;
|
|
663
|
+
try {
|
|
664
|
+
form = await request.formData();
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
return jsonResponse({ error: "expected multipart/form-data body" }, { status: 400, cors });
|
|
668
|
+
}
|
|
669
|
+
const file = form.get("image");
|
|
670
|
+
if (!(file instanceof File)) {
|
|
671
|
+
return jsonResponse({ error: "missing 'image' file field" }, { status: 400, cors });
|
|
672
|
+
}
|
|
673
|
+
const ext = MIME_TO_EXT[file.type];
|
|
674
|
+
if (!ext) {
|
|
675
|
+
return jsonResponse({ error: `unsupported image type: ${file.type || "unknown"}` }, { status: 415, cors });
|
|
676
|
+
}
|
|
677
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
678
|
+
return jsonResponse({ error: `image exceeds ${MAX_UPLOAD_BYTES} byte limit` }, { status: 413, cors });
|
|
679
|
+
}
|
|
680
|
+
const fileName = `upload_${Date.now()}_${randomUUID().slice(0, 8)}.${ext}`;
|
|
681
|
+
try {
|
|
682
|
+
await mkdir(imageDir, { recursive: true });
|
|
683
|
+
await writeFile(resolve(imageDir, fileName), Buffer.from(await file.arrayBuffer()));
|
|
684
|
+
}
|
|
685
|
+
catch (err) {
|
|
686
|
+
return jsonResponse({ error: "image upload failed", detail: err instanceof Error ? err.message : String(err) }, { status: 500, cors });
|
|
687
|
+
}
|
|
688
|
+
return jsonResponse({ url: `${basePath}/generated-images/${fileName}`, bytes: file.size, mimeType: file.type }, { status: 200, cors });
|
|
689
|
+
}
|
|
690
|
+
// Serve a previously-uploaded image off local disk. `basename` collapses
|
|
691
|
+
// any path-traversal attempt to a bare filename before it touches the FS.
|
|
692
|
+
if (request.method === "GET" && path.startsWith("/generated-images/")) {
|
|
693
|
+
const fileName = basename(path.slice("/generated-images/".length));
|
|
694
|
+
if (!fileName || !/^[A-Za-z0-9._-]+$/.test(fileName)) {
|
|
695
|
+
return jsonResponse({ error: "invalid file name" }, { status: 400, cors });
|
|
696
|
+
}
|
|
697
|
+
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
|
698
|
+
const mime = EXT_TO_MIME[ext];
|
|
699
|
+
if (!mime)
|
|
700
|
+
return jsonResponse({ error: "unsupported file type" }, { status: 415, cors });
|
|
701
|
+
let bytes;
|
|
702
|
+
try {
|
|
703
|
+
bytes = await readFile(resolve(imageDir, fileName));
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
return jsonResponse({ error: "not found" }, { status: 404, cors });
|
|
707
|
+
}
|
|
708
|
+
return new Response(new Uint8Array(bytes), {
|
|
709
|
+
status: 200,
|
|
710
|
+
headers: {
|
|
711
|
+
"content-type": mime,
|
|
712
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
713
|
+
...cors
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
return jsonResponse({
|
|
718
|
+
error: `Method ${request.method} ${path} not handled by createOrchestrator()`,
|
|
719
|
+
hint: "Supported: POST /chat, POST /chat/stream, POST /chat/start, GET /chat/stream, POST /chat/cancel, POST /publish, GET /status/planner, GET /draft/pages, GET /draft/slugs, POST /draft/bootstrap, GET+PUT /draft/site-config, POST /ops, POST /image/upload, GET /generated-images/:fileName. Use the Fastify orchestrator at apps/orchestrator for the full surface (sites, sessions, agent, etc.)."
|
|
720
|
+
}, { status: 405, cors });
|
|
721
|
+
};
|
|
722
|
+
handler.dispose = async () => {
|
|
723
|
+
if (!runtimePromise)
|
|
724
|
+
return;
|
|
725
|
+
try {
|
|
726
|
+
const runtime = await runtimePromise;
|
|
727
|
+
runtime.resumableStore.dispose();
|
|
728
|
+
}
|
|
729
|
+
catch { /* runtime never built; nothing to dispose */ }
|
|
730
|
+
runtimePromise = null;
|
|
731
|
+
};
|
|
732
|
+
return handler;
|
|
733
|
+
}
|