@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,195 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as v from "valibot";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ //#region src/config.ts
7
+ /**
8
+ * Flue config file support — `flue.config.{ts,mts,mjs,js,cjs,cts}`.
9
+ *
10
+ * Modeled on Vite/Astro:
11
+ *
12
+ * - The config file lives at the project root. Its directory IS the root for
13
+ * the purposes of resolving any relative paths it sets (`root`, `output`).
14
+ * - Discovery: `--config <path>` (resolved vs. cwd) wins; otherwise we search
15
+ * a starting directory (`--root` if given, else cwd) for any of the
16
+ * supported extensions, in order.
17
+ * - Loading: plain Node dynamic `import()`. We rely on Node's native
18
+ * TypeScript type-stripping (Node ≥ 22.18 / ≥ 23.6 by default) to handle
19
+ * `.ts` configs. We deliberately do NOT bundle the config — `flue.config`
20
+ * is a flat declarative surface, and "what valid TS works" should match
21
+ * the same rules the user already absorbed for the rest of the runtime.
22
+ * The CLI bin pre-checks the Node version before we ever get here, so
23
+ * `ERR_UNKNOWN_FILE_EXTENSION` shouldn't surface in practice.
24
+ * - Validation: valibot schema on the user-facing shape.
25
+ * - Resolution: CLI inline > config file > built-in defaults. CLI flags
26
+ * always win on a per-field basis — only the fields the user actually
27
+ * passed get to override the file.
28
+ *
29
+ * The two public types mirror Astro's `AstroUserConfig` / `AstroConfig`
30
+ * split: `UserFlueConfig` is what users author (everything optional);
31
+ * `FlueConfig` is the resolved shape with required defaults filled in.
32
+ *
33
+ * Provider/model configuration lives in `app.ts`, where runtime env is
34
+ * available.
35
+ */
36
+ /**
37
+ * Identity helper for type inference and editor intellisense, à la Vite's
38
+ * `defineConfig`. Returns its argument unchanged.
39
+ *
40
+ * ```ts
41
+ * import { defineConfig } from '@flue/sdk/config';
42
+ * export default defineConfig({ target: 'node' });
43
+ * ```
44
+ */
45
+ function defineConfig(config) {
46
+ return config;
47
+ }
48
+ const TargetSchema = v.picklist(["node", "cloudflare"]);
49
+ const UserFlueConfigSchema = v.strictObject({
50
+ target: v.optional(TargetSchema),
51
+ root: v.optional(v.string()),
52
+ output: v.optional(v.string())
53
+ });
54
+ /**
55
+ * Config file basenames searched, in priority order. TypeScript first because
56
+ * Flue's audience writes TS agents; the rest mirror Vite's supported set.
57
+ */
58
+ const CONFIG_BASENAMES = Object.freeze([
59
+ "flue.config.ts",
60
+ "flue.config.mts",
61
+ "flue.config.mjs",
62
+ "flue.config.js",
63
+ "flue.config.cjs",
64
+ "flue.config.cts"
65
+ ]);
66
+ /**
67
+ * Resolve the absolute path of the user's `flue.config.*` file, or
68
+ * `undefined` if none is found and the user didn't ask for one.
69
+ *
70
+ * Throws if `configFile` is an explicit path that doesn't exist on disk —
71
+ * that's a typo, not a "config not configured" situation.
72
+ */
73
+ function resolveConfigPath(opts) {
74
+ if (opts.configFile === false) return void 0;
75
+ if (opts.configFile) {
76
+ const explicit = path.isAbsolute(opts.configFile) ? opts.configFile : path.resolve(opts.cwd, opts.configFile);
77
+ if (!fs.existsSync(explicit)) throw new Error(`[flue] Config file not found: ${opts.configFile}`);
78
+ return explicit;
79
+ }
80
+ for (const basename of CONFIG_BASENAMES) {
81
+ const candidate = path.join(opts.cwd, basename);
82
+ if (fs.existsSync(candidate)) return candidate;
83
+ }
84
+ }
85
+ /**
86
+ * Load a config file's `default` export. We rely on Node's native dynamic
87
+ * `import()` for everything: plain JS, ESM, and TypeScript via type-stripping
88
+ * (Node ≥ 22.18 / ≥ 23.6 enable this by default). The CLI's bin entrypoint
89
+ * pre-validates the Node version, so by the time we reach this function the
90
+ * runtime is known to support the formats we accept.
91
+ *
92
+ * Cache-bust via a query param so repeated loads (e.g. a future dev-server
93
+ * config-watcher) get a fresh module instead of the cached one.
94
+ *
95
+ * Errors that come out of strip-mode (`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`)
96
+ * are repackaged with a hint pointing at the constraint, since the original
97
+ * Node message is terse.
98
+ *
99
+ * Returns the raw module default — caller is responsible for validation.
100
+ */
101
+ async function loadConfigModule(absConfigPath) {
102
+ const fileUrl = pathToFileURL(absConfigPath).href + `?t=${Date.now()}`;
103
+ try {
104
+ const mod = await import(fileUrl);
105
+ return mod.default ?? mod;
106
+ } catch (err) {
107
+ const code = err.code;
108
+ if (code === "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX") throw new Error(`[flue] ${path.basename(absConfigPath)} uses TypeScript syntax that Node's type-stripping loader doesn't support (e.g. \`enum\`, \`namespace\` with runtime code, parameter properties, decorators). Rewrite using only erasable types (or move the config to plain JS).\n Original: ${err.message}`);
109
+ if (code === "ERR_UNKNOWN_FILE_EXTENSION") throw new Error(`[flue] Cannot load ${path.basename(absConfigPath)}: this Node (v${process.versions.node}) does not support TypeScript natively. Upgrade to Node ≥ 22.18 or ≥ 23.6.`);
110
+ throw err;
111
+ }
112
+ }
113
+ /**
114
+ * Discover, load, validate, merge, and resolve a Flue config. The single
115
+ * entry point CLIs and embedders call.
116
+ *
117
+ * Precedence (highest first):
118
+ * 1. CLI inline values (`opts.inline.*`)
119
+ * 2. `flue.config.ts`
120
+ * 3. Built-in defaults
121
+ *
122
+ * Throws if validation fails or if no `target` is supplied anywhere.
123
+ */
124
+ async function resolveConfig(opts) {
125
+ const cwd = path.resolve(opts.cwd);
126
+ const searchFrom = path.resolve(opts.searchFrom ?? cwd);
127
+ const configPath = resolveConfigPath({
128
+ cwd: searchFrom,
129
+ configFile: opts.configFile
130
+ });
131
+ let fileConfig = {};
132
+ if (configPath) {
133
+ const raw = await loadConfigModule(configPath);
134
+ if (raw == null || typeof raw !== "object") throw new Error(`[flue] ${path.relative(cwd, configPath) || configPath} must export a config object as the default export.`);
135
+ const result = v.safeParse(UserFlueConfigSchema, raw);
136
+ if (!result.success) throw new Error(formatValidationError(configPath, result.issues));
137
+ fileConfig = result.output;
138
+ }
139
+ const configDir = configPath ? path.dirname(configPath) : searchFrom;
140
+ const inline = opts.inline ?? {};
141
+ const merged = {
142
+ target: inline.target ?? fileConfig.target,
143
+ root: inline.root ?? fileConfig.root,
144
+ output: inline.output ?? fileConfig.output
145
+ };
146
+ if (!merged.target) throw new Error("[flue] Missing required `target`. Set it via `--target <node|cloudflare>` or in `flue.config.ts` as `target: \"node\"` (or `\"cloudflare\"`).");
147
+ const root = resolvePath(merged.root, {
148
+ fromConfig: !!fileConfig.root && inline.root === void 0,
149
+ configDir,
150
+ fallback: configDir
151
+ });
152
+ const output = resolvePath(merged.output, {
153
+ fromConfig: !!fileConfig.output && inline.output === void 0,
154
+ configDir,
155
+ fallback: path.join(root, "dist")
156
+ });
157
+ return {
158
+ configPath,
159
+ userConfig: merged,
160
+ flueConfig: {
161
+ target: merged.target,
162
+ root,
163
+ output
164
+ }
165
+ };
166
+ }
167
+ /**
168
+ * Resolve a possibly-relative path to an absolute one.
169
+ *
170
+ * - If `value` is undefined, returns `fallback`.
171
+ * - If `value` is absolute, returns it as-is.
172
+ * - If `value` is relative AND came from the config file, resolves vs. the
173
+ * config dir.
174
+ * - If `value` is relative AND came from the CLI, the CLI is responsible for
175
+ * already having absolutized it (`path.resolve` against cwd at parse time)
176
+ * — this branch is defensive and resolves against `process.cwd()`.
177
+ */
178
+ function resolvePath(value, opts) {
179
+ if (!value) return opts.fallback;
180
+ if (path.isAbsolute(value)) return value;
181
+ if (opts.fromConfig) return path.resolve(opts.configDir, value);
182
+ return path.resolve(value);
183
+ }
184
+ function formatValidationError(configPath, issues) {
185
+ const lines = [`[flue] Invalid config in ${configPath}:`];
186
+ for (const issue of issues) {
187
+ const dotPath = v.getDotPath(issue);
188
+ const where = dotPath ? ` • ${dotPath}: ` : " • ";
189
+ lines.push(`${where}${issue.message}`);
190
+ }
191
+ return lines.join("\n");
192
+ }
193
+
194
+ //#endregion
195
+ export { defineConfig, resolveConfig, resolveConfigPath };
@@ -0,0 +1,184 @@
1
+ import { FlueContextInternal } from "./client.mjs";
2
+ import { Hono } from "hono";
3
+
4
+ //#region src/runtime/handle-agent.d.ts
5
+ /**
6
+ * Agent handler signature — the default export of a `.flue/agents/<name>.ts`
7
+ * file. Receives a context, may return any JSON-serializable value (or
8
+ * undefined for fire-and-forget agents).
9
+ */
10
+ type AgentHandler = (ctx: FlueContextInternal) => unknown | Promise<unknown>;
11
+ /**
12
+ * Caller-provided context factory. Differs per-target:
13
+ * - Node: env=process.env, defaultStore=in-memory, no resolveSandbox.
14
+ * - Cloudflare: env=DO env, defaultStore=DO SQLite, resolveSandbox=cfSandboxToSessionEnv.
15
+ */
16
+ type CreateContextFn = (id: string, payload: unknown, request: Request) => FlueContextInternal;
17
+ /**
18
+ * Webhook execution wrapper. Receives the prepared run callback and returns
19
+ * a promise that resolves with the handler's return value. Implementations:
20
+ *
21
+ * - Node: just `run()` — no fiber, no DO.
22
+ * - Cloudflare: `doInstance.runFiber('flue:webhook:<requestId>', run)`.
23
+ *
24
+ * The caller is responsible for any logging on completion/error; this routine
25
+ * just kicks it off and returns the 202.
26
+ */
27
+ type StartWebhookFn = (requestId: string, run: () => Promise<unknown>) => Promise<unknown>;
28
+ /**
29
+ * Foreground handler execution wrapper. Wraps the call to `handler(ctx)` so
30
+ * targets can layer in keepalive / context propagation. Defaults to direct
31
+ * invocation when omitted.
32
+ */
33
+ type RunHandlerFn = (ctx: FlueContextInternal, handler: AgentHandler) => unknown | Promise<unknown>;
34
+ interface HandleAgentOptions {
35
+ /** Standard Fetch Request. */
36
+ request: Request;
37
+ /**
38
+ * The agent name (URL segment). Used only in webhook completion / error
39
+ * log lines — routing has already happened by the time we get here.
40
+ */
41
+ agentName: string;
42
+ /** Agent id (URL segment / DO room name). */
43
+ id: string;
44
+ /** The agent's default-export handler. */
45
+ handler: AgentHandler;
46
+ /** Per-target context factory. */
47
+ createContext: CreateContextFn;
48
+ /**
49
+ * Per-target webhook runner. If omitted, fire-and-forget executes the
50
+ * prepared `run` callback directly (Node default — handler runs in the
51
+ * same process as the request handler). On Cloudflare the caller MUST
52
+ * provide this with a `runFiber` wrapper so the handler survives DO
53
+ * hibernation between the 202 ack and the actual completion.
54
+ */
55
+ startWebhook?: StartWebhookFn;
56
+ /**
57
+ * Per-target foreground handler wrapper. If omitted, the handler is
58
+ * invoked directly (Node default). On Cloudflare this is a
59
+ * `runWithCloudflareContext` + `keepAliveWhile` wrapper that propagates
60
+ * `env` via AsyncLocalStorage and prevents the DO from hibernating
61
+ * mid-stream.
62
+ */
63
+ runHandler?: RunHandlerFn;
64
+ }
65
+ /**
66
+ * Dispatch a single `/agents/:name/:id` request. The mode is chosen by
67
+ * inspecting headers:
68
+ *
69
+ * - `X-Webhook: true` → fire-and-forget. Returns 202 immediately; the
70
+ * handler runs in the background. Errors are logged server-side.
71
+ * - `Accept: text/event-stream` (and not webhook) → SSE streaming. Returns
72
+ * 200 + text/event-stream. Events come from the FlueContext's event
73
+ * callback; final result is appended as `event: result`. Per-event errors
74
+ * surface as `event: error` envelopes.
75
+ * - Otherwise → sync. Returns 200 + JSON `{ result }`.
76
+ *
77
+ * Errors thrown BEFORE streaming starts (body parse, agent lookup) bubble
78
+ * out as a `Response` via {@link toHttpResponse} — headers haven't been sent
79
+ * yet, so a regular HTTP error is still possible. Errors thrown AFTER the
80
+ * 200 + text/event-stream headers are on the wire (i.e. inside the agent
81
+ * handler) get framed as in-stream `error` events instead.
82
+ *
83
+ * Caller is responsible for routing — this function assumes the request has
84
+ * already been validated as a POST against a registered agent.
85
+ */
86
+ declare function handleAgentRequest(opts: HandleAgentOptions): Promise<Response>;
87
+ //#endregion
88
+ //#region src/runtime/flue-app.d.ts
89
+ /**
90
+ * Runtime configuration for {@link flue}, seeded by the generated server
91
+ * entry before the user's `app.ts` is imported. The shape is internal —
92
+ * users never construct this directly.
93
+ *
94
+ * The Node/Cloudflare branches use different fields. Splitting via a
95
+ * discriminated union would type-check more cleanly, but since the only
96
+ * caller of `configureFlueRuntime` is the build's own generated code,
97
+ * a flat optional-fields shape is simpler to maintain.
98
+ */
99
+ interface FlueRuntime {
100
+ target: 'node' | 'cloudflare';
101
+ /**
102
+ * Names of agents reachable over HTTP when not in local mode.
103
+ * Trigger-less agents are excluded from this list and gate access
104
+ * via {@link FlueRuntime.allowNonWebhook}.
105
+ */
106
+ webhookAgents: ReadonlyArray<string>;
107
+ /**
108
+ * If true, the agent route accepts any registered agent — including
109
+ * trigger-less ones. Used by the Node target when `FLUE_MODE=local`
110
+ * (set by `flue run` and `flue dev --target node`). Always false on
111
+ * Cloudflare today.
112
+ */
113
+ allowNonWebhook: boolean;
114
+ /**
115
+ * Map of agent name → handler function. Includes ALL agents (webhook
116
+ * and trigger-less); {@link webhookAgents} gates HTTP exposure when
117
+ * not in local mode. Required when {@link target} is `'node'`.
118
+ */
119
+ handlers?: Record<string, AgentHandler>;
120
+ /**
121
+ * Per-target context factory. Required when {@link target} is `'node'`.
122
+ */
123
+ createContext?: CreateContextFn;
124
+ /** Optional Node webhook execution wrapper. Defaults to direct invocation. */
125
+ startWebhook?: StartWebhookFn;
126
+ /** Optional Node foreground handler wrapper. Defaults to direct invocation. */
127
+ runHandler?: RunHandlerFn;
128
+ /**
129
+ * Forward an incoming request to the per-agent Durable Object via
130
+ * Cloudflare's Agents SDK. Required when {@link target} is `'cloudflare'`.
131
+ *
132
+ * Returning `null` means "no DO matched" — the caller renders a
133
+ * `RouteNotFoundError` envelope so the response shape stays
134
+ * consistent with every other miss.
135
+ */
136
+ routeAgentRequest?: (request: Request, env: unknown) => Promise<Response | null>;
137
+ }
138
+ /**
139
+ * Seed the runtime config consumed by {@link flue}. Called exactly
140
+ * once at module load by the generated server entry. The Hono routes
141
+ * returned by `flue()` read this config lazily — see the comment on
142
+ * {@link runtimeConfig} for why timing relative to user `app.ts`
143
+ * evaluation is fine.
144
+ *
145
+ * Not part of the public API — exposed via `@flue/sdk/internal` only
146
+ * because the generated entry imports it from a stable bare specifier.
147
+ */
148
+ declare function configureFlueRuntime(cfg: FlueRuntime): void;
149
+ /**
150
+ * Public Hono sub-app mounting Flue's built-in agent route. Users
151
+ * compose this into their own Hono via Hono's `app.route(path, subApp)`:
152
+ *
153
+ * import { Hono } from 'hono';
154
+ * import { flue } from '@flue/sdk/app';
155
+ *
156
+ * const app = new Hono();
157
+ * app.use('*', logger());
158
+ * app.get('/api/ping', (c) => c.json({ pong: true }));
159
+ * app.route('/', flue());
160
+ *
161
+ * export default app;
162
+ *
163
+ * Each call to `flue()` returns a fresh Hono. Mounting it twice is
164
+ * legal but pointless — both sub-apps read from the same seeded
165
+ * runtime and produce identical responses.
166
+ *
167
+ * Importable from `@flue/sdk/app`.
168
+ */
169
+ declare function flue(): Hono;
170
+ /**
171
+ * Build the default outer Hono app used when no user `app.ts` is
172
+ * present. Mounts `flue()` at root, renders canonical Flue envelopes
173
+ * for unmatched paths and any thrown errors.
174
+ *
175
+ * Lives in the SDK rather than the generated entry so that user
176
+ * projects on the Cloudflare target — whose `node_modules` does not
177
+ * declare `hono` directly — don't have to add it themselves just to
178
+ * keep the no-`app.ts` default behavior working. When a user does
179
+ * write an `app.ts`, they own this composition and must `pnpm add
180
+ * hono` (or equivalent) themselves.
181
+ */
182
+ declare function createDefaultFlueApp(): Hono;
183
+ //#endregion
184
+ export { AgentHandler as a, RunHandlerFn as c, flue as i, StartWebhookFn as l, configureFlueRuntime as n, CreateContextFn as o, createDefaultFlueApp as r, HandleAgentOptions as s, FlueRuntime as t, handleAgentRequest as u };