@xacos/server 1.0.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/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/Logger.d.ts +11 -0
- package/dist/Logger.d.ts.map +1 -0
- package/dist/Logger.js +36 -0
- package/dist/Logger.js.map +1 -0
- package/dist/XServer.d.ts +20 -0
- package/dist/XServer.d.ts.map +1 -0
- package/dist/XServer.js +155 -0
- package/dist/XServer.js.map +1 -0
- package/dist/XServer.test.d.ts +2 -0
- package/dist/XServer.test.d.ts.map +1 -0
- package/dist/XServer.test.js +76 -0
- package/dist/XServer.test.js.map +1 -0
- package/dist/adapters/RequestAdapter.d.ts +6 -0
- package/dist/adapters/RequestAdapter.d.ts.map +1 -0
- package/dist/adapters/RequestAdapter.js +95 -0
- package/dist/adapters/RequestAdapter.js.map +1 -0
- package/dist/adapters/RequestAdapter.test.d.ts +2 -0
- package/dist/adapters/RequestAdapter.test.d.ts.map +1 -0
- package/dist/adapters/RequestAdapter.test.js +38 -0
- package/dist/adapters/RequestAdapter.test.js.map +1 -0
- package/dist/dev/mountVite.d.ts +3 -0
- package/dist/dev/mountVite.d.ts.map +1 -0
- package/dist/dev/mountVite.js +72 -0
- package/dist/dev/mountVite.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/routing/mountApiRoutes.d.ts +4 -0
- package/dist/routing/mountApiRoutes.d.ts.map +1 -0
- package/dist/routing/mountApiRoutes.js +136 -0
- package/dist/routing/mountApiRoutes.js.map +1 -0
- package/dist/static/serveStaticChunks.d.ts +7 -0
- package/dist/static/serveStaticChunks.d.ts.map +1 -0
- package/dist/static/serveStaticChunks.js +15 -0
- package/dist/static/serveStaticChunks.js.map +1 -0
- package/dist/views/buildHtmlShell.d.ts +14 -0
- package/dist/views/buildHtmlShell.d.ts.map +1 -0
- package/dist/views/buildHtmlShell.js +24 -0
- package/dist/views/buildHtmlShell.js.map +1 -0
- package/dist/views/buildHtmlShell.test.d.ts +2 -0
- package/dist/views/buildHtmlShell.test.d.ts.map +1 -0
- package/dist/views/buildHtmlShell.test.js +22 -0
- package/dist/views/buildHtmlShell.test.js.map +1 -0
- package/dist/views/mountViewRoutes.d.ts +7 -0
- package/dist/views/mountViewRoutes.d.ts.map +1 -0
- package/dist/views/mountViewRoutes.js +25 -0
- package/dist/views/mountViewRoutes.js.map +1 -0
- package/package.json +77 -0
- package/src/Logger.ts +45 -0
- package/src/XServer.test.ts +83 -0
- package/src/XServer.ts +172 -0
- package/src/adapters/RequestAdapter.test.ts +48 -0
- package/src/adapters/RequestAdapter.ts +108 -0
- package/src/dev/mountVite.ts +90 -0
- package/src/index.ts +9 -0
- package/src/routing/mountApiRoutes.ts +152 -0
- package/src/static/serveStaticChunks.ts +21 -0
- package/src/views/buildHtmlShell.test.ts +26 -0
- package/src/views/buildHtmlShell.ts +35 -0
- package/src/views/mountViewRoutes.ts +34 -0
package/src/XServer.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import Fastify, { FastifyInstance } from "fastify";
|
|
2
|
+
import type { XApp, XMiddleware } from "@xacos/shared";
|
|
3
|
+
import { mountApiRoutes } from "./routing/mountApiRoutes";
|
|
4
|
+
import cookie from "@fastify/cookie";
|
|
5
|
+
import multipart from "@fastify/multipart";
|
|
6
|
+
import formbody from "@fastify/formbody";
|
|
7
|
+
import cors from "@fastify/cors";
|
|
8
|
+
import rateLimit from "@fastify/rate-limit";
|
|
9
|
+
|
|
10
|
+
import type { XacosConfig } from "@xacos/config";
|
|
11
|
+
import { log } from "./Logger";
|
|
12
|
+
import { closeDb } from "@xacos/orm";
|
|
13
|
+
import pkg from "../package.json";
|
|
14
|
+
|
|
15
|
+
let shutdownRegistered = false;
|
|
16
|
+
|
|
17
|
+
export class XServer {
|
|
18
|
+
public readonly fastify: FastifyInstance;
|
|
19
|
+
private readonly globalMiddlewares: XMiddleware[] = [];
|
|
20
|
+
|
|
21
|
+
constructor() {
|
|
22
|
+
const isDev = process.env["APP_ENV"] !== "production";
|
|
23
|
+
this.fastify = Fastify({
|
|
24
|
+
bodyLimit: 10 * 1024 * 1024, // 10MB max body size
|
|
25
|
+
logger: {
|
|
26
|
+
level: process.env["APP_DEBUG"] === "true" || isDev ? "debug" : "info",
|
|
27
|
+
transport: isDev
|
|
28
|
+
? {
|
|
29
|
+
target: "pino-pretty",
|
|
30
|
+
options: {
|
|
31
|
+
translateTime: "HH:MM:ss Z",
|
|
32
|
+
ignore: "pid,hostname",
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
: undefined,
|
|
36
|
+
} as any,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
this.fastify.addHook('onSend', async (_request, reply) => {
|
|
40
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
41
|
+
reply.header('X-Frame-Options', 'SAMEORIGIN');
|
|
42
|
+
reply.header('X-XSS-Protection', '1; mode=block');
|
|
43
|
+
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
44
|
+
if (process.env['APP_ENV'] === 'production') {
|
|
45
|
+
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
void this.fastify.register(cookie as any);
|
|
50
|
+
void this.fastify.register(formbody as any);
|
|
51
|
+
const metaSym = Symbol.for('plugin-meta');
|
|
52
|
+
if ((multipart as any)[metaSym]) {
|
|
53
|
+
(multipart as any)[metaSym].fastify = '4.x';
|
|
54
|
+
}
|
|
55
|
+
void this.fastify.register(multipart as any, {
|
|
56
|
+
limits: {
|
|
57
|
+
fileSize: 10 * 1024 * 1024, // 10MB default
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.setupErrorHandler();
|
|
62
|
+
this.setupHealthCheck();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private setupHealthCheck() {
|
|
66
|
+
this.fastify.get("/health", async (_req, reply) => {
|
|
67
|
+
return reply.send({
|
|
68
|
+
status: "ok",
|
|
69
|
+
uptime: process.uptime(),
|
|
70
|
+
memory: process.memoryUsage(),
|
|
71
|
+
version: pkg.version,
|
|
72
|
+
env: process.env["APP_ENV"] ?? "development",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async bootstrap(config: XacosConfig): Promise<void> {
|
|
78
|
+
// ── CORS ────────────────────────────────────────────────────────────────
|
|
79
|
+
await this.fastify.register(cors as any, {
|
|
80
|
+
origin: config.cors?.origin ?? true,
|
|
81
|
+
credentials: config.cors?.credentials ?? true,
|
|
82
|
+
methods: config.cors?.methods ?? ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── Rate Limit ──────────────────────────────────────────────────────────
|
|
86
|
+
await this.fastify.register(rateLimit as any, {
|
|
87
|
+
max: config.rateLimit?.max ?? 100,
|
|
88
|
+
timeWindow: config.rateLimit?.timeWindow ?? "1 minute",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private setupErrorHandler() {
|
|
93
|
+
this.fastify.setErrorHandler((error, request, reply) => {
|
|
94
|
+
const statusCode = error.statusCode ?? 500;
|
|
95
|
+
const isDev = process.env["APP_ENV"] !== "production";
|
|
96
|
+
|
|
97
|
+
let code = (error as any).code;
|
|
98
|
+
if (!code) {
|
|
99
|
+
if (statusCode === 422 || error.name === "ValidationError") code = "VALIDATION_FAILED";
|
|
100
|
+
else if (statusCode === 404) code = "NOT_FOUND";
|
|
101
|
+
else if (statusCode === 401) code = "UNAUTHORIZED";
|
|
102
|
+
else if (statusCode === 403) code = "FORBIDDEN";
|
|
103
|
+
else if (statusCode === 400) code = "BAD_REQUEST";
|
|
104
|
+
else code = "INTERNAL_ERROR";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Structured logging via Pino per error
|
|
108
|
+
log.error(`[XServer Error] ${error.message}`, {
|
|
109
|
+
url: request.url,
|
|
110
|
+
method: request.method,
|
|
111
|
+
statusCode,
|
|
112
|
+
code,
|
|
113
|
+
stack: isDev ? error.stack : undefined,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
request.log.error(error);
|
|
117
|
+
|
|
118
|
+
void reply.status(statusCode).send({
|
|
119
|
+
success: false,
|
|
120
|
+
error: {
|
|
121
|
+
message: error.message || "Internal Server Error",
|
|
122
|
+
code,
|
|
123
|
+
details: (error as any).details || (error as any).errors,
|
|
124
|
+
stack: isDev ? error.stack : undefined,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
useGlobal(middleware: XMiddleware): void {
|
|
131
|
+
this.globalMiddlewares.push(middleware);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
getGlobalMiddlewares(): readonly XMiddleware[] {
|
|
135
|
+
return [...this.globalMiddlewares];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Scan `apiDir` for `*.api.ts` modules and register routes on this Fastify instance.
|
|
140
|
+
*/
|
|
141
|
+
async mountApi(apiDir: string, app: XApp): Promise<void> {
|
|
142
|
+
await mountApiRoutes(this.fastify, apiDir, app);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async listen(port: number): Promise<void> {
|
|
146
|
+
await this.fastify.listen({ port, host: "0.0.0.0" });
|
|
147
|
+
console.log(`\n XAOCS running at http://localhost:${port}\n`);
|
|
148
|
+
|
|
149
|
+
if (!shutdownRegistered && process.env["APP_ENV"] !== "test") {
|
|
150
|
+
shutdownRegistered = true;
|
|
151
|
+
const gracefulShutdown = async (signal: string) => {
|
|
152
|
+
log.info(`Received ${signal} — shutting down gracefully...`);
|
|
153
|
+
try {
|
|
154
|
+
await this.close();
|
|
155
|
+
await closeDb();
|
|
156
|
+
process.exit(0);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
log.error(`Error during graceful shutdown: ${(err as Error).message}`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
process.on("SIGTERM", () => void gracefulShutdown("SIGTERM"));
|
|
164
|
+
process.on("SIGINT", () => void gracefulShutdown("SIGINT"));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async close(): Promise<void> {
|
|
169
|
+
await this.fastify.close();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
3
|
+
import { adaptRequest, adaptResponse } from "./RequestAdapter";
|
|
4
|
+
|
|
5
|
+
describe("RequestAdapter", () => {
|
|
6
|
+
it("adapts request shape", () => {
|
|
7
|
+
const req = {
|
|
8
|
+
params: { id: "42" },
|
|
9
|
+
query: { q: "ok" },
|
|
10
|
+
body: { x: 1 },
|
|
11
|
+
headers: { "x-test": "1" },
|
|
12
|
+
} as unknown as FastifyRequest;
|
|
13
|
+
|
|
14
|
+
const app = {
|
|
15
|
+
make: <T>(_key: string) => ({}) as T,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const adapted = adaptRequest(req, app);
|
|
19
|
+
|
|
20
|
+
expect(adapted.params["id"]).toBe("42");
|
|
21
|
+
expect(adapted.query["q"]).toBe("ok");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("adapts response shape", () => {
|
|
25
|
+
let statusCode = 200;
|
|
26
|
+
let payload: unknown;
|
|
27
|
+
|
|
28
|
+
const reply = {
|
|
29
|
+
code(code: number) {
|
|
30
|
+
statusCode = code;
|
|
31
|
+
return this;
|
|
32
|
+
},
|
|
33
|
+
send(data: unknown) {
|
|
34
|
+
payload = data;
|
|
35
|
+
return this;
|
|
36
|
+
},
|
|
37
|
+
redirect(_url: string) {},
|
|
38
|
+
} as unknown as FastifyReply;
|
|
39
|
+
|
|
40
|
+
const adapted = adaptResponse(reply);
|
|
41
|
+
|
|
42
|
+
adapted.status(201).json({ ok: true });
|
|
43
|
+
|
|
44
|
+
expect(statusCode).toBe(201);
|
|
45
|
+
expect((adapted as any)._payload).toEqual({ ok: true });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
2
|
+
import type { CookieOptions, XApp, XRequest, XResponse } from "@xacos/shared";
|
|
3
|
+
import "@fastify/multipart";
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
export function adaptRequest(req: FastifyRequest, app: XApp): XRequest {
|
|
7
|
+
const xReq: XRequest = {
|
|
8
|
+
ip: req.ip,
|
|
9
|
+
params: (req.params as Record<string, string>) ?? {},
|
|
10
|
+
query: (req.query as Record<string, string>) ?? {},
|
|
11
|
+
body: req.body,
|
|
12
|
+
headers: req.headers as Record<string, string | string[] | undefined>,
|
|
13
|
+
cookies: ((req as any).cookies as Record<string, string> | undefined) ?? {},
|
|
14
|
+
app,
|
|
15
|
+
validate(schema) {
|
|
16
|
+
const result = schema.safeParse(this.body);
|
|
17
|
+
if (!result.success) {
|
|
18
|
+
const error: any = new Error('Validation failed');
|
|
19
|
+
error.statusCode = 422;
|
|
20
|
+
error.code = 'VALIDATION_ERROR';
|
|
21
|
+
error.details = result.error.flatten().fieldErrors;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
return result.data;
|
|
25
|
+
},
|
|
26
|
+
async file(name: string) {
|
|
27
|
+
if (!(req as any).isMultipart()) return undefined;
|
|
28
|
+
|
|
29
|
+
const fileIter = (req as any).files();
|
|
30
|
+
for await (const part of fileIter) {
|
|
31
|
+
if (part.fieldname === name) {
|
|
32
|
+
return createXFile(part, app);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
},
|
|
37
|
+
async files(name?: string) {
|
|
38
|
+
if (!(req as any).isMultipart()) return [];
|
|
39
|
+
|
|
40
|
+
const results = [];
|
|
41
|
+
const fileIter = (req as any).files();
|
|
42
|
+
for await (const part of fileIter) {
|
|
43
|
+
if (!name || part.fieldname === name) {
|
|
44
|
+
results.push(createXFile(part, app));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return results;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
return xReq;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createXFile(part: any, app: XApp) {
|
|
54
|
+
return {
|
|
55
|
+
fieldname: part.fieldname,
|
|
56
|
+
filename: part.filename,
|
|
57
|
+
mimetype: part.mimetype,
|
|
58
|
+
encoding: part.encoding,
|
|
59
|
+
async getBuffer() {
|
|
60
|
+
return await part.toBuffer();
|
|
61
|
+
},
|
|
62
|
+
async store(path: string) {
|
|
63
|
+
const buf = await part.toBuffer();
|
|
64
|
+
const storage = app.make<any>('storage');
|
|
65
|
+
await storage.put(path, buf);
|
|
66
|
+
return path;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
export function adaptResponse(reply: FastifyReply): XResponse {
|
|
75
|
+
const res: any = {
|
|
76
|
+
_payload: undefined,
|
|
77
|
+
setHeader(name: string, value: string) {
|
|
78
|
+
reply.header(name, value);
|
|
79
|
+
return res;
|
|
80
|
+
},
|
|
81
|
+
json(data: unknown) {
|
|
82
|
+
this._payload = data;
|
|
83
|
+
return res;
|
|
84
|
+
},
|
|
85
|
+
status(code: number) {
|
|
86
|
+
reply.code(code);
|
|
87
|
+
return res;
|
|
88
|
+
},
|
|
89
|
+
send(data?: string) {
|
|
90
|
+
this._payload = data ?? "";
|
|
91
|
+
return res;
|
|
92
|
+
},
|
|
93
|
+
redirect(url: string) {
|
|
94
|
+
reply.redirect(url);
|
|
95
|
+
},
|
|
96
|
+
setCookie(name: string, value: string, opts?: CookieOptions) {
|
|
97
|
+
const cookieReply = reply as FastifyReply & {
|
|
98
|
+
setCookie?: (cookieName: string, cookieValue: string, cookieOpts?: CookieOptions) => void;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
cookieReply.setCookie?.(name, value, opts);
|
|
102
|
+
return res;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return res;
|
|
107
|
+
}
|
|
108
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { createViteConfig, ViewScanner } from '@xacos/compiler';
|
|
3
|
+
|
|
4
|
+
import { createServer as createViteServer, type InlineConfig } from 'vite';
|
|
5
|
+
|
|
6
|
+
export async function mountViteDev(
|
|
7
|
+
fastify: FastifyInstance,
|
|
8
|
+
viewsDir: string,
|
|
9
|
+
root: string
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
const scanner = new ViewScanner(viewsDir);
|
|
12
|
+
const routes = scanner.scan();
|
|
13
|
+
|
|
14
|
+
// Register routes in Fastify so it knows about them,
|
|
15
|
+
// even though Vite middleware will actually serve them.
|
|
16
|
+
for (const route of routes) {
|
|
17
|
+
fastify.get(route.path, async (request, reply) => {
|
|
18
|
+
const mod = await vite.ssrLoadModule(route.filePath);
|
|
19
|
+
|
|
20
|
+
if (mod['ssr'] === true) {
|
|
21
|
+
const Component = mod.default;
|
|
22
|
+
if (typeof Component !== 'function') {
|
|
23
|
+
throw new Error(`[XAOCS] Page at ${route.filePath} does not have a default export component`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// In dev mode, we still let Vite handle the HTML shell usually,
|
|
27
|
+
// but for a true SSR opt-in, we might want to render the component here.
|
|
28
|
+
// For now, following the sprint goal: render to string if ssr=true.
|
|
29
|
+
const { renderSSR } = await import('@xacos/compiler');
|
|
30
|
+
const { html, meta } = await renderSSR(route.filePath, {
|
|
31
|
+
params: request.params as Record<string, string>,
|
|
32
|
+
query: request.query as Record<string, string>,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// We need a basic HTML shell. For now, we'll return the rendered HTML.
|
|
36
|
+
// Real implementation would inject into a template.
|
|
37
|
+
reply.type('text/html').send(`
|
|
38
|
+
<!DOCTYPE html>
|
|
39
|
+
<html>
|
|
40
|
+
<head>
|
|
41
|
+
<title>${meta.title ?? 'XAOCS App'}</title>
|
|
42
|
+
${meta.description ? `<meta name="description" content="${meta.description}">` : ''}
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<div id="root">${html}</div>
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
|
48
|
+
`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The onRequest hook handles the actual Vite forwarding for non-SSR.
|
|
53
|
+
return reply.callNotFound();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
const config = createViteConfig(viewsDir, root) as unknown as InlineConfig;
|
|
59
|
+
const vite = await createViteServer(config);
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
// Forward all non-API requests to Vite in dev mode
|
|
64
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
65
|
+
const url = request.raw.url ?? '/';
|
|
66
|
+
// Skip API routes — those are handled by XAOCS API router
|
|
67
|
+
if (url.startsWith('/api/')) return;
|
|
68
|
+
|
|
69
|
+
// Use Vite's connect-style middleware
|
|
70
|
+
return new Promise<void>((resolve, reject) => {
|
|
71
|
+
vite.middlewares(request.raw, reply.raw, (err: unknown) => {
|
|
72
|
+
|
|
73
|
+
if (err) {
|
|
74
|
+
reject(err);
|
|
75
|
+
} else {
|
|
76
|
+
// If Vite doesn't handle it, it will call next()
|
|
77
|
+
// We don't really have a 'next' in Fastify onRequest hook in this way,
|
|
78
|
+
// but resolve() lets Fastify continue.
|
|
79
|
+
resolve();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Handle graceful shutdown
|
|
86
|
+
fastify.addHook('onClose', async () => {
|
|
87
|
+
await vite.close();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { XServer } from "./XServer";
|
|
2
|
+
export { adaptRequest, adaptResponse } from "./adapters/RequestAdapter";
|
|
3
|
+
export { mountApiRoutes } from "./routing/mountApiRoutes";
|
|
4
|
+
export { mountViteDev } from "./dev/mountVite";
|
|
5
|
+
export { mountViewRoutes } from "./views/mountViewRoutes";
|
|
6
|
+
export { serveStaticChunks } from "./static/serveStaticChunks";
|
|
7
|
+
export { buildHtmlShell, type HtmlShellOptions } from "./views/buildHtmlShell";
|
|
8
|
+
export { Logger, log } from "./Logger";
|
|
9
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { FastifyInstance } from "fastify";
|
|
4
|
+
import type { XApp } from "@xacos/shared";
|
|
5
|
+
import { scanApiFiles, compose, invalidateScanCache } from "@xacos/core";
|
|
6
|
+
import { adaptRequest, adaptResponse } from "../adapters/RequestAdapter";
|
|
7
|
+
import { validate, ValidationError } from "@xacos/validation";
|
|
8
|
+
import { log } from "../Logger";
|
|
9
|
+
|
|
10
|
+
interface ActiveRouteMeta {
|
|
11
|
+
route: any;
|
|
12
|
+
pipeline: any;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// WeakMap scoping route registries per Fastify instance
|
|
16
|
+
const serverRoutesRegistry = new WeakMap<FastifyInstance, Map<string, ActiveRouteMeta>>();
|
|
17
|
+
// Track if chokidar watcher is already initialized for this fastify instance
|
|
18
|
+
const activeWatchers = new WeakSet<FastifyInstance>();
|
|
19
|
+
|
|
20
|
+
export async function mountApiRoutes(
|
|
21
|
+
fastify: FastifyInstance,
|
|
22
|
+
apiDir: string,
|
|
23
|
+
app: XApp,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
let activeRoutesRegistry = serverRoutesRegistry.get(fastify);
|
|
26
|
+
if (!activeRoutesRegistry) {
|
|
27
|
+
activeRoutesRegistry = new Map<string, ActiveRouteMeta>();
|
|
28
|
+
serverRoutesRegistry.set(fastify, activeRoutesRegistry);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
invalidateScanCache();
|
|
32
|
+
const files = scanApiFiles(apiDir);
|
|
33
|
+
|
|
34
|
+
for (const { filePath, urlPrefix } of files) {
|
|
35
|
+
try {
|
|
36
|
+
const mod = await import(pathToFileURL(filePath).href);
|
|
37
|
+
const router = mod?.router;
|
|
38
|
+
if (!router) continue;
|
|
39
|
+
|
|
40
|
+
for (const route of router.routes) {
|
|
41
|
+
const routePath = route.path === "/" ? "" : route.path;
|
|
42
|
+
const fullPath = `${urlPrefix}${routePath}`.replace(/\/+/g, "/");
|
|
43
|
+
const pipeline = route.middlewares && route.middlewares.length ? compose(route.middlewares) : null;
|
|
44
|
+
const routeKey = `${route.method}:${fullPath}`;
|
|
45
|
+
|
|
46
|
+
if (activeRoutesRegistry.has(routeKey)) {
|
|
47
|
+
activeRoutesRegistry.set(routeKey, { route, pipeline });
|
|
48
|
+
} else {
|
|
49
|
+
activeRoutesRegistry.set(routeKey, { route, pipeline });
|
|
50
|
+
|
|
51
|
+
fastify.route({
|
|
52
|
+
method: route.method,
|
|
53
|
+
url: fullPath,
|
|
54
|
+
handler: async (request, reply) => {
|
|
55
|
+
// Ensure we retrieve from the instance-scoped registry at request time
|
|
56
|
+
const registry = serverRoutesRegistry.get(fastify);
|
|
57
|
+
const meta = registry?.get(routeKey);
|
|
58
|
+
if (!meta) {
|
|
59
|
+
void reply.code(404);
|
|
60
|
+
return { error: "Route not found" };
|
|
61
|
+
}
|
|
62
|
+
const currentRoute = meta.route;
|
|
63
|
+
const currentPipeline = meta.pipeline;
|
|
64
|
+
|
|
65
|
+
const req = adaptRequest(request, app);
|
|
66
|
+
const res = adaptResponse(reply);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
if (currentRoute.schema) {
|
|
70
|
+
if (currentRoute.schema.body) validate(req.body, currentRoute.schema.body);
|
|
71
|
+
if (currentRoute.schema.query) validate(req.query as unknown, currentRoute.schema.query);
|
|
72
|
+
if (currentRoute.schema.params) validate(req.params as unknown, currentRoute.schema.params);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (currentPipeline) {
|
|
76
|
+
await currentPipeline(req, res);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await currentRoute.handler(req, res);
|
|
80
|
+
if ((res as unknown as { _payload?: unknown })._payload !== undefined) {
|
|
81
|
+
return (res as unknown as { _payload: unknown })._payload;
|
|
82
|
+
}
|
|
83
|
+
} catch (e) {
|
|
84
|
+
if (e instanceof ValidationError) {
|
|
85
|
+
void reply.code(e.statusCode);
|
|
86
|
+
return { errors: e.errors };
|
|
87
|
+
}
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error("IMPORT ERROR:", err);
|
|
96
|
+
log.error(`Failed to mount API file ${filePath}: ${(err as Error).message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (process.env["APP_ENV"] !== "production" && !activeWatchers.has(fastify)) {
|
|
101
|
+
activeWatchers.add(fastify);
|
|
102
|
+
try {
|
|
103
|
+
const chokidar = await import("chokidar");
|
|
104
|
+
const watcher = chokidar.watch([apiDir, resolve(apiDir, "../app")], {
|
|
105
|
+
ignoreInitial: true,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
fastify.addHook("onClose", async () => {
|
|
109
|
+
await watcher.close();
|
|
110
|
+
activeWatchers.delete(fastify);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
watcher.on("all", (event, path) => {
|
|
114
|
+
log.info(`[HMR] Detected change in ${path} (${event})`);
|
|
115
|
+
void (async () => {
|
|
116
|
+
try {
|
|
117
|
+
const registry = serverRoutesRegistry.get(fastify);
|
|
118
|
+
if (!registry) return;
|
|
119
|
+
|
|
120
|
+
invalidateScanCache();
|
|
121
|
+
const updatedFiles = scanApiFiles(apiDir);
|
|
122
|
+
for (const { filePath, urlPrefix } of updatedFiles) {
|
|
123
|
+
const mod = await import(pathToFileURL(filePath).href + "?t=" + Date.now());
|
|
124
|
+
const router = mod?.router;
|
|
125
|
+
if (!router) continue;
|
|
126
|
+
|
|
127
|
+
for (const route of router.routes) {
|
|
128
|
+
const routePath = route.path === "/" ? "" : route.path;
|
|
129
|
+
const fullPath = `${urlPrefix}${routePath}`.replace(/\/+/g, "/");
|
|
130
|
+
const pipeline = route.middlewares && route.middlewares.length ? compose(route.middlewares) : null;
|
|
131
|
+
const routeKey = `${route.method}:${fullPath}`;
|
|
132
|
+
|
|
133
|
+
if (registry.has(routeKey)) {
|
|
134
|
+
registry.set(routeKey, { route, pipeline });
|
|
135
|
+
log.info(`[HMR] Hot-swapped route ${route.method} ${fullPath}`);
|
|
136
|
+
} else {
|
|
137
|
+
registry.set(routeKey, { route, pipeline });
|
|
138
|
+
log.info(`[HMR] Registered new route ${route.method} ${fullPath}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
log.error(`[HMR] Failed to hot-reload routes: ${(err as Error).message}`);
|
|
144
|
+
}
|
|
145
|
+
})();
|
|
146
|
+
});
|
|
147
|
+
} catch (err) {
|
|
148
|
+
log.error(`[HMR] Failed to initialize chokidar: ${(err as Error).message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Serves production view bundles generated by esbuild.
|
|
6
|
+
* Chunks are served under the /__xacos/ prefix.
|
|
7
|
+
*/
|
|
8
|
+
export async function serveStaticChunks(
|
|
9
|
+
fastify: FastifyInstance,
|
|
10
|
+
chunksDir: string
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
// @ts-ignore - Dynamic import of @fastify/static
|
|
13
|
+
const fastifyStatic = (await import('@fastify/static')).default;
|
|
14
|
+
|
|
15
|
+
await fastify.register(fastifyStatic, {
|
|
16
|
+
root: resolve(chunksDir),
|
|
17
|
+
prefix: '/__xacos/',
|
|
18
|
+
decorateReply: false, // Avoid collision if already registered
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
|
+
import { buildHtmlShell } from './buildHtmlShell';
|
|
3
|
+
|
|
4
|
+
describe('buildHtmlShell', () => {
|
|
5
|
+
it('injects chunk URL and meta tags', () => {
|
|
6
|
+
const html = buildHtmlShell({
|
|
7
|
+
chunkUrl: '/__xacos/home.js',
|
|
8
|
+
meta: { title: 'Home', description: 'Testing' }
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
expect(html).toContain('<title>Home</title>');
|
|
12
|
+
expect(html).toContain('description" content="Testing"');
|
|
13
|
+
expect(html).toContain('src="/__xacos/home.js"');
|
|
14
|
+
expect(html).toContain('<div id="root"></div>');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('injects SSR HTML into root div', () => {
|
|
18
|
+
const html = buildHtmlShell({
|
|
19
|
+
chunkUrl: '/__xacos/home.js',
|
|
20
|
+
ssrHtml: '<h1>SSR Content</h1>'
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(html).toContain('<div id="root"><h1>SSR Content</h1></div>');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface HtmlShellOptions {
|
|
2
|
+
chunkUrl: string; // e.g. /__xacos/index.js
|
|
3
|
+
ssrHtml?: string; // pre-rendered HTML (SSR only)
|
|
4
|
+
meta?: {
|
|
5
|
+
title?: string | undefined;
|
|
6
|
+
description?: string | undefined;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate a complete HTML shell for a page.
|
|
12
|
+
* Wires the page-specific JS chunk into the <script> tag.
|
|
13
|
+
*/
|
|
14
|
+
export function buildHtmlShell(options: HtmlShellOptions): string {
|
|
15
|
+
const { chunkUrl, ssrHtml = '', meta = {} } = options;
|
|
16
|
+
|
|
17
|
+
const headTags = [
|
|
18
|
+
`<title>${meta.title ?? 'XAOCS App'}</title>`,
|
|
19
|
+
meta.description ? `<meta name="description" content="${meta.description}" />` : '',
|
|
20
|
+
`<script type="module" src="${chunkUrl}"></script>`,
|
|
21
|
+
].filter(Boolean).join('\n ');
|
|
22
|
+
|
|
23
|
+
return `<!DOCTYPE html>
|
|
24
|
+
<html lang="en">
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="UTF-8" />
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
28
|
+
${headTags}
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<div id="root">${ssrHtml}</div>
|
|
32
|
+
</body>
|
|
33
|
+
</html>`;
|
|
34
|
+
}
|
|
35
|
+
|