@fastang/core 0.1.0-alpha.3 → 0.1.0-alpha.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.5
4
+
5
+ - Default development Fastify/Vite logging to warn, retain the startup address and allow FASTANG_LOG_LEVEL overrides. Suppress successful dev check summaries with check --quiet.
6
+
7
+ - Preserve relative dependency symlinks in Vercel functions for pnpm installations.
8
+
9
+ ## 0.1.0-alpha.4
10
+
11
+ - Add source-only `fastang check` diagnostics for API route/contract correspondence, with tsconfig resolution, JSON and strict mode.
12
+ - Run checks before builds and development restarts; share diagnostics with the Vite overlay.
13
+
14
+ - Bound development server shutdown to three seconds before forced termination.
15
+ - Use English for framework diagnostics.
16
+
3
17
  ## 0.1.0-alpha.3
4
18
 
5
19
  - Coordinate the standalone CLI release and starter while preserving runtime APIs.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @fastang/core
2
2
 
3
3
  Experimental FastAng runtime and tooling for Angular + Fastify applications.
4
- Version 0.1.0-alpha.2 is an experimental alpha. Public APIs may change between alpha releases.
4
+ Version 0.1.0-alpha.5 is an experimental alpha. Public APIs may change between alpha releases.
5
5
 
6
6
  The package contains compiled JavaScript and TypeScript declarations. Angular,
7
7
  Fastify, Vite and the Angular compiler are explicit peer dependencies so the app
@@ -82,7 +82,62 @@ See the [public API and support reference](https://fastang.dev/en/docs/reference
82
82
  ## Standalone CLI
83
83
 
84
84
  Project creation and upgrades belong to the separate lightweight `@fastang/cli`
85
- package introduced in alpha.3. Core provides `dev`, `build`, `generate` and `routes`.
85
+ package introduced in alpha.3. Core provides `dev`, `build`, `generate`, `routes` and (from alpha.4) `check`.
86
86
  The global CLI forwards these commands to the app's installed core.
87
87
 
88
88
  See [CLI behavior and limitations](../../docs/cli.md).
89
+
90
+ ## Static route checks
91
+
92
+ From `0.1.0-alpha.4`, FastAng includes a route/contract diagnostic command:
93
+
94
+ ```sh
95
+ npx fastang check
96
+ npx fastang check --json
97
+ npx fastang check --strict --project tsconfig.node.json
98
+ ```
99
+
100
+ The checker defaults to `tsconfig.node.json`, falling back to `tsconfig.json`.
101
+ It uses TypeScript module resolution, including inherited compiler settings,
102
+ `paths`, aliases and re-exports. It starts from file-based API routes and checks
103
+ only the contract actually passed to `defineContractRoute`; unused contracts
104
+ are not treated as missing routes. It never imports application modules, loads
105
+ FastAng/Vite configuration, emits files or connects to application services.
106
+
107
+ Literal strings, const references, object literals and resolvable spreads are
108
+ supported. Function calls that construct contracts, mutable variables, wrappers
109
+ and unresolved imports produce `FA1003` (unverified), rather than a successful
110
+ verification. `--strict` makes these warnings errors. A known method/path mismatch
111
+ is `FA1002`; configuration/syntax errors are `FA1000`, and route discovery errors
112
+ are `FA1001`. Errors exit with code 1. JSON output has `schemaVersion: 1`, counts
113
+ and diagnostics with source positions and a related contract location for
114
+ mismatches. `defineApiRoute` needs no contract correspondence check.
115
+
116
+ This is a source-convention check, not general constant evaluation or a replacement
117
+ for TypeScript/Angular checks. Keep method/path definitions declarative; mutations
118
+ or side effects elsewhere are not evaluated. Resolving a TS alias here does not
119
+ configure runtime aliases. Registration-time validation remains in place.
120
+
121
+ `fastang build` checks before loading application configuration or clearing build
122
+ output. The Vite plugin reuses the checker and reports errors in its terminal and
123
+ overlay on route/contract edits; fixing them triggers a full reload. `fastang dev`
124
+ checks before restarting the backend and keeps an existing server during an invalid
125
+ edit. The standalone CLI delegates `check` to the app's installed core: both need
126
+ a release containing this command. Use `0.1.0-alpha.4` or later for both packages.
127
+
128
+ ## Development logging
129
+
130
+ From `0.1.0-alpha.5`, development defaults to `warn` for both Fastify and Vite during `fastang dev`.
131
+ The listening address remains visible. Routine HTTP request/completion logs and
132
+ successful route-check summaries are omitted; warnings, errors and failed checks
133
+ remain visible. Production logger configuration is unchanged.
134
+
135
+ Set `FASTANG_LOG_LEVEL=info` or `FASTANG_LOG_LEVEL=debug` in the process environment
136
+ when investigating requests. Accepted values are `trace`, `debug`, `info`, `warn`,
137
+ `error`, `fatal` and `silent`. Vite maps trace/debug to info and fatal to error.
138
+ This controls framework loggers, not application `console.log` calls or package
139
+ manager output. The startup address is retained even with `silent`.
140
+
141
+ `fastang check --quiet` suppresses only a successful text summary; diagnostics
142
+ still print, and `--json` always returns the JSON report. The dev supervisor uses
143
+ this option for checks before backend restarts. These options are available from `0.1.0-alpha.5`.
@@ -1,5 +1,5 @@
1
- import { copyFile, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
- import { dirname, resolve } from 'node:path';
1
+ import { copyFile, mkdir, readFile, rm, stat, lstat, readlink, symlink, writeFile } from 'node:fs/promises';
2
+ import { dirname, resolve, relative, isAbsolute } from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { nodeFileTrace } from '@vercel/nft';
5
5
  import { staticFile } from "../server.js";
@@ -15,7 +15,22 @@ export async function buildVercelOutput(plan, root = process.cwd()) {
15
15
  await rm(output, { recursive: true, force: true });
16
16
  const copy = async (file, destination) => {
17
17
  await mkdir(dirname(destination), { recursive: true });
18
- await copyFile(resolve(root, file), destination);
18
+ const source = resolve(root, file);
19
+ const info = await lstat(source);
20
+ if (info.isSymbolicLink()) {
21
+ const target = await readlink(source);
22
+ const sourceTarget = relative(root, resolve(dirname(source), target));
23
+ if (isAbsolute(target) || sourceTarget === '..' || sourceTarget.startsWith('../') || sourceTarget.startsWith('..\\'))
24
+ throw new Error(`Dependency symlink outside project: ${file}`);
25
+ const directory = (await stat(source)).isDirectory();
26
+ if (directory)
27
+ await mkdir(resolve(dirname(destination), target), { recursive: true });
28
+ await symlink(target, destination, directory ? 'dir' : 'file');
29
+ }
30
+ else if (info.isDirectory())
31
+ await mkdir(destination, { recursive: true });
32
+ else
33
+ await copyFile(source, destination);
19
34
  };
20
35
  const { cp } = await import('node:fs/promises');
21
36
  await cp(resolve(root, 'dist/client/assets'), resolve(output, 'static/assets'), { recursive: true });
@@ -39,11 +54,16 @@ export async function buildVercelOutput(plan, root = process.cwd()) {
39
54
  traces.set(entryFile, trace);
40
55
  for (const warning of trace.warnings)
41
56
  console.warn(`[vercel:${name}] ${warning.message}`);
42
- for (const file of trace.fileList) {
57
+ const files = await Promise.all([...trace.fileList].map(async (file) => {
43
58
  if (file.startsWith('../') || file.startsWith('/'))
44
59
  throw new Error(`Dependency outside project: ${file}`);
60
+ return { file, link: (await lstat(resolve(root, file))).isSymbolicLink() };
61
+ }));
62
+ // pnpm exposes dependencies through relative directory symlinks. Keep that
63
+ // topology in the function, creating links before copying traced files.
64
+ files.sort((a, b) => Number(b.link) - Number(a.link) || a.file.length - b.file.length);
65
+ for (const { file } of files)
45
66
  await copy(file, resolve(directory, file));
46
- }
47
67
  if (kind === 'ssr')
48
68
  await copy('dist/client/index.html', resolve(directory, dirname(entryFile), '../client/index.html'));
49
69
  await mkdir(directory, { recursive: true });
@@ -0,0 +1,29 @@
1
+ export interface CheckDiagnostic {
2
+ code: string;
3
+ severity: 'error' | 'warning';
4
+ message: string;
5
+ file?: string;
6
+ line?: number;
7
+ column?: number;
8
+ contract?: {
9
+ file: string;
10
+ line: number;
11
+ column: number;
12
+ };
13
+ }
14
+ export interface CheckResult {
15
+ schemaVersion: 1;
16
+ project: string;
17
+ routes: number;
18
+ verified: number;
19
+ diagnostics: CheckDiagnostic[];
20
+ }
21
+ export interface CheckOptions {
22
+ root?: string;
23
+ project?: string;
24
+ strict?: boolean;
25
+ onFiles?: (files: string[]) => void;
26
+ }
27
+ /** Analyze source only. No application imports, config execution, generation or emit. */
28
+ export declare function checkRoutes(options?: CheckOptions): Promise<CheckResult>;
29
+ export declare function formatCheck(result: CheckResult): string;
package/dist/check.js ADDED
@@ -0,0 +1,159 @@
1
+ import ts from 'typescript';
2
+ import { existsSync } from 'node:fs';
3
+ import { basename, relative, resolve } from 'node:path';
4
+ import { discover } from "./routes.js";
5
+ /** Analyze source only. No application imports, config execution, generation or emit. */
6
+ export async function checkRoutes(options = {}) {
7
+ const root = resolve(options.root ?? process.cwd());
8
+ const project = resolve(root, options.project ?? (existsSync(resolve(root, 'tsconfig.node.json')) ? 'tsconfig.node.json' : 'tsconfig.json'));
9
+ const result = { schemaVersion: 1, project: relative(root, project).replaceAll('\\', '/'), routes: 0, verified: 0, diagnostics: [] };
10
+ const fileName = (file) => relative(root, file).replaceAll('\\', '/');
11
+ const position = (node) => {
12
+ const source = node.getSourceFile();
13
+ const { line, character } = source.getLineAndCharacterOfPosition(node.getStart());
14
+ return { file: fileName(source.fileName), line: line + 1, column: character + 1 };
15
+ };
16
+ const configError = (error) => {
17
+ const location = error.file && error.start !== undefined ? error.file.getLineAndCharacterOfPosition(error.start) : undefined;
18
+ result.diagnostics.push({ code: 'FA1000', severity: 'error', message: ts.flattenDiagnosticMessageText(error.messageText, '\n'), ...(error.file ? { file: fileName(error.file.fileName) } : {}), ...(location ? { line: location.line + 1, column: location.character + 1 } : {}) });
19
+ };
20
+ const config = ts.readConfigFile(project, ts.sys.readFile);
21
+ if (config.error) {
22
+ configError(config.error);
23
+ return result;
24
+ }
25
+ const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, resolve(project, '..'), undefined, project);
26
+ if (parsed.errors.length) {
27
+ parsed.errors.forEach(configError);
28
+ return result;
29
+ }
30
+ let routes;
31
+ try {
32
+ routes = await discover(resolve(root, 'src/server/api'), 'api');
33
+ }
34
+ catch (error) {
35
+ result.diagnostics.push({ code: 'FA1001', severity: 'error', message: String(error instanceof Error ? error.message : error) });
36
+ return result;
37
+ }
38
+ result.routes = routes.length;
39
+ const program = ts.createProgram({ rootNames: [...new Set([...parsed.fileNames, ...routes.map(route => resolve(root, 'src/server/api', route.file))])], options: { ...parsed.options, noEmit: true } });
40
+ options.onFiles?.(program.getSourceFiles().filter(file => !file.isDeclarationFile && !file.fileName.replaceAll('\\', '/').includes('/node_modules/')).map(file => file.fileName));
41
+ const checker = program.getTypeChecker();
42
+ function symbol(node) {
43
+ const value = checker.getSymbolAtLocation(node);
44
+ return value && value.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(value) : value;
45
+ }
46
+ function unwrap(node) {
47
+ while (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isSatisfiesExpression(node) || ts.isNonNullExpression(node))
48
+ node = node.expression;
49
+ return node;
50
+ }
51
+ function value(node, seen = new Set()) {
52
+ if (!node || seen.has(node) || seen.size > 100)
53
+ return undefined;
54
+ seen.add(node);
55
+ node = unwrap(node);
56
+ if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) {
57
+ const declaration = symbol(ts.isPropertyAccessExpression(node) ? node.name : node)?.valueDeclaration;
58
+ if (declaration && ts.isVariableDeclaration(declaration) && declaration.parent.flags & ts.NodeFlags.Const)
59
+ return value(declaration.initializer, seen);
60
+ if (declaration && ts.isExportAssignment(declaration))
61
+ return value(declaration.expression, seen);
62
+ if (declaration && ts.isPropertyAssignment(declaration))
63
+ return value(declaration.initializer, seen);
64
+ if (ts.isPropertyAccessExpression(node))
65
+ return property(value(node.expression, seen), node.name.text, seen);
66
+ return undefined;
67
+ }
68
+ return node;
69
+ }
70
+ function property(node, key, seen = new Set()) {
71
+ if (!node || !ts.isObjectLiteralExpression(node))
72
+ return undefined;
73
+ for (const field of [...node.properties].reverse()) {
74
+ if (ts.isSpreadAssignment(field)) {
75
+ const spread = value(field.expression, new Set(seen));
76
+ if (!spread || !ts.isObjectLiteralExpression(spread))
77
+ return undefined;
78
+ const found = property(spread, key, new Set(seen));
79
+ if (found)
80
+ return found;
81
+ // An unresolved nested spread could override earlier fields.
82
+ if (spread.properties.some(field => ts.isSpreadAssignment(field) || field.name && (ts.isComputedPropertyName(field.name) || (ts.isIdentifier(field.name) || ts.isStringLiteral(field.name)) && field.name.text === key)))
83
+ return undefined;
84
+ }
85
+ else {
86
+ if (field.name && ts.isComputedPropertyName(field.name))
87
+ return undefined;
88
+ if (field.name && (ts.isIdentifier(field.name) || ts.isStringLiteral(field.name)) && field.name.text === key) {
89
+ if (ts.isPropertyAssignment(field))
90
+ return value(field.initializer, new Set(seen));
91
+ if (ts.isShorthandPropertyAssignment(field)) {
92
+ const declaration = checker.getShorthandAssignmentValueSymbol(field)?.valueDeclaration;
93
+ return declaration && ts.isVariableDeclaration(declaration) && declaration.parent.flags & ts.NodeFlags.Const ? value(declaration.initializer, new Set(seen)) : undefined;
94
+ }
95
+ return undefined;
96
+ }
97
+ }
98
+ }
99
+ return undefined;
100
+ }
101
+ function apiCall(node, name, module) {
102
+ if (!node || !ts.isCallExpression(node))
103
+ return false;
104
+ const target = symbol(ts.isPropertyAccessExpression(node.expression) ? node.expression.name : node.expression);
105
+ return target?.name === name && !!target.declarations?.some(declaration => {
106
+ const path = declaration.getSourceFile().fileName.replaceAll('\\', '/');
107
+ return /\/(?:@fastang\/core|packages\/core)\/(?:src|dist)\//.test(path) && [module + '.ts', module + '.d.ts'].includes(basename(path));
108
+ });
109
+ }
110
+ const syntaxFiles = new Set();
111
+ for (const route of routes) {
112
+ const source = program.getSourceFile(resolve(root, 'src/server/api', route.file));
113
+ if (!source)
114
+ continue;
115
+ const expected = `${route.method} /api${route.path ? '/' + route.path : ''}`;
116
+ const module = checker.getSymbolAtLocation(source);
117
+ const exported = module && checker.getExportsOfModule(module).find(item => item.name === 'default');
118
+ const declaration = exported && (exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported).valueDeclaration;
119
+ const expression = declaration && (ts.isExportAssignment(declaration) ? value(declaration.expression) : ts.isVariableDeclaration(declaration) ? value(declaration.initializer) : declaration);
120
+ if (apiCall(expression, 'defineApiRoute', 'api'))
121
+ continue;
122
+ const warn = (message) => result.diagnostics.push({ code: 'FA1003', severity: options.strict ? 'error' : 'warning', message: `${expected}: ${message}`, ...position(declaration ?? source) });
123
+ if (!apiCall(expression, 'defineContractRoute', 'contract-route')) {
124
+ warn('Route export is not a statically resolvable defineContractRoute or defineApiRoute call; registration-time validation still applies to contract routes.');
125
+ continue;
126
+ }
127
+ const contract = value(expression.arguments[0]);
128
+ if (!apiCall(contract, 'defineApiContract', 'contracts')) {
129
+ warn('Contract is dynamic or unresolved; static correspondence cannot be verified.');
130
+ continue;
131
+ }
132
+ const definition = value(contract.arguments[0]);
133
+ const method = property(definition, 'method');
134
+ const path = property(definition, 'path');
135
+ if (!method || !path || !ts.isStringLiteralLike(method) || !ts.isStringLiteralLike(path)) {
136
+ warn('Contract method/path must resolve to string literals or const references.');
137
+ continue;
138
+ }
139
+ const actual = `${method.text} ${path.text}`;
140
+ if (actual !== expected)
141
+ result.diagnostics.push({ code: 'FA1002', severity: 'error', message: `File route ${expected}, contract ${actual}. Update the contract or rename/move the route file.`, ...position(expression.getSourceFile() === source ? expression : source), contract: position(contract) });
142
+ else
143
+ result.verified++;
144
+ syntaxFiles.add(contract.getSourceFile().fileName);
145
+ }
146
+ routes.forEach(route => syntaxFiles.add(resolve(root, 'src/server/api', route.file)));
147
+ for (const file of syntaxFiles) {
148
+ const source = program.getSourceFile(file);
149
+ if (source)
150
+ program.getSyntacticDiagnostics(source).forEach(configError);
151
+ }
152
+ result.diagnostics.sort((a, b) => (a.file ?? '').localeCompare(b.file ?? '') || (a.line ?? 0) - (b.line ?? 0) || a.code.localeCompare(b.code));
153
+ return result;
154
+ }
155
+ export function formatCheck(result) {
156
+ const lines = result.diagnostics.map(d => `${d.file ?? result.project}${d.line ? `:${d.line}:${d.column}` : ''} ${d.severity} ${d.code}: ${d.message}${d.contract ? `\n Contract: ${d.contract.file}:${d.contract.line}:${d.contract.column}` : ''}`);
157
+ lines.push(`FastAng routes: ${result.verified} contracts verified, ${result.routes} API routes, ${result.diagnostics.filter(d => d.severity === 'error').length} errors, ${result.diagnostics.filter(d => d.severity === 'warning').length} warnings.`);
158
+ return lines.join('\n');
159
+ }
package/dist/cli.js CHANGED
@@ -3,6 +3,8 @@ const command = process.argv[2];
3
3
  try {
4
4
  if (command === 'generate')
5
5
  await (await import("./routes.js")).generate();
6
+ else if (command === 'check')
7
+ await import("./commands/check.js");
6
8
  else if (command === 'routes')
7
9
  await import("./commands/inspect-routes.js");
8
10
  else if (command === 'dev')
@@ -10,7 +12,7 @@ try {
10
12
  else if (command === 'build')
11
13
  await import("./commands/build.js");
12
14
  else if (!command || command === '--help' || command === '-h')
13
- console.log('Usage: fastang <dev|generate|build|routes>\n build [--vercel]\n routes [--json]');
15
+ console.log('Usage: fastang <dev|generate|build|routes|check>\n build [--vercel]\n routes [--json]\n check [--project tsconfig.node.json] [--json] [--strict] [--quiet]');
14
16
  else
15
17
  throw new Error(`Unknown command: ${command}`);
16
18
  }
@@ -14,6 +14,12 @@ import { prepareVercelPlan } from "../adapters/vercel-plan.js";
14
14
  import { buildVercelOutput } from "../adapters/vercel.js";
15
15
  import { staticFile } from "../static-path.js";
16
16
  import { loadFastangConfig } from "./load-config.js";
17
+ const { checkRoutes, formatCheck } = await import("../check.js");
18
+ const check = await checkRoutes();
19
+ if (check.diagnostics.length)
20
+ console.log(formatCheck(check));
21
+ if (check.diagnostics.some(diagnostic => diagnostic.severity === 'error'))
22
+ throw new Error('FastAng route check failed');
17
23
  const config = await loadFastangConfig('build');
18
24
  const vercel = process.argv.includes('--vercel');
19
25
  const paths = [...new Set(config.prerender)];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import { checkRoutes, formatCheck } from "../check.js";
2
+ const args = process.argv.slice(3);
3
+ let project;
4
+ for (let index = 0; index < args.length; index++) {
5
+ if (args[index] === '--project' && args[index + 1] && !args[index + 1].startsWith('-'))
6
+ project = args[++index];
7
+ else if (!['--json', '--strict', '--quiet'].includes(args[index]))
8
+ throw new Error('Usage: fastang check [--project tsconfig.node.json] [--json] [--strict] [--quiet]');
9
+ }
10
+ const result = await checkRoutes({ project, strict: args.includes('--strict') });
11
+ if (args.includes('--json'))
12
+ console.log(JSON.stringify(result, null, 2));
13
+ else if (!args.includes('--quiet') || result.diagnostics.length)
14
+ console.log(formatCheck(result));
15
+ if (result.diagnostics.some(diagnostic => diagnostic.severity === 'error'))
16
+ process.exitCode = 1;
@@ -0,0 +1,10 @@
1
+ import type { LogLevel } from 'vite';
2
+ declare const levels: readonly ["trace", "debug", "info", "warn", "error", "fatal", "silent"];
3
+ type Level = typeof levels[number];
4
+ /** Development only: production servers keep their own logger configuration. */
5
+ export declare function developmentLogging(value?: string | undefined): {
6
+ level: Level;
7
+ vite: LogLevel;
8
+ banner: boolean;
9
+ };
10
+ export {};
@@ -0,0 +1,13 @@
1
+ const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent'];
2
+ /** Development only: production servers keep their own logger configuration. */
3
+ export function developmentLogging(value = process.env['FASTANG_LOG_LEVEL']) {
4
+ const level = value ?? 'warn';
5
+ if (!levels.includes(level))
6
+ throw new Error(`FASTANG_LOG_LEVEL must be one of: ${levels.join(', ')}`);
7
+ return {
8
+ level: level,
9
+ vite: ['trace', 'debug', 'info'].includes(level) ? 'info' : level === 'fatal' ? 'error' : level,
10
+ // Fastify already announces the address at info; preserve it at quieter levels.
11
+ banner: !['trace', 'debug', 'info'].includes(level),
12
+ };
13
+ }
@@ -6,16 +6,18 @@ import { listen } from "../listen.js";
6
6
  import { tsImport } from 'tsx/esm/api';
7
7
  import { resolve } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
9
+ import { developmentLogging } from "./dev-logging.js";
9
10
  import { loadFastangConfig } from "./load-config.js";
11
+ const logging = developmentLogging();
10
12
  await loadFastangConfig('serve');
11
13
  const { configureServer } = await tsImport(pathToFileURL(resolve('src/server/plugins.ts')).href, import.meta.url);
12
14
  const { registerApi } = await tsImport(pathToFileURL(resolve('.fastang/api.ts')).href, import.meta.url);
13
15
  let vite;
14
16
  try {
15
17
  const app = await createServer({
16
- logger: true, registerApi,
18
+ logger: { level: logging.level }, registerApi,
17
19
  async configure(server) {
18
- vite = await createViteServer({ server: { middlewareMode: true, hmr: { server: server.server } }, appType: 'custom' });
20
+ vite = await createViteServer({ logLevel: logging.vite, server: { middlewareMode: true, hmr: { server: server.server } }, appType: 'custom' });
19
21
  server.addHook('onClose', async () => { await vite?.close(); });
20
22
  await server.register(middie);
21
23
  // Middie gestisce next(), risposte completate ed errori del middleware Connect.
@@ -36,6 +38,13 @@ try {
36
38
  },
37
39
  });
38
40
  await listen(app);
41
+ if (logging.banner) {
42
+ const address = app.server.address();
43
+ if (address && typeof address !== 'string') {
44
+ const host = address.family === 'IPv6' ? `[${address.address}]` : address.address;
45
+ console.log(`Server listening at http://${host}:${address.port}`);
46
+ }
47
+ }
39
48
  }
40
49
  catch (error) {
41
50
  await vite?.close();
@@ -3,26 +3,27 @@ import { fileURLToPath } from 'node:url';
3
3
  import { watch } from 'node:fs';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { once } from 'node:events';
6
+ import { stopChild } from "./stop-child.js";
6
7
  let child;
7
8
  let stopping = false;
8
9
  let timer;
9
10
  let queue = Promise.resolve();
10
- async function stopChild() {
11
- if (child && child.exitCode === null && child.signalCode === null) {
12
- const exited = once(child, 'exit');
13
- child.kill('SIGTERM');
14
- await exited;
15
- }
16
- }
17
11
  async function restart() {
18
- await stopChild();
12
+ if (stopping)
13
+ return;
14
+ // Keep the previous server available for Vite diagnostics while an edit is invalid.
15
+ const checker = spawn(process.execPath, [fileURLToPath(new URL('../cli.js', import.meta.url)), 'check', '--quiet'], { stdio: 'inherit', env: process.env });
16
+ const [checked] = await once(checker, 'exit');
17
+ if (checked !== 0 || stopping)
18
+ return;
19
+ await stopChild(child);
19
20
  if (stopping)
20
21
  return;
21
22
  // Processo nuovo: anche le modifiche al generatore sono applicate al riavvio.
22
23
  const generator = spawn(process.execPath, [fileURLToPath(new URL('../cli.js', import.meta.url)), 'generate'], { stdio: 'inherit', env: process.env });
23
24
  const [code] = await once(generator, 'exit');
24
25
  if (code !== 0)
25
- throw new Error('Generazione route fallita');
26
+ throw new Error('Route generation failed');
26
27
  child = spawn(process.execPath, [fileURLToPath(new URL('./dev-server.js', import.meta.url))], { stdio: 'inherit', env: process.env });
27
28
  child.on('error', (error) => { console.error(error); process.exitCode = 1; });
28
29
  }
@@ -33,14 +34,15 @@ function changed() {
33
34
  }, 150);
34
35
  }
35
36
  // Solo sorgenti backend e configurazione: i moduli temporanei di Vite non causano riavvii.
36
- const watchers = ['src/server', 'src/shared', 'vite.config.ts', 'fastang.config.ts'].filter(existsSync).map(path => watch(path, { recursive: true }, changed));
37
+ const watchers = ['src/server', 'src/shared', 'vite.config.ts', 'fastang.config.ts', 'tsconfig.json', 'tsconfig.node.json'].filter(existsSync).map(path => watch(path, { recursive: true }, changed));
37
38
  async function shutdown() {
38
39
  stopping = true;
39
40
  clearTimeout(timer);
40
41
  watchers.forEach((watcher) => watcher.close());
41
42
  await queue;
42
- await stopChild();
43
+ await stopChild(child);
43
44
  }
44
45
  process.once('SIGINT', shutdown);
45
46
  process.once('SIGTERM', shutdown);
46
- await restart();
47
+ queue = restart();
48
+ await queue;
@@ -0,0 +1,6 @@
1
+ import type { ChildProcess } from 'node:child_process';
2
+ /** Give application shutdown hooks time to finish, then bound the supervisor wait. */
3
+ export declare function stopChild(child: ChildProcess | undefined, timeoutMs?: number, warn?: {
4
+ (...data: any[]): void;
5
+ (message?: any, ...optionalParams: any[]): void;
6
+ }): Promise<void>;
@@ -0,0 +1,22 @@
1
+ /** Give application shutdown hooks time to finish, then bound the supervisor wait. */
2
+ export async function stopChild(child, timeoutMs = 3000, warn = console.warn) {
3
+ if (!child || child.exitCode !== null || child.signalCode !== null)
4
+ return;
5
+ await new Promise((resolveStop, reject) => {
6
+ let timer;
7
+ const cleanup = () => {
8
+ clearTimeout(timer);
9
+ child.removeListener('exit', exited);
10
+ child.removeListener('error', failed);
11
+ };
12
+ const exited = () => { cleanup(); resolveStop(); };
13
+ const failed = (error) => { cleanup(); reject(error); };
14
+ child.once('exit', exited);
15
+ child.once('error', failed);
16
+ timer = setTimeout(() => {
17
+ warn(`Development server did not stop within ${timeoutMs}ms; forcing termination.`);
18
+ child.kill('SIGKILL');
19
+ }, timeoutMs);
20
+ child.kill('SIGTERM');
21
+ });
22
+ }
package/dist/listen.js CHANGED
@@ -4,7 +4,7 @@ export async function listen(app) {
4
4
  const port = Number(process.env['PORT'] ?? DEFAULT_PORT);
5
5
  const host = process.env['HOST'] ?? '127.0.0.1';
6
6
  if (!Number.isInteger(port) || port < 0 || port > 65535)
7
- throw new Error('PORT deve essere una porta valida');
7
+ throw new Error('PORT must be a valid port');
8
8
  const shutdown = async () => {
9
9
  try {
10
10
  await app.close();
package/dist/redirect.js CHANGED
@@ -1,13 +1,13 @@
1
1
  /** Solo destinazioni locali: gli header Host del client non decidono l'origine. */
2
2
  export function normalizeRedirectLocation(location) {
3
3
  if (!location.startsWith('/') || location.startsWith('//') || /[\\\u0000-\u0020\u007f]/.test(location)) {
4
- throw new Error('Destinazione redirect non valida');
4
+ throw new Error('Invalid redirect destination');
5
5
  }
6
6
  const target = new URL(location, 'http://fastang.local');
7
7
  if (target.origin !== 'http://fastang.local')
8
- throw new Error('Redirect esterno non consentito');
8
+ throw new Error('External redirects are not allowed');
9
9
  // La normalizzazione può trasformare /a/..//host in //host: ricontrolla l'output.
10
10
  if (target.pathname.startsWith('//'))
11
- throw new Error('Destinazione redirect ambigua');
11
+ throw new Error('Ambiguous redirect destination');
12
12
  return target.pathname + target.search + target.hash;
13
13
  }
package/dist/routes.js CHANGED
@@ -4,14 +4,14 @@ const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'optio
4
4
  export function fileRoute(file, kind) {
5
5
  const suffix = kind === 'page' ? '.page.ts' : '.ts';
6
6
  if (!file.endsWith(suffix))
7
- throw new Error(`Estensione non valida: ${file}`);
7
+ throw new Error(`Invalid extension: ${file}`);
8
8
  let stem = file.slice(0, -suffix.length);
9
9
  let method;
10
10
  if (kind === 'api') {
11
11
  const parts = stem.split('.');
12
12
  const candidate = parts.pop();
13
13
  if (!METHODS.has(candidate))
14
- throw new Error(`L'API ${file} deve specificare il metodo, es. health.get.ts`);
14
+ throw new Error(`API ${file} must specify a method, e.g. health.get.ts`);
15
15
  method = candidate.toUpperCase();
16
16
  stem = parts.join('.');
17
17
  }
@@ -27,7 +27,7 @@ export function fileRoute(file, kind) {
27
27
  const wildcard = /^\[([A-Za-z_][\w]*)\*\]$/.exec(segment);
28
28
  if (wildcard) {
29
29
  if (index !== normalized.length - 1)
30
- throw new Error(`Catch-all non terminale: ${file}`);
30
+ throw new Error(`Catch-all must be the final segment: ${file}`);
31
31
  paths.push(kind === 'page' ? '**' : '*');
32
32
  continue;
33
33
  }
@@ -37,13 +37,13 @@ export function fileRoute(file, kind) {
37
37
  else if (/^[\w-]+$/.test(segment))
38
38
  paths.push(segment);
39
39
  else
40
- throw new Error(`Segmento non supportato in ${file}: ${segment}`);
40
+ throw new Error(`Unsupported segment in ${file}: ${segment}`);
41
41
  }
42
42
  if (!segments.length)
43
- throw new Error(`Rotta vuota: ${file}`);
43
+ throw new Error(`Empty route: ${file}`);
44
44
  const params = paths.filter((part) => part.startsWith(':'));
45
45
  if (new Set(params).size !== params.length)
46
- throw new Error(`Parametro duplicato: ${file}`);
46
+ throw new Error(`Duplicate parameter: ${file}`);
47
47
  return { file, path: paths.join('/'), ...(method ? { method } : {}) };
48
48
  }
49
49
  export function sortAndValidate(routes) {
@@ -51,7 +51,7 @@ export function sortAndValidate(routes) {
51
51
  for (const route of routes) {
52
52
  const key = `${route.method ?? 'PAGE'} ${route.path.replace(/:[^/]+/g, ':')}`;
53
53
  if (seen.has(key))
54
- throw new Error(`Collisione tra ${seen.get(key)} e ${route.file}`);
54
+ throw new Error(`Route collision between ${seen.get(key)} and ${route.file}`);
55
55
  seen.set(key, route.file);
56
56
  }
57
57
  const weight = (segment) => segment.startsWith('*') ? 0 : segment.startsWith(':') ? 1 : 2;
@@ -110,7 +110,7 @@ function leaves(nodes) {
110
110
  export function buildPageTree(files) {
111
111
  const pages = sortAndValidate(files.filter((file) => file.endsWith('.page.ts')).map((file) => fileRoute(file, 'page')));
112
112
  if (pages.some((page) => /(^|\/)layout\.page\.ts$/.test(page.file))) {
113
- throw new Error('layout.page.ts è riservato: usa layout.ts per un layout o un altro nome per la pagina');
113
+ throw new Error('layout.page.ts is reserved: use layout.ts for a layout or another page name');
114
114
  }
115
115
  const layouts = files.filter((file) => /(^|\/)layout\.ts$/.test(file))
116
116
  .sort((a, b) => segments(a).length - segments(b).length || a.localeCompare(b));
@@ -135,10 +135,10 @@ export function buildPageTree(files) {
135
135
  // Non includere se stesso nella ricerca del layout genitore.
136
136
  const parent = parentFor(file);
137
137
  if (/\[\.\.\./.test(directory))
138
- throw new Error(`Layout sotto catch-all non supportato: ${file}`);
138
+ throw new Error(`Layouts below catch-all routes are not supported: ${file}`);
139
139
  const fullPath = fileRoute((directory ? directory + '/' : '') + 'index.page.ts', 'page').path;
140
140
  if (fullPath.includes('**'))
141
- throw new Error(`Layout sotto catch-all non supportato: ${file}`);
141
+ throw new Error(`Layouts below catch-all routes are not supported: ${file}`);
142
142
  const node = makeNode(file, fullPath, 'layout', parent?.node);
143
143
  (parent?.node.children ?? roots).push(node);
144
144
  parents.push({ directory, node });
@@ -152,7 +152,7 @@ export function buildPageTree(files) {
152
152
  }
153
153
  for (const file of files.filter((file) => file.endsWith('.route.ts'))) {
154
154
  if (!usedMetadata.has(file))
155
- throw new Error(`Metadati senza pagina o layout: ${file}`);
155
+ throw new Error(`Metadata has no matching page or layout: ${file}`);
156
156
  }
157
157
  const ranks = new Map(pages.map((page, index) => [page.file, index]));
158
158
  const rank = (node) => Math.min(...leaves([node]).map((page) => ranks.get(page.file)));
@@ -168,7 +168,7 @@ export function buildPageTree(files) {
168
168
  for (let i = 0; i < ordered.length; i++) {
169
169
  for (let j = i + 1; j < ordered.length; j++) {
170
170
  if (ranks.get(ordered[i].file) > ranks.get(ordered[j].file) && overlaps(ordered[i].fullPath, ordered[j].fullPath)) {
171
- throw new Error(`Ordine ambiguo tra ${ordered[i].file} e ${ordered[j].file}: sposta il layout su un antenato comune o separa i percorsi`);
171
+ throw new Error(`Ambiguous order between ${ordered[i].file} and ${ordered[j].file}: move the layout to a shared ancestor or separate the paths`);
172
172
  }
173
173
  }
174
174
  }
package/dist/server.js CHANGED
@@ -44,7 +44,7 @@ export async function createServer(options) {
44
44
  async dispatchApi(path, method, body, headers, dispatchOptions = {}) {
45
45
  path = normalizeRedirectLocation(path);
46
46
  if (!path.startsWith('/api/'))
47
- throw new Error('Solo API locali consentite');
47
+ throw new Error('Only local API paths are allowed');
48
48
  const forwarded = {};
49
49
  for (const name of ['cookie', 'authorization', 'x-request-label']) {
50
50
  const value = request.headers[name];
@@ -75,7 +75,7 @@ export async function createServer(options) {
75
75
  if (context.redirect) {
76
76
  const status = context.redirect.status ?? 302;
77
77
  if (![301, 302, 303, 307, 308].includes(status))
78
- throw new Error('Stato redirect non valido');
78
+ throw new Error('Invalid redirect status');
79
79
  const location = normalizeRedirectLocation(context.redirect.location);
80
80
  if (responseCookies.length)
81
81
  reply.header('set-cookie', responseCookies);
@@ -1,6 +1,6 @@
1
1
  export function staticFile(path) {
2
2
  if (!path.startsWith('/') || path.includes('//') || !/^\/[a-zA-Z0-9_/-]*$/.test(path) || path.split('/').includes('..')) {
3
- throw new Error(`Percorso di prerendering non supportato: ${path}`);
3
+ throw new Error(`Unsupported prerender path: ${path}`);
4
4
  }
5
5
  return `${path.replace(/^\/|\/$/g, '')}${path === '/' ? '' : '/'}index.html`;
6
6
  }
@@ -1,30 +1,74 @@
1
1
  import { generate } from "./routes.js";
2
+ import { resolve } from 'node:path';
3
+ import { checkRoutes, formatCheck } from "./check.js";
2
4
  export function isServerModule(id) {
3
5
  const path = id.replaceAll('\\', '/').split('?')[0];
4
6
  return /\/(src\/server|framework|\.fastang)\//.test(path)
5
7
  || /\.server\.[cm]?[jt]s$/.test(path)
6
- || /\/(?:@fastang\/core|packages\/core)\/(?:src|dist)\/(?:index|server|http-server|node-handler|listen|angular|api|contract-route|routes|vite|vite-plugin|cli|commands\/.*|adapters\/.*)\.[cm]?[jt]s$/.test(path);
8
+ || /\/(?:@fastang\/core|packages\/core)\/(?:src|dist)\/(?:index|server|http-server|node-handler|listen|angular|api|contract-route|check|routes|vite|vite-plugin|cli|commands\/.*|adapters\/.*)\.[cm]?[jt]s$/.test(path);
7
9
  }
8
10
  export function fastang() {
11
+ let root = process.cwd();
12
+ const dependencies = new Set();
9
13
  return {
14
+ configResolved(config) { root = config.root; },
10
15
  name: 'fastang',
11
16
  enforce: 'pre',
12
17
  transform(_code, id, options) {
13
18
  if (!options?.ssr && isServerModule(id))
14
- this.error(`Modulo server importato nel client: ${id}`);
19
+ this.error(`Server module imported into the client: ${id}`);
20
+ },
21
+ async buildStart() {
22
+ const result = await checkRoutes({ root, onFiles: files => files.forEach(file => dependencies.add(file.replaceAll('\\', '/'))) });
23
+ if (result.diagnostics.some(d => d.severity === 'error'))
24
+ this.error(formatCheck(result));
25
+ if (result.diagnostics.length)
26
+ this.warn(formatCheck(result));
27
+ await generate(root);
15
28
  },
16
- async buildStart() { await generate(); },
17
29
  configureServer(server) {
18
- // Full reload per cambiamenti della struttura; HMR Angular per i componenti.
19
30
  let pending = Promise.resolve();
31
+ let timer;
32
+ let closed = false;
20
33
  const update = (file) => {
21
- if (!/src\/(app\/pages|server\/api)\/.*\.ts$/.test(file.replaceAll('\\', '/')))
34
+ const path = file.replaceAll('\\', '/');
35
+ if (!(/src\/(?:server|shared)\/.*\.ts$/.test(path) || /tsconfig[^/]*\.json$/.test(path) || /src\/app\/pages\/.*\.ts$/.test(path) || dependencies.has(path)) || /\/(?:node_modules|dist|\.fastang)\//.test(path) || path.endsWith('/fastang.routes.ts'))
22
36
  return;
23
- pending = pending.then(() => generate()).then(() => {
24
- server.ws.send({ type: 'full-reload' });
25
- }).catch((error) => server.config.logger.error(String(error)));
37
+ clearTimeout(timer);
38
+ timer = setTimeout(() => {
39
+ pending = pending.then(async () => {
40
+ if (closed)
41
+ return;
42
+ const result = await checkRoutes({ root, onFiles: files => { files.forEach(file => dependencies.add(file.replaceAll('\\', '/'))); server.watcher.add(files); } });
43
+ if (closed)
44
+ return;
45
+ const message = formatCheck(result);
46
+ if (result.diagnostics.some(d => d.severity === 'error')) {
47
+ server.config.logger.error(message);
48
+ server.ws.send({ type: 'error', err: { message, stack: '', plugin: 'fastang' } });
49
+ }
50
+ else {
51
+ if (result.diagnostics.length)
52
+ server.config.logger.warn(message);
53
+ await generate(root);
54
+ server.ws.send({ type: 'full-reload' });
55
+ }
56
+ }).catch((error) => {
57
+ if (!closed) {
58
+ const message = String(error);
59
+ server.config.logger.error(message);
60
+ server.ws.send({ type: 'error', err: { message, stack: '', plugin: 'fastang' } });
61
+ }
62
+ });
63
+ }, 150);
26
64
  };
27
- server.watcher.on('add', update).on('unlink', update);
65
+ server.watcher.add([resolve(root, 'src/server'), resolve(root, 'src/shared'), ...dependencies]);
66
+ server.watcher.on('add', update).on('unlink', update).on('change', (file) => {
67
+ // Leave Angular page/component HMR to its native plugin.
68
+ if (!/src\/app\//.test(file.replaceAll('\\', '/')))
69
+ update(file);
70
+ });
71
+ server.watcher.once('close', () => { closed = true; clearTimeout(timer); });
28
72
  },
29
73
  };
30
74
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastang/core",
3
- "version": "0.1.0-alpha.3",
3
+ "version": "0.1.0-alpha.5",
4
4
  "type": "module",
5
5
  "description": "Angular rendering, Fastify APIs and file-based routing.",
6
6
  "license": "MIT",