@simplysm/service-server 13.0.0-beta.28 → 13.0.0-beta.30
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 +53 -646
- package/docs/authentication.md +109 -0
- package/docs/built-in-services.md +172 -0
- package/docs/server.md +274 -0
- package/docs/transport.md +154 -0
- package/package.json +8 -7
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Authentication
|
|
2
|
+
|
|
3
|
+
## Authorize Decorator
|
|
4
|
+
|
|
5
|
+
Use Stage 3 decorators to set authentication requirements on services or methods. Only works when `ServiceServerOptions.auth` is configured.
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { ServiceBase, Authorize } from "@simplysm/service-server";
|
|
9
|
+
|
|
10
|
+
// Class level: all methods require login
|
|
11
|
+
@Authorize()
|
|
12
|
+
class UserService extends ServiceBase<{ userId: number; role: string }> {
|
|
13
|
+
// Login only required (inherits from class level)
|
|
14
|
+
async getProfile(): Promise<unknown> {
|
|
15
|
+
const userId = this.authInfo?.userId;
|
|
16
|
+
// ...
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Method level: specific role required (overrides class level)
|
|
20
|
+
@Authorize(["admin"])
|
|
21
|
+
async deleteUser(targetId: number): Promise<void> {
|
|
22
|
+
// Only users with admin role can call
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// No authentication required (no decorator)
|
|
27
|
+
class PublicService extends ServiceBase {
|
|
28
|
+
async healthCheck(): Promise<string> {
|
|
29
|
+
return "OK";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Decorator behavior:
|
|
35
|
+
|
|
36
|
+
| Target | `@Authorize()` | `@Authorize(["admin"])` |
|
|
37
|
+
|-----------|----------------|-------------------------|
|
|
38
|
+
| Class | All methods require login | All methods require admin role |
|
|
39
|
+
| Method | Method requires login | Method requires admin role |
|
|
40
|
+
| None | No auth required (Public) | - |
|
|
41
|
+
|
|
42
|
+
Method-level decorators override class-level settings.
|
|
43
|
+
|
|
44
|
+
## getAuthPermissions
|
|
45
|
+
|
|
46
|
+
Query auth permissions for a given service class and method. Primarily used internally by `ServiceExecutor`, but exported for advanced use cases.
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import { getAuthPermissions } from "@simplysm/service-server";
|
|
50
|
+
|
|
51
|
+
// Returns string[] if permissions are set, or undefined for public (no decorator)
|
|
52
|
+
const perms = getAuthPermissions(UserService, "deleteUser");
|
|
53
|
+
// ["admin"]
|
|
54
|
+
|
|
55
|
+
const classPerms = getAuthPermissions(UserService);
|
|
56
|
+
// [] (empty array = login required, no specific role)
|
|
57
|
+
|
|
58
|
+
const publicPerms = getAuthPermissions(PublicService, "healthCheck");
|
|
59
|
+
// undefined (no auth required)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## JWT Token Management
|
|
63
|
+
|
|
64
|
+
### JwtManager
|
|
65
|
+
|
|
66
|
+
`JwtManager<TAuthInfo>` handles JWT operations internally. Access its functionality through `ServiceServer` methods.
|
|
67
|
+
|
|
68
|
+
| Method | Returns | Description |
|
|
69
|
+
|--------|---------|------|
|
|
70
|
+
| `sign(payload)` | `Promise<string>` | Generate a JWT token (HS256, 12-hour expiration) |
|
|
71
|
+
| `verify(token)` | `Promise<AuthTokenPayload<TAuthInfo>>` | Verify token signature and expiration, return payload |
|
|
72
|
+
| `decode(token)` | `AuthTokenPayload<TAuthInfo>` | Decode token without verification (synchronous) |
|
|
73
|
+
|
|
74
|
+
Generate and verify JWT tokens through the `ServiceServer` instance:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
import { ServiceServer } from "@simplysm/service-server";
|
|
78
|
+
|
|
79
|
+
const server = new ServiceServer({
|
|
80
|
+
port: 8080,
|
|
81
|
+
rootPath: "/app/data",
|
|
82
|
+
auth: { jwtSecret: "my-secret-key" },
|
|
83
|
+
services: [],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Generate token (12-hour expiration, HS256 algorithm)
|
|
87
|
+
const token = await server.generateAuthToken({
|
|
88
|
+
roles: ["admin", "user"],
|
|
89
|
+
data: { userId: 1, name: "John" },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Verify token
|
|
93
|
+
const payload = await server.verifyAuthToken(token);
|
|
94
|
+
// payload.roles: ["admin", "user"]
|
|
95
|
+
// payload.data: { userId: 1, name: "John" }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### AuthTokenPayload
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
import type { AuthTokenPayload } from "@simplysm/service-server";
|
|
102
|
+
|
|
103
|
+
interface AuthTokenPayload<TAuthInfo = unknown> extends JWTPayload {
|
|
104
|
+
/** User role list (used for permission check in Authorize decorator) */
|
|
105
|
+
roles: string[];
|
|
106
|
+
/** Custom auth info (generic type) */
|
|
107
|
+
data: TAuthInfo;
|
|
108
|
+
}
|
|
109
|
+
```
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Built-in Services
|
|
2
|
+
|
|
3
|
+
## OrmService
|
|
4
|
+
|
|
5
|
+
Provides database connection/query/transaction via WebSocket. `@Authorize()` decorator is applied, requiring login.
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { ServiceServer, OrmService } from "@simplysm/service-server";
|
|
9
|
+
|
|
10
|
+
const server = new ServiceServer({
|
|
11
|
+
port: 8080,
|
|
12
|
+
rootPath: "/app/data",
|
|
13
|
+
auth: { jwtSecret: "secret" },
|
|
14
|
+
services: [OrmService],
|
|
15
|
+
});
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Define ORM config in `.config.json`:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"orm": {
|
|
23
|
+
"default": {
|
|
24
|
+
"dialect": "mysql",
|
|
25
|
+
"host": "localhost",
|
|
26
|
+
"port": 3306,
|
|
27
|
+
"database": "mydb",
|
|
28
|
+
"user": "root",
|
|
29
|
+
"password": "password"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Methods provided by `OrmService`:
|
|
36
|
+
|
|
37
|
+
| Method | Returns | Description |
|
|
38
|
+
|--------|---------|------|
|
|
39
|
+
| `getInfo(opt)` | `Promise<{ dialect, database?, schema? }>` | Query DB connection info |
|
|
40
|
+
| `connect(opt)` | `Promise<number>` | Create DB connection. Returns connection ID |
|
|
41
|
+
| `close(connId)` | `Promise<void>` | Close DB connection |
|
|
42
|
+
| `beginTransaction(connId, isolationLevel?)` | `Promise<void>` | Begin transaction |
|
|
43
|
+
| `commitTransaction(connId)` | `Promise<void>` | Commit transaction |
|
|
44
|
+
| `rollbackTransaction(connId)` | `Promise<void>` | Rollback transaction |
|
|
45
|
+
| `executeParametrized(connId, query, params?)` | `Promise<unknown[][]>` | Execute parameterized query |
|
|
46
|
+
| `executeDefs(connId, defs, options?)` | `Promise<unknown[][]>` | Execute QueryDef-based queries |
|
|
47
|
+
| `bulkInsert(connId, tableName, columnDefs, records)` | `Promise<void>` | Bulk INSERT |
|
|
48
|
+
|
|
49
|
+
When a WebSocket connection is closed, all DB connections opened from that socket are automatically cleaned up.
|
|
50
|
+
|
|
51
|
+
## CryptoService
|
|
52
|
+
|
|
53
|
+
Provides SHA256 hash and AES-256-CBC symmetric key encryption/decryption.
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { ServiceServer, CryptoService } from "@simplysm/service-server";
|
|
57
|
+
|
|
58
|
+
const server = new ServiceServer({
|
|
59
|
+
port: 8080,
|
|
60
|
+
rootPath: "/app/data",
|
|
61
|
+
services: [CryptoService],
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`.config.json` config:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"crypto": {
|
|
70
|
+
"key": "your-32-byte-secret-key-here!!"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| Method | Returns | Description |
|
|
76
|
+
|--------|---------|------|
|
|
77
|
+
| `encrypt(data)` | `Promise<string>` | Generate SHA256 HMAC hash (one-way). `data` is `string \| Uint8Array` |
|
|
78
|
+
| `encryptAes(data)` | `Promise<string>` | AES-256-CBC encryption. `data` is `Uint8Array`. Returns hex string in `iv:encrypted` format |
|
|
79
|
+
| `decryptAes(encText)` | `Promise<Uint8Array>` | AES-256-CBC decryption. Returns original binary |
|
|
80
|
+
|
|
81
|
+
## SmtpService
|
|
82
|
+
|
|
83
|
+
A nodemailer-based email sending service. Can pass SMTP config directly or reference server config file.
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
import { ServiceServer, SmtpService } from "@simplysm/service-server";
|
|
87
|
+
|
|
88
|
+
const server = new ServiceServer({
|
|
89
|
+
port: 8080,
|
|
90
|
+
rootPath: "/app/data",
|
|
91
|
+
services: [SmtpService],
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`.config.json` config (when using config reference method):
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"smtp": {
|
|
100
|
+
"default": {
|
|
101
|
+
"host": "smtp.example.com",
|
|
102
|
+
"port": 587,
|
|
103
|
+
"secure": false,
|
|
104
|
+
"user": "user@example.com",
|
|
105
|
+
"pass": "password",
|
|
106
|
+
"senderName": "My App",
|
|
107
|
+
"senderEmail": "noreply@example.com"
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| Method | Returns | Description |
|
|
114
|
+
|--------|---------|------|
|
|
115
|
+
| `send(options)` | `Promise<string>` | Send email by directly passing SMTP config. Returns message ID |
|
|
116
|
+
| `sendByConfig(configName, options)` | `Promise<string>` | Send email by referencing SMTP config in config file. Returns message ID |
|
|
117
|
+
|
|
118
|
+
`send()` options:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
interface SmtpSendOption {
|
|
122
|
+
host: string;
|
|
123
|
+
port?: number;
|
|
124
|
+
secure?: boolean;
|
|
125
|
+
user?: string;
|
|
126
|
+
pass?: string;
|
|
127
|
+
from: string;
|
|
128
|
+
to: string;
|
|
129
|
+
cc?: string;
|
|
130
|
+
bcc?: string;
|
|
131
|
+
subject: string;
|
|
132
|
+
html: string;
|
|
133
|
+
attachments?: SmtpSendAttachment[];
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## AutoUpdateService
|
|
138
|
+
|
|
139
|
+
Supports auto-update for client apps. Searches for latest version files by platform in the client directory.
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
import { ServiceServer, AutoUpdateService } from "@simplysm/service-server";
|
|
143
|
+
|
|
144
|
+
const server = new ServiceServer({
|
|
145
|
+
port: 8080,
|
|
146
|
+
rootPath: "/app/data",
|
|
147
|
+
services: [AutoUpdateService],
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Update file structure:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
rootPath/www/{clientName}/{platform}/updates/
|
|
155
|
+
1.0.0.exe (Windows)
|
|
156
|
+
1.0.1.exe
|
|
157
|
+
1.0.0.apk (Android)
|
|
158
|
+
1.0.1.apk
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
| Method | Returns | Description |
|
|
162
|
+
|--------|---------|------|
|
|
163
|
+
| `getLastVersion(platform)` | `Promise<{ version: string; downloadPath: string } \| undefined>` | Returns latest version and download path for the platform. Returns `undefined` if no update |
|
|
164
|
+
|
|
165
|
+
Return value example:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
{
|
|
169
|
+
version: "1.0.1",
|
|
170
|
+
downloadPath: "/my-app/android/updates/1.0.1.apk",
|
|
171
|
+
}
|
|
172
|
+
```
|
package/docs/server.md
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# ServiceServer
|
|
2
|
+
|
|
3
|
+
## ServiceServer
|
|
4
|
+
|
|
5
|
+
`ServiceServer<TAuthInfo>` extends `EventEmitter` and is the main entry point for creating a server.
|
|
6
|
+
|
|
7
|
+
**Properties:**
|
|
8
|
+
|
|
9
|
+
| Property | Type | Description |
|
|
10
|
+
|----------|------|------|
|
|
11
|
+
| `options` | `ServiceServerOptions` | Server configuration (read-only, passed via constructor) |
|
|
12
|
+
| `isOpen` | `boolean` | Whether the server is currently listening |
|
|
13
|
+
| `fastify` | `FastifyInstance` | Underlying Fastify instance (read-only, for advanced use) |
|
|
14
|
+
|
|
15
|
+
**Methods:**
|
|
16
|
+
|
|
17
|
+
| Method | Returns | Description |
|
|
18
|
+
|--------|---------|------|
|
|
19
|
+
| `listen()` | `Promise<void>` | Register all plugins/routes and start listening |
|
|
20
|
+
| `close()` | `Promise<void>` | Close all WebSocket connections and shut down the server |
|
|
21
|
+
| `generateAuthToken(payload)` | `Promise<string>` | Generate a JWT token (HS256, 12-hour expiration) |
|
|
22
|
+
| `verifyAuthToken(token)` | `Promise<AuthTokenPayload<TAuthInfo>>` | Verify and decode a JWT token |
|
|
23
|
+
| `emitEvent(eventType, infoSelector, data)` | `Promise<void>` | Publish an event to matching WebSocket clients |
|
|
24
|
+
| `broadcastReload(clientName, changedFileSet)` | `Promise<void>` | Send a reload command to all connected clients |
|
|
25
|
+
|
|
26
|
+
**Events:**
|
|
27
|
+
|
|
28
|
+
| Event | Payload | Description |
|
|
29
|
+
|-------|---------|------|
|
|
30
|
+
| `ready` | `void` | Emitted when the server starts listening |
|
|
31
|
+
| `close` | `void` | Emitted when the server is closed |
|
|
32
|
+
|
|
33
|
+
## Server Options (`ServiceServerOptions`)
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import type { ServiceServerOptions } from "@simplysm/service-server";
|
|
37
|
+
|
|
38
|
+
interface ServiceServerOptions {
|
|
39
|
+
/** Server root path (base directory for static files and config files) */
|
|
40
|
+
rootPath: string;
|
|
41
|
+
/** Listen port */
|
|
42
|
+
port: number;
|
|
43
|
+
/** SSL/TLS config (enables HTTPS) */
|
|
44
|
+
ssl?: {
|
|
45
|
+
pfxBytes: Uint8Array;
|
|
46
|
+
passphrase: string;
|
|
47
|
+
};
|
|
48
|
+
/** JWT authentication config */
|
|
49
|
+
auth?: {
|
|
50
|
+
jwtSecret: string;
|
|
51
|
+
};
|
|
52
|
+
/** List of service classes to register */
|
|
53
|
+
services: Type<ServiceBase>[];
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The following structure is expected under `rootPath`:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
rootPath/
|
|
61
|
+
.config.json # Root config file
|
|
62
|
+
www/ # Static file root
|
|
63
|
+
uploads/ # Upload file storage directory
|
|
64
|
+
{clientName}/ # Per-client directory
|
|
65
|
+
.config.json # Per-client config file
|
|
66
|
+
index.html
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## SSL/HTTPS Server
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
import { ServiceServer } from "@simplysm/service-server";
|
|
73
|
+
import { fsReadFile } from "@simplysm/core-node";
|
|
74
|
+
|
|
75
|
+
const pfxBytes = await fsReadFile("/path/to/cert.pfx");
|
|
76
|
+
|
|
77
|
+
const server = new ServiceServer({
|
|
78
|
+
port: 443,
|
|
79
|
+
rootPath: "/app/data",
|
|
80
|
+
ssl: {
|
|
81
|
+
pfxBytes,
|
|
82
|
+
passphrase: "certificate-password",
|
|
83
|
+
},
|
|
84
|
+
auth: { jwtSecret: "my-secret-key" },
|
|
85
|
+
services: [],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await server.listen();
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Custom Service Definition
|
|
92
|
+
|
|
93
|
+
Define services by inheriting from `ServiceBase`. Service methods are called via RPC from the client.
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import { ServiceBase } from "@simplysm/service-server";
|
|
97
|
+
|
|
98
|
+
class MyService extends ServiceBase {
|
|
99
|
+
async hello(name: string): Promise<string> {
|
|
100
|
+
return `Hello, ${name}!`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async getServerTime(): Promise<Date> {
|
|
104
|
+
return new Date();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### ServiceBase Properties
|
|
110
|
+
|
|
111
|
+
`ServiceBase<TAuthInfo>` is an abstract class. The generic `TAuthInfo` type represents the shape of the authenticated user's data stored in the JWT token.
|
|
112
|
+
|
|
113
|
+
| Property | Type | Description |
|
|
114
|
+
|----------|------|------|
|
|
115
|
+
| `this.server` | `ServiceServer<TAuthInfo>` | Server instance reference |
|
|
116
|
+
| `this.socket` | `ServiceSocket \| undefined` | WebSocket connection (`undefined` for HTTP calls) |
|
|
117
|
+
| `this.http` | `{ clientName: string; authTokenPayload?: AuthTokenPayload<TAuthInfo> } \| undefined` | HTTP request context |
|
|
118
|
+
| `this.authInfo` | `TAuthInfo \| undefined` | Authenticated user's custom data (from JWT `data` field) |
|
|
119
|
+
| `this.clientName` | `string \| undefined` | Client app name (validated against path traversal) |
|
|
120
|
+
| `this.clientPath` | `string \| undefined` | Resolved per-client directory path (`rootPath/www/{clientName}`) |
|
|
121
|
+
|
|
122
|
+
### ServiceBase Methods
|
|
123
|
+
|
|
124
|
+
| Method | Returns | Description |
|
|
125
|
+
|--------|---------|------|
|
|
126
|
+
| `getConfig<T>(section)` | `Promise<T>` | Read a section from `.config.json` (root + client configs merged) |
|
|
127
|
+
|
|
128
|
+
## Config File Reference
|
|
129
|
+
|
|
130
|
+
Read sections from `.config.json` files using `ServiceBase.getConfig()`. Root and per-client configs are automatically merged.
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
import { ServiceBase } from "@simplysm/service-server";
|
|
134
|
+
|
|
135
|
+
class MyService extends ServiceBase {
|
|
136
|
+
async getDbHost(): Promise<string> {
|
|
137
|
+
// Read "mySection" key from rootPath/.config.json or clientPath/.config.json
|
|
138
|
+
const config = await this.getConfig<{ host: string }>("mySection");
|
|
139
|
+
return config.host;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`.config.json` example:
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"mySection": {
|
|
149
|
+
"host": "localhost"
|
|
150
|
+
},
|
|
151
|
+
"orm": {
|
|
152
|
+
"default": {
|
|
153
|
+
"dialect": "mysql",
|
|
154
|
+
"host": "localhost",
|
|
155
|
+
"port": 3306,
|
|
156
|
+
"database": "mydb",
|
|
157
|
+
"user": "root",
|
|
158
|
+
"password": "password"
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`ConfigManager` caches config files and automatically refreshes the cache on file changes (LazyGcMap-based, auto expires after 1 hour).
|
|
165
|
+
|
|
166
|
+
## ConfigManager
|
|
167
|
+
|
|
168
|
+
A static utility class that manages loading, caching, and real-time monitoring of JSON config files. Used internally by `ServiceBase.getConfig()`.
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
import { ConfigManager } from "@simplysm/service-server";
|
|
172
|
+
|
|
173
|
+
// Returns undefined if the file does not exist
|
|
174
|
+
const config = await ConfigManager.getConfig<MyConfig>("/path/to/.config.json");
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
| Method | Returns | Description |
|
|
178
|
+
|--------|---------|------|
|
|
179
|
+
| `ConfigManager.getConfig<T>(filePath)` | `Promise<T \| undefined>` | Load and cache a JSON config file. Returns `undefined` if file not found |
|
|
180
|
+
|
|
181
|
+
Behavior:
|
|
182
|
+
- Caches file in `LazyGcMap` on first load.
|
|
183
|
+
- Registers file change watch (`FsWatcher`) to auto-refresh cache on changes.
|
|
184
|
+
- Cache auto-expires after 1 hour of no access, and associated watch is released.
|
|
185
|
+
- GC runs every 10 minutes to check for expired entries.
|
|
186
|
+
|
|
187
|
+
## Server Route Structure
|
|
188
|
+
|
|
189
|
+
The following routes are automatically registered when `ServiceServer.listen()` is called:
|
|
190
|
+
|
|
191
|
+
| Route | Method | Description |
|
|
192
|
+
|--------|--------|------|
|
|
193
|
+
| `/api/:service/:method` | GET, POST | Service method call via HTTP |
|
|
194
|
+
| `/upload` | POST | Multipart file upload (auth required) |
|
|
195
|
+
| `/` | WebSocket | WebSocket connection endpoint |
|
|
196
|
+
| `/ws` | WebSocket | WebSocket connection endpoint (alias) |
|
|
197
|
+
| `/*` | GET, etc. | Static file serving (based on `rootPath/www/`) |
|
|
198
|
+
|
|
199
|
+
## Full Server Example
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
import { ServiceServer, ServiceBase, Authorize, OrmService, CryptoService } from "@simplysm/service-server";
|
|
203
|
+
import { ServiceEventListener } from "@simplysm/service-common";
|
|
204
|
+
|
|
205
|
+
// Define a custom service
|
|
206
|
+
@Authorize()
|
|
207
|
+
class UserService extends ServiceBase<{ userId: number; role: string }> {
|
|
208
|
+
async getProfile(): Promise<{ name: string }> {
|
|
209
|
+
const userId = this.authInfo?.userId;
|
|
210
|
+
// Use this.getConfig(), this.socket, this.server, etc.
|
|
211
|
+
return { name: "John" };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@Authorize(["admin"])
|
|
215
|
+
async deleteUser(targetId: number): Promise<void> {
|
|
216
|
+
// Admin-only operation
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
class PublicService extends ServiceBase {
|
|
221
|
+
async healthCheck(): Promise<string> {
|
|
222
|
+
return "OK";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Create and start server
|
|
227
|
+
const server = new ServiceServer({
|
|
228
|
+
port: 8080,
|
|
229
|
+
rootPath: "/app/data",
|
|
230
|
+
auth: { jwtSecret: "my-secret-key" },
|
|
231
|
+
services: [UserService, PublicService, OrmService, CryptoService],
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
server.on("ready", () => {
|
|
235
|
+
console.log("Server is ready on port 8080");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await server.listen();
|
|
239
|
+
|
|
240
|
+
// Generate auth token for a user
|
|
241
|
+
const token = await server.generateAuthToken({
|
|
242
|
+
roles: ["admin"],
|
|
243
|
+
data: { userId: 1, role: "admin" },
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Emit events to connected clients
|
|
247
|
+
class UserUpdatedEvent extends ServiceEventListener<
|
|
248
|
+
{ userId: number },
|
|
249
|
+
{ action: string }
|
|
250
|
+
> {
|
|
251
|
+
readonly eventName = "UserUpdatedEvent";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await server.emitEvent(
|
|
255
|
+
UserUpdatedEvent,
|
|
256
|
+
(info) => info.userId === 1,
|
|
257
|
+
{ action: "profile-updated" },
|
|
258
|
+
);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Security
|
|
262
|
+
|
|
263
|
+
- **Helmet**: `@fastify/helmet` plugin automatically sets security headers like CSP, HSTS.
|
|
264
|
+
- **CORS**: `@fastify/cors` plugin configures CORS.
|
|
265
|
+
- **Path Traversal Prevention**: Static file handler and client name validation block `..`, `/`, `\` characters.
|
|
266
|
+
- **Hidden File Blocking**: Files starting with `.` return a 403 response.
|
|
267
|
+
- **Graceful Shutdown**: Detects `SIGINT`/`SIGTERM` signals to safely close open WebSocket connections and server (10-second timeout).
|
|
268
|
+
|
|
269
|
+
## Caveats
|
|
270
|
+
|
|
271
|
+
- `OrmService` is WebSocket-only. Cannot be used via HTTP requests.
|
|
272
|
+
- Config files (`.config.json`) contain sensitive information (DB passwords, JWT secrets, etc.), so hidden files (starting with `.`) are automatically blocked by the static file handler.
|
|
273
|
+
- WebSocket connection requires query parameters `ver=2`, `clientId`, `clientName`. Without these parameters, it operates in V1 legacy mode.
|
|
274
|
+
- If SSL is not configured, the `upgrade-insecure-requests` CSP directive is disabled.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Transport Layer
|
|
2
|
+
|
|
3
|
+
## ServiceSocket
|
|
4
|
+
|
|
5
|
+
`ServiceSocket` extends `EventEmitter` and wraps an individual WebSocket connection. It is available in service methods as `this.socket` when the request comes via WebSocket.
|
|
6
|
+
|
|
7
|
+
**Properties:**
|
|
8
|
+
|
|
9
|
+
| Property | Type | Description |
|
|
10
|
+
|----------|------|------|
|
|
11
|
+
| `clientName` | `string` | Client app name (from WebSocket query parameter) |
|
|
12
|
+
| `connectedAtDateTime` | `DateTime` | Connection timestamp |
|
|
13
|
+
| `authTokenPayload` | `AuthTokenPayload \| undefined` | Authenticated token payload (set after `auth` message) |
|
|
14
|
+
| `connReq` | `FastifyRequest` | Original Fastify request that initiated the WebSocket upgrade |
|
|
15
|
+
|
|
16
|
+
**Methods:**
|
|
17
|
+
|
|
18
|
+
| Method | Returns | Description |
|
|
19
|
+
|--------|---------|------|
|
|
20
|
+
| `send(uuid, msg)` | `Promise<number>` | Send a message to this client. Returns total bytes sent |
|
|
21
|
+
| `close()` | `void` | Terminate the WebSocket connection |
|
|
22
|
+
| `addEventListener(key, eventName, info)` | `void` | Register an event listener for this socket |
|
|
23
|
+
| `removeEventListener(key)` | `void` | Remove an event listener by key |
|
|
24
|
+
| `getEventListeners(eventName)` | `{ key, info }[]` | Get all event listeners for a given event name |
|
|
25
|
+
|
|
26
|
+
**Events:**
|
|
27
|
+
|
|
28
|
+
| Event | Payload | Description |
|
|
29
|
+
|-------|---------|------|
|
|
30
|
+
| `error` | `Error` | WebSocket error occurred |
|
|
31
|
+
| `close` | `number` | Connection closed (payload is the close code) |
|
|
32
|
+
| `message` | `{ uuid: string; msg: ServiceClientMessage }` | Decoded message received from client |
|
|
33
|
+
|
|
34
|
+
## HTTP API Call
|
|
35
|
+
|
|
36
|
+
Service methods can also be called via HTTP through the `/api/:service/:method` path.
|
|
37
|
+
|
|
38
|
+
**GET Request:**
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
GET /api/MyService/hello?json=["World"]
|
|
42
|
+
Header: x-sd-client-name: my-app
|
|
43
|
+
Header: Authorization: Bearer <token> (optional)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**POST Request:**
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
POST /api/MyService/hello
|
|
50
|
+
Header: Content-Type: application/json
|
|
51
|
+
Header: x-sd-client-name: my-app
|
|
52
|
+
Header: Authorization: Bearer <token> (optional)
|
|
53
|
+
Body: ["World"]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- The `x-sd-client-name` header is required.
|
|
57
|
+
- Parameters are passed in array form (in the order of method arguments).
|
|
58
|
+
- For GET requests, pass a JSON-serialized array in the `json` query parameter.
|
|
59
|
+
|
|
60
|
+
## File Upload
|
|
61
|
+
|
|
62
|
+
Upload files via multipart request to the `/upload` endpoint. Auth token is required.
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// Client-side example
|
|
66
|
+
const formData = new FormData();
|
|
67
|
+
formData.append("file", file);
|
|
68
|
+
|
|
69
|
+
const response = await fetch("/upload", {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: {
|
|
72
|
+
Authorization: `Bearer ${token}`,
|
|
73
|
+
},
|
|
74
|
+
body: formData,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Response: ServiceUploadResult[]
|
|
78
|
+
const results = await response.json();
|
|
79
|
+
// [{ path: "uploads/uuid.ext", filename: "original-filename.ext", size: 12345 }]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Uploaded files are stored in the `rootPath/www/uploads/` directory with UUID-based filenames.
|
|
83
|
+
|
|
84
|
+
## Real-time Event Publishing
|
|
85
|
+
|
|
86
|
+
Publish events to connected clients from the server.
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { ServiceServer } from "@simplysm/service-server";
|
|
90
|
+
import { ServiceEventListener } from "@simplysm/service-common";
|
|
91
|
+
|
|
92
|
+
// Event definition (from service-common)
|
|
93
|
+
class OrderUpdatedEvent extends ServiceEventListener<
|
|
94
|
+
{ orderId: number },
|
|
95
|
+
{ status: string }
|
|
96
|
+
> {
|
|
97
|
+
readonly eventName = "OrderUpdatedEvent";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Publish event from server
|
|
101
|
+
await server.emitEvent(
|
|
102
|
+
OrderUpdatedEvent,
|
|
103
|
+
(info) => info.orderId === 123, // Target filter
|
|
104
|
+
{ status: "completed" }, // Data to send
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Send reload command to all clients
|
|
108
|
+
await server.broadcastReload("my-app", new Set(["main.js"]));
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## ProtocolWrapper
|
|
112
|
+
|
|
113
|
+
Handles encoding/decoding of WebSocket messages. Automatically branches between main thread and worker thread based on message size.
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
import { ProtocolWrapper } from "@simplysm/service-server";
|
|
117
|
+
|
|
118
|
+
const protocol = new ProtocolWrapper();
|
|
119
|
+
|
|
120
|
+
// Encode a message into chunks
|
|
121
|
+
const { chunks, totalSize } = await protocol.encode(uuid, message);
|
|
122
|
+
|
|
123
|
+
// Decode received bytes
|
|
124
|
+
const result = await protocol.decode(bytes);
|
|
125
|
+
|
|
126
|
+
// Clean up
|
|
127
|
+
protocol.dispose();
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
| Method | Returns | Description |
|
|
131
|
+
|--------|---------|------|
|
|
132
|
+
| `encode(uuid, message)` | `Promise<{ chunks: Uint8Array[]; totalSize: number }>` | Encode a message into transmittable chunks |
|
|
133
|
+
| `decode(bytes)` | `Promise<ServiceMessageDecodeResult>` | Decode received bytes into a message |
|
|
134
|
+
| `dispose()` | `void` | Clean up internal protocol resources |
|
|
135
|
+
|
|
136
|
+
Worker thread branching:
|
|
137
|
+
|
|
138
|
+
| Condition | Processing Method |
|
|
139
|
+
|------|-----------|
|
|
140
|
+
| 30KB or less | Processed directly in main thread |
|
|
141
|
+
| Over 30KB | Processed in worker thread (max 4GB memory allocation) |
|
|
142
|
+
|
|
143
|
+
Messages containing large binary data (Uint8Array) also branch to worker thread.
|
|
144
|
+
|
|
145
|
+
## Legacy: handleV1Connection
|
|
146
|
+
|
|
147
|
+
Handles V1 protocol WebSocket clients. Only supports the `SdAutoUpdateService.getLastVersion` command. All other requests return an upgrade-required error.
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
import { handleV1Connection, AutoUpdateService } from "@simplysm/service-server";
|
|
151
|
+
|
|
152
|
+
// Used internally by ServiceServer for WebSocket connections without ver=2 query parameter
|
|
153
|
+
handleV1Connection(webSocket, autoUpdateService);
|
|
154
|
+
```
|