@devmoods/express-extras 0.76.0 → 0.77.0
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/contentNegotiation.d.ts +2 -0
- package/dist/auth/contentNegotiation.js +9 -0
- package/dist/auth/google.js +2 -1
- package/dist/auth/passwordless.js +2 -1
- package/dist/auth/tokenStorage.d.ts +2 -2
- package/dist/auth/tokenStorage.js +4 -4
- package/dist/auth/types.d.ts +2 -1
- package/dist/auth.js +27 -20
- package/dist/cache.js +4 -4
- package/dist/channels.d.ts +26 -3
- package/dist/channels.js +231 -56
- package/dist/cli.js +1 -0
- package/dist/context.js +6 -3
- package/dist/email.d.ts +1 -1
- package/dist/faktory.d.ts +12 -25
- package/dist/faktory.js +39 -51
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/jobs.d.ts +52 -0
- package/dist/jobs.js +58 -0
- package/dist/listen.js +37 -6
- package/dist/rate-limit.d.ts +11 -3
- package/dist/rate-limit.js +52 -22
- package/dist/redis-jobs.d.ts +23 -0
- package/dist/redis-jobs.js +185 -0
- package/dist/routes.js +95 -60
- package/dist/streams.js +1 -1
- package/dist/validate.js +6 -6
- package/dist/vite/defineViteConfig.d.ts +0 -1
- package/dist/vite/ssr.d.ts +17 -1
- package/dist/vite/ssr.js +21 -2
- package/dist/vitest/matchers.d.ts +16 -5
- package/dist/vitest/matchers.js +66 -3
- package/dist/vitest/testClient.js +15 -3
- package/package.json +24 -24
package/dist/context.js
CHANGED
|
@@ -7,7 +7,7 @@ export const JOB_ID_CONTEXT_KEY = 'jobId';
|
|
|
7
7
|
/**
|
|
8
8
|
* Middleware for setting a `requestId` on the request context.
|
|
9
9
|
*/
|
|
10
|
-
export function withRequestContext({ getRequestId = (req) => req.
|
|
10
|
+
export function withRequestContext({ getRequestId = (req) => req.get('X-Request-ID'), } = {}) {
|
|
11
11
|
return (req, res, next) => {
|
|
12
12
|
const requestId = getRequestId(req) || randomUUID();
|
|
13
13
|
requestContext.run(new Map(), () => {
|
|
@@ -20,6 +20,9 @@ export function withRequestContext({ getRequestId = (req) => req.headers['X-Requ
|
|
|
20
20
|
* Return a value from the request context
|
|
21
21
|
*/
|
|
22
22
|
export function useRequestContext(key) {
|
|
23
|
+
return getRequestContextValue(key);
|
|
24
|
+
}
|
|
25
|
+
function getRequestContextValue(key) {
|
|
23
26
|
const store = requestContext.getStore();
|
|
24
27
|
if (store) {
|
|
25
28
|
return store.get(key);
|
|
@@ -36,8 +39,8 @@ export function setRequestContext(key, value) {
|
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
41
|
export function useRequestCounter(key) {
|
|
39
|
-
const value =
|
|
40
|
-
const increase = () => setRequestContext(key,
|
|
42
|
+
const value = getRequestContextValue(key) || 0;
|
|
43
|
+
const increase = () => setRequestContext(key, (getRequestContextValue(key) || 0) + 1);
|
|
41
44
|
return {
|
|
42
45
|
value,
|
|
43
46
|
increase,
|
package/dist/email.d.ts
CHANGED
|
@@ -13,5 +13,5 @@ export interface SendEmailOptions extends nodemailer.SendMailOptions {
|
|
|
13
13
|
}
|
|
14
14
|
export declare function createEmail({ host, port, user, pass, senderName, senderAddress, onEmailSent, }?: EmailOptions): {
|
|
15
15
|
send: ({ to, subject, message, ...rest }: SendEmailOptions) => Promise<void>;
|
|
16
|
-
transporter: nodemailer.
|
|
16
|
+
transporter: nodemailer.Mail<nodemailer.SMTPSentMessageInfo, nodemailer.SMTPTransportOptions>;
|
|
17
17
|
};
|
package/dist/faktory.d.ts
CHANGED
|
@@ -1,34 +1,21 @@
|
|
|
1
1
|
import { create, type WorkerOptions } from 'faktory-worker';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { type JobBackend, type JobSubmission, type JobWorkerOptions } from './jobs.js';
|
|
3
|
+
export { createJobs, createJobWorker, type AnyJobContext, type Job, type JobBackend, type JobOptions, type JobSubmission, type JobWorker, type JobWorkerOptions, type JobsOptions, } from './jobs.js';
|
|
4
|
+
export interface FaktoryJobBackendOptions {
|
|
4
5
|
faktory?: typeof create;
|
|
5
6
|
}
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
export declare class FaktoryJobBackend implements JobBackend {
|
|
8
|
+
private createFactory;
|
|
9
|
+
constructor({ faktory }?: FaktoryJobBackendOptions);
|
|
10
|
+
enqueue({ jid, jobName, args, queue, ...options }: JobSubmission): Promise<void>;
|
|
11
|
+
createWorker(options: JobWorkerOptions<any>): Promise<{
|
|
12
|
+
start(workerOptions?: WorkerOptions): Promise<void>;
|
|
13
|
+
stop(): Promise<void>;
|
|
14
|
+
}>;
|
|
12
15
|
}
|
|
13
|
-
|
|
14
|
-
export declare function createJobs<QueueName extends string, GlobalContext extends AnyJobContext>({ queues, faktory: createFactory }: JobsOptions<QueueName>): {
|
|
15
|
-
job: <Arguments>(optionsOrFn: JobOptions<QueueName> | JobFn<Arguments, GlobalContext>, fnOrUndefined?: JobFn<Arguments, GlobalContext>) => {
|
|
16
|
-
$job: boolean;
|
|
17
|
-
jobName: string;
|
|
18
|
-
options: JobOptions<QueueName>;
|
|
19
|
-
delay(args?: Parameters<JobFn<Arguments, GlobalContext>>[0]): Promise<`${string}-${string}-${string}-${string}-${string}`>;
|
|
20
|
-
execute(args: Parameters<JobFn<Arguments, GlobalContext>>[0], context: GlobalContext): Promise<any>;
|
|
21
|
-
};
|
|
22
|
-
queues: QueueName[];
|
|
23
|
-
};
|
|
24
|
-
export type Job = ReturnType<ReturnType<typeof createJobs>['job']>;
|
|
25
|
-
export interface FaktoryWorkerOptions<GlobalContext> {
|
|
26
|
-
jobs: unknown[];
|
|
27
|
-
queues: string[];
|
|
16
|
+
export interface FaktoryWorkerOptions<GlobalContext> extends JobWorkerOptions<GlobalContext> {
|
|
28
17
|
faktory?: typeof create;
|
|
29
|
-
jobContext?: GlobalContext;
|
|
30
18
|
}
|
|
31
|
-
export { createFaktoryWorker as createJobWorker };
|
|
32
19
|
export declare function createFaktoryWorker<GlobalContext>({ queues, jobs, faktory: createFactory, jobContext, }: FaktoryWorkerOptions<GlobalContext>): Promise<{
|
|
33
20
|
start(workerOptions?: WorkerOptions): Promise<void>;
|
|
34
21
|
stop(): Promise<void>;
|
package/dist/faktory.js
CHANGED
|
@@ -1,61 +1,56 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
1
|
import { startTimer } from '@devmoods/observability';
|
|
3
2
|
import { create } from 'faktory-worker';
|
|
4
3
|
import { JOB_ID_CONTEXT_KEY, requestContext, setRequestContext, } from './context.js';
|
|
4
|
+
import {} from './jobs.js';
|
|
5
5
|
import { getLogger } from './logger.js';
|
|
6
|
+
export { createJobs, createJobWorker, } from './jobs.js';
|
|
6
7
|
const logger = getLogger('faktory');
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
export class FaktoryJobBackend {
|
|
9
|
+
createFactory;
|
|
10
|
+
constructor({ faktory = create } = {}) {
|
|
11
|
+
this.createFactory = faktory;
|
|
12
|
+
}
|
|
13
|
+
async enqueue({ jid, jobName, args, queue, ...options }) {
|
|
14
|
+
const faktory = this.createFactory();
|
|
15
|
+
let client;
|
|
16
|
+
let enqueueError;
|
|
17
|
+
let closeError;
|
|
18
|
+
try {
|
|
19
|
+
client = await faktory.connect();
|
|
20
|
+
const job = client.job(jobName, args);
|
|
21
|
+
job.jid = jid;
|
|
22
|
+
job.queue = queue;
|
|
23
|
+
Object.assign(job, options);
|
|
24
|
+
await job.push();
|
|
14
25
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
throw new Error('Can not create job without a name');
|
|
26
|
+
catch (error) {
|
|
27
|
+
enqueueError = error;
|
|
18
28
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
jobName,
|
|
22
|
-
options,
|
|
23
|
-
async delay(args) {
|
|
24
|
-
const jid = randomUUID();
|
|
29
|
+
finally {
|
|
30
|
+
if (client) {
|
|
25
31
|
try {
|
|
26
|
-
const client = await faktory.connect();
|
|
27
|
-
const j = client.job(jobName, args);
|
|
28
|
-
j.jid = jid;
|
|
29
|
-
j.queue = options.queue || queues[0];
|
|
30
|
-
Object.assign(j, options);
|
|
31
|
-
await j.push();
|
|
32
32
|
await client.close();
|
|
33
33
|
}
|
|
34
34
|
catch (error) {
|
|
35
|
-
|
|
35
|
+
closeError = error;
|
|
36
|
+
logger.error(error, 'failed to close faktory client', {
|
|
36
37
|
jobId: jid,
|
|
37
38
|
jobName,
|
|
38
|
-
args: JSON.stringify(args),
|
|
39
39
|
});
|
|
40
|
-
throw error;
|
|
41
|
-
}
|
|
42
|
-
return jid;
|
|
43
|
-
},
|
|
44
|
-
async execute(args, context) {
|
|
45
|
-
try {
|
|
46
|
-
const result = await fn(args, context);
|
|
47
|
-
return result;
|
|
48
40
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (enqueueError) {
|
|
44
|
+
throw enqueueError;
|
|
45
|
+
}
|
|
46
|
+
if (closeError) {
|
|
47
|
+
throw closeError;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
createWorker(options) {
|
|
51
|
+
return createFaktoryWorker({ ...options, faktory: this.createFactory });
|
|
55
52
|
}
|
|
56
|
-
return { job, queues };
|
|
57
53
|
}
|
|
58
|
-
export { createFaktoryWorker as createJobWorker };
|
|
59
54
|
export async function createFaktoryWorker({ queues, jobs, faktory: createFactory = create, jobContext, }) {
|
|
60
55
|
const faktory = createFactory();
|
|
61
56
|
const filter = (j) => !!Object.prototype.hasOwnProperty.call(j, '$job');
|
|
@@ -83,25 +78,18 @@ export async function createFaktoryWorker({ queues, jobs, faktory: createFactory
|
|
|
83
78
|
faktory.use(async (ctx, next) => {
|
|
84
79
|
const jobId = ctx.job.jid;
|
|
85
80
|
const timer = startTimer();
|
|
86
|
-
logger.info('job started', {
|
|
87
|
-
jobName: ctx.job.jobtype,
|
|
88
|
-
jobId,
|
|
89
|
-
});
|
|
81
|
+
logger.info('job started', { jobName: ctx.job.jobtype, jobId });
|
|
90
82
|
await requestContext.run(new Map(), async () => {
|
|
91
83
|
setRequestContext(JOB_ID_CONTEXT_KEY, jobId);
|
|
92
84
|
await next();
|
|
93
85
|
});
|
|
94
|
-
const duration = timer.getDuration();
|
|
95
86
|
logger.info('job succeeded', {
|
|
96
87
|
jobName: ctx.job.jobtype,
|
|
97
88
|
jobId,
|
|
98
|
-
duration,
|
|
89
|
+
duration: timer.getDuration(),
|
|
99
90
|
});
|
|
100
91
|
});
|
|
101
|
-
await faktory.work({
|
|
102
|
-
queues,
|
|
103
|
-
...workerOptions,
|
|
104
|
-
});
|
|
92
|
+
await faktory.work({ queues, ...workerOptions });
|
|
105
93
|
}
|
|
106
94
|
catch (error) {
|
|
107
95
|
logger.error(error);
|
package/dist/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export * from './manage.js';
|
|
|
17
17
|
export * from './middleware.js';
|
|
18
18
|
export * from './rate-limit.js';
|
|
19
19
|
export * from './redis.js';
|
|
20
|
+
export * from './redis-jobs.js';
|
|
20
21
|
export * from './router.js';
|
|
21
22
|
export * from './routes.js';
|
|
22
23
|
export * from './slack.js';
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export * from './manage.js';
|
|
|
18
18
|
export * from './middleware.js';
|
|
19
19
|
export * from './rate-limit.js';
|
|
20
20
|
export * from './redis.js';
|
|
21
|
+
export * from './redis-jobs.js';
|
|
21
22
|
export * from './router.js';
|
|
22
23
|
export * from './routes.js';
|
|
23
24
|
export * from './slack.js';
|
package/dist/jobs.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type create } from 'faktory-worker';
|
|
2
|
+
export type AnyJobContext = Record<string, any>;
|
|
3
|
+
export interface JobOptions<QueueName extends string> {
|
|
4
|
+
retry?: number;
|
|
5
|
+
priority?: number;
|
|
6
|
+
at?: Date | string;
|
|
7
|
+
queue?: QueueName;
|
|
8
|
+
}
|
|
9
|
+
export interface JobSubmission {
|
|
10
|
+
jid: string;
|
|
11
|
+
jobName: string;
|
|
12
|
+
args: unknown;
|
|
13
|
+
queue: string;
|
|
14
|
+
retry?: number;
|
|
15
|
+
priority?: number;
|
|
16
|
+
at?: Date | string;
|
|
17
|
+
}
|
|
18
|
+
export interface JobWorker {
|
|
19
|
+
start(options?: any): Promise<void>;
|
|
20
|
+
stop(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export interface JobBackend {
|
|
23
|
+
enqueue(job: JobSubmission): Promise<void>;
|
|
24
|
+
createWorker(options: JobWorkerOptions<any>): Promise<JobWorker> | JobWorker;
|
|
25
|
+
}
|
|
26
|
+
export interface JobWorkerOptions<GlobalContext> {
|
|
27
|
+
jobs: unknown[];
|
|
28
|
+
queues: string[];
|
|
29
|
+
jobContext?: GlobalContext;
|
|
30
|
+
backend?: JobBackend;
|
|
31
|
+
}
|
|
32
|
+
export interface JobsOptions<QueueName extends string> {
|
|
33
|
+
queues: QueueName[];
|
|
34
|
+
backend?: JobBackend;
|
|
35
|
+
/** @deprecated Pass `new FaktoryJobBackend({ faktory })` as backend. */
|
|
36
|
+
faktory?: typeof create;
|
|
37
|
+
}
|
|
38
|
+
type JobFn<Arguments, Context extends AnyJobContext> = (input: Arguments, ctx: Context) => undefined | Promise<any>;
|
|
39
|
+
export declare function createJobs<QueueName extends string, GlobalContext extends AnyJobContext>({ queues, backend, faktory }: JobsOptions<QueueName>): {
|
|
40
|
+
job: <Arguments>(optionsOrFn: JobOptions<QueueName> | JobFn<Arguments, GlobalContext>, fnOrUndefined?: JobFn<Arguments, GlobalContext>) => {
|
|
41
|
+
$job: boolean;
|
|
42
|
+
jobName: string;
|
|
43
|
+
options: JobOptions<QueueName>;
|
|
44
|
+
delay(args?: Arguments): Promise<`${string}-${string}-${string}-${string}-${string}`>;
|
|
45
|
+
execute(args: Arguments, context: GlobalContext): Promise<any>;
|
|
46
|
+
};
|
|
47
|
+
queues: QueueName[];
|
|
48
|
+
backend: JobBackend;
|
|
49
|
+
};
|
|
50
|
+
export type Job = ReturnType<ReturnType<typeof createJobs>['job']>;
|
|
51
|
+
export declare function createJobWorker<GlobalContext>({ backend, ...options }: JobWorkerOptions<GlobalContext>): JobWorker | Promise<JobWorker>;
|
|
52
|
+
export {};
|
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {} from 'faktory-worker';
|
|
3
|
+
import { FaktoryJobBackend } from './faktory.js';
|
|
4
|
+
import { getLogger } from './logger.js';
|
|
5
|
+
const logger = getLogger('jobs');
|
|
6
|
+
export function createJobs({ queues, backend, faktory }) {
|
|
7
|
+
const jobBackend = backend ?? new FaktoryJobBackend({ faktory });
|
|
8
|
+
function job(optionsOrFn, fnOrUndefined) {
|
|
9
|
+
const fn = typeof optionsOrFn === 'function' ? optionsOrFn : fnOrUndefined;
|
|
10
|
+
const options = typeof optionsOrFn === 'function' ? {} : optionsOrFn;
|
|
11
|
+
if (!fn) {
|
|
12
|
+
throw new Error('job() must get a function as the first or second argument');
|
|
13
|
+
}
|
|
14
|
+
const jobName = fn.name;
|
|
15
|
+
if (!jobName) {
|
|
16
|
+
throw new Error('Can not create job without a name');
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
$job: true,
|
|
20
|
+
jobName,
|
|
21
|
+
options,
|
|
22
|
+
async delay(args) {
|
|
23
|
+
const jid = randomUUID();
|
|
24
|
+
try {
|
|
25
|
+
await jobBackend.enqueue({
|
|
26
|
+
jid,
|
|
27
|
+
jobName,
|
|
28
|
+
args,
|
|
29
|
+
queue: options.queue ?? queues[0],
|
|
30
|
+
...options,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
logger.error(error, 'failed to create job', {
|
|
35
|
+
jobId: jid,
|
|
36
|
+
jobName,
|
|
37
|
+
args: JSON.stringify(args),
|
|
38
|
+
});
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
return jid;
|
|
42
|
+
},
|
|
43
|
+
async execute(args, context) {
|
|
44
|
+
try {
|
|
45
|
+
return await fn(args, context);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
logger.error(error);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { job, queues, backend: jobBackend };
|
|
55
|
+
}
|
|
56
|
+
export function createJobWorker({ backend = new FaktoryJobBackend(), ...options }) {
|
|
57
|
+
return backend.createWorker(options);
|
|
58
|
+
}
|
package/dist/listen.js
CHANGED
|
@@ -77,10 +77,28 @@ export function stoppable(server, gracePeriodMs = Infinity) {
|
|
|
77
77
|
return address.port;
|
|
78
78
|
};
|
|
79
79
|
const start = (port = 0) => {
|
|
80
|
-
return new Promise((resolve) => {
|
|
81
|
-
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const cleanup = () => {
|
|
82
|
+
server.off('listening', onListening);
|
|
83
|
+
server.off('error', onError);
|
|
84
|
+
};
|
|
85
|
+
const onListening = () => {
|
|
86
|
+
cleanup();
|
|
82
87
|
resolve();
|
|
83
|
-
}
|
|
88
|
+
};
|
|
89
|
+
const onError = (error) => {
|
|
90
|
+
cleanup();
|
|
91
|
+
reject(error);
|
|
92
|
+
};
|
|
93
|
+
server.once('listening', onListening);
|
|
94
|
+
server.once('error', onError);
|
|
95
|
+
try {
|
|
96
|
+
server.listen(port);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
cleanup();
|
|
100
|
+
reject(error);
|
|
101
|
+
}
|
|
84
102
|
});
|
|
85
103
|
};
|
|
86
104
|
const inspect = () => {
|
|
@@ -109,6 +127,12 @@ export function createExpressServer(app, options = {}) {
|
|
|
109
127
|
app.set('trust proxy', trustProxy);
|
|
110
128
|
}
|
|
111
129
|
let stopping = false;
|
|
130
|
+
const signals = ['SIGTERM', 'SIGINT'];
|
|
131
|
+
function removeSignalHandlers() {
|
|
132
|
+
for (const signal of signals) {
|
|
133
|
+
process.off(signal, onSignal);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
112
136
|
async function onSignal(signal) {
|
|
113
137
|
if (stopping) {
|
|
114
138
|
return;
|
|
@@ -126,16 +150,18 @@ export function createExpressServer(app, options = {}) {
|
|
|
126
150
|
gracefully,
|
|
127
151
|
});
|
|
128
152
|
await options.afterShutdown?.();
|
|
153
|
+
removeSignalHandlers();
|
|
129
154
|
process.exit(0);
|
|
130
155
|
}
|
|
131
156
|
catch (error) {
|
|
132
157
|
logger.error(error, 'error during shutdown', {
|
|
133
158
|
duration: timer.getDuration(),
|
|
134
159
|
});
|
|
160
|
+
removeSignalHandlers();
|
|
135
161
|
process.exit(1);
|
|
136
162
|
}
|
|
137
163
|
}
|
|
138
|
-
for (const signal of
|
|
164
|
+
for (const signal of signals) {
|
|
139
165
|
process.on(signal, onSignal);
|
|
140
166
|
}
|
|
141
167
|
const healthTypes = ['live', 'ready'];
|
|
@@ -182,8 +208,13 @@ export function createExpressServer(app, options = {}) {
|
|
|
182
208
|
duration: appTimer.getDuration(),
|
|
183
209
|
});
|
|
184
210
|
};
|
|
185
|
-
enhanced.stop = () => {
|
|
186
|
-
|
|
211
|
+
enhanced.stop = async () => {
|
|
212
|
+
try {
|
|
213
|
+
return await ctl.stop();
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
removeSignalHandlers();
|
|
217
|
+
}
|
|
187
218
|
};
|
|
188
219
|
enhanced.terminate = function terminate() {
|
|
189
220
|
return onSignal('SIGTERM');
|
package/dist/rate-limit.d.ts
CHANGED
|
@@ -6,11 +6,17 @@ export interface RateLimitMiddleware extends RequestHandler {
|
|
|
6
6
|
*/
|
|
7
7
|
clear: (key: string) => Promise<void>;
|
|
8
8
|
/**
|
|
9
|
-
* Clear
|
|
9
|
+
* Clear this namespace's rate limits and return the number actually deleted.
|
|
10
|
+
* Not atomic with concurrent requests: quiesce traffic for a complete reset.
|
|
10
11
|
*/
|
|
11
12
|
clearAll: () => Promise<number>;
|
|
12
13
|
}
|
|
13
14
|
export interface RateLimitOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Stable, nonempty policy name. Use the same namespace and options on all
|
|
17
|
+
* workers sharing a quota, and different namespaces for independent policies.
|
|
18
|
+
*/
|
|
19
|
+
namespace: string;
|
|
14
20
|
/**
|
|
15
21
|
* Maximum number of requests allowed within the window
|
|
16
22
|
* @default 100
|
|
@@ -28,6 +34,8 @@ export interface RateLimitOptions {
|
|
|
28
34
|
getKey?: (req: Request) => string;
|
|
29
35
|
}
|
|
30
36
|
/**
|
|
31
|
-
*
|
|
37
|
+
* Fixed-window rate limiting shared across workers through Redis.
|
|
38
|
+
* The window starts on the first accepted request. Redis errors go to next(error)
|
|
39
|
+
* without admitting the request. Requires permission to execute Redis scripts.
|
|
32
40
|
*/
|
|
33
|
-
export declare function rateLimit(redis: RedisClient, { max, windowSizeInSeconds, getKey, }
|
|
41
|
+
export declare function rateLimit(redis: RedisClient, { namespace, max, windowSizeInSeconds, getKey, }: RateLimitOptions): RateLimitMiddleware;
|
package/dist/rate-limit.js
CHANGED
|
@@ -2,33 +2,64 @@ import { config } from '@devmoods/config';
|
|
|
2
2
|
import {} from 'express';
|
|
3
3
|
import { makeError } from './errors.js';
|
|
4
4
|
import {} from './redis.js';
|
|
5
|
+
// Redis executes this entire decision atomically, including key expiration.
|
|
6
|
+
// Rejected requests neither increment the counter nor extend the window.
|
|
7
|
+
const consumeScript = `
|
|
8
|
+
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
9
|
+
local ttl = redis.call('PTTL', KEYS[1])
|
|
10
|
+
local window = tonumber(ARGV[2])
|
|
11
|
+
|
|
12
|
+
if ttl == -2 then
|
|
13
|
+
redis.call('SET', KEYS[1], '1', 'PX', ARGV[2])
|
|
14
|
+
return {1, 1, window}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
-- Repair counters that have lost their expiry without resetting their usage.
|
|
18
|
+
if ttl == -1 then
|
|
19
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
|
20
|
+
ttl = window
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if count >= tonumber(ARGV[1]) then
|
|
24
|
+
return {0, count, ttl}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
count = redis.call('INCR', KEYS[1])
|
|
28
|
+
return {1, count, ttl}
|
|
29
|
+
`;
|
|
5
30
|
/**
|
|
6
|
-
*
|
|
31
|
+
* Fixed-window rate limiting shared across workers through Redis.
|
|
32
|
+
* The window starts on the first accepted request. Redis errors go to next(error)
|
|
33
|
+
* without admitting the request. Requires permission to execute Redis scripts.
|
|
7
34
|
*/
|
|
8
|
-
export function rateLimit(redis, { max = 100, windowSizeInSeconds = 60,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
35
|
+
export function rateLimit(redis, { namespace, max = 100, windowSizeInSeconds = 60, getKey = (req) => req.ip || 'unknown', }) {
|
|
36
|
+
if (typeof namespace !== 'string' || namespace.trim().length === 0) {
|
|
37
|
+
throw new TypeError('namespace must be a nonempty string');
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isSafeInteger(max) || max <= 0) {
|
|
40
|
+
throw new RangeError('max must be a positive safe integer');
|
|
41
|
+
}
|
|
42
|
+
const windowMs = windowSizeInSeconds * 1000;
|
|
43
|
+
if (!Number.isSafeInteger(windowSizeInSeconds) ||
|
|
44
|
+
windowSizeInSeconds <= 0 ||
|
|
45
|
+
!Number.isSafeInteger(windowMs)) {
|
|
46
|
+
throw new RangeError('windowSizeInSeconds must be a positive integer representable safely in milliseconds');
|
|
47
|
+
}
|
|
48
|
+
const keyPrefix = `${config.REDIS_KEY_PREFIX}rate-limit:v2:${encodeURIComponent(namespace)}:`;
|
|
14
49
|
const middleware = async (req, res, next) => {
|
|
15
50
|
try {
|
|
16
51
|
const key = `${keyPrefix}${getKey(req)}`;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
52
|
+
const [allowed, count, ttlMs] = (await redis.eval(consumeScript, {
|
|
53
|
+
keys: [key],
|
|
54
|
+
arguments: [String(max), String(windowMs)],
|
|
55
|
+
}));
|
|
20
56
|
// Set headers for rate limiting info
|
|
21
57
|
res.setHeader('X-RateLimit-Limit', max.toString());
|
|
22
|
-
res.setHeader('X-RateLimit-Remaining', Math.max(0, max - count
|
|
23
|
-
if (
|
|
58
|
+
res.setHeader('X-RateLimit-Remaining', Math.max(0, max - count).toString());
|
|
59
|
+
if (!allowed) {
|
|
60
|
+
res.setHeader('Retry-After', Math.max(1, Math.ceil(ttlMs / 1000)).toString());
|
|
24
61
|
throw makeError(429, 'Rate limit exceeded');
|
|
25
62
|
}
|
|
26
|
-
if (count === 0) {
|
|
27
|
-
await redis.set(key, '1', { EX: windowSizeInSeconds });
|
|
28
|
-
}
|
|
29
|
-
else {
|
|
30
|
-
await redis.incr(key);
|
|
31
|
-
}
|
|
32
63
|
next();
|
|
33
64
|
}
|
|
34
65
|
catch (error) {
|
|
@@ -41,7 +72,8 @@ getKey = (req) => req.ip || 'unknown',
|
|
|
41
72
|
const clearAll = async () => {
|
|
42
73
|
let cursor = '0';
|
|
43
74
|
let deletedCount = 0;
|
|
44
|
-
|
|
75
|
+
// Escape Redis glob metacharacters in both the app prefix and namespace.
|
|
76
|
+
const pattern = `${keyPrefix.replace(/[\\*?[\]]/g, '\\$&')}*`;
|
|
45
77
|
do {
|
|
46
78
|
// Use SCAN instead of KEYS - non-blocking iteration
|
|
47
79
|
const result = await redis.scan(cursor, {
|
|
@@ -50,11 +82,9 @@ getKey = (req) => req.ip || 'unknown',
|
|
|
50
82
|
});
|
|
51
83
|
cursor = result.cursor;
|
|
52
84
|
const keys = result.keys;
|
|
53
|
-
/* v8 ignore next -- @preserve */
|
|
54
85
|
if (keys.length > 0) {
|
|
55
86
|
// Use UNLINK instead of DEL - asynchronous deletion
|
|
56
|
-
await redis.unlink(keys);
|
|
57
|
-
deletedCount += keys.length;
|
|
87
|
+
deletedCount += await redis.unlink(keys);
|
|
58
88
|
}
|
|
59
89
|
} while (cursor !== '0');
|
|
60
90
|
return deletedCount;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type JobBackend, type JobSubmission, type JobWorkerOptions } from './jobs.js';
|
|
2
|
+
import { type RedisClient } from './redis.js';
|
|
3
|
+
export interface RedisJobBackendOptions {
|
|
4
|
+
/** Defaults to the configured Redis prefix followed by `jobs:`. */
|
|
5
|
+
keyPrefix?: string;
|
|
6
|
+
/** Reservation lifetime in milliseconds. Defaults to 30 seconds. */
|
|
7
|
+
leaseMs?: number;
|
|
8
|
+
}
|
|
9
|
+
/** Persistent, at least once job processing using Redis sorted sets. */
|
|
10
|
+
export declare class RedisJobBackend implements JobBackend {
|
|
11
|
+
private redis;
|
|
12
|
+
private options;
|
|
13
|
+
constructor(redis: RedisClient, options?: RedisJobBackendOptions);
|
|
14
|
+
private key;
|
|
15
|
+
private get leaseMs();
|
|
16
|
+
enqueue(job: JobSubmission): Promise<void>;
|
|
17
|
+
private claim;
|
|
18
|
+
private finish;
|
|
19
|
+
createWorker<GlobalContext>({ jobs, queues, jobContext, }: JobWorkerOptions<GlobalContext>): {
|
|
20
|
+
start(): Promise<void>;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
}
|