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.
Files changed (119) hide show
  1. package/BENCHMARK_REPORT.md +96 -0
  2. package/CARBONAH.md +140 -0
  3. package/CLAUDE.md +599 -0
  4. package/COMPARISON.md +194 -0
  5. package/README.md +595 -0
  6. package/package.json +59 -0
  7. package/packages/cli/src/bin.ts +110 -0
  8. package/packages/cli/src/commands/init.ts +194 -0
  9. package/packages/cli/src/commands/migrate.ts +96 -0
  10. package/packages/cli/src/commands/migrateCreate.ts +59 -0
  11. package/packages/cli/src/commands/routes.ts +61 -0
  12. package/packages/cli/src/commands/serve.ts +58 -0
  13. package/packages/cli/src/commands/test.ts +83 -0
  14. package/packages/core/gallery/auth/meta.json +1 -0
  15. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
  16. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
  17. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
  18. package/packages/core/gallery/database/meta.json +1 -0
  19. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
  20. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
  21. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
  22. package/packages/core/gallery/error-overlay/meta.json +1 -0
  23. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
  24. package/packages/core/gallery/orm/meta.json +1 -0
  25. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
  26. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
  27. package/packages/core/gallery/queue/meta.json +1 -0
  28. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
  29. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
  30. package/packages/core/gallery/rest-api/meta.json +1 -0
  31. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
  32. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
  33. package/packages/core/gallery/templates/meta.json +1 -0
  34. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
  35. package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
  36. package/packages/core/public/css/tina4.css +2463 -0
  37. package/packages/core/public/css/tina4.min.css +1 -0
  38. package/packages/core/public/favicon.ico +0 -0
  39. package/packages/core/public/images/logo.svg +5 -0
  40. package/packages/core/public/images/tina4-logo-icon.webp +0 -0
  41. package/packages/core/public/js/frond.min.js +420 -0
  42. package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
  43. package/packages/core/public/js/tina4.min.js +93 -0
  44. package/packages/core/public/swagger/index.html +90 -0
  45. package/packages/core/public/swagger/oauth2-redirect.html +63 -0
  46. package/packages/core/src/ai.ts +359 -0
  47. package/packages/core/src/api.ts +248 -0
  48. package/packages/core/src/auth.ts +287 -0
  49. package/packages/core/src/cache.ts +121 -0
  50. package/packages/core/src/constants.ts +48 -0
  51. package/packages/core/src/container.ts +90 -0
  52. package/packages/core/src/devAdmin.ts +2024 -0
  53. package/packages/core/src/devMailbox.ts +316 -0
  54. package/packages/core/src/dotenv.ts +172 -0
  55. package/packages/core/src/errorOverlay.test.ts +122 -0
  56. package/packages/core/src/errorOverlay.ts +278 -0
  57. package/packages/core/src/events.ts +112 -0
  58. package/packages/core/src/fakeData.ts +309 -0
  59. package/packages/core/src/graphql.ts +812 -0
  60. package/packages/core/src/health.ts +31 -0
  61. package/packages/core/src/htmlElement.ts +172 -0
  62. package/packages/core/src/i18n.ts +136 -0
  63. package/packages/core/src/index.ts +88 -0
  64. package/packages/core/src/logger.ts +226 -0
  65. package/packages/core/src/messenger.ts +822 -0
  66. package/packages/core/src/middleware.ts +138 -0
  67. package/packages/core/src/queue.ts +481 -0
  68. package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
  69. package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
  70. package/packages/core/src/rateLimiter.ts +107 -0
  71. package/packages/core/src/request.ts +189 -0
  72. package/packages/core/src/response.ts +146 -0
  73. package/packages/core/src/routeDiscovery.ts +87 -0
  74. package/packages/core/src/router.ts +398 -0
  75. package/packages/core/src/scss.ts +366 -0
  76. package/packages/core/src/server.ts +610 -0
  77. package/packages/core/src/service.ts +380 -0
  78. package/packages/core/src/session.ts +480 -0
  79. package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
  80. package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
  81. package/packages/core/src/static.ts +58 -0
  82. package/packages/core/src/testing.ts +233 -0
  83. package/packages/core/src/types.ts +98 -0
  84. package/packages/core/src/watcher.ts +37 -0
  85. package/packages/core/src/websocket.ts +408 -0
  86. package/packages/core/src/wsdl.ts +546 -0
  87. package/packages/core/templates/errors/302.twig +14 -0
  88. package/packages/core/templates/errors/401.twig +9 -0
  89. package/packages/core/templates/errors/403.twig +29 -0
  90. package/packages/core/templates/errors/404.twig +29 -0
  91. package/packages/core/templates/errors/500.twig +38 -0
  92. package/packages/core/templates/errors/502.twig +9 -0
  93. package/packages/core/templates/errors/503.twig +12 -0
  94. package/packages/core/templates/errors/base.twig +37 -0
  95. package/packages/frond/src/engine.ts +1475 -0
  96. package/packages/frond/src/index.ts +2 -0
  97. package/packages/orm/src/adapters/firebird.ts +455 -0
  98. package/packages/orm/src/adapters/mssql.ts +440 -0
  99. package/packages/orm/src/adapters/mysql.ts +355 -0
  100. package/packages/orm/src/adapters/postgres.ts +362 -0
  101. package/packages/orm/src/adapters/sqlite.ts +270 -0
  102. package/packages/orm/src/autoCrud.ts +231 -0
  103. package/packages/orm/src/baseModel.ts +536 -0
  104. package/packages/orm/src/database.ts +321 -0
  105. package/packages/orm/src/fakeData.ts +118 -0
  106. package/packages/orm/src/index.ts +49 -0
  107. package/packages/orm/src/migration.ts +392 -0
  108. package/packages/orm/src/model.ts +56 -0
  109. package/packages/orm/src/query.ts +113 -0
  110. package/packages/orm/src/seeder.ts +120 -0
  111. package/packages/orm/src/sqlTranslation.ts +272 -0
  112. package/packages/orm/src/types.ts +110 -0
  113. package/packages/orm/src/validation.ts +93 -0
  114. package/packages/swagger/src/generator.ts +189 -0
  115. package/packages/swagger/src/index.ts +2 -0
  116. package/packages/swagger/src/ui.ts +48 -0
  117. package/skills/tina4-developer.skill +0 -0
  118. package/skills/tina4-js.skill +0 -0
  119. package/skills/tina4-maintainer.skill +0 -0
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Tina4 AI — Detect AI coding assistants and scaffold context files.
3
+ *
4
+ * Detect which AI coding tools are available and install framework-aware
5
+ * context so that any AI assistant understands how to build with Tina4.
6
+ *
7
+ * import { detectAi, installAiContext } from "@tina4/core";
8
+ *
9
+ * const tools = detectAi(); // [{ name: "claude-code", ... }]
10
+ * installAiContext(); // Scaffold context for all detected tools
11
+ */
12
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, copyFileSync, cpSync, statSync } from "node:fs";
13
+ import { join, resolve, relative, dirname } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ // ── Types ────────────────────────────────────────────────────
17
+
18
+ export interface AiTool {
19
+ name: string;
20
+ description: string;
21
+ configDir: string | null;
22
+ contextFile: string;
23
+ }
24
+
25
+ export interface AiDetection {
26
+ name: string;
27
+ description: string;
28
+ configFile: string;
29
+ status: "detected" | "not-detected";
30
+ }
31
+
32
+ // ── Tool definitions ─────────────────────────────────────────
33
+
34
+ const AI_TOOLS: Record<string, AiTool> = {
35
+ "claude-code": {
36
+ name: "claude-code",
37
+ description: "Claude Code (Anthropic CLI)",
38
+ configDir: ".claude",
39
+ contextFile: "CLAUDE.md",
40
+ },
41
+ cursor: {
42
+ name: "cursor",
43
+ description: "Cursor IDE",
44
+ configDir: ".cursor",
45
+ contextFile: ".cursorules",
46
+ },
47
+ copilot: {
48
+ name: "copilot",
49
+ description: "GitHub Copilot",
50
+ configDir: ".github",
51
+ contextFile: ".github/copilot-instructions.md",
52
+ },
53
+ windsurf: {
54
+ name: "windsurf",
55
+ description: "Windsurf (Codeium)",
56
+ configDir: null,
57
+ contextFile: ".windsurfrules",
58
+ },
59
+ aider: {
60
+ name: "aider",
61
+ description: "Aider",
62
+ configDir: null,
63
+ contextFile: "CONVENTIONS.md",
64
+ },
65
+ cline: {
66
+ name: "cline",
67
+ description: "Cline (VS Code)",
68
+ configDir: null,
69
+ contextFile: ".clinerules",
70
+ },
71
+ codex: {
72
+ name: "codex",
73
+ description: "OpenAI Codex CLI",
74
+ configDir: null,
75
+ contextFile: "AGENTS.md",
76
+ },
77
+ };
78
+
79
+ // ── Detection helpers ────────────────────────────────────────
80
+
81
+ function detectTool(root: string, toolName: string): boolean {
82
+ const r = resolve(root);
83
+ switch (toolName) {
84
+ case "claude-code":
85
+ return existsSync(join(r, ".claude")) || existsSync(join(r, "CLAUDE.md"));
86
+ case "cursor":
87
+ return existsSync(join(r, ".cursor")) || existsSync(join(r, ".cursorules"));
88
+ case "copilot":
89
+ return existsSync(join(r, ".github", "copilot-instructions.md")) || existsSync(join(r, ".github"));
90
+ case "windsurf":
91
+ return existsSync(join(r, ".windsurfrules"));
92
+ case "aider":
93
+ return existsSync(join(r, ".aider.conf.yml")) || existsSync(join(r, "CONVENTIONS.md"));
94
+ case "cline":
95
+ return existsSync(join(r, ".clinerules"));
96
+ case "codex":
97
+ return existsSync(join(r, "AGENTS.md")) || existsSync(join(r, "codex.md"));
98
+ default:
99
+ return false;
100
+ }
101
+ }
102
+
103
+ // ── Public API ───────────────────────────────────────────────
104
+
105
+ /**
106
+ * Detect which AI coding tools are present in the project.
107
+ */
108
+ export function detectAi(root: string = "."): AiDetection[] {
109
+ const r = resolve(root);
110
+ const results: AiDetection[] = [];
111
+
112
+ for (const [name, tool] of Object.entries(AI_TOOLS)) {
113
+ results.push({
114
+ name,
115
+ description: tool.description,
116
+ configFile: tool.contextFile,
117
+ status: detectTool(r, name) ? "detected" : "not-detected",
118
+ });
119
+ }
120
+
121
+ return results;
122
+ }
123
+
124
+ /**
125
+ * Return just the names of detected AI tools.
126
+ */
127
+ export function detectAiNames(root: string = "."): string[] {
128
+ return detectAi(root)
129
+ .filter((t) => t.status === "detected")
130
+ .map((t) => t.name);
131
+ }
132
+
133
+ /**
134
+ * Generate a universal Tina4 context document for any AI assistant.
135
+ */
136
+ export function generateContext(): string {
137
+ return `# Tina4 Node.js — AI Context
138
+
139
+ This project uses **Tina4 for Node.js/TypeScript**, a lightweight, batteries-included
140
+ web framework with zero third-party dependencies for core features.
141
+
142
+ **Documentation:** https://tina4.com
143
+
144
+ ## Quick Start
145
+
146
+ \`\`\`bash
147
+ tina4nodejs init . # Scaffold project
148
+ tina4nodejs serve # Start dev server on port 7148
149
+ tina4nodejs migrate # Run database migrations
150
+ tina4nodejs test # Run test suite
151
+ tina4nodejs routes # List all registered routes
152
+ \`\`\`
153
+
154
+ ## Project Structure
155
+
156
+ \`\`\`
157
+ src/routes/ — Route handlers (auto-discovered, file-based routing)
158
+ src/models/ — ORM models (one per file, convention-based)
159
+ src/templates/ — Twig templates
160
+ src/public/ — Static assets served at /
161
+ src/scss/ — SCSS files (auto-compiled to public/css/)
162
+ migrations/ — SQL migration files (sequential numbered)
163
+ test/ — Test files
164
+ \`\`\`
165
+
166
+ ## Built-in Features (No External Packages Needed)
167
+
168
+ | Feature | Module | Import |
169
+ |---------|--------|--------|
170
+ | Routing | router | \`import { get, post, put, del } from "@tina4/core"\` |
171
+ | ORM | orm | \`import { BaseModel } from "@tina4/orm"\` |
172
+ | Database | database | \`import { initDatabase } from "@tina4/orm"\` |
173
+ | Templates | twig | \`import { renderTemplate } from "@tina4/twig"\` |
174
+ | JWT Auth | auth | \`import { createToken, validateToken } from "@tina4/core"\` |
175
+ | REST API Client | api | \`import { Api } from "@tina4/core"\` |
176
+ | GraphQL | graphql | \`import { GraphQL } from "@tina4/core"\` |
177
+ | WebSocket | websocket | \`import { WebSocketServer } from "@tina4/core"\` |
178
+ | SOAP/WSDL | wsdl | \`import { WSDLService } from "@tina4/core"\` |
179
+ | Email (SMTP+IMAP) | messenger | \`import { Messenger } from "@tina4/core"\` |
180
+ | Background Queue | queue | \`import { Queue } from "@tina4/core"\` |
181
+ | SCSS Compilation | scss | Auto-compiled from src/scss/ |
182
+ | Migrations | migration | \`tina4nodejs migrate\` CLI command |
183
+ | i18n | i18n | \`import { I18n } from "@tina4/core"\` |
184
+ | Swagger/OpenAPI | swagger | Auto-generated at /swagger |
185
+ | Sessions | session | \`import { Session } from "@tina4/core"\` |
186
+ | Middleware | middleware | \`import { MiddlewareChain } from "@tina4/core"\` |
187
+ | Cache | cache | \`import { responseCache } from "@tina4/core"\` |
188
+
189
+ ## Key Conventions
190
+
191
+ 1. **Route files export a default async function** — \`export default async function(req, res) {}\`
192
+ 2. **File-based routing** — directory structure mirrors URL paths
193
+ 3. **Dynamic params use brackets** — \`[id]\` for params, \`[...slug]\` for catch-all
194
+ 4. **GET routes are public**, POST/PUT/PATCH/DELETE require auth by default
195
+ 5. **ESM everywhere** — use \`.js\` extensions in imports
196
+ 6. **No inline styles** — use SCSS in \`src/scss/\`
197
+ 7. **All schema changes via migrations** — never create tables in route code
198
+ 8. **Use built-in features** — never install packages for things Tina4 already provides
199
+
200
+ ## Common Patterns
201
+
202
+ ### Route
203
+ \`\`\`typescript
204
+ // src/routes/api/users/post.ts
205
+ import type { Tina4Request, Tina4Response } from "@tina4/core";
206
+
207
+ export const meta = { summary: "Create a user", tags: ["users"] };
208
+
209
+ export default async function (req: Tina4Request, res: Tina4Response) {
210
+ const data = req.body;
211
+ return res.json({ created: true }, 201);
212
+ }
213
+ \`\`\`
214
+
215
+ ### Model
216
+ \`\`\`typescript
217
+ // src/models/User.ts
218
+ export default class User {
219
+ static tableName = "users";
220
+ static fields = {
221
+ id: { type: "integer" as const, primaryKey: true, autoIncrement: true },
222
+ name: { type: "string" as const, required: true },
223
+ email: { type: "string" as const, required: true },
224
+ };
225
+ }
226
+ \`\`\`
227
+ `;
228
+ }
229
+
230
+ /**
231
+ * Install Tina4 context files for detected (or specified) AI tools.
232
+ *
233
+ * Returns list of files created/updated.
234
+ */
235
+ export function installAiContext(
236
+ root: string = ".",
237
+ options?: { tools?: string[]; force?: boolean },
238
+ ): string[] {
239
+ const r = resolve(root);
240
+ const force = options?.force ?? false;
241
+ const created: string[] = [];
242
+
243
+ const toolNames = options?.tools ?? detectAiNames(r);
244
+ const context = generateContext();
245
+
246
+ for (const toolName of toolNames) {
247
+ const tool = AI_TOOLS[toolName];
248
+ if (!tool) continue;
249
+
250
+ const contextPath = join(r, tool.contextFile);
251
+
252
+ // Create config directory if needed
253
+ if (tool.configDir) {
254
+ mkdirSync(join(r, tool.configDir), { recursive: true });
255
+ }
256
+
257
+ // Ensure parent directory of context file exists
258
+ const parentDir = join(contextPath, "..");
259
+ mkdirSync(parentDir, { recursive: true });
260
+
261
+ if (!existsSync(contextPath) || force) {
262
+ writeFileSync(contextPath, context, "utf-8");
263
+ created.push(relative(r, contextPath));
264
+ }
265
+
266
+ // Install Claude Code skills if it's Claude
267
+ if (toolName === "claude-code") {
268
+ const skillFiles = installClaudeSkills(r, force);
269
+ created.push(...skillFiles);
270
+ }
271
+ }
272
+
273
+ return created;
274
+ }
275
+
276
+ /**
277
+ * Copy Claude Code skill files from the framework's directories.
278
+ */
279
+ function installClaudeSkills(root: string, force: boolean): string[] {
280
+ const created: string[] = [];
281
+
282
+ // Determine the framework root (where packages/core/src/ lives)
283
+ const thisDir = dirname(fileURLToPath(import.meta.url));
284
+ const frameworkRoot = resolve(thisDir, "..", "..", "..");
285
+
286
+ // Copy .skill files from the framework's skills/ directory to project root
287
+ const skillsSource = join(frameworkRoot, "skills");
288
+ if (existsSync(skillsSource)) {
289
+ for (const entry of readdirSync(skillsSource)) {
290
+ if (entry.endsWith(".skill")) {
291
+ const srcFile = join(skillsSource, entry);
292
+ const target = join(root, entry);
293
+ if (!existsSync(target) || force) {
294
+ copyFileSync(srcFile, target);
295
+ created.push(entry);
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ // Copy skill directories from .claude/skills/ in the framework to the project
302
+ const frameworkSkillsDir = join(frameworkRoot, ".claude", "skills");
303
+ if (existsSync(frameworkSkillsDir)) {
304
+ const targetSkillsDir = join(root, ".claude", "skills");
305
+ mkdirSync(targetSkillsDir, { recursive: true });
306
+ for (const entry of readdirSync(frameworkSkillsDir)) {
307
+ const skillDir = join(frameworkSkillsDir, entry);
308
+ if (existsSync(skillDir) && statSync(skillDir).isDirectory()) {
309
+ const targetDir = join(targetSkillsDir, entry);
310
+ if (!existsSync(targetDir) || force) {
311
+ cpSync(skillDir, targetDir, { recursive: true, force: true });
312
+ created.push(relative(root, targetDir));
313
+ }
314
+ }
315
+ }
316
+ }
317
+
318
+ return created;
319
+ }
320
+
321
+ /**
322
+ * Install Tina4 context for ALL known AI tools (not just detected ones).
323
+ */
324
+ export function installAllAiContext(root: string = ".", force: boolean = false): string[] {
325
+ return installAiContext(root, {
326
+ tools: Object.keys(AI_TOOLS),
327
+ force,
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Generate a human-readable report of AI tool detection.
333
+ */
334
+ export function aiStatusReport(root: string = "."): string {
335
+ const tools = detectAi(root);
336
+ const installed = tools.filter((t) => t.status === "detected");
337
+ const missing = tools.filter((t) => t.status === "not-detected");
338
+
339
+ const lines: string[] = ["\nTina4 AI Context Status\n"];
340
+
341
+ if (installed.length > 0) {
342
+ lines.push("Detected AI tools:");
343
+ for (const t of installed) {
344
+ lines.push(` + ${t.description} (${t.name})`);
345
+ }
346
+ } else {
347
+ lines.push("No AI coding tools detected.");
348
+ }
349
+
350
+ if (missing.length > 0) {
351
+ lines.push("\nNot detected (install context with `tina4nodejs ai --all`):");
352
+ for (const t of missing) {
353
+ lines.push(` - ${t.description} (${t.name})`);
354
+ }
355
+ }
356
+
357
+ lines.push("");
358
+ return lines.join("\n");
359
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Tina4 API Client — HTTP client using Node.js built-in modules only.
3
+ *
4
+ * import { Api } from "@tina4/core";
5
+ *
6
+ * const api = new Api("https://api.example.com");
7
+ * const result = await api.get("/users");
8
+ * const result = await api.post("/users", { name: "Alice" });
9
+ */
10
+ import http from "node:http";
11
+ import https from "node:https";
12
+ import { URL } from "node:url";
13
+
14
+ export interface ApiResult {
15
+ http_code: number | null;
16
+ body: unknown;
17
+ headers: Record<string, string>;
18
+ error: string | null;
19
+ }
20
+
21
+ export class Api {
22
+ private baseUrl: string;
23
+ private headers: Record<string, string>;
24
+ private timeout: number;
25
+ private authHeader: string;
26
+ private ignoreSsl: boolean;
27
+
28
+ constructor(baseUrl: string = "", authHeader: string = "", timeout: number = 30) {
29
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
30
+ this.authHeader = authHeader;
31
+ this.timeout = timeout;
32
+ this.headers = {};
33
+ this.ignoreSsl = false;
34
+ }
35
+
36
+ /**
37
+ * Add custom headers to all subsequent requests.
38
+ */
39
+ addCustomHeaders(headers: Record<string, string>): void {
40
+ Object.assign(this.headers, headers);
41
+ }
42
+
43
+ /**
44
+ * Set Bearer token authentication.
45
+ */
46
+ setBearerToken(token: string): void {
47
+ this.authHeader = `Bearer ${token}`;
48
+ }
49
+
50
+ /**
51
+ * Set Basic authentication.
52
+ */
53
+ setBasicAuth(username: string, password: string): void {
54
+ const encoded = Buffer.from(`${username}:${password}`).toString("base64");
55
+ this.authHeader = `Basic ${encoded}`;
56
+ }
57
+
58
+ /**
59
+ * Disable SSL certificate verification (dev/self-signed certs only).
60
+ */
61
+ setIgnoreSsl(ignore: boolean): void {
62
+ this.ignoreSsl = ignore;
63
+ }
64
+
65
+ /**
66
+ * HTTP GET request.
67
+ */
68
+ async get(path: string, params?: Record<string, string>): Promise<ApiResult> {
69
+ let url = this.buildUrl(path);
70
+ if (params && Object.keys(params).length > 0) {
71
+ const qs = new URLSearchParams(params).toString();
72
+ url += (url.includes("?") ? "&" : "?") + qs;
73
+ }
74
+ return this.execute("GET", url);
75
+ }
76
+
77
+ /**
78
+ * HTTP POST request.
79
+ */
80
+ async post(path: string, body?: unknown, contentType: string = "application/json"): Promise<ApiResult> {
81
+ return this.sendRequest(path, "POST", body, contentType);
82
+ }
83
+
84
+ /**
85
+ * HTTP PUT request.
86
+ */
87
+ async put(path: string, body?: unknown, contentType: string = "application/json"): Promise<ApiResult> {
88
+ return this.sendRequest(path, "PUT", body, contentType);
89
+ }
90
+
91
+ /**
92
+ * HTTP PATCH request.
93
+ */
94
+ async patch(path: string, body?: unknown, contentType: string = "application/json"): Promise<ApiResult> {
95
+ return this.sendRequest(path, "PATCH", body, contentType);
96
+ }
97
+
98
+ /**
99
+ * HTTP DELETE request.
100
+ */
101
+ async delete(path: string, body?: unknown): Promise<ApiResult> {
102
+ return this.sendRequest(path, "DELETE", body);
103
+ }
104
+
105
+ /**
106
+ * Generic request method — public entry point for any HTTP method.
107
+ */
108
+ async sendRequest(
109
+ path: string,
110
+ method: string,
111
+ body?: unknown,
112
+ contentType: string = "application/json",
113
+ ): Promise<ApiResult> {
114
+ const url = this.buildUrl(path);
115
+ return this.execute(method.toUpperCase(), url, body, contentType);
116
+ }
117
+
118
+ // ── Internal helpers ──────────────────────────────────────────────
119
+
120
+ private buildUrl(path: string): string {
121
+ if (path.startsWith("http://") || path.startsWith("https://")) {
122
+ return path;
123
+ }
124
+ if (!path) {
125
+ return this.baseUrl;
126
+ }
127
+ return `${this.baseUrl}/${path.replace(/^\/+/, "")}`;
128
+ }
129
+
130
+ private execute(
131
+ method: string,
132
+ url: string,
133
+ body?: unknown,
134
+ contentType: string = "application/json",
135
+ ): Promise<ApiResult> {
136
+ return new Promise<ApiResult>((resolve) => {
137
+ try {
138
+ const parsed = new URL(url);
139
+ const isHttps = parsed.protocol === "https:";
140
+ const transport = isHttps ? https : http;
141
+
142
+ // Build headers
143
+ const reqHeaders: Record<string, string> = { ...this.headers };
144
+ if (this.authHeader) {
145
+ reqHeaders["Authorization"] = this.authHeader;
146
+ }
147
+
148
+ // Serialize body
149
+ let data: Buffer | undefined;
150
+ if (body !== undefined && body !== null) {
151
+ if (contentType === "application/json" && typeof body === "object") {
152
+ data = Buffer.from(JSON.stringify(body), "utf-8");
153
+ reqHeaders["Content-Type"] = "application/json";
154
+ } else if (typeof body === "string") {
155
+ data = Buffer.from(body, "utf-8");
156
+ reqHeaders["Content-Type"] = contentType;
157
+ } else if (Buffer.isBuffer(body)) {
158
+ data = body;
159
+ reqHeaders["Content-Type"] = contentType;
160
+ } else {
161
+ // Fallback: stringify anything else as JSON
162
+ data = Buffer.from(JSON.stringify(body), "utf-8");
163
+ reqHeaders["Content-Type"] = "application/json";
164
+ }
165
+ if (data) {
166
+ reqHeaders["Content-Length"] = String(data.length);
167
+ }
168
+ }
169
+
170
+ const options: http.RequestOptions = {
171
+ hostname: parsed.hostname,
172
+ port: parsed.port || (isHttps ? 443 : 80),
173
+ path: parsed.pathname + parsed.search,
174
+ method,
175
+ headers: reqHeaders,
176
+ timeout: this.timeout * 1000,
177
+ };
178
+
179
+ if (isHttps && this.ignoreSsl) {
180
+ (options as https.RequestOptions).rejectUnauthorized = false;
181
+ }
182
+
183
+ const req = transport.request(options, (res) => {
184
+ const chunks: Buffer[] = [];
185
+
186
+ res.on("data", (chunk: Buffer) => {
187
+ chunks.push(chunk);
188
+ });
189
+
190
+ res.on("end", () => {
191
+ const raw = Buffer.concat(chunks).toString("utf-8");
192
+ const respHeaders: Record<string, string> = {};
193
+ for (const [key, val] of Object.entries(res.headers)) {
194
+ if (val !== undefined) {
195
+ respHeaders[key] = Array.isArray(val) ? val.join(", ") : val;
196
+ }
197
+ }
198
+
199
+ let parsed: unknown;
200
+ try {
201
+ parsed = JSON.parse(raw);
202
+ } catch {
203
+ parsed = raw;
204
+ }
205
+
206
+ resolve({
207
+ http_code: res.statusCode ?? null,
208
+ body: parsed,
209
+ headers: respHeaders,
210
+ error: null,
211
+ });
212
+ });
213
+ });
214
+
215
+ req.on("timeout", () => {
216
+ req.destroy();
217
+ resolve({
218
+ http_code: null,
219
+ body: null,
220
+ headers: {},
221
+ error: `Request timed out after ${this.timeout}s`,
222
+ });
223
+ });
224
+
225
+ req.on("error", (err) => {
226
+ resolve({
227
+ http_code: null,
228
+ body: null,
229
+ headers: {},
230
+ error: err.message,
231
+ });
232
+ });
233
+
234
+ if (data) {
235
+ req.write(data);
236
+ }
237
+ req.end();
238
+ } catch (err) {
239
+ resolve({
240
+ http_code: null,
241
+ body: null,
242
+ headers: {},
243
+ error: err instanceof Error ? err.message : String(err),
244
+ });
245
+ }
246
+ });
247
+ }
248
+ }