@hotfusion/modeller 0.0.6 → 0.0.8
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 +802 -38
- package/dist/View.js +3 -0
- package/dist/View.js.map +1 -0
- package/dist/connector.js +4 -0
- package/dist/connector.js.map +1 -1
- package/dist/core.js +1 -0
- package/dist/core.js.map +1 -1
- package/dist/extensions/oidc/client.js +221 -0
- package/dist/extensions/oidc/client.js.map +1 -0
- package/dist/extensions/oidc/index.js +192 -0
- package/dist/extensions/oidc/index.js.map +1 -0
- package/dist/logger.js +129 -0
- package/dist/logger.js.map +1 -0
- package/dist/model.js +73 -15
- package/dist/model.js.map +1 -1
- package/dist/server.js +333 -183
- package/dist/server.js.map +1 -1
- package/dist/types/View.d.ts +2 -0
- package/dist/types/View.d.ts.map +1 -0
- package/dist/types/connector.d.ts +1 -1
- package/dist/types/connector.d.ts.map +1 -1
- package/dist/types/core.d.ts +1 -0
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/extensions/oidc/client.d.ts +32 -0
- package/dist/types/extensions/oidc/client.d.ts.map +1 -0
- package/dist/types/extensions/oidc/index.d.ts +20 -0
- package/dist/types/extensions/oidc/index.d.ts.map +1 -0
- package/dist/types/extensions/oidc/oidc.d.ts +20 -0
- package/dist/types/extensions/oidc/oidc.d.ts.map +1 -0
- package/dist/types/extensions/oidc.d.ts +20 -0
- package/dist/types/extensions/oidc.d.ts.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/logger.d.ts +24 -0
- package/dist/types/logger.d.ts.map +1 -0
- package/dist/types/model.d.ts +21 -0
- package/dist/types/model.d.ts.map +1 -1
- package/dist/types/server.d.ts +23 -4
- package/dist/types/server.d.ts.map +1 -1
- package/dist/types/utils/bundler.d.ts +2 -0
- package/dist/types/utils/bundler.d.ts.map +1 -1
- package/dist/types/view.d.ts +13 -0
- package/dist/types/view.d.ts.map +1 -0
- package/dist/utils/bundler.js +138 -39
- package/dist/utils/bundler.js.map +1 -1
- package/dist/view.js +34 -0
- package/dist/view.js.map +1 -0
- package/package.json +16 -7
package/README.md
CHANGED
|
@@ -1,71 +1,835 @@
|
|
|
1
1
|
# @hotfusion/modeller
|
|
2
2
|
|
|
3
|
-
A schema-first
|
|
3
|
+
A schema-first, all-in-one backend framework for Node.js. Define your data model once and get validation, persistence, business logic, HTTP API, real-time WebSocket, authentication, file uploads, background workers, and static file serving — all wired up automatically.
|
|
4
4
|
|
|
5
|
+
Think of it as the Node.js equivalent of IIS or Apache, but built for modern data-driven applications. Where a traditional web server just routes requests, **modeller** understands your data: it knows its shape, enforces its rules, exposes it safely over the network, and reacts to changes in real time.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
### What problem does it solve?
|
|
10
|
+
|
|
11
|
+
Building a production backend typically means stitching together a dozen separate libraries: a validation library, an ORM, an HTTP framework, a WebSocket server, an auth layer, a job scheduler, a file upload handler, and more. Each has its own config, its own conventions, and its own failure modes. Keeping them consistent as your app grows is where most of the complexity lives.
|
|
12
|
+
|
|
13
|
+
**modeller** collapses that stack into a single, coherent package:
|
|
14
|
+
|
|
15
|
+
- **One schema** drives validation, CRUD, API shape, and documentation — no duplication.
|
|
16
|
+
- **One model** handles your business logic: hooks that fire before/after operations, background workers, custom methods, event subscriptions, and file processing.
|
|
17
|
+
- **One server** exposes everything over HTTP and WebSocket automatically. Register a model and your REST API, real-time events, and file upload endpoints exist immediately — no route boilerplate.
|
|
18
|
+
- **One connector** gives the client a typed interface to the entire backend without writing a single fetch call by hand.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
### What makes it different?
|
|
23
|
+
|
|
24
|
+
**Fast deployment.** A fully functional API server with real-time support, auth, sessions, file uploads, and static file serving can be up in under 50 lines of code. No scaffolding, no generators, no config files.
|
|
25
|
+
|
|
26
|
+
**Security by default.** Sessions are signed and encrypted. Private schema fields are stripped from all public responses automatically. Protected routes verify JWTs out of the box. OIDC/OAuth provider support is built in. Chunked file uploads use per-upload IDs with server-side ack and timeout protection.
|
|
27
|
+
|
|
28
|
+
**Real-time built in.** Every model can publish events to WebSocket subscribers scoped to a query. Clients subscribe to a slice of data, dispatch typed events, and receive live updates — with no extra infrastructure.
|
|
29
|
+
|
|
30
|
+
**Schema-first, not code-first.** Your JSON Schema definition is the single source of truth. It drives runtime validation, unique index enforcement, default values, private field filtering, and the metadata endpoint that powers the client connector — all from the same object.
|
|
31
|
+
|
|
32
|
+
**Layered and composable.** Use only what you need. The `Core` layer is a standalone schema-validated CRUD store. The `Model` layer adds behavior on top. The `Server` layer adds transport. Each layer is independently usable and testable.
|
|
33
|
+
|
|
34
|
+
Each layer is usable independently. Start with `Model` for most apps.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install @hotfusion/modeller
|
|
5
42
|
```
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { Model, Server } from '@hotfusion/modeller';
|
|
50
|
+
|
|
51
|
+
const users = new Model('users', {
|
|
52
|
+
type: 'object',
|
|
53
|
+
required: ['email'],
|
|
54
|
+
properties: {
|
|
55
|
+
email : { type: 'string', format: 'email', unique: true },
|
|
56
|
+
name : { type: 'string' },
|
|
57
|
+
password : { type: 'string', private: true },
|
|
58
|
+
role : { type: 'string', enum: ['user', 'admin'], default: 'user' }
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
users.hook({
|
|
63
|
+
id: 'normalize-email',
|
|
64
|
+
on: 'before:insert',
|
|
65
|
+
callback: ({ data }) => { data.email = data.email.toLowerCase(); }
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await users.insert({ email: 'alex@example.com', name: 'Alex' });
|
|
69
|
+
|
|
70
|
+
const server = new Server(3030);
|
|
71
|
+
server.registerModel('system', 'users', users);
|
|
72
|
+
await server.start();
|
|
17
73
|
```
|
|
18
74
|
|
|
19
|
-
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Table of Contents
|
|
78
|
+
|
|
79
|
+
- [Model](#model)
|
|
80
|
+
- [Schema](#schema)
|
|
81
|
+
- [CRUD](#crud)
|
|
82
|
+
- [Hooks](#hooks)
|
|
83
|
+
- [Workers](#workers)
|
|
84
|
+
- [Methods](#methods)
|
|
85
|
+
- [Functions](#functions)
|
|
86
|
+
- [Events & Subscriptions](#events--subscriptions)
|
|
87
|
+
- [Uploads](#uploads)
|
|
88
|
+
- [Streams](#streams)
|
|
89
|
+
- [Configurations](#configurations)
|
|
90
|
+
- [Extensions](#extensions)
|
|
91
|
+
- [Views](#views)
|
|
92
|
+
- [Folders](#folders)
|
|
93
|
+
- [Logging](#logging)
|
|
94
|
+
- [Server](#server)
|
|
95
|
+
- [Registering Models](#registering-models)
|
|
96
|
+
- [Static Folders](#static-folders)
|
|
97
|
+
- [Custom Routes](#custom-routes)
|
|
98
|
+
- [Extensions](#server-extensions)
|
|
99
|
+
- [Auth](#auth)
|
|
100
|
+
- [WebSocket Subscriptions](#websocket-subscriptions)
|
|
101
|
+
- [File Uploads](#file-uploads)
|
|
102
|
+
- [Connector](#connector)
|
|
103
|
+
- [Connecting](#connecting)
|
|
104
|
+
- [CRUD Operations](#crud-operations)
|
|
105
|
+
- [Custom Methods](#custom-methods)
|
|
106
|
+
- [Subscriptions](#subscriptions)
|
|
107
|
+
- [Authentication](#authentication)
|
|
108
|
+
- [File Uploads](#file-uploads-1)
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Model
|
|
113
|
+
|
|
114
|
+
### Schema
|
|
115
|
+
|
|
116
|
+
The schema follows JSON Schema with extra modeller-specific keywords (`unique`, `private`, `default`).
|
|
20
117
|
|
|
21
118
|
```ts
|
|
22
|
-
|
|
119
|
+
const products = new Model('products', {
|
|
120
|
+
type: 'object',
|
|
121
|
+
required: ['name', 'price'],
|
|
122
|
+
properties: {
|
|
123
|
+
name : { type: 'string' },
|
|
124
|
+
price : { type: 'number', minimum: 0 },
|
|
125
|
+
sku : { type: 'string', unique: true },
|
|
126
|
+
internal : { type: 'string', private: true }, // excluded from public responses
|
|
127
|
+
status : { type: 'string', enum: ['active', 'draft'], default: 'draft' }
|
|
128
|
+
}
|
|
129
|
+
});
|
|
23
130
|
```
|
|
24
131
|
|
|
25
132
|
---
|
|
26
133
|
|
|
27
|
-
|
|
134
|
+
### CRUD
|
|
135
|
+
|
|
136
|
+
All CRUD methods are async and available directly on the model instance.
|
|
28
137
|
|
|
29
138
|
```ts
|
|
30
|
-
|
|
139
|
+
// Insert
|
|
140
|
+
const user = await users.insert({ email: 'alex@example.com', name: 'Alex' });
|
|
31
141
|
|
|
32
|
-
|
|
142
|
+
// Get by query
|
|
143
|
+
const found = await users.get({ email: 'alex@example.com' });
|
|
144
|
+
|
|
145
|
+
// List with pagination
|
|
146
|
+
const page = await users.list({ role: 'admin' }, { start: 0, count: 20 });
|
|
147
|
+
|
|
148
|
+
// Find (returns array, no pagination)
|
|
149
|
+
const admins = await users.find({ role: 'admin' });
|
|
150
|
+
|
|
151
|
+
// Update
|
|
152
|
+
await users.update({ _id: user._id }, { name: 'Alexander' });
|
|
153
|
+
|
|
154
|
+
// Delete
|
|
155
|
+
await users.delete({ _id: user._id });
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
### Hooks
|
|
161
|
+
|
|
162
|
+
Hooks run before or after CRUD operations. They are async and can mutate the payload.
|
|
163
|
+
|
|
164
|
+
**Available events:** `before:insert` · `after:insert` · `before:update` · `after:update` · `before:delete` · `after:delete` · `before:list` · `after:list`
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
// Mutate data before insert
|
|
168
|
+
users.hook({
|
|
169
|
+
id: 'hash-password',
|
|
170
|
+
on: 'before:insert',
|
|
171
|
+
callback: async ({ data }) => {
|
|
172
|
+
if (data.password)
|
|
173
|
+
data.password = await bcrypt.hash(data.password, 10);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Run after delete with a delay
|
|
178
|
+
users.hook({
|
|
179
|
+
id: 'cleanup-sessions',
|
|
180
|
+
on: 'after:delete',
|
|
181
|
+
schedule: 2000, // runs 2s after the operation
|
|
182
|
+
callback: ({ key }) => {
|
|
183
|
+
sessions.delete({ userId: key._id });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Listen to multiple events
|
|
188
|
+
users.hook({
|
|
189
|
+
id: 'audit-log',
|
|
190
|
+
on: ['before:insert', 'before:update', 'before:delete'],
|
|
191
|
+
callback: ({ operation, data }) => {
|
|
192
|
+
console.log(`[audit] ${operation}`, data);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Remove a hook at runtime
|
|
197
|
+
users.unhook('audit-log');
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
### Workers
|
|
203
|
+
|
|
204
|
+
Workers are background tasks that run on a fixed interval.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
// Named worker — can be started/stopped by id
|
|
208
|
+
users.worker({
|
|
209
|
+
id : 'cleanup-expired',
|
|
210
|
+
interval : 60_000, // every 60s
|
|
211
|
+
immediate: true, // run immediately on start (default: true)
|
|
212
|
+
handler : async (model) => {
|
|
213
|
+
const expired = await model.find({ expiresAt: { $lt: Date.now() } });
|
|
214
|
+
for (const u of expired) await model.delete({ _id: u._id });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Unsigned worker (no id, runs with all workers)
|
|
219
|
+
users.worker({
|
|
220
|
+
interval: 5000,
|
|
221
|
+
handler : (model) => console.log('tick')
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
users.start(); // start all workers
|
|
225
|
+
users.start('cleanup-expired'); // start one by id
|
|
226
|
+
users.stop('cleanup-expired'); // stop one by id
|
|
227
|
+
users.stop(); // stop all
|
|
228
|
+
users.isRunning('cleanup-expired'); // → boolean
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
### Methods
|
|
234
|
+
|
|
235
|
+
Methods are custom RPC-style handlers exposed over HTTP automatically.
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
users.method({
|
|
239
|
+
id: 'resetPassword',
|
|
240
|
+
handler: async ({ email }, model) => {
|
|
241
|
+
const user = await model.get({ email });
|
|
242
|
+
if (!user) throw { status: 404, message: 'User not found' };
|
|
243
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
244
|
+
await model.update({ _id: user._id }, { resetToken: token });
|
|
245
|
+
return { token };
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Call locally
|
|
250
|
+
const result = await users.call('resetPassword', { email: 'alex@example.com' });
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
HTTP: `POST /system/users/resetPassword`
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
### Functions
|
|
258
|
+
|
|
259
|
+
Functions are bound directly to the model instance, useful for shared utilities.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
users.function('findByEmail', async function(email: string) {
|
|
263
|
+
return this.get({ email });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Call on the model instance directly
|
|
267
|
+
const user = await users.findByEmail('alex@example.com');
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
### Events & Subscriptions
|
|
273
|
+
|
|
274
|
+
Subscriptions are scoped real-time channels. A client subscribes with a query (e.g. `{ roomId: 'abc' }`) and receives any payload the server publishes to that query. Events are named actions the client can dispatch back to the server through the same subscription.
|
|
275
|
+
|
|
276
|
+
#### Registering events (server)
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
const chat = new Model('chat', schema);
|
|
280
|
+
|
|
281
|
+
// Register a named event the client can dispatch
|
|
282
|
+
chat.event({
|
|
283
|
+
id: 'sendMessage',
|
|
284
|
+
schema: {
|
|
33
285
|
type: 'object',
|
|
34
|
-
required: ['
|
|
286
|
+
required: ['text'],
|
|
35
287
|
properties: {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
password : { type: 'string', private: true },
|
|
39
|
-
role : { type: 'string', enum: ['user', 'admin'], default: 'user' }
|
|
288
|
+
text : { type: 'string' },
|
|
289
|
+
userId : { type: 'string' }
|
|
40
290
|
}
|
|
291
|
+
},
|
|
292
|
+
handler: async ({ text, userId }, model, ctx) => {
|
|
293
|
+
// Persist the message
|
|
294
|
+
const message = await model.insert({ text, userId, createdAt: Date.now() });
|
|
295
|
+
|
|
296
|
+
// Publish to all clients subscribed to the same roomId
|
|
297
|
+
const sub = model.subscription({ roomId: ctx.scope });
|
|
298
|
+
sub.publish({ type: 'message', message });
|
|
299
|
+
|
|
300
|
+
return { delivered: true };
|
|
301
|
+
}
|
|
41
302
|
});
|
|
42
303
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
304
|
+
// Register multiple events on the same model
|
|
305
|
+
chat.event({
|
|
306
|
+
id: 'typing',
|
|
307
|
+
handler: async ({ userId }, model, ctx) => {
|
|
308
|
+
model.subscription({ roomId: ctx.scope }).publish({ type: 'typing', userId });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
#### Publishing from anywhere on the server
|
|
314
|
+
|
|
315
|
+
You can publish to subscribers at any point — inside hooks, workers, methods, or external triggers.
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
// Inside a hook — notify subscribers after every insert
|
|
319
|
+
chat.hook({
|
|
320
|
+
id: 'broadcast-on-insert',
|
|
321
|
+
on: 'after:insert',
|
|
322
|
+
callback: ({ data }) => {
|
|
323
|
+
chat.subscription({ roomId: data.roomId }).publish({ type: 'new-message', data });
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Inside a worker — push periodic updates
|
|
328
|
+
chat.worker({
|
|
329
|
+
id : 'heartbeat',
|
|
330
|
+
interval: 10_000,
|
|
331
|
+
handler : (model) => {
|
|
332
|
+
model.subscription({}).publish({ type: 'heartbeat', ts: Date.now() });
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// From a custom method — trigger an event in response to an HTTP call
|
|
337
|
+
chat.method({
|
|
338
|
+
id: 'closeRoom',
|
|
339
|
+
handler: async ({ roomId }, model) => {
|
|
340
|
+
await model.delete({ roomId });
|
|
341
|
+
model.subscription({ roomId }).publish({ type: 'room-closed', roomId });
|
|
342
|
+
return { ok: true };
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
### Uploads
|
|
350
|
+
|
|
351
|
+
Register a handler for multipart file uploads.
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
users.upload({
|
|
355
|
+
id: 'avatar',
|
|
356
|
+
handler: async (file: Buffer, fields, model) => {
|
|
357
|
+
const filename = `${Date.now()}.jpg`;
|
|
358
|
+
fs.writeFileSync(`./uploads/${filename}`, file);
|
|
359
|
+
await model.update({ _id: fields._id }, { avatar: filename });
|
|
360
|
+
return { filename };
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
HTTP: `POST /system/users/avatar` (multipart/form-data)
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
### Streams
|
|
370
|
+
|
|
371
|
+
Register a handler for chunked binary uploads over WebSocket.
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
users.stream({
|
|
375
|
+
id: 'video',
|
|
376
|
+
handler: async (buffer: Buffer, meta) => {
|
|
377
|
+
fs.writeFileSync(`./uploads/${meta.filename}`, buffer);
|
|
378
|
+
return { path: meta.filename };
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
### Configurations
|
|
386
|
+
|
|
387
|
+
Store and retrieve key/value configuration on the model.
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
users.configurations({
|
|
391
|
+
maxLoginAttempts : 5,
|
|
392
|
+
sessionTtl : 3600
|
|
47
393
|
});
|
|
48
394
|
|
|
49
|
-
|
|
395
|
+
// Or lazily
|
|
396
|
+
users.configurations(() => ({
|
|
397
|
+
secret: process.env.JWT_SECRET
|
|
398
|
+
}));
|
|
399
|
+
|
|
400
|
+
users.getConfiguration('maxLoginAttempts'); // → 5
|
|
401
|
+
users.setConfiguration('maxLoginAttempts', 10);
|
|
402
|
+
users.getConfigurations(); // → { maxLoginAttempts: 10, ... }
|
|
50
403
|
```
|
|
51
404
|
|
|
52
405
|
---
|
|
53
406
|
|
|
54
|
-
|
|
407
|
+
### Extensions
|
|
408
|
+
|
|
409
|
+
Attach sub-models as extensions. They inherit their own scoped routes automatically.
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
const posts = new Model('posts', { ... });
|
|
413
|
+
|
|
414
|
+
const users = new Model('users', schema, {
|
|
415
|
+
extensions: [posts]
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Access extension on the model
|
|
419
|
+
users.posts.insert({ title: 'Hello' });
|
|
420
|
+
users.getExtensions(); // → [posts]
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
HTTP routes for `posts` are mounted at `/system/users/posts/...`
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
### Views
|
|
428
|
+
|
|
429
|
+
Serve a bundled Vue SFC as an HTML view.
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
import { Bundler } from '@hotfusion/modeller';
|
|
433
|
+
import path from 'path';
|
|
434
|
+
|
|
435
|
+
const bundler = new Bundler(path.resolve(__dirname, './view/index.vue'));
|
|
436
|
+
|
|
437
|
+
const firewall = new Model('firewall', schema, { bundler });
|
|
438
|
+
|
|
439
|
+
firewall.view('/', { title: 'Firewall Manager' });
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
The view is compiled once on startup, cached, and rebuilt automatically when the source file changes.
|
|
443
|
+
|
|
444
|
+
HTTP: `GET /system/firewall/`
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
### Folders
|
|
449
|
+
|
|
450
|
+
Serve a local directory as static files.
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
firewall.folder('/assets/fonts', path.resolve(__dirname, '../fonts'));
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
HTTP: `GET /assets/fonts/...` → serves files from the given directory.
|
|
55
457
|
|
|
56
|
-
|
|
57
|
-
|--------------------------------------|-----------------------------------------------------------------------|
|
|
58
|
-
| [Core layer](./docs/CORE.md) | Schema, validator, CRUD primitives, indexes, adapter, trash. |
|
|
59
|
-
| [Model layer](./docs/MODEL.md) | Hooks, workers, methods, uploads, streams, events, subscriptions, logging, extensions. |
|
|
60
|
-
| [Server layer](./docs/SERVER.md) | HTTP + WebSocket transport, auth, server events, public API. |
|
|
61
|
-
| [Utilities](./docs/UTILITIES.md) | `Bundler`, `KeyGen`, `Adapter`, encryption, JWT helpers. |
|
|
62
|
-
| [Errors](./docs/ERRORS.md) | Error codes thrown by the framework. |
|
|
63
|
-
| [Common patterns](./docs/PATTERNS.md)| Recipes for publish-on-update, FK validation, extensions, workers. |
|
|
458
|
+
Throws immediately at startup if the path does not exist.
|
|
64
459
|
|
|
65
|
-
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
### Logging
|
|
463
|
+
|
|
464
|
+
Every model has a built-in structured logger. Log level is `silent` by default.
|
|
465
|
+
|
|
466
|
+
```ts
|
|
467
|
+
const users = new Model('users', schema, {
|
|
468
|
+
level : 'info', // 'debug' | 'info' | 'warn' | 'error' | 'silent'
|
|
469
|
+
timestamp: true,
|
|
470
|
+
features : {
|
|
471
|
+
hook : true,
|
|
472
|
+
worker : true,
|
|
473
|
+
method : true,
|
|
474
|
+
event : true,
|
|
475
|
+
crud : false, // suppress CRUD logs
|
|
476
|
+
},
|
|
477
|
+
onEntry: (entry) => {
|
|
478
|
+
// entry: { level, feature, modelId, message, timestamp, callSite?, data? }
|
|
479
|
+
myLogService.write(entry);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Use inside methods, hooks, workers
|
|
484
|
+
users.method({
|
|
485
|
+
id: 'doSomething',
|
|
486
|
+
handler: async (args, model) => {
|
|
487
|
+
model.log.info('method', 'Doing something', { args });
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
You can also plug in a custom adapter:
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
const users = new Model('users', schema, {
|
|
496
|
+
logAdapter: {
|
|
497
|
+
log: (entry) => sentryCapture(entry)
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
---
|
|
503
|
+
|
|
504
|
+
## Server
|
|
505
|
+
|
|
506
|
+
### Registering Models
|
|
507
|
+
|
|
508
|
+
```ts
|
|
509
|
+
import { Server } from '@hotfusion/modeller';
|
|
510
|
+
|
|
511
|
+
const server = new Server(3030, {
|
|
512
|
+
secret: process.env.SECRET,
|
|
513
|
+
domain: 'myapp.com'
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
server.registerModel('system', 'users', users);
|
|
517
|
+
server.registerModel('system', 'products', products);
|
|
518
|
+
|
|
519
|
+
await server.start();
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
All CRUD, methods, uploads, views, and folders registered on each model are mounted automatically.
|
|
523
|
+
|
|
524
|
+
**Auto-generated routes for each model:**
|
|
525
|
+
|
|
526
|
+
| Operation | Method | Path |
|
|
527
|
+
|-----------|--------|------|
|
|
528
|
+
| insert | POST | `/:id/:scope/insert` |
|
|
529
|
+
| update | PUT | `/:id/:scope/update` |
|
|
530
|
+
| delete | DELETE | `/:id/:scope/delete` |
|
|
531
|
+
| get | GET | `/:id/:scope/get` |
|
|
532
|
+
| list | GET | `/:id/:scope/list` |
|
|
533
|
+
| find | POST | `/:id/:scope/find` |
|
|
534
|
+
|
|
535
|
+
Metadata for all registered models is available at `GET /@metadata`.
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
### Static Folders
|
|
540
|
+
|
|
541
|
+
Declared on the model via `.folder()` — see [Folders](#folders) above. The folder is served globally (not scoped to the model path).
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
### Custom Routes
|
|
546
|
+
|
|
547
|
+
```ts
|
|
548
|
+
server.route({
|
|
549
|
+
method : 'get',
|
|
550
|
+
path : '/health',
|
|
551
|
+
callback : async (ctx) => ({ status: 'ok' })
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
server.route({
|
|
555
|
+
method : 'get',
|
|
556
|
+
path : '/admin/stats',
|
|
557
|
+
protected : true, // requires Authorization: Bearer <token>
|
|
558
|
+
callback : async (ctx) => ({ users: await users.list() })
|
|
559
|
+
});
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
### Server Extensions
|
|
565
|
+
|
|
566
|
+
Extensions hook into the server setup lifecycle.
|
|
567
|
+
|
|
568
|
+
```ts
|
|
569
|
+
const oidcExtension = {
|
|
570
|
+
id: 'oidc',
|
|
571
|
+
setup(server, config) {
|
|
572
|
+
server.router.get('/auth/callback', async (ctx) => {
|
|
573
|
+
// handle callback
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
server.registerExtension(oidcExtension);
|
|
579
|
+
server.setOIDCProviders('auth', [
|
|
580
|
+
{ id: 'google', clientId: '...', clientSecret: '...' }
|
|
581
|
+
]);
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
### Auth
|
|
587
|
+
|
|
588
|
+
```ts
|
|
589
|
+
server.setOIDCProviders('auth', providers);
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
Protected routes verify a JWT `Authorization: Bearer <token>` header. The decoded payload is available as `ctx.token`.
|
|
593
|
+
|
|
594
|
+
---
|
|
595
|
+
|
|
596
|
+
### WebSocket Subscriptions
|
|
597
|
+
|
|
598
|
+
Handled automatically for any model that has `.event()` registered. Clients use the [Connector](#connector) to subscribe.
|
|
599
|
+
|
|
600
|
+
---
|
|
601
|
+
|
|
602
|
+
### File Uploads
|
|
603
|
+
|
|
604
|
+
Multipart uploads are handled automatically for any model with `.upload()` registered.
|
|
605
|
+
|
|
606
|
+
Chunked binary streaming over WebSocket is handled automatically for any model with `.stream()` registered.
|
|
607
|
+
|
|
608
|
+
---
|
|
609
|
+
|
|
610
|
+
## Connector
|
|
611
|
+
|
|
612
|
+
The `Connector` is the client-side counterpart — it connects to a running server over HTTP + WebSocket and exposes a typed interface to all model operations.
|
|
613
|
+
|
|
614
|
+
### Connecting
|
|
615
|
+
|
|
616
|
+
```ts
|
|
617
|
+
import { Connector } from '@hotfusion/modeller';
|
|
618
|
+
|
|
619
|
+
const connector = new Connector('http://localhost:3030');
|
|
620
|
+
await connector.connect();
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
---
|
|
624
|
+
|
|
625
|
+
### CRUD Operations
|
|
626
|
+
|
|
627
|
+
```ts
|
|
628
|
+
// Generic operation by path (auto-detects method from metadata)
|
|
629
|
+
const users = await connector.operation('system/users/list');
|
|
630
|
+
const user = await connector.operation('system/users/insert', { email: 'alex@example.com' });
|
|
631
|
+
|
|
632
|
+
// Raw HTTP
|
|
633
|
+
await connector.get('system/users/list');
|
|
634
|
+
await connector.post('system/users/insert', { email: 'alex@example.com' });
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
---
|
|
638
|
+
|
|
639
|
+
### Custom Methods
|
|
640
|
+
|
|
641
|
+
```ts
|
|
642
|
+
const result = await connector.operation('system/users/resetPassword', {
|
|
643
|
+
email: 'alex@example.com'
|
|
644
|
+
});
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
---
|
|
648
|
+
|
|
649
|
+
### Subscriptions
|
|
650
|
+
|
|
651
|
+
Subscriptions connect the client to a scoped real-time channel. The query you pass acts as the scope — only payloads published to the same query reach your listener.
|
|
652
|
+
|
|
653
|
+
#### Basic subscribe and receive
|
|
654
|
+
|
|
655
|
+
```ts
|
|
656
|
+
const connector = new Connector('http://localhost:3030');
|
|
657
|
+
await connector.connect();
|
|
658
|
+
|
|
659
|
+
const { proxy } = await connector.subscribe(
|
|
660
|
+
'system', // model id
|
|
661
|
+
'chat', // scope
|
|
662
|
+
{ roomId: 'abc' }, // query — scopes this subscription to room abc
|
|
663
|
+
(payload) => {
|
|
664
|
+
// Called every time the server publishes to { roomId: 'abc' }
|
|
665
|
+
console.log('received:', payload);
|
|
666
|
+
|
|
667
|
+
if (payload.type === 'message') {
|
|
668
|
+
appendMessage(payload.message);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (payload.type === 'typing') {
|
|
672
|
+
showTypingIndicator(payload.userId);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
);
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
#### Dispatching events back to the server
|
|
679
|
+
|
|
680
|
+
`proxy` contains one method per registered event. Calling it sends the event to the server over the same WebSocket and waits for the handler's return value.
|
|
681
|
+
|
|
682
|
+
```ts
|
|
683
|
+
// Send a message — calls the 'sendMessage' event handler on the server
|
|
684
|
+
const result = await proxy.sendMessage({ text: 'Hello!', userId: 'u1' });
|
|
685
|
+
console.log(result); // → { delivered: true }
|
|
686
|
+
|
|
687
|
+
// Notify others you're typing
|
|
688
|
+
await proxy.typing({ userId: 'u1' });
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
#### Full chat example (client)
|
|
692
|
+
|
|
693
|
+
```ts
|
|
694
|
+
const connector = new Connector('http://localhost:3030');
|
|
695
|
+
await connector.connect();
|
|
696
|
+
|
|
697
|
+
const messages = [];
|
|
698
|
+
|
|
699
|
+
const { proxy } = await connector.subscribe(
|
|
700
|
+
'system',
|
|
701
|
+
'chat',
|
|
702
|
+
{ roomId: 'room-42' },
|
|
703
|
+
(payload) => {
|
|
704
|
+
if (payload.type === 'message') {
|
|
705
|
+
messages.push(payload.message);
|
|
706
|
+
renderMessages(messages);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (payload.type === 'room-closed') {
|
|
710
|
+
alert('This room has been closed.');
|
|
711
|
+
connector.unsubscribe('system', 'chat', { roomId: 'room-42' });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
// User sends a message
|
|
717
|
+
sendButton.addEventListener('click', async () => {
|
|
718
|
+
await proxy.sendMessage({ text: input.value, userId: currentUser._id });
|
|
719
|
+
input.value = '';
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
// User is typing
|
|
723
|
+
input.addEventListener('input', () => {
|
|
724
|
+
proxy.typing({ userId: currentUser._id });
|
|
725
|
+
});
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
#### Unsubscribing
|
|
729
|
+
|
|
730
|
+
```ts
|
|
731
|
+
// Remove the listener and tell the server to stop tracking this subscription
|
|
732
|
+
connector.unsubscribe('system', 'chat', { roomId: 'room-42' });
|
|
733
|
+
|
|
734
|
+
// Or unsubscribe a specific listener function
|
|
735
|
+
connector.unsubscribe('system', 'chat', { roomId: 'room-42' }, myListener);
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
Subscriptions are also cleaned up automatically on socket disconnect.
|
|
739
|
+
|
|
740
|
+
---
|
|
741
|
+
|
|
742
|
+
### Authentication
|
|
743
|
+
|
|
744
|
+
```ts
|
|
745
|
+
// Credential login
|
|
746
|
+
const token = await connector.authenticate('alex@example.com', 'password');
|
|
747
|
+
|
|
748
|
+
// OIDC / OAuth (opens a popup)
|
|
749
|
+
await connector.authorize('google');
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
---
|
|
753
|
+
|
|
754
|
+
### File Uploads
|
|
755
|
+
|
|
756
|
+
Files are detected automatically inside any operation payload — just pass a `{ source: File }` object.
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
// Single file inside a regular operation
|
|
760
|
+
await connector.operation('system/users/avatar', {
|
|
761
|
+
_id : user._id,
|
|
762
|
+
avatar : { source: fileInputRef.files[0] }
|
|
763
|
+
});
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
#### Chunked streaming with progress
|
|
767
|
+
|
|
768
|
+
Large files are uploaded in chunks over WebSocket with per-chunk server acknowledgement. Use the `onProgress` callback to track progress on the client.
|
|
769
|
+
|
|
770
|
+
```ts
|
|
771
|
+
await connector.operation('system/filesystem/upload', { file: { source: myFile } }, {
|
|
772
|
+
stream: {
|
|
773
|
+
path : 'system/filesystem/stream',
|
|
774
|
+
onProgress: (sent, total, file) => {
|
|
775
|
+
const percent = Math.round((sent / total) * 100);
|
|
776
|
+
console.log(`${file.name}: ${percent}%`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
`onProgress` receives three arguments: `sent` (chunks acknowledged so far), `total` (total chunk count), and `file` (the original `File` object). It is called once at 0% when the server confirms it is ready, then once per acknowledged chunk.
|
|
783
|
+
|
|
784
|
+
#### Example: progress bar in the browser
|
|
785
|
+
|
|
786
|
+
```ts
|
|
787
|
+
const progressBar = document.getElementById('progress');
|
|
788
|
+
|
|
789
|
+
await connector.operation('system/filesystem/upload', { file: { source: input.files[0] } }, {
|
|
790
|
+
stream: {
|
|
791
|
+
path : 'system/filesystem/stream',
|
|
792
|
+
onProgress: (sent, total, file) => {
|
|
793
|
+
const percent = Math.round((sent / total) * 100);
|
|
794
|
+
progressBar.style.width = `${percent}%`;
|
|
795
|
+
progressBar.textContent = `${percent}% — ${file.name}`;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
progressBar.textContent = 'Upload complete';
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
#### Multiple files
|
|
804
|
+
|
|
805
|
+
When the payload contains multiple `{ source: File }` fields, each file is streamed sequentially. `onProgress` fires independently for each file.
|
|
806
|
+
|
|
807
|
+
```ts
|
|
808
|
+
await connector.operation('system/gallery/upload', {
|
|
809
|
+
thumbnail : { source: files[0] },
|
|
810
|
+
full : { source: files[1] }
|
|
811
|
+
}, {
|
|
812
|
+
stream: {
|
|
813
|
+
path : 'system/filesystem/stream',
|
|
814
|
+
onProgress: (sent, total, file) => {
|
|
815
|
+
console.log(`Uploading ${file.name}: ${Math.round(sent / total * 100)}%`);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
#### Resume support
|
|
822
|
+
|
|
823
|
+
If a chunked upload is interrupted (network drop, page refresh), the connector can resume from the last acknowledged chunk — the server tracks which chunks it has already received per `uploadId`. Resume is handled automatically when you restart the upload with the same session.
|
|
824
|
+
|
|
825
|
+
```ts
|
|
826
|
+
socket.on('upload:resume', async ({ uploadId }) => {
|
|
827
|
+
// server replies with fromChunk — the connector picks up from there
|
|
828
|
+
});
|
|
829
|
+
```
|
|
66
830
|
|
|
67
831
|
---
|
|
68
832
|
|
|
69
833
|
## License
|
|
70
834
|
|
|
71
|
-
UNLICENSED — internal HotFusion project.
|
|
835
|
+
UNLICENSED — internal HotFusion project.
|