tina4-nodejs 3.10.91 → 3.10.93

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.
@@ -1,8 +1,8 @@
1
1
  // Tina4 SCSS — Zero-dependency SCSS-to-CSS compiler (subset).
2
2
  // Supports variables, nesting, & parent selector, @import, @mixin/@include, comments, basic math.
3
3
 
4
- import { readFileSync, existsSync } from "node:fs";
5
- import { join, resolve, dirname } from "node:path";
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
5
+ import { join, resolve, dirname, basename } from "node:path";
6
6
 
7
7
  // ── Types ────────────────────────────────────────────────────────
8
8
 
@@ -46,6 +46,48 @@ export class ScssCompiler {
46
46
  const key = name.startsWith("$") ? name.slice(1) : name;
47
47
  this._variables[key] = value;
48
48
  }
49
+
50
+ /** Compile all .scss files in a directory into a single CSS output file. */
51
+ compileScss(scssDir: string = "src/scss", output: string = "public/css/default.css", minify: boolean = false): string {
52
+ const absDir = resolve(scssDir);
53
+ if (!existsSync(absDir)) return "";
54
+
55
+ // Collect non-partial .scss files, sorted
56
+ const files = readdirSync(absDir)
57
+ .filter((f) => f.endsWith(".scss") && !f.startsWith("_"))
58
+ .sort()
59
+ .map((f) => join(absDir, f));
60
+
61
+ if (files.length === 0) return "";
62
+
63
+ // Merge all files, resolving imports
64
+ const paths = [absDir, ...this._importPaths];
65
+ const imported = new Set<string>();
66
+ let merged = "";
67
+ for (const file of files) {
68
+ const content = readFileSync(file, "utf-8");
69
+ imported.add(file);
70
+ merged += resolveImports(content, paths, imported) + "\n";
71
+ }
72
+
73
+ let css = compileString(merged, paths, { ...this._variables });
74
+
75
+ if (minify) {
76
+ css = css.replace(/\/\*.*?\*\//gs, "");
77
+ css = css.replace(/\s+/g, " ");
78
+ css = css.replace(/\s*([{}:;,])\s*/g, "$1");
79
+ css = css.replace(/;}/g, "}");
80
+ css = css.trim();
81
+ }
82
+
83
+ // Write output
84
+ const absOutput = resolve(output);
85
+ const outDir = dirname(absOutput);
86
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
87
+ writeFileSync(absOutput, css, "utf-8");
88
+
89
+ return css;
90
+ }
49
91
  }
50
92
 
51
93
  // ── Internal Compilation Pipeline ────────────────────────────────
@@ -8,9 +8,9 @@ import cluster from "node:cluster";
8
8
  import os from "node:os";
9
9
  import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js";
10
10
  import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
11
- import { validToken, getPayload } from "./auth.js";
11
+ import { validToken, getPayload, refreshToken } from "./auth.js";
12
12
  import { discoverRoutes } from "./routeDiscovery.js";
13
- import { createRequest, parseBody } from "./request.js";
13
+ import { createRequest } from "./request.js";
14
14
  import { createResponse, setDefaultTemplatesDir } from "./response.js";
15
15
  import { MiddlewareChain, cors, requestLogger } from "./middleware.js";
16
16
  import { tryServeStatic } from "./static.js";
@@ -19,6 +19,7 @@ import { createHealthRoute } from "./health.js";
19
19
  import { rateLimiter } from "./rateLimiter.js";
20
20
  import { Log } from "./logger.js";
21
21
  import { DevAdmin, RequestInspector } from "./devAdmin.js";
22
+ import { I18n } from "./i18n.js";
22
23
 
23
24
  const __filename = fileURLToPath(import.meta.url);
24
25
  const __dirname = dirname(__filename);
@@ -439,6 +440,28 @@ function deployGallery(name) {
439
440
  // Allows handle() to route requests without requiring a reference to the server.
440
441
  let _dispatchFn: ((rawReq: IncomingMessage, rawRes: ServerResponse) => Promise<void>) | null = null;
441
442
 
443
+ /** Module-level server handle for start()/stop() parity. */
444
+ let _serverHandle: { close: () => void; router: Router; port: number } | null = null;
445
+
446
+ /**
447
+ * Start the Tina4 HTTP server.
448
+ * Thin wrapper around startServer() for cross-framework parity with PHP and Ruby.
449
+ */
450
+ export async function start(config?: Tina4Config): Promise<{ close: () => void; router: Router; port: number }> {
451
+ _serverHandle = await startServer(config);
452
+ return _serverHandle;
453
+ }
454
+
455
+ /**
456
+ * Stop the running Tina4 server gracefully.
457
+ */
458
+ export function stop(): void {
459
+ if (_serverHandle) {
460
+ _serverHandle.close();
461
+ _serverHandle = null;
462
+ }
463
+ }
464
+
442
465
  /**
443
466
  * Dispatch a raw Node.js request through the Tina4 router and write the response.
444
467
  * Requires startServer() to have been called first.
@@ -546,6 +569,22 @@ ${reset}
546
569
  // Frond not available
547
570
  }
548
571
 
572
+ // Auto-wire i18n → template global t() when locale files exist
573
+ if (frondEngine) {
574
+ const localeDir = resolve(base, process.env.TINA4_LOCALE_DIR ?? "src/locales");
575
+ if (existsSync(localeDir)) {
576
+ try {
577
+ const localeFiles = readdirSync(localeDir).filter((f: string) => f.endsWith(".json"));
578
+ if (localeFiles.length > 0 && !frondEngine.globals?.t) {
579
+ const i18nInstance = new I18n(localeDir, process.env.TINA4_LOCALE ?? "en");
580
+ frondEngine.addGlobal("t", (key: string, params?: Record<string, string>) => i18nInstance.t(key, params));
581
+ }
582
+ } catch {
583
+ // Locale directory unreadable — skip auto-wire
584
+ }
585
+ }
586
+ }
587
+
549
588
  // Built-in middleware
550
589
  middleware.use(cors());
551
590
  middleware.use(requestLogger());
@@ -640,7 +679,7 @@ ${reset}
640
679
  // ORM not available, swagger will work without model schemas
641
680
  }
642
681
 
643
- const getSpec = () => swagger.generateOpenAPISpec(allRoutes, modelDefs as any);
682
+ const getSpec = () => swagger.generate(allRoutes, modelDefs as any);
644
683
  const swaggerRoutes = swagger.createSwaggerRoutes(getSpec);
645
684
  for (const route of swaggerRoutes) {
646
685
  router.addRoute(route);
@@ -696,7 +735,7 @@ ${reset}
696
735
  if (res.raw.writableEnded) return;
697
736
 
698
737
  // Parse request body
699
- await parseBody(req);
738
+ await req.parseBody();
700
739
 
701
740
  const pathname = (req.url ?? "/").split("?")[0];
702
741
 
@@ -785,18 +824,56 @@ ${reset}
785
824
  if (!proceed || res.raw.writableEnded) return;
786
825
  }
787
826
 
788
- // Auth enforcement: secure routes require a valid Bearer token
827
+ // Auth enforcement: secure routes require a valid token
828
+ // Check sources in priority order: Authorization header > body formToken > session token
789
829
  // Dev admin routes (/__dev) are always public
790
830
  const isDevAdmin = pathname.startsWith("/__dev");
791
831
  if (match.secure === true && match.noAuth !== true && !isDevAdmin) {
792
832
  const authHeader = req.headers.authorization ?? "";
793
- const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
794
- if (!token || !validToken(token)) {
833
+ const headerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
834
+
835
+ // Priority 1: Authorization Bearer header
836
+ let resolvedToken = "";
837
+ let tokenSource: "header" | "body" | "session" | "" = "";
838
+
839
+ if (headerToken && validToken(headerToken)) {
840
+ resolvedToken = headerToken;
841
+ tokenSource = "header";
842
+ }
843
+
844
+ // Priority 2: formToken from request body
845
+ if (!resolvedToken) {
846
+ const bodyToken = (req.body as Record<string, unknown>)?.formToken as string | undefined;
847
+ if (bodyToken && validToken(bodyToken)) {
848
+ resolvedToken = bodyToken;
849
+ tokenSource = "body";
850
+ }
851
+ }
852
+
853
+ // Priority 3: Session token
854
+ if (!resolvedToken) {
855
+ const sessionToken = (req as any).session?.get?.("token") as string | undefined;
856
+ if (sessionToken && validToken(sessionToken)) {
857
+ resolvedToken = sessionToken;
858
+ tokenSource = "session";
859
+ }
860
+ }
861
+
862
+ if (!resolvedToken) {
795
863
  res.raw.writeHead(401, { "Content-Type": "application/json" });
796
864
  res.raw.end(JSON.stringify({ error: "Unauthorized" }));
797
865
  return;
798
866
  }
799
- req.user = getPayload(token) ?? {};
867
+
868
+ req.user = getPayload(resolvedToken) ?? {};
869
+
870
+ // When body formToken validates, return a FreshToken header with a refreshed JWT
871
+ if (tokenSource === "body") {
872
+ const fresh = refreshToken(resolvedToken);
873
+ if (fresh) {
874
+ res.header("FreshToken", fresh);
875
+ }
876
+ }
800
877
  }
801
878
 
802
879
  // Inject path params by name into handler arguments, then request/response
@@ -828,7 +905,7 @@ ${reset}
828
905
  typeof result === "object" &&
829
906
  !Buffer.isBuffer(result)
830
907
  ) {
831
- await res.template(match.template, result as Record<string, unknown>);
908
+ await res.render(match.template, result as Record<string, unknown>);
832
909
  }
833
910
 
834
911
  if (!res.raw.writableEnded) {
@@ -322,6 +322,13 @@ export class ServiceRunner {
322
322
  registry.clear();
323
323
  }
324
324
 
325
+ /**
326
+ * Check if a 5-field cron pattern matches the given (or current) date/time.
327
+ */
328
+ static matchCron(pattern: string, now?: Date): boolean {
329
+ return matchesCron(pattern, now ?? new Date());
330
+ }
331
+
325
332
  /**
326
333
  * Watch service files for changes and hot-reload in dev mode.
327
334
  */
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { IncomingMessage, ServerResponse } from "node:http";
18
18
  import { Socket } from "node:net";
19
- import { createRequest, parseBody } from "./request.js";
19
+ import { createRequest } from "./request.js";
20
20
  import { createResponse } from "./response.js";
21
21
  import { defaultRouter, type Router } from "./router.js";
22
22
 
@@ -153,7 +153,7 @@ export class TestClient {
153
153
  const res = createResponse(rawRes);
154
154
 
155
155
  // Parse body (populates req.body)
156
- await parseBody(req);
156
+ await req.parseBody();
157
157
 
158
158
  // Split path for route matching
159
159
  const cleanPath = path.includes("?") ? path.split("?")[0] : path;
@@ -3,17 +3,17 @@
3
3
  *
4
4
  * Attach test assertions to functions and run them all at once.
5
5
  *
6
- * import { tests, assertEqual, assertThrows, runAllTests } from "./testing.js";
6
+ * import { tests, assertEqual, assertRaises, runAll } from "./testing.js";
7
7
  *
8
8
  * const add = tests(
9
9
  * assertEqual([5, 3], 8),
10
- * assertThrows(Error, [null]),
10
+ * assertRaises(Error, [null]),
11
11
  * )(function add(a: number, b: number | null = null): number {
12
12
  * if (b === null) throw new Error("b required");
13
13
  * return a + b;
14
14
  * });
15
15
  *
16
- * runAllTests();
16
+ * runAll();
17
17
  */
18
18
 
19
19
  // ── Types ──────────────────────────────────────────────────────────
@@ -50,7 +50,7 @@ export function assertEqual(args: unknown[], expected: unknown): Assertion {
50
50
  }
51
51
 
52
52
  /** Assert that calling the function with `args` throws an instance of `errorClass`. */
53
- export function assertThrows(
53
+ export function assertRaises(
54
54
  errorClass: new (...a: unknown[]) => Error,
55
55
  args: unknown[],
56
56
  ): Assertion {
@@ -93,7 +93,7 @@ export function tests(
93
93
  // ── Runner ─────────────────────────────────────────────────────────
94
94
 
95
95
  /** Run all registered tests. Returns results summary. */
96
- export function runAllTests(
96
+ export function runAll(
97
97
  options: { quiet?: boolean; failfast?: boolean } = {},
98
98
  ): TestResults {
99
99
  const { quiet = false, failfast = false } = options;
@@ -141,7 +141,7 @@ export function runAllTests(
141
141
  }
142
142
 
143
143
  /** Reset the test registry (useful between test runs). */
144
- export function resetTests(): void {
144
+ export function reset(): void {
145
145
  registry.length = 0;
146
146
  }
147
147
 
@@ -27,6 +27,14 @@ export interface Tina4Request extends IncomingMessage {
27
27
  contentType: string;
28
28
  session: Tina4Session;
29
29
  user?: Record<string, unknown>;
30
+ /** Get a specific header value by name (case-insensitive). */
31
+ header(name: string): string | undefined;
32
+ /** Extract the Bearer token from the Authorization header. */
33
+ bearerToken(): string | null;
34
+ /** Get a parameter by key from merged params (route + query). */
35
+ param(key: string, defaultValue?: string): string | undefined;
36
+ /** Parse the request body based on content type. */
37
+ parseBody(): Promise<void>;
30
38
  }
31
39
 
32
40
  export interface CookieOptions {
@@ -53,7 +61,6 @@ export interface Tina4ResponseMethods {
53
61
  file(path: string, options?: { download?: boolean; contentType?: string }): Tina4Response;
54
62
  error(code: string, message: string, status?: number): Tina4Response;
55
63
  render(template: string, data?: Record<string, unknown>, status?: number, templateDir?: string): Promise<Tina4Response>;
56
- template(name: string, data?: Record<string, unknown>, status?: number, templateDir?: string): Promise<Tina4Response>;
57
64
  /** Stream response from an async generator (SSE or chunked). */
58
65
  stream(source: AsyncIterable<string | Buffer>, contentType?: string): Promise<Tina4Response>;
59
66
  /** The underlying ServerResponse for advanced use */
@@ -7,6 +7,72 @@ import { resolve, extname } from "node:path";
7
7
  */
8
8
  const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
9
9
 
10
+ // Module-level state for start()/stop() API
11
+ let _watchers: ReturnType<typeof watch>[] = [];
12
+ let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
13
+ let _codeChangePending = false;
14
+ let _running = false;
15
+
16
+ /**
17
+ * Start the DevReload file watcher.
18
+ *
19
+ * Watches the given directories for file changes and calls onChange when
20
+ * a change is detected. Mirrors the Python DevReload.start() API.
21
+ *
22
+ * @param dirs - Directories to watch. Defaults to ["src", "public"].
23
+ * @param onChange - Callback invoked on file change. Receives `{ code: boolean }`.
24
+ */
25
+ export function start(
26
+ dirs: string[] = ["src", "public"],
27
+ onChange: (info: { code: boolean }) => void = () => {},
28
+ ): void {
29
+ if (_running) return;
30
+ _running = true;
31
+
32
+ const debouncedOnChange = () => {
33
+ if (_debounceTimer) clearTimeout(_debounceTimer);
34
+ _debounceTimer = setTimeout(() => {
35
+ const code = _codeChangePending;
36
+ _codeChangePending = false;
37
+ console.log(
38
+ `\n \x1b[33mFile change detected${code ? ", reloading routes" : ""}...\x1b[0m\n`,
39
+ );
40
+ onChange({ code });
41
+ }, 200);
42
+ };
43
+
44
+ for (const dir of dirs) {
45
+ if (!existsSync(dir)) continue;
46
+ try {
47
+ const watcher = watch(resolve(dir), { recursive: true }, (_event, filename) => {
48
+ if (filename && CODE_EXTENSIONS.has(extname(filename))) {
49
+ _codeChangePending = true;
50
+ }
51
+ debouncedOnChange();
52
+ });
53
+ _watchers.push(watcher);
54
+ } catch {
55
+ console.warn(` Warning: Could not watch ${dir}`);
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Stop the DevReload file watcher.
62
+ *
63
+ * Closes all active file watchers and resets internal state.
64
+ * Mirrors the Python DevReload.stop() API.
65
+ */
66
+ export function stop(): void {
67
+ if (!_running) return;
68
+ _running = false;
69
+ for (const w of _watchers) w.close();
70
+ _watchers = [];
71
+ if (_debounceTimer) clearTimeout(_debounceTimer);
72
+ _debounceTimer = null;
73
+ _codeChangePending = false;
74
+ }
75
+
10
76
  /**
11
77
  * Watch directories for file changes.
12
78
  *
@@ -214,9 +214,9 @@ export class WebSocketServer {
214
214
  }
215
215
 
216
216
  /**
217
- * Send a message to a specific client.
217
+ * Send a message to a specific client by ID.
218
218
  */
219
- send(clientId: string, message: string): void {
219
+ sendTo(clientId: string, message: string): void {
220
220
  const client = this.clients.get(clientId);
221
221
  if (!client || client.closed) return;
222
222
 
@@ -17,6 +17,10 @@ export interface WebSocketConnection {
17
17
  send(message: string): void;
18
18
  /** Broadcast a message to all connections on the same path (path-scoped) */
19
19
  broadcast(message: string): void;
20
+ /** Join a room */
21
+ joinRoom(roomName: string): void;
22
+ /** Leave a room */
23
+ leaveRoom(roomName: string): void;
20
24
  /** Close this connection */
21
25
  close(): void;
22
26
  }
@@ -6,13 +6,13 @@
6
6
  *
7
7
  * Matches the PHP reference implementation (Tina4\WSDL).
8
8
  *
9
- * import { WSDLService, WSDLOp } from "@tina4/core";
9
+ * import { WSDLService, WSDLOperation } from "@tina4/core";
10
10
  *
11
11
  * class Calculator extends WSDLService {
12
12
  * serviceName = "Calculator";
13
13
  * serviceUrl = "/api/calculator";
14
14
  *
15
- * @WSDLOp({ output: { Result: "int" } })
15
+ * @WSDLOperation({ output: { Result: "int" } })
16
16
  * async Add(a: number, b: number): Promise<Record<string, unknown>> {
17
17
  * return { Result: a + b };
18
18
  * }
@@ -21,14 +21,14 @@
21
21
 
22
22
  // ── Types ────────────────────────────────────────────────────
23
23
 
24
- export interface WSDLOperation {
24
+ export interface WSDLOperationMeta {
25
25
  name: string;
26
26
  description?: string;
27
27
  input?: Record<string, string>; // param name -> type
28
28
  output?: Record<string, string>; // return name -> type
29
29
  }
30
30
 
31
- interface WSDLOpConfig {
31
+ interface WSDLOperationConfig {
32
32
  description?: string;
33
33
  input?: Record<string, string>;
34
34
  output?: Record<string, string>;
@@ -167,13 +167,13 @@ const WSDL_OPS_KEY = Symbol("wsdl_operations");
167
167
  /**
168
168
  * Decorator function for marking methods as WSDL operations.
169
169
  *
170
- * @WSDLOp({ description: "Add two numbers", input: { a: "int", b: "int" }, output: { Result: "int" } })
170
+ * @WSDLOperation({ description: "Add two numbers", input: { a: "int", b: "int" }, output: { Result: "int" } })
171
171
  * async Add(a: number, b: number): Promise<Record<string, unknown>> { ... }
172
172
  */
173
- export function WSDLOp(config?: WSDLOpConfig) {
173
+ export function WSDLOperation(config?: WSDLOperationConfig) {
174
174
  return function (_target: unknown, propertyKey: string, descriptor: PropertyDescriptor) {
175
175
  // Store metadata on the method itself
176
- const op: WSDLOperation = {
176
+ const op: WSDLOperationMeta = {
177
177
  name: propertyKey,
178
178
  description: config?.description,
179
179
  input: config?.input,
@@ -214,12 +214,12 @@ export abstract class WSDLService {
214
214
  }
215
215
 
216
216
  /** Discovered operations (populated on first use). */
217
- private _operations: Map<string, WSDLOperation> | null = null;
217
+ private _operations: Map<string, WSDLOperationMeta> | null = null;
218
218
 
219
219
  /**
220
220
  * Discover operations by scanning for methods with _wsdlOp metadata.
221
221
  */
222
- private discoverOperations(): Map<string, WSDLOperation> {
222
+ private discoverOperations(): Map<string, WSDLOperationMeta> {
223
223
  if (this._operations) return this._operations;
224
224
 
225
225
  this._operations = new Map();
@@ -233,7 +233,7 @@ export abstract class WSDLService {
233
233
  try {
234
234
  const method = (this as Record<string, unknown>)[name];
235
235
  if (typeof method === "function" && (method as unknown as Record<string, unknown>)._wsdlOp) {
236
- const op = (method as unknown as Record<string, unknown>)._wsdlOp as WSDLOperation;
236
+ const op = (method as unknown as Record<string, unknown>)._wsdlOp as WSDLOperationMeta;
237
237
  if (!this._operations.has(name)) {
238
238
  this._operations.set(name, op);
239
239
  }
@@ -386,7 +386,7 @@ export abstract class WSDLService {
386
386
  /**
387
387
  * Handle incoming SOAP request (parse XML, dispatch to method, return SOAP response).
388
388
  */
389
- async handleRequest(soapXml: string): Promise<string> {
389
+ async handle(soapXml: string = ""): Promise<string> {
390
390
  const ops = this.discoverOperations();
391
391
 
392
392
  // Parse SOAP body
@@ -538,7 +538,7 @@ export abstract class WSDLService {
538
538
  return;
539
539
  }
540
540
 
541
- const soapResponse = await this.handleRequest(xmlBody);
541
+ const soapResponse = await this.handle(xmlBody);
542
542
 
543
543
  if (typeof res.send === "function") {
544
544
  if (typeof res.setHeader === "function") {