@carlos-tzin/tzin 0.1.7 → 0.1.9
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/dist/auth.d.ts +76 -0
- package/dist/auth.js +177 -0
- package/dist/jobs.d.ts +104 -0
- package/dist/jobs.js +202 -0
- package/package.json +9 -1
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Middleware } from './middleware.js';
|
|
2
|
+
export interface AuthUser {
|
|
3
|
+
id: string;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface AuthConfig {
|
|
7
|
+
/** Secret key for JWT signing/verification */
|
|
8
|
+
secret: string;
|
|
9
|
+
/** Token issuer (optional) */
|
|
10
|
+
issuer?: string;
|
|
11
|
+
/** Token audience (optional) */
|
|
12
|
+
audience?: string;
|
|
13
|
+
/** Token expiration (default: '1h') */
|
|
14
|
+
expiresIn?: string;
|
|
15
|
+
/** Custom token extractor (default: Bearer header) */
|
|
16
|
+
extractToken?: (req: Request) => string | null;
|
|
17
|
+
/** Called when auth fails */
|
|
18
|
+
onUnauthorized?: (req: Request, reason: string) => Response | Promise<Response>;
|
|
19
|
+
}
|
|
20
|
+
export interface JwtPayload {
|
|
21
|
+
sub: string;
|
|
22
|
+
iat: number;
|
|
23
|
+
exp: number;
|
|
24
|
+
iss?: string;
|
|
25
|
+
aud?: string;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
/** Typed key for the authenticated user */
|
|
29
|
+
export declare const AUTH_USER: import("./context.js").ContextKey<AuthUser>;
|
|
30
|
+
/**
|
|
31
|
+
* Sign a JWT token.
|
|
32
|
+
*/
|
|
33
|
+
export declare function signJwt(payload: Record<string, unknown>, secret: string, options?: {
|
|
34
|
+
expiresIn?: string;
|
|
35
|
+
issuer?: string;
|
|
36
|
+
audience?: string;
|
|
37
|
+
}): string;
|
|
38
|
+
/**
|
|
39
|
+
* Verify a JWT token.
|
|
40
|
+
*/
|
|
41
|
+
export declare function verifyJwt(token: string, secret: string, options?: {
|
|
42
|
+
issuer?: string;
|
|
43
|
+
audience?: string;
|
|
44
|
+
}): JwtPayload;
|
|
45
|
+
/**
|
|
46
|
+
* Bearer token authentication middleware.
|
|
47
|
+
*
|
|
48
|
+
* Validates the Authorization: Bearer <token> header and attaches
|
|
49
|
+
* the decoded user to ctx.set(AUTH_USER, user).
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* import { bearerAuth, AUTH_USER } from '@carlos-tzin/tzin/auth'
|
|
54
|
+
*
|
|
55
|
+
* const app = createApp(routes, {
|
|
56
|
+
* middleware: [bearerAuth({ secret: process.env.JWT_SECRET! })],
|
|
57
|
+
* })
|
|
58
|
+
*
|
|
59
|
+
* // In handler:
|
|
60
|
+
* const user = ctx.require(AUTH_USER)
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export declare function bearerAuth(config: AuthConfig): Middleware;
|
|
64
|
+
/**
|
|
65
|
+
* Optional auth middleware - doesn't fail if no token,
|
|
66
|
+
* but validates if present.
|
|
67
|
+
*/
|
|
68
|
+
export declare function optionalAuth(config: AuthConfig): Middleware;
|
|
69
|
+
/**
|
|
70
|
+
* API key authentication middleware.
|
|
71
|
+
* Checks for a header like X-API-Key.
|
|
72
|
+
*/
|
|
73
|
+
export declare function apiKeyAuth(config: {
|
|
74
|
+
key: string;
|
|
75
|
+
header?: string;
|
|
76
|
+
}): Middleware;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { defineContext } from './context.js';
|
|
2
|
+
// ── Context Key ──────────────────────────────────────────────────────
|
|
3
|
+
/** Typed key for the authenticated user */
|
|
4
|
+
export const AUTH_USER = defineContext('auth_user');
|
|
5
|
+
// ── JWT Utilities (minimal, no dependencies) ────────────────────────
|
|
6
|
+
function base64UrlEncode(data) {
|
|
7
|
+
return Buffer.from(data).toString('base64url');
|
|
8
|
+
}
|
|
9
|
+
function base64UrlDecode(data) {
|
|
10
|
+
return Buffer.from(data, 'base64url').toString();
|
|
11
|
+
}
|
|
12
|
+
function hmacSign(data, secret) {
|
|
13
|
+
const { createHmac } = require('node:crypto');
|
|
14
|
+
return createHmac('sha256', secret).update(data).digest('base64url');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Sign a JWT token.
|
|
18
|
+
*/
|
|
19
|
+
export function signJwt(payload, secret, options) {
|
|
20
|
+
const header = { alg: 'HS256', typ: 'JWT' };
|
|
21
|
+
const now = Math.floor(Date.now() / 1000);
|
|
22
|
+
const exp = options?.expiresIn ? parseDuration(options.expiresIn) : 3600;
|
|
23
|
+
const fullPayload = {
|
|
24
|
+
...payload,
|
|
25
|
+
iat: now,
|
|
26
|
+
exp: now + exp,
|
|
27
|
+
...(options?.issuer && { iss: options.issuer }),
|
|
28
|
+
...(options?.audience && { aud: options.audience }),
|
|
29
|
+
};
|
|
30
|
+
const headerEncoded = base64UrlEncode(JSON.stringify(header));
|
|
31
|
+
const payloadEncoded = base64UrlEncode(JSON.stringify(fullPayload));
|
|
32
|
+
const signature = hmacSign(`${headerEncoded}.${payloadEncoded}`, secret);
|
|
33
|
+
return `${headerEncoded}.${payloadEncoded}.${signature}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Verify a JWT token.
|
|
37
|
+
*/
|
|
38
|
+
export function verifyJwt(token, secret, options) {
|
|
39
|
+
const parts = token.split('.');
|
|
40
|
+
if (parts.length !== 3)
|
|
41
|
+
throw new Error('Invalid JWT format');
|
|
42
|
+
const [headerEncoded, payloadEncoded, signature] = parts;
|
|
43
|
+
const expectedSig = hmacSign(`${headerEncoded}.${payloadEncoded}`, secret);
|
|
44
|
+
if (signature !== expectedSig)
|
|
45
|
+
throw new Error('Invalid JWT signature');
|
|
46
|
+
const payload = JSON.parse(base64UrlDecode(payloadEncoded));
|
|
47
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
|
48
|
+
throw new Error('Token expired');
|
|
49
|
+
}
|
|
50
|
+
if (options?.issuer && payload.iss !== options.issuer) {
|
|
51
|
+
throw new Error('Invalid issuer');
|
|
52
|
+
}
|
|
53
|
+
if (options?.audience && payload.aud !== options.audience) {
|
|
54
|
+
throw new Error('Invalid audience');
|
|
55
|
+
}
|
|
56
|
+
return payload;
|
|
57
|
+
}
|
|
58
|
+
function parseDuration(str) {
|
|
59
|
+
const match = str.match(/^(\d+)([smhd])$/);
|
|
60
|
+
if (!match)
|
|
61
|
+
return 3600;
|
|
62
|
+
const [, num, unit] = match;
|
|
63
|
+
const n = parseInt(num, 10);
|
|
64
|
+
switch (unit) {
|
|
65
|
+
case 's': return n;
|
|
66
|
+
case 'm': return n * 60;
|
|
67
|
+
case 'h': return n * 3600;
|
|
68
|
+
case 'd': return n * 86400;
|
|
69
|
+
default: return 3600;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// ── Middleware ────────────────────────────────────────────────────────
|
|
73
|
+
/**
|
|
74
|
+
* Bearer token authentication middleware.
|
|
75
|
+
*
|
|
76
|
+
* Validates the Authorization: Bearer <token> header and attaches
|
|
77
|
+
* the decoded user to ctx.set(AUTH_USER, user).
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* import { bearerAuth, AUTH_USER } from '@carlos-tzin/tzin/auth'
|
|
82
|
+
*
|
|
83
|
+
* const app = createApp(routes, {
|
|
84
|
+
* middleware: [bearerAuth({ secret: process.env.JWT_SECRET! })],
|
|
85
|
+
* })
|
|
86
|
+
*
|
|
87
|
+
* // In handler:
|
|
88
|
+
* const user = ctx.require(AUTH_USER)
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export function bearerAuth(config) {
|
|
92
|
+
const extract = config.extractToken ?? defaultExtractToken;
|
|
93
|
+
const onUnauthorized = config.onUnauthorized ?? defaultUnauthorized;
|
|
94
|
+
return async ({ req, ctx, next }) => {
|
|
95
|
+
const token = extract(req);
|
|
96
|
+
if (!token) {
|
|
97
|
+
return onUnauthorized(req, 'Missing token');
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const payload = verifyJwt(token, config.secret, {
|
|
101
|
+
issuer: config.issuer,
|
|
102
|
+
audience: config.audience,
|
|
103
|
+
});
|
|
104
|
+
const user = {
|
|
105
|
+
id: payload.sub,
|
|
106
|
+
...payload,
|
|
107
|
+
};
|
|
108
|
+
ctx.set(AUTH_USER, user);
|
|
109
|
+
return next();
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
const reason = err instanceof Error ? err.message : 'Invalid token';
|
|
113
|
+
return onUnauthorized(req, reason);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Optional auth middleware - doesn't fail if no token,
|
|
119
|
+
* but validates if present.
|
|
120
|
+
*/
|
|
121
|
+
export function optionalAuth(config) {
|
|
122
|
+
const extract = config.extractToken ?? defaultExtractToken;
|
|
123
|
+
return async ({ req, ctx, next }) => {
|
|
124
|
+
const token = extract(req);
|
|
125
|
+
if (!token) {
|
|
126
|
+
return next();
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const payload = verifyJwt(token, config.secret, {
|
|
130
|
+
issuer: config.issuer,
|
|
131
|
+
audience: config.audience,
|
|
132
|
+
});
|
|
133
|
+
const user = {
|
|
134
|
+
id: payload.sub,
|
|
135
|
+
...payload,
|
|
136
|
+
};
|
|
137
|
+
ctx.set(AUTH_USER, user);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Ignore invalid tokens in optional mode
|
|
141
|
+
}
|
|
142
|
+
return next();
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* API key authentication middleware.
|
|
147
|
+
* Checks for a header like X-API-Key.
|
|
148
|
+
*/
|
|
149
|
+
export function apiKeyAuth(config) {
|
|
150
|
+
const headerName = config.header ?? 'x-api-key';
|
|
151
|
+
return async ({ req, next }) => {
|
|
152
|
+
const apiKey = req.headers.get(headerName);
|
|
153
|
+
if (!apiKey || apiKey !== config.key) {
|
|
154
|
+
return new Response(JSON.stringify({ error: 'Invalid API key' }), {
|
|
155
|
+
status: 401,
|
|
156
|
+
headers: { 'content-type': 'application/json' },
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return next();
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
163
|
+
function defaultExtractToken(req) {
|
|
164
|
+
const auth = req.headers.get('authorization');
|
|
165
|
+
if (!auth)
|
|
166
|
+
return null;
|
|
167
|
+
const parts = auth.split(' ');
|
|
168
|
+
if (parts.length !== 2 || parts[0] !== 'Bearer')
|
|
169
|
+
return null;
|
|
170
|
+
return parts[1];
|
|
171
|
+
}
|
|
172
|
+
function defaultUnauthorized(_req, reason) {
|
|
173
|
+
return new Response(JSON.stringify({ error: reason }), {
|
|
174
|
+
status: 401,
|
|
175
|
+
headers: { 'content-type': 'application/json' },
|
|
176
|
+
});
|
|
177
|
+
}
|
package/dist/jobs.d.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export interface JobConfig {
|
|
2
|
+
/** Unique job name */
|
|
3
|
+
name: string;
|
|
4
|
+
/** Max retries on failure (default: 3) */
|
|
5
|
+
maxRetries?: number;
|
|
6
|
+
/** Delay between retries in ms (default: 1000) */
|
|
7
|
+
retryDelay?: number;
|
|
8
|
+
/** Job timeout in ms (default: 30000) */
|
|
9
|
+
timeout?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface JobDefinition<Payload = unknown> {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly config: JobConfig;
|
|
14
|
+
/** Process the job */
|
|
15
|
+
handler: (payload: Payload, ctx: JobContext) => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export interface JobContext {
|
|
18
|
+
/** Job attempt number (0-based) */
|
|
19
|
+
attempt: number;
|
|
20
|
+
/** Abort signal for cancellation */
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
/** Logger scoped to this job */
|
|
23
|
+
log: JobLogger;
|
|
24
|
+
}
|
|
25
|
+
export interface JobLogger {
|
|
26
|
+
info(message: string, data?: Record<string, unknown>): void;
|
|
27
|
+
warn(message: string, data?: Record<string, unknown>): void;
|
|
28
|
+
error(message: string, data?: Record<string, unknown>): void;
|
|
29
|
+
}
|
|
30
|
+
export interface Job<Payload = unknown> {
|
|
31
|
+
/** Job name */
|
|
32
|
+
readonly name: string;
|
|
33
|
+
/** Job configuration */
|
|
34
|
+
readonly config: JobConfig;
|
|
35
|
+
/** Enqueue the job for processing */
|
|
36
|
+
enqueue(payload: Payload, options?: EnqueueOptions): Promise<JobHandle>;
|
|
37
|
+
}
|
|
38
|
+
export interface EnqueueOptions {
|
|
39
|
+
/** Delay before processing in ms */
|
|
40
|
+
delay?: number;
|
|
41
|
+
/** Schedule for a specific time */
|
|
42
|
+
scheduledAt?: Date;
|
|
43
|
+
}
|
|
44
|
+
export interface JobHandle {
|
|
45
|
+
/** Job ID */
|
|
46
|
+
id: string;
|
|
47
|
+
/** Poll for completion */
|
|
48
|
+
wait(): Promise<JobResult>;
|
|
49
|
+
}
|
|
50
|
+
export interface JobResult {
|
|
51
|
+
status: 'completed' | 'failed';
|
|
52
|
+
error?: string;
|
|
53
|
+
duration: number;
|
|
54
|
+
}
|
|
55
|
+
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed';
|
|
56
|
+
export interface JobRecord {
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
payload: unknown;
|
|
60
|
+
status: JobStatus;
|
|
61
|
+
attempt: number;
|
|
62
|
+
maxRetries: number;
|
|
63
|
+
error?: string;
|
|
64
|
+
createdAt: Date;
|
|
65
|
+
startedAt?: Date;
|
|
66
|
+
completedAt?: Date;
|
|
67
|
+
}
|
|
68
|
+
export interface JobStore {
|
|
69
|
+
add(record: JobRecord): void;
|
|
70
|
+
update(id: string, data: Partial<JobRecord>): void;
|
|
71
|
+
getById(id: string): JobRecord | null;
|
|
72
|
+
getPending(): JobRecord[];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Define a background job.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```ts
|
|
79
|
+
* import { defineJob } from '@carlos-tzin/tzin/jobs'
|
|
80
|
+
*
|
|
81
|
+
* const sendEmail = defineJob<{ to: string; subject: string; body: string }>({
|
|
82
|
+
* name: 'send-email',
|
|
83
|
+
* maxRetries: 3,
|
|
84
|
+
* handler: async (payload, ctx) => {
|
|
85
|
+
* ctx.log.info('Sending email', { to: payload.to })
|
|
86
|
+
* await resend.emails.send({ ... })
|
|
87
|
+
* },
|
|
88
|
+
* })
|
|
89
|
+
*
|
|
90
|
+
* // In a handler:
|
|
91
|
+
* await sendEmail.enqueue({ to: 'ada@example.com', subject: 'Hello', body: '...' })
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export declare function defineJob<Payload = void>(config: JobConfig & {
|
|
95
|
+
handler: (payload: Payload, ctx: JobContext) => Promise<void>;
|
|
96
|
+
}): Job<Payload>;
|
|
97
|
+
/**
|
|
98
|
+
* Get all job records (useful for debugging).
|
|
99
|
+
*/
|
|
100
|
+
export declare function getJobRecords(): JobRecord[];
|
|
101
|
+
/**
|
|
102
|
+
* Reset the job store (useful for testing).
|
|
103
|
+
*/
|
|
104
|
+
export declare function resetJobs(): void;
|
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
2
|
+
class MemoryJobStore {
|
|
3
|
+
records = new Map();
|
|
4
|
+
add(record) {
|
|
5
|
+
this.records.set(record.id, { ...record });
|
|
6
|
+
}
|
|
7
|
+
update(id, data) {
|
|
8
|
+
const record = this.records.get(id);
|
|
9
|
+
if (record)
|
|
10
|
+
Object.assign(record, data);
|
|
11
|
+
}
|
|
12
|
+
getById(id) {
|
|
13
|
+
const record = this.records.get(id);
|
|
14
|
+
return record ? { ...record } : null;
|
|
15
|
+
}
|
|
16
|
+
getPending() {
|
|
17
|
+
return [...this.records.values()].filter((r) => r.status === 'pending');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// ── Queue State ──────────────────────────────────────────────────────
|
|
21
|
+
let globalStore = new MemoryJobStore();
|
|
22
|
+
let processing = false;
|
|
23
|
+
let processTimer = null;
|
|
24
|
+
const handlers = new Map();
|
|
25
|
+
// ── defineJob ────────────────────────────────────────────────────────
|
|
26
|
+
/**
|
|
27
|
+
* Define a background job.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { defineJob } from '@carlos-tzin/tzin/jobs'
|
|
32
|
+
*
|
|
33
|
+
* const sendEmail = defineJob<{ to: string; subject: string; body: string }>({
|
|
34
|
+
* name: 'send-email',
|
|
35
|
+
* maxRetries: 3,
|
|
36
|
+
* handler: async (payload, ctx) => {
|
|
37
|
+
* ctx.log.info('Sending email', { to: payload.to })
|
|
38
|
+
* await resend.emails.send({ ... })
|
|
39
|
+
* },
|
|
40
|
+
* })
|
|
41
|
+
*
|
|
42
|
+
* // In a handler:
|
|
43
|
+
* await sendEmail.enqueue({ to: 'ada@example.com', subject: 'Hello', body: '...' })
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function defineJob(config) {
|
|
47
|
+
const jobConfig = {
|
|
48
|
+
maxRetries: 3,
|
|
49
|
+
retryDelay: 1000,
|
|
50
|
+
timeout: 30000,
|
|
51
|
+
...config,
|
|
52
|
+
};
|
|
53
|
+
handlers.set(config.name, config.handler);
|
|
54
|
+
return {
|
|
55
|
+
name: config.name,
|
|
56
|
+
config: jobConfig,
|
|
57
|
+
async enqueue(payload, options) {
|
|
58
|
+
const id = `${config.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
59
|
+
const record = {
|
|
60
|
+
id,
|
|
61
|
+
name: config.name,
|
|
62
|
+
payload,
|
|
63
|
+
status: 'pending',
|
|
64
|
+
attempt: 0,
|
|
65
|
+
maxRetries: jobConfig.maxRetries,
|
|
66
|
+
createdAt: new Date(),
|
|
67
|
+
...(options?.scheduledAt && { scheduledAt: options.scheduledAt }),
|
|
68
|
+
};
|
|
69
|
+
globalStore.add(record);
|
|
70
|
+
scheduleProcess();
|
|
71
|
+
return {
|
|
72
|
+
id,
|
|
73
|
+
async wait() {
|
|
74
|
+
return waitForJob(id, jobConfig.timeout * (jobConfig.maxRetries + 1));
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// ── Processing ───────────────────────────────────────────────────────
|
|
81
|
+
function scheduleProcess() {
|
|
82
|
+
if (processTimer)
|
|
83
|
+
return;
|
|
84
|
+
processTimer = setTimeout(async () => {
|
|
85
|
+
processTimer = null;
|
|
86
|
+
await processPending();
|
|
87
|
+
}, 10);
|
|
88
|
+
}
|
|
89
|
+
async function processPending() {
|
|
90
|
+
if (processing)
|
|
91
|
+
return;
|
|
92
|
+
processing = true;
|
|
93
|
+
try {
|
|
94
|
+
const pending = globalStore.getPending();
|
|
95
|
+
for (const record of pending) {
|
|
96
|
+
await processRecord(record);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
processing = false;
|
|
101
|
+
if (globalStore.getPending().length > 0) {
|
|
102
|
+
scheduleProcess();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function processRecord(record) {
|
|
107
|
+
const handler = handlers.get(record.name);
|
|
108
|
+
if (!handler) {
|
|
109
|
+
globalStore.update(record.id, { status: 'failed', error: 'No handler found' });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
globalStore.update(record.id, { status: 'running', startedAt: new Date() });
|
|
113
|
+
const controller = new AbortController();
|
|
114
|
+
const ctx = {
|
|
115
|
+
attempt: record.attempt,
|
|
116
|
+
signal: controller.signal,
|
|
117
|
+
log: createLogger(record.name),
|
|
118
|
+
};
|
|
119
|
+
try {
|
|
120
|
+
await Promise.race([
|
|
121
|
+
handler(record.payload, ctx),
|
|
122
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Job timeout')), 30000)),
|
|
123
|
+
]);
|
|
124
|
+
globalStore.update(record.id, {
|
|
125
|
+
status: 'completed',
|
|
126
|
+
completedAt: new Date(),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
131
|
+
const attempt = record.attempt + 1;
|
|
132
|
+
if (attempt < record.maxRetries) {
|
|
133
|
+
globalStore.update(record.id, { status: 'pending', attempt });
|
|
134
|
+
setTimeout(() => scheduleProcess(), 1000);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
globalStore.update(record.id, {
|
|
138
|
+
status: 'failed',
|
|
139
|
+
error,
|
|
140
|
+
attempt,
|
|
141
|
+
completedAt: new Date(),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function waitForJob(id, timeout) {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const start = Date.now();
|
|
149
|
+
const check = () => {
|
|
150
|
+
const record = globalStore.getById(id);
|
|
151
|
+
if (!record) {
|
|
152
|
+
reject(new Error('Job not found'));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (record.status === 'completed') {
|
|
156
|
+
resolve({
|
|
157
|
+
status: 'completed',
|
|
158
|
+
duration: (record.completedAt?.getTime() ?? Date.now()) - record.createdAt.getTime(),
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (record.status === 'failed') {
|
|
163
|
+
resolve({
|
|
164
|
+
status: 'failed',
|
|
165
|
+
error: record.error,
|
|
166
|
+
duration: (record.completedAt?.getTime() ?? Date.now()) - record.createdAt.getTime(),
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (Date.now() - start > timeout) {
|
|
171
|
+
reject(new Error('Timeout waiting for job'));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
setTimeout(check, 100);
|
|
175
|
+
};
|
|
176
|
+
check();
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
function createLogger(name) {
|
|
180
|
+
const prefix = `[job:${name}]`;
|
|
181
|
+
return {
|
|
182
|
+
info: (msg, data) => console.log(prefix, msg, data ?? ''),
|
|
183
|
+
warn: (msg, data) => console.warn(prefix, msg, data ?? ''),
|
|
184
|
+
error: (msg, data) => console.error(prefix, msg, data ?? ''),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// ── Utilities ────────────────────────────────────────────────────────
|
|
188
|
+
/**
|
|
189
|
+
* Get all job records (useful for debugging).
|
|
190
|
+
*/
|
|
191
|
+
export function getJobRecords() {
|
|
192
|
+
return globalStore['records']
|
|
193
|
+
? [...globalStore['records'].values()]
|
|
194
|
+
: [];
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Reset the job store (useful for testing).
|
|
198
|
+
*/
|
|
199
|
+
export function resetJobs() {
|
|
200
|
+
globalStore = new MemoryJobStore();
|
|
201
|
+
handlers.clear();
|
|
202
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carlos-tzin/tzin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Contract-first TypeScript framework. Types that scale, realtime channels with presence, and an MCP server for every API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "The tzin authors",
|
|
@@ -50,6 +50,14 @@
|
|
|
50
50
|
"./db": {
|
|
51
51
|
"types": "./dist/db.d.ts",
|
|
52
52
|
"default": "./dist/db.js"
|
|
53
|
+
},
|
|
54
|
+
"./auth": {
|
|
55
|
+
"types": "./dist/auth.d.ts",
|
|
56
|
+
"default": "./dist/auth.js"
|
|
57
|
+
},
|
|
58
|
+
"./jobs": {
|
|
59
|
+
"types": "./dist/jobs.d.ts",
|
|
60
|
+
"default": "./dist/jobs.js"
|
|
53
61
|
}
|
|
54
62
|
},
|
|
55
63
|
"files": [
|