@heshammesalam/heshjs 0.2.0 → 0.3.0
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/README.md +8 -2
- package/cli.js +26 -0
- package/dist/architecture/diagnostics.d.ts +12 -0
- package/dist/architecture/diagnostics.js +8 -0
- package/dist/architecture/diagnostics.js.map +1 -0
- package/dist/architecture/html.d.ts +16 -0
- package/dist/architecture/html.js +132 -0
- package/dist/architecture/html.js.map +1 -0
- package/dist/architecture/index.d.ts +42 -0
- package/dist/architecture/index.js +418 -0
- package/dist/architecture/index.js.map +1 -0
- package/dist/compiler/index.js +2 -0
- package/dist/compiler/index.js.map +1 -1
- package/dist/destinations/index.d.ts +63 -0
- package/dist/destinations/index.js +185 -0
- package/dist/destinations/index.js.map +1 -0
- package/dist/dom/index.d.ts +2 -0
- package/dist/dom/index.js +5 -1
- package/dist/dom/index.js.map +1 -1
- package/dist/domains/index.d.ts +24 -0
- package/dist/domains/index.js +110 -0
- package/dist/domains/index.js.map +1 -0
- package/dist/parser/index.d.ts +15 -1
- package/dist/parser/index.js +73 -3
- package/dist/parser/index.js.map +1 -1
- package/dist/presentation/index.d.ts +9 -0
- package/dist/presentation/index.js +38 -0
- package/dist/presentation/index.js.map +1 -0
- package/dist/vite/index.d.ts +4 -1
- package/dist/vite/index.js +36 -2
- package/dist/vite/index.js.map +1 -1
- package/docs/contracts.md +60 -0
- package/docs/domain-graph.md +205 -0
- package/docs/grammar.md +4 -0
- package/package.json +8 -3
- package/src/architecture/diagnostics.ts +11 -0
- package/src/architecture/html.ts +88 -0
- package/src/architecture/index.ts +283 -0
- package/src/compiler/index.ts +1 -0
- package/src/destinations/index.ts +146 -0
- package/src/dom/index.ts +6 -1
- package/src/domains/index.ts +97 -0
- package/src/parser/index.ts +55 -4
- package/src/presentation/index.ts +31 -0
- package/src/vite/index.ts +24 -2
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { readFileSync, existsSync, realpathSync, mkdirSync, writeFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { resolve, dirname, relative, extname, join } from 'node:path';
|
|
4
|
+
import { parse, HeshError, type Module, type Contract, type ContractEntry } from '../parser/index.js';
|
|
5
|
+
import { compileHTML } from './html.js';
|
|
6
|
+
import { diagnostic, formatDiagnostic, type ArchitectureDiagnostic } from './diagnostics.js';
|
|
7
|
+
export { compileHTML, formatDiagnostic };
|
|
8
|
+
export type { ArchitectureDiagnostic };
|
|
9
|
+
export interface ProjectOptions { root: string; entry?: string; tsconfig?: string; outDir?: string }
|
|
10
|
+
export interface ProjectResult {
|
|
11
|
+
diagnostics: ArchitectureDiagnostic[]; files: string[]; outputs: Record<string, string>;
|
|
12
|
+
graph: { domains: { name: string; exposes: string[]; consumes: string[] }[]; destinations: { name: string; url: string; input: Record<string, string>; owner: string }[]; links: { from: string; to: string }[] };
|
|
13
|
+
statistics: { parsed: number; reused: number };
|
|
14
|
+
}
|
|
15
|
+
interface Located { node: Contract; file: string; source: string }
|
|
16
|
+
interface Factory { file: string; name: string; result: ts.Type }
|
|
17
|
+
const canonical = (file: string) => {
|
|
18
|
+
const path = resolve(file); const real = existsSync(path) ? realpathSync(path) : path;
|
|
19
|
+
return (ts.sys.useCaseSensitiveFileNames ? real : real.toLowerCase()).replace(/\\/g, '/');
|
|
20
|
+
};
|
|
21
|
+
const inside = (file: string, folder: string) => file === folder || file.startsWith(folder + '/');
|
|
22
|
+
const isFile = (file: string) => existsSync(file) && statSync(file).isFile();
|
|
23
|
+
const modulePath = (from: string, to: string) => { const path = relative(dirname(from), to).replace(/\\/g, '/'); return path.startsWith('.') ? path : './' + path; };
|
|
24
|
+
export function createArchitectureCompiler() {
|
|
25
|
+
const cache = new Map<string, { source: string; ast: Module }>();
|
|
26
|
+
return { clear() { cache.clear(); }, check(options: ProjectOptions): ProjectResult {
|
|
27
|
+
const root = canonical(options.root), entry = canonical(resolve(root, options.entry ?? 'app.hesh'));
|
|
28
|
+
const outDir = resolve(root, options.outDir ?? '.hesh-generated');
|
|
29
|
+
const diagnostics: ArchitectureDiagnostic[] = [], descriptors = new Map<string, Module>(), declarations = new Map<string, Located>();
|
|
30
|
+
const files = new Set<string>(); let parsed = 0, reused = 0;
|
|
31
|
+
const error = (code: string, message: string, item: Located, position = item.node.start, suggestion = 'Correct the declared contract.') => diagnostics.push(diagnostic(code, message, item.file, item.source, position, suggestion));
|
|
32
|
+
const read = (file: string): string => { files.add(file); return readFileSync(file, 'utf8'); };
|
|
33
|
+
const load = (file: string): void => {
|
|
34
|
+
if (descriptors.has(file)) return;
|
|
35
|
+
if (!inside(file, root) || !isFile(file)) { diagnostics.push(diagnostic('HESH-ARCH-001', 'Missing or out-of-project descriptor', file, '', 0, 'Import an existing .hesh file within the application root.')); return; }
|
|
36
|
+
const source = read(file); let ast: Module;
|
|
37
|
+
try {
|
|
38
|
+
const saved = cache.get(file);
|
|
39
|
+
if (saved?.source === source) { ast = saved.ast; reused++; }
|
|
40
|
+
else { ast = parse(source, file); cache.set(file, { source, ast }); parsed++; }
|
|
41
|
+
} catch (failure) {
|
|
42
|
+
diagnostics.push(diagnostic('HESH-SYNTAX-001', failure instanceof Error ? failure.message : String(failure), file, source, failure instanceof HeshError ? failure.offset : 0, 'Fix the Hesh declaration syntax.')); return;
|
|
43
|
+
}
|
|
44
|
+
descriptors.set(file, ast);
|
|
45
|
+
if (ast.views.length) diagnostics.push(diagnostic('HESH-ARCH-001', 'Architecture descriptor files cannot also contain view implementations', file, source, ast.views[0]!.start, 'Keep existing .hesh views in separate component files.'));
|
|
46
|
+
for (const node of ast.contracts ?? []) {
|
|
47
|
+
const item = { node, file, source };
|
|
48
|
+
if (declarations.has(node.name)) error('HESH-ARCH-002', `Duplicate declaration ${node.name}`, item);
|
|
49
|
+
else declarations.set(node.name, item);
|
|
50
|
+
}
|
|
51
|
+
for (const item of ast.imports) {
|
|
52
|
+
const syntax = ts.createSourceFile(file, item.source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
|
53
|
+
const statement = syntax.statements[0];
|
|
54
|
+
if (!statement || !ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.importClause || !statement.moduleSpecifier.text.endsWith('.hesh')) {
|
|
55
|
+
diagnostics.push(diagnostic('HESH-ARCH-001', 'Architecture imports must be side-effect imports of .hesh descriptors', file, source, item.start, 'Use import "./domains/inventory/domain.hesh";.')); continue;
|
|
56
|
+
}
|
|
57
|
+
load(canonical(resolve(dirname(file), statement.moduleSpecifier.text)));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
load(entry);
|
|
61
|
+
const all = [...declarations.values()];
|
|
62
|
+
const applications = all.filter(d => d.node.kind === 'application');
|
|
63
|
+
const app = applications[0];
|
|
64
|
+
if (applications.length !== 1) diagnostics.push(diagnostic('HESH-ARCH-001', 'Exactly one application declaration is required', entry, descriptors.get(entry)?.source ?? '', 0, 'Declare one application listing domains and destinations.'));
|
|
65
|
+
const select = (kind: Contract['kind'], names: ContractEntry[] | undefined) => (names ?? []).flatMap(name => {
|
|
66
|
+
const target = declarations.get(name.name);
|
|
67
|
+
if (!target || target.node.kind !== kind) { if (app) error('HESH-ARCH-003', `Missing ${kind} ${name.name}`, app, name.start); return []; }
|
|
68
|
+
return [target];
|
|
69
|
+
});
|
|
70
|
+
const domains = select('domain', app?.node.blocks.domains), destinations = select('destination', app?.node.blocks.destinations);
|
|
71
|
+
const presentations = all.filter(d => d.node.kind === 'presentation');
|
|
72
|
+
const declaredDomains = all.filter(d => d.node.kind === 'domain');
|
|
73
|
+
const owner = (file: string): Located | undefined => declaredDomains.filter(d => inside(file, canonical(dirname(d.file)))).sort((a, b) => dirname(b.file).length - dirname(a.file).length)[0];
|
|
74
|
+
const field = (item: Located, name: string, required = true) => {
|
|
75
|
+
const value = item.node.fields[name]?.value;
|
|
76
|
+
if (required && !value) error('HESH-ARCH-003', `Missing field ${name} in ${item.node.name}`, item);
|
|
77
|
+
return value ?? '';
|
|
78
|
+
};
|
|
79
|
+
const list = (item: Located, name: string, required = false): ContractEntry[] => {
|
|
80
|
+
if (required && !item.node.blocks[name]) error('HESH-ARCH-003', `Missing block ${name}`, item);
|
|
81
|
+
return item.node.blocks[name] ?? [];
|
|
82
|
+
};
|
|
83
|
+
if (app) { list(app, 'domains', true); list(app, 'destinations', true); }
|
|
84
|
+
const factories = new Map<Located, { file: string; name: string }>();
|
|
85
|
+
for (const item of [...domains, ...presentations]) {
|
|
86
|
+
const specifier = field(item, item.node.kind === 'domain' ? 'implementation' : 'logic');
|
|
87
|
+
const hash = specifier.lastIndexOf('#'), path = specifier.slice(0, hash), name = specifier.slice(hash + 1);
|
|
88
|
+
if (hash < 1 || !/^[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}]*$/u.test(name) || !path.startsWith('.') || !['.js', '.ts', '.mjs'].includes(extname(path)) || path.endsWith('.d.ts')) { error('HESH-ARCH-003', `Invalid implementation reference ${specifier}`, item, item.node.start, 'Use ./module.js#exportName (or .ts/.mjs).'); continue; }
|
|
89
|
+
const file = canonical(resolve(dirname(item.file), path));
|
|
90
|
+
if (!existsSync(file) || !inside(file, root) || owner(file) !== owner(item.file)) { error('HESH-ARCH-004', `Implementation must exist inside its owning domain: ${specifier}`, item); continue; }
|
|
91
|
+
factories.set(item, { file, name }); files.add(file);
|
|
92
|
+
}
|
|
93
|
+
const byDomain = new Map(domains.map(item => [item.node.name, item]));
|
|
94
|
+
for (const item of declaredDomains) if (declaredDomains.some(other => other !== item && dirname(other.file) === dirname(item.file))) error('HESH-ARCH-002', 'Each domain needs its own directory boundary', item);
|
|
95
|
+
const edges = new Map<string, string[]>();
|
|
96
|
+
for (const item of domains) {
|
|
97
|
+
const exposes = list(item, 'exposes', true).map(v => v.name);
|
|
98
|
+
if (!exposes.length) error('HESH-ARCH-003', `Domain ${item.node.name} must expose at least one operation`, item);
|
|
99
|
+
for (const name of exposes) if (exposes.some(other => other !== name && other.startsWith(name + '.'))) error('HESH-ARCH-003', `Overlapping public operation ${name}`, item);
|
|
100
|
+
const dependencies: string[] = [];
|
|
101
|
+
for (const consume of list(item, 'consumes')) {
|
|
102
|
+
const [provider, ...path] = consume.name.split('.'), target = byDomain.get(provider!);
|
|
103
|
+
if (!target || !path.length || !list(target, 'exposes').some(v => v.name === path.join('.'))) error('HESH-ARCH-003', `Unresolved consumed operation ${consume.name}`, item, consume.start, 'Consume an operation explicitly exposed by a selected domain.');
|
|
104
|
+
dependencies.push(provider!);
|
|
105
|
+
}
|
|
106
|
+
for (const policy of list(item, 'access')) if (!exposes.includes(policy.name)) error('HESH-ARCH-003', `Access policy refers to unexposed operation ${policy.name}`, item, policy.start);
|
|
107
|
+
edges.set(item.node.name, dependencies);
|
|
108
|
+
}
|
|
109
|
+
const visited = new Set<string>(), visiting: string[] = [];
|
|
110
|
+
const visit = (name: string) => {
|
|
111
|
+
if (visiting.includes(name)) { const item = byDomain.get(name); if (item) error('HESH-ARCH-005', `Circular dependency: ${[...visiting, name].join(' -> ')}`, item, item.node.start, 'Remove the cycle or extract an explicit shared contract.'); return; }
|
|
112
|
+
if (visited.has(name)) return;
|
|
113
|
+
visiting.push(name); for (const next of edges.get(name) ?? []) visit(next); visiting.pop(); visited.add(name);
|
|
114
|
+
};
|
|
115
|
+
for (const name of byDomain.keys()) visit(name);
|
|
116
|
+
let settings: ts.CompilerOptions = { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, allowJs: true, checkJs: true, noEmit: true, skipLibCheck: true, allowImportingTsExtensions: true, types: [] };
|
|
117
|
+
if (options.tsconfig) {
|
|
118
|
+
const configFile = resolve(root, options.tsconfig), config = ts.readConfigFile(configFile, ts.sys.readFile);
|
|
119
|
+
if (config.error) diagnostics.push(diagnostic('HESH-ARCH-001', ts.flattenDiagnosticMessageText(config.error.messageText, '\n'), configFile, '', 0, 'Correct the TypeScript project configuration.'));
|
|
120
|
+
else settings = { ...settings, ...ts.parseJsonConfigFileContent(config.config, ts.sys, dirname(configFile)).options, noEmit: true, allowJs: true, checkJs: true };
|
|
121
|
+
}
|
|
122
|
+
const program = ts.createProgram([...factories.values()].map(f => f.file), settings);
|
|
123
|
+
const checker = program.getTypeChecker();
|
|
124
|
+
const resolvedFactories = new Map<Located, Factory>();
|
|
125
|
+
const getMember = (type: ts.Type, path: string[], unwrap = false): ts.Type | undefined => {
|
|
126
|
+
let current = type;
|
|
127
|
+
const unwrapped = (value: ts.Type): ts.Type => {
|
|
128
|
+
const get = checker.getPropertyOfType(value, 'get');
|
|
129
|
+
const callable = get ? checker.getTypeOfSymbolAtLocation(get, get.valueDeclaration ?? program.getSourceFiles()[0]!) : value;
|
|
130
|
+
return checker.getSignaturesOfType(callable, ts.SignatureKind.Call)[0]?.getReturnType() ?? value;
|
|
131
|
+
};
|
|
132
|
+
for (const piece of path) {
|
|
133
|
+
if (unwrap) current = unwrapped(current);
|
|
134
|
+
if (current.flags & ts.TypeFlags.Any) return current;
|
|
135
|
+
const symbol = checker.getPropertyOfType(current, piece); if (!symbol) return;
|
|
136
|
+
current = checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration ?? program.getSourceFiles()[0]!);
|
|
137
|
+
}
|
|
138
|
+
return unwrap ? unwrapped(current) : current;
|
|
139
|
+
};
|
|
140
|
+
for (const [item, reference] of factories) {
|
|
141
|
+
const sourceFile = program.getSourceFile(reference.file);
|
|
142
|
+
const symbol = sourceFile && checker.getSymbolAtLocation(sourceFile);
|
|
143
|
+
let exported = symbol && checker.getExportsOfModule(symbol).find(v => v.name === reference.name);
|
|
144
|
+
if (exported?.flags && exported.flags & ts.SymbolFlags.Alias) exported = checker.getAliasedSymbol(exported);
|
|
145
|
+
const type = exported && checker.getTypeOfSymbolAtLocation(exported, exported.valueDeclaration ?? sourceFile!);
|
|
146
|
+
const signature = type && checker.getSignaturesOfType(type, ts.SignatureKind.Call)[0];
|
|
147
|
+
if (!signature) { error('HESH-ARCH-003', `Missing callable export ${reference.name} in ${reference.file}`, item); continue; }
|
|
148
|
+
const result = signature.getReturnType();
|
|
149
|
+
if (result.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown) || checker.getPropertyOfType(result, 'then')) { error('HESH-ARCH-003', `Factory ${reference.name} must return a statically inspectable synchronous object`, item); continue; }
|
|
150
|
+
resolvedFactories.set(item, { ...reference, result });
|
|
151
|
+
if (item.node.kind === 'domain') for (const operation of list(item, 'exposes')) {
|
|
152
|
+
const target = getMember(result, operation.name.split('.'));
|
|
153
|
+
if (!target || !checker.getSignaturesOfType(target, ts.SignatureKind.Call).length) error('HESH-ARCH-003', `Unresolved callable implementation ${item.node.name}.${operation.name}`, item, operation.start, 'Return this operation from the declared implementation factory.');
|
|
154
|
+
}
|
|
155
|
+
else for (const block of ['bindings', 'actions']) {
|
|
156
|
+
if (!getMember(result, [block])) error('HESH-BIND-002', `Presentation factory must return a ${block} object`, item);
|
|
157
|
+
for (const binding of list(item, block, true)) {
|
|
158
|
+
const target = getMember(result, [block, binding.name]);
|
|
159
|
+
if (!target || (block === 'actions' && !checker.getSignaturesOfType(target, ts.SignatureKind.Call).length)) error(block === 'actions' ? 'HESH-ACTION-001' : 'HESH-BIND-002', `Missing ${block} implementation ${binding.name}`, item, binding.start, `Return ${block}.${binding.name} from the presentation factory.`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Validate actual imports/re-exports, including aliases and files shared by domains.
|
|
164
|
+
const sources = ts.sys.readDirectory(root, ['.ts', '.js', '.mjs'], ['**/node_modules/**', '**/dist/**', '**/.hesh-generated/**'], ['**/*']);
|
|
165
|
+
for (const path of sources) {
|
|
166
|
+
const file = canonical(path); if (inside(file, canonical(outDir))) continue;
|
|
167
|
+
files.add(file); const source = read(file), syntax = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, file.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
|
|
168
|
+
const checkImport = (specifier: ts.Node | undefined): void => {
|
|
169
|
+
if (!specifier) return;
|
|
170
|
+
if (!ts.isStringLiteralLike(specifier)) { diagnostics.push(diagnostic('HESH-ARCH-007', 'Computed module imports cannot be architecture-validated', file, source, specifier.getStart(syntax), 'Use literal imports and explicit injected dependencies.')); return; }
|
|
171
|
+
if (specifier.text.startsWith('node:') || ['fs', 'path', 'child_process', 'worker_threads', 'net', 'tls', 'http', 'https', 'os', 'module'].includes(specifier.text)) {
|
|
172
|
+
diagnostics.push(diagnostic('HESH-SEC-001', `Node/server-only import in client code: ${specifier.text}`, file, source, specifier.getStart(syntax), 'Call server infrastructure through an API rather than bundling it.')); return;
|
|
173
|
+
}
|
|
174
|
+
const target = ts.resolveModuleName(specifier.text, file, settings, ts.sys).resolvedModule?.resolvedFileName;
|
|
175
|
+
const generated = canonical(resolve(dirname(file), specifier.text));
|
|
176
|
+
if (inside(generated, canonical(outDir))) return;
|
|
177
|
+
if (!target) { if (!specifier.text.endsWith('.css') && !specifier.text.endsWith('.hesh')) diagnostics.push(diagnostic('HESH-ARCH-006', `Unresolved module ${specifier.text}`, file, source, specifier.getStart(syntax), 'Fix the path or supply the correct tsconfig alias.')); return; }
|
|
178
|
+
const targetFile = canonical(target); files.add(targetFile);
|
|
179
|
+
if (/(?:^|\/)server(?:\/|$)|\.server\.[cm]?[jt]s$/.test(targetFile)) diagnostics.push(diagnostic('HESH-SEC-001', `Server-only module in client graph: ${specifier.text}`, file, source, specifier.getStart(syntax), 'Move server calls behind a server API; never import server-only code into the browser.'));
|
|
180
|
+
const targetDomain = owner(targetFile), sourceDomain = owner(file);
|
|
181
|
+
if (targetDomain && targetDomain !== sourceDomain) diagnostics.push(diagnostic('HESH-ARCH-004', `Illegal cross-domain dependency. Source domain: ${sourceDomain?.node.name ?? 'shared/application'}. Target domain: ${targetDomain.node.name}. The imported module is private.`, file, source, specifier.getStart(syntax), `Use an explicitly exposed ${targetDomain.node.name} operation through declared dependency injection.`));
|
|
182
|
+
};
|
|
183
|
+
const walk = (node: ts.Node): void => {
|
|
184
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) checkImport(node.moduleSpecifier);
|
|
185
|
+
else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) checkImport(node.argument.literal);
|
|
186
|
+
else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) checkImport(node.arguments[0]);
|
|
187
|
+
ts.forEachChild(node, walk);
|
|
188
|
+
};
|
|
189
|
+
walk(syntax);
|
|
190
|
+
}
|
|
191
|
+
for (const failure of [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()]) if (failure.file && inside(canonical(failure.file.fileName), root)) diagnostics.push(diagnostic('HESH-TYPE-001', ts.flattenDiagnosticMessageText(failure.messageText, '\n'), failure.file.fileName, failure.file.text, failure.start ?? 0, 'Correct the implementation type/syntax error.'));
|
|
192
|
+
const schemas: Record<string, Record<string, string>> = Object.create(null), urls = new Map<string, Located>();
|
|
193
|
+
const graph: ProjectResult['graph'] = { domains: domains.map(d => ({ name: d.node.name, exposes: list(d, 'exposes').map(e => e.name), consumes: list(d, 'consumes').map(e => e.name) })), destinations: [], links: [] };
|
|
194
|
+
const presentationFor = new Map<Located, Located>();
|
|
195
|
+
for (const item of destinations) {
|
|
196
|
+
const url = field(item, 'url'), inputs = Object.fromEntries(list(item, 'input', true).map(e => [e.name, e.value ?? '']));
|
|
197
|
+
const segments = url.split('/').filter(Boolean), params = segments.filter(p => p.startsWith(':')).map(p => p.slice(1));
|
|
198
|
+
if (!url.startsWith('/') || url.startsWith('//') || /[?#*%]/.test(url) || new Set(params).size !== params.length || params.some(p => !Object.hasOwn(inputs, p)) || Object.keys(inputs).some(p => !params.includes(p))) error('HESH-NAV-003', `URL/input mismatch for ${item.node.name}`, item, item.node.start, 'Use an absolute URL whose :params exactly match the input block; use plain static segments.');
|
|
199
|
+
for (const input of list(item, 'input')) if (!['string', 'number', 'boolean'].includes(input.value ?? '') || input.name.includes('.')) error('HESH-NAV-003', `Invalid input schema ${input.name}`, item, input.start);
|
|
200
|
+
for (const key of Object.keys(inputs)) if (!/^[A-Za-z_][A-Za-z\d_]*$/.test(key)) error('HESH-NAV-003', `Unsupported URL parameter name ${key}`, item, item.node.start, 'Use ASCII letters/digits/underscore with a letter or underscore first.');
|
|
201
|
+
if (new Set(Object.keys(inputs).map(key => key.toLowerCase())).size !== Object.keys(inputs).length) error('HESH-NAV-003', 'Destination input names must be unique ignoring case for HTML attributes', item);
|
|
202
|
+
const signature = segments.map(p => p.startsWith(':') ? ':' : p).join('/');
|
|
203
|
+
if (urls.has(signature)) error('HESH-NAV-004', `Conflicting destination URL ${url}`, item); else urls.set(signature, item);
|
|
204
|
+
for (const previous of destinations.slice(0, destinations.indexOf(item))) {
|
|
205
|
+
const other = (previous.node.fields.url?.value ?? '').split('/').filter(Boolean);
|
|
206
|
+
if (other.length === segments.length && other.every((part, index) => part === segments[index] || part.startsWith(':') || segments[index]!.startsWith(':'))) error('HESH-NAV-004', `Overlapping destination URLs: ${url} and ${previous.node.fields.url?.value}`, item, item.node.start, 'Use non-overlapping addresses so destination input always resolves to the declared screen.');
|
|
207
|
+
}
|
|
208
|
+
const presentation = declarations.get(field(item, 'presentation'));
|
|
209
|
+
if (!presentation || presentation.node.kind !== 'presentation') error('HESH-NAV-001', `Missing presentation for ${item.node.name}`, item);
|
|
210
|
+
else {
|
|
211
|
+
const own = owner(presentation.file);
|
|
212
|
+
if (!own || !byDomain.has(own.node.name) || owner(item.file) !== own) error('HESH-ARCH-004', 'Destination and presentation must belong to the same selected domain', item);
|
|
213
|
+
else { presentationFor.set(item, presentation); graph.destinations.push({ name: item.node.name, url, input: inputs, owner: own.node.name }); }
|
|
214
|
+
}
|
|
215
|
+
schemas[item.node.name] = inputs;
|
|
216
|
+
}
|
|
217
|
+
const outputs: Record<string, string> = {}, htmlModules = new Map<Located, string>();
|
|
218
|
+
for (const item of new Set(presentationFor.values())) {
|
|
219
|
+
const html = canonical(resolve(dirname(item.file), field(item, 'html'))), stylePath = field(item, 'styles', false);
|
|
220
|
+
if (!isFile(html) || !html.endsWith('.html') || !inside(html, canonical(dirname(item.file)))) { error('HESH-HTML-001', `Missing or private presentation HTML ${html}`, item); continue; }
|
|
221
|
+
const source = read(html), factory = resolvedFactories.get(item);
|
|
222
|
+
const compiled = compileHTML(source, { filename: html, bindings: list(item, 'bindings', true).map(e => e.name), actions: list(item, 'actions', true).map(e => e.name), destinations: schemas,
|
|
223
|
+
validateBinding(path, offset, expected, writable) {
|
|
224
|
+
if (!factory) return;
|
|
225
|
+
const type = getMember(factory.result, ['bindings', ...path.split('.')], true);
|
|
226
|
+
if (type && type.flags & ts.TypeFlags.Any) diagnostics.push(diagnostic('HESH-TYPE-002', `Binding ${path} has a dynamic any type; its nested shape/input type cannot be proven`, html, source, offset, 'Add JSDoc or a TypeScript type if static checking is required; runtime destination validation still applies.', 'warning'));
|
|
227
|
+
if (!type) diagnostics.push(diagnostic('HESH-BIND-002', `Missing nested data path ${path}`, html, source, offset, 'Return this field in the declared bindings.'));
|
|
228
|
+
if (writable) {
|
|
229
|
+
const raw = getMember(factory.result, ['bindings', path]);
|
|
230
|
+
if (!raw || !checker.getPropertyOfType(raw, 'set')) diagnostics.push(diagnostic('HESH-BIND-004', `Binding ${path} is not writable`, html, source, offset, 'Use a writable signal for h-value.'));
|
|
231
|
+
}
|
|
232
|
+
if (expected && type && !(type.flags & ts.TypeFlags.Any)) {
|
|
233
|
+
const actual = type.flags & ts.TypeFlags.StringLike ? 'string' : type.flags & ts.TypeFlags.NumberLike ? 'number' : type.flags & ts.TypeFlags.BooleanLike ? 'boolean' : 'object/unknown';
|
|
234
|
+
if (actual !== expected) diagnostics.push(diagnostic('HESH-NAV-003', `Destination binding ${path} is ${actual}, expected ${expected}`, html, source, offset, 'Bind a value of the destination input type.'));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
diagnostics.push(...compiled.diagnostics);
|
|
239
|
+
for (const [destination, screen] of presentationFor) if (screen === item) for (const target of compiled.destinations) graph.links.push({ from: destination.node.name, to: target });
|
|
240
|
+
const output = join(outDir, `${item.node.name}.js`); htmlModules.set(item, output);
|
|
241
|
+
let css = '';
|
|
242
|
+
if (stylePath) {
|
|
243
|
+
const style = canonical(resolve(dirname(item.file), stylePath));
|
|
244
|
+
if (!isFile(style) || !style.endsWith('.css') || !inside(style, canonical(dirname(item.file)))) error('HESH-HTML-001', `Missing/private CSS resource ${stylePath}`, item);
|
|
245
|
+
else { files.add(style); css = `import ${JSON.stringify(modulePath(output, style))};\n`; }
|
|
246
|
+
}
|
|
247
|
+
outputs[output] = css + compiled.code;
|
|
248
|
+
}
|
|
249
|
+
const output = join(outDir, 'app.js'), types = join(outDir, 'app.d.ts');
|
|
250
|
+
const js = ["import { createGraphApplication, createDomainGraph } from '@heshammesalam/heshjs/destinations';"];
|
|
251
|
+
const domainValues: string[] = [], pageValues: string[] = [], typeImports: string[] = [], domainTypes: string[] = [];
|
|
252
|
+
let index = 0;
|
|
253
|
+
for (const item of domains) {
|
|
254
|
+
const factory = resolvedFactories.get(item); if (!factory) continue;
|
|
255
|
+
const alias = `factory${index++}`, spec = modulePath(output, factory.file);
|
|
256
|
+
js.push(`import { ${factory.name} as ${alias} } from ${JSON.stringify(spec)};`);
|
|
257
|
+
typeImports.push(`import { ${factory.name} as ${alias} } from ${JSON.stringify(spec)};`);
|
|
258
|
+
const operations = list(item, 'exposes').map(e => e.name);
|
|
259
|
+
domainValues.push(`{name:${JSON.stringify(item.node.name)}, factory:${alias}, exposes:${JSON.stringify(operations)}, consumes:${JSON.stringify(list(item, 'consumes').map(e => e.name))}, access:${JSON.stringify(Object.fromEntries(list(item, 'access').map(e => [e.name, e.value])))}}`);
|
|
260
|
+
const tree: Record<string, unknown> = {};
|
|
261
|
+
for (const operation of operations) { let node = tree; const parts = operation.split('.'); parts.forEach((part, i) => { if (i === parts.length - 1) node[part] = `ReturnType<typeof ${alias}>${parts.map(p => `[${JSON.stringify(p)}]`).join('')}`; else node = (node[part] ??= {}) as Record<string, unknown>; }); }
|
|
262
|
+
const typeTree = (value: Record<string, unknown>): string => '{' + Object.entries(value).map(([key, value]) => `readonly ${JSON.stringify(key)}:${typeof value === 'string' ? value : typeTree(value as Record<string, unknown>)}`).join(';') + '}';
|
|
263
|
+
domainTypes.push(`${JSON.stringify(item.node.name)}:${typeTree(tree)}`);
|
|
264
|
+
}
|
|
265
|
+
for (const item of destinations) {
|
|
266
|
+
const presentation = presentationFor.get(item), factory = presentation && resolvedFactories.get(presentation), html = presentation && htmlModules.get(presentation);
|
|
267
|
+
const data = graph.destinations.find(d => d.name === item.node.name);
|
|
268
|
+
if (!presentation || !factory || !html || !data) continue;
|
|
269
|
+
pageValues.push(`{...${JSON.stringify({ ...data, access: item.node.fields.access?.value })}, load:async()=>{const [logic,template]=await Promise.all([import(${JSON.stringify(modulePath(output, factory.file))}),import(${JSON.stringify(modulePath(output, html))})]);return {create:logic[${JSON.stringify(factory.name)}],view:template.default};}}`);
|
|
270
|
+
}
|
|
271
|
+
js.push(`const domains = [${domainValues.join(',\n')}];`, `const pages = [${pageValues.join(',\n')}];`, 'export const metadata = ' + JSON.stringify(graph) + ';', 'export const createDomains = (options = {}) => createDomainGraph(domains, options);', 'export const createApplication = (options = {}) => createGraphApplication(domains, pages, options);');
|
|
272
|
+
const destinationTypes = graph.destinations.map(d => `${JSON.stringify(d.name)}: Destination<${Object.keys(d.input).length ? '{' + Object.entries(d.input).map(([key, type]) => `${JSON.stringify(key)}:${type}`).join(';') + '}' : 'Record<string, never>'}>`);
|
|
273
|
+
outputs[output] = js.join('\n');
|
|
274
|
+
outputs[types] = ["import type { Destination, GraphApplication, ApplicationOptions, DomainGraph } from '@heshammesalam/heshjs/destinations';", ...typeImports, `export interface DomainAPIs {${domainTypes.join(';')}}`, `export interface Destinations {${destinationTypes.join(';')}}`, 'export declare function createDomains(options?: ApplicationOptions): DomainGraph<DomainAPIs>;', 'export declare function createApplication(options?: ApplicationOptions): GraphApplication<Destinations, DomainAPIs>;', 'export declare const metadata: unknown;'].join('\n');
|
|
275
|
+
if (diagnostics.some(d => d.severity === 'error')) for (const name of Object.keys(outputs)) delete outputs[name];
|
|
276
|
+
return { diagnostics, files: [...files].sort(), outputs, graph, statistics: { parsed, reused } };
|
|
277
|
+
} };
|
|
278
|
+
}
|
|
279
|
+
export function checkProject(options: ProjectOptions): ProjectResult { return createArchitectureCompiler().check(options); }
|
|
280
|
+
export function writeProject(result: ProjectResult): void {
|
|
281
|
+
if (result.diagnostics.some(d => d.severity === 'error')) throw new Error(result.diagnostics.map(formatDiagnostic).join('\n'));
|
|
282
|
+
for (const [file, source] of Object.entries(result.outputs)) { mkdirSync(dirname(file), { recursive: true }); if (!existsSync(file) || readFileSync(file, 'utf8') !== source) writeFileSync(file, source); }
|
|
283
|
+
}
|
package/src/compiler/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ const f = ts.factory;
|
|
|
11
11
|
|
|
12
12
|
export function compile(source: string, options: CompileOptions = {}): Result {
|
|
13
13
|
const ast = parse(source, options.filename);
|
|
14
|
+
if (ast.contracts.length) throw new HeshError('Architecture declarations require the project compiler (hesh check or hesh({ architecture: ... }))', source, ast.contracts[0]!.start, ast.filename);
|
|
14
15
|
const metadata: Binding[] = [];
|
|
15
16
|
const fail = (message: string, offset: number): never => { throw new HeshError(message, source, offset, ast.filename); };
|
|
16
17
|
const names = new Set<string>();
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createRouter } from '../router/index.js';
|
|
2
|
+
import { defineModule, useController, type RouteLocation } from '../hmvc/index.js';
|
|
3
|
+
import { createDomainGraph, ClientPolicyDenied, type DomainDefinition, type DomainOptions, type DomainGraph } from '../domains/index.js';
|
|
4
|
+
import { signal, effect, onCleanup, scope, type Disposer } from '../reactivity/index.js';
|
|
5
|
+
import { mount, element, text } from '../dom/index.js';
|
|
6
|
+
import type { PresentationState } from '../presentation/index.js';
|
|
7
|
+
export type InputType = 'string' | 'number' | 'boolean';
|
|
8
|
+
export interface TransitionOptions { query?: Record<string, string>; hash?: string; presentation?: 'page' }
|
|
9
|
+
export interface Destination<I extends object> {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
href(input: I, options?: TransitionOptions): string;
|
|
12
|
+
open(input: I, options?: TransitionOptions): void;
|
|
13
|
+
replace(input: I, options?: TransitionOptions): void;
|
|
14
|
+
/** Prefetch code/resources, not business data or authorization results. */
|
|
15
|
+
prefetch(input: I): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export interface PresentationContext {
|
|
18
|
+
domains: Record<string, unknown>;
|
|
19
|
+
input: Record<string, string | number | boolean>;
|
|
20
|
+
location: RouteLocation;
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
}
|
|
23
|
+
export interface PresentationResources {
|
|
24
|
+
create(context: PresentationContext): PresentationState;
|
|
25
|
+
view(parent: Node, state: PresentationState, destinations: Record<string, Destination<Record<string, unknown>>>): void;
|
|
26
|
+
}
|
|
27
|
+
export interface DestinationDefinition {
|
|
28
|
+
name: string; url: string; input: Record<string, InputType>; owner: string; access?: string;
|
|
29
|
+
load(): Promise<PresentationResources>;
|
|
30
|
+
}
|
|
31
|
+
export interface DestinationEvent { type: 'loading' | 'ready' | 'blocked' | 'error' | 'cancelled' | 'disposed'; destination: string; reason?: string }
|
|
32
|
+
export interface ApplicationOptions extends DomainOptions { window?: Window; onError?: (error: unknown) => void; onDiagnostic?: (event: DestinationEvent) => void }
|
|
33
|
+
export interface GraphApplication<D = Record<string, Destination<Record<string, unknown>>>, G = Record<string, Record<string, unknown>>> {
|
|
34
|
+
destinations: D;
|
|
35
|
+
domains: G;
|
|
36
|
+
status: { get(): { phase: 'idle' | 'loading' | 'ready' | 'error'; destination?: string; error?: unknown } };
|
|
37
|
+
start(parent: Node): Disposer;
|
|
38
|
+
stop(): void;
|
|
39
|
+
}
|
|
40
|
+
export function validateInput(schema: Record<string, InputType>, input: object): Record<string, string | number | boolean> {
|
|
41
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Destination input must be an object');
|
|
42
|
+
const values = input as Record<string, unknown>;
|
|
43
|
+
for (const key of Object.keys(values)) if (!Object.hasOwn(schema, key)) throw new Error(`Unknown destination input ${key}`);
|
|
44
|
+
const result: Record<string, string | number | boolean> = Object.create(null);
|
|
45
|
+
for (const [key, type] of Object.entries(schema)) {
|
|
46
|
+
const value = values[key];
|
|
47
|
+
if (!Object.hasOwn(values, key) || typeof value !== type || (type === 'number' && !Number.isFinite(value)) || (type === 'string' && !(value as string).length)) throw new Error(`Destination input ${key} must be ${type}${type === 'string' ? ' (nonempty)' : ''}`);
|
|
48
|
+
if (type === 'string' && (value === '.' || value === '..')) throw new Error(`Destination input ${key} cannot be a URL dot segment`);
|
|
49
|
+
result[key] = value as string | number | boolean;
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
function decodeInput(definition: DestinationDefinition, params: Readonly<Record<string, string>>): Record<string, string | number | boolean> {
|
|
54
|
+
const values: Record<string, unknown> = Object.create(null);
|
|
55
|
+
for (const [key, type] of Object.entries(definition.input)) {
|
|
56
|
+
const raw = params[key];
|
|
57
|
+
if (raw === undefined) throw new Error(`Missing URL input ${key}`);
|
|
58
|
+
if (type === 'boolean' && raw !== 'true' && raw !== 'false') throw new Error(`URL input ${key} must be true/false`);
|
|
59
|
+
if (type === 'number' && !raw.trim()) throw new Error(`URL input ${key} must be numeric`);
|
|
60
|
+
values[key] = type === 'number' ? Number(raw) : type === 'boolean' ? raw === 'true' : raw;
|
|
61
|
+
}
|
|
62
|
+
return validateInput(definition.input, values);
|
|
63
|
+
}
|
|
64
|
+
export function createGraphApplication(definitions: readonly DomainDefinition[], pages: readonly DestinationDefinition[], options: ApplicationOptions = {}): GraphApplication {
|
|
65
|
+
const graph = createDomainGraph(definitions, options);
|
|
66
|
+
const status = signal<{ phase: 'idle' | 'loading' | 'ready' | 'error'; destination?: string; error?: unknown }>({ phase: 'idle' });
|
|
67
|
+
const report = options.onError ?? (error => console.error(error));
|
|
68
|
+
const emit = (type: DestinationEvent['type'], destination: string, error?: unknown) => {
|
|
69
|
+
if (!options.onDiagnostic) return;
|
|
70
|
+
try { options.onDiagnostic({ type, destination, ...(error === undefined ? {} : { reason: error instanceof Error ? error.message : String(error) }) }); }
|
|
71
|
+
catch (failure) { report(failure); }
|
|
72
|
+
};
|
|
73
|
+
const destinations: Record<string, Destination<Record<string, unknown>>> = Object.create(null);
|
|
74
|
+
const code = new Map<string, Promise<PresentationResources>>();
|
|
75
|
+
let stopped = false;
|
|
76
|
+
const load = (page: DestinationDefinition) => {
|
|
77
|
+
let result = code.get(page.name);
|
|
78
|
+
if (!result) { result = Promise.resolve().then(page.load).catch(error => { code.delete(page.name); throw error; }); code.set(page.name, result); }
|
|
79
|
+
return result;
|
|
80
|
+
};
|
|
81
|
+
const authorize = (page: DestinationDefinition) => {
|
|
82
|
+
if (page.access && options.authorize?.(page.access) !== true) throw new ClientPolicyDenied(page.access, page.name);
|
|
83
|
+
};
|
|
84
|
+
const routes = pages.map(page => ({ path: page.url, module: defineModule({
|
|
85
|
+
model: context => context.route,
|
|
86
|
+
controller: route => route,
|
|
87
|
+
view(parent) {
|
|
88
|
+
const route = useController<{ get(): RouteLocation }>();
|
|
89
|
+
effect(() => {
|
|
90
|
+
const location = route.get();
|
|
91
|
+
const abort = new AbortController(); let dispose: Disposer | undefined, settled = false;
|
|
92
|
+
onCleanup(() => { abort.abort(); dispose?.(); emit(settled ? 'disposed' : 'cancelled', page.name); });
|
|
93
|
+
const message = element(parent, 'p'); message.setAttribute('role', 'status'); text(message, () => 'Loading…');
|
|
94
|
+
status.set({ phase: 'loading', destination: page.name });
|
|
95
|
+
emit('loading', page.name);
|
|
96
|
+
const failed = (error: unknown) => {
|
|
97
|
+
if (abort.signal.aborted) return;
|
|
98
|
+
settled = true;
|
|
99
|
+
message.textContent = 'Unable to open this destination.'; message.setAttribute('role', 'alert');
|
|
100
|
+
status.set({ phase: 'error', destination: page.name, error }); emit(error instanceof ClientPolicyDenied ? 'blocked' : 'error', page.name, error); report(error);
|
|
101
|
+
};
|
|
102
|
+
let input: ReturnType<typeof validateInput>;
|
|
103
|
+
try { authorize(page); input = decodeInput(page, location.params); } catch (error) { failed(error); return; }
|
|
104
|
+
void load(page).then(resources => {
|
|
105
|
+
if (abort.signal.aborted) return;
|
|
106
|
+
const owned = scope(() => {
|
|
107
|
+
const state = resources.create({ input, domains: graph.forDomain(page.owner), location, signal: abort.signal });
|
|
108
|
+
if (state && typeof (state as unknown as { then?: unknown }).then === 'function') throw new Error('Presentation factories must be synchronous; use the provided AbortSignal for async work');
|
|
109
|
+
mount(() => resources.view(parent, state, destinations), parent, { onError: report });
|
|
110
|
+
});
|
|
111
|
+
dispose = owned.dispose;
|
|
112
|
+
if (abort.signal.aborted) { dispose(); return; }
|
|
113
|
+
settled = true; message.remove(); status.set({ phase: 'ready', destination: page.name }); emit('ready', page.name);
|
|
114
|
+
// Page-only focus management; opt-in target or the first heading.
|
|
115
|
+
const target = (parent as Element).querySelector?.('[data-hesh-focus], h1, h2') as HTMLElement | null | undefined;
|
|
116
|
+
if (target) { if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1'); target.focus({ preventScroll: true }); }
|
|
117
|
+
}).catch(failed);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}) }));
|
|
121
|
+
let router: ReturnType<typeof createRouter>;
|
|
122
|
+
try { router = createRouter({ routes, window: options.window, onError: report }); }
|
|
123
|
+
catch (error) { graph.dispose(); throw error; }
|
|
124
|
+
for (const page of pages) {
|
|
125
|
+
if (destinations[page.name]) { graph.dispose(); throw new Error(`Duplicate destination ${page.name}`); }
|
|
126
|
+
const href = (input: object, transition: TransitionOptions = {}) => {
|
|
127
|
+
if (stopped) throw new Error('Application is disposed');
|
|
128
|
+
if (transition.presentation && transition.presentation !== 'page') throw new Error('Only page presentation is supported');
|
|
129
|
+
const values = validateInput(page.input, input);
|
|
130
|
+
const path = page.url.split('/').map(part => part.startsWith(':') ? encodeURIComponent(String(values[part.slice(1)])) : part).join('/');
|
|
131
|
+
const query = new URLSearchParams(transition.query).toString();
|
|
132
|
+
const hash = transition.hash ? '#' + encodeURIComponent(transition.hash) : '';
|
|
133
|
+
return path + (query ? '?' + query : '') + hash;
|
|
134
|
+
};
|
|
135
|
+
destinations[page.name] = Object.freeze({ name: page.name, href,
|
|
136
|
+
open(input: object, transition?: TransitionOptions) { authorize(page); router.navigate(href(input, transition)); },
|
|
137
|
+
replace(input: object, transition?: TransitionOptions) { authorize(page); router.navigate(href(input, transition), { replace: true }); },
|
|
138
|
+
async prefetch(input: object) { href(input); await load(page); }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const stop = () => { if (stopped) return; stopped = true; try { router.stop(); } finally { try { graph.dispose(); } finally { code.clear(); status.set({ phase: 'idle' }); } } };
|
|
142
|
+
return { destinations: Object.freeze(destinations), domains: graph.domains, status: { get: status.get },
|
|
143
|
+
start(parent) { if (stopped) throw new Error('Application is disposed'); try { router.start(parent); return stop; } catch (error) { stop(); throw error; } }, stop };
|
|
144
|
+
}
|
|
145
|
+
export { createDomainGraph };
|
|
146
|
+
export type { DomainDefinition, DomainGraph };
|
package/src/dom/index.ts
CHANGED
|
@@ -12,7 +12,12 @@ export function mount(component: Component, parent: Node, options: MountOptions
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function element(parent: Node, name: string): HTMLElement {
|
|
15
|
-
|
|
15
|
+
return nativeElement(parent, aliases[name] ?? name);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Creates the exact HTML tag name, without Hesh shorthand aliases. */
|
|
19
|
+
export function nativeElement(parent: Node, name: string): HTMLElement {
|
|
20
|
+
const node = parent.ownerDocument!.createElement(name);
|
|
16
21
|
parent.appendChild(node);
|
|
17
22
|
onCleanup(() => node.remove());
|
|
18
23
|
return node;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { scope, type Disposer } from '../reactivity/index.js';
|
|
2
|
+
export interface DomainDefinition {
|
|
3
|
+
name: string;
|
|
4
|
+
exposes: readonly string[];
|
|
5
|
+
consumes: readonly string[];
|
|
6
|
+
access?: Readonly<Record<string, string>>;
|
|
7
|
+
factory: (dependencies: Record<string, Record<string, unknown>>) => unknown;
|
|
8
|
+
}
|
|
9
|
+
export interface DomainOptions { authorize?: (policy: string) => boolean }
|
|
10
|
+
export class ClientPolicyDenied extends Error {
|
|
11
|
+
readonly code = 'HESH-ACCESS-001';
|
|
12
|
+
constructor(readonly policy: string, readonly contract: string) {
|
|
13
|
+
super(`Client policy denied: ${policy} (${contract}). Server authorization is still required.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export interface DomainGraph<T = Record<string, Record<string, unknown>>> {
|
|
17
|
+
readonly domains: T;
|
|
18
|
+
forDomain(name: string): Record<string, unknown>;
|
|
19
|
+
dispose: Disposer;
|
|
20
|
+
}
|
|
21
|
+
const unsafe = new Set(['__proto__', 'constructor', 'prototype']);
|
|
22
|
+
export function contractPath(path: string): string[] {
|
|
23
|
+
const parts = path.split('.');
|
|
24
|
+
if (parts.some(p => !p || unsafe.has(p))) throw new Error(`Invalid contract path ${path}`);
|
|
25
|
+
return parts;
|
|
26
|
+
}
|
|
27
|
+
function project(target: Record<string, unknown>, path: string[], value: unknown): void {
|
|
28
|
+
const name = path[0]!;
|
|
29
|
+
if (path.length === 1) { target[name] = value; return; }
|
|
30
|
+
if (target[name] !== undefined && (typeof target[name] !== 'object' || target[name] === null)) throw new Error(`Overlapping public contracts at ${name}`);
|
|
31
|
+
const next = (target[name] ??= Object.create(null)) as Record<string, unknown>;
|
|
32
|
+
project(next, path.slice(1), value);
|
|
33
|
+
}
|
|
34
|
+
function freeze(value: Record<string, unknown>): Record<string, unknown> {
|
|
35
|
+
for (const child of Object.values(value)) if (child && typeof child === 'object') freeze(child as Record<string, unknown>);
|
|
36
|
+
return Object.freeze(value);
|
|
37
|
+
}
|
|
38
|
+
function lookup(value: unknown, parts: string[]): { value: unknown; receiver: unknown } {
|
|
39
|
+
let receiver: unknown;
|
|
40
|
+
for (const part of parts) {
|
|
41
|
+
receiver = value;
|
|
42
|
+
value = value != null && Object.hasOwn(Object(value), part) ? (value as Record<string, unknown>)[part] : undefined;
|
|
43
|
+
}
|
|
44
|
+
return { value, receiver };
|
|
45
|
+
}
|
|
46
|
+
export function createDomainGraph(definitions: readonly DomainDefinition[], options: DomainOptions = {}): DomainGraph {
|
|
47
|
+
const byName = new Map(definitions.map(d => [d.name, d]));
|
|
48
|
+
if (byName.size !== definitions.length) throw new Error('Duplicate domain name');
|
|
49
|
+
const domains: Record<string, Record<string, unknown>> = Object.create(null);
|
|
50
|
+
const contexts: Record<string, Record<string, unknown>> = Object.create(null);
|
|
51
|
+
const building = new Set<string>();
|
|
52
|
+
let disposed = false;
|
|
53
|
+
const owned = scope(() => {
|
|
54
|
+
const build = (name: string): Record<string, unknown> => {
|
|
55
|
+
if (domains[name]) return domains[name]!;
|
|
56
|
+
const definition = byName.get(name);
|
|
57
|
+
if (!definition) throw new Error(`Missing domain ${name}`);
|
|
58
|
+
if (building.has(name)) throw new Error(`Circular domain dependency at ${name}`);
|
|
59
|
+
building.add(name);
|
|
60
|
+
const dependencies: Record<string, Record<string, unknown>> = Object.create(null);
|
|
61
|
+
for (const dependency of definition.consumes) {
|
|
62
|
+
const [provider, ...operation] = contractPath(dependency);
|
|
63
|
+
if (!operation.length) throw new Error(`Dependency must name an operation: ${dependency}`);
|
|
64
|
+
const target = build(provider!);
|
|
65
|
+
const value = lookup(target, operation).value;
|
|
66
|
+
if (typeof value !== 'function') throw new Error(`Missing public operation ${dependency}`);
|
|
67
|
+
project(dependencies[provider!] ??= Object.create(null), operation, value);
|
|
68
|
+
}
|
|
69
|
+
freeze(dependencies);
|
|
70
|
+
const implementation = definition.factory(dependencies);
|
|
71
|
+
if (implementation && typeof (implementation as { then?: unknown }).then === 'function') throw new Error(`Domain ${name} factory must be synchronous`);
|
|
72
|
+
const facade: Record<string, unknown> = Object.create(null);
|
|
73
|
+
for (const operation of definition.exposes) {
|
|
74
|
+
const parts = contractPath(operation);
|
|
75
|
+
const { value, receiver } = lookup(implementation, parts);
|
|
76
|
+
if (typeof value !== 'function') throw new Error(`Unresolved implementation ${name}.${operation}`);
|
|
77
|
+
project(facade, parts, (...args: unknown[]) => {
|
|
78
|
+
if (disposed) throw new Error('Domain graph is disposed');
|
|
79
|
+
const policy = definition.access?.[operation];
|
|
80
|
+
if (policy && options.authorize?.(policy) !== true) throw new ClientPolicyDenied(policy, `${name}.${operation}`);
|
|
81
|
+
return Reflect.apply(value, receiver, args);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
domains[name] = freeze(facade);
|
|
85
|
+
contexts[name] = Object.freeze({ ...dependencies, [name]: domains[name] });
|
|
86
|
+
building.delete(name);
|
|
87
|
+
return domains[name]!;
|
|
88
|
+
};
|
|
89
|
+
for (const definition of definitions) build(definition.name);
|
|
90
|
+
freeze(domains);
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
domains,
|
|
94
|
+
forDomain(name) { if (disposed) throw new Error('Domain graph is disposed'); if (!contexts[name]) throw new Error(`Unknown domain context ${name}`); return contexts[name]!; },
|
|
95
|
+
dispose() { if (disposed) return; disposed = true; owned.dispose(); }
|
|
96
|
+
};
|
|
97
|
+
}
|