@c9up/ream 0.2.9 → 0.2.10
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 +1 -0
- package/dist/Application.d.ts +26 -6
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +40 -12
- package/dist/Application.js.map +1 -1
- package/dist/ErrorBoundary.d.ts.map +1 -1
- package/dist/ErrorBoundary.js +2 -1
- package/dist/ErrorBoundary.js.map +1 -1
- package/dist/Ignitor.d.ts +13 -0
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +98 -23
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/MultipartFile.d.ts +8 -0
- package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
- package/dist/bodyparser/MultipartFile.js +46 -1
- package/dist/bodyparser/MultipartFile.js.map +1 -1
- package/dist/env/Env.d.ts.map +1 -1
- package/dist/env/Env.js +2 -1
- package/dist/env/Env.js.map +1 -1
- package/dist/env/loadEnvFiles.d.ts.map +1 -1
- package/dist/env/loadEnvFiles.js +5 -1
- package/dist/env/loadEnvFiles.js.map +1 -1
- package/dist/env/nodeEnv.d.ts +24 -0
- package/dist/env/nodeEnv.d.ts.map +1 -0
- package/dist/env/nodeEnv.js +40 -0
- package/dist/env/nodeEnv.js.map +1 -0
- package/dist/errors/ReamError.d.ts +6 -1
- package/dist/errors/ReamError.d.ts.map +1 -1
- package/dist/errors/ReamError.js +8 -2
- package/dist/errors/ReamError.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +3 -2
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +2 -1
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/Request.d.ts +18 -3
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +71 -8
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts +11 -4
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +46 -17
- package/dist/http/Response.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +14 -5
- package/dist/router/Router.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +2 -1
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/ScheduleProvider.d.ts +14 -0
- package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
- package/dist/scheduler/ScheduleProvider.js +62 -14
- package/dist/scheduler/ScheduleProvider.js.map +1 -1
- package/dist/scheduler/config.d.ts +29 -0
- package/dist/scheduler/config.d.ts.map +1 -0
- package/dist/scheduler/config.js +14 -0
- package/dist/scheduler/config.js.map +1 -0
- package/dist/scheduler/index.d.ts +4 -0
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +3 -0
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/locks/LockBackend.d.ts +13 -3
- package/dist/scheduler/locks/LockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/LockBackend.js +20 -4
- package/dist/scheduler/locks/LockBackend.js.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.d.ts +5 -6
- package/dist/scheduler/locks/MemoryLockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.js +5 -13
- package/dist/scheduler/locks/MemoryLockBackend.js.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.d.ts +53 -0
- package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -0
- package/dist/scheduler/locks/RedisLockBackend.js +83 -0
- package/dist/scheduler/locks/RedisLockBackend.js.map +1 -0
- package/dist/scheduler/locks/locks.d.ts +39 -0
- package/dist/scheduler/locks/locks.d.ts.map +1 -0
- package/dist/scheduler/locks/locks.js +42 -0
- package/dist/scheduler/locks/locks.js.map +1 -0
- package/dist/scheduler/locks/quasar.d.ts +17 -0
- package/dist/scheduler/locks/quasar.d.ts.map +1 -0
- package/dist/scheduler/locks/quasar.js +51 -0
- package/dist/scheduler/locks/quasar.js.map +1 -0
- package/dist/security/CookieSigner.d.ts +16 -13
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +33 -14
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/services/encryption.d.ts +12 -2
- package/dist/services/encryption.d.ts.map +1 -1
- package/dist/services/encryption.js +11 -1
- package/dist/services/encryption.js.map +1 -1
- package/dist/session/Session.d.ts +8 -7
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +4 -1
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/testing/ApiResponse.d.ts.map +1 -1
- package/dist/testing/ApiResponse.js +1 -1
- package/dist/testing/ApiResponse.js.map +1 -1
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +1 -1
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/dist/utils/Macroable.d.ts.map +1 -1
- package/dist/utils/Macroable.js +1 -0
- package/dist/utils/Macroable.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +5 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Application.ts +42 -15
- package/src/ErrorBoundary.ts +3 -1
- package/src/Ignitor.ts +109 -27
- package/src/bodyparser/MultipartFile.ts +48 -1
- package/src/env/Env.ts +2 -1
- package/src/env/loadEnvFiles.ts +5 -1
- package/src/env/nodeEnv.ts +38 -0
- package/src/errors/ReamError.ts +9 -2
- package/src/graphql/GraphQLEngine.ts +3 -2
- package/src/http/Exception.ts +2 -1
- package/src/http/Request.ts +73 -8
- package/src/http/Response.ts +58 -19
- package/src/index.ts +8 -1
- package/src/router/Router.ts +14 -5
- package/src/rpc/RpcRouter.ts +2 -1
- package/src/scheduler/ScheduleProvider.ts +65 -15
- package/src/scheduler/config.ts +34 -0
- package/src/scheduler/index.ts +8 -0
- package/src/scheduler/locks/LockBackend.ts +26 -3
- package/src/scheduler/locks/MemoryLockBackend.ts +5 -19
- package/src/scheduler/locks/RedisLockBackend.ts +114 -0
- package/src/scheduler/locks/locks.ts +49 -0
- package/src/scheduler/locks/quasar.ts +67 -0
- package/src/security/CookieSigner.ts +36 -14
- package/src/services/encryption.ts +12 -2
- package/src/session/Session.ts +8 -7
- package/src/session/SessionMiddleware.ts +4 -1
- package/src/testing/ApiResponse.ts +1 -2
- package/src/testing/RequestBuilder.ts +1 -2
- package/src/utils/Macroable.ts +1 -0
|
@@ -24,8 +24,9 @@ import { getServiceRegistry } from '../decorators/Service.js'
|
|
|
24
24
|
import { ReamError } from '../errors/ReamError.js'
|
|
25
25
|
import type { AppContext } from '../Provider.js'
|
|
26
26
|
import { Provider } from '../Provider.js'
|
|
27
|
+
import type { SchedulerConfig } from './config.js'
|
|
27
28
|
import { getScheduleMetadata } from './Schedule.js'
|
|
28
|
-
import { Scheduler } from './Scheduler.js'
|
|
29
|
+
import { Scheduler, type SchedulerOptions } from './Scheduler.js'
|
|
29
30
|
|
|
30
31
|
export interface ScheduleProviderOptions {
|
|
31
32
|
/**
|
|
@@ -40,12 +41,14 @@ let activeProvider: ScheduleProvider | undefined
|
|
|
40
41
|
|
|
41
42
|
export class ScheduleProvider extends Provider {
|
|
42
43
|
readonly scheduler: Scheduler
|
|
43
|
-
|
|
44
|
+
/** Task names already registered, so a second pass adds only what is new. */
|
|
45
|
+
readonly #known = new Set<string>()
|
|
44
46
|
#registered = false
|
|
45
47
|
|
|
46
48
|
constructor(app: AppContext, options: ScheduleProviderOptions = {}) {
|
|
47
49
|
super(app)
|
|
48
|
-
this.scheduler =
|
|
50
|
+
this.scheduler =
|
|
51
|
+
options.scheduler ?? new Scheduler(schedulerOptionsFrom(app.config.get('scheduler')))
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
/**
|
|
@@ -82,8 +85,44 @@ export class ScheduleProvider extends Provider {
|
|
|
82
85
|
this.#registered = true
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Discovery runs at START, not at boot, and that is the whole point.
|
|
90
|
+
*
|
|
91
|
+
* `app/modules/**` is auto-loaded during the start phase — after every
|
|
92
|
+
* provider has booted. A `@Service()` carrying `@Schedule` there, which is
|
|
93
|
+
* where one naturally lives, was therefore registered into the service
|
|
94
|
+
* registry AFTER this had already read it. The task was never registered,
|
|
95
|
+
* and nothing said so: no error, no warning, the application started
|
|
96
|
+
* normally and the task simply never fired.
|
|
97
|
+
*
|
|
98
|
+
* Reading the registry once the modules are in place is what fixes it. A
|
|
99
|
+
* service declared anywhere earlier — a provider, a preload — is in the
|
|
100
|
+
* registry by then too, so nothing is lost by waiting.
|
|
101
|
+
*/
|
|
85
102
|
override async boot(): Promise<void> {
|
|
86
|
-
|
|
103
|
+
// Anything already in the registry — declared by a provider, or imported
|
|
104
|
+
// by one. The rest is caught at start().
|
|
105
|
+
this.#discover()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
override async start(): Promise<void> {
|
|
109
|
+
this.#discover()
|
|
110
|
+
if (activeProvider !== undefined && activeProvider !== this) {
|
|
111
|
+
// A previous app's scheduler is still running — stop it before this one
|
|
112
|
+
// starts, otherwise every @Schedule task fires twice per tick.
|
|
113
|
+
activeProvider.scheduler.stop()
|
|
114
|
+
}
|
|
115
|
+
activeProvider = this
|
|
116
|
+
this.scheduler.start()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Walk the service registry and register every `@Schedule` it declares.
|
|
121
|
+
*
|
|
122
|
+
* Idempotent: a second call registers nothing twice, so an application that
|
|
123
|
+
* restarts its Ignitor does not end up firing everything in duplicate.
|
|
124
|
+
*/
|
|
125
|
+
#discover(): void {
|
|
87
126
|
const registry = getServiceRegistry()
|
|
88
127
|
const registered: string[] = []
|
|
89
128
|
|
|
@@ -118,6 +157,8 @@ export class ScheduleProvider extends Provider {
|
|
|
118
157
|
}
|
|
119
158
|
|
|
120
159
|
const taskName = `${target.name}.${methodName}`
|
|
160
|
+
// Registered on an earlier pass: skip rather than fire it twice.
|
|
161
|
+
if (this.#known.has(taskName)) continue
|
|
121
162
|
try {
|
|
122
163
|
this.scheduler.register(taskName, cronExpr, async () => {
|
|
123
164
|
// Resolve from the container on every fire so transient
|
|
@@ -133,6 +174,7 @@ export class ScheduleProvider extends Provider {
|
|
|
133
174
|
await (method as (...args: unknown[]) => unknown).call(instance)
|
|
134
175
|
})
|
|
135
176
|
registered.push(taskName)
|
|
177
|
+
this.#known.add(taskName)
|
|
136
178
|
} catch (cause) {
|
|
137
179
|
const inner = cause as ReamError & { message?: string; hint?: string }
|
|
138
180
|
throw new ReamError(
|
|
@@ -146,7 +188,6 @@ export class ScheduleProvider extends Provider {
|
|
|
146
188
|
}
|
|
147
189
|
}
|
|
148
190
|
}
|
|
149
|
-
this.#booted = true
|
|
150
191
|
} catch (err) {
|
|
151
192
|
// Roll back any partial registrations so a retry does not hit
|
|
152
193
|
// DUPLICATE_TASK and the scheduler is never half-initialized.
|
|
@@ -162,16 +203,6 @@ export class ScheduleProvider extends Provider {
|
|
|
162
203
|
}
|
|
163
204
|
}
|
|
164
205
|
|
|
165
|
-
override async start(): Promise<void> {
|
|
166
|
-
if (activeProvider !== undefined && activeProvider !== this) {
|
|
167
|
-
// A previous app's scheduler is still running — stop it before this one starts,
|
|
168
|
-
// otherwise every @Schedule task fires twice per tick.
|
|
169
|
-
activeProvider.scheduler.stop()
|
|
170
|
-
}
|
|
171
|
-
activeProvider = this
|
|
172
|
-
this.scheduler.start()
|
|
173
|
-
}
|
|
174
|
-
|
|
175
206
|
override async shutdown(): Promise<void> {
|
|
176
207
|
if (activeProvider === this) activeProvider = undefined
|
|
177
208
|
this.scheduler.stop()
|
|
@@ -180,3 +211,22 @@ export class ScheduleProvider extends Provider {
|
|
|
180
211
|
|
|
181
212
|
// Default export so reamrc's provider loader can `() => import('@c9up/ream/<feature>/provider')` (resolves to { default }), matching events/rpc. Named export above stays.
|
|
182
213
|
export default ScheduleProvider
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Turn `config/scheduler.ts` into the options the `Scheduler` takes.
|
|
217
|
+
*
|
|
218
|
+
* The lock is built here rather than in the config file because that file is
|
|
219
|
+
* read before the application boots — a Redis connection named in it does not
|
|
220
|
+
* exist yet, so the helpers hand back a factory and this calls it.
|
|
221
|
+
*/
|
|
222
|
+
function schedulerOptionsFrom(config: SchedulerConfig | undefined): SchedulerOptions {
|
|
223
|
+
if (config === undefined) return {}
|
|
224
|
+
const options: SchedulerOptions = {}
|
|
225
|
+
if (config.lock !== undefined) {
|
|
226
|
+
options.lockBackend = typeof config.lock === 'function' ? config.lock() : config.lock
|
|
227
|
+
}
|
|
228
|
+
if (config.defaultLockTtlMs !== undefined) {
|
|
229
|
+
options.defaultLockTtlMs = config.defaultLockTtlMs
|
|
230
|
+
}
|
|
231
|
+
return options
|
|
232
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Author-time config for `config/scheduler.ts`.
|
|
3
|
+
*
|
|
4
|
+
* import { defineConfig, locks } from '@c9up/ream/scheduler/config'
|
|
5
|
+
*
|
|
6
|
+
* export default defineConfig({
|
|
7
|
+
* lock: locks.redis({ connection: 'main' }),
|
|
8
|
+
* })
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { LockBackend } from './locks/LockBackend.js'
|
|
12
|
+
import type { LockBackendFactory } from './locks/locks.js'
|
|
13
|
+
|
|
14
|
+
export interface SchedulerConfig {
|
|
15
|
+
/**
|
|
16
|
+
* The lock consulted before a task fires, so that only one instance runs
|
|
17
|
+
* it. Takes a backend, or a factory answering one — which is what the
|
|
18
|
+
* `locks.*` helpers return, so a config file can name a Redis lock before
|
|
19
|
+
* the connection exists.
|
|
20
|
+
*
|
|
21
|
+
* Left out, the scheduler locks nothing: every instance runs every task.
|
|
22
|
+
* Correct on one instance, and a duplicate run per replica past that.
|
|
23
|
+
*/
|
|
24
|
+
lock?: LockBackend | LockBackendFactory
|
|
25
|
+
/** How long a lease is held before it expires on its own. Default 60 000. */
|
|
26
|
+
defaultLockTtlMs?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function defineConfig(config: SchedulerConfig): SchedulerConfig {
|
|
30
|
+
return config
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type { LockBackend } from './locks/LockBackend.js'
|
|
34
|
+
export { type LockBackendFactory, locks } from './locks/locks.js'
|
package/src/scheduler/index.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
export type { SchedulerConfig } from './config.js'
|
|
2
|
+
export { defineConfig as defineSchedulerConfig } from './config.js'
|
|
1
3
|
export type { LockBackend } from './locks/LockBackend.js'
|
|
4
|
+
export { type LockBackendFactory, locks } from './locks/locks.js'
|
|
2
5
|
export { MemoryLockBackend } from './locks/MemoryLockBackend.js'
|
|
6
|
+
export {
|
|
7
|
+
type LockRedisClient,
|
|
8
|
+
type LockRedisResolver,
|
|
9
|
+
RedisLockBackend,
|
|
10
|
+
} from './locks/RedisLockBackend.js'
|
|
3
11
|
export type { ErrorReporter } from './observability/ErrorReporter.js'
|
|
4
12
|
export type {
|
|
5
13
|
ScheduleEvent,
|
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The scheduler consults a `LockBackend` immediately before firing a
|
|
5
5
|
* registered task so that in multi-instance deployments only one
|
|
6
|
-
* instance runs a given task on each tick.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* instance runs a given task on each tick.
|
|
7
|
+
*
|
|
8
|
+
* Two backends ship with it: `MemoryLockBackend`, correct while the
|
|
9
|
+
* application runs in one process, and `RedisLockBackend`, which is
|
|
10
|
+
* what a second replica needs — see `locks` for the factories a
|
|
11
|
+
* config file names.
|
|
9
12
|
*
|
|
10
13
|
* @implements Story 28.3
|
|
11
14
|
*/
|
|
12
15
|
|
|
16
|
+
import { ReamError } from '../../errors/ReamError.js'
|
|
17
|
+
|
|
13
18
|
export interface LockBackend {
|
|
14
19
|
/**
|
|
15
20
|
* Attempt to acquire an exclusive lock on `name` for up to `ttlMs`
|
|
@@ -36,3 +41,21 @@ export interface LockBackend {
|
|
|
36
41
|
*/
|
|
37
42
|
release(name: string): Promise<void>
|
|
38
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reject a TTL that cannot bound anything. A zero or negative lease
|
|
47
|
+
* expires the instant it is taken, so every instance would acquire the
|
|
48
|
+
* same lock and run the same task — the exact outcome locking exists to
|
|
49
|
+
* prevent, arriving silently.
|
|
50
|
+
*/
|
|
51
|
+
export function assertValidTtl(ttlMs: number): void {
|
|
52
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
53
|
+
throw new ReamError(
|
|
54
|
+
'SCHEDULE_INVALID_LOCK_TTL',
|
|
55
|
+
`Lock TTL must be a finite positive number, got ${ttlMs}`,
|
|
56
|
+
{
|
|
57
|
+
hint: 'Use a millisecond value greater than zero (typical range 1_000 - 600_000).',
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Default single-process lock backend backed by a `Map`.
|
|
3
3
|
*
|
|
4
|
-
* Correct for apps that run in a single Node.js process
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* package" rule.
|
|
4
|
+
* Correct for apps that run in a single Node.js process, and only
|
|
5
|
+
* those: two processes each keep their own map, so both acquire the
|
|
6
|
+
* same name and both run the task. Reach for `RedisLockBackend` the
|
|
7
|
+
* moment a second replica exists.
|
|
9
8
|
*
|
|
10
9
|
* Expiry is evaluated **lazily** at `acquire` time — no background
|
|
11
10
|
* timer, no `setInterval`. This preserves Story 28.1's
|
|
@@ -20,23 +19,10 @@
|
|
|
20
19
|
* @implements Story 28.3
|
|
21
20
|
*/
|
|
22
21
|
|
|
23
|
-
import {
|
|
24
|
-
import type { LockBackend } from './LockBackend.js'
|
|
22
|
+
import { assertValidTtl, type LockBackend } from './LockBackend.js'
|
|
25
23
|
|
|
26
24
|
const SWEEP_THRESHOLD = 256
|
|
27
25
|
|
|
28
|
-
function assertValidTtl(ttlMs: number): void {
|
|
29
|
-
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
30
|
-
throw new ReamError(
|
|
31
|
-
'SCHEDULE_INVALID_LOCK_TTL',
|
|
32
|
-
`Lock TTL must be a finite positive number, got ${ttlMs}`,
|
|
33
|
-
{
|
|
34
|
-
hint: 'Use a millisecond value greater than zero (typical range 1_000 – 600_000).',
|
|
35
|
-
},
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
26
|
export class MemoryLockBackend implements LockBackend {
|
|
41
27
|
#locks = new Map<string, number>()
|
|
42
28
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis lock backend — the one a second replica needs.
|
|
3
|
+
*
|
|
4
|
+
* Without a shared lock, every instance of an application runs every
|
|
5
|
+
* scheduled task on every tick: a nightly invoice run goes out N times,
|
|
6
|
+
* a reminder email arrives N times, and nothing in the logs says so.
|
|
7
|
+
* `MemoryLockBackend` does not close that gap — each process keeps its
|
|
8
|
+
* own map — and the scheduler locks nothing at all by default.
|
|
9
|
+
*
|
|
10
|
+
* No import of a Redis package. The client is taken structurally — `set`
|
|
11
|
+
* and `eval` — so this works against a `@c9up/quasar` connection without
|
|
12
|
+
* ream depending on quasar, which is an optional peer.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from 'node:crypto'
|
|
16
|
+
import { assertValidTtl, type LockBackend } from './LockBackend.js'
|
|
17
|
+
|
|
18
|
+
/** The commands this issues. Any client answering them will do. */
|
|
19
|
+
export interface LockRedisClient {
|
|
20
|
+
/** `SET key value PX <ttl> NX` — answers null when the key already exists. */
|
|
21
|
+
set(key: string, value: string, ...args: unknown[]): Promise<unknown>
|
|
22
|
+
/** `EVAL script numKeys ...` — used for the compare-and-delete release. */
|
|
23
|
+
eval(script: string, numKeys: number, ...args: unknown[]): Promise<unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How the backend gets its client: the client itself, or something that
|
|
28
|
+
* answers with one.
|
|
29
|
+
*
|
|
30
|
+
* The resolver form is what a config file needs. `config/scheduler.ts` is
|
|
31
|
+
* read before the application boots, so the connection does not exist yet
|
|
32
|
+
* and cannot be awaited there — a function defers the lookup to the first
|
|
33
|
+
* task that fires.
|
|
34
|
+
*/
|
|
35
|
+
export type LockRedisResolver = LockRedisClient | (() => LockRedisClient | Promise<LockRedisClient>)
|
|
36
|
+
|
|
37
|
+
export interface RedisLockBackendOptions {
|
|
38
|
+
/** Key prefix. Default `"ream:schedule:lock:"`. */
|
|
39
|
+
prefix?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Release only a lease we still hold.
|
|
44
|
+
*
|
|
45
|
+
* A plain `DEL` is the classic way to break this: a task that outlives its
|
|
46
|
+
* TTL loses the lock, another instance acquires it and starts running, and
|
|
47
|
+
* the first one then deletes THAT instance's lease on its way out — leaving
|
|
48
|
+
* the name free while a task is still running under it. Comparing the token
|
|
49
|
+
* before deleting makes the release a no-op once the lease is no longer ours.
|
|
50
|
+
*/
|
|
51
|
+
const RELEASE = `
|
|
52
|
+
if redis.call('get', KEYS[1]) == ARGV[1] then
|
|
53
|
+
return redis.call('del', KEYS[1])
|
|
54
|
+
end
|
|
55
|
+
return 0
|
|
56
|
+
`
|
|
57
|
+
|
|
58
|
+
export class RedisLockBackend implements LockBackend {
|
|
59
|
+
readonly #source: LockRedisResolver
|
|
60
|
+
readonly #prefix: string
|
|
61
|
+
#resolved: Promise<LockRedisClient> | undefined
|
|
62
|
+
/** Our token per held name, so a release can prove the lease is ours. */
|
|
63
|
+
readonly #tokens = new Map<string, string>()
|
|
64
|
+
|
|
65
|
+
constructor(client: LockRedisResolver, options: RedisLockBackendOptions = {}) {
|
|
66
|
+
this.#source = client
|
|
67
|
+
this.#prefix = options.prefix ?? 'ream:schedule:lock:'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The client, resolved once and kept. */
|
|
71
|
+
#client(): Promise<LockRedisClient> {
|
|
72
|
+
if (!this.#resolved) {
|
|
73
|
+
this.#resolved = Promise.resolve(
|
|
74
|
+
typeof this.#source === 'function' ? this.#source() : this.#source,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
return this.#resolved
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#key(name: string): string {
|
|
81
|
+
return `${this.#prefix}${name}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `SET key token PX ttl NX` — one round trip, atomic on the server, which
|
|
86
|
+
* is what makes the decision the same for every instance asking at once.
|
|
87
|
+
*
|
|
88
|
+
* The TTL is what recovers the lock after a crash: an instance that dies
|
|
89
|
+
* holding it never releases, and the lease simply expires.
|
|
90
|
+
*/
|
|
91
|
+
async acquire(name: string, ttlMs: number): Promise<boolean> {
|
|
92
|
+
assertValidTtl(ttlMs)
|
|
93
|
+
const client = await this.#client()
|
|
94
|
+
const token = randomUUID()
|
|
95
|
+
// Redis takes an integer number of milliseconds; a fractional TTL is a
|
|
96
|
+
// protocol error, not a rounding detail.
|
|
97
|
+
const result = await client.set(this.#key(name), token, 'PX', Math.ceil(ttlMs), 'NX')
|
|
98
|
+
if (result === null || result === undefined) return false
|
|
99
|
+
this.#tokens.set(name, token)
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Idempotent, as the contract requires: releasing a name this instance
|
|
105
|
+
* never acquired — or one whose lease has since expired — does nothing.
|
|
106
|
+
*/
|
|
107
|
+
async release(name: string): Promise<void> {
|
|
108
|
+
const token = this.#tokens.get(name)
|
|
109
|
+
if (token === undefined) return
|
|
110
|
+
this.#tokens.delete(name)
|
|
111
|
+
const client = await this.#client()
|
|
112
|
+
await client.eval(RELEASE, 1, this.#key(name), token)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lock backends a scheduler config names.
|
|
3
|
+
*
|
|
4
|
+
* // config/scheduler.ts
|
|
5
|
+
* import { defineConfig, locks } from '@c9up/ream'
|
|
6
|
+
*
|
|
7
|
+
* export default defineConfig({
|
|
8
|
+
* lock: locks.redis({ connection: 'main' }),
|
|
9
|
+
* })
|
|
10
|
+
*
|
|
11
|
+
* Without a lock the scheduler runs every task on every instance, which is
|
|
12
|
+
* correct while there is one, and duplicates every run once there are two.
|
|
13
|
+
*
|
|
14
|
+
* Factories are lazy: the backend is built when the scheduler is, so naming
|
|
15
|
+
* a Redis lock in a config that runs single-instance costs nothing until it
|
|
16
|
+
* is selected.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { LockBackend } from './LockBackend.js'
|
|
20
|
+
import { MemoryLockBackend } from './MemoryLockBackend.js'
|
|
21
|
+
import { quasarConnection } from './quasar.js'
|
|
22
|
+
import { type LockRedisResolver, RedisLockBackend } from './RedisLockBackend.js'
|
|
23
|
+
|
|
24
|
+
/** A lock backend, built on first use. */
|
|
25
|
+
export type LockBackendFactory = () => LockBackend
|
|
26
|
+
|
|
27
|
+
export const locks = {
|
|
28
|
+
/**
|
|
29
|
+
* In this process's memory. Two processes each keep their own, so this
|
|
30
|
+
* bounds nothing across replicas — it is for a single-process deployment
|
|
31
|
+
* and for tests.
|
|
32
|
+
*/
|
|
33
|
+
memory(): LockBackendFactory {
|
|
34
|
+
return () => new MemoryLockBackend()
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Redis. `connection` takes a client, a function answering one, or the
|
|
39
|
+
* NAME of a `@c9up/quasar` connection — the last of which is resolved at
|
|
40
|
+
* runtime without ream importing quasar, which stays an optional peer.
|
|
41
|
+
*/
|
|
42
|
+
redis(options: { connection: LockRedisResolver | string; prefix?: string }): LockBackendFactory {
|
|
43
|
+
const client: LockRedisResolver =
|
|
44
|
+
typeof options.connection === 'string'
|
|
45
|
+
? quasarConnection(options.connection)
|
|
46
|
+
: options.connection
|
|
47
|
+
return () => new RedisLockBackend(client, { prefix: options.prefix })
|
|
48
|
+
},
|
|
49
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving a Redis connection by name, from `@c9up/quasar`.
|
|
3
|
+
*
|
|
4
|
+
* Ream does not depend on quasar: it is an optional peer, and this module
|
|
5
|
+
* never imports it statically. The specifier is built at runtime so the
|
|
6
|
+
* TypeScript build stays free of it too — a hard type import would make
|
|
7
|
+
* ream unbuildable for anyone scheduling on a single instance.
|
|
8
|
+
*
|
|
9
|
+
* Same bridge the session store uses, for the same reason.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { LockRedisClient } from './RedisLockBackend.js'
|
|
13
|
+
|
|
14
|
+
/** The slice of quasar's manager this needs: a connection, by name. */
|
|
15
|
+
interface ConnectionSource {
|
|
16
|
+
connection(name?: string): unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isConnectionSource(value: unknown): value is ConnectionSource {
|
|
20
|
+
return (
|
|
21
|
+
typeof value === 'object' &&
|
|
22
|
+
value !== null &&
|
|
23
|
+
typeof Reflect.get(value, 'connection') === 'function'
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The commands the backend issues, checked before use: a connection missing
|
|
29
|
+
* one would fail on the first task that fires, far from the cause.
|
|
30
|
+
*/
|
|
31
|
+
function isLockRedisClient(value: unknown): value is LockRedisClient {
|
|
32
|
+
if (typeof value !== 'object' || value === null) return false
|
|
33
|
+
return ['set', 'eval'].every((name) => typeof Reflect.get(value, name) === 'function')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A resolver for `locks.redis({ connection })` — quasar is loaded when the
|
|
38
|
+
* first task fires, not while the config file is read.
|
|
39
|
+
*/
|
|
40
|
+
export function quasarConnection(name?: string): () => Promise<LockRedisClient> {
|
|
41
|
+
return async () => {
|
|
42
|
+
const specifier = '@c9up/quasar/services/main'
|
|
43
|
+
let loaded: unknown
|
|
44
|
+
try {
|
|
45
|
+
loaded = await import(/* @vite-ignore */ specifier)
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Ream: the scheduler lock asks for the '${name ?? 'default'}' quasar connection, but @c9up/quasar is not installed.\n` +
|
|
49
|
+
' pnpm add @c9up/quasar',
|
|
50
|
+
{ cause },
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const manager = isConnectionSource(loaded) ? loaded : Reflect.get(Object(loaded), 'default')
|
|
55
|
+
if (!isConnectionSource(manager)) {
|
|
56
|
+
throw new Error('Ream: @c9up/quasar/services/main did not expose a connection() manager')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const connection = manager.connection(name)
|
|
60
|
+
if (!isLockRedisClient(connection)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Ream: quasar connection '${name ?? 'default'}' does not carry the commands a scheduler lock needs`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
return connection
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -2,19 +2,22 @@
|
|
|
2
2
|
* Cookie signing and encryption.
|
|
3
3
|
* Uses Rust NAPI via crypto facade when available.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* The cipher is `aes-256-gcm`, where authentication is part of the cipher
|
|
6
|
+
* rather than a second primitive bolted on: encrypt-then-MAC assembled by hand
|
|
7
|
+
* is a well-known source of subtle breaks.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* That is upstream's own current choice, not a divergence from it — v7 ships
|
|
10
|
+
* AES-256-GCM, ChaCha20-Poly1305 and AES-SIV as first-class drivers, and its
|
|
11
|
+
* documented example configures `aes256gcm` as the default. The CBC + HMAC
|
|
12
|
+
* construction lives on there under the name `legacy`, described in its own
|
|
13
|
+
* source as maintaining compatibility with the OLD v6 format.
|
|
12
14
|
*
|
|
13
|
-
* The consequence, stated plainly: a cookie encrypted by
|
|
14
|
-
* be decrypted here
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* The consequence, stated plainly: a cookie encrypted by a v6-era application
|
|
16
|
+
* cannot be decrypted here — those users are signed out once, at deploy. The
|
|
17
|
+
* same is true of upstream v7 unless the legacy driver is configured. Should
|
|
18
|
+
* that migration path ever be wanted, the answer is the one taken there: an
|
|
19
|
+
* additional, explicitly named legacy reader — never a downgrade of the
|
|
20
|
+
* default.
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
23
|
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto'
|
|
@@ -29,15 +32,29 @@ export class E_MISSING_APP_KEY extends Error {
|
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
/** Raised when the APP_KEY is too short
|
|
35
|
+
/** Raised when the APP_KEY is too short, or is a value everyone already has. */
|
|
33
36
|
export class E_INSECURE_APP_KEY extends Error {
|
|
34
37
|
readonly code = 'E_INSECURE_APP_KEY' as const
|
|
35
|
-
constructor() {
|
|
36
|
-
super(
|
|
38
|
+
constructor(reason = 'it must be at least 16 characters') {
|
|
39
|
+
super(`APP_KEY is not usable — ${reason}.`)
|
|
37
40
|
this.name = 'E_INSECURE_APP_KEY'
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Keys that appear in scaffolding, documentation and tutorials.
|
|
46
|
+
*
|
|
47
|
+
* A key anyone can read is not a secret: with it, cookies, sessions, CSRF
|
|
48
|
+
* tokens and signed URLs can all be forged. Refusing them costs a developer
|
|
49
|
+
* one `ream generate:key` and costs an attacker everything.
|
|
50
|
+
*/
|
|
51
|
+
const PUBLICLY_KNOWN_KEYS = new Set([
|
|
52
|
+
'change-me-to-a-unique-32+-byte-secret!!',
|
|
53
|
+
'change-me',
|
|
54
|
+
'your-app-key-here',
|
|
55
|
+
'secret',
|
|
56
|
+
])
|
|
57
|
+
|
|
41
58
|
/** The envelope carried inside an encrypted or signed value. */
|
|
42
59
|
interface Envelope {
|
|
43
60
|
/**
|
|
@@ -61,6 +78,11 @@ export class CookieSigner {
|
|
|
61
78
|
// configuration error, not something to discover at decrypt time.
|
|
62
79
|
if (typeof secret !== 'string' || secret.length === 0) throw new E_MISSING_APP_KEY()
|
|
63
80
|
if (secret.length < 16) throw new E_INSECURE_APP_KEY()
|
|
81
|
+
if (PUBLICLY_KNOWN_KEYS.has(secret.trim().toLowerCase())) {
|
|
82
|
+
throw new E_INSECURE_APP_KEY(
|
|
83
|
+
'it is a placeholder from the scaffolding, which everyone can read. Run `ream generate:key`',
|
|
84
|
+
)
|
|
85
|
+
}
|
|
64
86
|
this.#secret = secret
|
|
65
87
|
// Derive a 32-byte key for AES-256-GCM via HKDF-like derivation
|
|
66
88
|
this.#keyBuffer = Buffer.from(createHmac('sha256', secret).update('cookie-key').digest())
|
|
@@ -14,8 +14,18 @@ import { createServiceProxy } from './createServiceProxy.js'
|
|
|
14
14
|
|
|
15
15
|
let instance: CookieSigner | undefined
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* @internal Set the signer, or clear it with `undefined`.
|
|
19
|
+
*
|
|
20
|
+
* Called by the Ignitor for EVERY boot, key or no key. Leaving a previous
|
|
21
|
+
* application's signer in place when this one has none is the dangerous
|
|
22
|
+
* reading: `import encryption from '.../services/encryption'` would answer
|
|
23
|
+
* with a key this application never configured, and sign its cookies with it.
|
|
24
|
+
* Last boot owns the locator — the same rule as `setApp` / `setRouter` /
|
|
25
|
+
* `setServer` beside it. A process running two applications at once should
|
|
26
|
+
* resolve `'encryption'` from its own container, which is per-application.
|
|
27
|
+
*/
|
|
28
|
+
export function setEncryption(signer: CookieSigner | undefined): void {
|
|
19
29
|
instance = signer
|
|
20
30
|
}
|
|
21
31
|
|
package/src/session/Session.ts
CHANGED
|
@@ -78,16 +78,17 @@ const INTENDED_URL_KEY = '__intended_url'
|
|
|
78
78
|
|
|
79
79
|
export interface SessionConfig {
|
|
80
80
|
/**
|
|
81
|
-
* Which store to use
|
|
82
|
-
*
|
|
83
|
-
*
|
|
81
|
+
* Which store to use — a key of {@link stores}, or a driver name on its own.
|
|
82
|
+
* Read from the environment in the generated config, so a deployment picks
|
|
83
|
+
* its backend without editing a file.
|
|
84
84
|
*/
|
|
85
|
-
driver?: string
|
|
86
|
-
/** AdonisJS spelling of {@link driver}. */
|
|
87
85
|
store?: string
|
|
86
|
+
/** Ream's older spelling of {@link store}. Both are accepted; `store` wins. */
|
|
87
|
+
driver?: string
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
90
|
-
* The selected entry supplies the
|
|
89
|
+
* The stores this application can use, by name:
|
|
90
|
+
* `{ store: 'redis', stores: { redis: … } }`. The selected entry supplies the
|
|
91
|
+
* driver name and its options.
|
|
91
92
|
*/
|
|
92
93
|
stores?: Record<string, { driver: string } & Record<string, unknown>>
|
|
93
94
|
/** `file` driver only — the directory session files are written to. */
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { randomBytes } from 'node:crypto'
|
|
9
|
+
import { currentNodeEnv } from '../env/nodeEnv.js'
|
|
9
10
|
import { durationToSeconds } from '../helpers/duration.js'
|
|
10
11
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
11
12
|
import { CookieDriver } from './drivers/CookieDriver.js'
|
|
@@ -104,7 +105,9 @@ export default class SessionMiddleware {
|
|
|
104
105
|
const cookieName = this.#config.cookieName
|
|
105
106
|
const maxAge = this.#config.maxAge
|
|
106
107
|
const rolling = this.#config.rolling
|
|
107
|
-
|
|
108
|
+
// Normalised: `NODE_ENV=prod` reaching here as "not production" ships
|
|
109
|
+
// the session cookie without Secure, over plain HTTP.
|
|
110
|
+
const isProduction = currentNodeEnv() === 'production'
|
|
108
111
|
|
|
109
112
|
// Read the session cookie — pre-parsed by the Rust HyperServer.
|
|
110
113
|
//
|