@spfn/core 0.3.0-beta.4 → 0.3.0-beta.6

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.
Files changed (45) hide show
  1. package/README.md +183 -4
  2. package/dist/authz/index.js +1 -381
  3. package/dist/authz/index.js.map +1 -1
  4. package/dist/db/index.d.ts +173 -27
  5. package/dist/db/index.js +192 -57
  6. package/dist/db/index.js.map +1 -1
  7. package/dist/env/loader.js +24 -1
  8. package/dist/env/loader.js.map +1 -1
  9. package/dist/errors/index.js +1 -381
  10. package/dist/errors/index.js.map +1 -1
  11. package/dist/logger/index.js +0 -12
  12. package/dist/logger/index.js.map +1 -1
  13. package/dist/middleware/index.js +6 -387
  14. package/dist/middleware/index.js.map +1 -1
  15. package/dist/nextjs/index.d.ts +18 -1
  16. package/dist/nextjs/index.js +40 -1
  17. package/dist/nextjs/index.js.map +1 -1
  18. package/dist/nextjs/server.d.ts +34 -1
  19. package/dist/nextjs/server.js +14 -0
  20. package/dist/nextjs/server.js.map +1 -1
  21. package/dist/ops/index.d.ts +61 -6
  22. package/dist/ops/index.js +330 -30
  23. package/dist/ops/index.js.map +1 -1
  24. package/dist/server/index.js +24 -1
  25. package/dist/server/index.js.map +1 -1
  26. package/docs/file-upload.md +195 -333
  27. package/package.json +6 -5
  28. package/src/cache/README.md +330 -0
  29. package/src/codegen/README.md +516 -0
  30. package/src/config/README.md +326 -0
  31. package/src/contract/README.md +326 -0
  32. package/src/db/README.md +589 -0
  33. package/src/db/manager/README.md +500 -0
  34. package/src/db/schema/README.md +344 -0
  35. package/src/db/transaction/README.md +822 -0
  36. package/src/env/README.md +651 -0
  37. package/src/errors/README.md +429 -0
  38. package/src/event/README.md +736 -0
  39. package/src/job/README.md +514 -0
  40. package/src/logger/README.md +321 -0
  41. package/src/middleware/README.md +634 -0
  42. package/src/nextjs/README.md +608 -0
  43. package/src/route/README.md +738 -0
  44. package/src/security/README.md +100 -0
  45. package/src/server/README.md +704 -0
package/README.md CHANGED
@@ -185,18 +185,144 @@ import type { AppRouter } from '@/server/router';
185
185
 
186
186
  export const api = createApi<AppRouter>();
187
187
 
188
- // anywhere server component, client component, or server action:
188
+ // the same client in a Server Component, a Client Component or a Server Action:
189
189
  const user = await api.getUser.call({ params: { id: '123' } }); // typed { id, name }
190
190
  const made = await api.createUser.call({ body: { name: 'A' } });
191
191
  ```
192
192
 
193
+ That the client is isomorphic does not make the three callers interchangeable. **A page's
194
+ initial data is awaited in the Server Component**, where
195
+ `api.getUser.fetchOptions({ next: { revalidate, tags } }).call(…)` participates in
196
+ Next.js caching and reaches the backend without a browser round trip. Fetching that same
197
+ first paint from a `'use client'` component inside a `useEffect` is the anti-pattern: it
198
+ ships a loading state and a second network hop for data the server already had. Client
199
+ Components and Server Actions are for what happens after the first paint — interaction
200
+ and mutation. Cache tags, revalidation and SSR cookie forwarding are in
201
+ [the Next.js bridge docs](https://superfunction.xyz/docs/packages/core/nextjs).
202
+
203
+ ---
204
+
205
+ ## How does a repository talk to the database?
206
+
207
+ Through `BaseRepository`. Extending it gives a repository two transaction-aware
208
+ connections — `this.db` (write/primary) and `this.readDb` (the replica, when one is
209
+ configured) — plus the CRUD set as protected methods.
210
+
211
+ ```typescript
212
+ // server/repositories/order.ts
213
+ import { BaseRepository } from '@spfn/core/db';
214
+ import { desc } from 'drizzle-orm';
215
+ import { orders } from '../entities/order';
216
+
217
+ export class OrderRepository extends BaseRepository
218
+ {
219
+ findRecentFor(userId: string)
220
+ {
221
+ return this._findMany(orders, { where: { userId }, orderBy: desc(orders.createdAt) });
222
+ }
223
+
224
+ place(data: { userId: string; total: number })
225
+ {
226
+ return this._create(orders, data);
227
+ }
228
+ }
229
+
230
+ export const orderRepo = new OrderRepository();
231
+ ```
232
+
233
+ Handlers never import drizzle query builders; repositories do. `_findMany` reads through
234
+ `this.readDb`, `_create` writes through `this.db`, and both getters resolve to the active
235
+ transaction's connection when there is one — so the same method is correct inside a
236
+ transaction and outside it. When a helper cannot express a query, drop to
237
+ `this.readDb.select()…` inside the repository rather than in the handler. The full
238
+ protected CRUD set is in [src/db](./src/db/README.md).
239
+
240
+ ---
241
+
242
+ ## Where do transactions and their side effects go?
243
+
244
+ `Transactional()` covers the route case — commit on return, rollback on throw. Two rules
245
+ decide the rest.
246
+
247
+ **Nothing takes a `tx` parameter.** The transaction travels in AsyncLocalStorage, so
248
+ `this.db` inside a repository already resolves to it. A service that accepts `tx` and
249
+ threads it downward re-implements propagation that already happened, and the first caller
250
+ that forgets to pass it writes outside the transaction. For a service, script or job with
251
+ no route around it, open one with `runInTransaction(fn, options?)`;
252
+ `runWithTransaction(tx, txId, fn)` is the lower-level primitive that binds an existing
253
+ Drizzle transaction into the context.
254
+
255
+ **Side effects go on the commit hooks, not inline.** An event emitted or a mail sent from
256
+ inside the transaction still went out when the transaction later rolls back.
257
+
258
+ | Hook | When it runs | What it is for |
259
+ |---|---|---|
260
+ | `onBeforeCommit(fn)` | Inside the still-open transaction, just before commit — a throw aborts and rolls back | Last-moment invariant checks, and statements that must land in the same commit |
261
+ | `onAfterCommit(fn)` | After the root transaction commits, outside the transaction context; errors are logged, never thrown | Events, mail, cache invalidation — anything the outside world observes |
262
+ | `onAfterRollback(fn)` | After the root transaction rolls back, before the causing error propagates; errors are logged, never thrown | Undoing external work that cannot roll itself back, such as an object already uploaded |
263
+
264
+ All three import from `@spfn/core/db`, can be registered anywhere inside the transaction,
265
+ and bubble to the **root** transaction — a nested block's callbacks fire on the outermost
266
+ outcome, not on a savepoint's.
267
+
268
+ ```typescript
269
+ import { runInTransaction, onAfterCommit } from '@spfn/core/db';
270
+
271
+ export async function placeOrder(input: { userId: string; total: number })
272
+ {
273
+ return runInTransaction(async () =>
274
+ {
275
+ const order = await orderRepo.place(input); // no tx argument, at any depth
276
+ onAfterCommit(() => orderPlacedEvent.emit({ orderId: order.id }));
277
+
278
+ return order;
279
+ });
280
+ }
281
+ ```
282
+
283
+ ---
284
+
285
+ ## What else can defineServerConfig configure?
286
+
287
+ `.port()` and `.routes()` are the two every app calls. The rest of the builder is how an
288
+ app wires its infrastructure without touching the server's boot sequence. Every method
289
+ returns the builder; `.build()` ends the chain.
290
+
291
+ | Method | What it configures |
292
+ |---|---|
293
+ | `.port(n)` / `.host(s)` | Where the server listens |
294
+ | `.routes(router)` | The `defineRouter` router to mount, with its own `.use()` and `.packages()` |
295
+ | `.jobs(router, config?)` | Background jobs — a `defineJobRouter`, plus pg-boss options |
296
+ | `.events(router, config?)` | SSE streaming — a `defineEventRouter`, served at `GET /events/stream` |
297
+ | `.websockets(router, config?)` | Bidirectional WebSockets — a `defineWSRouter`, served at `WS /ws` |
298
+ | `.workflows(router, config?)` | `@spfn/workflow` orchestration; the engine starts once the database is ready |
299
+ | `.lifecycle(hooks)` | Boot and shutdown hooks. Callable more than once; hooks run in registration order |
300
+ | `.migrations(opts)` | The migration boot gate — `{ allowPending: true }` lets a server start behind its migrations |
301
+ | `.database(opts)` | Connection and pool settings |
302
+ | `.infrastructure(opts)` | Which infrastructure is initialized at boot |
303
+ | `.healthCheck(opts)` | The health endpoint |
304
+ | `.serverTime(clock)` | The clock behind `GET /_core/time`; normally left at the default |
305
+ | `.middleware(opts)` | The built-in middleware — `ErrorHandler`, `RequestLogger` |
306
+ | `.use(handlers)` | Additional global Hono middleware |
307
+ | `.middlewares(named)` | Global middleware under names, so a route can `.skip([...])` it |
308
+ | `.cors(opts)` | CORS |
309
+ | `.rateLimit(opts)` | A global default limiter plus the named policies routes resolve against |
310
+ | `.proxyGuard(opts)` | Trusted-proxy signature and origin verification, resolved to a `clientType` |
311
+ | `.outboundFetch(opts)` | The SSRF policy `safeFetch` applies to outbound calls |
312
+ | `.timeout(opts)` / `.shutdown(opts)` | Request timeouts and graceful shutdown |
313
+ | `.debug(bool)` | Debug logging |
314
+
315
+ The options each one takes are in [src/server](./src/server/README.md).
316
+
193
317
  ---
194
318
 
195
319
  ## Which import path do I use for what?
196
320
 
197
321
  There is **no root barrel**: `import … from '@spfn/core'` does not resolve. Every symbol
198
322
  comes from a subpath, and the table below is the complete public surface — one row per
199
- entry in `package.json` `exports`. Each module has its own README with the API detail.
323
+ entry in `package.json` `exports`, with a single exclusion: `./client` is still listed in
324
+ `exports` but the build no longer emits it, so it has no row (see [Pitfalls](#pitfalls)).
325
+ Each module has its own README with the API detail.
200
326
 
201
327
  | Import path | Purpose | Doc |
202
328
  |-------------|---------|-----|
@@ -207,16 +333,17 @@ entry in `package.json` `exports`. Each module has its own README with the API d
207
333
  | `@spfn/core/nextjs/server` | Server-only: `createRpcProxy({ routeMap })`, `registerInterceptors`. Uses `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
208
334
  | `@spfn/core/db` | PostgreSQL through Drizzle: CRUD helpers, `BaseRepository`, schema helpers, transactions, Postgres error mapping. One entry point for all of it. | [src/db](./src/db/README.md) |
209
335
  | `@spfn/core/db` → manager | Connection lifecycle, pool, primary/replica, health check, reconnect (`initDatabase`, `getDatabase`). Re-exported from `@spfn/core/db`. | [src/db/manager](./src/db/manager/README.md) |
210
- | `@spfn/core/db` → migrations | Which migrations each installed function package ships, and which the database has applied (`collectMigrationStatus`, `discoverFunctionMigrations`). What `spfn db status`, the boot gate and health all read. Re-exported from `@spfn/core/db`. | [src/db/migrations](./src/db/migrations/index.ts) |
336
+ | `@spfn/core/db` → migrations | Which migrations each installed function package ships, and which the database has applied (`collectMigrationStatus`, `discoverFunctionMigrations`). What `spfn db status`, the boot gate and health all read. Re-exported from `@spfn/core/db`. | [src/db/migrations](https://github.com/fxylabs/spfn/blob/main/packages/core/src/db/migrations/index.ts) |
211
337
  | `@spfn/core/db` → schema | Drizzle column helpers (`id`, `uuid`, `timestamps`, `foreignKey`, `enumText`, `typedJsonb`, `softDelete`, …). Re-exported from `@spfn/core/db`. | [src/db/schema](./src/db/schema/README.md) |
212
338
  | `@spfn/core/db` → transaction | `Transactional()` middleware and `runInTransaction`; the transaction reaches every repository through AsyncLocalStorage. Re-exported from `@spfn/core/db`. | [src/db/transaction](./src/db/transaction/README.md) |
213
339
  | `@spfn/core/middleware` | Built-in Hono middleware: `ErrorHandler`, `RequestLogger` and its masking helper. | [src/middleware](./src/middleware/README.md) |
214
340
  | `@spfn/core/errors` | Serializable HTTP and database error classes, plus `ErrorRegistry` so an error survives the trip to the client as its own class. | [src/errors](./src/errors/README.md) |
215
341
  | `@spfn/core/security` | `safeFetch` — a drop-in `fetch` hardened against SSRF, including DNS rebinding, by pinning the connection to a validated IP. | [src/security](./src/security/README.md) |
216
- | `@spfn/core/authz` | Ownership guards. `requireOwner(resource, userId)` makes "load it, then check it belongs to the requester" one call, so a handler cannot forget it. | [src/authz/index.ts](./src/authz/index.ts) |
342
+ | `@spfn/core/authz` | Ownership guards. `requireOwner(resource, userId)` makes "load it, then check it belongs to the requester" one call, so a handler cannot forget it. | [src/authz/index.ts](https://github.com/fxylabs/spfn/blob/main/packages/core/src/authz/index.ts) |
217
343
  | `@spfn/core/env` | Schema-based environment validation, isomorphic. | [src/env](./src/env/README.md) |
218
344
  | `@spfn/core/env/loader` | The **server-only** `.env` file loader (uses `node:fs`). | [src/env](./src/env/README.md) |
219
345
  | `@spfn/core/config` | `@spfn/core`'s own validated env config (`env`, `envSchema`, `registry`), built on `@spfn/core/env`. | [src/config](./src/config/README.md) |
346
+ | `@spfn/core/app-config` | Reads `spfn.config.js` — the one committed place that says which ports and host the app is served on (`loadAppConfig`, `resolvePorts`, `resolveHost`, `PORT_DEFAULTS`). Deliberately side-effect free, so the CLI can import it before an app's environment exists. | [src/app-config/index.ts](https://github.com/fxylabs/spfn/blob/main/packages/core/src/app-config/index.ts) |
220
347
  | `@spfn/core/logger` | Structured singleton `logger` with child loggers and level masking. No dependencies. | [src/logger](./src/logger/README.md) |
221
348
  | `@spfn/core/cache` | Valkey/Redis singleton over ioredis (`getCache`, `getCacheRead`). Degrades to disabled rather than throwing. | [src/cache](./src/cache/README.md) |
222
349
  | `@spfn/core/job` | Background jobs on pg-boss: a fluent `job()` builder, cron, run-once, event-driven, `defineJobRouter`. | [src/job](./src/job/README.md) |
@@ -227,6 +354,7 @@ entry in `package.json` `exports`. Each module has its own README with the API d
227
354
  | `@spfn/core/event/ws/client` | Browser WebSocket client. | [src/event](./src/event/README.md) |
228
355
  | `@spfn/core/codegen` | The codegen orchestrator and the built-in generators: `@spfn/core:route-map` for the proxy's route map, `@spfn/core:contract` for the client contract. | [src/codegen](./src/codegen/README.md) |
229
356
  | `@spfn/core/contract` | Route contracts for clients that ship separately: collect, snapshot, and the build gate that refuses a breaking change. | [src/contract](./src/contract/README.md) |
357
+ | `@spfn/core/ops` | The operations surface `spfn ops` drives: `opsRoute`, `createOpsRouter`, `defineOpsModule`, and the manifest the CLI discovers commands from. Structure only — the router is always authenticated, and token verification lives in `@spfn/auth`. | [How do I operate the app from the terminal?](#how-do-i-operate-the-app-from-the-terminal) |
230
358
 
231
359
  `db/manager`, `db/schema` and `db/transaction` are **not** package subpaths of their own.
232
360
  They are internal modules re-exported by `@spfn/core/db` — import their symbols from
@@ -516,6 +644,57 @@ spfn ops call listSignups --query limit=50 # invoke one
516
644
  spfn ops call listSignups --describe # print its usage (--json for raw schemas)
517
645
  ```
518
646
 
647
+ ### Can a package ship ops commands?
648
+
649
+ It can describe them. Whether they are reachable is your application's decision, made in
650
+ the `createOpsRouter` call — installing a package never adds anything to your ops surface.
651
+ Available from **0.3.0-beta.5**.
652
+
653
+ ```typescript
654
+ // in the package
655
+ export const ledgerOpsModule = defineOpsModule({
656
+ id: 'ledger',
657
+ source: '@acme/ledger',
658
+ contractVersion: '1.0.0',
659
+ summary: 'Ledger diagnostics',
660
+ commands: {
661
+ verify: {
662
+ summary: 'Verify ledger invariants',
663
+ effect: 'read', // read | write | destructive
664
+ scopes: ['ledger:read'],
665
+ route: opsRoute.get('/ledger/verify').handler(verifyLedger),
666
+ },
667
+ },
668
+ });
669
+
670
+ // in the application — nothing is mounted until this line names it
671
+ export const opsRouter = createOpsRouter({ listSignups }, {
672
+ auth: opsTokenAuth,
673
+ authorize: requireOpsScope,
674
+ modules: [ledgerOpsModule],
675
+ });
676
+ ```
677
+
678
+ A module command is named `<module>.<command>` (`ledger.verify`) and its route must live
679
+ under `/_ops/<module>/`. The scopes it declares become a server-side guard, run after
680
+ authentication — `authorize` is required as soon as any module is mounted, and it is passed
681
+ in rather than imported so core stays independent of `@spfn/auth`.
682
+
683
+ What is refused at definition time: a path that could decode its way out of the module's
684
+ namespace, two commands in one module that could answer the same URL, and an app route that
685
+ overlaps a mounted module's command. That last one matters because the alternative is a
686
+ surface where which command answers depends on route registration order.
687
+
688
+ The manifest gains a `modules` array and per-command `module`, `summary`, `effect` and
689
+ `scopes`. All of it is additive — an app that mounts no modules serves exactly the v1
690
+ manifest it served before.
691
+
692
+ ```bash
693
+ spfn ops modules # what is mounted, and from where
694
+ spfn ops list --module ledger # just that module's commands
695
+ spfn ops call ledger.compact --yes # effect=destructive needs this
696
+ ```
697
+
519
698
  Authentication is an ops token from [`@spfn/auth`](../auth/README.md#ops-tokens-spfn-ops):
520
699
  scoped, revocable, hash-stored, issued with `spfn ops token issue` against the running app
521
700
  — the CLI signs in as an administrator, so issuance needs no database access. On macOS the
@@ -1,4 +1,4 @@
1
- import { format } from 'util';
1
+ import { logger } from '@spfn/core/logger';
2
2
 
3
3
  // src/errors/error-registry.ts
4
4
  var ErrorRegistry = class _ErrorRegistry {
@@ -90,386 +90,6 @@ var ErrorRegistry = class _ErrorRegistry {
90
90
  return Array.from(this.errors.keys());
91
91
  }
92
92
  };
93
-
94
- // src/logger/types.ts
95
- var LOG_LEVEL_PRIORITY = {
96
- debug: 0,
97
- info: 1,
98
- warn: 2,
99
- error: 3,
100
- fatal: 4
101
- };
102
-
103
- // src/logger/formatters.ts
104
- var SENSITIVE_KEYS = [
105
- "password",
106
- "passwd",
107
- "pwd",
108
- "secret",
109
- "token",
110
- "apikey",
111
- "api_key",
112
- "accesstoken",
113
- "access_token",
114
- "refreshtoken",
115
- "refresh_token",
116
- "authorization",
117
- "auth",
118
- "cookie",
119
- "session",
120
- "sessionid",
121
- "session_id",
122
- "privatekey",
123
- "private_key",
124
- "creditcard",
125
- "credit_card",
126
- "cardnumber",
127
- "card_number",
128
- "cvv",
129
- "ssn",
130
- "pin"
131
- ];
132
- var MASKED_VALUE = "***MASKED***";
133
- function isSensitiveKey(key) {
134
- const lowerKey = key.toLowerCase();
135
- return SENSITIVE_KEYS.some((sensitive) => lowerKey.includes(sensitive));
136
- }
137
- function maskSensitiveData(data, seen = /* @__PURE__ */ new WeakSet()) {
138
- if (data === null || data === void 0) {
139
- return data;
140
- }
141
- if (typeof data !== "object") {
142
- return data;
143
- }
144
- if (seen.has(data)) {
145
- return "[Circular]";
146
- }
147
- seen.add(data);
148
- if (Array.isArray(data)) {
149
- return data.map((item) => maskSensitiveData(item, seen));
150
- }
151
- const masked = {};
152
- for (const [key, value] of Object.entries(data)) {
153
- if (isSensitiveKey(key)) {
154
- masked[key] = MASKED_VALUE;
155
- } else if (typeof value === "object" && value !== null) {
156
- masked[key] = maskSensitiveData(value, seen);
157
- } else {
158
- masked[key] = value;
159
- }
160
- }
161
- return masked;
162
- }
163
- var COLORS = {
164
- reset: "\x1B[0m",
165
- bright: "\x1B[1m",
166
- dim: "\x1B[2m",
167
- // 로그 레벨 컬러
168
- debug: "\x1B[36m",
169
- // cyan
170
- info: "\x1B[32m",
171
- // green
172
- warn: "\x1B[33m",
173
- // yellow
174
- error: "\x1B[31m",
175
- // red
176
- fatal: "\x1B[35m",
177
- // magenta
178
- // 추가 컬러
179
- gray: "\x1B[90m"
180
- };
181
- function formatTimestampHuman(date) {
182
- const year = date.getFullYear();
183
- const month = String(date.getMonth() + 1).padStart(2, "0");
184
- const day = String(date.getDate()).padStart(2, "0");
185
- const hours = String(date.getHours()).padStart(2, "0");
186
- const minutes = String(date.getMinutes()).padStart(2, "0");
187
- const seconds = String(date.getSeconds()).padStart(2, "0");
188
- const ms = String(date.getMilliseconds()).padStart(3, "0");
189
- return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms}`;
190
- }
191
- function formatError(error) {
192
- const lines = [];
193
- lines.push(`${error.name}: ${error.message}`);
194
- if (error.stack) {
195
- const stackLines = error.stack.split("\n").slice(1);
196
- lines.push(...stackLines);
197
- }
198
- if (error.cause instanceof Error) {
199
- lines.push(`Caused by: ${formatError(error.cause)}`);
200
- } else if (error.cause !== void 0) {
201
- lines.push(`Caused by: ${String(error.cause)}`);
202
- }
203
- return lines.join("\n");
204
- }
205
- function formatConsole(metadata, colorize = true) {
206
- const parts = [];
207
- const timestamp = formatTimestampHuman(metadata.timestamp);
208
- if (colorize) {
209
- parts.push(`${COLORS.gray}[${timestamp}]${COLORS.reset}`);
210
- } else {
211
- parts.push(`[${timestamp}]`);
212
- }
213
- const pid = process.pid;
214
- if (colorize) {
215
- parts.push(`${COLORS.dim}[pid=${pid}]${COLORS.reset}`);
216
- } else {
217
- parts.push(`[pid=${pid}]`);
218
- }
219
- if (metadata.module) {
220
- if (colorize) {
221
- parts.push(`${COLORS.dim}[module=${metadata.module}]${COLORS.reset}`);
222
- } else {
223
- parts.push(`[module=${metadata.module}]`);
224
- }
225
- }
226
- if (metadata.context && Object.keys(metadata.context).length > 0) {
227
- Object.entries(metadata.context).forEach(([key, value]) => {
228
- let valueStr;
229
- if (typeof value === "string") {
230
- valueStr = value;
231
- } else if (typeof value === "object" && value !== null) {
232
- try {
233
- valueStr = JSON.stringify(value);
234
- } catch (error) {
235
- valueStr = "[circular]";
236
- }
237
- } else {
238
- valueStr = String(value);
239
- }
240
- if (colorize) {
241
- parts.push(`${COLORS.dim}[${key}=${valueStr}]${COLORS.reset}`);
242
- } else {
243
- parts.push(`[${key}=${valueStr}]`);
244
- }
245
- });
246
- }
247
- const levelStr = metadata.level.toUpperCase();
248
- if (colorize) {
249
- const color = COLORS[metadata.level];
250
- parts.push(`${color}(${levelStr})${COLORS.reset}:`);
251
- } else {
252
- parts.push(`(${levelStr}):`);
253
- }
254
- if (colorize) {
255
- parts.push(`${COLORS.bright}${metadata.message}${COLORS.reset}`);
256
- } else {
257
- parts.push(metadata.message);
258
- }
259
- let output = parts.join(" ");
260
- if (metadata.error) {
261
- output += "\n" + formatError(metadata.error);
262
- }
263
- return output;
264
- }
265
-
266
- // src/logger/logger.ts
267
- var FORMAT_PATTERN = /%[sdifjoOc%]/;
268
- var Logger = class _Logger {
269
- config;
270
- module;
271
- constructor(config) {
272
- this.config = config;
273
- this.module = config.module;
274
- }
275
- /**
276
- * Convert unknown error to Error object
277
- */
278
- toError(error) {
279
- if (error instanceof Error) return error;
280
- if (typeof error === "string") return new Error(error);
281
- if (typeof error === "object" && error !== null) {
282
- return new Error(JSON.stringify(error));
283
- }
284
- return new Error(String(error));
285
- }
286
- /**
287
- * Check if value is a context object (not an error)
288
- */
289
- isContext(value) {
290
- if (typeof value !== "object" || value === null) return false;
291
- if (value instanceof Error) return false;
292
- const hasStack = "stack" in value && typeof value.stack === "string";
293
- if (hasStack) {
294
- return false;
295
- }
296
- return true;
297
- }
298
- /**
299
- * Get current log level
300
- */
301
- get level() {
302
- return this.config.level;
303
- }
304
- /**
305
- * Create child logger (per module)
306
- */
307
- child(module) {
308
- return new _Logger({
309
- ...this.config,
310
- module
311
- });
312
- }
313
- /**
314
- * Common log method with error/context detection
315
- */
316
- logWithLevel(level, message, errorOrContext, context) {
317
- if (errorOrContext !== void 0 && FORMAT_PATTERN.test(message)) {
318
- this.log(level, format(message, errorOrContext), void 0, context);
319
- return;
320
- }
321
- if (errorOrContext instanceof Error) {
322
- this.log(level, message, errorOrContext, context);
323
- } else if (errorOrContext !== void 0 && typeof errorOrContext === "object" && !this.isContext(errorOrContext)) {
324
- this.log(level, message, this.toError(errorOrContext), context);
325
- } else if (typeof errorOrContext === "string" || typeof errorOrContext === "number" || typeof errorOrContext === "boolean") {
326
- this.log(level, message, this.toError(errorOrContext), context);
327
- } else {
328
- this.log(level, message, void 0, errorOrContext);
329
- }
330
- }
331
- debug(message, errorOrContext, context) {
332
- this.logWithLevel("debug", message, errorOrContext, context);
333
- }
334
- info(message, errorOrContext, context) {
335
- this.logWithLevel("info", message, errorOrContext, context);
336
- }
337
- warn(message, errorOrContext, context) {
338
- this.logWithLevel("warn", message, errorOrContext, context);
339
- }
340
- error(message, errorOrContext, context) {
341
- this.logWithLevel("error", message, errorOrContext, context);
342
- }
343
- fatal(message, errorOrContext, context) {
344
- this.logWithLevel("fatal", message, errorOrContext, context);
345
- }
346
- /**
347
- * Log processing (internal)
348
- */
349
- log(level, message, error, context) {
350
- if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[this.config.level]) {
351
- return;
352
- }
353
- const metadata = {
354
- timestamp: /* @__PURE__ */ new Date(),
355
- level,
356
- message,
357
- module: this.module,
358
- error,
359
- // Mask sensitive information in context to prevent credential leaks
360
- context: context ? maskSensitiveData(context) : void 0
361
- };
362
- this.processTransports(metadata);
363
- }
364
- /**
365
- * Process Transports
366
- */
367
- processTransports(metadata) {
368
- const promises = this.config.transports.filter((transport) => transport.enabled).map((transport) => this.safeTransportLog(transport, metadata));
369
- Promise.all(promises).catch((error) => {
370
- const errorMessage = error instanceof Error ? error.message : String(error);
371
- process.stderr.write(`[Logger] Transport error: ${errorMessage}
372
- `);
373
- });
374
- }
375
- /**
376
- * Transport log (error-safe)
377
- */
378
- async safeTransportLog(transport, metadata) {
379
- try {
380
- await transport.log(metadata);
381
- } catch (error) {
382
- const errorMessage = error instanceof Error ? error.message : String(error);
383
- process.stderr.write(`[Logger] Transport "${transport.name}" failed: ${errorMessage}
384
- `);
385
- }
386
- }
387
- /**
388
- * Close all Transports
389
- */
390
- async close() {
391
- const closePromises = this.config.transports.filter((transport) => transport.close).map((transport) => transport.close());
392
- await Promise.all(closePromises);
393
- }
394
- };
395
-
396
- // src/logger/transports/console.ts
397
- var ConsoleTransport = class {
398
- name = "console";
399
- level;
400
- enabled;
401
- colorize;
402
- constructor(config) {
403
- this.level = config.level;
404
- this.enabled = config.enabled;
405
- this.colorize = config.colorize ?? true;
406
- }
407
- async log(metadata) {
408
- if (!this.enabled) {
409
- return;
410
- }
411
- if (LOG_LEVEL_PRIORITY[metadata.level] < LOG_LEVEL_PRIORITY[this.level]) {
412
- return;
413
- }
414
- const message = formatConsole(metadata, this.colorize);
415
- if (metadata.level === "warn" || metadata.level === "error" || metadata.level === "fatal") {
416
- console.error(message);
417
- } else {
418
- console.log(message);
419
- }
420
- }
421
- };
422
-
423
- // src/logger/config.ts
424
- function getConsoleConfig() {
425
- const isProduction = process.env.NODE_ENV === "production";
426
- return {
427
- level: "debug",
428
- enabled: true,
429
- colorize: !isProduction
430
- // Dev: colored output, Production: plain text
431
- };
432
- }
433
- function validateEnvironment() {
434
- const nodeEnv = process.env.NODE_ENV;
435
- if (!nodeEnv) {
436
- process.stderr.write(
437
- "[Logger] Warning: NODE_ENV is not set. Defaulting to test environment.\n"
438
- );
439
- }
440
- }
441
- function validateConfig() {
442
- validateEnvironment();
443
- }
444
-
445
- // src/logger/factory.ts
446
- function initializeTransports() {
447
- const transports = [];
448
- const consoleConfig = getConsoleConfig();
449
- transports.push(new ConsoleTransport(consoleConfig));
450
- return transports;
451
- }
452
- function getLogLevel() {
453
- const envLevel = process.env.SPFN_LOG_LEVEL || process.env.NEXT_PUBLIC_SPFN_LOG_LEVEL || "info";
454
- if (envLevel in LOG_LEVEL_PRIORITY) {
455
- return envLevel;
456
- }
457
- process.stderr.write(
458
- `[Logger] Invalid log level "${envLevel}", defaulting to "info"
459
- `
460
- );
461
- return "info";
462
- }
463
- function initializeLogger() {
464
- validateConfig();
465
- return new Logger({
466
- level: getLogLevel(),
467
- transports: initializeTransports()
468
- });
469
- }
470
- var logger = initializeLogger();
471
-
472
- // src/errors/serializable-error.ts
473
93
  var RESERVED_RESPONSE_KEYS = /* @__PURE__ */ new Set(["__type", "message", "error"]);
474
94
  var AUTHORING_ENVIRONMENTS = /* @__PURE__ */ new Set(["local", "development", "test"]);
475
95
  function refuseReservedKey(className, key) {