@simplysm/service-server 13.0.69 → 13.0.70
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 +48 -249
- package/dist/auth/jwt-manager.js +2 -2
- package/dist/auth/jwt-manager.js.map +1 -1
- package/dist/core/define-service.js +2 -2
- package/dist/core/define-service.js.map +1 -1
- package/dist/core/service-executor.js +5 -5
- package/dist/core/service-executor.js.map +1 -1
- package/dist/legacy/v1-auto-update-handler.d.ts +2 -2
- package/dist/legacy/v1-auto-update-handler.js +2 -2
- package/dist/legacy/v1-auto-update-handler.js.map +1 -1
- package/dist/service-server.js +11 -11
- package/dist/service-server.js.map +1 -1
- package/dist/services/auto-update-service.js +1 -1
- package/dist/services/auto-update-service.js.map +1 -1
- package/dist/services/orm-service.js +6 -6
- package/dist/services/orm-service.js.map +1 -1
- package/dist/transport/http/http-request-handler.js +1 -1
- package/dist/transport/http/http-request-handler.js.map +1 -1
- package/dist/transport/http/static-file-handler.js +3 -3
- package/dist/transport/http/upload-handler.js +2 -2
- package/dist/transport/http/upload-handler.js.map +1 -1
- package/dist/transport/socket/service-socket.js +2 -2
- package/dist/transport/socket/service-socket.js.map +1 -1
- package/dist/transport/socket/websocket-handler.d.ts.map +1 -1
- package/dist/transport/socket/websocket-handler.js +11 -9
- package/dist/transport/socket/websocket-handler.js.map +1 -1
- package/dist/utils/config-manager.js +7 -7
- package/dist/utils/config-manager.js.map +1 -1
- package/package.json +9 -9
- package/src/auth/jwt-manager.ts +2 -2
- package/src/core/define-service.ts +2 -2
- package/src/core/service-executor.ts +13 -13
- package/src/legacy/v1-auto-update-handler.ts +8 -8
- package/src/service-server.ts +28 -28
- package/src/services/auto-update-service.ts +1 -1
- package/src/services/orm-service.ts +6 -6
- package/src/transport/http/http-request-handler.ts +5 -5
- package/src/transport/http/static-file-handler.ts +7 -7
- package/src/transport/http/upload-handler.ts +3 -3
- package/src/transport/socket/service-socket.ts +4 -4
- package/src/transport/socket/websocket-handler.ts +12 -10
- package/src/utils/config-manager.ts +11 -11
- package/tests/define-service.spec.ts +85 -0
- package/tests/orm-service.spec.ts +83 -0
- package/tests/service-executor.spec.ts +114 -0
- package/docs/authentication.md +0 -114
- package/docs/built-in-services.md +0 -100
- package/docs/server.md +0 -374
- package/docs/transport.md +0 -273
package/src/service-server.ts
CHANGED
|
@@ -37,8 +37,8 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
|
|
|
37
37
|
constructor(readonly options: ServiceServerOptions) {
|
|
38
38
|
super();
|
|
39
39
|
|
|
40
|
-
// SSL
|
|
41
|
-
// Note: Fastify HTTPS
|
|
40
|
+
// SSL configuration (synchronous)
|
|
41
|
+
// Note: Fastify HTTPS requires Buffer type (Uint8Array not directly usable)
|
|
42
42
|
const httpsConf = options.ssl
|
|
43
43
|
? { pfx: Buffer.from(options.ssl.pfxBytes), passphrase: options.ssl.passphrase }
|
|
44
44
|
: null;
|
|
@@ -52,12 +52,12 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
async listen(): Promise<void> {
|
|
55
|
-
logger.info(
|
|
55
|
+
logger.info(`Server starting... ${env.VER ?? ""}`);
|
|
56
56
|
|
|
57
|
-
//
|
|
57
|
+
// WebSocket plugin
|
|
58
58
|
await this.fastify.register(fastifyWebsocket);
|
|
59
59
|
|
|
60
|
-
//
|
|
60
|
+
// Security plugin
|
|
61
61
|
await this.fastify.register(fastifyHelmet, {
|
|
62
62
|
global: true,
|
|
63
63
|
contentSecurityPolicy: {
|
|
@@ -78,17 +78,17 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
|
|
|
78
78
|
originAgentCluster: false,
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
//
|
|
81
|
+
// Upload plugin
|
|
82
82
|
await this.fastify.register(fastifyMultipart);
|
|
83
83
|
|
|
84
|
-
// @fastify/static
|
|
84
|
+
// Register @fastify/static
|
|
85
85
|
await this.fastify.register(fastifyStatic, {
|
|
86
86
|
root: path.resolve(this.options.rootPath, "www"),
|
|
87
87
|
wildcard: false,
|
|
88
88
|
serve: false,
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
// CORS
|
|
91
|
+
// CORS configuration
|
|
92
92
|
await this.fastify.register(fastifyCors, {
|
|
93
93
|
origin: (_origin, cb) => {
|
|
94
94
|
cb(null, true);
|
|
@@ -97,7 +97,7 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
|
|
|
97
97
|
exposedHeaders: ["Content-Disposition", "Content-Length"],
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
// JSON
|
|
100
|
+
// JSON parser
|
|
101
101
|
this.fastify.addContentTypeParser(
|
|
102
102
|
"application/json",
|
|
103
103
|
{ parseAs: "string" },
|
|
@@ -113,22 +113,22 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
|
|
|
113
113
|
},
|
|
114
114
|
);
|
|
115
115
|
|
|
116
|
-
// JSON
|
|
116
|
+
// JSON serializer
|
|
117
117
|
this.fastify.setSerializerCompiler(() => (data) => jsonStringify(data));
|
|
118
118
|
|
|
119
|
-
// API
|
|
119
|
+
// API routes
|
|
120
120
|
this.fastify.all("/api/:service/:method", async (req, reply) => {
|
|
121
121
|
await handleHttpRequest(req, reply, this.options.auth?.jwtSecret, (def) =>
|
|
122
122
|
runServiceMethod(this, def),
|
|
123
123
|
);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
//
|
|
126
|
+
// Upload route
|
|
127
127
|
this.fastify.all("/upload", async (req, reply) => {
|
|
128
128
|
await handleUpload(req, reply, this.options.rootPath, this.options.auth?.jwtSecret);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
// WebSocket
|
|
131
|
+
// WebSocket route
|
|
132
132
|
const onWebSocketConnected = (socket: WebSocket, req: FastifyRequest) => {
|
|
133
133
|
const { ver, clientId, clientName } = req.query as {
|
|
134
134
|
ver: string | undefined;
|
|
@@ -143,7 +143,7 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
|
|
|
143
143
|
}
|
|
144
144
|
this._wsHandler.addSocket(socket, clientId, clientName, req);
|
|
145
145
|
} else {
|
|
146
|
-
// V1
|
|
146
|
+
// V1 legacy support (auto-update only)
|
|
147
147
|
const autoUpdateDef = this.options.services.find((s) => s.name === "AutoUpdate");
|
|
148
148
|
if (autoUpdateDef == null) {
|
|
149
149
|
socket.close(1008, "AutoUpdate service not configured");
|
|
@@ -163,7 +163,7 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
|
|
|
163
163
|
this.fastify.get("/", { websocket: true }, onWebSocketConnected.bind(this));
|
|
164
164
|
this.fastify.get("/ws", { websocket: true }, onWebSocketConnected.bind(this));
|
|
165
165
|
|
|
166
|
-
//
|
|
166
|
+
// Static file wildcard handler
|
|
167
167
|
this.fastify.route({
|
|
168
168
|
method: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"],
|
|
169
169
|
url: "/*",
|
|
@@ -175,19 +175,19 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
|
|
|
175
175
|
},
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
// HTTP
|
|
178
|
+
// HTTP server-level error handling
|
|
179
179
|
this.fastify.server.on("error", (err) => {
|
|
180
|
-
logger.error("HTTP
|
|
180
|
+
logger.error("HTTP server error", err);
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
-
//
|
|
183
|
+
// Listen
|
|
184
184
|
await this.fastify.listen({ port: this.options.port, host: "0.0.0.0" });
|
|
185
185
|
|
|
186
|
-
//
|
|
186
|
+
// Register graceful shutdown handler
|
|
187
187
|
this._registerGracefulShutdown();
|
|
188
188
|
|
|
189
189
|
this.isOpen = true;
|
|
190
|
-
logger.info(
|
|
190
|
+
logger.info(`Server started (port: ${this.options.port})`);
|
|
191
191
|
this.emit("ready");
|
|
192
192
|
}
|
|
193
193
|
|
|
@@ -196,12 +196,12 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
|
|
|
196
196
|
await this.fastify.close();
|
|
197
197
|
|
|
198
198
|
this.isOpen = false;
|
|
199
|
-
logger.debug("
|
|
199
|
+
logger.debug("Server closed");
|
|
200
200
|
this.emit("close");
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
async broadcastReload(clientName: string | undefined, changedFileSet: Set<string>) {
|
|
204
|
-
logger.debug("
|
|
204
|
+
logger.debug("Broadcasting RELOAD to all server clients");
|
|
205
205
|
await this._wsHandler.broadcastReload(clientName, changedFileSet);
|
|
206
206
|
}
|
|
207
207
|
|
|
@@ -215,24 +215,24 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
|
|
|
215
215
|
|
|
216
216
|
async generateAuthToken(payload: AuthTokenPayload<TAuthInfo>) {
|
|
217
217
|
const jwtSecret = this.options.auth?.jwtSecret;
|
|
218
|
-
if (jwtSecret == null) throw new Error("JWT Secret
|
|
218
|
+
if (jwtSecret == null) throw new Error("JWT Secret is not defined.");
|
|
219
219
|
|
|
220
220
|
return signJwt(jwtSecret, payload);
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
async verifyAuthToken(token: string): Promise<AuthTokenPayload<TAuthInfo>> {
|
|
224
224
|
const jwtSecret = this.options.auth?.jwtSecret;
|
|
225
|
-
if (jwtSecret == null) throw new Error("JWT Secret
|
|
225
|
+
if (jwtSecret == null) throw new Error("JWT Secret is not defined.");
|
|
226
226
|
|
|
227
227
|
return verifyJwt(jwtSecret, token);
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
private _registerGracefulShutdown() {
|
|
231
231
|
const shutdownHandler = async (signal: string) => {
|
|
232
|
-
logger.info(`${signal}
|
|
232
|
+
logger.info(`${signal} signal received. Starting server shutdown...`);
|
|
233
233
|
|
|
234
234
|
const forceExitTimer = setTimeout(() => {
|
|
235
|
-
logger.error("
|
|
235
|
+
logger.error("Server shutdown timed out (10s). Forcing exit.");
|
|
236
236
|
process.exit(1);
|
|
237
237
|
}, 10000);
|
|
238
238
|
|
|
@@ -240,11 +240,11 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
|
|
|
240
240
|
if (this.isOpen) {
|
|
241
241
|
await this.close();
|
|
242
242
|
}
|
|
243
|
-
logger.info("
|
|
243
|
+
logger.info("Server shut down gracefully.");
|
|
244
244
|
clearTimeout(forceExitTimer);
|
|
245
245
|
process.exit(0);
|
|
246
246
|
} catch (err) {
|
|
247
|
-
logger.error("
|
|
247
|
+
logger.error("Error during server shutdown", err);
|
|
248
248
|
process.exit(1);
|
|
249
249
|
}
|
|
250
250
|
};
|
|
@@ -12,7 +12,7 @@ export const AutoUpdateService = defineService("AutoUpdate", (ctx) => ({
|
|
|
12
12
|
| undefined
|
|
13
13
|
> {
|
|
14
14
|
const clientPath = ctx.clientPath;
|
|
15
|
-
if (clientPath == null) throw new Error("
|
|
15
|
+
if (clientPath == null) throw new Error("Client path not found.");
|
|
16
16
|
|
|
17
17
|
if (!(await fsExists(path.resolve(clientPath, platform, "updates")))) return undefined;
|
|
18
18
|
|
|
@@ -24,7 +24,7 @@ export const OrmService = defineService(
|
|
|
24
24
|
const sock = (): ServiceSocket => {
|
|
25
25
|
const socket = ctx.socket;
|
|
26
26
|
if (socket == null) {
|
|
27
|
-
throw new Error("WebSocket
|
|
27
|
+
throw new Error("WebSocket connection is required. ORM service cannot be used over HTTP.");
|
|
28
28
|
}
|
|
29
29
|
return socket;
|
|
30
30
|
};
|
|
@@ -34,7 +34,7 @@ export const OrmService = defineService(
|
|
|
34
34
|
opt.configName
|
|
35
35
|
];
|
|
36
36
|
if (config == null) {
|
|
37
|
-
throw new Error(`ORM
|
|
37
|
+
throw new Error(`ORM configuration not found: ${opt.configName}`);
|
|
38
38
|
}
|
|
39
39
|
return { ...config, ...opt.config } as DbConnConfig;
|
|
40
40
|
};
|
|
@@ -43,7 +43,7 @@ export const OrmService = defineService(
|
|
|
43
43
|
const myConns = socketConns.get(sock());
|
|
44
44
|
const conn = myConns?.get(connId);
|
|
45
45
|
if (conn == null) {
|
|
46
|
-
throw new Error("
|
|
46
|
+
throw new Error("Not connected to database. (Invalid Connection ID)");
|
|
47
47
|
}
|
|
48
48
|
return conn;
|
|
49
49
|
};
|
|
@@ -71,7 +71,7 @@ export const OrmService = defineService(
|
|
|
71
71
|
sock().on("close", async () => {
|
|
72
72
|
if (myConns == null) return;
|
|
73
73
|
|
|
74
|
-
logger.debug("
|
|
74
|
+
logger.debug("Socket close detected: cleaning up all open DB connections.");
|
|
75
75
|
const conns = Array.from(myConns.values());
|
|
76
76
|
|
|
77
77
|
await Promise.all(
|
|
@@ -81,7 +81,7 @@ export const OrmService = defineService(
|
|
|
81
81
|
await conn.close();
|
|
82
82
|
}
|
|
83
83
|
} catch (err) {
|
|
84
|
-
logger.warn("
|
|
84
|
+
logger.warn("Error ignored during forced DB connection close", err);
|
|
85
85
|
}
|
|
86
86
|
}),
|
|
87
87
|
);
|
|
@@ -110,7 +110,7 @@ export const OrmService = defineService(
|
|
|
110
110
|
const conn = getConn(connId);
|
|
111
111
|
await conn.close();
|
|
112
112
|
} catch (err) {
|
|
113
|
-
logger.warn("
|
|
113
|
+
logger.warn("Error ignored during DB connection close", err);
|
|
114
114
|
}
|
|
115
115
|
},
|
|
116
116
|
|
|
@@ -16,16 +16,16 @@ export async function handleHttpRequest<TAuthInfo = unknown>(
|
|
|
16
16
|
): Promise<void> {
|
|
17
17
|
const { service, method } = req.params as { service: string; method: string };
|
|
18
18
|
|
|
19
|
-
// ClientName
|
|
19
|
+
// ClientName header
|
|
20
20
|
const clientName = req.headers["x-sd-client-name"] as string | undefined;
|
|
21
21
|
if (clientName == null) throw new Error("ClientName header is required");
|
|
22
22
|
|
|
23
|
-
//
|
|
23
|
+
// Parse and verify Authorization header
|
|
24
24
|
let authTokenPayload: AuthTokenPayload<TAuthInfo> | undefined;
|
|
25
25
|
try {
|
|
26
26
|
const authHeader = req.headers.authorization;
|
|
27
27
|
if (authHeader != null) {
|
|
28
|
-
if (jwtSecret == null) throw new Error("JWT Secret
|
|
28
|
+
if (jwtSecret == null) throw new Error("JWT Secret is not defined.");
|
|
29
29
|
|
|
30
30
|
const token = authHeader.split(" ")[1]; // "Bearer <token>"
|
|
31
31
|
authTokenPayload = await verifyJwt<TAuthInfo>(jwtSecret, token);
|
|
@@ -38,7 +38,7 @@ export async function handleHttpRequest<TAuthInfo = unknown>(
|
|
|
38
38
|
return;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
//
|
|
41
|
+
// Parse parameters
|
|
42
42
|
let params: unknown[] | undefined;
|
|
43
43
|
if (req.method === "GET") {
|
|
44
44
|
const query = req.query as { json?: string };
|
|
@@ -58,7 +58,7 @@ export async function handleHttpRequest<TAuthInfo = unknown>(
|
|
|
58
58
|
params = req.body as unknown[];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
//
|
|
61
|
+
// Execute service and send response
|
|
62
62
|
if (params != null) {
|
|
63
63
|
const serviceResult = await runMethod({
|
|
64
64
|
serviceName: service,
|
|
@@ -14,12 +14,12 @@ export async function handleStaticFile(
|
|
|
14
14
|
let targetFilePath = path.resolve(rootPath, "www", urlPath);
|
|
15
15
|
const allowedRootPath = path.resolve(rootPath, "www");
|
|
16
16
|
|
|
17
|
-
//
|
|
17
|
+
// Security guard for targetPath (prevent path traversal)
|
|
18
18
|
if (targetFilePath !== allowedRootPath && !pathIsChildPath(targetFilePath, allowedRootPath)) {
|
|
19
19
|
throw new Error("Access denied");
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
//
|
|
22
|
+
// Redirect with trailing slash for directories (standard web server behavior)
|
|
23
23
|
if ((await fsExists(targetFilePath)) && (await fsStat(targetFilePath)).isDirectory()) {
|
|
24
24
|
if (!urlPath.endsWith("/")) {
|
|
25
25
|
const urlObj = new URL(req.raw.url!, "http://localhost");
|
|
@@ -29,15 +29,15 @@ export async function handleStaticFile(
|
|
|
29
29
|
targetFilePath = path.resolve(targetFilePath, "index.html");
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
//
|
|
32
|
+
// Permission check (hidden files, etc.)
|
|
33
33
|
if (path.basename(targetFilePath).startsWith(".")) {
|
|
34
|
-
const errorMessage = "
|
|
34
|
+
const errorMessage = "You do not have permission to access this file.";
|
|
35
35
|
responseErrorHtml(reply, 403, errorMessage);
|
|
36
36
|
logger.warn(`[403] ${errorMessage} (${targetFilePath})`);
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
//
|
|
40
|
+
// Send file
|
|
41
41
|
const filename = path.basename(targetFilePath);
|
|
42
42
|
const directory = path.dirname(targetFilePath);
|
|
43
43
|
|
|
@@ -46,11 +46,11 @@ export async function handleStaticFile(
|
|
|
46
46
|
} catch (err: unknown) {
|
|
47
47
|
const error = err as { code?: string };
|
|
48
48
|
if (error.code === "ENOENT") {
|
|
49
|
-
const errorMessage = "
|
|
49
|
+
const errorMessage = "File not found.";
|
|
50
50
|
responseErrorHtml(reply, 404, errorMessage);
|
|
51
51
|
logger.warn(`[404] ${errorMessage} (${targetFilePath})`);
|
|
52
52
|
} else {
|
|
53
|
-
const errorMessage = "
|
|
53
|
+
const errorMessage = "An error occurred while sending the file.";
|
|
54
54
|
responseErrorHtml(reply, 500, errorMessage);
|
|
55
55
|
logger.error(`[500] ${errorMessage}`, err);
|
|
56
56
|
}
|
|
@@ -21,14 +21,14 @@ export async function handleUpload(
|
|
|
21
21
|
return;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
//
|
|
24
|
+
// Auth check
|
|
25
25
|
try {
|
|
26
26
|
const authHeader = req.headers.authorization;
|
|
27
27
|
if (authHeader == null) {
|
|
28
|
-
throw new Error("
|
|
28
|
+
throw new Error("Authentication token is missing.");
|
|
29
29
|
}
|
|
30
30
|
if (jwtSecret == null) {
|
|
31
|
-
throw new Error("JWT Secret
|
|
31
|
+
throw new Error("JWT Secret is not defined.");
|
|
32
32
|
}
|
|
33
33
|
const token = authHeader.split(" ")[1];
|
|
34
34
|
await verifyJwt(jwtSecret, token);
|
|
@@ -80,7 +80,7 @@ export function createServiceSocket(
|
|
|
80
80
|
// State
|
|
81
81
|
// -------------------------------------------------------------------
|
|
82
82
|
|
|
83
|
-
const PING_INTERVAL = 5000; //
|
|
83
|
+
const PING_INTERVAL = 5000; // Send ping every 5s
|
|
84
84
|
const PONG_PACKET = new Uint8Array([0x02]);
|
|
85
85
|
|
|
86
86
|
const protocol = createProtocolWrapper();
|
|
@@ -125,7 +125,7 @@ export function createServiceSocket(
|
|
|
125
125
|
// -------------------------------------------------------------------
|
|
126
126
|
|
|
127
127
|
function onError(err: Error): void {
|
|
128
|
-
logger.error("WebSocket
|
|
128
|
+
logger.error("WebSocket client error", err);
|
|
129
129
|
emitEvent("error", err);
|
|
130
130
|
}
|
|
131
131
|
|
|
@@ -137,7 +137,7 @@ export function createServiceSocket(
|
|
|
137
137
|
|
|
138
138
|
async function onMessage(msgBuffer: Bytes): Promise<void> {
|
|
139
139
|
try {
|
|
140
|
-
//
|
|
140
|
+
// Handle pong response to ping
|
|
141
141
|
if (msgBuffer.length === 1 && msgBuffer[0] === 0x01) {
|
|
142
142
|
if (socket.readyState === WebSocket.OPEN) {
|
|
143
143
|
socket.send(PONG_PACKET);
|
|
@@ -159,7 +159,7 @@ export function createServiceSocket(
|
|
|
159
159
|
emitEvent("message", { uuid: decodeResult.uuid, msg });
|
|
160
160
|
}
|
|
161
161
|
} catch (err) {
|
|
162
|
-
logger.error("
|
|
162
|
+
logger.error("Error processing WebSocket message", err);
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
|
|
@@ -114,13 +114,13 @@ export function createWebSocketHandler(
|
|
|
114
114
|
|
|
115
115
|
return await serviceSocket.send(uuid, { name: "response" });
|
|
116
116
|
} else if (message.name === "auth") {
|
|
117
|
-
if (jwtSecret == null) throw new Error("JWT Secret
|
|
117
|
+
if (jwtSecret == null) throw new Error("JWT Secret is not defined.");
|
|
118
118
|
|
|
119
119
|
const token = message.body;
|
|
120
120
|
serviceSocket.authTokenPayload = await verifyJwt(jwtSecret, token);
|
|
121
121
|
return await serviceSocket.send(uuid, { name: "response" });
|
|
122
122
|
} else {
|
|
123
|
-
const err = new Error("
|
|
123
|
+
const err = new Error("Invalid request.");
|
|
124
124
|
|
|
125
125
|
return await serviceSocket.send(uuid, {
|
|
126
126
|
name: "error",
|
|
@@ -136,7 +136,7 @@ export function createWebSocketHandler(
|
|
|
136
136
|
const error =
|
|
137
137
|
err instanceof Error
|
|
138
138
|
? err
|
|
139
|
-
: new Error(typeof err === "string" ? err : "
|
|
139
|
+
: new Error(typeof err === "string" ? err : "An unknown error has occurred.");
|
|
140
140
|
|
|
141
141
|
return serviceSocket.send(uuid, {
|
|
142
142
|
name: "error",
|
|
@@ -164,38 +164,40 @@ export function createWebSocketHandler(
|
|
|
164
164
|
try {
|
|
165
165
|
const serviceSocket = createServiceSocket(socket, clientId, clientName, connReq);
|
|
166
166
|
|
|
167
|
-
//
|
|
167
|
+
// Disconnect existing connection
|
|
168
168
|
const prevServiceSocket = socketMap.get(clientId);
|
|
169
169
|
if (prevServiceSocket != null) {
|
|
170
170
|
prevServiceSocket.close();
|
|
171
171
|
|
|
172
172
|
const connectionDateTimeText =
|
|
173
173
|
prevServiceSocket.connectedAtDateTime.toFormatString("yyyy:MM:dd HH:mm:ss.fff");
|
|
174
|
-
logger.debug(
|
|
174
|
+
logger.debug(
|
|
175
|
+
`Disconnected previous client connection: ${clientId}: ${connectionDateTimeText}`,
|
|
176
|
+
);
|
|
175
177
|
}
|
|
176
178
|
|
|
177
179
|
socketMap.set(clientId, serviceSocket);
|
|
178
180
|
|
|
179
181
|
serviceSocket.on("close", (code) => {
|
|
180
|
-
logger.debug(
|
|
182
|
+
logger.debug(`Client disconnected: (code: ${code})`);
|
|
181
183
|
|
|
182
184
|
if (socketMap.get(clientId) !== serviceSocket) return;
|
|
183
185
|
socketMap.delete(clientId);
|
|
184
186
|
});
|
|
185
187
|
|
|
186
188
|
serviceSocket.on("message", async ({ uuid, msg }) => {
|
|
187
|
-
logger.debug("
|
|
189
|
+
logger.debug("Request received", msg);
|
|
188
190
|
const sentSize = await processRequest(serviceSocket, uuid, msg);
|
|
189
|
-
logger.debug(
|
|
191
|
+
logger.debug(`Response sent (size: ${sentSize})`);
|
|
190
192
|
});
|
|
191
193
|
|
|
192
|
-
logger.debug("
|
|
194
|
+
logger.debug("Client connected", {
|
|
193
195
|
clientId,
|
|
194
196
|
remoteAddress: connReq.socket.remoteAddress,
|
|
195
197
|
socketSize: socketMap.size,
|
|
196
198
|
});
|
|
197
199
|
} catch (err) {
|
|
198
|
-
logger.error("
|
|
200
|
+
logger.error("Error handling connection", err);
|
|
199
201
|
socket.terminate();
|
|
200
202
|
}
|
|
201
203
|
},
|
|
@@ -5,12 +5,12 @@ import consola from "consola";
|
|
|
5
5
|
|
|
6
6
|
const logger = consola.withTag("service-server:ConfigManager");
|
|
7
7
|
|
|
8
|
-
//
|
|
8
|
+
// Value: Config object, Key: file path
|
|
9
9
|
const _cache = new LazyGcMap<string, unknown>({
|
|
10
|
-
gcInterval: 10 * 60 * 1000, // 10
|
|
11
|
-
expireTime: 60 * 60 * 1000, // 1
|
|
10
|
+
gcInterval: 10 * 60 * 1000, // Every 10 minutes
|
|
11
|
+
expireTime: 60 * 60 * 1000, // Expire after 1 hour
|
|
12
12
|
onExpire: async (filePath) => {
|
|
13
|
-
logger.debug(
|
|
13
|
+
logger.debug(`Config cache expired and watcher released: ${path.basename(filePath)}`);
|
|
14
14
|
await closeWatcher(filePath);
|
|
15
15
|
},
|
|
16
16
|
});
|
|
@@ -18,18 +18,18 @@ const _cache = new LazyGcMap<string, unknown>({
|
|
|
18
18
|
const _watchers = new Map<string, FsWatcher>();
|
|
19
19
|
|
|
20
20
|
export async function getConfig<TConfig>(filePath: string): Promise<TConfig | undefined> {
|
|
21
|
-
// 1.
|
|
21
|
+
// 1. Cache hit (time auto-renewed)
|
|
22
22
|
if (_cache.has(filePath)) {
|
|
23
23
|
return _cache.get(filePath) as TConfig;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
if (!(await fsExists(filePath))) return undefined;
|
|
27
27
|
|
|
28
|
-
// 2.
|
|
28
|
+
// 2. Load and cache
|
|
29
29
|
const config = await fsReadJson(filePath);
|
|
30
30
|
_cache.set(filePath, config);
|
|
31
31
|
|
|
32
|
-
// 3.
|
|
32
|
+
// 3. Register watcher
|
|
33
33
|
if (!_watchers.has(filePath)) {
|
|
34
34
|
try {
|
|
35
35
|
const watcher = await FsWatcher.watch([filePath]);
|
|
@@ -39,20 +39,20 @@ export async function getConfig<TConfig>(filePath: string): Promise<TConfig | un
|
|
|
39
39
|
if (!(await fsExists(filePath))) {
|
|
40
40
|
_cache.delete(filePath);
|
|
41
41
|
await closeWatcher(filePath);
|
|
42
|
-
logger.debug(
|
|
42
|
+
logger.debug(`Config file deleted: ${path.basename(filePath)}`);
|
|
43
43
|
return;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
try {
|
|
47
47
|
const newConfig = await fsReadJson(filePath);
|
|
48
48
|
_cache.set(filePath, newConfig);
|
|
49
|
-
logger.debug(
|
|
49
|
+
logger.debug(`Config file live-reloaded: ${path.basename(filePath)}`);
|
|
50
50
|
} catch (err) {
|
|
51
|
-
logger.warn(
|
|
51
|
+
logger.warn(`Config file reload failed: ${filePath}`, err);
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
54
|
} catch (err) {
|
|
55
|
-
logger.error(
|
|
55
|
+
logger.error(`Watch failed: ${filePath}`, err);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { defineService, auth, getServiceAuthPermissions } from "@simplysm/service-server";
|
|
3
|
+
|
|
4
|
+
describe("defineService", () => {
|
|
5
|
+
it("creates service definition with name and factory", () => {
|
|
6
|
+
const svc = defineService("Health", (_ctx) => ({
|
|
7
|
+
check: () => "ok",
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
expect(svc.name).toBe("Health");
|
|
11
|
+
expect(typeof svc.factory).toBe("function");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("factory generates methods when called with context", () => {
|
|
15
|
+
const svc = defineService("Echo", (_ctx) => ({
|
|
16
|
+
echo: (msg: string) => `Echo: ${msg}`,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const methods = svc.factory({} as any);
|
|
20
|
+
expect(methods.echo("hello")).toBe("Echo: hello");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("Authentication", () => {
|
|
25
|
+
it("marks function with empty permissions (login required only)", () => {
|
|
26
|
+
const fn = auth(() => "result");
|
|
27
|
+
expect(getServiceAuthPermissions(fn)).toEqual([]);
|
|
28
|
+
expect(fn()).toBe("result");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("marks function with specific permissions", () => {
|
|
32
|
+
const fn = auth(["admin"], (id: number) => id * 2);
|
|
33
|
+
expect(getServiceAuthPermissions(fn)).toEqual(["admin"]);
|
|
34
|
+
expect(fn(5)).toBe(10);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns undefined for unmarked function", () => {
|
|
38
|
+
const fn = () => "plain";
|
|
39
|
+
expect(getServiceAuthPermissions(fn)).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("works at service level (factory wrapping)", () => {
|
|
43
|
+
const svc = defineService(
|
|
44
|
+
"User",
|
|
45
|
+
auth((_ctx) => ({
|
|
46
|
+
getProfile: () => "profile",
|
|
47
|
+
})),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(svc.authPermissions).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("works at service-level with roles", () => {
|
|
54
|
+
const svc = defineService(
|
|
55
|
+
"Admin",
|
|
56
|
+
auth(["admin"], (_ctx) => ({
|
|
57
|
+
manage: () => "managed",
|
|
58
|
+
})),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(svc.authPermissions).toEqual(["admin"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("service without auth has no authPermissions", () => {
|
|
65
|
+
const svc = defineService("Public", (_ctx) => ({
|
|
66
|
+
open: () => "open",
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
expect(svc.authPermissions).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("method-level auth is readable from returned methods", () => {
|
|
73
|
+
const svc = defineService(
|
|
74
|
+
"Mixed",
|
|
75
|
+
auth((_ctx) => ({
|
|
76
|
+
normal: () => "normal",
|
|
77
|
+
adminOnly: auth(["admin"], () => "admin"),
|
|
78
|
+
})),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const methods = svc.factory({} as any);
|
|
82
|
+
expect(getServiceAuthPermissions(methods.normal)).toBeUndefined();
|
|
83
|
+
expect(getServiceAuthPermissions(methods.adminOnly)).toEqual(["admin"]);
|
|
84
|
+
});
|
|
85
|
+
});
|