@flue/sdk 0.3.11 → 0.4.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.
@@ -0,0 +1,730 @@
1
+ import { Hono } from "hono";
2
+
3
+ //#region src/errors.ts
4
+ /**
5
+ * Complete error framework for Flue.
6
+ *
7
+ * This file contains both the error vocabulary (concrete error classes) and
8
+ * the framework utilities (renderers, type guards, request parsing helpers).
9
+ * Previously split across `errors.ts` and `error-utils.ts`, but consolidated
10
+ * for better LLM comprehension.
11
+ *
12
+ * ──── Why this file exists ────────────────────────────────────────────────
13
+ *
14
+ * Concentrating every error in one file is deliberate. When all errors are
15
+ * visible together, it's easy to:
16
+ *
17
+ * - Keep message tone and detail level consistent across the codebase.
18
+ * - Notice duplicates ("oh, we already have an error for this case").
19
+ * - Establish norms by example — when adding a new error, look at the
20
+ * neighbors above and copy the pattern.
21
+ *
22
+ * Application code throughout the codebase should reach for one of these
23
+ * classes rather than constructing a `FlueError` ad hoc. If no existing class
24
+ * fits, add one here. That's the entire convention.
25
+ *
26
+ * ──── Two audiences: caller vs. developer ─────────────────────────────────
27
+ *
28
+ * The reader of an error message is one of two distinct audiences:
29
+ *
30
+ * - The *caller*: an HTTP client. Possibly third-party, possibly hostile,
31
+ * possibly an end user who shouldn't even know we're built on Flue.
32
+ * Sees `message` and `details` always.
33
+ *
34
+ * - The *developer*: the human running the service (`flue dev`, `flue run`,
35
+ * local debugging). Sees `dev` in addition, but only when the server is
36
+ * running in local/dev mode (gated by `FLUE_MODE=local`).
37
+ *
38
+ * Every error class must classify its prose by audience. The required-but-
39
+ * possibly-empty shape of both `details` and `dev` is the discipline:
40
+ * forgetting either field is a TypeScript error, and writing `''` is a
41
+ * deliberate "I have nothing for that audience" decision.
42
+ *
43
+ * Concretely:
44
+ *
45
+ * - `message` One sentence. Caller-safe. Always rendered.
46
+ * - `details` Longer caller-safe prose. About the request itself, the
47
+ * contract, what the caller can do to fix it. Always
48
+ * rendered. NEVER includes:
49
+ * - sibling/neighbor enumeration (leaks namespace)
50
+ * - filesystem paths or "agents/" / "skills/" / etc.
51
+ * (leaks framework internals)
52
+ * - source-code-level fix instructions ("add ... to your
53
+ * agent definition") (caller can't act on these)
54
+ * - build-time or runtime mechanics
55
+ * - `dev` Longer dev-audience prose. Available alternatives,
56
+ * filesystem layout, framework guidance, source-code-level
57
+ * fix instructions. Rendered ONLY when FLUE_MODE=local.
58
+ *
59
+ * When in doubt, put information in `dev`. The default is conservative.
60
+ *
61
+ * ──── Conventions for new error classes ───────────────────────────────────
62
+ *
63
+ * - Class name: PascalCase, suffixed with `Error`. E.g. `AgentNotFoundError`.
64
+ * - The class owns its `type` constant (snake_case). Set once in the
65
+ * subclass constructor, never passed by callers. Renaming the wire type
66
+ * is then a one-line change.
67
+ * - Constructor takes ONLY structured input data (the values used to build
68
+ * the message). The constructor assembles `message`, `details`, and
69
+ * `dev` from that data, so call sites never reinvent phrasing.
70
+ * - `details` and `dev` are both required strings. Pass `''` only when
71
+ * there's genuinely nothing more to say for that audience.
72
+ * - For HTTP errors, the class sets its own `status` (and `headers` where
73
+ * relevant). Callers do not pick HTTP status codes ad-hoc.
74
+ *
75
+ * Worked example (matches `AgentNotFoundError` below):
76
+ *
77
+ * new AgentNotFoundError({ name, available });
78
+ * // builds:
79
+ * // message: `Agent "foo" is not registered.`
80
+ * // details: `Verify the agent name is correct.`
81
+ * // dev: `Available agents: "echo", "greeter". Agents are
82
+ * // loaded from the project root's "agents/" directory at
83
+ * // build time. ...`
84
+ *
85
+ * The wire response in production omits `dev`; in `flue dev` / `flue run`
86
+ * it includes `dev`. That separation is what lets the dev field be richly
87
+ * helpful without leaking namespace state to public callers.
88
+ *
89
+ * Counter-example to avoid:
90
+ *
91
+ * class AgentNotFoundError extends FlueHttpError {
92
+ * constructor(message: string) { // ✗ free-form
93
+ * super({ // ✗ wrong type
94
+ * type: 'agent_error',
95
+ * message,
96
+ * details: 'Available: "x", "y", "z"', // ✗ leaks names
97
+ * dev: '', // ✗ wasted channel
98
+ * status: 500, // ✗ wrong status
99
+ * });
100
+ * }
101
+ * }
102
+ *
103
+ * The structured-constructor pattern below is what prevents that drift.
104
+ */
105
+ /**
106
+ * Format a list of items for inclusion in error details. Empty lists render
107
+ * as the supplied fallback (default `(none)`), so messages read naturally
108
+ * regardless of whether anything is registered.
109
+ *
110
+ * Module-private: only used by the concrete error subclasses below. Promote
111
+ * to `export` if/when a real cross-file caller appears.
112
+ */
113
+ function formatList(items, fallback = "(none)") {
114
+ if (items.length === 0) return fallback;
115
+ return items.map((item) => `"${String(item)}"`).join(", ");
116
+ }
117
+ /**
118
+ * Base class for every error Flue throws. Do not instantiate directly in
119
+ * application code — extend it via a subclass below. If a use case isn't
120
+ * covered, add a new subclass here rather than throwing a raw `FlueError`.
121
+ */
122
+ var FlueError = class extends Error {
123
+ type;
124
+ details;
125
+ dev;
126
+ meta;
127
+ cause;
128
+ constructor(options) {
129
+ super(options.message);
130
+ this.name = "FlueError";
131
+ this.type = options.type;
132
+ this.details = options.details;
133
+ this.dev = options.dev;
134
+ this.meta = options.meta;
135
+ this.cause = options.cause;
136
+ }
137
+ };
138
+ /**
139
+ * Base class for HTTP-layer errors. Adds `status` and optional `headers`.
140
+ * Subclasses set these in the `super({...})` call so the call site doesn't
141
+ * have to think about HTTP semantics.
142
+ */
143
+ var FlueHttpError = class extends FlueError {
144
+ status;
145
+ headers;
146
+ constructor(options) {
147
+ super(options);
148
+ this.name = "FlueHttpError";
149
+ this.status = options.status;
150
+ this.headers = options.headers;
151
+ }
152
+ };
153
+ var MethodNotAllowedError = class extends FlueHttpError {
154
+ constructor({ method, allowed }) {
155
+ super({
156
+ type: "method_not_allowed",
157
+ message: `HTTP method ${method} is not allowed on this endpoint.`,
158
+ details: `This endpoint accepts ${formatList(allowed)} only.`,
159
+ dev: "",
160
+ status: 405,
161
+ headers: { Allow: allowed.join(", ") }
162
+ });
163
+ }
164
+ };
165
+ var UnsupportedMediaTypeError = class extends FlueHttpError {
166
+ constructor({ received }) {
167
+ const detailLines = [];
168
+ if (received) detailLines.push(`Received Content-Type: "${received}".`);
169
+ else detailLines.push(`No Content-Type header was sent.`);
170
+ detailLines.push("Send the request body as JSON with the header \"Content-Type: application/json\", or omit the body entirely (and the Content-Type header) if the request doesn't have a payload.");
171
+ super({
172
+ type: "unsupported_media_type",
173
+ message: `Request body must be sent as application/json.`,
174
+ details: detailLines.join("\n"),
175
+ dev: "",
176
+ status: 415
177
+ });
178
+ }
179
+ };
180
+ var InvalidJsonError = class extends FlueHttpError {
181
+ constructor({ parseError }) {
182
+ super({
183
+ type: "invalid_json",
184
+ message: `Request body is not valid JSON.`,
185
+ details: `The JSON parser reported: ${parseError}\nVerify the body is well-formed JSON, or omit the body entirely if the request doesn't have a payload.`,
186
+ dev: "",
187
+ status: 400
188
+ });
189
+ }
190
+ };
191
+ var AgentNotFoundError = class extends FlueHttpError {
192
+ constructor({ name, available }) {
193
+ super({
194
+ type: "agent_not_found",
195
+ message: `Agent "${name}" is not registered.`,
196
+ details: `Verify the agent name is correct.`,
197
+ dev: `Available agents: ${formatList(available)}.\nAgents are loaded from the project root's "agents/" directory at build time. Verify the agent file is present in the project root being served.`,
198
+ status: 404
199
+ });
200
+ }
201
+ };
202
+ var AgentNotWebhookError = class extends FlueHttpError {
203
+ constructor({ name }) {
204
+ super({
205
+ type: "agent_not_webhook",
206
+ message: `Agent "${name}" is not web-accessible.`,
207
+ details: `This endpoint is not exposed over HTTP.`,
208
+ dev: "This agent has no webhook trigger configured. To expose it, add a webhook trigger to its definition (`triggers: { webhook: true }`). Trigger-less agents remain invokable via \"flue run\" in local mode.",
209
+ status: 404
210
+ });
211
+ }
212
+ };
213
+ var RouteNotFoundError = class extends FlueHttpError {
214
+ constructor({ method, path }) {
215
+ super({
216
+ type: "route_not_found",
217
+ message: `No route matches ${method} ${path}.`,
218
+ details: `Webhook agents are served at POST /agents/<name>/<id>.`,
219
+ dev: "",
220
+ status: 404
221
+ });
222
+ }
223
+ };
224
+ var InvalidRequestError = class extends FlueHttpError {
225
+ constructor({ reason }) {
226
+ super({
227
+ type: "invalid_request",
228
+ message: `Request is malformed.`,
229
+ details: reason,
230
+ dev: "",
231
+ status: 400
232
+ });
233
+ }
234
+ };
235
+ /**
236
+ * Error framework utilities: renderers, type guards, request parsing helpers.
237
+ *
238
+ * Wire envelope (HTTP body + SSE `data:` payload for error events):
239
+ *
240
+ * {
241
+ * "error": {
242
+ * "type": "...",
243
+ * "message": "...",
244
+ * "details": "...",
245
+ * "dev": "..." // present only in local/dev mode AND when non-empty
246
+ * }
247
+ * }
248
+ *
249
+ * Field rules:
250
+ * - `type`, `message`, `details` are always present on the wire.
251
+ * - `dev` is gated by `FLUE_MODE === 'local'` (set by `flue run` and
252
+ * `flue dev --target node`). Even in dev mode, `dev` is omitted when
253
+ * the error class set it to `''` — so its presence is not a reliable
254
+ * signal of mode by itself; clients should not depend on it that way.
255
+ * See the error classes above for the two-audience rationale.
256
+ * - `meta` is included on the wire only when an error subclass sets it
257
+ * (rare).
258
+ * - `cause` is never included on the wire (it's logged server-side only).
259
+ */
260
+ function isFlueError(value) {
261
+ return value instanceof FlueError;
262
+ }
263
+ /**
264
+ * Module-private for now: when an external call site appears we can promote
265
+ * to `export` and decide the right shape for `warn`/`info` (FlueError
266
+ * subclasses with severity? plain strings? structured data?) — rather than
267
+ * committing to a shape now without any usage to validate it.
268
+ */
269
+ function formatForLog(prefix, err) {
270
+ if (isFlueError(err)) {
271
+ const lines = [`${prefix} [${err.type}] ${err.message}`];
272
+ if (err.details) for (const line of err.details.split("\n")) lines.push(` ${line}`);
273
+ if (err.dev) for (const line of err.dev.split("\n")) lines.push(` ${line}`);
274
+ if (err.cause !== void 0) lines.push(` cause: ${err.cause instanceof Error ? err.cause.stack ?? err.cause.message : String(err.cause)}`);
275
+ return lines.join("\n");
276
+ }
277
+ if (err instanceof Error) return `${prefix} ${err.stack ?? err.message}`;
278
+ return `${prefix} ${String(err)}`;
279
+ }
280
+ const flueLog = { error(err) {
281
+ console.error(formatForLog("[flue]", err));
282
+ } };
283
+ /**
284
+ * Detect whether the server is running in local/dev mode. Gates whether the
285
+ * `dev` field is included on the wire — see the convention doc in the error
286
+ * classes above.
287
+ *
288
+ * Currently keyed off the `FLUE_MODE=local` env var, which is set by
289
+ * `flue run` and `flue dev --target node`. On Cloudflare workers there is
290
+ * no global `process` and no current "local mode" plumbing for the worker —
291
+ * so deployed CF and `flue dev --target cloudflare` both currently render
292
+ * the prod envelope. Threading a dev-mode signal through to the worker
293
+ * fetch handler is left as a follow-up.
294
+ */
295
+ function isDevMode() {
296
+ return typeof process !== "undefined" && process.env?.FLUE_MODE === "local";
297
+ }
298
+ function envelope(err) {
299
+ const out = { error: {
300
+ type: err.type,
301
+ message: err.message,
302
+ details: err.details
303
+ } };
304
+ if (isDevMode() && err.dev) out.error.dev = err.dev;
305
+ if (err.meta) out.error.meta = err.meta;
306
+ return out;
307
+ }
308
+ const GENERIC_INTERNAL = { error: {
309
+ type: "internal_error",
310
+ message: "An internal error occurred.",
311
+ details: "The server encountered an unexpected error while handling this request."
312
+ } };
313
+ /**
314
+ * Render any thrown value into a `Response` with the canonical Flue error
315
+ * envelope. Unknown / non-Flue errors are logged in full and rendered as a
316
+ * generic 500 with no message leaked.
317
+ */
318
+ function toHttpResponse(err) {
319
+ if (isFlueError(err)) {
320
+ const isHttp = err instanceof FlueHttpError;
321
+ const status = isHttp ? err.status : 500;
322
+ const headers = { "content-type": "application/json" };
323
+ if (isHttp && err.headers) Object.assign(headers, err.headers);
324
+ if (!isHttp) flueLog.error(err);
325
+ return new Response(JSON.stringify(envelope(err)), {
326
+ status,
327
+ headers
328
+ });
329
+ }
330
+ flueLog.error(err);
331
+ return new Response(JSON.stringify(GENERIC_INTERNAL), {
332
+ status: 500,
333
+ headers: { "content-type": "application/json" }
334
+ });
335
+ }
336
+ /**
337
+ * Render any thrown value into a JSON string suitable for the `data:` line of
338
+ * an SSE `error` event. Same envelope as `toHttpResponse`. Unknown / non-Flue
339
+ * errors are logged and replaced with a generic envelope.
340
+ */
341
+ function toSseData(err) {
342
+ if (isFlueError(err)) {
343
+ if (!(err instanceof FlueHttpError)) flueLog.error(err);
344
+ return JSON.stringify({
345
+ type: "error",
346
+ ...envelope(err)
347
+ });
348
+ }
349
+ flueLog.error(err);
350
+ return JSON.stringify({
351
+ type: "error",
352
+ ...GENERIC_INTERNAL
353
+ });
354
+ }
355
+ /**
356
+ * Parse a request body as JSON. Returns `{}` for genuinely empty bodies
357
+ * (Content-Length: 0 or missing) so that webhook agents which don't accept
358
+ * a payload can be invoked without one.
359
+ *
360
+ * Throws `UnsupportedMediaTypeError` if a body is present without
361
+ * `application/json` content-type, and `InvalidJsonError` if the body is
362
+ * present but unparseable.
363
+ */
364
+ async function parseJsonBody(request) {
365
+ const contentLengthHeader = request.headers.get("content-length");
366
+ const contentLength = contentLengthHeader === null ? null : Number(contentLengthHeader);
367
+ const contentType = request.headers.get("content-type");
368
+ if (contentLength === 0 || contentLengthHeader === null && contentType === null) return {};
369
+ if (!contentType || !contentType.toLowerCase().includes("application/json")) throw new UnsupportedMediaTypeError({ received: contentType });
370
+ let text;
371
+ try {
372
+ text = await request.clone().text();
373
+ } catch (err) {
374
+ throw new InvalidJsonError({ parseError: err instanceof Error ? err.message : String(err) });
375
+ }
376
+ if (text.trim() === "") return {};
377
+ try {
378
+ return JSON.parse(text);
379
+ } catch (err) {
380
+ throw new InvalidJsonError({ parseError: err instanceof Error ? err.message : String(err) });
381
+ }
382
+ }
383
+ function validateAgentRequest(opts) {
384
+ if (opts.method !== "POST") throw new MethodNotAllowedError({
385
+ method: opts.method,
386
+ allowed: ["POST"]
387
+ });
388
+ if (opts.name.trim() === "" || opts.id.trim() === "") throw new InvalidRequestError({ reason: "Webhook URLs must have the shape /agents/<name>/<id> with non-empty segments." });
389
+ if (!opts.registeredAgents.includes(opts.name)) throw new AgentNotFoundError({
390
+ name: opts.name,
391
+ available: opts.registeredAgents
392
+ });
393
+ if (!opts.allowNonWebhook && !opts.webhookAgents.includes(opts.name)) throw new AgentNotWebhookError({ name: opts.name });
394
+ }
395
+
396
+ //#endregion
397
+ //#region src/runtime/handle-agent.ts
398
+ /** Shared per-agent HTTP dispatcher for the Node and Cloudflare targets. */
399
+ /**
400
+ * Dispatch a single `/agents/:name/:id` request. The mode is chosen by
401
+ * inspecting headers:
402
+ *
403
+ * - `X-Webhook: true` → fire-and-forget. Returns 202 immediately; the
404
+ * handler runs in the background. Errors are logged server-side.
405
+ * - `Accept: text/event-stream` (and not webhook) → SSE streaming. Returns
406
+ * 200 + text/event-stream. Events come from the FlueContext's event
407
+ * callback; final result is appended as `event: result`. Per-event errors
408
+ * surface as `event: error` envelopes.
409
+ * - Otherwise → sync. Returns 200 + JSON `{ result }`.
410
+ *
411
+ * Errors thrown BEFORE streaming starts (body parse, agent lookup) bubble
412
+ * out as a `Response` via {@link toHttpResponse} — headers haven't been sent
413
+ * yet, so a regular HTTP error is still possible. Errors thrown AFTER the
414
+ * 200 + text/event-stream headers are on the wire (i.e. inside the agent
415
+ * handler) get framed as in-stream `error` events instead.
416
+ *
417
+ * Caller is responsible for routing — this function assumes the request has
418
+ * already been validated as a POST against a registered agent.
419
+ */
420
+ async function handleAgentRequest(opts) {
421
+ const { request, agentName, id, handler, createContext } = opts;
422
+ const startWebhook = opts.startWebhook ?? defaultStartWebhook;
423
+ const runHandler = opts.runHandler ?? defaultRunHandler;
424
+ try {
425
+ const payload = await parseJsonBody(request);
426
+ const accept = request.headers.get("accept") || "";
427
+ const isWebhook = request.headers.get("x-webhook") === "true";
428
+ const isSSE = accept.includes("text/event-stream") && !isWebhook;
429
+ if (isWebhook) return runWebhookMode({
430
+ agentName,
431
+ id,
432
+ handler,
433
+ payload,
434
+ request,
435
+ createContext,
436
+ startWebhook
437
+ });
438
+ if (isSSE) return runSseMode({
439
+ id,
440
+ handler,
441
+ payload,
442
+ request,
443
+ createContext,
444
+ runHandler
445
+ });
446
+ return runSyncMode({
447
+ id,
448
+ handler,
449
+ payload,
450
+ request,
451
+ createContext,
452
+ runHandler
453
+ });
454
+ } catch (err) {
455
+ return toHttpResponse(err);
456
+ }
457
+ }
458
+ function runWebhookMode(opts) {
459
+ const { agentName, id, handler, payload, request, createContext, startWebhook } = opts;
460
+ const requestId = generateRequestId();
461
+ const ctx = createContext(id, payload, request);
462
+ const run = async () => {
463
+ try {
464
+ return await handler(ctx);
465
+ } finally {
466
+ ctx.setEventCallback(void 0);
467
+ }
468
+ };
469
+ startWebhook(requestId, run).then((result) => {
470
+ console.log("[flue] Webhook handler complete:", agentName, result !== void 0 ? JSON.stringify(result) : "(no return)");
471
+ }, (err) => {
472
+ console.error("[flue] Webhook handler error:", agentName, err);
473
+ });
474
+ return new Response(JSON.stringify({
475
+ status: "accepted",
476
+ requestId
477
+ }), {
478
+ status: 202,
479
+ headers: { "content-type": "application/json" }
480
+ });
481
+ }
482
+ /**
483
+ * Heartbeat interval for long-idle SSE streams. The actual cadence matters
484
+ * less than the existence of *some* periodic payload — the heartbeat exists
485
+ * to defeat intermediary timeouts (Node's default 300s requestTimeout, CDN
486
+ * proxies, browser EventSource reconnect heuristics). 25s is the conventional
487
+ * choice and matches what Hono's `streamSSE` defaults to.
488
+ */
489
+ const SSE_HEARTBEAT_MS = 25e3;
490
+ function runSseMode(opts) {
491
+ const { id, handler, payload, request, createContext, runHandler } = opts;
492
+ const { readable, writable } = new TransformStream();
493
+ const writer = writable.getWriter();
494
+ const encoder = new TextEncoder();
495
+ let eventId = 0;
496
+ let isIdle = false;
497
+ let closed = false;
498
+ const writeSSE = async (data, event) => {
499
+ if (closed) return;
500
+ const lines = [];
501
+ lines.push(`event: ${event}`);
502
+ lines.push(`id: ${eventId++}`);
503
+ lines.push(`data: ${typeof data === "string" ? data : JSON.stringify(data)}`);
504
+ lines.push("", "");
505
+ try {
506
+ await writer.write(encoder.encode(lines.join("\n")));
507
+ } catch {}
508
+ };
509
+ const writeHeartbeat = async () => {
510
+ if (closed) return;
511
+ try {
512
+ await writer.write(encoder.encode(": heartbeat\n\n"));
513
+ } catch {}
514
+ };
515
+ const heartbeat = setInterval(() => {
516
+ writeHeartbeat().catch(() => {});
517
+ }, SSE_HEARTBEAT_MS);
518
+ const ctx = createContext(id, payload, request);
519
+ ctx.setEventCallback((event) => {
520
+ if (event.type === "idle") isIdle = true;
521
+ writeSSE(event, event.type).catch(() => {});
522
+ });
523
+ (async () => {
524
+ try {
525
+ const result = await runHandler(ctx, handler);
526
+ if (!isIdle) await writeSSE({ type: "idle" }, "idle");
527
+ await writeSSE({
528
+ type: "result",
529
+ data: result !== void 0 ? result : null
530
+ }, "result");
531
+ } catch (err) {
532
+ await writeSSE(toSseData(err), "error");
533
+ if (!isIdle) await writeSSE({ type: "idle" }, "idle");
534
+ } finally {
535
+ clearInterval(heartbeat);
536
+ ctx.setEventCallback(void 0);
537
+ closed = true;
538
+ try {
539
+ await writer.close();
540
+ } catch {}
541
+ }
542
+ })();
543
+ return new Response(readable, { headers: {
544
+ "content-type": "text/event-stream",
545
+ "cache-control": "no-cache",
546
+ connection: "keep-alive"
547
+ } });
548
+ }
549
+ async function runSyncMode(opts) {
550
+ const { id, handler, payload, request, createContext, runHandler } = opts;
551
+ const ctx = createContext(id, payload, request);
552
+ try {
553
+ const result = await runHandler(ctx, handler);
554
+ return new Response(JSON.stringify({ result: result !== void 0 ? result : null }), { headers: { "content-type": "application/json" } });
555
+ } finally {
556
+ ctx.setEventCallback(void 0);
557
+ }
558
+ }
559
+ /**
560
+ * Default webhook runner: invoke `run()` directly so the handler executes
561
+ * in the current process. Used by the Node target. The Cloudflare target
562
+ * overrides this with a `runFiber` wrapper for crash-recoverable execution
563
+ * across DO hibernation.
564
+ */
565
+ const defaultStartWebhook = (_requestId, run) => run();
566
+ /**
567
+ * Default foreground handler runner: invoke directly. Used by the Node
568
+ * target. The Cloudflare target overrides this with a `keepAliveWhile`
569
+ * wrapper.
570
+ */
571
+ const defaultRunHandler = (ctx, handler) => handler(ctx);
572
+ /**
573
+ * Generate a UUID for webhook request correlation. `crypto.randomUUID()` is
574
+ * available on both modern Node (≥18) and workerd, so no per-target shim is
575
+ * needed.
576
+ */
577
+ function generateRequestId() {
578
+ return crypto.randomUUID();
579
+ }
580
+
581
+ //#endregion
582
+ //#region src/runtime/flue-app.ts
583
+ /**
584
+ * Public Hono sub-app exposing Flue's built-in agent route.
585
+ *
586
+ * Two consumers:
587
+ *
588
+ * 1. **User `app.ts` files.** Users mount this sub-app inside their own
589
+ * Hono app via `app.route('/', flue())`. The user owns the outer
590
+ * Hono and controls everything around Flue's routes (logging,
591
+ * auth, custom routes, framework-level error handlers).
592
+ *
593
+ * 2. **The default fallback when no `app.ts` exists.** {@link
594
+ * createDefaultFlueApp} wraps `flue()` in a thin outer Hono so the
595
+ * no-customization case ships the same routes as it always has.
596
+ *
597
+ * Only the agent route at `/agents/:name/:id` is exposed. `/health` and
598
+ * `/agents` are NOT mounted — projects that want them add them in their
599
+ * own `app.ts`. The magic surface stays minimal; users opt in to
600
+ * whatever shape of liveness / introspection endpoint they actually
601
+ * want.
602
+ *
603
+ * Targets diverge inside the agent route:
604
+ *
605
+ * - **Node**: dispatches in-process via `handleAgentRequest` against
606
+ * the seeded handler map.
607
+ * - **Cloudflare**: forwards to `routeAgentRequest()` (provided by
608
+ * the seeded runtime), which reaches the per-agent Durable Object
609
+ * class. The DO's `onRequest` then calls `handleAgentRequest`
610
+ * itself with the CF-specific keepalive / fiber wrappers.
611
+ *
612
+ * The split is invisible to the user. They `import { flue } from
613
+ * '@flue/sdk/app'` and mount it the same way regardless of target. See
614
+ * {@link configureFlueRuntime} for the seeding contract that lets user
615
+ * `app.ts` files call `flue()` at top level.
616
+ */
617
+ /** Module-scoped runtime config seeded by the generated server entry. */
618
+ let runtimeConfig;
619
+ /**
620
+ * Seed the runtime config consumed by {@link flue}. Called exactly
621
+ * once at module load by the generated server entry. The Hono routes
622
+ * returned by `flue()` read this config lazily — see the comment on
623
+ * {@link runtimeConfig} for why timing relative to user `app.ts`
624
+ * evaluation is fine.
625
+ *
626
+ * Not part of the public API — exposed via `@flue/sdk/internal` only
627
+ * because the generated entry imports it from a stable bare specifier.
628
+ */
629
+ function configureFlueRuntime(cfg) {
630
+ runtimeConfig = cfg;
631
+ }
632
+ /**
633
+ * Public Hono sub-app mounting Flue's built-in agent route. Users
634
+ * compose this into their own Hono via Hono's `app.route(path, subApp)`:
635
+ *
636
+ * import { Hono } from 'hono';
637
+ * import { flue } from '@flue/sdk/app';
638
+ *
639
+ * const app = new Hono();
640
+ * app.use('*', logger());
641
+ * app.get('/api/ping', (c) => c.json({ pong: true }));
642
+ * app.route('/', flue());
643
+ *
644
+ * export default app;
645
+ *
646
+ * Each call to `flue()` returns a fresh Hono. Mounting it twice is
647
+ * legal but pointless — both sub-apps read from the same seeded
648
+ * runtime and produce identical responses.
649
+ *
650
+ * Importable from `@flue/sdk/app`.
651
+ */
652
+ function flue() {
653
+ const app = new Hono();
654
+ app.all("/agents/:name/:id", agentRouteHandler);
655
+ app.onError((err) => toHttpResponse(err));
656
+ return app;
657
+ }
658
+ /**
659
+ * Build the default outer Hono app used when no user `app.ts` is
660
+ * present. Mounts `flue()` at root, renders canonical Flue envelopes
661
+ * for unmatched paths and any thrown errors.
662
+ *
663
+ * Lives in the SDK rather than the generated entry so that user
664
+ * projects on the Cloudflare target — whose `node_modules` does not
665
+ * declare `hono` directly — don't have to add it themselves just to
666
+ * keep the no-`app.ts` default behavior working. When a user does
667
+ * write an `app.ts`, they own this composition and must `pnpm add
668
+ * hono` (or equivalent) themselves.
669
+ */
670
+ function createDefaultFlueApp() {
671
+ const app = new Hono();
672
+ app.route("/", flue());
673
+ app.notFound((c) => {
674
+ throw new RouteNotFoundError({
675
+ method: c.req.method,
676
+ path: new URL(c.req.url).pathname
677
+ });
678
+ });
679
+ app.onError((err) => toHttpResponse(err));
680
+ return app;
681
+ }
682
+ const agentRouteHandler = async (c) => {
683
+ const rt = runtimeConfig;
684
+ if (!rt) throw new Error("[flue] flue() route invoked before runtime was configured. This usually means flue() was used outside a Flue-built server entry.");
685
+ const name = c.req.param("name") ?? "";
686
+ const id = c.req.param("id") ?? "";
687
+ validateAgentRequest({
688
+ method: c.req.method,
689
+ name,
690
+ id,
691
+ registeredAgents: registeredAgentsFor(rt),
692
+ webhookAgents: rt.webhookAgents,
693
+ allowNonWebhook: rt.allowNonWebhook
694
+ });
695
+ if (rt.target === "node") {
696
+ const handler = rt.handlers[name];
697
+ return handleAgentRequest({
698
+ request: c.req.raw,
699
+ agentName: name,
700
+ id,
701
+ handler,
702
+ createContext: rt.createContext,
703
+ startWebhook: rt.startWebhook,
704
+ runHandler: rt.runHandler
705
+ });
706
+ }
707
+ const response = await rt.routeAgentRequest(c.req.raw, c.env);
708
+ if (response) return response;
709
+ throw new RouteNotFoundError({
710
+ method: c.req.method,
711
+ path: new URL(c.req.url).pathname
712
+ });
713
+ };
714
+ /**
715
+ * Compute the set of agent names considered "registered" for purposes
716
+ * of the agent route's name-validity check.
717
+ *
718
+ * - Node: every entry in the handler map (including trigger-less
719
+ * agents — `allowNonWebhook` controls whether they're actually
720
+ * reachable).
721
+ * - Cloudflare: only webhook agents have generated DO classes, so
722
+ * non-webhook names have no valid landing target.
723
+ */
724
+ function registeredAgentsFor(rt) {
725
+ if (rt.target === "node") return Object.keys(rt.handlers ?? {});
726
+ return rt.webhookAgents;
727
+ }
728
+
729
+ //#endregion
730
+ export { handleAgentRequest as i, createDefaultFlueApp as n, flue as r, configureFlueRuntime as t };