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,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 DevMailbox — File-backed development mailbox, zero dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Captures emails to the filesystem instead of sending them via SMTP.
|
|
5
|
+
* Perfect for local development — no email server needed.
|
|
6
|
+
*
|
|
7
|
+
* import { DevMailbox, createMessenger } from "@tina4/core";
|
|
8
|
+
*
|
|
9
|
+
* const mailbox = new DevMailbox();
|
|
10
|
+
* mailbox.capture({ to: "alice@test.com", subject: "Hello", body: "Hi!" });
|
|
11
|
+
* const messages = mailbox.inbox();
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
import type { SendResult, EmailMessage } from "./messenger.js";
|
|
18
|
+
import { Messenger } from "./messenger.js";
|
|
19
|
+
import { isTruthy } from "./dotenv.js";
|
|
20
|
+
|
|
21
|
+
// ── DevMailbox ───────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export class DevMailbox {
|
|
24
|
+
private mailboxDir: string;
|
|
25
|
+
|
|
26
|
+
constructor(mailboxDir?: string) {
|
|
27
|
+
this.mailboxDir = mailboxDir ?? process.env.TINA4_MAILBOX_DIR ?? "data/mailbox";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ensure a folder directory exists.
|
|
32
|
+
*/
|
|
33
|
+
private ensureFolder(folder: string): string {
|
|
34
|
+
const dir = join(this.mailboxDir, folder);
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Capture an email to the dev mailbox instead of sending it.
|
|
41
|
+
*/
|
|
42
|
+
capture(options: {
|
|
43
|
+
to: string | string[];
|
|
44
|
+
subject: string;
|
|
45
|
+
body: string;
|
|
46
|
+
html?: boolean;
|
|
47
|
+
cc?: string[];
|
|
48
|
+
bcc?: string[];
|
|
49
|
+
replyTo?: string;
|
|
50
|
+
attachments?: string[];
|
|
51
|
+
from?: string;
|
|
52
|
+
}): SendResult {
|
|
53
|
+
const id = randomUUID();
|
|
54
|
+
const toList = Array.isArray(options.to) ? options.to : [options.to];
|
|
55
|
+
const now = new Date().toISOString();
|
|
56
|
+
|
|
57
|
+
const message: EmailMessage = {
|
|
58
|
+
id,
|
|
59
|
+
type: "outbox",
|
|
60
|
+
from: options.from ?? process.env.SMTP_FROM ?? "dev@localhost",
|
|
61
|
+
to: toList,
|
|
62
|
+
cc: options.cc ?? [],
|
|
63
|
+
bcc: options.bcc ?? [],
|
|
64
|
+
reply_to: options.replyTo,
|
|
65
|
+
subject: options.subject,
|
|
66
|
+
body: options.body,
|
|
67
|
+
html: options.html ?? false,
|
|
68
|
+
attachments: options.attachments ?? [],
|
|
69
|
+
date: now,
|
|
70
|
+
read: false,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Save to outbox
|
|
74
|
+
const outboxDir = this.ensureFolder("outbox");
|
|
75
|
+
writeFileSync(join(outboxDir, `${id}.json`), JSON.stringify(message, null, 2));
|
|
76
|
+
|
|
77
|
+
// Also save a copy to each recipient's inbox
|
|
78
|
+
const inboxDir = this.ensureFolder("inbox");
|
|
79
|
+
const inboxMessage: EmailMessage = { ...message, type: "inbox" };
|
|
80
|
+
writeFileSync(join(inboxDir, `${id}.json`), JSON.stringify(inboxMessage, null, 2));
|
|
81
|
+
|
|
82
|
+
return { success: true, message: "Email captured to dev mailbox", id };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* List messages from a folder (default: inbox).
|
|
87
|
+
*/
|
|
88
|
+
inbox(limit: number = 50, offset: number = 0, folder: string = "inbox"): EmailMessage[] {
|
|
89
|
+
const dir = this.ensureFolder(folder);
|
|
90
|
+
const results: EmailMessage[] = [];
|
|
91
|
+
|
|
92
|
+
let files: string[];
|
|
93
|
+
try {
|
|
94
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".json")).sort().reverse();
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sliced = files.slice(offset, offset + limit);
|
|
100
|
+
|
|
101
|
+
for (const file of sliced) {
|
|
102
|
+
try {
|
|
103
|
+
const msg: EmailMessage = JSON.parse(readFileSync(join(dir, file), "utf-8"));
|
|
104
|
+
results.push(msg);
|
|
105
|
+
} catch {
|
|
106
|
+
// skip corrupt files
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return results;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read a single message by ID. Searches all folders.
|
|
115
|
+
*/
|
|
116
|
+
read(msgId: string): EmailMessage | null {
|
|
117
|
+
const folders = ["inbox", "outbox"];
|
|
118
|
+
|
|
119
|
+
for (const folder of folders) {
|
|
120
|
+
const filePath = join(this.mailboxDir, folder, `${msgId}.json`);
|
|
121
|
+
if (existsSync(filePath)) {
|
|
122
|
+
try {
|
|
123
|
+
const msg: EmailMessage = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
124
|
+
// Mark as read
|
|
125
|
+
msg.read = true;
|
|
126
|
+
writeFileSync(filePath, JSON.stringify(msg, null, 2));
|
|
127
|
+
return msg;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Count unread messages in the inbox.
|
|
139
|
+
*/
|
|
140
|
+
unreadCount(): number {
|
|
141
|
+
const dir = this.ensureFolder("inbox");
|
|
142
|
+
let count = 0;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
try {
|
|
148
|
+
const msg: EmailMessage = JSON.parse(readFileSync(join(dir, file), "utf-8"));
|
|
149
|
+
if (!msg.read) count++;
|
|
150
|
+
} catch {
|
|
151
|
+
// skip corrupt files
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// directory might not exist
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return count;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Delete a message by ID. Removes from all folders.
|
|
163
|
+
*/
|
|
164
|
+
delete(msgId: string): boolean {
|
|
165
|
+
let deleted = false;
|
|
166
|
+
const folders = ["inbox", "outbox"];
|
|
167
|
+
|
|
168
|
+
for (const folder of folders) {
|
|
169
|
+
const filePath = join(this.mailboxDir, folder, `${msgId}.json`);
|
|
170
|
+
if (existsSync(filePath)) {
|
|
171
|
+
try {
|
|
172
|
+
unlinkSync(filePath);
|
|
173
|
+
deleted = true;
|
|
174
|
+
} catch {
|
|
175
|
+
// ignore
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return deleted;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Clear all messages from a folder, or all folders if none specified.
|
|
185
|
+
*/
|
|
186
|
+
clear(folder?: string): void {
|
|
187
|
+
const folders = folder ? [folder] : ["inbox", "outbox"];
|
|
188
|
+
|
|
189
|
+
for (const f of folders) {
|
|
190
|
+
const dir = join(this.mailboxDir, f);
|
|
191
|
+
try {
|
|
192
|
+
if (existsSync(dir)) {
|
|
193
|
+
const files = readdirSync(dir).filter((file) => file.endsWith(".json"));
|
|
194
|
+
for (const file of files) {
|
|
195
|
+
unlinkSync(join(dir, file));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
// ignore
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Seed the mailbox with sample messages for development.
|
|
206
|
+
*/
|
|
207
|
+
seed(count: number = 5): void {
|
|
208
|
+
const subjects = [
|
|
209
|
+
"Welcome to Tina4",
|
|
210
|
+
"Your account has been created",
|
|
211
|
+
"Weekly digest",
|
|
212
|
+
"Action required: Verify your email",
|
|
213
|
+
"New feature announcement",
|
|
214
|
+
"Security alert: New login detected",
|
|
215
|
+
"Invoice #1234",
|
|
216
|
+
"Meeting reminder",
|
|
217
|
+
"Password reset request",
|
|
218
|
+
"Feedback requested",
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
const senders = [
|
|
222
|
+
"noreply@tina4.com",
|
|
223
|
+
"admin@example.com",
|
|
224
|
+
"support@company.com",
|
|
225
|
+
"billing@service.io",
|
|
226
|
+
"alerts@monitoring.dev",
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
for (let i = 0; i < count; i++) {
|
|
230
|
+
const subject = subjects[i % subjects.length];
|
|
231
|
+
const from = senders[i % senders.length];
|
|
232
|
+
const date = new Date(Date.now() - i * 3600000).toISOString();
|
|
233
|
+
const id = randomUUID();
|
|
234
|
+
|
|
235
|
+
const message: EmailMessage = {
|
|
236
|
+
id,
|
|
237
|
+
type: "inbox",
|
|
238
|
+
from,
|
|
239
|
+
to: ["dev@localhost"],
|
|
240
|
+
cc: [],
|
|
241
|
+
bcc: [],
|
|
242
|
+
subject,
|
|
243
|
+
body: `This is sample email #${i + 1}.\n\nGenerated by DevMailbox.seed() for development purposes.`,
|
|
244
|
+
html: false,
|
|
245
|
+
attachments: [],
|
|
246
|
+
date,
|
|
247
|
+
read: i > 1, // first two are unread
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const dir = this.ensureFolder("inbox");
|
|
251
|
+
writeFileSync(join(dir, `${id}.json`), JSON.stringify(message, null, 2));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Count messages in a folder, or all folders if none specified.
|
|
257
|
+
*/
|
|
258
|
+
count(folder?: string): { inbox: number; outbox: number; total: number } {
|
|
259
|
+
const countDir = (f: string): number => {
|
|
260
|
+
const dir = join(this.mailboxDir, f);
|
|
261
|
+
try {
|
|
262
|
+
if (existsSync(dir)) {
|
|
263
|
+
return readdirSync(dir).filter((file) => file.endsWith(".json")).length;
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// ignore
|
|
267
|
+
}
|
|
268
|
+
return 0;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
if (folder) {
|
|
272
|
+
const c = countDir(folder);
|
|
273
|
+
return { inbox: folder === "inbox" ? c : 0, outbox: folder === "outbox" ? c : 0, total: c };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const inbox = countDir("inbox");
|
|
277
|
+
const outbox = countDir("outbox");
|
|
278
|
+
return { inbox, outbox, total: inbox + outbox };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Factory ──────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Create a Messenger or DevMailbox based on the environment.
|
|
286
|
+
*
|
|
287
|
+
* Returns DevMailbox when:
|
|
288
|
+
* - TINA4_DEBUG is "true", OR
|
|
289
|
+
* - No SMTP_HOST is configured
|
|
290
|
+
*
|
|
291
|
+
* Returns a real Messenger otherwise (SMTP configured + not debug mode).
|
|
292
|
+
*
|
|
293
|
+
* This follows the factory pattern from PHP's MessengerFactory.
|
|
294
|
+
*/
|
|
295
|
+
export function createMessenger(): Messenger | DevMailbox {
|
|
296
|
+
const debug = process.env.TINA4_DEBUG;
|
|
297
|
+
const smtpHost = process.env.SMTP_HOST;
|
|
298
|
+
|
|
299
|
+
// Force dev mode when TINA4_DEBUG is truthy
|
|
300
|
+
if (isTruthy(debug)) {
|
|
301
|
+
return new DevMailbox();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// No SMTP configured — must use dev mailbox
|
|
305
|
+
if (!smtpHost) {
|
|
306
|
+
return new DevMailbox();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Non-production environment — use dev mailbox
|
|
310
|
+
if (!isProd) {
|
|
311
|
+
return new DevMailbox();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Production with SMTP configured — use real Messenger
|
|
315
|
+
return new Messenger();
|
|
316
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse a .env file content string into key-value pairs.
|
|
6
|
+
* Supports:
|
|
7
|
+
* - KEY=value
|
|
8
|
+
* - KEY="double quoted"
|
|
9
|
+
* - KEY='single quoted'
|
|
10
|
+
* - export KEY=value
|
|
11
|
+
* - # comments
|
|
12
|
+
* - Empty lines
|
|
13
|
+
* - Multi-line with trailing backslash \
|
|
14
|
+
*/
|
|
15
|
+
function parseEnvContent(content: string): Record<string, string> {
|
|
16
|
+
const result: Record<string, string> = {};
|
|
17
|
+
const lines = content.split("\n");
|
|
18
|
+
let i = 0;
|
|
19
|
+
|
|
20
|
+
while (i < lines.length) {
|
|
21
|
+
let line = lines[i].trim();
|
|
22
|
+
i++;
|
|
23
|
+
|
|
24
|
+
// Skip empty lines and comments
|
|
25
|
+
if (line === "" || line.startsWith("#")) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Strip "export " prefix
|
|
30
|
+
if (line.startsWith("export ")) {
|
|
31
|
+
line = line.slice(7).trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Find the first = sign
|
|
35
|
+
const eqIndex = line.indexOf("=");
|
|
36
|
+
if (eqIndex === -1) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const key = line.slice(0, eqIndex).trim();
|
|
41
|
+
let value = line.slice(eqIndex + 1).trim();
|
|
42
|
+
|
|
43
|
+
// Handle quoted values
|
|
44
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
45
|
+
value = value.slice(1, -1);
|
|
46
|
+
// Process escape sequences in double-quoted values
|
|
47
|
+
value = value
|
|
48
|
+
.replace(/\\n/g, "\n")
|
|
49
|
+
.replace(/\\r/g, "\r")
|
|
50
|
+
.replace(/\\t/g, "\t")
|
|
51
|
+
.replace(/\\"/g, '"')
|
|
52
|
+
.replace(/\\\\/g, "\\");
|
|
53
|
+
} else if (value.startsWith("'") && value.endsWith("'")) {
|
|
54
|
+
// Single-quoted: literal, no escape processing
|
|
55
|
+
value = value.slice(1, -1);
|
|
56
|
+
} else {
|
|
57
|
+
// Unquoted: handle multi-line with trailing backslash
|
|
58
|
+
while (value.endsWith("\\") && i < lines.length) {
|
|
59
|
+
value = value.slice(0, -1) + lines[i].trim();
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
// Strip inline comments (only for unquoted values)
|
|
63
|
+
const commentIndex = value.indexOf(" #");
|
|
64
|
+
if (commentIndex !== -1) {
|
|
65
|
+
value = value.slice(0, commentIndex).trim();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
result[key] = value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Load environment variables from a .env file into process.env.
|
|
77
|
+
* Does not override existing process.env values unless they are undefined.
|
|
78
|
+
*
|
|
79
|
+
* @param path - Path to the .env file. Defaults to ".env" in the current working directory.
|
|
80
|
+
* @returns The parsed key-value pairs, or an empty object if the file doesn't exist.
|
|
81
|
+
*/
|
|
82
|
+
export function loadEnv(path?: string): Record<string, string> {
|
|
83
|
+
const envPath = resolve(path ?? ".env");
|
|
84
|
+
|
|
85
|
+
if (!existsSync(envPath)) {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const content = readFileSync(envPath, "utf-8");
|
|
90
|
+
const parsed = parseEnvContent(content);
|
|
91
|
+
|
|
92
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
93
|
+
if (process.env[key] === undefined) {
|
|
94
|
+
process.env[key] = value;
|
|
95
|
+
_loadedKeys.push(key);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return parsed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get an environment variable value with an optional default.
|
|
104
|
+
*
|
|
105
|
+
* @param key - The environment variable name.
|
|
106
|
+
* @param defaultValue - Value to return if the variable is not set.
|
|
107
|
+
* @returns The environment variable value, or the default.
|
|
108
|
+
*/
|
|
109
|
+
export function getEnv(key: string, defaultValue?: string): string | undefined {
|
|
110
|
+
return process.env[key] ?? defaultValue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get a required environment variable. Throws if not set.
|
|
115
|
+
*
|
|
116
|
+
* @param key - The environment variable name.
|
|
117
|
+
* @returns The environment variable value.
|
|
118
|
+
* @throws Error if the variable is not set.
|
|
119
|
+
*/
|
|
120
|
+
export function requireEnv(key: string): string {
|
|
121
|
+
const value = process.env[key];
|
|
122
|
+
if (value === undefined) {
|
|
123
|
+
throw new Error(`Required environment variable "${key}" is not set.`);
|
|
124
|
+
}
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if an environment variable exists (is defined in process.env).
|
|
130
|
+
*
|
|
131
|
+
* @param key - The environment variable name.
|
|
132
|
+
* @returns true if the variable is set, false otherwise.
|
|
133
|
+
*/
|
|
134
|
+
export function hasEnv(key: string): boolean {
|
|
135
|
+
return process.env[key] !== undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Return all currently loaded environment variables.
|
|
140
|
+
*
|
|
141
|
+
* @returns A shallow copy of process.env as a record.
|
|
142
|
+
*/
|
|
143
|
+
export function allEnv(): Record<string, string | undefined> {
|
|
144
|
+
return { ...process.env };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if a value is truthy for env boolean checks.
|
|
149
|
+
*
|
|
150
|
+
* Accepts: "true", "True", "TRUE", "1", "yes", "Yes", "YES", "on", "On", "ON".
|
|
151
|
+
* Everything else is falsy (including empty string, undefined, not set).
|
|
152
|
+
*
|
|
153
|
+
* Mirrors Python's `is_truthy()` in `tina4_python.dotenv`.
|
|
154
|
+
*/
|
|
155
|
+
export function isTruthy(val: string | undefined | null): boolean {
|
|
156
|
+
if (val == null) return false;
|
|
157
|
+
return ["true", "1", "yes", "on"].includes(val.trim().toLowerCase());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Keys loaded by loadEnv, tracked for resetEnv(). */
|
|
161
|
+
const _loadedKeys: string[] = [];
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Remove all environment variables that were loaded by loadEnv().
|
|
165
|
+
* Useful for testing. Only removes keys set by loadEnv(), not pre-existing system env vars.
|
|
166
|
+
*/
|
|
167
|
+
export function resetEnv(): void {
|
|
168
|
+
for (const key of _loadedKeys) {
|
|
169
|
+
delete process.env[key];
|
|
170
|
+
}
|
|
171
|
+
_loadedKeys.length = 0;
|
|
172
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for errorOverlay module.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { renderErrorOverlay, renderProductionError, isDebugMode } from "./errorOverlay.js";
|
|
6
|
+
|
|
7
|
+
let passed = 0;
|
|
8
|
+
let failed = 0;
|
|
9
|
+
|
|
10
|
+
function assert(condition: boolean, message: string): void {
|
|
11
|
+
if (condition) {
|
|
12
|
+
passed++;
|
|
13
|
+
} else {
|
|
14
|
+
failed++;
|
|
15
|
+
console.error(` FAIL: ${message}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertIncludes(html: string, needle: string, label: string): void {
|
|
20
|
+
assert(html.includes(needle), `${label}: expected HTML to include "${needle}"`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assertNotIncludes(html: string, needle: string, label: string): void {
|
|
24
|
+
assert(!html.includes(needle), `${label}: expected HTML NOT to include "${needle}"`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeError(): Error {
|
|
28
|
+
try {
|
|
29
|
+
throw new TypeError("something broke");
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return e as Error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── renderErrorOverlay ──
|
|
36
|
+
|
|
37
|
+
const err = makeError();
|
|
38
|
+
const html = renderErrorOverlay(err);
|
|
39
|
+
|
|
40
|
+
assert(typeof html === "string", "returns a string");
|
|
41
|
+
assert(html.startsWith("<!DOCTYPE html>"), "starts with DOCTYPE");
|
|
42
|
+
assertIncludes(html, "TypeError", "contains exception type");
|
|
43
|
+
assertIncludes(html, "something broke", "contains exception message");
|
|
44
|
+
assertIncludes(html, "errorOverlay.test", "contains file path");
|
|
45
|
+
assertIncludes(html, "▶", "contains error line marker");
|
|
46
|
+
assertIncludes(html, "Stack Trace", "contains stack trace section");
|
|
47
|
+
assertIncludes(html, "<details", "uses details element");
|
|
48
|
+
assertIncludes(html, "open", "stack trace open by default");
|
|
49
|
+
assertIncludes(html, "Environment", "contains environment section");
|
|
50
|
+
assertIncludes(html, "Tina4 Node.js", "contains framework name");
|
|
51
|
+
assertIncludes(html, "TINA4_DEBUG", "contains debug reference");
|
|
52
|
+
|
|
53
|
+
// With request
|
|
54
|
+
const htmlWithReq = renderErrorOverlay(err, {
|
|
55
|
+
method: "GET",
|
|
56
|
+
url: "/api/users",
|
|
57
|
+
headers: { host: "localhost" },
|
|
58
|
+
});
|
|
59
|
+
assertIncludes(htmlWithReq, "GET", "request method shown");
|
|
60
|
+
assertIncludes(htmlWithReq, "/api/users", "request URL shown");
|
|
61
|
+
assertIncludes(htmlWithReq, "localhost", "request header shown");
|
|
62
|
+
assertIncludes(htmlWithReq, "Request Details", "request section present");
|
|
63
|
+
|
|
64
|
+
// Without request
|
|
65
|
+
assertNotIncludes(html, "Request Details", "no request section when no request");
|
|
66
|
+
|
|
67
|
+
// XSS escaping
|
|
68
|
+
const xssErr = new Error('<script>alert("xss")</script>');
|
|
69
|
+
const xssHtml = renderErrorOverlay(xssErr);
|
|
70
|
+
assertNotIncludes(xssHtml, "<script>", "escapes script tags");
|
|
71
|
+
assertIncludes(xssHtml, "<script>", "HTML-encodes script tags");
|
|
72
|
+
|
|
73
|
+
// ── renderProductionError ──
|
|
74
|
+
|
|
75
|
+
const prodHtml = renderProductionError();
|
|
76
|
+
assert(prodHtml.startsWith("<!DOCTYPE html>"), "production: starts with DOCTYPE");
|
|
77
|
+
assertIncludes(prodHtml, "500", "production: contains 500");
|
|
78
|
+
assertIncludes(prodHtml, "Internal Server Error", "production: default message");
|
|
79
|
+
assertNotIncludes(prodHtml, "Stack Trace", "production: no stack trace");
|
|
80
|
+
|
|
81
|
+
const prod404 = renderProductionError(404, "Not Found");
|
|
82
|
+
assertIncludes(prod404, "404", "production 404: contains code");
|
|
83
|
+
assertIncludes(prod404, "Not Found", "production 404: contains message");
|
|
84
|
+
|
|
85
|
+
// ── isDebugMode ──
|
|
86
|
+
|
|
87
|
+
const origDebug = process.env.TINA4_DEBUG;
|
|
88
|
+
|
|
89
|
+
process.env.TINA4_DEBUG = "true";
|
|
90
|
+
assert(isDebugMode() === true, "isDebugMode: true => true");
|
|
91
|
+
|
|
92
|
+
process.env.TINA4_DEBUG = "false";
|
|
93
|
+
assert(isDebugMode() === false, "isDebugMode: false => false");
|
|
94
|
+
|
|
95
|
+
process.env.TINA4_DEBUG = "TRUE";
|
|
96
|
+
assert(isDebugMode() === true, "isDebugMode: TRUE (uppercase) => true (case insensitive)");
|
|
97
|
+
|
|
98
|
+
process.env.TINA4_DEBUG = "1";
|
|
99
|
+
assert(isDebugMode() === true, "isDebugMode: 1 => true");
|
|
100
|
+
|
|
101
|
+
process.env.TINA4_DEBUG = "yes";
|
|
102
|
+
assert(isDebugMode() === true, "isDebugMode: yes => true");
|
|
103
|
+
|
|
104
|
+
process.env.TINA4_DEBUG = "on";
|
|
105
|
+
assert(isDebugMode() === true, "isDebugMode: on => true");
|
|
106
|
+
|
|
107
|
+
process.env.TINA4_DEBUG = "0";
|
|
108
|
+
assert(isDebugMode() === false, "isDebugMode: 0 => false");
|
|
109
|
+
|
|
110
|
+
delete process.env.TINA4_DEBUG;
|
|
111
|
+
assert(isDebugMode() === false, "isDebugMode: unset => false");
|
|
112
|
+
|
|
113
|
+
// Restore
|
|
114
|
+
if (origDebug !== undefined) {
|
|
115
|
+
process.env.TINA4_DEBUG = origDebug;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Summary ──
|
|
119
|
+
console.log(`\nerrorOverlay tests: ${passed} passed, ${failed} failed`);
|
|
120
|
+
if (failed > 0) {
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|