@hotfusion/modeller 0.0.7 → 0.0.9

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