create-nexwire 4.2.4 → 4.3.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 +20 -8
- package/express/.env +1 -1
- package/express/.vscode/extensions.json +6 -0
- package/express/.vscode/settings.json +37 -26
- package/express/biome.json +99 -66
- package/express/package.json +31 -29
- package/express/src/config/database.ts +48 -29
- package/express/src/config/index.ts +83 -83
- package/express/src/config/openapi.ts +6 -24
- package/express/src/config/queue.ts +30 -30
- package/express/src/config/validate.ts +50 -40
- package/express/src/database/migrations/sqlite/0000_init.sql +47 -47
- package/express/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
- package/express/src/database/migrations/sqlite/meta/_journal.json +1 -1
- package/express/src/database/schema.ts +2 -2
- package/express/src/framework/cache/cache.ts +40 -8
- package/express/src/framework/circuit-breaker/cb.ts +88 -60
- package/express/src/framework/database/connection.ts +52 -27
- package/express/src/framework/events/dispatcher.ts +61 -49
- package/express/src/framework/facade.ts +46 -3
- package/express/src/framework/http/app.ts +28 -22
- package/express/src/framework/http/logger.ts +27 -16
- package/express/src/framework/http/openapi.ts +9 -11
- package/express/src/framework/modules/routes.ts +26 -21
- package/express/src/framework/notification/channels/broadcast.ts +15 -0
- package/express/src/framework/notification/channels/mail.ts +40 -0
- package/express/src/framework/notification/contracts.ts +29 -0
- package/express/src/framework/notification/index.ts +19 -94
- package/express/src/framework/notification/registry.ts +15 -0
- package/express/src/framework/providers/adapter.ts +27 -17
- package/express/src/framework/queue/queue.ts +43 -44
- package/express/src/framework/realtime/broadcast.ts +29 -5
- package/express/src/framework/realtime/socket.ts +149 -119
- package/express/src/framework/redis/client.ts +148 -151
- package/express/src/framework/runtime/server-adapter.ts +39 -34
- package/express/src/framework/scheduler/lock.ts +128 -116
- package/express/src/framework/server.ts +33 -49
- package/express/src/framework/session/session.ts +69 -14
- package/express/src/framework/storage/storage.ts +744 -785
- package/express/src/framework/support/mail.ts +15 -7
- package/express/src/providers/notification.ts +4 -0
- package/express/src/resources/src/components/Checkbox.vue +32 -29
- package/express/src/resources/src/components/Input.vue +243 -223
- package/express/src/resources/src/components/InputGroup.vue +147 -145
- package/express/src/resources/src/components/InputPasswordToggle.vue +229 -213
- package/express/src/resources/src/components/Modal.vue +71 -55
- package/express/src/resources/src/components/Refresh.vue +19 -0
- package/express/src/resources/src/helpers/assets.ts +51 -0
- package/express/src/resources/src/layouts/Layout/Header.vue +124 -105
- package/express/src/resources/src/main.ts +3 -1
- package/express/src/resources/src/plugins/gum.ts +1 -1
- package/express/src/resources/src/plugins/pulse.ts +157 -147
- package/express/src/resources/src/stores/auth.ts +184 -164
- package/express/src/resources/vite.config.ts +54 -36
- package/express/src/storage/database/nexwire.sqlite +0 -0
- package/express/src/storage/logs/app.log +0 -0
- package/express/src/storage/logs/fatal.log +0 -0
- package/express/src/types/global.d.ts +32 -26
- package/hono/.env +1 -1
- package/hono/.vscode/extensions.json +6 -0
- package/hono/.vscode/settings.json +37 -26
- package/hono/biome.json +25 -2
- package/hono/package.json +25 -23
- package/hono/src/config/database.ts +48 -29
- package/hono/src/config/index.ts +83 -83
- package/hono/src/config/openapi.ts +6 -24
- package/hono/src/config/queue.ts +30 -30
- package/hono/src/config/validate.ts +50 -40
- package/hono/src/database/migrations/sqlite/0000_init.sql +47 -47
- package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
- package/hono/src/database/migrations/sqlite/meta/_journal.json +1 -1
- package/hono/src/database/schema.ts +2 -2
- package/hono/src/framework/cache/cache.ts +40 -8
- package/hono/src/framework/circuit-breaker/cb.ts +88 -60
- package/hono/src/framework/database/connection.ts +52 -27
- package/hono/src/framework/events/dispatcher.ts +61 -49
- package/hono/src/framework/facade.ts +38 -1
- package/hono/src/framework/http/openapi.ts +7 -9
- package/hono/src/framework/notification/channels/broadcast.ts +15 -0
- package/hono/src/framework/notification/channels/mail.ts +40 -0
- package/hono/src/framework/notification/contracts.ts +29 -0
- package/hono/src/framework/notification/index.ts +19 -94
- package/hono/src/framework/notification/registry.ts +15 -0
- package/hono/src/framework/providers/adapter.ts +27 -17
- package/hono/src/framework/queue/queue.ts +43 -44
- package/hono/src/framework/realtime/broadcast.ts +29 -5
- package/hono/src/framework/realtime/socket.ts +217 -196
- package/hono/src/framework/redis/client.ts +148 -151
- package/hono/src/framework/scheduler/lock.ts +128 -116
- package/hono/src/framework/server.ts +27 -29
- package/hono/src/framework/session/session.ts +62 -14
- package/hono/src/framework/storage/storage.ts +744 -785
- package/hono/src/framework/support/mail.ts +56 -48
- package/hono/src/providers/notification.ts +4 -0
- package/hono/src/resources/src/components/Checkbox.vue +32 -29
- package/hono/src/resources/src/components/Input.vue +243 -234
- package/hono/src/resources/src/components/InputGroup.vue +147 -149
- package/hono/src/resources/src/components/InputPasswordToggle.vue +229 -222
- package/hono/src/resources/src/components/Modal.vue +71 -55
- package/hono/src/resources/src/components/Refresh.vue +19 -0
- package/hono/src/resources/src/helpers/assets.ts +51 -0
- package/hono/src/resources/src/layouts/Layout/Header.vue +124 -105
- package/hono/src/resources/src/main.ts +3 -1
- package/hono/src/resources/src/plugins/gum.ts +1 -1
- package/hono/src/resources/src/plugins/pulse.ts +157 -147
- package/hono/src/resources/src/stores/auth.ts +184 -164
- package/hono/src/resources/vite.config.ts +54 -36
- package/hono/src/storage/database/nexwire.sqlite +0 -0
- package/hono/src/storage/logs/app.log +0 -0
- package/hono/src/storage/logs/fatal.log +0 -0
- package/hono/src/types/global.d.ts +32 -26
- package/package.json +2 -2
- package/src/index.js +139 -14
|
@@ -1,116 +1,128 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { redisConfig } from "@/config/index.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { redisConfig } from "@/config/index.js";
|
|
3
|
+
import { createCircuitBreaker, isCircuitBreakerError } from "@/framework/circuit-breaker/cb.js";
|
|
4
|
+
import { databaseDialect, databasePool } from "@/framework/database/connection.js";
|
|
5
|
+
import { redis, redisReady } from "@/framework/redis/client.js";
|
|
6
|
+
import { logger } from "@/framework/support/logger.js";
|
|
7
|
+
|
|
8
|
+
type LockOptions = { ttlMs?: number; tableName?: string };
|
|
9
|
+
|
|
10
|
+
// Circuit breaker for the Redis Lock
|
|
11
|
+
const lockBreaker = createCircuitBreaker({ name: "Redis Lock", failureThreshold: 3, resetTimeout: 30000 });
|
|
12
|
+
|
|
13
|
+
function table(name?: string) {
|
|
14
|
+
return name || "scheduler_locks";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function ensureTable(tableName?: string) {
|
|
18
|
+
const pool = databasePool();
|
|
19
|
+
const dialect = databaseDialect();
|
|
20
|
+
const lockTable = table(tableName);
|
|
21
|
+
|
|
22
|
+
if (dialect === "mysql") {
|
|
23
|
+
await pool.query(
|
|
24
|
+
`CREATE TABLE IF NOT EXISTS \`${lockTable}\` (name VARCHAR(191) PRIMARY KEY, owner VARCHAR(191) NOT NULL, expires_at BIGINT NOT NULL)`
|
|
25
|
+
);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (dialect === "postgresql") {
|
|
30
|
+
await pool.unsafe(
|
|
31
|
+
`CREATE TABLE IF NOT EXISTS "${lockTable}" (name VARCHAR(191) PRIMARY KEY, owner VARCHAR(191) NOT NULL, expires_at BIGINT NOT NULL)`
|
|
32
|
+
);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await pool.execute(`CREATE TABLE IF NOT EXISTS "${lockTable}" (name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL)`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function acquireDbLock(name: string, owner: string, ttlMs: number, tableName?: string) {
|
|
40
|
+
await ensureTable(tableName);
|
|
41
|
+
const pool = databasePool();
|
|
42
|
+
const dialect = databaseDialect();
|
|
43
|
+
const lockTable = table(tableName);
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const expiresAt = now + ttlMs;
|
|
46
|
+
|
|
47
|
+
if (dialect === "postgresql") {
|
|
48
|
+
const rows = await pool.unsafe(
|
|
49
|
+
`INSERT INTO "${lockTable}" (name, owner, expires_at) VALUES ($1, $2, $3) ON CONFLICT (name) DO UPDATE SET owner = EXCLUDED.owner, expires_at = EXCLUDED.expires_at WHERE "${lockTable}".expires_at < $4 RETURNING owner`,
|
|
50
|
+
[name, owner, expiresAt, now]
|
|
51
|
+
);
|
|
52
|
+
return rows?.[0]?.owner === owner;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (dialect === "mysql") {
|
|
56
|
+
await pool.query(
|
|
57
|
+
`INSERT INTO \`${lockTable}\` (name, owner, expires_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE owner = IF(expires_at < ?, VALUES(owner), owner), expires_at = IF(expires_at < ?, VALUES(expires_at), expires_at)`,
|
|
58
|
+
[name, owner, expiresAt, now, now]
|
|
59
|
+
);
|
|
60
|
+
const [rows] = await pool.query(`SELECT owner FROM \`${lockTable}\` WHERE name = ?`, [name]);
|
|
61
|
+
return rows?.[0]?.owner === owner;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await pool.execute({
|
|
65
|
+
sql: `INSERT INTO "${lockTable}" (name, owner, expires_at) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at WHERE "${lockTable}".expires_at < ?`,
|
|
66
|
+
args: [name, owner, expiresAt, now]
|
|
67
|
+
});
|
|
68
|
+
const result = await pool.execute({
|
|
69
|
+
sql: `SELECT owner FROM "${lockTable}" WHERE name = ?`,
|
|
70
|
+
args: [name]
|
|
71
|
+
});
|
|
72
|
+
return result.rows?.[0]?.owner === owner;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function releaseDbLock(name: string, owner: string, tableName?: string) {
|
|
76
|
+
const pool = databasePool();
|
|
77
|
+
const dialect = databaseDialect();
|
|
78
|
+
const lockTable = table(tableName);
|
|
79
|
+
|
|
80
|
+
if (dialect === "postgresql") return await pool.unsafe(`DELETE FROM "${lockTable}" WHERE name = $1 AND owner = $2`, [name, owner]);
|
|
81
|
+
if (dialect === "mysql") return await pool.query(`DELETE FROM \`${lockTable}\` WHERE name = ? AND owner = ?`, [name, owner]);
|
|
82
|
+
await pool.execute({
|
|
83
|
+
sql: `DELETE FROM "${lockTable}" WHERE name = ? AND owner = ?`,
|
|
84
|
+
args: [name, owner]
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function runWithLock<T>(name: string, handler: () => Promise<T>, options: LockOptions = {}) {
|
|
89
|
+
const owner = randomUUID();
|
|
90
|
+
const ttlMs = options.ttlMs || 60_000;
|
|
91
|
+
const client = redis();
|
|
92
|
+
|
|
93
|
+
if (redisReady() && client) {
|
|
94
|
+
try {
|
|
95
|
+
return await lockBreaker.exec(async () => {
|
|
96
|
+
const key = `${redisConfig.prefix}:lock:${name}`;
|
|
97
|
+
const ok = await client.set(key, owner, "PX", ttlMs, "NX");
|
|
98
|
+
if (!ok) return { ran: false, backend: "redis" as const };
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const result = await handler();
|
|
102
|
+
return { ran: true, backend: "redis" as const, result };
|
|
103
|
+
} finally {
|
|
104
|
+
await client.del(key);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// If the circuit breaker is open, we log it and fall back to DB lock
|
|
109
|
+
if (isCircuitBreakerError(error)) {
|
|
110
|
+
logger.warn(`Redis lock skipped due to circuit breaker for ${name}; falling back to DB`);
|
|
111
|
+
} else {
|
|
112
|
+
logger.error(`Redis lock error for ${name}; falling back to DB`, { error });
|
|
113
|
+
}
|
|
114
|
+
// Fall through to DB lock
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// DB Lock Fallback
|
|
119
|
+
const ok = await acquireDbLock(name, owner, ttlMs, options.tableName);
|
|
120
|
+
if (!ok) return { ran: false, backend: "db" as const };
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const result = await handler();
|
|
124
|
+
return { ran: true, backend: "db" as const, result };
|
|
125
|
+
} finally {
|
|
126
|
+
await releaseDbLock(name, owner, options.tableName);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -5,7 +5,7 @@ import { appConfig, mailConfig, realtimeConfig, redisConfig } from "@/config/ind
|
|
|
5
5
|
import { createKernel } from "@/framework/kernel.js";
|
|
6
6
|
import { stopQueueRuntime } from "@/framework/queue/queue.js";
|
|
7
7
|
import { stopQueueDashboard } from "@/framework/queue/ui.js";
|
|
8
|
-
import { broadcast, closeRealtime,
|
|
8
|
+
import { broadcast, closeRealtime, socketServer, WS_PATH } from "@/framework/realtime/index.js";
|
|
9
9
|
import { setupSocketAdminUI } from "@/framework/realtime/ui.js";
|
|
10
10
|
import { closeRedis, redisClientIfReady, redisError, redisReady } from "@/framework/redis/client.js";
|
|
11
11
|
import { type HttpServerHandle, startHttpServer } from "@/framework/runtime/server-adapter.js";
|
|
@@ -14,23 +14,15 @@ import { logger } from "@/framework/support/logger.js";
|
|
|
14
14
|
|
|
15
15
|
const redisBackedServices = "cache, session, queue, events, Queue Dashboard";
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
* Why: Builds reliable local URL string for runtime service output.
|
|
19
|
-
* When: Printing API/docs/Queue Dashboard endpoints at startup.
|
|
20
|
-
* Where: Server runtime logging.
|
|
21
|
-
* How: Derives bound port from server address and normalizes hostname.
|
|
22
|
-
*/
|
|
23
17
|
function serverUrl(server: HttpServerHandle, pathname = "") {
|
|
24
18
|
const address = server.address();
|
|
25
19
|
const port = typeof address === "object" && address ? address.port : appConfig.port;
|
|
26
|
-
|
|
27
20
|
try {
|
|
28
21
|
const currentUrl = new URL(appConfig.url);
|
|
29
22
|
currentUrl.port = String(port);
|
|
30
23
|
if (currentUrl.hostname === "0.0.0.0" || currentUrl.hostname === "::") {
|
|
31
24
|
currentUrl.hostname = "localhost";
|
|
32
25
|
}
|
|
33
|
-
|
|
34
26
|
return `${currentUrl.toString().replace(/\/$/, "")}${pathname}`;
|
|
35
27
|
} catch {
|
|
36
28
|
return `http://localhost:${port}${pathname}`;
|
|
@@ -41,27 +33,22 @@ export interface ServerHandle {
|
|
|
41
33
|
app: Awaited<ReturnType<typeof createKernel>>["app"];
|
|
42
34
|
bullBoard: Awaited<ReturnType<typeof createKernel>>["bullBoard"];
|
|
43
35
|
server: HttpServerHandle;
|
|
44
|
-
realtime:
|
|
36
|
+
realtime: any; // Type for Express is simpler
|
|
45
37
|
broadcastSubClient: ReturnType<typeof redisClientIfReady> | null;
|
|
46
38
|
shutdown: (signal: ShutdownSignal) => Promise<void>;
|
|
47
39
|
closeHttpServer: () => Promise<void>;
|
|
48
40
|
}
|
|
49
41
|
|
|
50
|
-
/**
|
|
51
|
-
* Why: Starts the HTTP server and wires realtime/queue/redis shutdown.
|
|
52
|
-
* When: CLI `serve` command or E2E test bootstrap.
|
|
53
|
-
* Where: Server entrypoint.
|
|
54
|
-
* How: Creates kernel, starts HTTP, registers signal handlers, returns handle.
|
|
55
|
-
* When called as the main module, also prints startup banners.
|
|
56
|
-
*/
|
|
57
42
|
export async function startServer(): Promise<ServerHandle> {
|
|
58
43
|
const { app, bullBoard } = await createKernel();
|
|
59
44
|
|
|
60
|
-
|
|
45
|
+
// 1. Start the server (this internally attaches Socket.IO for Express)
|
|
61
46
|
const server = await startHttpServer(app, appConfig.port);
|
|
62
|
-
realtime = await initRealtime({ httpServer: server.native });
|
|
63
47
|
|
|
64
|
-
|
|
48
|
+
// 2. Grab the existing Socket.IO instance
|
|
49
|
+
const io = socketServer();
|
|
50
|
+
const realtime = io ? { io } : null;
|
|
51
|
+
const socketAdmin = setupSocketAdminUI(io);
|
|
65
52
|
|
|
66
53
|
let shuttingDown = false;
|
|
67
54
|
let broadcastSubClient: ReturnType<typeof redisClientIfReady> | null = null;
|
|
@@ -83,7 +70,9 @@ export async function startServer(): Promise<ServerHandle> {
|
|
|
83
70
|
}
|
|
84
71
|
broadcastSubClient = null;
|
|
85
72
|
}
|
|
86
|
-
|
|
73
|
+
|
|
74
|
+
// closeRealtime is handled by server.close() adapter
|
|
75
|
+
await Promise.allSettled([stopQueueRuntime(), closeHttpServer()]);
|
|
87
76
|
stopQueueDashboard();
|
|
88
77
|
await closeRedis();
|
|
89
78
|
|
|
@@ -111,57 +100,52 @@ export async function startServer(): Promise<ServerHandle> {
|
|
|
111
100
|
}
|
|
112
101
|
|
|
113
102
|
const views = new Set(parseCsvOrFallback(process.env.NEXWIRE_DEV_VIEWS, []).map((view) => view.toLowerCase()));
|
|
114
|
-
|
|
103
|
+
|
|
115
104
|
if (appConfig.openApiEnabled) {
|
|
116
105
|
console.log(`API Docs: ${serverUrl(server, "/api-docs")}`);
|
|
117
106
|
}
|
|
118
107
|
|
|
119
108
|
if (appConfig.uiEnabled) {
|
|
120
|
-
|
|
121
|
-
console.log(`UI: ${process.env.NEXWIRE_FRONTEND_URL}`);
|
|
122
|
-
} else {
|
|
123
|
-
console.log("UI enabled");
|
|
124
|
-
}
|
|
109
|
+
console.log(`UI: ${process.env.NEXWIRE_FRONTEND_URL}`);
|
|
125
110
|
} else {
|
|
126
|
-
console.log(chalk.gray("UI
|
|
111
|
+
console.log(chalk.gray("UI: .env -> UI=false (API-only mode)"));
|
|
127
112
|
}
|
|
128
113
|
|
|
129
|
-
|
|
130
|
-
|
|
114
|
+
if (redisConfig.enabled && bullBoard.enabled) {
|
|
115
|
+
console.log(`Queue: ${serverUrl(server, bullBoard.basePath)}`);
|
|
116
|
+
}
|
|
131
117
|
|
|
132
118
|
if (views.has("maildev")) {
|
|
133
|
-
|
|
134
|
-
const line = `${maildevLine} (requested; see dev process status)`;
|
|
135
|
-
console.log(redisWarnColor ? redisWarnColor(line) : line);
|
|
119
|
+
console.log(`MailDev: http://localhost:${mailConfig.maildev.webPort} (SMTP ${mailConfig.maildev.smtpPort})`);
|
|
136
120
|
}
|
|
137
121
|
|
|
138
122
|
if (views.has("studio")) {
|
|
139
|
-
console.log("Drizzle
|
|
123
|
+
console.log("Drizzle: https://local.drizzle.studio");
|
|
140
124
|
}
|
|
141
125
|
|
|
142
126
|
const wsUrl = serverUrl(server, WS_PATH).replace(/^http/, "ws");
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
:
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
127
|
+
if (!realtimeConfig.enabled) {
|
|
128
|
+
console.log(chalk.gray("Realtime (Socket.IO) disabled"));
|
|
129
|
+
} else if (realtime) {
|
|
130
|
+
if (socketAdmin.enabled) {
|
|
131
|
+
console.log(`Realtime: https://admin.socket.io | ${wsUrl}`);
|
|
132
|
+
} else {
|
|
133
|
+
console.log(`Realtime: ${wsUrl}`);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
console.log(chalk.yellow("Realtime (Socket.IO) unavailable"));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (redisConfig.enabled && views.has("redis")) {
|
|
140
|
+
console.log(`Redis UI: http://localhost:${redisConfig.commanderPort}`);
|
|
154
141
|
}
|
|
155
142
|
|
|
156
143
|
if (!redisConfig.enabled) {
|
|
157
|
-
|
|
158
|
-
console.log(chalk.cyan("Redis disabled (configured in src/config/redis.ts)"));
|
|
159
|
-
console.log(chalk.cyan(`Redis-backed services disabled: ${redisBackedServices}`));
|
|
144
|
+
console.log(chalk.cyan(`Redis: .env -> REDIS=false (disabled ${redisBackedServices})`));
|
|
160
145
|
} else if (redisReady()) {
|
|
161
146
|
console.log(chalk.green(`Redis connected: ${redisConfig.url}`));
|
|
162
147
|
console.log(chalk.green(`Redis-backed services enabled: ${redisBackedServices}`));
|
|
163
148
|
} else {
|
|
164
|
-
redisWarnColor = chalk.yellow;
|
|
165
149
|
console.log(chalk.yellow(`Redis unavailable: ${redisError() || "not connected"}`));
|
|
166
150
|
console.log(chalk.yellow(`Redis-backed services unavailable: ${redisBackedServices}`));
|
|
167
151
|
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { NextFunction, Request, Response } from "express";
|
|
3
3
|
import { appConfig, redisConfig, sessionConfig } from "@/config/index.js";
|
|
4
|
+
import { createCircuitBreaker, isCircuitBreakerError } from "@/framework/circuit-breaker/cb.js";
|
|
4
5
|
import { redisClientIfReady } from "@/framework/redis/client.js";
|
|
5
6
|
|
|
7
|
+
// Initialize the breaker for Session operations
|
|
8
|
+
const sessionBreaker = createCircuitBreaker({
|
|
9
|
+
name: "Redis Session",
|
|
10
|
+
failureThreshold: 3,
|
|
11
|
+
resetTimeout: 30000
|
|
12
|
+
});
|
|
13
|
+
|
|
6
14
|
function originOf(url?: string) {
|
|
7
15
|
if (!url) return "";
|
|
8
16
|
try {
|
|
@@ -22,6 +30,9 @@ function sessionKey(id: string) {
|
|
|
22
30
|
return `${sessionConfig.keyPrefix}:${id}`;
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Global middleware that ensures every request has a session cookie.
|
|
35
|
+
*/
|
|
25
36
|
export async function sessionMiddleware(req: Request, res: Response, next: NextFunction) {
|
|
26
37
|
let sessionId = req.cookies?.[sessionConfig.cookieName];
|
|
27
38
|
|
|
@@ -38,26 +49,44 @@ export async function sessionMiddleware(req: Request, res: Response, next: NextF
|
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
res.locals.sessionId = sessionId;
|
|
52
|
+
|
|
53
|
+
// Refresh is safely wrapped in the breaker internally
|
|
41
54
|
await session.refresh(sessionId);
|
|
55
|
+
|
|
42
56
|
next();
|
|
43
57
|
}
|
|
44
58
|
|
|
59
|
+
/** Redis-backed server-side session store. */
|
|
45
60
|
export const session = {
|
|
46
61
|
async start(data: Record<string, unknown> = {}) {
|
|
47
62
|
const client = redisClientIfReady();
|
|
48
63
|
if (!client) return "";
|
|
49
64
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
try {
|
|
66
|
+
return await sessionBreaker.exec(async () => {
|
|
67
|
+
const id = randomUUID();
|
|
68
|
+
await client.set(sessionKey(id), JSON.stringify(data), "EX", sessionConfig.ttlSeconds);
|
|
69
|
+
return id;
|
|
70
|
+
});
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (isCircuitBreakerError(error)) return "";
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
53
75
|
},
|
|
54
76
|
|
|
55
77
|
async all<T = Record<string, unknown>>(id: string): Promise<T | null> {
|
|
56
78
|
const client = redisClientIfReady();
|
|
57
79
|
if (!client) return null;
|
|
58
80
|
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
try {
|
|
82
|
+
return await sessionBreaker.exec(async () => {
|
|
83
|
+
const data = await client.get(sessionKey(id));
|
|
84
|
+
return data ? (JSON.parse(data) as T) : null;
|
|
85
|
+
});
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (isCircuitBreakerError(error)) return null;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
61
90
|
},
|
|
62
91
|
|
|
63
92
|
async get<T>(id: string, key: string): Promise<T | null> {
|
|
@@ -69,10 +98,22 @@ export const session = {
|
|
|
69
98
|
const client = redisClientIfReady();
|
|
70
99
|
if (!client) return false;
|
|
71
100
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
101
|
+
try {
|
|
102
|
+
return await sessionBreaker.exec(async () => {
|
|
103
|
+
// Read through the client directly rather than `session.all()`. Calling
|
|
104
|
+
// another breaker-wrapped method from inside `exec` deadlocks the
|
|
105
|
+
// half-open probe: the inner call sees the outer probe in flight and
|
|
106
|
+
// rejects, so a `set` could never be the call that closes the breaker.
|
|
107
|
+
const raw = await client.get(sessionKey(id));
|
|
108
|
+
const data = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
|
109
|
+
data[key] = value;
|
|
110
|
+
await client.set(sessionKey(id), JSON.stringify(data), "EX", sessionConfig.ttlSeconds);
|
|
111
|
+
return true;
|
|
112
|
+
});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (isCircuitBreakerError(error)) return false;
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
76
117
|
},
|
|
77
118
|
|
|
78
119
|
/** @deprecated Use set() instead. */
|
|
@@ -84,19 +125,33 @@ export const session = {
|
|
|
84
125
|
const client = redisClientIfReady();
|
|
85
126
|
if (!client) return false;
|
|
86
127
|
|
|
87
|
-
|
|
88
|
-
|
|
128
|
+
try {
|
|
129
|
+
return await sessionBreaker.exec(async () => {
|
|
130
|
+
await client.expire(sessionKey(id), sessionConfig.ttlSeconds);
|
|
131
|
+
return true;
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (isCircuitBreakerError(error)) return false;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
89
137
|
},
|
|
90
138
|
|
|
91
139
|
async destroy(id: string) {
|
|
92
140
|
const client = redisClientIfReady();
|
|
93
141
|
if (!client) return false;
|
|
94
142
|
|
|
95
|
-
|
|
96
|
-
|
|
143
|
+
try {
|
|
144
|
+
return await sessionBreaker.exec(async () => {
|
|
145
|
+
await client.del(sessionKey(id));
|
|
146
|
+
return true;
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (isCircuitBreakerError(error)) return false;
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
97
152
|
},
|
|
98
153
|
|
|
99
154
|
isAvailable() {
|
|
100
|
-
return redisConfig.enabled && redisClientIfReady() !== null;
|
|
155
|
+
return redisConfig.enabled && redisClientIfReady() !== null && sessionBreaker.getState() !== "open";
|
|
101
156
|
}
|
|
102
157
|
};
|