@spfn/core 0.3.0-beta.3 → 0.3.0-beta.31

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 (104) hide show
  1. package/README.md +304 -12
  2. package/dist/app-config/index.d.ts +39 -1
  3. package/dist/app-config/index.js.map +1 -1
  4. package/dist/authz/index.js +47 -391
  5. package/dist/authz/index.js.map +1 -1
  6. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  7. package/dist/codegen/index.d.ts +157 -16
  8. package/dist/codegen/index.js +772 -166
  9. package/dist/codegen/index.js.map +1 -1
  10. package/dist/config/index.js +4 -4
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/contract/index.d.ts +39 -16
  13. package/dist/contract/index.js +147 -23
  14. package/dist/contract/index.js.map +1 -1
  15. package/dist/db/index.d.ts +230 -35
  16. package/dist/db/index.js +316 -160
  17. package/dist/db/index.js.map +1 -1
  18. package/dist/env/index.d.ts +6 -442
  19. package/dist/env/index.js +13 -16
  20. package/dist/env/index.js.map +1 -1
  21. package/dist/env/loader.js +24 -1
  22. package/dist/env/loader.js.map +1 -1
  23. package/dist/error-CIZlYJlL.d.ts +157 -0
  24. package/dist/errors/index.d.ts +18 -1
  25. package/dist/errors/index.js +47 -391
  26. package/dist/errors/index.js.map +1 -1
  27. package/dist/event/index.d.ts +5 -5
  28. package/dist/event/index.js +202 -3
  29. package/dist/event/index.js.map +1 -1
  30. package/dist/event/sse/client.d.ts +18 -4
  31. package/dist/event/sse/client.js +94 -18
  32. package/dist/event/sse/client.js.map +1 -1
  33. package/dist/event/sse/index.d.ts +18 -5
  34. package/dist/event/sse/index.js +452 -114
  35. package/dist/event/sse/index.js.map +1 -1
  36. package/dist/event/ws/client.d.ts +3 -2
  37. package/dist/event/ws/index.d.ts +4 -3
  38. package/dist/job/index.d.ts +48 -3
  39. package/dist/job/index.js +131 -52
  40. package/dist/job/index.js.map +1 -1
  41. package/dist/logger/index.js +0 -12
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.js +52 -397
  44. package/dist/middleware/index.js.map +1 -1
  45. package/dist/nextjs/index.d.ts +75 -14
  46. package/dist/nextjs/index.js +200 -84
  47. package/dist/nextjs/index.js.map +1 -1
  48. package/dist/nextjs/server.d.ts +46 -8
  49. package/dist/nextjs/server.js +15 -1
  50. package/dist/nextjs/server.js.map +1 -1
  51. package/dist/ops/index.d.ts +61 -42
  52. package/dist/ops/index.js +648 -97
  53. package/dist/ops/index.js.map +1 -1
  54. package/dist/registry-C2KQCh0m.d.ts +473 -0
  55. package/dist/route/index.d.ts +5 -2
  56. package/dist/route/index.js +107 -28
  57. package/dist/route/index.js.map +1 -1
  58. package/dist/route/types.d.ts +7 -1
  59. package/dist/{router-Cy7rAfmj.d.ts → route-builder-DRW-MSCJ.d.ts} +51 -126
  60. package/dist/router-C57y-vz1.d.ts +184 -0
  61. package/dist/router-CX5uVjmU.d.ts +406 -0
  62. package/dist/server/index.d.ts +130 -7
  63. package/dist/server/index.js +1174 -164
  64. package/dist/server/index.js.map +1 -1
  65. package/dist/state/client.d.ts +206 -0
  66. package/dist/state/client.js +678 -0
  67. package/dist/state/client.js.map +1 -0
  68. package/dist/state/index.d.ts +443 -0
  69. package/dist/state/index.js +644 -0
  70. package/dist/state/index.js.map +1 -0
  71. package/dist/state/react.d.ts +49 -0
  72. package/dist/state/react.js +50 -0
  73. package/dist/state/react.js.map +1 -0
  74. package/dist/surface/index.d.ts +115 -0
  75. package/dist/surface/index.js +1013 -0
  76. package/dist/surface/index.js.map +1 -0
  77. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  78. package/dist/{types-CfYVhIQ9.d.ts → types-5Rl11_Nb.d.ts} +43 -4
  79. package/dist/types-CKH0D3Dy.d.ts +441 -0
  80. package/dist/{types-ZQODsBft.d.ts → types-CurMab2Q.d.ts} +52 -2
  81. package/dist/{types-2AbaW4Ie.d.ts → types-DncqXfWN.d.ts} +4 -3
  82. package/docs/file-upload.md +195 -333
  83. package/package.json +41 -11
  84. package/src/cache/README.md +330 -0
  85. package/src/codegen/README.md +721 -0
  86. package/src/config/README.md +333 -0
  87. package/src/contract/README.md +442 -0
  88. package/src/db/README.md +589 -0
  89. package/src/db/manager/README.md +500 -0
  90. package/src/db/schema/README.md +354 -0
  91. package/src/db/transaction/README.md +845 -0
  92. package/src/env/README.md +787 -0
  93. package/src/errors/README.md +445 -0
  94. package/src/event/README.md +994 -0
  95. package/src/job/README.md +613 -0
  96. package/src/logger/README.md +321 -0
  97. package/src/middleware/README.md +634 -0
  98. package/src/nextjs/README.md +756 -0
  99. package/src/route/README.md +763 -0
  100. package/src/security/README.md +100 -0
  101. package/src/server/README.md +704 -0
  102. package/src/state/README.md +806 -0
  103. package/dist/token-manager-BT5EnUAR.d.ts +0 -278
  104. package/dist/types-9oszaJqp.d.ts +0 -250
package/README.md CHANGED
@@ -43,10 +43,12 @@ Next.js owns the frontend, Drizzle owns the SQL, and your services own the rules
43
43
 
44
44
  ```bash
45
45
  pnpm add @spfn/core@beta drizzle-orm@1.0.0-rc.4 postgres pg
46
- # optional peer: next ^16.2.11 (only for the Next.js bridge)
46
+ # optional peer: next ^16.4.1 (only for the Next.js bridge)
47
+ # optional peer: react ^18 || ^19 (only for @spfn/core/state/react)
47
48
  ```
48
49
 
49
- Node `>=20.0.0`. ESM only.
50
+ Node `^22.23.4 || ^24.22.1 || >=26.12.1` — earlier releases lack the fix for
51
+ CVE-2026-111497, and Node 20 reached end of life without one. ESM only.
50
52
 
51
53
  > **Declare Drizzle and the Postgres drivers in your own app, not just in SPFN.**
52
54
  > `@spfn/core` takes `drizzle-orm` as a peer dependency, and Drizzle changes how it
@@ -122,8 +124,9 @@ the route map the RPC proxy needs — and nothing else.
122
124
  5. **Mount the RPC proxy** in Next.js as the `app/api/rpc/[routeName]/route.ts`
123
125
  catch-all. The browser only ever sends `GET` (no body) or `POST` (body or formData)
124
126
  to `/api/rpc/{routeName}`; the proxy looks up `routeMap[routeName]`, substitutes
125
- `:params`, and forwards with the real method. Package route maps (`authRouteMap`,
126
- `eventRouteMap`) merge into the same map.
127
+ `:params`, and forwards with the real method. The generated map already carries the
128
+ routes of the packages the app router mounts with `.packages()`; `eventRouteMap` is
129
+ merged by hand, being a constant rather than a mounted router.
127
130
  6. **Call it** through `createApi<AppRouter>()`. The client is a `Proxy` over the
128
131
  `AppRouter` type — typed in and out, with no runtime cost for the types. Errors
129
132
  arrive as `ApiError`, or as the original error class when that class is registered in
@@ -185,18 +188,144 @@ import type { AppRouter } from '@/server/router';
185
188
 
186
189
  export const api = createApi<AppRouter>();
187
190
 
188
- // anywhere — server component, client component, or server action:
191
+ // the same client in a Server Component, a Client Component or a Server Action:
189
192
  const user = await api.getUser.call({ params: { id: '123' } }); // typed { id, name }
190
193
  const made = await api.createUser.call({ body: { name: 'A' } });
191
194
  ```
192
195
 
196
+ That the client is isomorphic does not make the three callers interchangeable. **A page's
197
+ initial data is awaited in the Server Component**, where
198
+ `api.getUser.fetchOptions({ next: { revalidate, tags } }).call(…)` participates in
199
+ Next.js caching and reaches the backend without a browser round trip. Fetching that same
200
+ first paint from a `'use client'` component inside a `useEffect` is the anti-pattern: it
201
+ ships a loading state and a second network hop for data the server already had. Client
202
+ Components and Server Actions are for what happens after the first paint — interaction
203
+ and mutation. Cache tags, revalidation and SSR cookie forwarding are in
204
+ [the Next.js bridge docs](https://superfunction.xyz/docs/packages/core/nextjs).
205
+
206
+ ---
207
+
208
+ ## How does a repository talk to the database?
209
+
210
+ Through `BaseRepository`. Extending it gives a repository two transaction-aware
211
+ connections — `this.db` (write/primary) and `this.readDb` (the replica, when one is
212
+ configured) — plus the CRUD set as protected methods.
213
+
214
+ ```typescript
215
+ // server/repositories/order.ts
216
+ import { BaseRepository } from '@spfn/core/db';
217
+ import { desc } from 'drizzle-orm';
218
+ import { orders } from '../entities/order';
219
+
220
+ export class OrderRepository extends BaseRepository
221
+ {
222
+ findRecentFor(userId: string)
223
+ {
224
+ return this._findMany(orders, { where: { userId }, orderBy: desc(orders.createdAt) });
225
+ }
226
+
227
+ place(data: { userId: string; total: number })
228
+ {
229
+ return this._create(orders, data);
230
+ }
231
+ }
232
+
233
+ export const orderRepo = new OrderRepository();
234
+ ```
235
+
236
+ Handlers never import drizzle query builders; repositories do. `_findMany` reads through
237
+ `this.readDb`, `_create` writes through `this.db`, and both getters resolve to the active
238
+ transaction's connection when there is one — so the same method is correct inside a
239
+ transaction and outside it. When a helper cannot express a query, drop to
240
+ `this.readDb.select()…` inside the repository rather than in the handler. The full
241
+ protected CRUD set is in [src/db](./src/db/README.md).
242
+
243
+ ---
244
+
245
+ ## Where do transactions and their side effects go?
246
+
247
+ `Transactional()` covers the route case — commit on return, rollback on throw. Two rules
248
+ decide the rest.
249
+
250
+ **Nothing takes a `tx` parameter.** The transaction travels in AsyncLocalStorage, so
251
+ `this.db` inside a repository already resolves to it. A service that accepts `tx` and
252
+ threads it downward re-implements propagation that already happened, and the first caller
253
+ that forgets to pass it writes outside the transaction. For a service, script or job with
254
+ no route around it, open one with `runInTransaction(fn, options?)`;
255
+ `runWithTransaction(tx, txId, fn)` is the lower-level primitive that binds an existing
256
+ Drizzle transaction into the context.
257
+
258
+ **Side effects go on the commit hooks, not inline.** An event emitted or a mail sent from
259
+ inside the transaction still went out when the transaction later rolls back.
260
+
261
+ | Hook | When it runs | What it is for |
262
+ |---|---|---|
263
+ | `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 |
264
+ | `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 |
265
+ | `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 |
266
+
267
+ All three import from `@spfn/core/db`, can be registered anywhere inside the transaction,
268
+ and bubble to the **root** transaction — a nested block's callbacks fire on the outermost
269
+ outcome, not on a savepoint's.
270
+
271
+ ```typescript
272
+ import { runInTransaction, onAfterCommit } from '@spfn/core/db';
273
+
274
+ export async function placeOrder(input: { userId: string; total: number })
275
+ {
276
+ return runInTransaction(async () =>
277
+ {
278
+ const order = await orderRepo.place(input); // no tx argument, at any depth
279
+ onAfterCommit(() => orderPlacedEvent.emit({ orderId: order.id }));
280
+
281
+ return order;
282
+ });
283
+ }
284
+ ```
285
+
286
+ ---
287
+
288
+ ## What else can defineServerConfig configure?
289
+
290
+ `.port()` and `.routes()` are the two every app calls. The rest of the builder is how an
291
+ app wires its infrastructure without touching the server's boot sequence. Every method
292
+ returns the builder; `.build()` ends the chain.
293
+
294
+ | Method | What it configures |
295
+ |---|---|
296
+ | `.port(n)` / `.host(s)` | Where the server listens |
297
+ | `.routes(router)` | The `defineRouter` router to mount, with its own `.use()` and `.packages()` |
298
+ | `.jobs(router, config?)` | Background jobs — a `defineJobRouter`, plus pg-boss options |
299
+ | `.events(router, config?)` | SSE streaming — a `defineEventRouter`, served at `GET /events/stream` |
300
+ | `.websockets(router, config?)` | Bidirectional WebSockets — a `defineWSRouter`, served at `WS /ws` |
301
+ | `.workflows(router, config?)` | `@spfn/workflow` orchestration; the engine starts once the database is ready |
302
+ | `.lifecycle(hooks)` | Boot and shutdown hooks. Callable more than once; hooks run in registration order |
303
+ | `.migrations(opts)` | The migration boot gate — `{ allowPending: true }` lets a server start behind its migrations |
304
+ | `.database(opts)` | Connection and pool settings |
305
+ | `.infrastructure(opts)` | Which infrastructure is initialized at boot |
306
+ | `.healthCheck(opts)` | The health endpoint |
307
+ | `.serverTime(clock)` | The clock behind `GET /_core/time`; normally left at the default |
308
+ | `.middleware(opts)` | The built-in middleware — `ErrorHandler`, `RequestLogger` |
309
+ | `.use(handlers)` | Additional global Hono middleware |
310
+ | `.middlewares(named)` | Global middleware under names, so a route can `.skip([...])` it |
311
+ | `.cors(opts)` | CORS |
312
+ | `.rateLimit(opts)` | A global default limiter plus the named policies routes resolve against |
313
+ | `.proxyGuard(opts)` | Trusted-proxy signature and origin verification, resolved to a `clientType` |
314
+ | `.outboundFetch(opts)` | The SSRF policy `safeFetch` applies to outbound calls |
315
+ | `.timeout(opts)` / `.shutdown(opts)` | Request timeouts and graceful shutdown |
316
+ | `.debug(bool)` | Debug logging |
317
+
318
+ The options each one takes are in [src/server](./src/server/README.md).
319
+
193
320
  ---
194
321
 
195
322
  ## Which import path do I use for what?
196
323
 
197
324
  There is **no root barrel**: `import … from '@spfn/core'` does not resolve. Every symbol
198
325
  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.
326
+ entry in `package.json` `exports`, with a single exclusion: `./client` is still listed in
327
+ `exports` but the build no longer emits it, so it has no row (see [Pitfalls](#pitfalls)).
328
+ Each module has its own README with the API detail.
200
329
 
201
330
  | Import path | Purpose | Doc |
202
331
  |-------------|---------|-----|
@@ -207,16 +336,17 @@ entry in `package.json` `exports`. Each module has its own README with the API d
207
336
  | `@spfn/core/nextjs/server` | Server-only: `createRpcProxy({ routeMap })`, `registerInterceptors`. Uses `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
208
337
  | `@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
338
  | `@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) |
339
+ | `@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
340
  | `@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
341
  | `@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
342
  | `@spfn/core/middleware` | Built-in Hono middleware: `ErrorHandler`, `RequestLogger` and its masking helper. | [src/middleware](./src/middleware/README.md) |
214
343
  | `@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
344
  | `@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) |
345
+ | `@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
346
  | `@spfn/core/env` | Schema-based environment validation, isomorphic. | [src/env](./src/env/README.md) |
218
347
  | `@spfn/core/env/loader` | The **server-only** `.env` file loader (uses `node:fs`). | [src/env](./src/env/README.md) |
219
348
  | `@spfn/core/config` | `@spfn/core`'s own validated env config (`env`, `envSchema`, `registry`), built on `@spfn/core/env`. | [src/config](./src/config/README.md) |
349
+ | `@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`), and where the app's env schema modules are (`env.schemas`, read by `spfn env` / `spfn secret`). 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
350
  | `@spfn/core/logger` | Structured singleton `logger` with child loggers and level masking. No dependencies. | [src/logger](./src/logger/README.md) |
221
351
  | `@spfn/core/cache` | Valkey/Redis singleton over ioredis (`getCache`, `getCacheRead`). Degrades to disabled rather than throwing. | [src/cache](./src/cache/README.md) |
222
352
  | `@spfn/core/job` | Background jobs on pg-boss: a fluent `job()` builder, cron, run-once, event-driven, `defineJobRouter`. | [src/job](./src/job/README.md) |
@@ -225,8 +355,13 @@ entry in `package.json` `exports`. Each module has its own README with the API d
225
355
  | `@spfn/core/event/sse/client` | Browser SSE client (`EventSource`). | [src/event](./src/event/README.md) |
226
356
  | `@spfn/core/event/ws` | Server-side WebSocket handler. Server only; needs the optional `ws` dependency. | [src/event](./src/event/README.md) |
227
357
  | `@spfn/core/event/ws/client` | Browser WebSocket client. | [src/event](./src/event/README.md) |
358
+ | `@spfn/core/state` | A server-owned state declared once (`defineState`, `defineStateRouter`) derives its read, write and snapshot routes and its SSE event; the app emits `state.changed` after a commit. Server only. | [src/state](./src/state/README.md) |
359
+ | `@spfn/core/state/client` | The browser side of a state router, typed from `typeof states` (type-only import) and told the rest by the snapshot's manifest: `createStateClient` reads, writes and snapshots through the derived routes, keeps one SSE stream for every state, and orders snapshots against frames by `at`. Framework-neutral, no React. | [src/state](./src/state/README.md#browser) |
360
+ | `@spfn/core/state/react` | `useServerState(client, id, key?)` — a component's view of one state through `useSyncExternalStore`, with `write` on a state that has one. Needs the optional `react` peer. | [src/state](./src/state/README.md#browser) |
228
361
  | `@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
362
  | `@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) |
363
+ | `@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) |
364
+ | `@spfn/core/surface` | `defineSurface`: a command surface on any namespace — the same authenticated router, manifest and modules `@spfn/core/ops` is on `/_ops`, for a caller other than an operator. | [Can a second surface sit beside `/_ops`?](#can-a-second-surface-sit-beside-_ops) |
230
365
 
231
366
  `db/manager`, `db/schema` and `db/transaction` are **not** package subpaths of their own.
232
367
  They are internal modules re-exported by `@spfn/core/db` — import their symbols from
@@ -359,6 +494,39 @@ probe depends on, and a probe reaches it unauthenticated. An app with `src/serve
359
494
 
360
495
  ---
361
496
 
497
+ ## How does a client synchronize with the server clock?
498
+
499
+ Call `GET /_core/time` before minting a timestamped proof. It is a built-in,
500
+ unauthenticated and session-free operation registered before application middleware and
501
+ routes:
502
+
503
+ ```json
504
+ { "serverTimeMillis": 1750000000123 }
505
+ ```
506
+
507
+ The response is a closed contract: `serverTimeMillis` is an integer Unix epoch in
508
+ milliseconds and no additional fields are declared. It always carries
509
+ `Cache-Control: no-store`; a cached clock reading is not a synchronization point.
510
+
511
+ The value is trustworthy only when the transport is trustworthy. Production clients must
512
+ call the endpoint over HTTPS and validate the server certificate. The endpoint does not
513
+ sign its response, compensate for network latency, choose an authentication skew margin,
514
+ or define client retry and persistence policy — those belong to the consuming protocol.
515
+
516
+ Tests can inject an exact clock without replacing global time:
517
+
518
+ ```typescript
519
+ const app = await createServer(defineServerConfig()
520
+ .serverTime({ clock: { now: () => 1750000000123 } })
521
+ .build());
522
+ ```
523
+
524
+ `CORE_TIME_ROUTE`, `CORE_TIME_PATH`, `ServerTimeResponseSchema` and the
525
+ `ServerTimeResponse` type are exported from `@spfn/core/server` for separately deployed
526
+ client-contract exporters to consume from the same wire definition.
527
+
528
+ ---
529
+
362
530
  ## Can I deploy this to Vercel?
363
531
 
364
532
  Yes, and it is a first-class target rather than a workaround. From your app:
@@ -483,6 +651,61 @@ spfn ops call listSignups --query limit=50 # invoke one
483
651
  spfn ops call listSignups --describe # print its usage (--json for raw schemas)
484
652
  ```
485
653
 
654
+ ### Can a package ship ops commands?
655
+
656
+ It can describe them. Whether they are reachable is your application's decision, made in
657
+ the `createOpsRouter` call — installing a package never adds anything to your ops surface.
658
+ Available from **0.3.0-beta.5**.
659
+
660
+ ```typescript
661
+ // in the package
662
+ export const ledgerOpsModule = defineOpsModule({
663
+ id: 'ledger',
664
+ source: '@acme/ledger',
665
+ contractVersion: '1.0.0',
666
+ summary: 'Ledger diagnostics',
667
+ commands: {
668
+ verify: {
669
+ summary: 'Verify ledger invariants',
670
+ effect: 'read', // read | write | destructive
671
+ scopes: ['ledger:read'],
672
+ route: opsRoute.get('/ledger/verify').handler(verifyLedger),
673
+ },
674
+ },
675
+ });
676
+
677
+ // in the application — nothing is mounted until this line names it
678
+ export const opsRouter = createOpsRouter({ listSignups }, {
679
+ auth: opsTokenAuth,
680
+ authorize: requireOpsScope,
681
+ modules: [ledgerOpsModule],
682
+ });
683
+ ```
684
+
685
+ A module command is named `<module>.<command>` (`ledger.verify`) and its route must live
686
+ under `/_ops/<module>/`. The scopes it declares become a server-side guard, run after
687
+ authentication — `authorize` is required as soon as any module is mounted, and it is passed
688
+ in rather than imported so core stays independent of `@spfn/auth`.
689
+
690
+ What is refused at definition time: a path that could decode its way out of the module's
691
+ namespace, two commands in one module that could answer the same URL, and an app route that
692
+ overlaps a mounted module's command. That last one matters because the alternative is a
693
+ surface where which command answers depends on route registration order.
694
+
695
+ The manifest gains a `modules` array and per-command `module`, `summary`, `effect` and
696
+ `scopes`. All of it is additive — an app that mounts no modules serves exactly the v1
697
+ manifest it served before.
698
+
699
+ An app's own ops routes may declare `.effect()` and `.summary()` too, and the manifest
700
+ carries both for that command. On `/_ops` they are optional: a route declaring neither is
701
+ announced exactly as before.
702
+
703
+ ```bash
704
+ spfn ops modules # what is mounted, and from where
705
+ spfn ops list --module ledger # just that module's commands
706
+ spfn ops call ledger.compact --yes # effect=destructive needs this
707
+ ```
708
+
486
709
  Authentication is an ops token from [`@spfn/auth`](../auth/README.md#ops-tokens-spfn-ops):
487
710
  scoped, revocable, hash-stored, issued with `spfn ops token issue` against the running app
488
711
  — the CLI signs in as an administrator, so issuance needs no database access. On macOS the
@@ -490,6 +713,74 @@ CLI keeps the token in the keychain (`spfn ops token store`), and resolution ord
490
713
  `--token` → `SPFN_OPS_TOKEN` → keychain. The `--app` URL must be https, since every command
491
714
  carries a secret; `http` is accepted only against a loopback host.
492
715
 
716
+ ### Can a second surface sit beside `/_ops`?
717
+
718
+ Yes. `/_ops` is one command surface; `defineSurface` from `@spfn/core/surface` builds
719
+ another on a namespace of your choosing, for a caller that is not an operator — a service
720
+ holding a token some issuer signed, say. It is the same structure: a `route` with the
721
+ namespace applied, auth injected into every command, a manifest at `<namespace>/_manifest`
722
+ registered first, and modules mounted only when you name them. `createOpsRouter` is
723
+ `defineSurface({ namespace: '/_ops', ... }).router(...)`.
724
+
725
+ ```typescript
726
+ // src/server/app-surface.ts
727
+ import { Type } from '@sinclair/typebox';
728
+ import { defineSurface } from '@spfn/core/surface';
729
+ import { signedTokenAuth } from '@spfn/auth/server';
730
+
731
+ export const app = defineSurface({
732
+ namespace: '/_app', // required
733
+ auth: signedTokenAuth({ // required
734
+ keysUrl: 'https://issuer.example/.well-known/keys',
735
+ iss: 'https://issuer.example',
736
+ aud: 'my-app',
737
+ }),
738
+ manifest: 'public', // 'authenticated' (default) | 'public'
739
+ });
740
+
741
+ export const pageAdd = app.route.post('/pages') // POST /_app/pages
742
+ .input({ body: Type.Object({ title: Type.String() }) })
743
+ .effect('write') // required: read | write | destructive
744
+ .summary('Add a page to the blueprint') // optional, at most 500 characters
745
+ .handler(async (c) => pagesRepository.add((await c.data()).body.title));
746
+
747
+ // src/server/router.ts — both surfaces, side by side
748
+ export const appRouter = defineRouter({ ... }).packages([app.router({ pageAdd }), opsRouter]);
749
+ ```
750
+
751
+ - **Namespace.** An absolute path of `[A-Za-z0-9_-]` segments with no trailing slash —
752
+ `/_app`, `/machine/v1`. Anything else (`app`, `/_app/`, `/`, a `%`, a `:param`) is refused
753
+ at `defineSurface`.
754
+ - **Manifest.** Served at `<namespace>/_manifest` under the reserved route name
755
+ `get<Namespace>Manifest` — `getAppManifest` for `/_app`, `getOpsManifest` for `/_ops`,
756
+ `getappManifest` for `/app`. The namespace is encoded one-to-one (`_x` → `X`; any other
757
+ `_`, `-`, `/` or uppercase letter is escaped with `_`), so two surfaces never register one
758
+ name. Two surfaces on one namespace are refused by `.packages()`. `manifest: 'public'`
759
+ drops `auth` from that one route and skips a server-level `auth` middleware on it, so a
760
+ caller can discover commands before it holds a token; every command stays behind `auth`
761
+ regardless.
762
+ - **`authorize`.** Optional, `(command) => middleware | undefined`, called once per command
763
+ (module commands included) when the router is built. The middleware runs after `auth` and
764
+ before the command's own: `[auth, authorize?, ...group, ...own]`. Required as soon as a
765
+ module is mounted. A guard named like `auth` or like one of the command's group or own
766
+ middlewares is refused: registration keeps one middleware per name, so the other one would
767
+ never run.
768
+ - **Effect.** Every app command on a `defineSurface` surface declares `.effect()` on its
769
+ route; `router()` refuses one that does not, naming the command, so a client never
770
+ receives a command whose effect it cannot know. `/_ops` routes may declare it and need
771
+ not. The manifest announces `effect` and `summary` beside the command, and `authorize`
772
+ receives them on the command it is given. A module command's own declaration wins over its
773
+ route's: a route `.effect()` that disagrees with it is refused.
774
+ - **Modules.** A module is validated against the namespace of the surface that mounts it —
775
+ its commands must live under `<namespace>/<id>/`. Write one for a surface with
776
+ `defineSurfaceModule(module, '/_app')`; a module written for `/_ops` mounted on `/_app` is
777
+ refused, and the message names the namespace.
778
+ - **Errors.** Every refusal is a `SurfaceError`. `@spfn/core/ops` exports the same class as
779
+ `OpsRouterError`, so `instanceof` holds under either name.
780
+
781
+ The client for any surface is the CLI's library export,
782
+ [`spfn/surface`](../cli/README.md#can-i-call-a-surface-from-my-own-code).
783
+
493
784
  ---
494
785
 
495
786
  ## How is this different from NestJS or tRPC?
@@ -565,7 +856,7 @@ There is no `.` entry in `exports` — by design. Subpaths keep server-only code
565
856
  client bundles, which a single barrel file cannot do.
566
857
 
567
858
  **Which Node version?**
568
- `>=20.0.0`. The package is ESM only.
859
+ `^22.23.4 || ^24.22.1 || >=26.12.1`. The package is ESM only.
569
860
 
570
861
  **Where do my business rules go?**
571
862
  In services and repositories, not in route handlers. A handler validates, calls, and
@@ -579,9 +870,10 @@ way so the endpoint never reveals that someone else's record exists.
579
870
 
580
871
  Other SPFN packages build on `@spfn/core`:
581
872
 
582
- - [`@spfn/auth`](../auth/README.md) — sessions, social login and RBAC. Exports
583
- `authRouteMap` and registers proxy interceptors automatically; merge its route map into
584
- `createRpcProxy`.
873
+ - [`@spfn/auth`](../auth/README.md) — sessions, social login and RBAC. Mount `authRouter`
874
+ with `.packages()` and its routes are in the app's generated map; the package registers
875
+ proxy interceptors automatically. (`authRouteMap` is still exported, and merging it is
876
+ now a no-op.)
585
877
  - [`@spfn/mcp`](../mcp/README.md) — exposes your operations as MCP tools, so an agent can
586
878
  run them instead of you building an admin dashboard.
587
879
  - `@spfn/i18n`, `@spfn/storage`, `@spfn/notification`, `@spfn/cms`, `@spfn/monitor`,
@@ -28,10 +28,48 @@ interface SpfnPorts {
28
28
  /** SPFN API server. */
29
29
  server?: number;
30
30
  }
31
+ /**
32
+ * Where the app's own environment schema lives.
33
+ *
34
+ * Read by the CLI (`spfn env`, `spfn secret`), which cannot otherwise find an
35
+ * app's variables: a package's schema sits at `<package>/config`, an app's
36
+ * sits wherever the app put it.
37
+ */
38
+ interface SpfnEnvConfig {
39
+ /**
40
+ * Modules that export `envSchema`, as paths relative to the project root —
41
+ * e.g. `['src/server/config/env.config.ts']`. TypeScript and the
42
+ * tsconfig path aliases it imports through are supported.
43
+ */
44
+ schemas?: string[];
45
+ /** What `spfn env audit` scans. */
46
+ audit?: SpfnEnvAuditConfig;
47
+ }
48
+ /**
49
+ * The files `spfn env audit` reads, as globs relative to the project root.
50
+ *
51
+ * `node_modules`, build output, `*.d.ts` files and symbolic links are never
52
+ * scanned, whatever the globs say.
53
+ */
54
+ interface SpfnEnvAuditConfig {
55
+ /**
56
+ * Directories or globs to scan — e.g. `['src', 'scripts/*.ts']`.
57
+ *
58
+ * @default ['src']
59
+ */
60
+ include?: string[];
61
+ /**
62
+ * Directories or globs to leave out — e.g. `['src/legacy/**']`. A file left
63
+ * out may read `process.env` directly, and names it reads do not count as read.
64
+ */
65
+ ignore?: string[];
66
+ }
31
67
  interface SpfnAppConfig {
32
68
  ports?: SpfnPorts;
33
69
  /** Host the SPFN API server binds. */
34
70
  host?: string;
71
+ /** The app's environment schema modules, and what `spfn env audit` scans. */
72
+ env?: SpfnEnvConfig;
35
73
  /** Deployment settings — untouched by this module, kept so the type is whole. */
36
74
  [key: string]: unknown;
37
75
  }
@@ -93,4 +131,4 @@ declare function resolveServerAddress(config: SpfnAppConfig, deprecated?: {
93
131
  host: string;
94
132
  };
95
133
 
96
- export { HOST_DEFAULT, PORT_DEFAULTS, type SpfnAppConfig, type SpfnPorts, loadAppConfig, resolveHost, resolvePorts, resolveServerAddress };
134
+ export { HOST_DEFAULT, PORT_DEFAULTS, type SpfnAppConfig, type SpfnEnvAuditConfig, type SpfnEnvConfig, type SpfnPorts, loadAppConfig, resolveHost, resolvePorts, resolveServerAddress };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/app-config/index.ts"],"names":[],"mappings":";;;;;AA2DO,IAAM,aAAA,GAAgB;AAAA,EACzB,IAAA,EAAM,IAAA;AAAA,EACN,MAAA,EAAQ;AACZ;AAEO,IAAM,YAAA,GAAe;AAE5B,IAAM,iBAAA,GAAoB;AAAA,EACtB,gBAAA;AAAA,EACA;AACJ,CAAA;AASA,eAAsB,aAAA,CAAc,GAAA,GAAc,OAAA,CAAQ,GAAA,EAAI,EAC9D;AACI,EAAA,KAAA,MAAW,YAAY,iBAAA,EACvB;AACI,IAAA,MAAM,QAAA,GAAW,IAAA,CAAK,GAAA,EAAK,QAAQ,CAAA;AAEnC,IAAA,IAAI,CAAC,UAAA,CAAW,QAAQ,CAAA,EACxB;AACI,MAAA;AAAA,IACJ;AAEA,IAAA,IACA;AAGI,MAAA,MAAM,MAAA,GAAS,MAAM,OAAO,aAAA,CAAc,QAAQ,CAAA,CAAE,IAAA,CAAA;AAEpD,MAAA,OAAQ,MAAA,CAAO,WAAW,EAAC;AAAA,IAC/B,SACO,KAAA,EACP;AAQI,MAAA,OAAA,CAAQ,IAAA;AAAA,QACJ,CAAA,cAAA,EAAO,QAAQ,CAAA,2EAAA,EACU,KAAA,YAAiB,QAAQ,KAAA,CAAM,OAAA,GAAU,MAAA,CAAO,KAAK,CAAC,CAAA;AAAA,OACnF;AAEA,MAAA,OAAO,EAAC;AAAA,IACZ;AAAA,EACJ;AAEA,EAAA,OAAO,EAAC;AACZ;AAQO,SAAS,YAAA,CACZ,MAAA,EACA,GAAA,GAAyB,OAAA,CAAQ,GAAA,EAErC;AACI,EAAA,OAAO;AAAA,IACH,IAAA,EAAM,SAAS,GAAA,CAAI,SAAS,KAAK,MAAA,CAAO,KAAA,EAAO,QAAQ,aAAA,CAAc,IAAA;AAAA,IACrE,MAAA,EAAQ,SAAS,GAAA,CAAI,SAAS,KAAK,MAAA,CAAO,KAAA,EAAO,UAAU,aAAA,CAAc;AAAA,GAC7E;AACJ;AASO,SAAS,WAAA,CACZ,MAAA,EACA,GAAA,GAAyB,OAAA,CAAQ,GAAA,EAErC;AACI,EAAA,OAAO,GAAA,CAAI,SAAA,IAAa,MAAA,CAAO,IAAA,IAAQ,YAAA;AAC3C;AAWO,SAAS,qBACZ,MAAA,EACA,UAAA,GAA+C,EAAC,EAChD,GAAA,GAAyB,QAAQ,GAAA,EAErC;AACI,EAAA,OAAO;AAAA,IACH,IAAA,EAAM,QAAA,CAAS,GAAA,CAAI,SAAS,CAAA,IACrB,OAAO,KAAA,EAAO,MAAA,IACd,UAAA,CAAW,IAAA,IACX,aAAA,CAAc,MAAA;AAAA,IACrB,MAAM,GAAA,CAAI,SAAA,IACH,MAAA,CAAO,IAAA,IACP,WAAW,IAAA,IACX;AAAA,GACX;AACJ;AAEA,SAAS,SAAS,KAAA,EAClB;AACI,EAAA,IAAI,CAAC,KAAA,EACL;AACI,IAAA,OAAO,MAAA;AAAA,EACX;AAEA,EAAA,MAAM,IAAA,GAAO,OAAO,KAAK,CAAA;AAEzB,EAAA,OAAO,MAAA,CAAO,UAAU,IAAI,CAAA,IAAK,OAAO,CAAA,IAAK,IAAA,GAAO,QAAQ,IAAA,GAAO,MAAA;AACvE","file":"index.js","sourcesContent":["/**\n * `spfn.config.js` — the app's serving shape, read by both the CLI and the server.\n *\n * The file has existed since `spfn init` started writing it, and until now\n * nothing read it back. It is the one committed place that describes how an app\n * is served, which makes it the right home for the ports: an app that wants a\n * different port says so once here instead of repeating the number across its\n * `server.config.ts`, Dockerfile, compose file, `next.config.ts` and env\n * example, where nothing checks that the copies still agree.\n *\n * This module is deliberately free of side effects. `@spfn/core/config`\n * validates the whole environment schema the moment it is imported, which is\n * fine inside a server and wrong inside a CLI that has not been handed an\n * app's environment yet.\n *\n * @module app-config\n */\n\nimport { existsSync } from 'fs';\nimport { pathToFileURL } from 'url';\nimport { join } from 'path';\n\n/**\n * Ports the app is served on.\n *\n * Two names because two processes are started. Next.js reads `PORT` as its own,\n * so the SPFN server cannot claim that name without the two colliding whenever\n * they run together.\n */\nexport interface SpfnPorts\n{\n /** Next.js frontend. Passed to `next` as `-p`. */\n next?: number;\n\n /** SPFN API server. */\n server?: number;\n}\n\nexport interface SpfnAppConfig\n{\n ports?: SpfnPorts;\n\n /** Host the SPFN API server binds. */\n host?: string;\n\n /** Deployment settings — untouched by this module, kept so the type is whole. */\n [key: string]: unknown;\n}\n\n/**\n * The single place each default exists.\n *\n * Defaults live at the bottom of the chain and nowhere else. A default declared\n * at an input layer — a commander option, a generated entry file, an env schema\n * — cannot be told apart from a value someone supplied, so it silently outranks\n * whatever is below it. Three separate defects in this repository came from\n * exactly that, including an env schema default that forced the environment to\n * be consulted last and made an injected port unreachable.\n */\nexport const PORT_DEFAULTS = {\n next: 3790,\n server: 8790,\n} as const;\n\nexport const HOST_DEFAULT = 'localhost';\n\nconst CONFIG_FILE_NAMES = [\n 'spfn.config.js',\n 'spfn.config.mjs',\n] as const;\n\n/**\n * Read `spfn.config.js` from an app directory.\n *\n * Returns an empty object when there is no file, and also when the file cannot\n * be imported — a malformed deployment config must not stop a server from\n * booting. The caller decides whether the absence is worth reporting.\n */\nexport async function loadAppConfig(cwd: string = process.cwd()): Promise<SpfnAppConfig>\n{\n for (const fileName of CONFIG_FILE_NAMES)\n {\n const fullPath = join(cwd, fileName);\n\n if (!existsSync(fullPath))\n {\n continue;\n }\n\n try\n {\n // A file URL, not a path: on Windows an absolute path is not a\n // valid ESM specifier, and this file is imported at runtime.\n const module = await import(pathToFileURL(fullPath).href);\n\n return (module.default ?? {}) as SpfnAppConfig;\n }\n catch (error)\n {\n // Falling back to defaults, and saying so. A file that exists and\n // cannot be imported is a typo, not an absence — silence would send\n // the server to port 8790 while its own config named another, and\n // the only symptom would be a port nobody chose.\n //\n // console, not the server logger: this runs inside the CLI too,\n // before any logger is configured.\n console.warn(\n `⚠️ ${fileName} could not be imported — falling back to defaults `\n + `for ports and host. ${error instanceof Error ? error.message : String(error)}`,\n );\n\n return {};\n }\n }\n\n return {};\n}\n\n/**\n * The port each process binds, given a loaded config and the environment.\n *\n * Three layers, in this order, and no more: environment variable, then\n * `spfn.config.js`, then the default.\n */\nexport function resolvePorts(\n config: SpfnAppConfig,\n env: NodeJS.ProcessEnv = process.env,\n): { next: number; server: number }\n{\n return {\n next: readPort(env.NEXT_PORT) ?? config.ports?.next ?? PORT_DEFAULTS.next,\n server: readPort(env.SPFN_PORT) ?? config.ports?.server ?? PORT_DEFAULTS.server,\n };\n}\n\n/**\n * The host the SPFN server binds, on the same three layers.\n *\n * `localhost` by default: a container states `SPFN_HOST=0.0.0.0` in its own\n * files, where that decision belongs, rather than every developer machine\n * publishing a dev server to its network.\n */\nexport function resolveHost(\n config: SpfnAppConfig,\n env: NodeJS.ProcessEnv = process.env,\n): string\n{\n return env.SPFN_HOST || config.host || HOST_DEFAULT;\n}\n\n/**\n * Where the SPFN server binds, including the address a server config may still\n * carry through `.port()` / `.host()`.\n *\n * Those two are deprecated and sit between `spfn.config.js` and the default for\n * one release, so an app that has not moved yet keeps the address it had. Once\n * they are removed this collapses back to {@link resolvePorts} and\n * {@link resolveHost}.\n */\nexport function resolveServerAddress(\n config: SpfnAppConfig,\n deprecated: { port?: number; host?: string } = {},\n env: NodeJS.ProcessEnv = process.env,\n): { port: number; host: string }\n{\n return {\n port: readPort(env.SPFN_PORT)\n ?? config.ports?.server\n ?? deprecated.port\n ?? PORT_DEFAULTS.server,\n host: env.SPFN_HOST\n || config.host\n || deprecated.host\n || HOST_DEFAULT,\n };\n}\n\nfunction readPort(value: string | undefined): number | undefined\n{\n if (!value)\n {\n return undefined;\n }\n\n const port = Number(value);\n\n return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;\n}\n"]}
1
+ {"version":3,"sources":["../../src/app-config/index.ts"],"names":[],"mappings":";;;;;AAwGO,IAAM,aAAA,GAAgB;AAAA,EACzB,IAAA,EAAM,IAAA;AAAA,EACN,MAAA,EAAQ;AACZ;AAEO,IAAM,YAAA,GAAe;AAE5B,IAAM,iBAAA,GAAoB;AAAA,EACtB,gBAAA;AAAA,EACA;AACJ,CAAA;AASA,eAAsB,aAAA,CAAc,GAAA,GAAc,OAAA,CAAQ,GAAA,EAAI,EAC9D;AACI,EAAA,KAAA,MAAW,YAAY,iBAAA,EACvB;AACI,IAAA,MAAM,QAAA,GAAW,IAAA,CAAK,GAAA,EAAK,QAAQ,CAAA;AAEnC,IAAA,IAAI,CAAC,UAAA,CAAW,QAAQ,CAAA,EACxB;AACI,MAAA;AAAA,IACJ;AAEA,IAAA,IACA;AAGI,MAAA,MAAM,MAAA,GAAS,MAAM,OAAO,aAAA,CAAc,QAAQ,CAAA,CAAE,IAAA,CAAA;AAEpD,MAAA,OAAQ,MAAA,CAAO,WAAW,EAAC;AAAA,IAC/B,SACO,KAAA,EACP;AAQI,MAAA,OAAA,CAAQ,IAAA;AAAA,QACJ,CAAA,cAAA,EAAO,QAAQ,CAAA,2EAAA,EACU,KAAA,YAAiB,QAAQ,KAAA,CAAM,OAAA,GAAU,MAAA,CAAO,KAAK,CAAC,CAAA;AAAA,OACnF;AAEA,MAAA,OAAO,EAAC;AAAA,IACZ;AAAA,EACJ;AAEA,EAAA,OAAO,EAAC;AACZ;AAQO,SAAS,YAAA,CACZ,MAAA,EACA,GAAA,GAAyB,OAAA,CAAQ,GAAA,EAErC;AACI,EAAA,OAAO;AAAA,IACH,IAAA,EAAM,SAAS,GAAA,CAAI,SAAS,KAAK,MAAA,CAAO,KAAA,EAAO,QAAQ,aAAA,CAAc,IAAA;AAAA,IACrE,MAAA,EAAQ,SAAS,GAAA,CAAI,SAAS,KAAK,MAAA,CAAO,KAAA,EAAO,UAAU,aAAA,CAAc;AAAA,GAC7E;AACJ;AASO,SAAS,WAAA,CACZ,MAAA,EACA,GAAA,GAAyB,OAAA,CAAQ,GAAA,EAErC;AACI,EAAA,OAAO,GAAA,CAAI,SAAA,IAAa,MAAA,CAAO,IAAA,IAAQ,YAAA;AAC3C;AAWO,SAAS,qBACZ,MAAA,EACA,UAAA,GAA+C,EAAC,EAChD,GAAA,GAAyB,QAAQ,GAAA,EAErC;AACI,EAAA,OAAO;AAAA,IACH,IAAA,EAAM,QAAA,CAAS,GAAA,CAAI,SAAS,CAAA,IACrB,OAAO,KAAA,EAAO,MAAA,IACd,UAAA,CAAW,IAAA,IACX,aAAA,CAAc,MAAA;AAAA,IACrB,MAAM,GAAA,CAAI,SAAA,IACH,MAAA,CAAO,IAAA,IACP,WAAW,IAAA,IACX;AAAA,GACX;AACJ;AAEA,SAAS,SAAS,KAAA,EAClB;AACI,EAAA,IAAI,CAAC,KAAA,EACL;AACI,IAAA,OAAO,MAAA;AAAA,EACX;AAEA,EAAA,MAAM,IAAA,GAAO,OAAO,KAAK,CAAA;AAEzB,EAAA,OAAO,MAAA,CAAO,UAAU,IAAI,CAAA,IAAK,OAAO,CAAA,IAAK,IAAA,GAAO,QAAQ,IAAA,GAAO,MAAA;AACvE","file":"index.js","sourcesContent":["/**\n * `spfn.config.js` — the app's serving shape, read by both the CLI and the server.\n *\n * The file has existed since `spfn init` started writing it, and until now\n * nothing read it back. It is the one committed place that describes how an app\n * is served, which makes it the right home for the ports: an app that wants a\n * different port says so once here instead of repeating the number across its\n * `server.config.ts`, Dockerfile, compose file, `next.config.ts` and env\n * example, where nothing checks that the copies still agree.\n *\n * This module is deliberately free of side effects. `@spfn/core/config`\n * validates the whole environment schema the moment it is imported, which is\n * fine inside a server and wrong inside a CLI that has not been handed an\n * app's environment yet.\n *\n * @module app-config\n */\n\nimport { existsSync } from 'fs';\nimport { pathToFileURL } from 'url';\nimport { join } from 'path';\n\n/**\n * Ports the app is served on.\n *\n * Two names because two processes are started. Next.js reads `PORT` as its own,\n * so the SPFN server cannot claim that name without the two colliding whenever\n * they run together.\n */\nexport interface SpfnPorts\n{\n /** Next.js frontend. Passed to `next` as `-p`. */\n next?: number;\n\n /** SPFN API server. */\n server?: number;\n}\n\n/**\n * Where the app's own environment schema lives.\n *\n * Read by the CLI (`spfn env`, `spfn secret`), which cannot otherwise find an\n * app's variables: a package's schema sits at `<package>/config`, an app's\n * sits wherever the app put it.\n */\nexport interface SpfnEnvConfig\n{\n /**\n * Modules that export `envSchema`, as paths relative to the project root —\n * e.g. `['src/server/config/env.config.ts']`. TypeScript and the\n * tsconfig path aliases it imports through are supported.\n */\n schemas?: string[];\n\n /** What `spfn env audit` scans. */\n audit?: SpfnEnvAuditConfig;\n}\n\n/**\n * The files `spfn env audit` reads, as globs relative to the project root.\n *\n * `node_modules`, build output, `*.d.ts` files and symbolic links are never\n * scanned, whatever the globs say.\n */\nexport interface SpfnEnvAuditConfig\n{\n /**\n * Directories or globs to scan — e.g. `['src', 'scripts/*.ts']`.\n *\n * @default ['src']\n */\n include?: string[];\n\n /**\n * Directories or globs to leave out — e.g. `['src/legacy/**']`. A file left\n * out may read `process.env` directly, and names it reads do not count as read.\n */\n ignore?: string[];\n}\n\nexport interface SpfnAppConfig\n{\n ports?: SpfnPorts;\n\n /** Host the SPFN API server binds. */\n host?: string;\n\n /** The app's environment schema modules, and what `spfn env audit` scans. */\n env?: SpfnEnvConfig;\n\n /** Deployment settings — untouched by this module, kept so the type is whole. */\n [key: string]: unknown;\n}\n\n/**\n * The single place each default exists.\n *\n * Defaults live at the bottom of the chain and nowhere else. A default declared\n * at an input layer — a commander option, a generated entry file, an env schema\n * — cannot be told apart from a value someone supplied, so it silently outranks\n * whatever is below it. Three separate defects in this repository came from\n * exactly that, including an env schema default that forced the environment to\n * be consulted last and made an injected port unreachable.\n */\nexport const PORT_DEFAULTS = {\n next: 3790,\n server: 8790,\n} as const;\n\nexport const HOST_DEFAULT = 'localhost';\n\nconst CONFIG_FILE_NAMES = [\n 'spfn.config.js',\n 'spfn.config.mjs',\n] as const;\n\n/**\n * Read `spfn.config.js` from an app directory.\n *\n * Returns an empty object when there is no file, and also when the file cannot\n * be imported — a malformed deployment config must not stop a server from\n * booting. The caller decides whether the absence is worth reporting.\n */\nexport async function loadAppConfig(cwd: string = process.cwd()): Promise<SpfnAppConfig>\n{\n for (const fileName of CONFIG_FILE_NAMES)\n {\n const fullPath = join(cwd, fileName);\n\n if (!existsSync(fullPath))\n {\n continue;\n }\n\n try\n {\n // A file URL, not a path: on Windows an absolute path is not a\n // valid ESM specifier, and this file is imported at runtime.\n const module = await import(pathToFileURL(fullPath).href);\n\n return (module.default ?? {}) as SpfnAppConfig;\n }\n catch (error)\n {\n // Falling back to defaults, and saying so. A file that exists and\n // cannot be imported is a typo, not an absence — silence would send\n // the server to port 8790 while its own config named another, and\n // the only symptom would be a port nobody chose.\n //\n // console, not the server logger: this runs inside the CLI too,\n // before any logger is configured.\n console.warn(\n `⚠️ ${fileName} could not be imported — falling back to defaults `\n + `for ports and host. ${error instanceof Error ? error.message : String(error)}`,\n );\n\n return {};\n }\n }\n\n return {};\n}\n\n/**\n * The port each process binds, given a loaded config and the environment.\n *\n * Three layers, in this order, and no more: environment variable, then\n * `spfn.config.js`, then the default.\n */\nexport function resolvePorts(\n config: SpfnAppConfig,\n env: NodeJS.ProcessEnv = process.env,\n): { next: number; server: number }\n{\n return {\n next: readPort(env.NEXT_PORT) ?? config.ports?.next ?? PORT_DEFAULTS.next,\n server: readPort(env.SPFN_PORT) ?? config.ports?.server ?? PORT_DEFAULTS.server,\n };\n}\n\n/**\n * The host the SPFN server binds, on the same three layers.\n *\n * `localhost` by default: a container states `SPFN_HOST=0.0.0.0` in its own\n * files, where that decision belongs, rather than every developer machine\n * publishing a dev server to its network.\n */\nexport function resolveHost(\n config: SpfnAppConfig,\n env: NodeJS.ProcessEnv = process.env,\n): string\n{\n return env.SPFN_HOST || config.host || HOST_DEFAULT;\n}\n\n/**\n * Where the SPFN server binds, including the address a server config may still\n * carry through `.port()` / `.host()`.\n *\n * Those two are deprecated and sit between `spfn.config.js` and the default for\n * one release, so an app that has not moved yet keeps the address it had. Once\n * they are removed this collapses back to {@link resolvePorts} and\n * {@link resolveHost}.\n */\nexport function resolveServerAddress(\n config: SpfnAppConfig,\n deprecated: { port?: number; host?: string } = {},\n env: NodeJS.ProcessEnv = process.env,\n): { port: number; host: string }\n{\n return {\n port: readPort(env.SPFN_PORT)\n ?? config.ports?.server\n ?? deprecated.port\n ?? PORT_DEFAULTS.server,\n host: env.SPFN_HOST\n || config.host\n || deprecated.host\n || HOST_DEFAULT,\n };\n}\n\nfunction readPort(value: string | undefined): number | undefined\n{\n if (!value)\n {\n return undefined;\n }\n\n const port = Number(value);\n\n return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;\n}\n"]}