@spfn/core 0.3.0-beta.17 → 0.3.0-beta.19

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.
@@ -25,6 +25,21 @@ type EventHandler<TPayload> = (payload: TPayload) => void | Promise<void>;
25
25
  * Job queue sender function type (used by job module)
26
26
  */
27
27
  type JobQueueSender = (queueName: string, payload: unknown) => Promise<void>;
28
+ /**
29
+ * What a contracted event promises to clients compiled separately from the
30
+ * server, written into `contracts/current.json` by `@spfn/core:contract`.
31
+ *
32
+ * The same version markers a route's `.contract()` carries; the payload shape
33
+ * is the event's own schema, so it is not repeated here.
34
+ */
35
+ interface EventContract {
36
+ /** Contract version the event first appeared in. */
37
+ since: string;
38
+ /** Contract version the event was announced for removal in. */
39
+ deprecatedIn?: string;
40
+ /** Contract version the event was removed in. */
41
+ removedIn?: string;
42
+ }
28
43
  /**
29
44
  * Event definition interface
30
45
  */
@@ -37,6 +52,22 @@ interface EventDef<TPayload = void> {
37
52
  * TypeBox payload schema (optional)
38
53
  */
39
54
  readonly schema?: TSchema;
55
+ /**
56
+ * The contract declared with `.contract()`, absent when the event is not
57
+ * part of the published contract.
58
+ *
59
+ * Named with an underscore because `.contract()` holds the plain name, as
60
+ * a router's `_contractVersion` sits beside `.contractVersion()`.
61
+ */
62
+ readonly _contract?: EventContract;
63
+ /**
64
+ * Publish this event in `contracts/current.json`.
65
+ *
66
+ * Sets the contract on this definition and returns it. An event is defined
67
+ * once and shared, so a second call throws instead of one of the two
68
+ * declarations silently winning.
69
+ */
70
+ contract: (contract: EventContract) => EventDef<TPayload>;
40
71
  /**
41
72
  * Subscribe to this event (in-memory handler)
42
73
  */
@@ -104,9 +135,32 @@ type InferEventPayload$1<TEvent> = TEvent extends EventDef<infer TPayload> ? TPa
104
135
  */
105
136
 
106
137
  /**
107
- * Event Router Definition
138
+ * How a client reaches the contracted events of a router.
139
+ *
140
+ * Declared on the router because it is the stream's promise, not one event's:
141
+ * `createServer` checks it against `.events()` at boot, so the document cannot
142
+ * describe a path or an auth mode the server does not serve.
108
143
  */
109
- interface EventRouterDef<TEvents extends Record<string, EventDef<any>>> {
144
+ interface EventRouterContract {
145
+ /**
146
+ * `'tokenExchange'` when `.events()` enables `auth` — the client first posts
147
+ * to the token path and opens the stream with `?token=`. `'none'` otherwise.
148
+ */
149
+ auth: 'none' | 'tokenExchange';
150
+ /**
151
+ * The stream path `.events()` registers.
152
+ * @default '/events/stream'
153
+ */
154
+ streamPath?: string;
155
+ }
156
+ /**
157
+ * What every router of events carries — the SSE event router and the
158
+ * WebSocket router alike.
159
+ *
160
+ * The WebSocket router builds on this rather than on `EventRouterDef`, because
161
+ * only an SSE event router can be contracted.
162
+ */
163
+ interface EventRouterBase<TEvents extends Record<string, EventDef<any>>> {
110
164
  /**
111
165
  * Event definitions
112
166
  */
@@ -122,21 +176,42 @@ interface EventRouterDef<TEvents extends Record<string, EventDef<any>>> {
122
176
  [K in keyof TEvents]: TEvents[K]['_payload'];
123
177
  };
124
178
  }
179
+ /**
180
+ * Event Router Definition
181
+ */
182
+ interface EventRouterDef<TEvents extends Record<string, EventDef<any>>> extends EventRouterBase<TEvents> {
183
+ /**
184
+ * The contract declared with `.contract()`, absent when the router declares
185
+ * none. Underscored for the same reason as `EventDef._contract`.
186
+ */
187
+ readonly _contract?: EventRouterContract;
188
+ /**
189
+ * Declare how a client reaches this router's contracted events.
190
+ *
191
+ * Sets the contract on this router and returns it. A `streamPath` that does
192
+ * not start with `/` throws here, where the typo is.
193
+ */
194
+ contract: (contract: EventRouterContract) => EventRouterDef<TEvents>;
195
+ }
125
196
  /**
126
197
  * Infer event names from EventRouter
127
198
  */
128
- type InferEventNames<T> = T extends EventRouterDef<infer E> ? keyof E & string : never;
199
+ type InferEventNames<T> = T extends EventRouterBase<infer E> ? keyof E & string : never;
129
200
  /**
130
201
  * Infer payload type for specific event
131
202
  */
132
- type InferEventPayload<T extends EventRouterDef<any>, K extends InferEventNames<T>> = T['_types'][K];
203
+ type InferEventPayload<T extends EventRouterBase<any>, K extends InferEventNames<T>> = T['_types'][K];
133
204
  /**
134
205
  * Infer all event payloads map
135
206
  */
136
- type InferEventPayloads<T extends EventRouterDef<any>> = T['_types'];
207
+ type InferEventPayloads<T extends EventRouterBase<any>> = T['_types'];
137
208
  /**
138
209
  * Define an event router for SSE subscription
139
210
  *
211
+ * `.contract({ auth })` on the router, together with `.contract({ since })` on
212
+ * its events, publishes those events in `contracts/current.json` — see the
213
+ * contract README.
214
+ *
140
215
  * @example
141
216
  * ```typescript
142
217
  * export const eventRouter = defineEventRouter({
@@ -154,125 +229,4 @@ type InferEventPayloads<T extends EventRouterDef<any>> = T['_types'];
154
229
  */
155
230
  declare function defineEventRouter<TEvents extends Record<string, EventDef<any>>>(events: TEvents): EventRouterDef<TEvents>;
156
231
 
157
- /**
158
- * SSE Token Manager
159
- *
160
- * Auth-agnostic token issuance and verification for SSE connections.
161
- * Issues one-time-use tokens with TTL for Token Exchange pattern.
162
- *
163
- * @example
164
- * ```typescript
165
- * const manager = new SSETokenManager({ ttl: 30000 });
166
- *
167
- * // Issue token for authenticated user
168
- * const token = await manager.issue('user-123');
169
- *
170
- * // Verify and consume token (one-time use)
171
- * const subject = await manager.verify(token); // 'user-123'
172
- * const again = await manager.verify(token); // null (already consumed)
173
- *
174
- * // Cleanup on shutdown
175
- * manager.destroy();
176
- * ```
177
- */
178
- /**
179
- * Minimal cache client interface (compatible with ioredis Redis | Cluster)
180
- */
181
- type CacheClient = {
182
- set(key: string, value: string, ...args: any[]): Promise<any>;
183
- getdel?(key: string): Promise<string | null>;
184
- get(key: string): Promise<string | null>;
185
- del(...keys: string[]): Promise<number>;
186
- };
187
- /**
188
- * Stored SSE token data
189
- */
190
- interface SSEToken {
191
- subject: string;
192
- expiresAt: number;
193
- }
194
- /**
195
- * Token storage interface
196
- *
197
- * Implement this for custom storage backends (e.g., Redis for multi-instance).
198
- */
199
- interface SSETokenStore {
200
- /** Store a token */
201
- set(token: string, data: SSEToken): Promise<void>;
202
- /** Get and delete a token (one-time use) */
203
- consume(token: string): Promise<SSEToken | null>;
204
- /** Remove expired tokens */
205
- cleanup(): Promise<void>;
206
- }
207
- /**
208
- * SSETokenManager configuration
209
- */
210
- interface SSETokenManagerConfig {
211
- /**
212
- * Token time-to-live in milliseconds
213
- * @default 30000
214
- */
215
- ttl?: number;
216
- /**
217
- * Custom token store (default: in-memory Map)
218
- */
219
- store?: SSETokenStore;
220
- /**
221
- * Cleanup interval in milliseconds
222
- * @default 60000
223
- */
224
- cleanupInterval?: number;
225
- }
226
- /**
227
- * Redis/Valkey-backed token store for multi-instance deployments.
228
- *
229
- * Uses SET EX for automatic TTL expiry and GETDEL for atomic one-time consumption.
230
- * No cleanup needed — Redis handles expiration automatically.
231
- *
232
- * @example
233
- * ```typescript
234
- * import { getCache } from '@spfn/core/cache';
235
- *
236
- * const cache = getCache();
237
- * if (cache) {
238
- * const store = new CacheTokenStore(cache);
239
- * const manager = new SSETokenManager({ store });
240
- * }
241
- * ```
242
- */
243
- declare class CacheTokenStore implements SSETokenStore {
244
- private cache;
245
- private prefix;
246
- constructor(cache: CacheClient);
247
- set(token: string, data: SSEToken): Promise<void>;
248
- consume(token: string): Promise<SSEToken | null>;
249
- cleanup(): Promise<void>;
250
- }
251
- declare class SSETokenManager {
252
- private store;
253
- private ttl;
254
- private cleanupTimer;
255
- constructor(config?: SSETokenManagerConfig);
256
- /**
257
- * How long an issued token stays valid, in milliseconds.
258
- *
259
- * Exposed so a caller that has to report an expiry to its client computes the same
260
- * number this manager stamps on the token, instead of assuming the default.
261
- */
262
- get ttlMs(): number;
263
- /**
264
- * Issue a new one-time-use token for the given subject
265
- */
266
- issue(subject: string): Promise<string>;
267
- /**
268
- * Verify and consume a token
269
- * @returns subject string if valid, null if invalid/expired/already consumed
270
- */
271
- verify(token: string): Promise<string | null>;
272
- /**
273
- * Cleanup timer and resources
274
- */
275
- destroy(): void;
276
- }
277
-
278
- export { CacheTokenStore as C, type EventDef as E, type InferEventNames as I, type JobQueueSender as J, type PubSubCache as P, SSETokenManager as S, type EventHandler as a, type EventRouterDef as b, type InferEventPayload$1 as c, type InferEventPayloads as d, type InferEventPayload as e, defineEventRouter as f, type SSEToken as g, type SSETokenManagerConfig as h, type SSETokenStore as i };
232
+ export { type EventRouterDef as E, type InferEventNames as I, type JobQueueSender as J, type PubSubCache as P, type EventDef as a, type EventContract as b, type EventHandler as c, type EventRouterContract as d, type InferEventPayload$1 as e, type InferEventPayloads as f, type InferEventPayload as g, defineEventRouter as h, type EventRouterBase as i };
@@ -6,15 +6,16 @@ import { NamedMiddleware, Router } from '@spfn/core/route';
6
6
  import { OnErrorContext, ProxyGuardConfig, RateLimitOptions } from '@spfn/core/middleware';
7
7
  import { SafeFetchPolicy } from '@spfn/core/security';
8
8
  import { c as JobRouter, f as BossOptions } from '../boss-BiDCbbAQ.js';
9
- import { b as EventRouterDef, E as EventDef } from '../token-manager-BT5EnUAR.js';
10
- import { d as SSEHandlerConfig, e as SSEAuthConfig } from '../types-ZQODsBft.js';
11
- import { W as WSRouterDef, f as WSHandlerConfig, e as WSMessageHandlers, g as WSAuthConfig } from '../types-2AbaW4Ie.js';
9
+ import { E as EventRouterDef, a as EventDef } from '../router-CysBmgv3.js';
10
+ import { d as SSEHandlerConfig, e as SSEAuthConfig } from '../types-B8zAxFsE.js';
11
+ import { W as WSRouterDef, f as WSHandlerConfig, e as WSMessageHandlers, g as WSAuthConfig } from '../types-W4u_S0lF.js';
12
12
  import { DatabaseProvider, MigrationStatus, MigrationStatusDb } from '@spfn/core/db';
13
13
  import * as _sinclair_typebox from '@sinclair/typebox';
14
14
  import { Static } from '@sinclair/typebox';
15
15
  import { R as RouteDef } from '../route-builder-2ani2jEI.js';
16
16
  import { a as EnvRegistry } from '../registry-C2KQCh0m.js';
17
17
  import 'pg-boss';
18
+ import '../token-manager-Ckjt2-qq.js';
18
19
  import '../define-middleware-CVKgqo8S.js';
19
20
  import 'hono/utils/http-status';
20
21
  import '../route/types.js';
@@ -644,6 +644,12 @@ async function authorizeEvents(subject, requestedEvents, authConfig) {
644
644
  }
645
645
  return allowed;
646
646
  }
647
+
648
+ // src/event/sse/paths.ts
649
+ var DEFAULT_SSE_STREAM_PATH = "/events/stream";
650
+ function sseTokenPath(streamPath) {
651
+ return streamPath.replace(/\/[^/]+$/, "/token");
652
+ }
647
653
  function hashToken(token) {
648
654
  return createHash("sha256").update(token).digest("hex");
649
655
  }
@@ -2126,8 +2132,9 @@ async function registerSSEEndpoint(app, config) {
2126
2132
  return;
2127
2133
  }
2128
2134
  const eventsConfig = config.eventsConfig ?? {};
2129
- const streamPath = eventsConfig.path ?? "/events/stream";
2135
+ const streamPath = eventsConfig.path ?? DEFAULT_SSE_STREAM_PATH;
2130
2136
  const authConfig = eventsConfig.auth;
2137
+ assertEventContractServed(config.events._contract, streamPath, !!authConfig?.enabled);
2131
2138
  const debug = isDebugMode(config);
2132
2139
  let tokenManager;
2133
2140
  if (authConfig?.enabled) {
@@ -2150,7 +2157,7 @@ async function registerSSEEndpoint(app, config) {
2150
2157
  ttl: authConfig.tokenTtl,
2151
2158
  store
2152
2159
  });
2153
- const tokenPath = streamPath.replace(/\/[^/]+$/, "/token");
2160
+ const tokenPath = sseTokenPath(streamPath);
2154
2161
  const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
2155
2162
  const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
2156
2163
  app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
@@ -2179,6 +2186,23 @@ async function registerSSEEndpoint(app, config) {
2179
2186
  });
2180
2187
  }
2181
2188
  }
2189
+ function assertEventContractServed(contract, streamPath, authEnabled) {
2190
+ if (!contract) {
2191
+ return;
2192
+ }
2193
+ const contractPath = contract.streamPath ?? DEFAULT_SSE_STREAM_PATH;
2194
+ if (contractPath !== streamPath) {
2195
+ throw new Error(
2196
+ `SSE event router contract declares streamPath ${contractPath} but .events() registers ${streamPath}; the contract would describe a path the server does not serve`
2197
+ );
2198
+ }
2199
+ const servedAuth = authEnabled ? "tokenExchange" : "none";
2200
+ if (contract.auth !== servedAuth) {
2201
+ throw new Error(
2202
+ `SSE event router contract declares auth ${contract.auth} but .events() serves auth ${servedAuth}; the contract would describe an auth mode the server does not serve`
2203
+ );
2204
+ }
2205
+ }
2182
2206
  function isDebugMode(config) {
2183
2207
  return config?.debug ?? process.env.NODE_ENV === "development";
2184
2208
  }