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
|
@@ -37,48 +37,13 @@ export async function serveProject(options: ServeOptions): Promise<void> {
|
|
|
37
37
|
staticDir,
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|