tina4-nodejs 3.0.0-rc.2
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/BENCHMARK_REPORT.md +96 -0
- package/CARBONAH.md +140 -0
- package/CLAUDE.md +599 -0
- package/COMPARISON.md +194 -0
- package/README.md +595 -0
- package/package.json +59 -0
- package/packages/cli/src/bin.ts +110 -0
- package/packages/cli/src/commands/init.ts +194 -0
- package/packages/cli/src/commands/migrate.ts +96 -0
- package/packages/cli/src/commands/migrateCreate.ts +59 -0
- package/packages/cli/src/commands/routes.ts +61 -0
- package/packages/cli/src/commands/serve.ts +58 -0
- package/packages/cli/src/commands/test.ts +83 -0
- package/packages/core/gallery/auth/meta.json +1 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
- package/packages/core/gallery/database/meta.json +1 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
- package/packages/core/gallery/error-overlay/meta.json +1 -0
- package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
- package/packages/core/gallery/orm/meta.json +1 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
- package/packages/core/gallery/queue/meta.json +1 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
- package/packages/core/gallery/rest-api/meta.json +1 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
- package/packages/core/gallery/templates/meta.json +1 -0
- package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
- package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
- package/packages/core/public/css/tina4.css +2463 -0
- package/packages/core/public/css/tina4.min.css +1 -0
- package/packages/core/public/favicon.ico +0 -0
- package/packages/core/public/images/logo.svg +5 -0
- package/packages/core/public/images/tina4-logo-icon.webp +0 -0
- package/packages/core/public/js/frond.min.js +420 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
- package/packages/core/public/js/tina4.min.js +93 -0
- package/packages/core/public/swagger/index.html +90 -0
- package/packages/core/public/swagger/oauth2-redirect.html +63 -0
- package/packages/core/src/ai.ts +359 -0
- package/packages/core/src/api.ts +248 -0
- package/packages/core/src/auth.ts +287 -0
- package/packages/core/src/cache.ts +121 -0
- package/packages/core/src/constants.ts +48 -0
- package/packages/core/src/container.ts +90 -0
- package/packages/core/src/devAdmin.ts +2024 -0
- package/packages/core/src/devMailbox.ts +316 -0
- package/packages/core/src/dotenv.ts +172 -0
- package/packages/core/src/errorOverlay.test.ts +122 -0
- package/packages/core/src/errorOverlay.ts +278 -0
- package/packages/core/src/events.ts +112 -0
- package/packages/core/src/fakeData.ts +309 -0
- package/packages/core/src/graphql.ts +812 -0
- package/packages/core/src/health.ts +31 -0
- package/packages/core/src/htmlElement.ts +172 -0
- package/packages/core/src/i18n.ts +136 -0
- package/packages/core/src/index.ts +88 -0
- package/packages/core/src/logger.ts +226 -0
- package/packages/core/src/messenger.ts +822 -0
- package/packages/core/src/middleware.ts +138 -0
- package/packages/core/src/queue.ts +481 -0
- package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
- package/packages/core/src/rateLimiter.ts +107 -0
- package/packages/core/src/request.ts +189 -0
- package/packages/core/src/response.ts +146 -0
- package/packages/core/src/routeDiscovery.ts +87 -0
- package/packages/core/src/router.ts +398 -0
- package/packages/core/src/scss.ts +366 -0
- package/packages/core/src/server.ts +610 -0
- package/packages/core/src/service.ts +380 -0
- package/packages/core/src/session.ts +480 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
- package/packages/core/src/static.ts +58 -0
- package/packages/core/src/testing.ts +233 -0
- package/packages/core/src/types.ts +98 -0
- package/packages/core/src/watcher.ts +37 -0
- package/packages/core/src/websocket.ts +408 -0
- package/packages/core/src/wsdl.ts +546 -0
- package/packages/core/templates/errors/302.twig +14 -0
- package/packages/core/templates/errors/401.twig +9 -0
- package/packages/core/templates/errors/403.twig +29 -0
- package/packages/core/templates/errors/404.twig +29 -0
- package/packages/core/templates/errors/500.twig +38 -0
- package/packages/core/templates/errors/502.twig +9 -0
- package/packages/core/templates/errors/503.twig +12 -0
- package/packages/core/templates/errors/base.twig +37 -0
- package/packages/frond/src/engine.ts +1475 -0
- package/packages/frond/src/index.ts +2 -0
- package/packages/orm/src/adapters/firebird.ts +455 -0
- package/packages/orm/src/adapters/mssql.ts +440 -0
- package/packages/orm/src/adapters/mysql.ts +355 -0
- package/packages/orm/src/adapters/postgres.ts +362 -0
- package/packages/orm/src/adapters/sqlite.ts +270 -0
- package/packages/orm/src/autoCrud.ts +231 -0
- package/packages/orm/src/baseModel.ts +536 -0
- package/packages/orm/src/database.ts +321 -0
- package/packages/orm/src/fakeData.ts +118 -0
- package/packages/orm/src/index.ts +49 -0
- package/packages/orm/src/migration.ts +392 -0
- package/packages/orm/src/model.ts +56 -0
- package/packages/orm/src/query.ts +113 -0
- package/packages/orm/src/seeder.ts +120 -0
- package/packages/orm/src/sqlTranslation.ts +272 -0
- package/packages/orm/src/types.ts +110 -0
- package/packages/orm/src/validation.ts +93 -0
- package/packages/swagger/src/generator.ts +189 -0
- package/packages/swagger/src/index.ts +2 -0
- package/packages/swagger/src/ui.ts +48 -0
- package/skills/tina4-developer.skill +0 -0
- package/skills/tina4-js.skill +0 -0
- package/skills/tina4-maintainer.skill +0 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 RabbitMQ Queue Backend — AMQP 0-9-1 via raw TCP, zero dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Implements the same interface as the file-based queue but uses RabbitMQ
|
|
5
|
+
* for message storage and delivery.
|
|
6
|
+
*
|
|
7
|
+
* Configure via environment variables:
|
|
8
|
+
* TINA4_RABBITMQ_HOST (default: "localhost")
|
|
9
|
+
* TINA4_RABBITMQ_PORT (default: 5672)
|
|
10
|
+
* TINA4_RABBITMQ_USERNAME (default: "guest")
|
|
11
|
+
* TINA4_RABBITMQ_PASSWORD (default: "guest")
|
|
12
|
+
* TINA4_RABBITMQ_VHOST (default: "/")
|
|
13
|
+
*/
|
|
14
|
+
import net from "node:net";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import type { QueueJob } from "../queue.js";
|
|
17
|
+
|
|
18
|
+
// ── Types ────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface RabbitMQConfig {
|
|
21
|
+
host?: string;
|
|
22
|
+
port?: number;
|
|
23
|
+
username?: string;
|
|
24
|
+
password?: string;
|
|
25
|
+
vhost?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface QueueBackend {
|
|
29
|
+
push(queue: string, payload: unknown, delay?: number): string;
|
|
30
|
+
pop(queue: string): QueueJob | null;
|
|
31
|
+
size(queue: string): number;
|
|
32
|
+
clear(queue: string): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── AMQP 0-9-1 Constants ────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const AMQP_PROTOCOL_HEADER = Buffer.from([65, 77, 81, 80, 0, 0, 9, 1]); // "AMQP" + 0.9.1
|
|
38
|
+
|
|
39
|
+
// Frame types
|
|
40
|
+
const FRAME_METHOD = 1;
|
|
41
|
+
const FRAME_HEADER = 2;
|
|
42
|
+
const FRAME_BODY = 3;
|
|
43
|
+
const FRAME_HEARTBEAT = 8;
|
|
44
|
+
const FRAME_END = 0xce;
|
|
45
|
+
|
|
46
|
+
// Class/method IDs
|
|
47
|
+
const CONNECTION_START = (10 << 16) | 10;
|
|
48
|
+
const CONNECTION_START_OK = (10 << 16) | 11;
|
|
49
|
+
const CONNECTION_TUNE = (10 << 16) | 30;
|
|
50
|
+
const CONNECTION_TUNE_OK = (10 << 16) | 31;
|
|
51
|
+
const CONNECTION_OPEN = (10 << 16) | 40;
|
|
52
|
+
const CONNECTION_OPEN_OK = (10 << 16) | 41;
|
|
53
|
+
const CONNECTION_CLOSE = (10 << 16) | 50;
|
|
54
|
+
const CONNECTION_CLOSE_OK = (10 << 16) | 51;
|
|
55
|
+
const CHANNEL_OPEN = (20 << 16) | 10;
|
|
56
|
+
const CHANNEL_OPEN_OK = (20 << 16) | 11;
|
|
57
|
+
const CHANNEL_CLOSE = (20 << 16) | 40;
|
|
58
|
+
const CHANNEL_CLOSE_OK = (20 << 16) | 41;
|
|
59
|
+
const QUEUE_DECLARE = (50 << 16) | 10;
|
|
60
|
+
const QUEUE_DECLARE_OK = (50 << 16) | 11;
|
|
61
|
+
const BASIC_PUBLISH = (60 << 16) | 40;
|
|
62
|
+
const BASIC_GET = (60 << 16) | 70;
|
|
63
|
+
const BASIC_GET_OK = (60 << 16) | 71;
|
|
64
|
+
const BASIC_GET_EMPTY = (60 << 16) | 72;
|
|
65
|
+
const BASIC_ACK = (60 << 16) | 80;
|
|
66
|
+
|
|
67
|
+
// ── AMQP Helpers ─────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function writeShortString(buf: Buffer, offset: number, str: string): number {
|
|
70
|
+
const len = Buffer.byteLength(str, "utf-8");
|
|
71
|
+
buf.writeUInt8(len, offset);
|
|
72
|
+
buf.write(str, offset + 1, len, "utf-8");
|
|
73
|
+
return offset + 1 + len;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeLongString(buf: Buffer, offset: number, str: string): number {
|
|
77
|
+
const len = Buffer.byteLength(str, "utf-8");
|
|
78
|
+
buf.writeUInt32BE(len, offset);
|
|
79
|
+
buf.write(str, offset + 4, len, "utf-8");
|
|
80
|
+
return offset + 4 + len;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeTable(table: Record<string, string>): Buffer {
|
|
84
|
+
const parts: Buffer[] = [];
|
|
85
|
+
for (const [key, value] of Object.entries(table)) {
|
|
86
|
+
const keyBuf = Buffer.alloc(1 + Buffer.byteLength(key));
|
|
87
|
+
writeShortString(keyBuf, 0, key);
|
|
88
|
+
parts.push(keyBuf);
|
|
89
|
+
|
|
90
|
+
// Type 'S' for long string
|
|
91
|
+
const valBuf = Buffer.alloc(1 + 4 + Buffer.byteLength(value));
|
|
92
|
+
valBuf.writeUInt8(83, 0); // 'S'
|
|
93
|
+
writeLongString(valBuf, 1, value);
|
|
94
|
+
parts.push(valBuf);
|
|
95
|
+
}
|
|
96
|
+
const tableData = Buffer.concat(parts);
|
|
97
|
+
const result = Buffer.alloc(4 + tableData.length);
|
|
98
|
+
result.writeUInt32BE(tableData.length, 0);
|
|
99
|
+
tableData.copy(result, 4);
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildMethodFrame(channel: number, classMethod: number, payload: Buffer): Buffer {
|
|
104
|
+
const framePayload = Buffer.alloc(4 + payload.length);
|
|
105
|
+
framePayload.writeUInt16BE((classMethod >> 16) & 0xffff, 0);
|
|
106
|
+
framePayload.writeUInt16BE(classMethod & 0xffff, 2);
|
|
107
|
+
payload.copy(framePayload, 4);
|
|
108
|
+
|
|
109
|
+
const frame = Buffer.alloc(7 + framePayload.length + 1);
|
|
110
|
+
frame.writeUInt8(FRAME_METHOD, 0);
|
|
111
|
+
frame.writeUInt16BE(channel, 1);
|
|
112
|
+
frame.writeUInt32BE(framePayload.length, 3);
|
|
113
|
+
framePayload.copy(frame, 7);
|
|
114
|
+
frame.writeUInt8(FRAME_END, 7 + framePayload.length);
|
|
115
|
+
return frame;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── RabbitMQ Backend ─────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* RabbitMQ queue backend using raw AMQP 0-9-1 protocol.
|
|
122
|
+
*
|
|
123
|
+
* Uses synchronous-style communication by spawning a child process
|
|
124
|
+
* for each operation, similar to the Redis session handler pattern.
|
|
125
|
+
* This keeps the interface synchronous as required by the Queue class.
|
|
126
|
+
*/
|
|
127
|
+
export class RabbitMQBackend implements QueueBackend {
|
|
128
|
+
private host: string;
|
|
129
|
+
private port: number;
|
|
130
|
+
private username: string;
|
|
131
|
+
private password: string;
|
|
132
|
+
private vhost: string;
|
|
133
|
+
|
|
134
|
+
constructor(config?: RabbitMQConfig) {
|
|
135
|
+
this.host = config?.host ?? process.env.TINA4_RABBITMQ_HOST ?? "localhost";
|
|
136
|
+
this.port = config?.port
|
|
137
|
+
?? (process.env.TINA4_RABBITMQ_PORT ? parseInt(process.env.TINA4_RABBITMQ_PORT, 10) : 5672);
|
|
138
|
+
this.username = config?.username ?? process.env.TINA4_RABBITMQ_USERNAME ?? "guest";
|
|
139
|
+
this.password = config?.password ?? process.env.TINA4_RABBITMQ_PASSWORD ?? "guest";
|
|
140
|
+
this.vhost = config?.vhost ?? process.env.TINA4_RABBITMQ_VHOST ?? "/";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Execute an AMQP operation synchronously via a child process.
|
|
145
|
+
*/
|
|
146
|
+
private execSync(operation: string, queue: string, data?: string): string {
|
|
147
|
+
const { execFileSync } = require("node:child_process");
|
|
148
|
+
|
|
149
|
+
const script = `
|
|
150
|
+
const net = require("node:net");
|
|
151
|
+
const host = ${JSON.stringify(this.host)};
|
|
152
|
+
const port = ${this.port};
|
|
153
|
+
const username = ${JSON.stringify(this.username)};
|
|
154
|
+
const password = ${JSON.stringify(this.password)};
|
|
155
|
+
const vhost = ${JSON.stringify(this.vhost)};
|
|
156
|
+
const operation = ${JSON.stringify(operation)};
|
|
157
|
+
const queueName = ${JSON.stringify(queue)};
|
|
158
|
+
const data = ${JSON.stringify(data ?? "")};
|
|
159
|
+
|
|
160
|
+
// Simplified AMQP interaction — connect, perform operation, disconnect
|
|
161
|
+
const sock = net.createConnection({ host, port }, () => {
|
|
162
|
+
// Send protocol header
|
|
163
|
+
sock.write(Buffer.from([65, 77, 81, 80, 0, 0, 9, 1]));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
let buffer = Buffer.alloc(0);
|
|
167
|
+
let step = "handshake";
|
|
168
|
+
let deliveryTag = null;
|
|
169
|
+
|
|
170
|
+
sock.on("data", (chunk) => {
|
|
171
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
172
|
+
processFrames();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
function processFrames() {
|
|
176
|
+
while (buffer.length >= 7) {
|
|
177
|
+
const frameType = buffer.readUInt8(0);
|
|
178
|
+
const channel = buffer.readUInt16BE(1);
|
|
179
|
+
const size = buffer.readUInt32BE(3);
|
|
180
|
+
|
|
181
|
+
if (buffer.length < 7 + size + 1) return; // Incomplete frame
|
|
182
|
+
|
|
183
|
+
const payload = buffer.subarray(7, 7 + size);
|
|
184
|
+
buffer = buffer.subarray(7 + size + 1);
|
|
185
|
+
|
|
186
|
+
if (frameType === 1) { // METHOD frame
|
|
187
|
+
const classId = payload.readUInt16BE(0);
|
|
188
|
+
const methodId = payload.readUInt16BE(2);
|
|
189
|
+
handleMethod(classId, methodId, payload.subarray(4), channel);
|
|
190
|
+
} else if (frameType === 2) { // HEADER frame
|
|
191
|
+
// Content header — skip for basic.get
|
|
192
|
+
} else if (frameType === 3) { // BODY frame
|
|
193
|
+
// Content body
|
|
194
|
+
const body = payload.toString("utf-8");
|
|
195
|
+
process.stdout.write(body);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function handleMethod(classId, methodId, args, channel) {
|
|
201
|
+
if (classId === 10 && methodId === 10) {
|
|
202
|
+
// Connection.Start → send Connection.Start-Ok
|
|
203
|
+
const props = buildTable({ product: "Tina4", version: "1.0" });
|
|
204
|
+
const mechanism = "PLAIN";
|
|
205
|
+
const saslData = "\\x00" + username + "\\x00" + password;
|
|
206
|
+
const locale = "en_US";
|
|
207
|
+
|
|
208
|
+
const payload = Buffer.alloc(4096);
|
|
209
|
+
let offset = 0;
|
|
210
|
+
|
|
211
|
+
// Client properties (table)
|
|
212
|
+
props.copy(payload, offset);
|
|
213
|
+
offset += props.length;
|
|
214
|
+
|
|
215
|
+
// Mechanism (short string)
|
|
216
|
+
const mechBuf = Buffer.from(mechanism, "utf-8");
|
|
217
|
+
payload.writeUInt8(mechBuf.length, offset); offset++;
|
|
218
|
+
mechBuf.copy(payload, offset); offset += mechBuf.length;
|
|
219
|
+
|
|
220
|
+
// Response (long string — SASL PLAIN)
|
|
221
|
+
const saslBuf = Buffer.from(saslData, "utf-8");
|
|
222
|
+
// Fix null bytes for PLAIN auth
|
|
223
|
+
saslBuf[0] = 0;
|
|
224
|
+
const userLen = Buffer.byteLength(username);
|
|
225
|
+
saslBuf[1 + userLen] = 0;
|
|
226
|
+
payload.writeUInt32BE(saslBuf.length, offset); offset += 4;
|
|
227
|
+
saslBuf.copy(payload, offset); offset += saslBuf.length;
|
|
228
|
+
|
|
229
|
+
// Locale (short string)
|
|
230
|
+
const localeBuf = Buffer.from(locale, "utf-8");
|
|
231
|
+
payload.writeUInt8(localeBuf.length, offset); offset++;
|
|
232
|
+
localeBuf.copy(payload, offset); offset += localeBuf.length;
|
|
233
|
+
|
|
234
|
+
sendMethod(0, 10, 11, payload.subarray(0, offset));
|
|
235
|
+
}
|
|
236
|
+
else if (classId === 10 && methodId === 30) {
|
|
237
|
+
// Connection.Tune → send Connection.Tune-Ok + Connection.Open
|
|
238
|
+
const tuneOk = Buffer.alloc(12);
|
|
239
|
+
tuneOk.writeUInt16BE(0, 0); // channel-max
|
|
240
|
+
tuneOk.writeUInt32BE(131072, 2); // frame-max
|
|
241
|
+
tuneOk.writeUInt16BE(60, 6); // heartbeat
|
|
242
|
+
sendMethod(0, 10, 31, tuneOk);
|
|
243
|
+
|
|
244
|
+
// Connection.Open
|
|
245
|
+
const vhostBuf = Buffer.from(vhost, "utf-8");
|
|
246
|
+
const openPayload = Buffer.alloc(3 + vhostBuf.length);
|
|
247
|
+
openPayload.writeUInt8(vhostBuf.length, 0);
|
|
248
|
+
vhostBuf.copy(openPayload, 1);
|
|
249
|
+
openPayload.writeUInt8(0, 1 + vhostBuf.length); // reserved
|
|
250
|
+
openPayload.writeUInt8(0, 2 + vhostBuf.length); // reserved
|
|
251
|
+
sendMethod(0, 10, 40, openPayload);
|
|
252
|
+
}
|
|
253
|
+
else if (classId === 10 && methodId === 41) {
|
|
254
|
+
// Connection.Open-Ok → open channel
|
|
255
|
+
const chanOpen = Buffer.alloc(1);
|
|
256
|
+
chanOpen.writeUInt8(0, 0);
|
|
257
|
+
sendMethod(1, 20, 10, chanOpen);
|
|
258
|
+
}
|
|
259
|
+
else if (classId === 20 && methodId === 11) {
|
|
260
|
+
// Channel.Open-Ok → declare queue
|
|
261
|
+
const qBuf = Buffer.from(queueName, "utf-8");
|
|
262
|
+
const declPayload = Buffer.alloc(7 + qBuf.length);
|
|
263
|
+
declPayload.writeUInt16BE(0, 0); // reserved
|
|
264
|
+
declPayload.writeUInt8(qBuf.length, 2);
|
|
265
|
+
qBuf.copy(declPayload, 3);
|
|
266
|
+
declPayload.writeUInt8(2, 3 + qBuf.length); // durable=true
|
|
267
|
+
declPayload.writeUInt32BE(0, 4 + qBuf.length); // arguments (empty table)
|
|
268
|
+
sendMethod(1, 50, 10, declPayload);
|
|
269
|
+
}
|
|
270
|
+
else if (classId === 50 && methodId === 11) {
|
|
271
|
+
// Queue.Declare-Ok → perform operation
|
|
272
|
+
if (operation === "publish") {
|
|
273
|
+
// Basic.Publish
|
|
274
|
+
const qBuf = Buffer.from(queueName, "utf-8");
|
|
275
|
+
const pubPayload = Buffer.alloc(5 + qBuf.length);
|
|
276
|
+
pubPayload.writeUInt16BE(0, 0); // reserved
|
|
277
|
+
pubPayload.writeUInt8(0, 2); // exchange (empty = default)
|
|
278
|
+
pubPayload.writeUInt8(qBuf.length, 3);
|
|
279
|
+
qBuf.copy(pubPayload, 4);
|
|
280
|
+
pubPayload.writeUInt8(0, 4 + qBuf.length); // mandatory=false
|
|
281
|
+
|
|
282
|
+
sendMethod(1, 60, 40, pubPayload);
|
|
283
|
+
|
|
284
|
+
// Content header
|
|
285
|
+
const bodyBuf = Buffer.from(data, "utf-8");
|
|
286
|
+
const header = Buffer.alloc(18);
|
|
287
|
+
header.writeUInt16BE(60, 0); // class = basic
|
|
288
|
+
header.writeUInt16BE(0, 2); // weight
|
|
289
|
+
// body size (64-bit, we only use lower 32)
|
|
290
|
+
header.writeUInt32BE(0, 4);
|
|
291
|
+
header.writeUInt32BE(bodyBuf.length, 8);
|
|
292
|
+
header.writeUInt16BE(0x6000, 12); // property flags: delivery-mode + content-type
|
|
293
|
+
// content-type
|
|
294
|
+
const ct = Buffer.from("application/json");
|
|
295
|
+
header.writeUInt8(ct.length, 14);
|
|
296
|
+
|
|
297
|
+
const fullHeader = Buffer.alloc(14 + 1 + ct.length + 1);
|
|
298
|
+
fullHeader.writeUInt16BE(60, 0);
|
|
299
|
+
fullHeader.writeUInt16BE(0, 2);
|
|
300
|
+
fullHeader.writeUInt32BE(0, 4);
|
|
301
|
+
fullHeader.writeUInt32BE(bodyBuf.length, 8);
|
|
302
|
+
fullHeader.writeUInt16BE(0x0000, 12); // no properties for simplicity
|
|
303
|
+
|
|
304
|
+
// Send header frame
|
|
305
|
+
const hFrame = Buffer.alloc(7 + fullHeader.length + 1);
|
|
306
|
+
hFrame.writeUInt8(2, 0); // header frame
|
|
307
|
+
hFrame.writeUInt16BE(1, 1); // channel
|
|
308
|
+
hFrame.writeUInt32BE(fullHeader.length, 3);
|
|
309
|
+
fullHeader.copy(hFrame, 7);
|
|
310
|
+
hFrame.writeUInt8(0xce, 7 + fullHeader.length);
|
|
311
|
+
sock.write(hFrame);
|
|
312
|
+
|
|
313
|
+
// Send body frame
|
|
314
|
+
const bFrame = Buffer.alloc(7 + bodyBuf.length + 1);
|
|
315
|
+
bFrame.writeUInt8(3, 0); // body frame
|
|
316
|
+
bFrame.writeUInt16BE(1, 1); // channel
|
|
317
|
+
bFrame.writeUInt32BE(bodyBuf.length, 3);
|
|
318
|
+
bodyBuf.copy(bFrame, 7);
|
|
319
|
+
bFrame.writeUInt8(0xce, 7 + bodyBuf.length);
|
|
320
|
+
sock.write(bFrame);
|
|
321
|
+
|
|
322
|
+
process.stdout.write("__PUBLISHED__");
|
|
323
|
+
closeConnection();
|
|
324
|
+
}
|
|
325
|
+
else if (operation === "get") {
|
|
326
|
+
// Basic.Get
|
|
327
|
+
const qBuf = Buffer.from(queueName, "utf-8");
|
|
328
|
+
const getPayload = Buffer.alloc(4 + qBuf.length);
|
|
329
|
+
getPayload.writeUInt16BE(0, 0); // reserved
|
|
330
|
+
getPayload.writeUInt8(qBuf.length, 2);
|
|
331
|
+
qBuf.copy(getPayload, 3);
|
|
332
|
+
getPayload.writeUInt8(1, 3 + qBuf.length); // no-ack=true
|
|
333
|
+
sendMethod(1, 60, 70, getPayload);
|
|
334
|
+
}
|
|
335
|
+
else if (operation === "size") {
|
|
336
|
+
// Queue.Declare-Ok already has message count
|
|
337
|
+
const msgCount = args.readUInt32BE(args.readUInt8(0) + 1);
|
|
338
|
+
process.stdout.write(String(msgCount));
|
|
339
|
+
closeConnection();
|
|
340
|
+
}
|
|
341
|
+
else if (operation === "purge") {
|
|
342
|
+
// Queue.Purge
|
|
343
|
+
const qBuf = Buffer.from(queueName, "utf-8");
|
|
344
|
+
const purgePayload = Buffer.alloc(4 + qBuf.length);
|
|
345
|
+
purgePayload.writeUInt16BE(0, 0);
|
|
346
|
+
purgePayload.writeUInt8(qBuf.length, 2);
|
|
347
|
+
qBuf.copy(purgePayload, 3);
|
|
348
|
+
purgePayload.writeUInt8(0, 3 + qBuf.length); // no-wait=false
|
|
349
|
+
sendMethod(1, 50, 30, purgePayload);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
else if (classId === 60 && methodId === 71) {
|
|
353
|
+
// Basic.Get-Ok — message body will follow in content frames
|
|
354
|
+
// Body comes next via BODY frames handled above
|
|
355
|
+
}
|
|
356
|
+
else if (classId === 60 && methodId === 72) {
|
|
357
|
+
// Basic.Get-Empty
|
|
358
|
+
process.stdout.write("__EMPTY__");
|
|
359
|
+
closeConnection();
|
|
360
|
+
}
|
|
361
|
+
else if (classId === 50 && methodId === 31) {
|
|
362
|
+
// Queue.Purge-Ok
|
|
363
|
+
process.stdout.write("__PURGED__");
|
|
364
|
+
closeConnection();
|
|
365
|
+
}
|
|
366
|
+
else if (classId === 10 && methodId === 50) {
|
|
367
|
+
// Connection.Close → send Connection.Close-Ok
|
|
368
|
+
sendMethod(0, 10, 51, Buffer.alloc(0));
|
|
369
|
+
sock.destroy();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function sendMethod(channel, classId, methodId, payload) {
|
|
374
|
+
const mp = Buffer.alloc(4 + payload.length);
|
|
375
|
+
mp.writeUInt16BE(classId, 0);
|
|
376
|
+
mp.writeUInt16BE(methodId, 2);
|
|
377
|
+
payload.copy(mp, 4);
|
|
378
|
+
|
|
379
|
+
const frame = Buffer.alloc(7 + mp.length + 1);
|
|
380
|
+
frame.writeUInt8(1, 0);
|
|
381
|
+
frame.writeUInt16BE(channel, 1);
|
|
382
|
+
frame.writeUInt32BE(mp.length, 3);
|
|
383
|
+
mp.copy(frame, 7);
|
|
384
|
+
frame.writeUInt8(0xce, 7 + mp.length);
|
|
385
|
+
sock.write(frame);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function buildTable(obj) {
|
|
389
|
+
const parts = [];
|
|
390
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
391
|
+
const keyBuf = Buffer.alloc(1 + Buffer.byteLength(k));
|
|
392
|
+
keyBuf.writeUInt8(Buffer.byteLength(k), 0);
|
|
393
|
+
keyBuf.write(k, 1, "utf-8");
|
|
394
|
+
parts.push(keyBuf);
|
|
395
|
+
const valBuf = Buffer.alloc(5 + Buffer.byteLength(v));
|
|
396
|
+
valBuf.writeUInt8(83, 0); // 'S'
|
|
397
|
+
valBuf.writeUInt32BE(Buffer.byteLength(v), 1);
|
|
398
|
+
valBuf.write(v, 5, "utf-8");
|
|
399
|
+
parts.push(valBuf);
|
|
400
|
+
}
|
|
401
|
+
const tableData = Buffer.concat(parts);
|
|
402
|
+
const result = Buffer.alloc(4 + tableData.length);
|
|
403
|
+
result.writeUInt32BE(tableData.length, 0);
|
|
404
|
+
tableData.copy(result, 4);
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function closeConnection() {
|
|
409
|
+
// Send Connection.Close
|
|
410
|
+
const closePayload = Buffer.alloc(6);
|
|
411
|
+
closePayload.writeUInt16BE(200, 0); // reply code
|
|
412
|
+
closePayload.writeUInt8(0, 2); // reply text (empty)
|
|
413
|
+
closePayload.writeUInt16BE(0, 3); // class
|
|
414
|
+
closePayload.writeUInt16BE(0, 5); // method
|
|
415
|
+
sendMethod(0, 10, 50, closePayload);
|
|
416
|
+
setTimeout(() => sock.destroy(), 500);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
sock.on("error", (err) => {
|
|
420
|
+
process.stderr.write(err.message);
|
|
421
|
+
process.exit(1);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
setTimeout(() => { sock.destroy(); process.exit(1); }, 10000);
|
|
425
|
+
`;
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
const result = execFileSync(process.execPath, ["-e", script], {
|
|
429
|
+
encoding: "utf-8",
|
|
430
|
+
timeout: 15000,
|
|
431
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
432
|
+
});
|
|
433
|
+
return result;
|
|
434
|
+
} catch {
|
|
435
|
+
return "";
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
push(queue: string, payload: unknown, _delay?: number): string {
|
|
440
|
+
const id = randomUUID();
|
|
441
|
+
const now = new Date().toISOString();
|
|
442
|
+
|
|
443
|
+
const job: QueueJob = {
|
|
444
|
+
id,
|
|
445
|
+
payload,
|
|
446
|
+
status: "pending",
|
|
447
|
+
createdAt: now,
|
|
448
|
+
attempts: 0,
|
|
449
|
+
delayUntil: null,
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const result = this.execSync("publish", queue, JSON.stringify(job));
|
|
453
|
+
if (!result.includes("__PUBLISHED__")) {
|
|
454
|
+
throw new Error("RabbitMQ publish failed");
|
|
455
|
+
}
|
|
456
|
+
return id;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
pop(queue: string): QueueJob | null {
|
|
460
|
+
const result = this.execSync("get", queue);
|
|
461
|
+
if (!result || result === "__EMPTY__") return null;
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
return JSON.parse(result) as QueueJob;
|
|
465
|
+
} catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
size(queue: string): number {
|
|
471
|
+
const result = this.execSync("size", queue);
|
|
472
|
+
const num = parseInt(result, 10);
|
|
473
|
+
return isNaN(num) ? 0 : num;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
clear(queue: string): void {
|
|
477
|
+
this.execSync("purge", queue);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Middleware } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Per-IP sliding window entry */
|
|
4
|
+
interface RateLimitEntry {
|
|
5
|
+
/** Timestamps of requests within the current window */
|
|
6
|
+
timestamps: number[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Configuration for the rate limiter */
|
|
10
|
+
export interface RateLimiterConfig {
|
|
11
|
+
/** Maximum number of requests per window. Default: 100 (or TINA4_RATE_LIMIT env) */
|
|
12
|
+
limit?: number;
|
|
13
|
+
/** Window duration in seconds. Default: 60 (or TINA4_RATE_WINDOW env) */
|
|
14
|
+
windowSeconds?: number;
|
|
15
|
+
/** Cleanup interval in milliseconds. Default: 60000 (1 minute) */
|
|
16
|
+
cleanupIntervalMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a rate limiter middleware using a sliding window algorithm.
|
|
21
|
+
* Tracks requests per IP in an in-memory Map.
|
|
22
|
+
*
|
|
23
|
+
* Response headers:
|
|
24
|
+
* X-RateLimit-Limit — Maximum requests per window
|
|
25
|
+
* X-RateLimit-Remaining — Requests remaining in the current window
|
|
26
|
+
* X-RateLimit-Reset — Unix timestamp (seconds) when the window resets
|
|
27
|
+
* Retry-After — Seconds to wait (only when rate limited)
|
|
28
|
+
*
|
|
29
|
+
* Returns 429 Too Many Requests when the limit is exceeded.
|
|
30
|
+
*/
|
|
31
|
+
export function rateLimiter(config?: RateLimiterConfig): Middleware {
|
|
32
|
+
const limit = config?.limit
|
|
33
|
+
?? (process.env.TINA4_RATE_LIMIT ? parseInt(process.env.TINA4_RATE_LIMIT, 10) : 100);
|
|
34
|
+
const windowSeconds = config?.windowSeconds
|
|
35
|
+
?? (process.env.TINA4_RATE_WINDOW ? parseInt(process.env.TINA4_RATE_WINDOW, 10) : 60);
|
|
36
|
+
const cleanupIntervalMs = config?.cleanupIntervalMs ?? 60_000;
|
|
37
|
+
|
|
38
|
+
const windowMs = windowSeconds * 1000;
|
|
39
|
+
const store = new Map<string, RateLimitEntry>();
|
|
40
|
+
|
|
41
|
+
// Periodic cleanup of expired entries
|
|
42
|
+
const cleanupTimer = setInterval(() => {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const cutoff = now - windowMs;
|
|
45
|
+
for (const [ip, entry] of store) {
|
|
46
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
47
|
+
if (entry.timestamps.length === 0) {
|
|
48
|
+
store.delete(ip);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}, cleanupIntervalMs);
|
|
52
|
+
|
|
53
|
+
// Don't let the cleanup timer keep the process alive
|
|
54
|
+
if (cleanupTimer.unref) {
|
|
55
|
+
cleanupTimer.unref();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (req, res, next) => {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const cutoff = now - windowMs;
|
|
61
|
+
|
|
62
|
+
// Extract client IP — check x-forwarded-for, then socket
|
|
63
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
64
|
+
const ip = (typeof forwarded === "string" ? forwarded.split(",")[0].trim() : undefined)
|
|
65
|
+
?? req.socket?.remoteAddress
|
|
66
|
+
?? "unknown";
|
|
67
|
+
|
|
68
|
+
// Get or create entry
|
|
69
|
+
let entry = store.get(ip);
|
|
70
|
+
if (!entry) {
|
|
71
|
+
entry = { timestamps: [] };
|
|
72
|
+
store.set(ip, entry);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Prune old timestamps outside the window
|
|
76
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
77
|
+
|
|
78
|
+
// Calculate reset time (end of window from the oldest request, or now + window)
|
|
79
|
+
const resetTimestamp = entry.timestamps.length > 0
|
|
80
|
+
? Math.ceil((entry.timestamps[0] + windowMs) / 1000)
|
|
81
|
+
: Math.ceil((now + windowMs) / 1000);
|
|
82
|
+
|
|
83
|
+
const remaining = Math.max(0, limit - entry.timestamps.length);
|
|
84
|
+
|
|
85
|
+
// Set rate limit headers
|
|
86
|
+
res.header("X-RateLimit-Limit", String(limit));
|
|
87
|
+
res.header("X-RateLimit-Remaining", String(Math.max(0, remaining - 1)));
|
|
88
|
+
res.header("X-RateLimit-Reset", String(resetTimestamp));
|
|
89
|
+
|
|
90
|
+
if (entry.timestamps.length >= limit) {
|
|
91
|
+
// Rate limited
|
|
92
|
+
const retryAfter = Math.max(1, resetTimestamp - Math.ceil(now / 1000));
|
|
93
|
+
res.header("Retry-After", String(retryAfter));
|
|
94
|
+
res.header("X-RateLimit-Remaining", "0");
|
|
95
|
+
res({
|
|
96
|
+
error: "Too Many Requests",
|
|
97
|
+
statusCode: 429,
|
|
98
|
+
message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
|
|
99
|
+
}, 429);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Record this request
|
|
104
|
+
entry.timestamps.push(now);
|
|
105
|
+
next();
|
|
106
|
+
};
|
|
107
|
+
}
|