@maestro-js/rate-limiting 1.0.0-alpha.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/README.md +201 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +87 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# @maestro-js/rate-limiting
|
|
2
|
+
|
|
3
|
+
Per-key rate limiting with configurable decay windows. Use when enforcing attempt limits on login,
|
|
4
|
+
API endpoints, or any action that needs throttling.
|
|
5
|
+
|
|
6
|
+
## Quick Setup
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { RateLimiting } from '@maestro-js/rate-limiting'
|
|
10
|
+
import { Cache } from '@maestro-js/cache'
|
|
11
|
+
|
|
12
|
+
// Create using a cache service as the driver
|
|
13
|
+
const cacheService = Cache.Provider.create({
|
|
14
|
+
driver: Cache.drivers.redis({ url: 'redis-host', port: 6379 })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const rateLimiter = RateLimiting.Provider.create(cacheService)
|
|
18
|
+
RateLimiting.Provider.register('default', rateLimiter)
|
|
19
|
+
|
|
20
|
+
// Check before allowing an action
|
|
21
|
+
await RateLimiting.increment('login:user@example.com', 1, 300)
|
|
22
|
+
|
|
23
|
+
if (await RateLimiting.tooManyAttempts('login:user@example.com', 5)) {
|
|
24
|
+
const retryAfter = await RateLimiting.availableIn('login:user@example.com')
|
|
25
|
+
throw new Error(`Too many attempts. Try again in ${retryAfter}`)
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## API Reference
|
|
30
|
+
|
|
31
|
+
### increment
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
RateLimiting.increment(key: string, amount?: number, decaySeconds?: number): Promise<number>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Records one or more hits against the bucket. Creates the bucket with the given decay window
|
|
38
|
+
(default 60 seconds) if it doesn't exist. Returns the new hit count.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const hits = await RateLimiting.increment('api:client-123') // +1, 60s window
|
|
42
|
+
const hits = await RateLimiting.increment('api:client-123', 5, 120) // +5, 120s window
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### tooManyAttempts
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
RateLimiting.tooManyAttempts(key: string, maxAttempts: number): Promise<boolean>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Returns `true` if the bucket has reached or exceeded `maxAttempts` and the decay window is still
|
|
52
|
+
active. If the timer has expired but the counter remains, the counter is cleaned up and `false`
|
|
53
|
+
is returned.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
if (await RateLimiting.tooManyAttempts('login:user@example.com', 5)) {
|
|
57
|
+
return res.status(429).json({ error: 'Too many login attempts' })
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### remaining
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
RateLimiting.remaining(key: string, maxAttempts: number): Promise<number>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Returns the number of attempts left before `maxAttempts` is reached. Never returns a negative
|
|
68
|
+
number.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const left = await RateLimiting.remaining('api:client-123', 100)
|
|
72
|
+
res.setHeader('X-RateLimit-Remaining', left)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### clear
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
RateLimiting.clear(key: string): Promise<void>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Removes both the hit counter and timer for the bucket, fully resetting it.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
// Reset after a successful login
|
|
85
|
+
await RateLimiting.clear('login:user@example.com')
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### availableIn
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
RateLimiting.availableIn(key: string): Promise<Iso.Duration | null>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Returns the time remaining until the decay window expires as an `Iso.Duration`, or `null` if no
|
|
95
|
+
active window exists. The duration is rounded down to the nearest second.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const retryAfter = await RateLimiting.availableIn('api:client-123')
|
|
99
|
+
if (retryAfter) {
|
|
100
|
+
res.setHeader('Retry-After', retryAfter) // e.g. "PT45S"
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### attempt
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
RateLimiting.attempt<T>(
|
|
108
|
+
key: string,
|
|
109
|
+
maxAttemptsPerWindow: number,
|
|
110
|
+
callback: () => T,
|
|
111
|
+
decaySeconds?: number
|
|
112
|
+
): Promise<T | boolean>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Guarded callback execution. If the bucket is under the limit, increments and executes
|
|
116
|
+
`callback`, returning its result. If the limit is exceeded, returns `false` without executing
|
|
117
|
+
the callback.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const result = await RateLimiting.attempt('send-email:user-1', 10, async () => {
|
|
121
|
+
await sendEmail(to, subject, body)
|
|
122
|
+
return { sent: true }
|
|
123
|
+
}, 3600)
|
|
124
|
+
|
|
125
|
+
if (result === false) {
|
|
126
|
+
throw new Error('Email rate limit exceeded')
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Common Patterns
|
|
131
|
+
|
|
132
|
+
### Login throttling
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
async function handleLogin(email: string, password: string) {
|
|
136
|
+
const key = `login:${email}`
|
|
137
|
+
|
|
138
|
+
if (await RateLimiting.tooManyAttempts(key, 5)) {
|
|
139
|
+
const retryAfter = await RateLimiting.availableIn(key)
|
|
140
|
+
return { error: `Too many attempts. Try again in ${retryAfter}` }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await RateLimiting.increment(key, 1, 300) // 5-minute window
|
|
144
|
+
|
|
145
|
+
const user = await authenticate(email, password)
|
|
146
|
+
if (user) {
|
|
147
|
+
await RateLimiting.clear(key)
|
|
148
|
+
return { success: true }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { error: 'Invalid credentials' }
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### API rate limiting with Retry-After header
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
async function rateLimitMiddleware(req: Request, res: Response, next: () => void) {
|
|
159
|
+
const key = `api:${req.ip}`
|
|
160
|
+
|
|
161
|
+
if (await RateLimiting.tooManyAttempts(key, 100)) {
|
|
162
|
+
const retryAfter = await RateLimiting.availableIn(key)
|
|
163
|
+
if (retryAfter) {
|
|
164
|
+
res.setHeader('Retry-After', retryAfter)
|
|
165
|
+
}
|
|
166
|
+
return res.status(429).json({ error: 'Rate limit exceeded' })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
await RateLimiting.increment(key, 1, 60)
|
|
170
|
+
res.setHeader('X-RateLimit-Remaining', await RateLimiting.remaining(key, 100))
|
|
171
|
+
next()
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Guarded action with attempt()
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
const result = await RateLimiting.attempt('report:org-5', 3, async () => {
|
|
179
|
+
return await generateExpensiveReport()
|
|
180
|
+
}, 3600) // 3 per hour
|
|
181
|
+
|
|
182
|
+
if (result === false) {
|
|
183
|
+
return { error: 'Report generation rate limit exceeded' }
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Warnings and Gotchas
|
|
188
|
+
|
|
189
|
+
- **No built-in drivers** — `RateLimiting.Provider.create()` takes a cache driver directly
|
|
190
|
+
(anything satisfying `RateLimiting.Driver`). Pass a `@maestro-js/cache` service instance.
|
|
191
|
+
- **`attempt()` returns `T | boolean`** — check `=== false` to distinguish a rate-limited call
|
|
192
|
+
from a callback that returns a falsy value.
|
|
193
|
+
- **`availableIn()` rounds down** — the returned duration is floored to the nearest second.
|
|
194
|
+
- **Two cache keys per bucket** — each rate-limit key uses `_rate_limit:{key}` (counter) and
|
|
195
|
+
`_rate_limit:{key}:timer` (expiry instant). Keep this in mind when inspecting cache contents.
|
|
196
|
+
- **Default decay is 60 seconds** — if you omit `decaySeconds`, the window is one minute.
|
|
197
|
+
|
|
198
|
+
## Key Source Files
|
|
199
|
+
|
|
200
|
+
- `packages/rate-limiting/src/index.ts` — single source file with create factory, Provider
|
|
201
|
+
pattern, and namespace types
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Iso } from 'iso-fns2';
|
|
2
|
+
|
|
3
|
+
declare function create(cache: RateLimiting.Driver): {
|
|
4
|
+
increment: (key: string, amount?: number, decaySeconds?: number) => Promise<number>;
|
|
5
|
+
tooManyAttempts: (key: string, maxAttempts: number) => Promise<boolean>;
|
|
6
|
+
availableIn: (key: string) => Promise<Iso.Duration | null>;
|
|
7
|
+
remaining: (key: string, maxAttempts: number) => Promise<number>;
|
|
8
|
+
clear: (key: string) => Promise<void>;
|
|
9
|
+
attempt: <T>(key: string, maxAttemptsPerWindow: number, callback: () => T, decaySeconds?: number) => Promise<T | boolean>;
|
|
10
|
+
};
|
|
11
|
+
declare function provider(key: RateLimiting.Provider.Key): {
|
|
12
|
+
increment: (key: string, amount?: number, decaySeconds?: number) => Promise<number>;
|
|
13
|
+
tooManyAttempts: (key: string, maxAttempts: number) => Promise<boolean>;
|
|
14
|
+
availableIn: (key: string) => Promise<Iso.Duration | null>;
|
|
15
|
+
remaining: (key: string, maxAttempts: number) => Promise<number>;
|
|
16
|
+
clear: (key: string) => Promise<void>;
|
|
17
|
+
attempt: <T>(key: string, maxAttemptsPerWindow: number, callback: () => T, decaySeconds?: number) => Promise<T | boolean>;
|
|
18
|
+
};
|
|
19
|
+
type KeysWithFallback = keyof RateLimiting.Provider.Keys extends never ? {
|
|
20
|
+
default: unknown;
|
|
21
|
+
} : RateLimiting.Provider.Keys;
|
|
22
|
+
/**
|
|
23
|
+
* Per-key rate limiting with configurable decay windows.
|
|
24
|
+
* Call methods directly on the default instance, or use `RateLimiting.Provider.create()` for named instances.
|
|
25
|
+
*/
|
|
26
|
+
declare const RateLimiting: {
|
|
27
|
+
provider: typeof provider;
|
|
28
|
+
increment: (key: string, amount?: number, decaySeconds?: number) => Promise<number>;
|
|
29
|
+
tooManyAttempts: (key: string, maxAttempts: number) => Promise<boolean>;
|
|
30
|
+
availableIn: (key: string) => Promise<Iso.Duration | null>;
|
|
31
|
+
remaining: (key: string, maxAttempts: number) => Promise<number>;
|
|
32
|
+
clear: (key: string) => Promise<void>;
|
|
33
|
+
attempt: <T>(key: string, maxAttemptsPerWindow: number, callback: () => T, decaySeconds?: number) => Promise<T | boolean>;
|
|
34
|
+
Provider: {
|
|
35
|
+
create: typeof create;
|
|
36
|
+
register: (name: RateLimiting.Provider.Key, item: RateLimiting.RateLimitingService) => void;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Per-key rate limiting with configurable decay windows.
|
|
41
|
+
*
|
|
42
|
+
* Each bucket is identified by a string key and tracked with two cache entries:
|
|
43
|
+
* a hit counter and a timer storing the window expiry as an ISO 8601 instant.
|
|
44
|
+
* The decay window (default 60 seconds) controls how long the counter lives.
|
|
45
|
+
*
|
|
46
|
+
* Use {@link increment} to record hits, {@link tooManyAttempts} to check if a
|
|
47
|
+
* limit has been exceeded, and {@link availableIn} to report how long until
|
|
48
|
+
* the window expires. For a combined check-and-execute flow, use {@link attempt}.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* await RateLimiting.increment('login:user@example.com', 1, 300)
|
|
53
|
+
*
|
|
54
|
+
* if (await RateLimiting.tooManyAttempts('login:user@example.com', 5)) {
|
|
55
|
+
* const retryAfter = await RateLimiting.availableIn('login:user@example.com')
|
|
56
|
+
* throw new Error(`Too many attempts. Try again in ${retryAfter}`)
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
declare namespace RateLimiting {
|
|
61
|
+
/**
|
|
62
|
+
* Rate-limiting service with increment/check/clear workflow.
|
|
63
|
+
*
|
|
64
|
+
* Returned by `RateLimiting.Provider.create()` or resolved via
|
|
65
|
+
* `RateLimiting.provider()`. Each method operates on a named bucket
|
|
66
|
+
* identified by a string key.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* await RateLimiting.increment('api:client-123')
|
|
71
|
+
* const blocked = await RateLimiting.tooManyAttempts('api:client-123', 100)
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
interface RateLimitingService {
|
|
75
|
+
/** Records one or more hits against the bucket, creating it with the given decay window if new */
|
|
76
|
+
increment: (key: string, amount?: number, decaySeconds?: number) => Promise<number>;
|
|
77
|
+
/** Returns `true` if the bucket has reached or exceeded `maxAttempts` and the decay window is still active */
|
|
78
|
+
tooManyAttempts: (key: string, maxAttempts: number) => Promise<boolean>;
|
|
79
|
+
/** Returns the time remaining until the decay window expires, or `null` if no active window */
|
|
80
|
+
availableIn: (key: string) => Promise<Iso.Duration | null>;
|
|
81
|
+
/** Returns the number of attempts remaining before `maxAttempts` is reached */
|
|
82
|
+
remaining: (key: string, maxAttempts: number) => Promise<number>;
|
|
83
|
+
/** Removes both the hit counter and timer for the bucket */
|
|
84
|
+
clear: (key: string) => Promise<void>;
|
|
85
|
+
/** Increments and executes `callback` if under the limit, otherwise returns `false` without executing */
|
|
86
|
+
attempt: <T>(key: string, maxAttemptsPerWindow: number, callback: () => T, decaySeconds?: number) => Promise<T | boolean>;
|
|
87
|
+
}
|
|
88
|
+
/** Subset of the cache interface required by the rate limiter — typically satisfied by a `@maestro-js/cache` service instance */
|
|
89
|
+
interface Driver {
|
|
90
|
+
has(key: string): Promise<boolean>;
|
|
91
|
+
get<T = any>(key: string): Promise<T | null>;
|
|
92
|
+
add(key: string, value: any, ttlSeconds: number): Promise<boolean>;
|
|
93
|
+
set(key: string, value: any, ttlSeconds: number): Promise<void>;
|
|
94
|
+
increment(key: string, value: number): Promise<number>;
|
|
95
|
+
decrement(key: string, value: number): Promise<number>;
|
|
96
|
+
del(key: string): Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
namespace Provider {
|
|
99
|
+
type Key = keyof KeysWithFallback;
|
|
100
|
+
interface Keys {
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { RateLimiting };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { ServiceRegistry } from "@maestro-js/service-registry";
|
|
3
|
+
import { instantFns } from "iso-fns2";
|
|
4
|
+
function create(cache) {
|
|
5
|
+
const getRateLimitKey = (key) => `_rate_limit:${key}`;
|
|
6
|
+
const getRateLimitTimerKey = (key) => `_rate_limit:${key}:timer`;
|
|
7
|
+
async function increment(key, amount = 1, decaySeconds = 60) {
|
|
8
|
+
await cache.add(getRateLimitTimerKey(key), instantFns.add(instantFns.now(), { seconds: decaySeconds }), decaySeconds);
|
|
9
|
+
const added = await cache.add(getRateLimitKey(key), 0, decaySeconds);
|
|
10
|
+
const hits = await cache.increment(getRateLimitKey(key), amount);
|
|
11
|
+
if (!added && hits === 1) {
|
|
12
|
+
await cache.set(getRateLimitKey(key), 1, decaySeconds);
|
|
13
|
+
}
|
|
14
|
+
return hits;
|
|
15
|
+
}
|
|
16
|
+
async function tooManyAttempts(key, maxAttempts) {
|
|
17
|
+
const value = await cache.get(getRateLimitKey(key));
|
|
18
|
+
if ((value ?? 0) >= maxAttempts) {
|
|
19
|
+
const hasTimer = await cache.has(getRateLimitTimerKey(key));
|
|
20
|
+
if (hasTimer) {
|
|
21
|
+
return true;
|
|
22
|
+
} else {
|
|
23
|
+
await cache.del(getRateLimitKey(key));
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function remaining(key, maxAttempts) {
|
|
31
|
+
const value = await cache.get(getRateLimitKey(key));
|
|
32
|
+
return Math.max(maxAttempts - (value ?? 0), 0);
|
|
33
|
+
}
|
|
34
|
+
async function clear(key) {
|
|
35
|
+
await cache.del(getRateLimitKey(key));
|
|
36
|
+
await cache.del(getRateLimitTimerKey(key));
|
|
37
|
+
}
|
|
38
|
+
async function availableIn(key) {
|
|
39
|
+
const availableInInstant = await cache.get(getRateLimitTimerKey(key));
|
|
40
|
+
const now = instantFns.now();
|
|
41
|
+
if (availableInInstant && now < availableInInstant) {
|
|
42
|
+
return instantFns.until(now, availableInInstant, {
|
|
43
|
+
largestUnit: "second",
|
|
44
|
+
smallestUnit: "second",
|
|
45
|
+
roundingMode: "floor"
|
|
46
|
+
});
|
|
47
|
+
} else {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function attempt(key, maxAttemptsPerWindow, callback, decaySeconds = 60) {
|
|
52
|
+
if (await tooManyAttempts(key, maxAttemptsPerWindow)) {
|
|
53
|
+
return false;
|
|
54
|
+
} else {
|
|
55
|
+
await increment(key, 1, decaySeconds);
|
|
56
|
+
const result = await callback();
|
|
57
|
+
return result || true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { increment, tooManyAttempts, availableIn, remaining, clear, attempt };
|
|
61
|
+
}
|
|
62
|
+
var registry = ServiceRegistry.createRegistry(
|
|
63
|
+
ServiceRegistry.proxyFunctionsForObject
|
|
64
|
+
);
|
|
65
|
+
var Provider = {
|
|
66
|
+
create,
|
|
67
|
+
register: registry.register
|
|
68
|
+
};
|
|
69
|
+
function provider(key) {
|
|
70
|
+
const service = registry.resolve(key);
|
|
71
|
+
return {
|
|
72
|
+
increment: service.increment,
|
|
73
|
+
tooManyAttempts: service.tooManyAttempts,
|
|
74
|
+
availableIn: service.availableIn,
|
|
75
|
+
remaining: service.remaining,
|
|
76
|
+
clear: service.clear,
|
|
77
|
+
attempt: service.attempt
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
var RateLimiting = {
|
|
81
|
+
Provider,
|
|
82
|
+
...provider("default"),
|
|
83
|
+
provider
|
|
84
|
+
};
|
|
85
|
+
export {
|
|
86
|
+
RateLimiting
|
|
87
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maestro-js/rate-limiting",
|
|
3
|
+
"description": "Per-key rate limiting with configurable decay windows. Use when enforcing attempt limits on login, API endpoints, or any action that needs throttling. Provides increment/check/clear workflow, guarded callback execution via attempt(), and ISO 8601 duration reporting. Requires a cache driver (e.g. from @maestro-js/cache).",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"version": "1.0.0-alpha.0",
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "restricted"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"iso-fns2": "npm:iso-fns@2.0.0-alpha.26",
|
|
20
|
+
"@maestro-js/service-registry": "1.0.0-alpha.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.19.11",
|
|
24
|
+
"@maestro-js/cache": "1.0.0-alpha.0"
|
|
25
|
+
},
|
|
26
|
+
"license": "UNLICENSED",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.18.0"
|
|
29
|
+
},
|
|
30
|
+
"repository": "https://github.com/Marcato-Partners/maestro-js",
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsup --config ../../tsup.config.ts",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "beartest ./tests/**/*",
|
|
35
|
+
"format": "prettier --write src/ tests/",
|
|
36
|
+
"lint": "prettier --check src/ tests/"
|
|
37
|
+
}
|
|
38
|
+
}
|