@hotfusion/modeller 0.0.7 → 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.
Files changed (44) hide show
  1. package/README.md +802 -38
  2. package/dist/View.js +3 -0
  3. package/dist/View.js.map +1 -0
  4. package/dist/connector.js +1 -0
  5. package/dist/connector.js.map +1 -1
  6. package/dist/extensions/oidc/client.js +221 -0
  7. package/dist/extensions/oidc/client.js.map +1 -0
  8. package/dist/extensions/oidc/index.js +192 -0
  9. package/dist/extensions/oidc/index.js.map +1 -0
  10. package/dist/logger.js +129 -0
  11. package/dist/logger.js.map +1 -0
  12. package/dist/model.js +53 -24
  13. package/dist/model.js.map +1 -1
  14. package/dist/server.js +333 -183
  15. package/dist/server.js.map +1 -1
  16. package/dist/types/View.d.ts +2 -0
  17. package/dist/types/View.d.ts.map +1 -0
  18. package/dist/types/connector.d.ts +1 -1
  19. package/dist/types/connector.d.ts.map +1 -1
  20. package/dist/types/extensions/oidc/client.d.ts +32 -0
  21. package/dist/types/extensions/oidc/client.d.ts.map +1 -0
  22. package/dist/types/extensions/oidc/index.d.ts +20 -0
  23. package/dist/types/extensions/oidc/index.d.ts.map +1 -0
  24. package/dist/types/extensions/oidc/oidc.d.ts +20 -0
  25. package/dist/types/extensions/oidc/oidc.d.ts.map +1 -0
  26. package/dist/types/extensions/oidc.d.ts +20 -0
  27. package/dist/types/extensions/oidc.d.ts.map +1 -0
  28. package/dist/types/index.d.ts +2 -0
  29. package/dist/types/index.d.ts.map +1 -1
  30. package/dist/types/logger.d.ts +24 -0
  31. package/dist/types/logger.d.ts.map +1 -0
  32. package/dist/types/model.d.ts +13 -0
  33. package/dist/types/model.d.ts.map +1 -1
  34. package/dist/types/server.d.ts +23 -4
  35. package/dist/types/server.d.ts.map +1 -1
  36. package/dist/types/utils/bundler.d.ts +2 -0
  37. package/dist/types/utils/bundler.d.ts.map +1 -1
  38. package/dist/types/view.d.ts +13 -0
  39. package/dist/types/view.d.ts.map +1 -0
  40. package/dist/utils/bundler.js +138 -39
  41. package/dist/utils/bundler.js.map +1 -1
  42. package/dist/view.js +34 -0
  43. package/dist/view.js.map +1 -0
  44. package/package.json +16 -7
package/README.md CHANGED
@@ -1,71 +1,835 @@
1
1
  # @hotfusion/modeller
2
2
 
3
- A schema-first data layer for Node.js, built as a three-layer chain:
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
- ┌──────────────┐ data primitives — schema, validation, CRUD,
7
- │ core.ts │ indexes, persistence adapter, trash
8
- └──────┬───────┘
9
- extends
10
- ┌──────▼───────┐ behavior — hooks, workers, methods, uploads,
11
- │ model.ts │ streams, events, subscriptions, logging
12
- └──────┬───────┘
13
- │ consumed by
14
- ┌──────▼───────┐ transport HTTP + WebSocket exposure of any model
15
- server.ts │ (auth, sessions, socket fan-out)
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
- Each layer is usable on its own. Pick the layer that matches the problem you're solving.
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
- import { Adapter, Model, Server, Bundler, KeyGen } from '@hotfusion/modeller';
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
- ## Quick Start
134
+ ### CRUD
135
+
136
+ All CRUD methods are async and available directly on the model instance.
28
137
 
29
138
  ```ts
30
- import { Model } from '@hotfusion/modeller';
139
+ // Insert
140
+ const user = await users.insert({ email: 'alex@example.com', name: 'Alex' });
31
141
 
32
- const users = new Model('users', {
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: ['email'],
286
+ required: ['text'],
35
287
  properties: {
36
- email : { type: 'string', format: 'email', unique: true },
37
- name : { type: 'string' },
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
- users.hook({
44
- id: 'normalize-email',
45
- on: 'before:insert',
46
- callback: ({ data }) => { data.email = data.email.toLowerCase(); }
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
- await users.insert({ email: 'alex@hotfusion.ca', name: 'Alex' });
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
- ## Documentation
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
- | Section | Covers |
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
- Start with **Model** if you're building an app. Drop down to **Core** when you need to understand schema or persistence semantics. Read **Server** when you're ready to expose models over the network.
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.