tina4-nodejs 3.11.1 → 3.11.4

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.11.1",
6
+ "version": "3.11.4",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript — 54 built-in features, zero dependencies",
@@ -37,48 +37,13 @@ export async function serveProject(options: ServeOptions): Promise<void> {
37
37
  staticDir,
38
38
  });
39
39
 
40
- // Watch for file changes.
41
- //
42
- // Templates and static assets are re-read from disk every request in dev mode,
43
- // so we only need to touch the router when a .ts/.js route file actually
44
- // changes. Clearing the router on every edit (including templates) leaves a
45
- // brief window where the router is empty — any request hitting that window
46
- // gets a 404 whose response path bypasses the dev toolbar injection, so the
47
- // toolbar appears to "vanish" after a hot reload. Route-file-only clearing
48
- // matches the behaviour of Python's DevReload and the fix made in PHP v3.10.87.
49
- const noReload = ["true", "1", "yes"].includes((process.env.TINA4_NO_RELOAD ?? "").toLowerCase());
50
- const watchDirs = [routesDir, ormDir, modelsDir, templatesDir].filter((d) => existsSync(d));
51
- let watcher: { close: () => void } | null = null;
52
- if (!noReload) {
53
- watcher = watchForChanges(watchDirs, async ({ code }) => {
54
- if (!code) {
55
- // Template/CSS/JS asset change — nothing to do in the server. The
56
- // browser will re-fetch on its own reload cycle and the request will
57
- // be served against the existing route set with the toolbar intact.
58
- return;
59
- }
60
- try {
61
- // Re-discover routes. discoverRoutes() cache-busts imports via ?t=<timestamp>,
62
- // so the new modules are loaded fresh. Build the new list first, then
63
- // replace the router's state in one back-to-back block to minimise the
64
- // window where the router is empty.
65
- const { discoverRoutes } = await import("../../../core/src/index.js");
66
- const routes = await discoverRoutes(routesDir);
67
- server.router.clear();
68
- for (const route of routes) {
69
- server.router.addRoute(route);
70
- }
71
- console.log(` Reloaded ${routes.length} route(s)`);
72
- } catch (err) {
73
- console.error(" Error reloading routes:", err);
74
- }
75
- });
76
- }
40
+ // File watching is handled by the Rust CLI (tina4 serve). The framework
41
+ // only needs POST /__dev/api/reload to update the mtime counter for browser polling.
42
+ // No internal file watcher.
77
43
 
78
44
  // Graceful shutdown
79
45
  const shutdown = () => {
80
46
  console.log("\n Shutting down...");
81
- watcher?.close();
82
47
  server.close();
83
48
  process.exit(0);
84
49
  };
@@ -437,6 +437,9 @@ export class DevAdmin {
437
437
  // Dashboard
438
438
  { method: "GET", pattern: "/__dev", handler: handleDashboard },
439
439
  { method: "GET", pattern: "/__dev/", handler: handleDashboard },
440
+ // Reload — called by Rust CLI on file changes
441
+ { method: "GET", pattern: "/__dev/api/mtime", handler: handleMtime },
442
+ { method: "POST", pattern: "/__dev/api/reload", handler: handleReload },
440
443
  // Status & system
441
444
  { method: "GET", pattern: "/__dev/api/status", handler: handleStatus(router) },
442
445
  { method: "GET", pattern: "/__dev/api/system", handler: handleSystem },
@@ -526,6 +529,23 @@ const handleDashboard: RouteHandler = (_req, res) => {
526
529
  res.raw.end(spa);
527
530
  };
528
531
 
532
+ // Reload mtime counter — updated by POST /__dev/api/reload from Rust CLI
533
+ let _reloadMtime = 0;
534
+ let _reloadFile = "";
535
+
536
+ const handleMtime: RouteHandler = async (_req, res) => {
537
+ res.json({ mtime: _reloadMtime, file: _reloadFile });
538
+ };
539
+
540
+ const handleReload: RouteHandler = async (req, res) => {
541
+ _reloadMtime = Math.floor(Date.now() / 1000);
542
+ const body = req.body as Record<string, unknown> | undefined;
543
+ _reloadFile = (body?.file as string) || "";
544
+ const reloadType = (body?.type as string) || "reload";
545
+ console.log(` External reload trigger: ${reloadType}${_reloadFile ? ` (${_reloadFile})` : ""}`);
546
+ res.json({ ok: true, type: reloadType });
547
+ };
548
+
529
549
  function handleStatus(router: Router): RouteHandler {
530
550
  return async (_req, res) => {
531
551
  const mem = process.memoryUsage();
@@ -480,8 +480,9 @@ export class GraphQL {
480
480
  /**
481
481
  * Execute a GraphQL query string.
482
482
  */
483
- execute(query: string, variables?: Record<string, unknown>): GraphQLResult {
483
+ execute(query: string, variables?: Record<string, unknown>, context?: Record<string, unknown>): GraphQLResult {
484
484
  const vars = variables ?? {};
485
+ const ctx = context ?? {};
485
486
  const errors: Array<{ message: string; path?: string[] }> = [];
486
487
 
487
488
  let doc: { definitions: ParsedOperation[] };
@@ -511,7 +512,10 @@ export class GraphQL {
511
512
  const data: Record<string, unknown> = {};
512
513
 
513
514
  for (const sel of op.selections) {
514
- const [value, errs] = this.resolveField(sel, resolvers, null, vars);
515
+ // Check directives (@skip, @include, @auth, @role, @guest)
516
+ if (!this.checkDirectives(sel.directives ?? [], vars, ctx)) continue;
517
+
518
+ const [value, errs] = this.resolveField(sel, resolvers, null, vars, ctx);
515
519
  errors.push(...errs);
516
520
  const key = sel.alias ?? sel.name;
517
521
  data[key] = value;
@@ -841,22 +845,36 @@ export class GraphQL {
841
845
  resolvers: Map<string, QueryConfig>,
842
846
  parent: unknown,
843
847
  variables: Record<string, unknown>,
848
+ context: Record<string, unknown> = {},
844
849
  ): [unknown, Array<{ message: string; path?: string[] }>] {
845
850
  const errors: Array<{ message: string; path?: string[] }> = [];
846
851
  const name = sel.name;
847
852
  const args = this.resolveArgs(sel.args, variables);
848
853
 
854
+ // Check directives (@auth, @role, @guest, @skip, @include)
855
+ if (!this.checkDirectives(sel.directives ?? [], variables, context)) {
856
+ return [null, errors];
857
+ }
858
+
849
859
  let value: unknown = undefined;
850
860
 
851
861
  if (parent !== null && parent !== undefined) {
852
- // Resolve from parent object
853
862
  if (typeof parent === "object" && parent !== null) {
854
863
  value = (parent as Record<string, unknown>)[name];
855
864
  }
856
865
  } else if (resolvers.has(name)) {
857
866
  const config = resolvers.get(name)!;
867
+
868
+ // Input validation
869
+ const validationErrors = this.validateArgs(args, config.args ?? {}, name);
870
+ if (validationErrors.length > 0) {
871
+ return [null, validationErrors];
872
+ }
873
+
874
+ // Inject sub-selections into context for DataLoader/eager-loading
875
+ const ctx = { ...context, __selections: sel.selections ?? [] };
858
876
  try {
859
- value = config.resolver(null, args, {});
877
+ value = config.resolver(null, args, ctx);
860
878
  } catch (e: unknown) {
861
879
  const message = e instanceof Error ? e.message : String(e);
862
880
  errors.push({ message, path: [name] });
@@ -864,45 +882,30 @@ export class GraphQL {
864
882
  }
865
883
  }
866
884
 
867
- // If no sub-selections, return the scalar value
868
885
  if (!sel.selections || sel.selections.length === 0) {
869
886
  return [value, errors];
870
887
  }
871
888
 
872
- // Handle list types
873
889
  if (Array.isArray(value)) {
874
890
  const result: Record<string, unknown>[] = [];
875
891
  for (const item of value) {
876
892
  const obj: Record<string, unknown> = {};
877
893
  for (const subSel of sel.selections) {
878
- const [subVal, subErrs] = this.resolveField(
879
- subSel,
880
- new Map(),
881
- item,
882
- variables,
883
- );
894
+ const [subVal, subErrs] = this.resolveField(subSel, new Map(), item, variables, context);
884
895
  errors.push(...subErrs);
885
- const key = subSel.alias ?? subSel.name;
886
- obj[key] = subVal;
896
+ obj[subSel.alias ?? subSel.name] = subVal;
887
897
  }
888
898
  result.push(obj);
889
899
  }
890
900
  return [result, errors];
891
901
  }
892
902
 
893
- // Handle object types
894
903
  if (value !== null && value !== undefined) {
895
904
  const obj: Record<string, unknown> = {};
896
905
  for (const subSel of sel.selections) {
897
- const [subVal, subErrs] = this.resolveField(
898
- subSel,
899
- new Map(),
900
- value,
901
- variables,
902
- );
906
+ const [subVal, subErrs] = this.resolveField(subSel, new Map(), value, variables, context);
903
907
  errors.push(...subErrs);
904
- const key = subSel.alias ?? subSel.name;
905
- obj[key] = subVal;
908
+ obj[subSel.alias ?? subSel.name] = subVal;
906
909
  }
907
910
  return [obj, errors];
908
911
  }
@@ -931,6 +934,120 @@ export class GraphQL {
931
934
  }
932
935
  return resolved;
933
936
  }
937
+
938
+ /**
939
+ * Check directives: @skip, @include, @auth, @role, @guest.
940
+ * Returns true if the field should be included, false to skip.
941
+ */
942
+ private checkDirectives(
943
+ directives: Array<{ name: string; args: Record<string, unknown> }>,
944
+ variables: Record<string, unknown>,
945
+ context: Record<string, unknown> = {},
946
+ ): boolean {
947
+ for (const d of directives) {
948
+ let val = d.args?.if;
949
+ if (typeof val === "object" && val !== null && "$var" in val) {
950
+ val = variables[(val as { $var: string }).$var];
951
+ }
952
+
953
+ if (d.name === "skip" && val) return false;
954
+ if (d.name === "include" && !val) return false;
955
+
956
+ // Auth: @auth — requires any authenticated user
957
+ if (d.name === "auth" && !context.user) return false;
958
+
959
+ // Auth: @role(role: "admin") — requires specific role
960
+ if (d.name === "role") {
961
+ const required = d.args?.role;
962
+ const user = context.user as Record<string, unknown> | undefined;
963
+ const actual = user?.role ?? context.role;
964
+ if (!required || actual !== required) return false;
965
+ }
966
+
967
+ // Auth: @guest — only for unauthenticated
968
+ if (d.name === "guest" && context.user) return false;
969
+ }
970
+ return true;
971
+ }
972
+
973
+ /**
974
+ * Validate resolved args against declared types.
975
+ */
976
+ private validateArgs(
977
+ args: Record<string, unknown>,
978
+ argConfigs: Record<string, string>,
979
+ fieldName: string,
980
+ ): Array<{ message: string; path?: string[] }> {
981
+ const errors: Array<{ message: string; path?: string[] }> = [];
982
+
983
+ for (const [argName, declaredType] of Object.entries(argConfigs)) {
984
+ const parsed = GraphQLType.parse(declaredType);
985
+ const value = args[argName];
986
+ const isNonNull = parsed.kind === "non_null";
987
+ const innerType = isNonNull ? parsed.ofType! : parsed;
988
+ const baseName = innerType.kind === "list" ? "list" : innerType.name;
989
+
990
+ if (isNonNull && (value === null || value === undefined || value === "")) {
991
+ errors.push({
992
+ message: `Argument '${argName}' on field '${fieldName}' is required (type: ${declaredType})`,
993
+ path: [fieldName],
994
+ });
995
+ continue;
996
+ }
997
+
998
+ if (value === null || value === undefined) continue;
999
+
1000
+ if (baseName === "list" && Array.isArray(value)) {
1001
+ const itemType = innerType.ofType;
1002
+ if (itemType) {
1003
+ const itemName = itemType.kind === "non_null" ? (itemType.ofType?.name ?? "String") : itemType.name;
1004
+ for (let i = 0; i < value.length; i++) {
1005
+ if (!this.coerceValue(value[i], itemName)) {
1006
+ errors.push({
1007
+ message: `Argument '${argName}[${i}]' on field '${fieldName}' expected ${itemName}, got ${typeof value[i]}`,
1008
+ path: [fieldName],
1009
+ });
1010
+ }
1011
+ }
1012
+ }
1013
+ continue;
1014
+ }
1015
+
1016
+ if (GraphQLType.SCALARS.includes(baseName)) {
1017
+ if (!this.coerceValue(value, baseName)) {
1018
+ errors.push({
1019
+ message: `Argument '${argName}' on field '${fieldName}' expected type ${baseName}, got ${typeof value}`,
1020
+ path: [fieldName],
1021
+ });
1022
+ }
1023
+ }
1024
+ }
1025
+
1026
+ return errors;
1027
+ }
1028
+
1029
+ private coerceValue(value: unknown, typeName: string): boolean {
1030
+ if (typeName === "String" || typeName === "ID") {
1031
+ return typeof value === "string" || typeof value === "number";
1032
+ }
1033
+ if (typeName === "Int") {
1034
+ if (typeof value === "boolean") return false;
1035
+ if (typeof value === "number") return Number.isInteger(value);
1036
+ if (typeof value === "string") return /^-?\d+$/.test(value);
1037
+ return false;
1038
+ }
1039
+ if (typeName === "Float") {
1040
+ if (typeof value === "boolean") return false;
1041
+ if (typeof value === "number") return true;
1042
+ if (typeof value === "string") return !isNaN(parseFloat(value));
1043
+ return false;
1044
+ }
1045
+ if (typeName === "Boolean") {
1046
+ return typeof value === "boolean" || value === 0 || value === 1
1047
+ || value === "true" || value === "false";
1048
+ }
1049
+ return true;
1050
+ }
934
1051
  }
935
1052
 
936
1053
  /**