@spfn/core 0.2.0-beta.4 → 0.2.0-beta.42
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.
- package/README.md +260 -1175
- package/dist/{boss-BO8ty33K.d.ts → boss-DI1r4kTS.d.ts} +24 -7
- package/dist/cache/index.js +32 -29
- package/dist/cache/index.js.map +1 -1
- package/dist/codegen/index.d.ts +55 -8
- package/dist/codegen/index.js +179 -5
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +168 -6
- package/dist/config/index.js +29 -5
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +128 -4
- package/dist/db/index.js +177 -50
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +55 -1
- package/dist/env/index.js +71 -3
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +27 -19
- package/dist/env/loader.js +33 -25
- package/dist/env/loader.js.map +1 -1
- package/dist/event/index.d.ts +27 -1
- package/dist/event/index.js +6 -1
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +77 -2
- package/dist/event/sse/client.js +87 -24
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +10 -4
- package/dist/event/sse/index.js +158 -12
- package/dist/event/sse/index.js.map +1 -1
- package/dist/job/index.d.ts +23 -8
- package/dist/job/index.js +96 -20
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.d.ts +5 -0
- package/dist/logger/index.js +14 -0
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.d.ts +23 -1
- package/dist/middleware/index.js +58 -5
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +77 -31
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +44 -23
- package/dist/nextjs/server.js +83 -65
- package/dist/nextjs/server.js.map +1 -1
- package/dist/route/index.d.ts +158 -4
- package/dist/route/index.js +253 -17
- package/dist/route/index.js.map +1 -1
- package/dist/server/index.d.ts +251 -16
- package/dist/server/index.js +774 -228
- package/dist/server/index.js.map +1 -1
- package/dist/{types-D_N_U-Py.d.ts → types-7Mhoxnnt.d.ts} +21 -1
- package/dist/types-DKQ90YL7.d.ts +372 -0
- package/docs/cache.md +133 -0
- package/docs/codegen.md +74 -0
- package/docs/database.md +370 -0
- package/docs/entity.md +539 -0
- package/docs/env.md +499 -0
- package/docs/errors.md +319 -0
- package/docs/event.md +443 -0
- package/docs/file-upload.md +717 -0
- package/docs/job.md +131 -0
- package/docs/logger.md +108 -0
- package/docs/middleware.md +337 -0
- package/docs/nextjs.md +247 -0
- package/docs/repository.md +496 -0
- package/docs/route.md +497 -0
- package/docs/server.md +429 -0
- package/package.json +3 -2
- package/dist/types-B-e_f2dQ.d.ts +0 -121
package/dist/event/sse/index.js
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
import { streamSSE } from 'hono/streaming';
|
|
2
2
|
import { logger } from '@spfn/core/logger';
|
|
3
|
+
import { randomBytes } from 'crypto';
|
|
3
4
|
|
|
4
5
|
// src/event/sse/handler.ts
|
|
5
6
|
var sseLogger = logger.child("@spfn/core:sse");
|
|
6
|
-
function createSSEHandler(router, config = {}) {
|
|
7
|
+
function createSSEHandler(router, config = {}, tokenManager) {
|
|
7
8
|
const {
|
|
8
|
-
pingInterval = 3e4
|
|
9
|
-
|
|
9
|
+
pingInterval = 3e4,
|
|
10
|
+
auth: authConfig
|
|
10
11
|
} = config;
|
|
11
12
|
return async (c) => {
|
|
12
|
-
const
|
|
13
|
-
if (
|
|
13
|
+
const subject = await authenticateToken(c, tokenManager);
|
|
14
|
+
if (subject === false) {
|
|
15
|
+
return c.json({ error: "Missing token parameter" }, 401);
|
|
16
|
+
}
|
|
17
|
+
if (subject === null) {
|
|
18
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
19
|
+
}
|
|
20
|
+
if (subject) {
|
|
21
|
+
c.set("sseSubject", subject);
|
|
22
|
+
}
|
|
23
|
+
const requestedEvents = parseRequestedEvents(c);
|
|
24
|
+
if (!requestedEvents) {
|
|
14
25
|
return c.json({ error: "Missing events parameter" }, 400);
|
|
15
26
|
}
|
|
16
|
-
const requestedEvents = eventsParam.split(",").map((e) => e.trim());
|
|
17
27
|
const validEventNames = router.eventNames;
|
|
18
28
|
const invalidEvents = requestedEvents.filter((e) => !validEventNames.includes(e));
|
|
19
29
|
if (invalidEvents.length > 0) {
|
|
@@ -23,19 +33,29 @@ function createSSEHandler(router, config = {}) {
|
|
|
23
33
|
validEvents: validEventNames
|
|
24
34
|
}, 400);
|
|
25
35
|
}
|
|
36
|
+
const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);
|
|
37
|
+
if (allowedEvents === null) {
|
|
38
|
+
return c.json({ error: "Not authorized for any requested events" }, 403);
|
|
39
|
+
}
|
|
26
40
|
sseLogger.debug("SSE connection requested", {
|
|
27
|
-
events:
|
|
41
|
+
events: allowedEvents,
|
|
42
|
+
subject: subject || void 0,
|
|
28
43
|
clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
|
|
29
44
|
});
|
|
30
45
|
return streamSSE(c, async (stream) => {
|
|
31
46
|
const unsubscribes = [];
|
|
32
47
|
let messageId = 0;
|
|
33
|
-
for (const eventName of
|
|
48
|
+
for (const eventName of allowedEvents) {
|
|
34
49
|
const eventDef = router.events[eventName];
|
|
35
50
|
if (!eventDef) {
|
|
36
51
|
continue;
|
|
37
52
|
}
|
|
38
53
|
const unsubscribe = eventDef.subscribe((payload) => {
|
|
54
|
+
if (subject && authConfig?.filter?.[eventName]) {
|
|
55
|
+
if (!authConfig.filter[eventName](subject, payload)) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
39
59
|
messageId++;
|
|
40
60
|
const message = {
|
|
41
61
|
event: eventName,
|
|
@@ -54,13 +74,13 @@ function createSSEHandler(router, config = {}) {
|
|
|
54
74
|
unsubscribes.push(unsubscribe);
|
|
55
75
|
}
|
|
56
76
|
sseLogger.info("SSE connection established", {
|
|
57
|
-
events:
|
|
77
|
+
events: allowedEvents,
|
|
58
78
|
subscriptionCount: unsubscribes.length
|
|
59
79
|
});
|
|
60
80
|
await stream.writeSSE({
|
|
61
81
|
event: "connected",
|
|
62
82
|
data: JSON.stringify({
|
|
63
|
-
subscribedEvents:
|
|
83
|
+
subscribedEvents: allowedEvents,
|
|
64
84
|
timestamp: Date.now()
|
|
65
85
|
})
|
|
66
86
|
});
|
|
@@ -77,7 +97,7 @@ function createSSEHandler(router, config = {}) {
|
|
|
77
97
|
clearInterval(pingTimer);
|
|
78
98
|
unsubscribes.forEach((fn) => fn());
|
|
79
99
|
sseLogger.info("SSE connection closed", {
|
|
80
|
-
events:
|
|
100
|
+
events: allowedEvents
|
|
81
101
|
});
|
|
82
102
|
}, async (err) => {
|
|
83
103
|
sseLogger.error("SSE stream error", {
|
|
@@ -86,7 +106,133 @@ function createSSEHandler(router, config = {}) {
|
|
|
86
106
|
});
|
|
87
107
|
};
|
|
88
108
|
}
|
|
109
|
+
async function authenticateToken(c, tokenManager) {
|
|
110
|
+
if (!tokenManager) {
|
|
111
|
+
return void 0;
|
|
112
|
+
}
|
|
113
|
+
const token = c.req.query("token");
|
|
114
|
+
if (!token) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
return await tokenManager.verify(token);
|
|
118
|
+
}
|
|
119
|
+
function parseRequestedEvents(c) {
|
|
120
|
+
const eventsParam = c.req.query("events");
|
|
121
|
+
if (!eventsParam) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return eventsParam.split(",").map((e) => e.trim());
|
|
125
|
+
}
|
|
126
|
+
async function authorizeEvents(subject, requestedEvents, authConfig) {
|
|
127
|
+
if (!subject || !authConfig?.authorize) {
|
|
128
|
+
return requestedEvents;
|
|
129
|
+
}
|
|
130
|
+
const allowed = await authConfig.authorize(subject, requestedEvents);
|
|
131
|
+
if (allowed.length === 0) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return allowed;
|
|
135
|
+
}
|
|
136
|
+
var InMemoryTokenStore = class {
|
|
137
|
+
tokens = /* @__PURE__ */ new Map();
|
|
138
|
+
async set(token, data) {
|
|
139
|
+
this.tokens.set(token, data);
|
|
140
|
+
}
|
|
141
|
+
async consume(token) {
|
|
142
|
+
const data = this.tokens.get(token);
|
|
143
|
+
if (!data) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
this.tokens.delete(token);
|
|
147
|
+
return data;
|
|
148
|
+
}
|
|
149
|
+
async cleanup() {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
for (const [token, data] of this.tokens) {
|
|
152
|
+
if (data.expiresAt <= now) {
|
|
153
|
+
this.tokens.delete(token);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
var CacheTokenStore = class {
|
|
159
|
+
constructor(cache) {
|
|
160
|
+
this.cache = cache;
|
|
161
|
+
}
|
|
162
|
+
prefix = "sse:token:";
|
|
163
|
+
async set(token, data) {
|
|
164
|
+
const ttlSeconds = Math.max(1, Math.ceil((data.expiresAt - Date.now()) / 1e3));
|
|
165
|
+
await this.cache.set(
|
|
166
|
+
this.prefix + token,
|
|
167
|
+
JSON.stringify(data),
|
|
168
|
+
"EX",
|
|
169
|
+
ttlSeconds
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
async consume(token) {
|
|
173
|
+
const key = this.prefix + token;
|
|
174
|
+
let raw = null;
|
|
175
|
+
if (this.cache.getdel) {
|
|
176
|
+
raw = await this.cache.getdel(key);
|
|
177
|
+
} else {
|
|
178
|
+
raw = await this.cache.get(key);
|
|
179
|
+
if (raw) {
|
|
180
|
+
await this.cache.del(key);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!raw) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
return JSON.parse(raw);
|
|
187
|
+
}
|
|
188
|
+
async cleanup() {
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
var SSETokenManager = class {
|
|
192
|
+
store;
|
|
193
|
+
ttl;
|
|
194
|
+
cleanupTimer = null;
|
|
195
|
+
constructor(config) {
|
|
196
|
+
this.ttl = config?.ttl ?? 3e4;
|
|
197
|
+
this.store = config?.store ?? new InMemoryTokenStore();
|
|
198
|
+
const cleanupInterval = config?.cleanupInterval ?? 6e4;
|
|
199
|
+
this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);
|
|
200
|
+
this.cleanupTimer.unref();
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Issue a new one-time-use token for the given subject
|
|
204
|
+
*/
|
|
205
|
+
async issue(subject) {
|
|
206
|
+
const token = randomBytes(32).toString("hex");
|
|
207
|
+
await this.store.set(token, {
|
|
208
|
+
token,
|
|
209
|
+
subject,
|
|
210
|
+
expiresAt: Date.now() + this.ttl
|
|
211
|
+
});
|
|
212
|
+
return token;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Verify and consume a token
|
|
216
|
+
* @returns subject string if valid, null if invalid/expired/already consumed
|
|
217
|
+
*/
|
|
218
|
+
async verify(token) {
|
|
219
|
+
const data = await this.store.consume(token);
|
|
220
|
+
if (!data || data.expiresAt <= Date.now()) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
return data.subject;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Cleanup timer and resources
|
|
227
|
+
*/
|
|
228
|
+
destroy() {
|
|
229
|
+
if (this.cleanupTimer) {
|
|
230
|
+
clearInterval(this.cleanupTimer);
|
|
231
|
+
this.cleanupTimer = null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
89
235
|
|
|
90
|
-
export { createSSEHandler };
|
|
236
|
+
export { CacheTokenStore, SSETokenManager, createSSEHandler };
|
|
91
237
|
//# sourceMappingURL=index.js.map
|
|
92
238
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/event/sse/handler.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * SSE Handler for Hono\n *\n * Creates SSE stream endpoint for event subscription\n *\n * @example\n * ```typescript\n * import { Hono } from 'hono';\n * import { createSSEHandler } from '@spfn/core/event/sse';\n * import { eventRouter } from './events';\n *\n * const app = new Hono();\n *\n * // GET /events/stream?events=userCreated,orderPlaced\n * app.get('/events/stream', createSSEHandler(eventRouter));\n * ```\n */\n\nimport type { Context } from 'hono';\nimport { streamSSE } from 'hono/streaming';\nimport { logger } from '@spfn/core/logger';\nimport type { EventRouterDef, InferEventNames } from '../router';\nimport type { SSEHandlerConfig } from './types';\n\nconst sseLogger = logger.child('@spfn/core:sse');\n\n/**\n * Create SSE handler for Hono\n *\n * Query parameters:\n * - events: Comma-separated list of event names to subscribe\n *\n * @example\n * ```typescript\n * app.get('/events/stream', createSSEHandler(eventRouter, {\n * pingInterval: 30000,\n * }));\n * ```\n */\nexport function createSSEHandler<TRouter extends EventRouterDef<any>>(\n router: TRouter,\n config: SSEHandlerConfig = {}\n)\n{\n const {\n pingInterval = 30000,\n // headers: customHeaders = {}, // Reserved for future use\n } = config;\n\n return async (c: Context) =>\n {\n // Parse events from query parameter\n const eventsParam = c.req.query('events');\n\n if (!eventsParam)\n {\n return c.json({ error: 'Missing events parameter' }, 400);\n }\n\n const requestedEvents = eventsParam.split(',').map(e => e.trim());\n\n // Validate event names\n const validEventNames = router.eventNames as string[];\n const invalidEvents = requestedEvents.filter(e => !validEventNames.includes(e));\n\n if (invalidEvents.length > 0)\n {\n return c.json({\n error: 'Invalid event names',\n invalidEvents,\n validEvents: validEventNames,\n }, 400);\n }\n\n sseLogger.debug('SSE connection requested', {\n events: requestedEvents,\n clientIp: c.req.header('x-forwarded-for') || c.req.header('x-real-ip'),\n });\n\n // Start SSE stream\n return streamSSE(c, async (stream) =>\n {\n const unsubscribes: (() => void)[] = [];\n let messageId = 0;\n\n // Subscribe to each requested event\n for (const eventName of requestedEvents as InferEventNames<TRouter>[])\n {\n const eventDef = router.events[eventName];\n\n if (!eventDef)\n {\n continue;\n }\n\n const unsubscribe = eventDef.subscribe((payload: unknown) =>\n {\n messageId++;\n\n const message = {\n event: eventName,\n data: payload,\n };\n\n sseLogger.debug('SSE sending event', {\n event: eventName,\n messageId,\n });\n\n // Fire-and-forget in sync callback\n void stream.writeSSE({\n id: String(messageId),\n event: eventName as string,\n data: JSON.stringify(message),\n });\n });\n\n unsubscribes.push(unsubscribe);\n }\n\n sseLogger.info('SSE connection established', {\n events: requestedEvents,\n subscriptionCount: unsubscribes.length,\n });\n\n // Send initial connection message\n await stream.writeSSE({\n event: 'connected',\n data: JSON.stringify({\n subscribedEvents: requestedEvents,\n timestamp: Date.now(),\n }),\n });\n\n // Keep-alive ping\n const pingTimer = setInterval(() =>\n {\n // Fire-and-forget in sync callback\n void stream.writeSSE({\n event: 'ping',\n data: JSON.stringify({ timestamp: Date.now() }),\n });\n }, pingInterval);\n\n // Wait for client disconnect using abort signal\n const abortSignal = c.req.raw.signal;\n\n while (!abortSignal.aborted)\n {\n await stream.sleep(pingInterval);\n }\n\n // Cleanup\n clearInterval(pingTimer);\n unsubscribes.forEach(fn => fn());\n\n sseLogger.info('SSE connection closed', {\n events: requestedEvents,\n });\n }, async (err: Error) =>\n {\n sseLogger.error('SSE stream error', {\n error: err.message,\n });\n });\n };\n}\n\n"]}
|
|
1
|
+
{"version":3,"sources":["../../../src/event/sse/handler.ts","../../../src/event/sse/token-manager.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * SSE Handler for Hono\n *\n * Creates SSE stream endpoint for event subscription\n *\n * @example\n * ```typescript\n * import { Hono } from 'hono';\n * import { createSSEHandler } from '@spfn/core/event/sse';\n * import { eventRouter } from './events';\n *\n * const app = new Hono();\n *\n * // GET /events/stream?events=userCreated,orderPlaced\n * app.get('/events/stream', createSSEHandler(eventRouter));\n * ```\n */\n\nimport type { Context } from 'hono';\nimport { streamSSE } from 'hono/streaming';\nimport { logger } from '@spfn/core/logger';\nimport type { EventRouterDef, InferEventNames } from '../router';\nimport type { SSEHandlerConfig, SSEHandlerAuthConfig } from './types';\nimport type { SSETokenManager } from './token-manager';\n\nconst sseLogger = logger.child('@spfn/core:sse');\n\n// Extend Hono context with SSE subject\ndeclare module 'hono'\n{\n interface ContextVariableMap\n {\n sseSubject?: string;\n }\n}\n\n/**\n * Create SSE handler for Hono\n *\n * Query parameters:\n * - events: Comma-separated list of event names to subscribe\n * - token: One-time auth token (when auth is enabled)\n *\n * @example\n * ```typescript\n * app.get('/events/stream', createSSEHandler(eventRouter, {\n * pingInterval: 30000,\n * }));\n * ```\n */\nexport function createSSEHandler<TRouter extends EventRouterDef<any>>(\n router: TRouter,\n config: SSEHandlerConfig = {},\n tokenManager?: SSETokenManager\n)\n{\n const {\n pingInterval = 30000,\n auth: authConfig,\n } = config;\n\n return async (c: Context) =>\n {\n // ── 1. Token Authentication ──\n const subject = await authenticateToken(c, tokenManager);\n if (subject === false)\n {\n return c.json({ error: 'Missing token parameter' }, 401);\n }\n if (subject === null)\n {\n return c.json({ error: 'Invalid or expired token' }, 401);\n }\n if (subject)\n {\n c.set('sseSubject', subject);\n }\n\n // ── 2. Parse events from query parameter ──\n const requestedEvents = parseRequestedEvents(c);\n if (!requestedEvents)\n {\n return c.json({ error: 'Missing events parameter' }, 400);\n }\n\n // ── 3. Validate event names ──\n const validEventNames = router.eventNames as string[];\n const invalidEvents = requestedEvents.filter(e => !validEventNames.includes(e));\n\n if (invalidEvents.length > 0)\n {\n return c.json({\n error: 'Invalid event names',\n invalidEvents,\n validEvents: validEventNames,\n }, 400);\n }\n\n // ── 4. Subscription Authorization ──\n const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);\n if (allowedEvents === null)\n {\n return c.json({ error: 'Not authorized for any requested events' }, 403);\n }\n\n sseLogger.debug('SSE connection requested', {\n events: allowedEvents,\n subject: subject || undefined,\n clientIp: c.req.header('x-forwarded-for') || c.req.header('x-real-ip'),\n });\n\n // ── 5. SSE Stream ──\n return streamSSE(c, async (stream) =>\n {\n const unsubscribes: (() => void)[] = [];\n let messageId = 0;\n\n for (const eventName of allowedEvents as InferEventNames<TRouter>[])\n {\n const eventDef = router.events[eventName];\n\n if (!eventDef)\n {\n continue;\n }\n\n const unsubscribe = eventDef.subscribe((payload: unknown) =>\n {\n // ── Payload Filtering ──\n if (subject && authConfig?.filter?.[eventName as string])\n {\n if (!authConfig.filter[eventName as string](subject, payload))\n {\n return;\n }\n }\n\n messageId++;\n\n const message = {\n event: eventName,\n data: payload,\n };\n\n sseLogger.debug('SSE sending event', {\n event: eventName,\n messageId,\n });\n\n // Fire-and-forget in sync callback\n void stream.writeSSE({\n id: String(messageId),\n event: eventName as string,\n data: JSON.stringify(message),\n });\n });\n\n unsubscribes.push(unsubscribe);\n }\n\n sseLogger.info('SSE connection established', {\n events: allowedEvents,\n subscriptionCount: unsubscribes.length,\n });\n\n // Send initial connection message\n await stream.writeSSE({\n event: 'connected',\n data: JSON.stringify({\n subscribedEvents: allowedEvents,\n timestamp: Date.now(),\n }),\n });\n\n // Keep-alive ping\n const pingTimer = setInterval(() =>\n {\n // Fire-and-forget in sync callback\n void stream.writeSSE({\n event: 'ping',\n data: JSON.stringify({ timestamp: Date.now() }),\n });\n }, pingInterval);\n\n // Wait for client disconnect using abort signal\n const abortSignal = c.req.raw.signal;\n\n while (!abortSignal.aborted)\n {\n await stream.sleep(pingInterval);\n }\n\n // Cleanup\n clearInterval(pingTimer);\n unsubscribes.forEach(fn => fn());\n\n sseLogger.info('SSE connection closed', {\n events: allowedEvents,\n });\n }, async (err: Error) =>\n {\n sseLogger.error('SSE stream error', {\n error: err.message,\n });\n });\n };\n}\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\n/**\n * Authenticate via one-time token\n * @returns subject string if authenticated, undefined if no auth required,\n * false if token missing, null if token invalid/expired\n */\nasync function authenticateToken(\n c: Context,\n tokenManager?: SSETokenManager\n): Promise<string | undefined | false | null>\n{\n if (!tokenManager)\n {\n return undefined;\n }\n\n const token = c.req.query('token');\n if (!token)\n {\n return false;\n }\n\n return await tokenManager.verify(token);\n}\n\n/**\n * Parse requested events from query parameter\n */\nfunction parseRequestedEvents(c: Context): string[] | null\n{\n const eventsParam = c.req.query('events');\n if (!eventsParam)\n {\n return null;\n }\n\n return eventsParam.split(',').map(e => e.trim());\n}\n\n/**\n * Authorize event subscription via auth hook\n * @returns allowed events array, or null if rejected\n */\nasync function authorizeEvents(\n subject: string | undefined,\n requestedEvents: string[],\n authConfig?: SSEHandlerAuthConfig\n): Promise<string[] | null>\n{\n if (!subject || !authConfig?.authorize)\n {\n return requestedEvents;\n }\n\n const allowed = await authConfig.authorize(subject, requestedEvents);\n\n if (allowed.length === 0)\n {\n return null;\n }\n\n return allowed;\n}\n","/**\n * SSE Token Manager\n *\n * Auth-agnostic token issuance and verification for SSE connections.\n * Issues one-time-use tokens with TTL for Token Exchange pattern.\n *\n * @example\n * ```typescript\n * const manager = new SSETokenManager({ ttl: 30000 });\n *\n * // Issue token for authenticated user\n * const token = await manager.issue('user-123');\n *\n * // Verify and consume token (one-time use)\n * const subject = await manager.verify(token); // 'user-123'\n * const again = await manager.verify(token); // null (already consumed)\n *\n * // Cleanup on shutdown\n * manager.destroy();\n * ```\n */\n\nimport { randomBytes } from 'crypto';\n\n/**\n * Minimal cache client interface (compatible with ioredis Redis | Cluster)\n */\ntype CacheClient = {\n set(key: string, value: string, ...args: any[]): Promise<any>;\n getdel?(key: string): Promise<string | null>;\n get(key: string): Promise<string | null>;\n del(key: string | string[]): Promise<number>;\n};\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Stored SSE token data\n */\nexport interface SSEToken\n{\n token: string;\n subject: string;\n expiresAt: number;\n}\n\n/**\n * Token storage interface\n *\n * Implement this for custom storage backends (e.g., Redis for multi-instance).\n */\nexport interface SSETokenStore\n{\n /** Store a token */\n set(token: string, data: SSEToken): Promise<void>;\n\n /** Get and delete a token (one-time use) */\n consume(token: string): Promise<SSEToken | null>;\n\n /** Remove expired tokens */\n cleanup(): Promise<void>;\n}\n\n/**\n * SSETokenManager configuration\n */\nexport interface SSETokenManagerConfig\n{\n /**\n * Token time-to-live in milliseconds\n * @default 30000\n */\n ttl?: number;\n\n /**\n * Custom token store (default: in-memory Map)\n */\n store?: SSETokenStore;\n\n /**\n * Cleanup interval in milliseconds\n * @default 60000\n */\n cleanupInterval?: number;\n}\n\n// ============================================================================\n// InMemoryTokenStore\n// ============================================================================\n\nclass InMemoryTokenStore implements SSETokenStore\n{\n private tokens = new Map<string, SSEToken>();\n\n async set(token: string, data: SSEToken): Promise<void>\n {\n this.tokens.set(token, data);\n }\n\n async consume(token: string): Promise<SSEToken | null>\n {\n const data = this.tokens.get(token);\n if (!data)\n {\n return null;\n }\n\n this.tokens.delete(token);\n return data;\n }\n\n async cleanup(): Promise<void>\n {\n const now = Date.now();\n\n for (const [token, data] of this.tokens)\n {\n if (data.expiresAt <= now)\n {\n this.tokens.delete(token);\n }\n }\n }\n}\n\n// ============================================================================\n// CacheTokenStore (Redis/Valkey)\n// ============================================================================\n\n/**\n * Redis/Valkey-backed token store for multi-instance deployments.\n *\n * Uses SET EX for automatic TTL expiry and GETDEL for atomic one-time consumption.\n * No cleanup needed — Redis handles expiration automatically.\n *\n * @example\n * ```typescript\n * import { getCache } from '@spfn/core/cache';\n *\n * const cache = getCache();\n * if (cache) {\n * const store = new CacheTokenStore(cache);\n * const manager = new SSETokenManager({ store });\n * }\n * ```\n */\nexport class CacheTokenStore implements SSETokenStore\n{\n private prefix = 'sse:token:';\n\n constructor(private cache: CacheClient) {}\n\n async set(token: string, data: SSEToken): Promise<void>\n {\n const ttlSeconds = Math.max(1, Math.ceil((data.expiresAt - Date.now()) / 1000));\n await this.cache.set(\n this.prefix + token,\n JSON.stringify(data),\n 'EX',\n ttlSeconds,\n );\n }\n\n async consume(token: string): Promise<SSEToken | null>\n {\n const key = this.prefix + token;\n\n // GETDEL (Redis 6.2+) for atomic consume, fallback to GET+DEL\n let raw: string | null = null;\n\n if (this.cache.getdel)\n {\n raw = await this.cache.getdel(key);\n }\n else\n {\n raw = await this.cache.get(key);\n if (raw)\n {\n await this.cache.del(key);\n }\n }\n\n if (!raw)\n {\n return null;\n }\n\n return JSON.parse(raw) as SSEToken;\n }\n\n async cleanup(): Promise<void>\n {\n // No-op: Redis TTL handles expiration automatically\n }\n}\n\n// ============================================================================\n// SSETokenManager\n// ============================================================================\n\nexport class SSETokenManager\n{\n private store: SSETokenStore;\n private ttl: number;\n private cleanupTimer: ReturnType<typeof setInterval> | null = null;\n\n constructor(config?: SSETokenManagerConfig)\n {\n this.ttl = config?.ttl ?? 30000;\n this.store = config?.store ?? new InMemoryTokenStore();\n\n const cleanupInterval = config?.cleanupInterval ?? 60000;\n this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);\n this.cleanupTimer.unref();\n }\n\n /**\n * Issue a new one-time-use token for the given subject\n */\n async issue(subject: string): Promise<string>\n {\n const token = randomBytes(32).toString('hex');\n\n await this.store.set(token, {\n token,\n subject,\n expiresAt: Date.now() + this.ttl,\n });\n\n return token;\n }\n\n /**\n * Verify and consume a token\n * @returns subject string if valid, null if invalid/expired/already consumed\n */\n async verify(token: string): Promise<string | null>\n {\n const data = await this.store.consume(token);\n\n if (!data || data.expiresAt <= Date.now())\n {\n return null;\n }\n\n return data.subject;\n }\n\n /**\n * Cleanup timer and resources\n */\n destroy(): void\n {\n if (this.cleanupTimer)\n {\n clearInterval(this.cleanupTimer);\n this.cleanupTimer = null;\n }\n }\n}\n"]}
|
package/dist/job/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as JobOptions, b as JobHandler, c as JobDef, d as JobRouterEntry, J as JobRouter } from '../boss-
|
|
2
|
-
export {
|
|
1
|
+
import { a as JobOptions, C as CompensateHandler, b as JobHandler, c as JobDef, d as JobRouterEntry, J as JobRouter } from '../boss-DI1r4kTS.js';
|
|
2
|
+
export { k as BossConfig, B as BossOptions, I as InferJobInput, f as InferJobOutput, e as JobSendOptions, g as getBoss, i as initBoss, h as isBossRunning, j as shouldClearOnStart, s as stopBoss } from '../boss-DI1r4kTS.js';
|
|
3
3
|
import * as _sinclair_typebox from '@sinclair/typebox';
|
|
4
4
|
import { Static } from '@sinclair/typebox';
|
|
5
5
|
import { EventDef, InferEventPayload } from '@spfn/core/event';
|
|
@@ -8,20 +8,26 @@ import 'pg-boss';
|
|
|
8
8
|
/**
|
|
9
9
|
* Job builder class with fluent API
|
|
10
10
|
*/
|
|
11
|
-
declare class JobBuilder<TInput = void> {
|
|
12
|
-
private _name;
|
|
11
|
+
declare class JobBuilder<TInput = void, TOutput = void> {
|
|
12
|
+
private readonly _name;
|
|
13
13
|
private _inputSchema?;
|
|
14
|
+
private _outputSchema?;
|
|
14
15
|
private _cronExpression?;
|
|
15
16
|
private _runOnce?;
|
|
16
17
|
private _subscribedEvent?;
|
|
17
18
|
private _subscribedEventDef?;
|
|
18
19
|
private _options?;
|
|
19
20
|
private _handler?;
|
|
21
|
+
private _compensate?;
|
|
20
22
|
constructor(name: string);
|
|
21
23
|
/**
|
|
22
24
|
* Define input schema with TypeBox
|
|
23
25
|
*/
|
|
24
|
-
input<TSchema extends _sinclair_typebox.TSchema>(schema: TSchema): JobBuilder<Static<TSchema
|
|
26
|
+
input<TSchema extends _sinclair_typebox.TSchema>(schema: TSchema): JobBuilder<Static<TSchema>, TOutput>;
|
|
27
|
+
/**
|
|
28
|
+
* Define output schema with TypeBox (for workflow integration)
|
|
29
|
+
*/
|
|
30
|
+
output<TSchema extends _sinclair_typebox.TSchema>(schema: TSchema): JobBuilder<TInput, Static<TSchema>>;
|
|
25
31
|
/**
|
|
26
32
|
* Subscribe to an event (decoupled triggering)
|
|
27
33
|
*
|
|
@@ -38,7 +44,7 @@ declare class JobBuilder<TInput = void> {
|
|
|
38
44
|
* });
|
|
39
45
|
* ```
|
|
40
46
|
*/
|
|
41
|
-
on<TEvent extends EventDef<any>>(event: TEvent): JobBuilder<InferEventPayload<TEvent
|
|
47
|
+
on<TEvent extends EventDef<any>>(event: TEvent): JobBuilder<InferEventPayload<TEvent>, TOutput>;
|
|
42
48
|
/**
|
|
43
49
|
* Set cron expression for scheduled execution
|
|
44
50
|
*/
|
|
@@ -51,10 +57,19 @@ declare class JobBuilder<TInput = void> {
|
|
|
51
57
|
* Set job options (retry, expiration, etc.)
|
|
52
58
|
*/
|
|
53
59
|
options(options: JobOptions): this;
|
|
60
|
+
/**
|
|
61
|
+
* Set job timeout in milliseconds
|
|
62
|
+
* (Converts to expireInSeconds for pg-boss)
|
|
63
|
+
*/
|
|
64
|
+
timeout(ms: number): this;
|
|
65
|
+
/**
|
|
66
|
+
* Define compensate handler for rollback (workflow integration)
|
|
67
|
+
*/
|
|
68
|
+
compensate(fn: CompensateHandler<TInput, TOutput>): this;
|
|
54
69
|
/**
|
|
55
70
|
* Define the job handler and finalize the job definition
|
|
56
71
|
*/
|
|
57
|
-
handler(fn: JobHandler<TInput>): JobDef<TInput>;
|
|
72
|
+
handler(fn: JobHandler<TInput, TOutput>): JobDef<TInput, TOutput>;
|
|
58
73
|
}
|
|
59
74
|
/**
|
|
60
75
|
* Create a new job definition
|
|
@@ -200,4 +215,4 @@ declare function collectJobs(router: JobRouter<any>, prefix?: string): JobDef<an
|
|
|
200
215
|
*/
|
|
201
216
|
declare function registerJobs(router: JobRouter<any>): Promise<void>;
|
|
202
217
|
|
|
203
|
-
export { JobDef, JobHandler, JobOptions, JobRouter, JobRouterEntry, collectJobs, defineJobRouter, isJobDef, isJobRouter, job, registerJobs };
|
|
218
|
+
export { CompensateHandler, JobDef, JobHandler, JobOptions, JobRouter, JobRouterEntry, collectJobs, defineJobRouter, isJobDef, isJobRouter, job, registerJobs };
|
package/dist/job/index.js
CHANGED
|
@@ -3,55 +3,91 @@ import { logger } from '@spfn/core/logger';
|
|
|
3
3
|
|
|
4
4
|
// src/job/boss.ts
|
|
5
5
|
var jobLogger = logger.child("@spfn/core:job");
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
function requiresSSLWithoutVerification(connectionString) {
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(connectionString);
|
|
9
|
+
const sslmode = url.searchParams.get("sslmode");
|
|
10
|
+
return sslmode === "require" || sslmode === "prefer";
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function stripSslModeFromUrl(connectionString) {
|
|
16
|
+
const url = new URL(connectionString);
|
|
17
|
+
url.searchParams.delete("sslmode");
|
|
18
|
+
return url.toString();
|
|
19
|
+
}
|
|
20
|
+
var BOSS_KEY = Symbol.for("spfn:boss-instance");
|
|
21
|
+
var CONFIG_KEY = Symbol.for("spfn:boss-config");
|
|
22
|
+
var g = globalThis;
|
|
23
|
+
function getBossInstance() {
|
|
24
|
+
return g[BOSS_KEY] ?? null;
|
|
25
|
+
}
|
|
26
|
+
function setBossInstance(instance) {
|
|
27
|
+
g[BOSS_KEY] = instance;
|
|
28
|
+
}
|
|
29
|
+
function getBossConfig() {
|
|
30
|
+
return g[CONFIG_KEY] ?? null;
|
|
31
|
+
}
|
|
32
|
+
function setBossConfig(config) {
|
|
33
|
+
g[CONFIG_KEY] = config;
|
|
34
|
+
}
|
|
8
35
|
async function initBoss(options) {
|
|
9
|
-
|
|
36
|
+
const existing = getBossInstance();
|
|
37
|
+
if (existing) {
|
|
10
38
|
jobLogger.warn("pg-boss already initialized, returning existing instance");
|
|
11
|
-
return
|
|
39
|
+
return existing;
|
|
12
40
|
}
|
|
13
41
|
jobLogger.info("Initializing pg-boss...");
|
|
14
|
-
|
|
42
|
+
setBossConfig(options);
|
|
43
|
+
const needsSSL = requiresSSLWithoutVerification(options.connectionString);
|
|
15
44
|
const pgBossOptions = {
|
|
16
|
-
|
|
45
|
+
// pg 드라이버가 URL의 sslmode=require를 verify-full로 해석해서
|
|
46
|
+
// ssl 옵션을 무시하므로, URL에서 sslmode를 빼고 ssl 객체만 전달
|
|
47
|
+
connectionString: needsSSL ? stripSslModeFromUrl(options.connectionString) : options.connectionString,
|
|
17
48
|
schema: options.schema ?? "spfn_queue",
|
|
18
49
|
maintenanceIntervalSeconds: options.maintenanceIntervalSeconds ?? 120
|
|
19
50
|
};
|
|
51
|
+
if (needsSSL) {
|
|
52
|
+
pgBossOptions.ssl = { rejectUnauthorized: false };
|
|
53
|
+
}
|
|
20
54
|
if (options.monitorIntervalSeconds !== void 0 && options.monitorIntervalSeconds >= 1) {
|
|
21
55
|
pgBossOptions.monitorIntervalSeconds = options.monitorIntervalSeconds;
|
|
22
56
|
}
|
|
23
|
-
|
|
24
|
-
|
|
57
|
+
const boss = new PgBoss(pgBossOptions);
|
|
58
|
+
boss.on("error", (error) => {
|
|
25
59
|
jobLogger.error("pg-boss error:", error);
|
|
26
60
|
});
|
|
27
|
-
await
|
|
61
|
+
await boss.start();
|
|
62
|
+
setBossInstance(boss);
|
|
28
63
|
jobLogger.info("pg-boss started successfully");
|
|
29
|
-
return
|
|
64
|
+
return boss;
|
|
30
65
|
}
|
|
31
66
|
function getBoss() {
|
|
32
|
-
return
|
|
67
|
+
return getBossInstance();
|
|
33
68
|
}
|
|
34
69
|
async function stopBoss() {
|
|
35
|
-
|
|
70
|
+
const boss = getBossInstance();
|
|
71
|
+
if (!boss) {
|
|
36
72
|
return;
|
|
37
73
|
}
|
|
38
74
|
jobLogger.info("Stopping pg-boss...");
|
|
39
75
|
try {
|
|
40
|
-
await
|
|
76
|
+
await boss.stop({ graceful: true, timeout: 3e4 });
|
|
41
77
|
jobLogger.info("pg-boss stopped gracefully");
|
|
42
78
|
} catch (error) {
|
|
43
79
|
jobLogger.error("Error stopping pg-boss:", error);
|
|
44
80
|
throw error;
|
|
45
81
|
} finally {
|
|
46
|
-
|
|
47
|
-
|
|
82
|
+
setBossInstance(null);
|
|
83
|
+
setBossConfig(null);
|
|
48
84
|
}
|
|
49
85
|
}
|
|
50
86
|
function isBossRunning() {
|
|
51
|
-
return
|
|
87
|
+
return getBossInstance() !== null;
|
|
52
88
|
}
|
|
53
89
|
function shouldClearOnStart() {
|
|
54
|
-
return
|
|
90
|
+
return getBossConfig()?.clearOnStart ?? false;
|
|
55
91
|
}
|
|
56
92
|
|
|
57
93
|
// src/job/job-builder.ts
|
|
@@ -89,12 +125,14 @@ function buildPgBossOptions(defaults, sendOptions) {
|
|
|
89
125
|
var JobBuilder = class _JobBuilder {
|
|
90
126
|
_name;
|
|
91
127
|
_inputSchema;
|
|
128
|
+
_outputSchema;
|
|
92
129
|
_cronExpression;
|
|
93
130
|
_runOnce;
|
|
94
131
|
_subscribedEvent;
|
|
95
132
|
_subscribedEventDef;
|
|
96
133
|
_options;
|
|
97
134
|
_handler;
|
|
135
|
+
_compensate;
|
|
98
136
|
constructor(name) {
|
|
99
137
|
this._name = name;
|
|
100
138
|
}
|
|
@@ -104,6 +142,20 @@ var JobBuilder = class _JobBuilder {
|
|
|
104
142
|
input(schema) {
|
|
105
143
|
const builder = new _JobBuilder(this._name);
|
|
106
144
|
builder._inputSchema = schema;
|
|
145
|
+
builder._outputSchema = this._outputSchema;
|
|
146
|
+
builder._cronExpression = this._cronExpression;
|
|
147
|
+
builder._runOnce = this._runOnce;
|
|
148
|
+
builder._subscribedEvent = this._subscribedEvent;
|
|
149
|
+
builder._options = this._options;
|
|
150
|
+
return builder;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Define output schema with TypeBox (for workflow integration)
|
|
154
|
+
*/
|
|
155
|
+
output(schema) {
|
|
156
|
+
const builder = new _JobBuilder(this._name);
|
|
157
|
+
builder._inputSchema = this._inputSchema;
|
|
158
|
+
builder._outputSchema = schema;
|
|
107
159
|
builder._cronExpression = this._cronExpression;
|
|
108
160
|
builder._runOnce = this._runOnce;
|
|
109
161
|
builder._subscribedEvent = this._subscribedEvent;
|
|
@@ -129,6 +181,7 @@ var JobBuilder = class _JobBuilder {
|
|
|
129
181
|
on(event) {
|
|
130
182
|
const builder = new _JobBuilder(this._name);
|
|
131
183
|
builder._inputSchema = event.schema;
|
|
184
|
+
builder._outputSchema = this._outputSchema;
|
|
132
185
|
builder._subscribedEvent = event.name;
|
|
133
186
|
builder._subscribedEventDef = event;
|
|
134
187
|
builder._cronExpression = this._cronExpression;
|
|
@@ -157,6 +210,24 @@ var JobBuilder = class _JobBuilder {
|
|
|
157
210
|
this._options = options;
|
|
158
211
|
return this;
|
|
159
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Set job timeout in milliseconds
|
|
215
|
+
* (Converts to expireInSeconds for pg-boss)
|
|
216
|
+
*/
|
|
217
|
+
timeout(ms) {
|
|
218
|
+
this._options = {
|
|
219
|
+
...this._options,
|
|
220
|
+
expireInSeconds: Math.ceil(ms / 1e3)
|
|
221
|
+
};
|
|
222
|
+
return this;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Define compensate handler for rollback (workflow integration)
|
|
226
|
+
*/
|
|
227
|
+
compensate(fn) {
|
|
228
|
+
this._compensate = fn;
|
|
229
|
+
return this;
|
|
230
|
+
}
|
|
160
231
|
/**
|
|
161
232
|
* Define the job handler and finalize the job definition
|
|
162
233
|
*/
|
|
@@ -164,12 +235,14 @@ var JobBuilder = class _JobBuilder {
|
|
|
164
235
|
this._handler = fn;
|
|
165
236
|
const name = this._name;
|
|
166
237
|
const inputSchema = this._inputSchema;
|
|
238
|
+
const outputSchema = this._outputSchema;
|
|
167
239
|
const cronExpression = this._cronExpression;
|
|
168
240
|
const runOnce = this._runOnce;
|
|
169
241
|
const subscribedEvent = this._subscribedEvent;
|
|
170
242
|
const subscribedEventDef = this._subscribedEventDef;
|
|
171
243
|
const options = this._options;
|
|
172
244
|
const handler = this._handler;
|
|
245
|
+
const compensate = this._compensate;
|
|
173
246
|
const send = async (inputOrOptions, maybeOptions) => {
|
|
174
247
|
const boss = getBoss();
|
|
175
248
|
if (!boss) {
|
|
@@ -186,23 +259,26 @@ var JobBuilder = class _JobBuilder {
|
|
|
186
259
|
};
|
|
187
260
|
const run = async (input) => {
|
|
188
261
|
if (inputSchema) {
|
|
189
|
-
await handler(input);
|
|
262
|
+
return await handler(input);
|
|
190
263
|
} else {
|
|
191
|
-
await handler();
|
|
264
|
+
return await handler();
|
|
192
265
|
}
|
|
193
266
|
};
|
|
194
267
|
return {
|
|
195
268
|
name,
|
|
196
269
|
inputSchema,
|
|
270
|
+
outputSchema,
|
|
197
271
|
cronExpression,
|
|
198
272
|
runOnce,
|
|
199
273
|
subscribedEvent,
|
|
200
274
|
_subscribedEventDef: subscribedEventDef,
|
|
201
275
|
options,
|
|
202
276
|
handler,
|
|
277
|
+
compensate,
|
|
203
278
|
send,
|
|
204
279
|
run,
|
|
205
|
-
_input: void 0
|
|
280
|
+
_input: void 0,
|
|
281
|
+
_output: void 0
|
|
206
282
|
};
|
|
207
283
|
}
|
|
208
284
|
};
|