arcway 0.1.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/LICENSE +21 -0
- package/README.md +711 -0
- package/client/env.js +55 -0
- package/client/fetcher.js +50 -0
- package/client/graphql.js +35 -0
- package/client/head.js +140 -0
- package/client/hooks/use-api.js +80 -0
- package/client/hooks/use-debounce.js +12 -0
- package/client/hooks/use-form.js +86 -0
- package/client/hooks/use-graphql.js +30 -0
- package/client/hooks/use-interval.js +12 -0
- package/client/hooks/use-mutation.js +27 -0
- package/client/hooks/use-query.js +45 -0
- package/client/hooks/web/use-click-outside.js +22 -0
- package/client/hooks/web/use-local-storage.js +42 -0
- package/client/index.js +62 -0
- package/client/page-loader.js +155 -0
- package/client/provider.js +53 -0
- package/client/query.js +13 -0
- package/client/router.jsx +303 -0
- package/client/ui/accordion.jsx +65 -0
- package/client/ui/accordion.stories.jsx +48 -0
- package/client/ui/alert-dialog.jsx +122 -0
- package/client/ui/alert-dialog.stories.jsx +44 -0
- package/client/ui/alert.jsx +52 -0
- package/client/ui/alert.stories.jsx +31 -0
- package/client/ui/app-shell.jsx +39 -0
- package/client/ui/app-shell.stories.jsx +51 -0
- package/client/ui/aspect-ratio.jsx +6 -0
- package/client/ui/aspect-ratio.stories.jsx +69 -0
- package/client/ui/avatar.jsx +78 -0
- package/client/ui/avatar.stories.jsx +62 -0
- package/client/ui/badge.jsx +34 -0
- package/client/ui/badge.stories.js +32 -0
- package/client/ui/breadcrumb.jsx +86 -0
- package/client/ui/breadcrumb.stories.jsx +43 -0
- package/client/ui/button-group.jsx +58 -0
- package/client/ui/button-group.stories.jsx +67 -0
- package/client/ui/button.jsx +46 -0
- package/client/ui/button.stories.js +72 -0
- package/client/ui/calendar.jsx +172 -0
- package/client/ui/card.jsx +57 -0
- package/client/ui/card.stories.jsx +33 -0
- package/client/ui/carousel.jsx +167 -0
- package/client/ui/chart.jsx +244 -0
- package/client/ui/checkbox.jsx +24 -0
- package/client/ui/checkbox.stories.js +33 -0
- package/client/ui/collapsible.jsx +12 -0
- package/client/ui/collapsible.stories.jsx +42 -0
- package/client/ui/combobox.jsx +223 -0
- package/client/ui/command.jsx +128 -0
- package/client/ui/context-menu.jsx +170 -0
- package/client/ui/context-menu.stories.jsx +35 -0
- package/client/ui/dialog.jsx +109 -0
- package/client/ui/dialog.stories.jsx +37 -0
- package/client/ui/direction.jsx +9 -0
- package/client/ui/drawer.jsx +87 -0
- package/client/ui/dropdown-menu.jsx +172 -0
- package/client/ui/dropdown-menu.stories.jsx +34 -0
- package/client/ui/empty.jsx +76 -0
- package/client/ui/empty.stories.jsx +64 -0
- package/client/ui/field.jsx +174 -0
- package/client/ui/field.stories.jsx +118 -0
- package/client/ui/form.jsx +17 -0
- package/client/ui/hooks/use-mobile.js +16 -0
- package/client/ui/hover-card.jsx +26 -0
- package/client/ui/hover-card.stories.jsx +28 -0
- package/client/ui/index.js +649 -0
- package/client/ui/input-group.jsx +116 -0
- package/client/ui/input-group.stories.jsx +65 -0
- package/client/ui/input-otp.jsx +62 -0
- package/client/ui/input.jsx +16 -0
- package/client/ui/input.stories.js +27 -0
- package/client/ui/item.jsx +155 -0
- package/client/ui/item.stories.jsx +118 -0
- package/client/ui/kbd.jsx +24 -0
- package/client/ui/kbd.stories.jsx +32 -0
- package/client/ui/label.jsx +16 -0
- package/client/ui/label.stories.js +25 -0
- package/client/ui/lib/utils.js +6 -0
- package/client/ui/main-content.jsx +30 -0
- package/client/ui/menubar.jsx +189 -0
- package/client/ui/menubar.stories.jsx +43 -0
- package/client/ui/native-select.jsx +34 -0
- package/client/ui/native-select.stories.jsx +67 -0
- package/client/ui/navigation-menu.jsx +120 -0
- package/client/ui/navigation-menu.stories.jsx +45 -0
- package/client/ui/pagination.jsx +92 -0
- package/client/ui/pagination.stories.jsx +52 -0
- package/client/ui/panel.jsx +66 -0
- package/client/ui/popover.jsx +54 -0
- package/client/ui/popover.stories.jsx +27 -0
- package/client/ui/progress.jsx +19 -0
- package/client/ui/progress.stories.js +34 -0
- package/client/ui/radio-group.jsx +33 -0
- package/client/ui/radio-group.stories.jsx +49 -0
- package/client/ui/resizable.jsx +33 -0
- package/client/ui/scroll-area.jsx +41 -0
- package/client/ui/scroll-area.stories.jsx +43 -0
- package/client/ui/select.jsx +145 -0
- package/client/ui/select.stories.jsx +80 -0
- package/client/ui/separator.jsx +18 -0
- package/client/ui/separator.stories.jsx +37 -0
- package/client/ui/sheet.jsx +95 -0
- package/client/ui/sheet.stories.jsx +56 -0
- package/client/ui/sidebar.jsx +544 -0
- package/client/ui/skeleton.jsx +8 -0
- package/client/ui/skeleton.stories.js +23 -0
- package/client/ui/slider.jsx +41 -0
- package/client/ui/slider.stories.js +31 -0
- package/client/ui/sonner.jsx +37 -0
- package/client/ui/spinner.jsx +14 -0
- package/client/ui/spinner.stories.js +16 -0
- package/client/ui/style-mira.css +1316 -0
- package/client/ui/switch.jsx +22 -0
- package/client/ui/switch.stories.js +44 -0
- package/client/ui/table.jsx +33 -0
- package/client/ui/table.stories.jsx +42 -0
- package/client/ui/tabs.jsx +63 -0
- package/client/ui/tabs.stories.jsx +45 -0
- package/client/ui/textarea.jsx +15 -0
- package/client/ui/textarea.stories.js +33 -0
- package/client/ui/theme.css +459 -0
- package/client/ui/toggle-group.jsx +62 -0
- package/client/ui/toggle-group.stories.jsx +68 -0
- package/client/ui/toggle.jsx +34 -0
- package/client/ui/toggle.stories.js +46 -0
- package/client/ui/tooltip.jsx +37 -0
- package/client/ui/tooltip.stories.jsx +32 -0
- package/client/ui/use-transition.js +35 -0
- package/client/ws.js +132 -0
- package/package.json +134 -0
- package/server/bin/cli.js +42 -0
- package/server/bin/commands/build.js +23 -0
- package/server/bin/commands/dev.js +57 -0
- package/server/bin/commands/docs.js +30 -0
- package/server/bin/commands/graphql-schema.js +32 -0
- package/server/bin/commands/lint.js +35 -0
- package/server/bin/commands/mcp.js +26 -0
- package/server/bin/commands/migrate.js +82 -0
- package/server/bin/commands/schema.js +41 -0
- package/server/bin/commands/seed.js +36 -0
- package/server/bin/commands/start.js +31 -0
- package/server/bin/commands/test.js +20 -0
- package/server/bin/solo.js +4 -0
- package/server/boot/index.js +150 -0
- package/server/boot.js +2 -0
- package/server/build.js +23 -0
- package/server/cache/drivers/memory.js +23 -0
- package/server/cache/drivers/redis.js +28 -0
- package/server/cache/index.js +69 -0
- package/server/config/loader.js +89 -0
- package/server/config/modules/api.js +17 -0
- package/server/config/modules/build.js +9 -0
- package/server/config/modules/cache.js +10 -0
- package/server/config/modules/database.js +29 -0
- package/server/config/modules/events.js +15 -0
- package/server/config/modules/files.js +15 -0
- package/server/config/modules/jobs.js +20 -0
- package/server/config/modules/logger.js +9 -0
- package/server/config/modules/mail.js +11 -0
- package/server/config/modules/mcp.js +9 -0
- package/server/config/modules/pages.js +20 -0
- package/server/config/modules/queue.js +10 -0
- package/server/config/modules/redis.js +9 -0
- package/server/config/modules/server.js +30 -0
- package/server/config/modules/session.js +9 -0
- package/server/config/modules/websocket.js +11 -0
- package/server/constants.js +67 -0
- package/server/context.js +15 -0
- package/server/db/index.js +87 -0
- package/server/db/schema/drivers/mysql.js +28 -0
- package/server/db/schema/drivers/pg.js +34 -0
- package/server/db/schema/drivers/sqlite.js +22 -0
- package/server/db/schema/index.js +78 -0
- package/server/db/seeds.js +22 -0
- package/server/discovery.js +67 -0
- package/server/docs/openapi.js +153 -0
- package/server/env.js +17 -0
- package/server/events/drivers/memory.js +45 -0
- package/server/events/drivers/redis.js +64 -0
- package/server/events/handler.js +67 -0
- package/server/events/index.js +35 -0
- package/server/events/pattern.js +5 -0
- package/server/files/drivers/local.js +83 -0
- package/server/files/drivers/s3.js +113 -0
- package/server/files/index.js +57 -0
- package/server/filewatcher/index.js +156 -0
- package/server/glob.js +6 -0
- package/server/graphql/discovery.js +70 -0
- package/server/graphql/handler.js +41 -0
- package/server/graphql/index.js +13 -0
- package/server/graphql/loaders.js +19 -0
- package/server/graphql/merge.js +48 -0
- package/server/graphql/subscriptions.js +43 -0
- package/server/health.js +34 -0
- package/server/helpers.js +9 -0
- package/server/index.js +55 -0
- package/server/internals.js +139 -0
- package/server/jobs/cron.js +10 -0
- package/server/jobs/drivers/knex-queue.js +207 -0
- package/server/jobs/drivers/lease.js +148 -0
- package/server/jobs/drivers/memory-queue.js +134 -0
- package/server/jobs/queue.js +27 -0
- package/server/jobs/runner.js +197 -0
- package/server/jobs/throughput.js +63 -0
- package/server/lib/vault/encrypt.js +40 -0
- package/server/lib/vault/ids.js +9 -0
- package/server/lib/vault/index.js +14 -0
- package/server/lib/vault/jwt.js +55 -0
- package/server/lib/vault/password.js +10 -0
- package/server/lint/boundaries.js +77 -0
- package/server/logger/index.js +130 -0
- package/server/mail/drivers/console.js +31 -0
- package/server/mail/drivers/smtp.js +34 -0
- package/server/mail/imap.js +105 -0
- package/server/mail/inbound-store.js +58 -0
- package/server/mail/inbound.js +79 -0
- package/server/mail/index.js +112 -0
- package/server/mcp/debug-api.js +137 -0
- package/server/mcp/helpers.js +30 -0
- package/server/mcp/index.js +77 -0
- package/server/mcp/runtime.js +7 -0
- package/server/mcp/server.js +19 -0
- package/server/mcp/tools/debugging.js +133 -0
- package/server/mcp/tools/introspection.js +87 -0
- package/server/middlewares/cors.js +30 -0
- package/server/middlewares/index.js +3 -0
- package/server/middlewares/require-session.js +15 -0
- package/server/module-loader.js +9 -0
- package/server/pages/build-client.js +187 -0
- package/server/pages/build-css.js +47 -0
- package/server/pages/build-manifest.js +55 -0
- package/server/pages/build-plugins.js +75 -0
- package/server/pages/build-server.js +115 -0
- package/server/pages/build.js +116 -0
- package/server/pages/discovery.js +120 -0
- package/server/pages/fonts.js +128 -0
- package/server/pages/handler.js +276 -0
- package/server/pages/hmr.js +176 -0
- package/server/pages/pages-router.js +78 -0
- package/server/pages/ssr.js +276 -0
- package/server/pages/static.js +92 -0
- package/server/pages/watcher.js +90 -0
- package/server/queue/drivers/knex.js +67 -0
- package/server/queue/drivers/redis.js +91 -0
- package/server/queue/index.js +61 -0
- package/server/rate-limit/consume.js +21 -0
- package/server/rate-limit/drivers/memory.js +24 -0
- package/server/rate-limit/drivers/redis.js +32 -0
- package/server/rate-limit/index.js +33 -0
- package/server/redis/index.js +67 -0
- package/server/ring-buffer.js +44 -0
- package/server/route.js +4 -0
- package/server/router/api-router.js +317 -0
- package/server/router/cors.js +31 -0
- package/server/router/middleware.js +91 -0
- package/server/router/routes.js +132 -0
- package/server/server.js +35 -0
- package/server/session/helpers.js +21 -0
- package/server/session/index.js +89 -0
- package/server/static/index.js +36 -0
- package/server/system-jobs/index.js +50 -0
- package/server/system-routes/index.js +84 -0
- package/server/testing/index.js +263 -0
- package/server/validation.js +41 -0
- package/server/watcher.js +34 -0
- package/server/web-server.js +231 -0
- package/server/ws/discovery.js +54 -0
- package/server/ws/index.js +14 -0
- package/server/ws/realtime.js +318 -0
- package/server/ws/registry.js +17 -0
- package/server/ws/server.js +152 -0
- package/server/ws/ws-router.js +335 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import pLimit from 'p-limit';
|
|
2
|
+
import { LEASE_HEARTBEAT_INTERVAL_MS, LEASE_EXPIRY_MS } from '../../constants.js';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
class LeaseManager {
|
|
6
|
+
db;
|
|
7
|
+
tableName;
|
|
8
|
+
runnerId;
|
|
9
|
+
heartbeatTimers = new Map();
|
|
10
|
+
/** In-memory limiters per job type (used when db is not available) */
|
|
11
|
+
_limiters = new Map();
|
|
12
|
+
|
|
13
|
+
constructor(db, options) {
|
|
14
|
+
this.db = db ?? null;
|
|
15
|
+
this.tableName = options?.tableName ?? 'arcway_job_leases';
|
|
16
|
+
this.runnerId = options?.runnerId ?? randomUUID();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Create the leases table if it doesn't exist. No-op without a db. */
|
|
20
|
+
async init() {
|
|
21
|
+
if (!this.db) return;
|
|
22
|
+
const exists = await this.db.schema.hasTable(this.tableName);
|
|
23
|
+
if (!exists) {
|
|
24
|
+
await this.db.schema.createTable(this.tableName, (table) => {
|
|
25
|
+
table.increments('id').primary();
|
|
26
|
+
table.string('qualified_name').notNullable().index();
|
|
27
|
+
table.integer('job_id').notNullable();
|
|
28
|
+
table.string('runner_id').notNullable();
|
|
29
|
+
table.timestamp('acquired_at').notNullable();
|
|
30
|
+
table.timestamp('heartbeat_at').notNullable();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Try to acquire a lease for the given job.
|
|
37
|
+
*
|
|
38
|
+
* With a DB: cleans up expired leases, checks active count, inserts a row.
|
|
39
|
+
* Without a DB: uses p-limit for in-memory concurrency tracking.
|
|
40
|
+
*
|
|
41
|
+
* @returns true if lease acquired, false if at capacity
|
|
42
|
+
*/
|
|
43
|
+
async acquire(qualifiedName, jobId, maxConcurrency) {
|
|
44
|
+
if (!this.db) {
|
|
45
|
+
return this._acquireInMemory(qualifiedName, maxConcurrency);
|
|
46
|
+
}
|
|
47
|
+
return this.db.transaction(async (trx) => {
|
|
48
|
+
const expiryCutoff = new Date(Date.now() - LEASE_EXPIRY_MS).toISOString();
|
|
49
|
+
await trx(this.tableName)
|
|
50
|
+
.where('qualified_name', qualifiedName)
|
|
51
|
+
.andWhere('heartbeat_at', '<', expiryCutoff)
|
|
52
|
+
.delete();
|
|
53
|
+
const result = await trx(this.tableName)
|
|
54
|
+
.where('qualified_name', qualifiedName)
|
|
55
|
+
.count('id as count')
|
|
56
|
+
.first();
|
|
57
|
+
const activeCount = Number(result?.count ?? 0);
|
|
58
|
+
if (activeCount >= maxConcurrency) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
const now = new Date().toISOString();
|
|
62
|
+
await trx(this.tableName).insert({
|
|
63
|
+
qualified_name: qualifiedName,
|
|
64
|
+
job_id: jobId,
|
|
65
|
+
runner_id: this.runnerId,
|
|
66
|
+
acquired_at: now,
|
|
67
|
+
heartbeat_at: now,
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_acquireInMemory(qualifiedName, maxConcurrency) {
|
|
74
|
+
let limiter = this._limiters.get(qualifiedName);
|
|
75
|
+
if (!limiter) {
|
|
76
|
+
limiter = { limit: pLimit(maxConcurrency), active: 0 };
|
|
77
|
+
this._limiters.set(qualifiedName, limiter);
|
|
78
|
+
}
|
|
79
|
+
if (limiter.active >= maxConcurrency) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
limiter.active++;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Start a heartbeat loop for a job lease.
|
|
88
|
+
* No-op without a db — in-memory mode doesn't need heartbeats.
|
|
89
|
+
*/
|
|
90
|
+
startHeartbeat(jobId) {
|
|
91
|
+
if (!this.db) return;
|
|
92
|
+
const timer = setInterval(async () => {
|
|
93
|
+
try {
|
|
94
|
+
await this.db(this.tableName)
|
|
95
|
+
.where('job_id', jobId)
|
|
96
|
+
.andWhere('runner_id', this.runnerId)
|
|
97
|
+
.update({ heartbeat_at: new Date().toISOString() });
|
|
98
|
+
} catch {
|
|
99
|
+
this.stopHeartbeat(jobId);
|
|
100
|
+
}
|
|
101
|
+
}, LEASE_HEARTBEAT_INTERVAL_MS);
|
|
102
|
+
if (timer.unref) timer.unref();
|
|
103
|
+
this.heartbeatTimers.set(jobId, timer);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Stop the heartbeat loop for a job. */
|
|
107
|
+
stopHeartbeat(jobId) {
|
|
108
|
+
const timer = this.heartbeatTimers.get(jobId);
|
|
109
|
+
if (timer) {
|
|
110
|
+
clearInterval(timer);
|
|
111
|
+
this.heartbeatTimers.delete(jobId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Release a lease after job completion or failure. */
|
|
116
|
+
async release(jobId, qualifiedName) {
|
|
117
|
+
this.stopHeartbeat(jobId);
|
|
118
|
+
if (!this.db) {
|
|
119
|
+
const limiter = this._limiters.get(qualifiedName);
|
|
120
|
+
if (limiter && limiter.active > 0) limiter.active--;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await this.db(this.tableName)
|
|
124
|
+
.where('job_id', jobId)
|
|
125
|
+
.andWhere('runner_id', this.runnerId)
|
|
126
|
+
.delete();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Release all leases held by this runner (used during shutdown). */
|
|
130
|
+
async releaseAll() {
|
|
131
|
+
for (const [jobId] of this.heartbeatTimers) {
|
|
132
|
+
this.stopHeartbeat(jobId);
|
|
133
|
+
}
|
|
134
|
+
if (this.db) {
|
|
135
|
+
await this.db(this.tableName).where('runner_id', this.runnerId).delete();
|
|
136
|
+
}
|
|
137
|
+
// Reset in-memory limiters
|
|
138
|
+
for (const limiter of this._limiters.values()) {
|
|
139
|
+
limiter.active = 0;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Get the runner ID for this lease manager instance. */
|
|
144
|
+
getRunnerId() {
|
|
145
|
+
return this.runnerId;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export default LeaseManager;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { buildContext } from '../../context.js';
|
|
2
|
+
import { validateEnqueue, toError, calculateBackoff } from '../queue.js';
|
|
3
|
+
import { MemoryThroughputTracker } from '../throughput.js';
|
|
4
|
+
import LeaseManager from './lease.js';
|
|
5
|
+
|
|
6
|
+
class JobDispatcher {
|
|
7
|
+
queue = [];
|
|
8
|
+
registered = new Map();
|
|
9
|
+
backoffMs;
|
|
10
|
+
throughputTracker = new MemoryThroughputTracker();
|
|
11
|
+
leaseManager;
|
|
12
|
+
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.backoffMs = options?.backoffMs ?? 1000;
|
|
15
|
+
this.leaseManager = new LeaseManager(null);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
register(domain, definition, store) {
|
|
19
|
+
const qualifiedName = `${domain}/${definition.name}`;
|
|
20
|
+
this.registered.set(qualifiedName, { domain, definition, store });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async enqueue(qualifiedName, payload, options) {
|
|
24
|
+
const { reg, validatedPayload, maxRetries, delay } = validateEnqueue(
|
|
25
|
+
qualifiedName,
|
|
26
|
+
payload,
|
|
27
|
+
this.registered,
|
|
28
|
+
options,
|
|
29
|
+
);
|
|
30
|
+
const job = {
|
|
31
|
+
id: `${qualifiedName}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`,
|
|
32
|
+
domain: reg.domain,
|
|
33
|
+
jobName: reg.definition.name,
|
|
34
|
+
payload: validatedPayload,
|
|
35
|
+
maxRetries,
|
|
36
|
+
attempt: 0,
|
|
37
|
+
runAt: Date.now() + delay,
|
|
38
|
+
status: 'pending',
|
|
39
|
+
};
|
|
40
|
+
this.queue.push(job);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async process() {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const ready = this.queue.filter((j) => j.status === 'pending' && j.runAt <= now);
|
|
46
|
+
const results = [];
|
|
47
|
+
|
|
48
|
+
for (const job of ready) {
|
|
49
|
+
const qualifiedName = `${job.domain}/${job.jobName}`;
|
|
50
|
+
const reg = this.registered.get(qualifiedName);
|
|
51
|
+
if (!reg) {
|
|
52
|
+
job.status = 'failed';
|
|
53
|
+
results.push({
|
|
54
|
+
id: job.id,
|
|
55
|
+
status: 'failed',
|
|
56
|
+
error: new Error(`Job definition not found: ${qualifiedName}`),
|
|
57
|
+
attempts: job.attempt,
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (reg.definition.throughput) {
|
|
63
|
+
if (!this.throughputTracker.isAllowed(qualifiedName, reg.definition.throughput)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (reg.definition.maxConcurrency !== undefined) {
|
|
69
|
+
const acquired = this.leaseManager.acquire(
|
|
70
|
+
qualifiedName,
|
|
71
|
+
job.id,
|
|
72
|
+
reg.definition.maxConcurrency,
|
|
73
|
+
);
|
|
74
|
+
if (!acquired) continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const result = await this._executeJob(job, reg, qualifiedName);
|
|
78
|
+
if (result) results.push(result);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.queue = this.queue.filter((j) => j.status === 'pending');
|
|
82
|
+
return results;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async _executeJob(job, reg, qualifiedName) {
|
|
86
|
+
job.attempt++;
|
|
87
|
+
job.status = 'running';
|
|
88
|
+
const maxAttempts = job.maxRetries + 1;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
console.log(`[job] ${qualifiedName} attempt ${job.attempt}/${maxAttempts}`);
|
|
92
|
+
const ctx = buildContext(reg.store, { payload: job.payload });
|
|
93
|
+
await reg.definition.handler(ctx);
|
|
94
|
+
job.status = 'completed';
|
|
95
|
+
console.log(`[job] ${qualifiedName} completed`);
|
|
96
|
+
this.throughputTracker.record(qualifiedName);
|
|
97
|
+
return { id: job.id, status: 'completed', attempts: job.attempt };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const error = toError(err);
|
|
100
|
+
console.error(
|
|
101
|
+
`[job] ${qualifiedName} attempt ${job.attempt}/${maxAttempts} failed:`,
|
|
102
|
+
error.message,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (job.attempt < maxAttempts) {
|
|
106
|
+
const backoffMs = calculateBackoff(this.backoffMs, job.attempt);
|
|
107
|
+
job.status = 'pending';
|
|
108
|
+
job.runAt = Date.now() + backoffMs;
|
|
109
|
+
console.log(
|
|
110
|
+
`[job] ${qualifiedName} retrying in ${backoffMs}ms (attempt ${job.attempt + 1}/${maxAttempts})`,
|
|
111
|
+
);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
job.status = 'failed';
|
|
116
|
+
return { id: job.id, status: 'failed', error, attempts: job.attempt };
|
|
117
|
+
} finally {
|
|
118
|
+
if (reg.definition.maxConcurrency !== undefined) {
|
|
119
|
+
await this.leaseManager.release(job.id, qualifiedName);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
get size() {
|
|
125
|
+
return this.queue.filter((j) => j.status === 'pending').length;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async syncSize() {}
|
|
129
|
+
|
|
130
|
+
async shutdown() {
|
|
131
|
+
await this.leaseManager.releaseAll();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export default JobDispatcher;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { validate } from '../validation.js';
|
|
2
|
+
function validateEnqueue(qualifiedName, payload, registered, options) {
|
|
3
|
+
const reg = registered.get(qualifiedName);
|
|
4
|
+
if (!reg) {
|
|
5
|
+
const available = [...registered.keys()];
|
|
6
|
+
throw new Error(`Unknown job "${qualifiedName}". Registered jobs: [${available.join(', ')}]`);
|
|
7
|
+
}
|
|
8
|
+
let validatedPayload = payload;
|
|
9
|
+
if (reg.definition.schema) {
|
|
10
|
+
const result = validate(reg.definition.schema, payload);
|
|
11
|
+
if (!result.success) {
|
|
12
|
+
const messages = Object.values(result.errors.fieldErrors).flat().join('; ');
|
|
13
|
+
throw new Error(`Job "${qualifiedName}" payload validation failed: ${messages}`);
|
|
14
|
+
}
|
|
15
|
+
validatedPayload = result.data;
|
|
16
|
+
}
|
|
17
|
+
const maxRetries = options?.retries ?? reg.definition.retries ?? 0;
|
|
18
|
+
const delay = options?.delay ?? 0;
|
|
19
|
+
return { reg, validatedPayload, maxRetries, delay };
|
|
20
|
+
}
|
|
21
|
+
function toError(err) {
|
|
22
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
23
|
+
}
|
|
24
|
+
function calculateBackoff(baseMs, attempt) {
|
|
25
|
+
return baseMs * Math.pow(2, attempt - 1);
|
|
26
|
+
}
|
|
27
|
+
export { calculateBackoff, toError, validateEnqueue };
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { discoverModules } from '../discovery.js';
|
|
3
|
+
import { buildContext } from '../context.js';
|
|
4
|
+
import { parseCron, matchesCron } from './cron.js';
|
|
5
|
+
import { toError, calculateBackoff } from './queue.js';
|
|
6
|
+
import JobDispatcher from './drivers/memory-queue.js';
|
|
7
|
+
import { SYSTEM_JOB_DOMAIN, registerSystemJobs } from '../system-jobs/index.js';
|
|
8
|
+
|
|
9
|
+
async function discoverJobs(jobsDir) {
|
|
10
|
+
const entries = await discoverModules(jobsDir, { recursive: true, label: 'job file' });
|
|
11
|
+
const jobs = [];
|
|
12
|
+
for (const { name, filePath, module } of entries) {
|
|
13
|
+
const job = module.default;
|
|
14
|
+
if (!job || typeof job !== 'object') {
|
|
15
|
+
throw new Error(`Job file at ${filePath} must have a default export`);
|
|
16
|
+
}
|
|
17
|
+
if (!job.name || typeof job.name !== 'string') {
|
|
18
|
+
throw new Error(`Job at ${filePath} must export a "name" string`);
|
|
19
|
+
}
|
|
20
|
+
if (typeof job.handler !== 'function') {
|
|
21
|
+
throw new Error(`Job at ${filePath} must export a "handler" function`);
|
|
22
|
+
}
|
|
23
|
+
jobs.push({ definition: job, fileName: name });
|
|
24
|
+
}
|
|
25
|
+
return jobs;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class JobRunner {
|
|
29
|
+
_dispatcher;
|
|
30
|
+
_config;
|
|
31
|
+
_log;
|
|
32
|
+
_appContext;
|
|
33
|
+
_jobs = [];
|
|
34
|
+
_cronEntries = [];
|
|
35
|
+
_continuousJobs = [];
|
|
36
|
+
_timer = null;
|
|
37
|
+
_started = false;
|
|
38
|
+
_stopped = false;
|
|
39
|
+
_lastEnqueuedMinute = new Map();
|
|
40
|
+
|
|
41
|
+
constructor(config, { db, queue, cache, files, mail, events, log } = {}) {
|
|
42
|
+
this._config = config;
|
|
43
|
+
this._log = log;
|
|
44
|
+
this._dispatcher = new JobDispatcher({ backoffMs: config?.backoffMs });
|
|
45
|
+
this._appContext = { db, queue, cache, files, mail, events, log };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get dispatcher() {
|
|
49
|
+
return this._dispatcher;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get jobs() {
|
|
53
|
+
return this._jobs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get running() {
|
|
57
|
+
return this._started;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async init() {
|
|
61
|
+
const jobsDir = this._config?.jobsDir;
|
|
62
|
+
if (!jobsDir) return;
|
|
63
|
+
|
|
64
|
+
// Discover and register user jobs
|
|
65
|
+
const discovered = await discoverJobs(jobsDir);
|
|
66
|
+
for (const { definition, fileName } of discovered) {
|
|
67
|
+
this._dispatcher.register('app', definition, this._appContext);
|
|
68
|
+
this._jobs.push({
|
|
69
|
+
jobName: definition.name,
|
|
70
|
+
fileName,
|
|
71
|
+
schedule: definition.schedule,
|
|
72
|
+
handler: definition.handler,
|
|
73
|
+
});
|
|
74
|
+
this._log?.info(` ${fileName} \u2192 ${definition.name}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Register system jobs
|
|
78
|
+
const systemContext = {
|
|
79
|
+
db: this._appContext.db,
|
|
80
|
+
events: { emit: async () => {} },
|
|
81
|
+
queue: this._appContext.queue?.withNamespace(SYSTEM_JOB_DOMAIN),
|
|
82
|
+
cache: this._appContext.cache?.withNamespace(SYSTEM_JOB_DOMAIN),
|
|
83
|
+
files: this._appContext.files?.withNamespace(SYSTEM_JOB_DOMAIN),
|
|
84
|
+
mail: this._appContext.mail,
|
|
85
|
+
log: this._log.extend({ logger: SYSTEM_JOB_DOMAIN }),
|
|
86
|
+
};
|
|
87
|
+
const systemJobs = registerSystemJobs(this._config, this._dispatcher, systemContext);
|
|
88
|
+
for (const sj of systemJobs) {
|
|
89
|
+
this._log?.info(` ${sj.jobName} (system${sj.schedule ? `, ${sj.schedule}` : ''})`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Build scheduled and continuous maps
|
|
93
|
+
for (const job of this._jobs) {
|
|
94
|
+
if (job.schedule === 'continuous') {
|
|
95
|
+
this._continuousJobs.push({
|
|
96
|
+
qualifiedName: `app/${job.jobName}`,
|
|
97
|
+
handler: job.handler,
|
|
98
|
+
appContext: this._appContext,
|
|
99
|
+
});
|
|
100
|
+
} else if (job.schedule) {
|
|
101
|
+
this._cronEntries.push({
|
|
102
|
+
qualifiedName: `app/${job.jobName}`,
|
|
103
|
+
fields: parseCron(job.schedule),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const sj of systemJobs) {
|
|
108
|
+
if (sj.schedule) {
|
|
109
|
+
this._cronEntries.push({
|
|
110
|
+
qualifiedName: `${sj.domainName}/${sj.jobName}`,
|
|
111
|
+
fields: parseCron(sj.schedule),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
start(intervalMs) {
|
|
118
|
+
if (this._started) return;
|
|
119
|
+
this._started = true;
|
|
120
|
+
this._stopped = false;
|
|
121
|
+
const pollMs = intervalMs ?? this._config?.pollIntervalMs ?? 60000;
|
|
122
|
+
this._timer = setInterval(() => {
|
|
123
|
+
this.tick().catch((err) => {
|
|
124
|
+
console.error('[job-runner] tick error:', err);
|
|
125
|
+
});
|
|
126
|
+
}, pollMs);
|
|
127
|
+
for (const entry of this._continuousJobs) {
|
|
128
|
+
this._runContinuousLoop(entry);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const parts = [];
|
|
132
|
+
if (this._cronEntries.length > 0) parts.push(`${this._cronEntries.length} scheduled`);
|
|
133
|
+
if (this._continuousJobs.length > 0) parts.push(`${this._continuousJobs.length} continuous`);
|
|
134
|
+
if (parts.length > 0) this._log?.info(`Job runner started (${parts.join(', ')})`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
stop() {
|
|
138
|
+
this._stopped = true;
|
|
139
|
+
this._started = false;
|
|
140
|
+
if (this._timer) {
|
|
141
|
+
clearInterval(this._timer);
|
|
142
|
+
this._timer = null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async shutdown() {
|
|
147
|
+
this.stop();
|
|
148
|
+
if (this._dispatcher.shutdown) await this._dispatcher.shutdown();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async tick(now) {
|
|
152
|
+
const date = now ?? new Date();
|
|
153
|
+
const minuteKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getHours()}-${date.getMinutes()}`;
|
|
154
|
+
for (const entry of this._cronEntries) {
|
|
155
|
+
if (matchesCron(entry.fields, date)) {
|
|
156
|
+
const lastMinute = this._lastEnqueuedMinute.get(entry.qualifiedName);
|
|
157
|
+
if (lastMinute === minuteKey) continue;
|
|
158
|
+
try {
|
|
159
|
+
await this._dispatcher.enqueue(entry.qualifiedName, void 0);
|
|
160
|
+
this._lastEnqueuedMinute.set(entry.qualifiedName, minuteKey);
|
|
161
|
+
console.log(`[job-runner] scheduled: ${entry.qualifiedName}`);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error(`[job-runner] failed to enqueue ${entry.qualifiedName}:`, err);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
await this._dispatcher.process();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async _runContinuousLoop(entry) {
|
|
171
|
+
const backoffMs = this._config?.backoffMs ?? 1000;
|
|
172
|
+
let consecutiveErrors = 0;
|
|
173
|
+
console.log(`[job-runner] continuous: ${entry.qualifiedName} started`);
|
|
174
|
+
while (!this._stopped) {
|
|
175
|
+
try {
|
|
176
|
+
const ctx = buildContext(entry.appContext, { payload: void 0 });
|
|
177
|
+
await entry.handler(ctx);
|
|
178
|
+
consecutiveErrors = 0;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
consecutiveErrors++;
|
|
181
|
+
const error = toError(err);
|
|
182
|
+
const delay = calculateBackoff(backoffMs, consecutiveErrors);
|
|
183
|
+
console.error(
|
|
184
|
+
`[job-runner] continuous: ${entry.qualifiedName} error (attempt ${consecutiveErrors}), retrying in ${delay}ms:`,
|
|
185
|
+
error.message,
|
|
186
|
+
);
|
|
187
|
+
const deadline = Date.now() + delay;
|
|
188
|
+
while (!this._stopped && Date.now() < deadline) {
|
|
189
|
+
await new Promise((r) => setTimeout(r, Math.min(100, deadline - Date.now())));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
console.log(`[job-runner] continuous: ${entry.qualifiedName} stopped`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export { JobRunner, discoverJobs };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
class MemoryThroughputTracker {
|
|
2
|
+
/** Map of qualifiedName → completion timestamps (epoch ms) */
|
|
3
|
+
completions = new Map();
|
|
4
|
+
/**
|
|
5
|
+
* Record a job completion for throughput tracking.
|
|
6
|
+
*/
|
|
7
|
+
record(qualifiedName) {
|
|
8
|
+
const timestamps = this.completions.get(qualifiedName) ?? [];
|
|
9
|
+
timestamps.push(Date.now());
|
|
10
|
+
this.completions.set(qualifiedName, timestamps);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Check if a job type is within its throughput budget.
|
|
14
|
+
*
|
|
15
|
+
* Returns true if the job can be executed, false if it should be skipped.
|
|
16
|
+
* When false, the job stays in the queue for the next processing cycle.
|
|
17
|
+
*/
|
|
18
|
+
isAllowed(qualifiedName, config, now) {
|
|
19
|
+
const currentTime = now ?? Date.now();
|
|
20
|
+
const timestamps = this.completions.get(qualifiedName);
|
|
21
|
+
if (!timestamps || timestamps.length === 0) return true;
|
|
22
|
+
const cutoff = currentTime - 6e4;
|
|
23
|
+
const pruned = timestamps.filter((t) => t > cutoff);
|
|
24
|
+
this.completions.set(qualifiedName, pruned);
|
|
25
|
+
if (config.maxPerSecond !== void 0) {
|
|
26
|
+
const secondAgo = currentTime - 1e3;
|
|
27
|
+
const countInSecond = pruned.filter((t) => t > secondAgo).length;
|
|
28
|
+
if (countInSecond >= config.maxPerSecond) return false;
|
|
29
|
+
}
|
|
30
|
+
if (config.maxPerMinute !== void 0) {
|
|
31
|
+
const minuteAgo = currentTime - 6e4;
|
|
32
|
+
const countInMinute = pruned.filter((t) => t > minuteAgo).length;
|
|
33
|
+
if (countInMinute >= config.maxPerMinute) return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function checkDbThroughput(db, tableName, qualifiedName, config) {
|
|
39
|
+
if (config.maxPerSecond !== void 0) {
|
|
40
|
+
const secondAgo = new Date(Date.now() - 1e3).toISOString();
|
|
41
|
+
const result = await db(tableName)
|
|
42
|
+
.where('qualified_name', qualifiedName)
|
|
43
|
+
.where('status', 'completed')
|
|
44
|
+
.where('updated_at', '>', secondAgo)
|
|
45
|
+
.count('id as count')
|
|
46
|
+
.first();
|
|
47
|
+
const count = Number(result?.count ?? 0);
|
|
48
|
+
if (count >= config.maxPerSecond) return false;
|
|
49
|
+
}
|
|
50
|
+
if (config.maxPerMinute !== void 0) {
|
|
51
|
+
const minuteAgo = new Date(Date.now() - 6e4).toISOString();
|
|
52
|
+
const result = await db(tableName)
|
|
53
|
+
.where('qualified_name', qualifiedName)
|
|
54
|
+
.where('status', 'completed')
|
|
55
|
+
.where('updated_at', '>', minuteAgo)
|
|
56
|
+
.count('id as count')
|
|
57
|
+
.first();
|
|
58
|
+
const count = Number(result?.count ?? 0);
|
|
59
|
+
if (count >= config.maxPerMinute) return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
export { MemoryThroughputTracker, checkDbThroughput };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
|
|
2
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
3
|
+
const IV_LENGTH = 16;
|
|
4
|
+
const AUTH_TAG_LENGTH = 16;
|
|
5
|
+
const SALT_LENGTH = 32;
|
|
6
|
+
const KEY_LENGTH = 32;
|
|
7
|
+
function deriveKey(key, salt) {
|
|
8
|
+
return scryptSync(key, salt, KEY_LENGTH);
|
|
9
|
+
}
|
|
10
|
+
function encrypt(plaintext, key) {
|
|
11
|
+
const salt = randomBytes(SALT_LENGTH);
|
|
12
|
+
const derivedKey = deriveKey(key, salt);
|
|
13
|
+
const iv = randomBytes(IV_LENGTH);
|
|
14
|
+
const cipher = createCipheriv(ALGORITHM, derivedKey, iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
15
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
16
|
+
const authTag = cipher.getAuthTag();
|
|
17
|
+
return [
|
|
18
|
+
salt.toString('hex'),
|
|
19
|
+
iv.toString('hex'),
|
|
20
|
+
authTag.toString('hex'),
|
|
21
|
+
encrypted.toString('hex'),
|
|
22
|
+
].join(':');
|
|
23
|
+
}
|
|
24
|
+
function decrypt(ciphertext, key) {
|
|
25
|
+
const parts = ciphertext.split(':');
|
|
26
|
+
if (parts.length !== 4) {
|
|
27
|
+
throw new Error('Invalid ciphertext format');
|
|
28
|
+
}
|
|
29
|
+
const [saltHex, ivHex, authTagHex, dataHex] = parts;
|
|
30
|
+
const salt = Buffer.from(saltHex, 'hex');
|
|
31
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
32
|
+
const authTag = Buffer.from(authTagHex, 'hex');
|
|
33
|
+
const data = Buffer.from(dataHex, 'hex');
|
|
34
|
+
const derivedKey = deriveKey(key, salt);
|
|
35
|
+
const decipher = createDecipheriv(ALGORITHM, derivedKey, iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
36
|
+
decipher.setAuthTag(authTag);
|
|
37
|
+
const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
38
|
+
return decrypted.toString('utf8');
|
|
39
|
+
}
|
|
40
|
+
export { decrypt, encrypt };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { hashPassword, verifyPassword } from './password.js';
|
|
2
|
+
import { generateUUID, generateNanoId } from './ids.js';
|
|
3
|
+
import { encrypt, decrypt } from './encrypt.js';
|
|
4
|
+
import { jwtEncode, jwtDecode } from './jwt.js';
|
|
5
|
+
export {
|
|
6
|
+
decrypt,
|
|
7
|
+
encrypt,
|
|
8
|
+
generateNanoId,
|
|
9
|
+
generateUUID,
|
|
10
|
+
hashPassword,
|
|
11
|
+
jwtDecode,
|
|
12
|
+
jwtEncode,
|
|
13
|
+
verifyPassword,
|
|
14
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { SignJWT, jwtVerify, errors } from 'jose';
|
|
2
|
+
function secretToKey(secret) {
|
|
3
|
+
return new TextEncoder().encode(secret);
|
|
4
|
+
}
|
|
5
|
+
function parseExpiration(expiresIn) {
|
|
6
|
+
if (typeof expiresIn === 'number') {
|
|
7
|
+
return `${expiresIn}s`;
|
|
8
|
+
}
|
|
9
|
+
return expiresIn;
|
|
10
|
+
}
|
|
11
|
+
async function jwtEncode(payload, secret, options) {
|
|
12
|
+
if (!secret) {
|
|
13
|
+
throw new Error('JWT secret is required');
|
|
14
|
+
}
|
|
15
|
+
const alg = options?.algorithm ?? 'HS256';
|
|
16
|
+
const key = secretToKey(secret);
|
|
17
|
+
let builder = new SignJWT(payload).setProtectedHeader({ alg }).setIssuedAt();
|
|
18
|
+
if (options?.expiresIn !== void 0) {
|
|
19
|
+
builder = builder.setExpirationTime(parseExpiration(options.expiresIn));
|
|
20
|
+
}
|
|
21
|
+
if (options?.issuer) {
|
|
22
|
+
builder = builder.setIssuer(options.issuer);
|
|
23
|
+
}
|
|
24
|
+
if (options?.audience) {
|
|
25
|
+
builder = builder.setAudience(options.audience);
|
|
26
|
+
}
|
|
27
|
+
return builder.sign(key);
|
|
28
|
+
}
|
|
29
|
+
async function jwtDecode(token, secret, options) {
|
|
30
|
+
if (!secret) {
|
|
31
|
+
throw new Error('JWT secret is required');
|
|
32
|
+
}
|
|
33
|
+
const key = secretToKey(secret);
|
|
34
|
+
const algorithms = options?.algorithms ?? ['HS256'];
|
|
35
|
+
try {
|
|
36
|
+
const { payload } = await jwtVerify(token, key, {
|
|
37
|
+
algorithms,
|
|
38
|
+
issuer: options?.issuer,
|
|
39
|
+
audience: options?.audience,
|
|
40
|
+
});
|
|
41
|
+
return payload;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err instanceof errors.JWTExpired) {
|
|
44
|
+
throw new Error('JWT token has expired');
|
|
45
|
+
}
|
|
46
|
+
if (err instanceof errors.JWSSignatureVerificationFailed) {
|
|
47
|
+
throw new Error('JWT signature verification failed');
|
|
48
|
+
}
|
|
49
|
+
if (err instanceof errors.JWTClaimValidationFailed) {
|
|
50
|
+
throw new Error(`JWT claim validation failed: ${err.message}`);
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`JWT verification failed: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export { jwtDecode, jwtEncode };
|