@venizia/ignis-docs 0.2.1-0 → 0.2.1-1

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 (145) hide show
  1. package/content/best-practices/architectural-patterns.md +3 -3
  2. package/content/best-practices/code-style-standards/naming-conventions.md +1 -1
  3. package/content/best-practices/contribution-workflow.md +2 -2
  4. package/content/best-practices/error-handling.md +94 -89
  5. package/content/best-practices/security-guidelines.md +5 -5
  6. package/content/extensions/components/api-reference.md +22 -21
  7. package/content/extensions/components/authentication/api.md +64 -28
  8. package/content/extensions/components/authentication/errors.md +19 -5
  9. package/content/extensions/components/authentication/index.md +19 -18
  10. package/content/extensions/components/authentication/usage.md +55 -30
  11. package/content/extensions/components/authorization/api.md +351 -84
  12. package/content/extensions/components/authorization/errors.md +51 -17
  13. package/content/extensions/components/authorization/getting-started.md +227 -0
  14. package/content/extensions/components/authorization/index.md +24 -16
  15. package/content/extensions/components/authorization/usage.md +45 -21
  16. package/content/extensions/components/health-check.md +15 -9
  17. package/content/extensions/components/index.md +24 -90
  18. package/content/extensions/components/mail/api.md +105 -54
  19. package/content/extensions/components/mail/errors.md +12 -10
  20. package/content/extensions/components/mail/index.md +32 -13
  21. package/content/extensions/components/mail/usage.md +26 -18
  22. package/content/extensions/components/request-tracker.md +18 -14
  23. package/content/extensions/components/socket-io/api.md +377 -882
  24. package/content/extensions/components/socket-io/errors.md +49 -51
  25. package/content/extensions/components/socket-io/index.md +72 -88
  26. package/content/extensions/components/socket-io/usage.md +107 -117
  27. package/content/extensions/components/static-asset/api.md +83 -31
  28. package/content/extensions/components/static-asset/errors.md +18 -7
  29. package/content/extensions/components/static-asset/index.md +18 -11
  30. package/content/extensions/components/static-asset/usage.md +11 -6
  31. package/content/extensions/components/template/index.md +3 -3
  32. package/content/extensions/components/websocket/api.md +58 -27
  33. package/content/extensions/components/websocket/errors.md +3 -3
  34. package/content/extensions/components/websocket/index.md +8 -7
  35. package/content/extensions/components/websocket/usage.md +21 -8
  36. package/content/extensions/helpers/cron/index.md +8 -7
  37. package/content/extensions/helpers/crypto/index.md +16 -8
  38. package/content/extensions/helpers/crypto/reference.md +96 -24
  39. package/content/extensions/helpers/env/index.md +14 -10
  40. package/content/extensions/helpers/error/index.md +99 -23
  41. package/content/extensions/helpers/index.md +61 -47
  42. package/content/extensions/helpers/inversion/index.md +23 -6
  43. package/content/extensions/helpers/inversion/reference.md +30 -22
  44. package/content/extensions/helpers/kafka/admin.md +3 -2
  45. package/content/extensions/helpers/kafka/compile-binary.md +69 -44
  46. package/content/extensions/helpers/kafka/consumer.md +24 -21
  47. package/content/extensions/helpers/kafka/examples.md +22 -234
  48. package/content/extensions/helpers/kafka/index.md +30 -62
  49. package/content/extensions/helpers/kafka/producer.md +31 -26
  50. package/content/extensions/helpers/kafka/schema-registry.md +19 -14
  51. package/content/extensions/helpers/logger/hf-logger.md +49 -22
  52. package/content/extensions/helpers/logger/index.md +37 -12
  53. package/content/extensions/helpers/logger/pino.md +31 -11
  54. package/content/extensions/helpers/logger/reference.md +243 -52
  55. package/content/extensions/helpers/network/api.md +65 -30
  56. package/content/extensions/helpers/network/index.md +32 -11
  57. package/content/extensions/helpers/queue/index.md +17 -6
  58. package/content/extensions/helpers/queue/reference.md +52 -25
  59. package/content/extensions/helpers/redis/index.md +29 -11
  60. package/content/extensions/helpers/redis/reference.md +85 -28
  61. package/content/extensions/helpers/secrets/index.md +82 -12
  62. package/content/extensions/helpers/socket-io/api.md +40 -21
  63. package/content/extensions/helpers/socket-io/index.md +26 -10
  64. package/content/extensions/helpers/storage/api.md +42 -24
  65. package/content/extensions/helpers/storage/index.md +10 -7
  66. package/content/extensions/helpers/types/index.md +20 -7
  67. package/content/extensions/helpers/types/reference.md +53 -14
  68. package/content/extensions/helpers/uid/index.md +166 -10
  69. package/content/extensions/helpers/websocket/api.md +52 -13
  70. package/content/extensions/helpers/websocket/index.md +7 -4
  71. package/content/extensions/helpers/worker-thread/index.md +9 -5
  72. package/content/extensions/helpers/worker-thread/reference.md +20 -20
  73. package/content/extensions/index.md +38 -39
  74. package/content/extensions/src-details/mcp-server.md +96 -548
  75. package/content/guides/core-concepts/persistent/datasources.md +2 -2
  76. package/content/guides/core-concepts/persistent/index.md +5 -1
  77. package/content/guides/core-concepts/persistent/pglite.md +218 -0
  78. package/content/guides/core-concepts/persistent/postgres-drivers.md +21 -15
  79. package/content/guides/core-concepts/persistent/search-meilisearch.md +22 -12
  80. package/content/guides/core-concepts/persistent/search-typesense.md +26 -14
  81. package/content/guides/core-concepts/persistent/sqlite.md +296 -0
  82. package/content/guides/core-concepts/persistent/transactions.md +12 -11
  83. package/content/guides/get-started/5-minute-quickstart.md +59 -206
  84. package/content/guides/get-started/philosophy.md +135 -670
  85. package/content/guides/get-started/setup.md +53 -74
  86. package/content/guides/migrations/redis-helpers-migration.md +4 -3
  87. package/content/guides/migrations/scoped-rbac-migration.md +5 -5
  88. package/content/guides/migrations/unified-connectors-migration.md +7 -6
  89. package/content/guides/tutorials/realtime-chat.md +1 -1
  90. package/content/references/base/application.md +63 -21
  91. package/content/references/base/components.md +3 -3
  92. package/content/references/base/connectors.md +14 -14
  93. package/content/references/base/controllers.md +12 -12
  94. package/content/references/base/datasources-reference.md +32 -31
  95. package/content/references/base/datasources.md +6 -6
  96. package/content/references/base/dependency-injection.md +12 -11
  97. package/content/references/base/filter-system/application-usage.md +54 -30
  98. package/content/references/base/filter-system/array-operators.md +24 -46
  99. package/content/references/base/filter-system/comparison-operators.md +47 -67
  100. package/content/references/base/filter-system/default-filter.md +59 -53
  101. package/content/references/base/filter-system/fields-order-pagination.md +92 -146
  102. package/content/references/base/filter-system/index.md +25 -13
  103. package/content/references/base/filter-system/json-filtering.md +45 -184
  104. package/content/references/base/filter-system/list-operators.md +23 -53
  105. package/content/references/base/filter-system/logical-operators.md +63 -121
  106. package/content/references/base/filter-system/null-operators.md +34 -104
  107. package/content/references/base/filter-system/pattern-matching.md +40 -55
  108. package/content/references/base/filter-system/quick-reference.md +86 -198
  109. package/content/references/base/filter-system/range-operators.md +18 -46
  110. package/content/references/base/filter-system/tips.md +6 -6
  111. package/content/references/base/filter-system/use-cases.md +33 -15
  112. package/content/references/base/grpc-controllers.md +53 -17
  113. package/content/references/base/index.md +5 -3
  114. package/content/references/base/middlewares.md +11 -10
  115. package/content/references/base/models-reference.md +17 -17
  116. package/content/references/base/models.md +4 -3
  117. package/content/references/base/providers.md +8 -8
  118. package/content/references/base/repositories/advanced.md +224 -326
  119. package/content/references/base/repositories/index.md +19 -6
  120. package/content/references/base/repositories/mixins.md +5 -5
  121. package/content/references/base/repositories/relations.md +160 -293
  122. package/content/references/base/repositories/soft-deletable.md +16 -6
  123. package/content/references/base/secrets.md +17 -13
  124. package/content/references/base/services.md +6 -4
  125. package/content/references/configuration/environment-variables.md +31 -23
  126. package/content/references/configuration/index.md +6 -4
  127. package/content/references/index.md +1 -1
  128. package/content/references/utilities/duration.md +85 -0
  129. package/content/references/utilities/index.md +5 -1
  130. package/content/references/utilities/jsx-reference.md +11 -11
  131. package/content/references/utilities/jsx.md +2 -2
  132. package/content/references/utilities/module.md +78 -25
  133. package/content/references/utilities/request.md +2 -1
  134. package/content/references/utilities/retry.md +139 -0
  135. package/content/references/utilities/schema.md +2 -2
  136. package/content/references/utilities/statuses-reference.md +4 -4
  137. package/content/references/utilities/statuses.md +5 -5
  138. package/dist/mcp-server/index.js +0 -0
  139. package/dist/mcp-server/tools/github/list-project-files.tool.d.ts +1 -1
  140. package/dist/mcp-server/tools/github/list-project-files.tool.d.ts.map +1 -1
  141. package/dist/mcp-server/tools/github/list-project-files.tool.js +3 -3
  142. package/dist/mcp-server/tools/github/search-code.tool.js +1 -1
  143. package/dist/mcp-server/tools/github/verify-dependencies.tool.js +1 -1
  144. package/dist/mcp-server/tools/github/view-source-file.tool.js +1 -1
  145. package/package.json +17 -16
@@ -10,14 +10,14 @@ Every binding, endpoint, type, and internal mechanism of `StaticAssetComponent`.
10
10
 
11
11
  **Files:**
12
12
 
13
- - [`packages/core/src/components/static-asset/component.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/static-asset/component.ts)
14
- - [`packages/core/src/components/static-asset/common/types.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/static-asset/common/types.ts)
15
- - [`packages/core/src/components/static-asset/common/constants.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/static-asset/common/constants.ts)
16
- - [`packages/core/src/components/static-asset/common/keys.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/static-asset/common/keys.ts)
17
- - [`packages/core/src/components/static-asset/controller/factory.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/static-asset/controller/factory.ts)
18
- - [`packages/core/src/components/static-asset/controller/base.definition.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/static-asset/controller/base.definition.ts)
19
- - [`packages/core/src/components/static-asset/models/base.model.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/static-asset/models/base.model.ts)
20
- - [`packages/core/src/components/static-asset/repositories/base.repository.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/static-asset/repositories/base.repository.ts)
13
+ - [`packages/core-server/src/components/static-asset/component.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/static-asset/component.ts)
14
+ - [`packages/core-server/src/components/static-asset/common/types.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/static-asset/common/types.ts)
15
+ - [`packages/core-server/src/components/static-asset/common/constants.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/static-asset/common/constants.ts)
16
+ - [`packages/core-server/src/components/static-asset/common/keys.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/static-asset/common/keys.ts)
17
+ - [`packages/core-server/src/components/static-asset/controller/factory.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/static-asset/controller/factory.ts)
18
+ - [`packages/core-server/src/components/static-asset/controller/base.definition.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/static-asset/controller/base.definition.ts)
19
+ - [`packages/core-server/src/components/static-asset/models/base.model.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/static-asset/models/base.model.ts)
20
+ - [`packages/core-server/src/components/static-asset/repositories/base.repository.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/static-asset/repositories/base.repository.ts)
21
21
  - [`packages/helpers/src/modules/storage/base.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/helpers/src/modules/storage/base.ts)
22
22
  - [`packages/helpers/src/modules/storage/disk/helper.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/helpers/src/modules/storage/disk/helper.ts)
23
23
  - [`packages/helpers/src/modules/storage/minio/helper.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/helpers/src/modules/storage/minio/helper.ts)
@@ -106,7 +106,7 @@ type TStaticAssetsComponentOptions = {
106
106
  | Field | Type | Default | Description |
107
107
  |-------|------|---------|--------------|
108
108
  | `controller.name` | `string` | - | Class name given to the generated controller (via `Object.defineProperty`) |
109
- | `controller.basePath` | `string` | - | Mount path, e.g. `'/assets'` |
109
+ | `controller.basePath` | `string` | - | Mount path, for example `'/assets'` |
110
110
  | `controller.isStrict` | `boolean` | `true` | Passed through to `BaseRestController`'s strict routing mode |
111
111
  | `controller.routes` | object | `undefined` | Per-route overrides - see [Per-route overrides](#per-route-overrides) |
112
112
  | `storage` | `'disk' \| 'minio' \| 'bun-s3'` | - | Selects which `helper` type is required (discriminated union) |
@@ -149,7 +149,7 @@ type TStaticAssetExtraOptions = {
149
149
  ```
150
150
 
151
151
  > [!NOTE]
152
- > `normalizeNameFn` receives **both** `originalName` and `folderPath` - the second lets a custom implementation decide how to fold the target folder into the stored name. Omit `folderPath` handling and nested uploads flatten into the bucket root.
152
+ > `normalizeNameFn` receives **both** `originalName` and `folderPath`. The second argument lets a custom implementation decide how to fold the target folder into the stored name. Omit `folderPath` handling and nested uploads flatten into the bucket root.
153
153
 
154
154
  | Field | Default | Notes |
155
155
  |-------|---------|-------|
@@ -170,7 +170,9 @@ type TStaticAssetExtraOptions = {
170
170
  };
171
171
  ```
172
172
 
173
- This is why every generated link points back at the `objects/{objectName}` stream route by default, regardless of storage backend. `BaseStorageHelper`'s own backend-specific `normalizeObjectLink()` (used when a helper's `upload()` is called directly, outside the component) is never reached through `StaticAssetComponent` - the component's default always takes priority when no `normalizeLinkFn` is set.
173
+ This is why every generated link points back at the `objects/{objectName}` stream route by default, regardless of storage backend.
174
+
175
+ `BaseStorageHelper` has its own backend-specific `normalizeObjectLink()`. It only runs when you call a helper's `upload()` directly, outside the component. Through `StaticAssetComponent`, the component's default `normalizeLinkFn` always takes priority instead.
174
176
 
175
177
  ## Storage types
176
178
 
@@ -294,9 +296,29 @@ interface IListObjectsOptions {
294
296
 
295
297
  ### Name and path validation (`BaseStorageHelper`)
296
298
 
297
- `isValidName(name)` rejects, in order: non-string input, empty string, `..`/`/`/`\` (path traversal), a leading `.` (hidden file), any of `;`, `\|`, `&`, `$`, `` ` ``, `<`, `>`, `{`, `}`, `[`, `]`, `!`, `#` (shell metacharacters), `\n`/`\r`/`\0` (control characters), length over 255, and whitespace-only input.
298
-
299
- `isValidPath(pathStr, { maxDepth })` trims leading/trailing slashes, rejects an empty result, rejects double slashes (empty segments), computes `folderDepth = segments.length - 1` and rejects it exceeding `maxDepth` (default `BaseStorageHelper.DEFAULT_MAX_FOLDER_DEPTH = 2`), validates every segment through `isValidName()`, and rejects a normalized length over 1024 characters.
299
+ `isValidName(name)` rejects any of the following, in order:
300
+
301
+ | Check | Rejects |
302
+ |-------|---------|
303
+ | Type | Non-string input |
304
+ | Empty | Empty string |
305
+ | Path traversal | `..`, `/`, or `\` |
306
+ | Hidden file | A leading `.` |
307
+ | Shell metacharacters | `;`, `\|`, `&`, `$`, `` ` ``, `<`, `>`, `{`, `}`, `[`, `]`, `!`, `#` |
308
+ | Control characters | `\n`, `\r`, `\0` |
309
+ | Length | Over 255 characters |
310
+ | Whitespace-only | Empty after trimming |
311
+
312
+ `isValidPath(pathStr, { maxDepth })` runs these checks, in order:
313
+
314
+ | Step | Behavior |
315
+ |------|----------|
316
+ | Normalize | Trims leading/trailing slashes |
317
+ | Empty check | Rejects if the result is empty |
318
+ | Double slashes | Rejects empty segments, for example `a//b` |
319
+ | Folder depth | `folderDepth = segments.length - 1`; rejects if it exceeds `maxDepth` (default `BaseStorageHelper.DEFAULT_MAX_FOLDER_DEPTH = 2`) |
320
+ | Segment names | Every segment must pass `isValidName()` |
321
+ | Path length | Rejects a normalized length over 1024 characters |
300
322
 
301
323
  ### `DiskHelper`
302
324
 
@@ -349,7 +371,7 @@ interface IBunS3HelperOptions {
349
371
  }
350
372
  ```
351
373
 
352
- Wraps Bun's native `S3Client`, imported from the `bun` builtin module - it only resolves under the Bun runtime, which is why it is exported from the separate `@venizia/ignis-helpers/bun-s3` subpath rather than the main entry point.
374
+ Wraps Bun's native `S3Client`, imported from the `bun` builtin module. That module only resolves under the Bun runtime. This is why `BunS3Helper` is exported from the separate `@venizia/ignis-helpers/bun-s3` subpath, not the main entry point.
353
375
 
354
376
  ```typescript
355
377
  import { BunS3Helper } from '@venizia/ignis-helpers/bun-s3';
@@ -412,7 +434,7 @@ const MultipartBodySchema = z.object({
412
434
  |--------|------|-------|
413
435
  | `GET` | `/buckets` | No params. Returns `IBucketInfo[]` |
414
436
  | `GET` | <code v-pre>/buckets/{bucketName}</code> | Returns `IBucketInfo \| null` |
415
- | `POST` | <code v-pre>/buckets/{bucketName}</code> | Returns `IBucketInfo \| null` (`null` if creation failed, e.g. already exists) |
437
+ | `POST` | <code v-pre>/buckets/{bucketName}</code> | Returns the created `IBucketInfo`. Throws if the bucket already exists or the name is invalid - see [Error Reference](./errors) |
416
438
  | `DELETE` | <code v-pre>/buckets/{bucketName}</code> | Returns <code v-pre>{ isDeleted: boolean }</code> |
417
439
  | `POST` | <code v-pre>/buckets/{bucketName}/upload</code> | `multipart/form-data` body; query: `principalType?`, `principalId?`, `variant?`, `folderPath?`. Returns `IUploadResult[]` |
418
440
  | `GET` | <code v-pre>/buckets/{bucketName}/objects</code> | Query: `prefix?`, `recursive?` (`'true'` string only), `maxKeys?` (positive integer string). Returns `IObjectInfo[]` |
@@ -424,25 +446,33 @@ const MultipartBodySchema = z.object({
424
446
  ### Upload validation order
425
447
 
426
448
  1. `bucketName` validated with `isValidName()` - `400 "Invalid bucket name"` on failure.
427
- 2. If `folderPath` is present: trimmed of leading/trailing slashes, `400 "Invalid folder path"` if empty after trimming; segment count checked against `maxFolderDepth`, `400 "Folder path exceeds max depth of {n}"` if over; each segment checked with `isValidName()`, `400 "Invalid folder path segment: {segment}"` if any fails.
449
+ 2. If `folderPath` is present, three checks run in order:
450
+
451
+ | Check | Failure |
452
+ |-------|---------|
453
+ | Trim leading/trailing slashes | `400 "Invalid folder path"` if empty after trimming |
454
+ | Segment count vs. `maxFolderDepth` | `400 "Folder path exceeds max depth of {n}"` if over |
455
+ | Each segment via `isValidName()` | `400 "Invalid folder path segment: {segment}"` if any fails |
456
+
428
457
  3. `multipart/form-data` parsed via `parseMultipartBody()`.
429
- 4. Each file's effective buffer (direct `buffer`, or `readFileSync(file.path)` when `storage: 'disk'` was used) checked non-empty - `400 "Empty file content | name: {originalName}"` if empty.
458
+ 4. Each file's effective buffer is checked non-empty - direct `buffer`, or `readFileSync(file.path)` when `storage: 'disk'` was used. Empty content returns `400 "Empty file content | name: {originalName}"`.
430
459
  5. `helper.upload()` runs the storage-helper-level checks below.
431
- 6. Spool files written by `storage: 'disk'` parsing are removed in a `finally` block via `rmSync({ force: true })`, regardless of success or failure; removal errors are logged, never thrown.
460
+ 6. Spool files written by `storage: 'disk'` parsing are removed in a `finally` block via `rmSync({ force: true })` - regardless of success or failure. Removal errors are logged, never thrown.
432
461
 
433
462
  ### Storage-helper-level upload checks (`BaseStorageHelper.upload`)
434
463
 
435
- These run inside `helper.upload()`, independent of and in addition to the controller's own checks above - reachable even when a caller uses the storage helper directly:
464
+ These run inside `helper.upload()`, separate from the controller checks above. They are reachable even when a caller uses the storage helper directly:
436
465
 
437
466
  | Check | Error message | Default status |
438
467
  |-------|----------------|-----------------|
439
468
  | Bucket does not exist (`isBucketExists()` false) | <code v-pre>[upload] Bucket does not exist \| name: {bucket}</code> | `400` |
440
469
  | `originalName` fails `isValidName()` | `[upload] Invalid original file name` | `400` |
441
- | `folderPath` exceeds depth or fails `isValidPath()` | `[upload] Invalid folder path` | `400` |
470
+ | `folderPath` segment count exceeds `maxFolderDepth` | <code v-pre>[upload] Invalid folder path \| depth: {n} \| max: {m}</code> | `400` |
471
+ | `folderPath` fails `isValidPath()` for any other reason | `[upload] Invalid folder path` | `400` |
442
472
  | `size` is `undefined`, `null`, or negative | <code v-pre>[upload] Invalid file size \| size: {size}</code> | `400` |
443
473
  | Normalized name (post `normalizeNameFn`) fails `isValidPath()` | <code v-pre>[upload] Invalid normalized object name \| name: {name}</code> | `400` |
444
474
 
445
- `getError()` defaults `statusCode` to `400` when the caller does not pass one explicitly - every message above is thrown without an explicit status, so all resolve to `400`.
475
+ `getError()` defaults `statusCode` to `400` when the caller does not pass one explicitly. Every message above is thrown without an explicit status, so all resolve to `400`.
446
476
 
447
477
  ## Header sanitization
448
478
 
@@ -456,7 +486,9 @@ const WHITELIST_HEADERS = [
456
486
  ] as const;
457
487
  ```
458
488
 
459
- These correspond to `HTTP.Headers.CONTENT_TYPE`, `CONTENT_ENCODING`, `CACHE_CONTROL`, `ETAG`, and `LAST_MODIFIED` from `@venizia/ignis-helpers`. When streaming a file (both `objects/{objectName}` and `download/{objectName}`), the controller copies only these keys from the storage metadata onto the response; every other metadata header is dropped. Each forwarded value is sanitized with `String(value).replace(/[\r\n]/g, '')` before being set, to prevent HTTP header injection.
489
+ These correspond to `HTTP.Headers.CONTENT_TYPE`, `CONTENT_ENCODING`, `CACHE_CONTROL`, `ETAG`, and `LAST_MODIFIED` from `@venizia/ignis-helpers`.
490
+
491
+ When streaming a file - both `objects/{objectName}` and `download/{objectName}` - the controller copies only these keys from the storage metadata onto the response. Every other metadata header is dropped. Each forwarded value is sanitized with `String(value).replace(/[\r\n]/g, '')` before being set, to prevent HTTP header injection.
460
492
 
461
493
  All streaming responses also set:
462
494
 
@@ -471,7 +503,7 @@ Content-Disposition: attachment; filename="..." (download endpoint only, via c
471
503
 
472
504
  Hono percent-decodes a path param before the handler reads it. The controller's `readObjectName()` is therefore a deliberate no-op - it does not run a second `decodeURIComponent()`:
473
505
 
474
- - `report_100%.pdf` is a legal object name. Its link is `.../objects/report_100%25.pdf`; Hono hands the handler back `report_100%.pdf`. A second decode would hit the invalid escape `%.p` and throw, making the object permanently unfetchable and undeletable.
506
+ - `report_100%.pdf` is a legal object name. Its link is `.../objects/report_100%25.pdf`. Hono hands the handler back `report_100%.pdf`. A second decode would hit the invalid escape `%.p` and throw - the object would become permanently unfetchable and undeletable.
475
507
  - An object named `a%2Fb.png` would decode twice into `a/b.png` - a different object than the one requested.
476
508
 
477
509
  `isValidName()`/`isValidPath()` still run on the singly-decoded value, so a traversal payload is rejected exactly as before.
@@ -490,7 +522,12 @@ type TMetaLinkConfig<Schema extends TMetaLinkSchema = TMetaLinkSchema> = {
490
522
  };
491
523
  ```
492
524
 
493
- `BaseRelationalEntity` and `DefaultRelationalRepository` are the canonical class names (`packages/core/src/connectors/postgres/`); `BasePostgresEntity` and `DefaultCRUDRepository` are re-exported aliases of the same classes.
525
+ | Canonical name | Alias |
526
+ |----------------|-------|
527
+ | `BaseRelationalEntity` | `BasePostgresEntity` |
528
+ | `DefaultRelationalRepository` | `DefaultCRUDRepository` |
529
+
530
+ Both canonical classes live in `packages/core-server/src/connectors/postgres/`. Each alias re-exports the same class under a different name.
494
531
 
495
532
  ## MetaLink SQL schema
496
533
 
@@ -510,19 +547,34 @@ type TMetaLinkConfig<Schema extends TMetaLinkSchema = TMetaLinkSchema> = {
510
547
  | `metadata` | JSONB | Yes | - | Additional file metadata |
511
548
  | `storage_type` | TEXT | No | - | `'disk'`, `'minio'`, or `'bun-s3'` |
512
549
  | `is_synced` | BOOLEAN | No | `false` | Set `true` on every upload and every meta-links sync |
513
- | `variant` | TEXT | Yes | - | Upload variant tag (e.g. `'thumbnail'`, `'original'`) |
550
+ | `variant` | TEXT | Yes | - | Upload variant tag (for example `'thumbnail'`, `'original'`) |
514
551
  | `principal_type` | TEXT | Yes | - | Associated principal type |
515
552
  | `principal_id` | TEXT | Yes | - | Associated principal ID, always stored as a string |
516
553
 
517
554
  **Indexes:** `bucket_name`, `object_name`, `storage_type`, `is_synced`.
518
555
 
519
- `@model({ type: 'entity', skipMigrate: true })` on `BaseMetaLinkModel` means IGNIS's schema migration skips this table - it must be created manually, once, per database.
556
+ `@model({ type: 'entity', skipMigrate: true })` on `BaseMetaLinkModel` means IGNIS's schema migration skips this table. Create it manually, once, per database.
520
557
 
521
558
  ### MetaLink lifecycle
522
559
 
523
- - **On upload:** creates one MetaLink row per uploaded file after fetching fresh stats via `helper.getStat()`. Uses `metaLink.createMetaLink()` when provided, otherwise a default insert covering every standard field. `principalType`, `principalId`, and `variant` are taken from the upload's query parameters. If the insert throws, the upload still succeeds and the file's entry in the response gets `metaLink: null` plus a `metaLinkError` string; the error is also logged.
524
- - **On delete:** storage delete happens first and is awaited; the MetaLink row delete (`deleteAll({ where: { bucketName, objectName } })`) is fired without awaiting it. The HTTP response returns as soon as the storage delete resolves - the database delete may still be in flight. Errors there are logged, never surfaced to the client.
525
- - **On sync (`PUT meta-links/:objectName`):** looks up an existing row by `bucketName` + `objectName`. If found, `updateById()` then re-fetches with `findById()`. If not found, `create()`. Either path always sets `isSynced: true` and returns `{ success: true, metaLink }`.
560
+ - **On upload:**
561
+ - Creates one MetaLink row per uploaded file, after fetching fresh stats via `helper.getStat()`.
562
+ - Uses `metaLink.createMetaLink()` when provided, otherwise a default insert that covers every standard field.
563
+ - `principalType`, `principalId`, and `variant` come from the upload's query parameters.
564
+ - If the insert throws, the upload still succeeds. The file's response entry gets `metaLink: null` plus a `metaLinkError` string, and the error is logged.
565
+ - **On delete:**
566
+ - The storage delete happens first and is awaited.
567
+ - The MetaLink row delete (`deleteAll({ where: { bucketName, objectName } })`) fires without being awaited.
568
+ - The HTTP response returns as soon as the storage delete resolves - the database delete may still be in flight.
569
+ - Errors there are logged, never surfaced to the client.
570
+ - **On sync (`PUT meta-links/:objectName`):** looks up an existing row by `bucketName` + `objectName`.
571
+
572
+ | Row found? | Action |
573
+ |------------|--------|
574
+ | Yes | `updateById()`, then re-fetches with `findById()` |
575
+ | No | `create()` |
576
+
577
+ Either path sets `isSynced: true` and returns `{ success: true, metaLink }`.
526
578
 
527
579
  ## Component lifecycle
528
580
 
@@ -532,7 +584,7 @@ type TMetaLinkConfig<Schema extends TMetaLinkSchema = TMetaLinkSchema> = {
532
584
  4. Calls `AssetControllerFactory.defineAssetController()` and registers the result with `this.application.controller()`.
533
585
  5. Logs the storage key, storage type, and whether MetaLink is enabled for each registered backend.
534
586
 
535
- `StaticAssetComponent` itself performs no eager configuration validation beyond the options type - a missing `metaLink` when `useMetaLink: true` is caught at compile time by the discriminated union, not at `binding()` runtime.
587
+ `StaticAssetComponent` itself performs no eager configuration validation beyond the options type. A missing `metaLink` when `useMetaLink: true` is caught at compile time by the discriminated union, not at `binding()` runtime.
536
588
 
537
589
  ## See also
538
590
 
@@ -21,18 +21,24 @@ Every error condition the static asset controller and storage helpers can raise,
21
21
  | <code v-pre>"Invalid maxKeys \| Expected a positive integer \| value: {value}"</code> | `listObjects`'s `maxKeys` query param does not parse to a positive integer | `400` |
22
22
  | <code v-pre>[upload] Bucket does not exist \| name: {bucket}</code> | `helper.upload()` found no matching bucket via `isBucketExists()` | `400` (default) |
23
23
  | `[upload] Invalid original file name` | A file's `originalName` fails `isValidName()`, checked inside `helper.upload()` | `400` (default) |
24
- | `[upload] Invalid folder path` | `helper.upload()`'s own `folderPath` depth/`isValidPath()` check failed | `400` (default) |
24
+ | <code v-pre>[upload] Invalid folder path \| depth: {n} \| max: {m}</code> | `helper.upload()`'s own check found more `folderPath` segments than `maxFolderDepth` allows | `400` (default) |
25
+ | `[upload] Invalid folder path` | `helper.upload()`'s own `isValidPath()` check failed for any other reason | `400` (default) |
25
26
  | <code v-pre>[upload] Invalid file size \| size: {size}</code> | A file's `size` is `undefined`, `null`, or negative | `400` (default) |
26
27
  | <code v-pre>[upload] Invalid normalized object name \| name: {name}</code> | The name returned by `normalizeNameFn` fails `isValidPath()` | `400` (default) |
27
- | `[createBucket] Invalid name to create bucket!` | `MinioHelper.createBucket()` called with a name failing `isValidName()` - only reachable calling the helper directly, the controller validates first | `400` (default) |
28
+ | `[createBucket] Invalid name to create bucket!` | `createBucket()` called with a name failing `isValidName()` - only reachable calling a helper directly, the controller validates first | `400` (default) |
29
+ | <code v-pre>[createBucket] Bucket already exists \| name: {name}</code> | `DiskHelper.createBucket()` called with a name that already exists on disk. `MinioHelper` and `BunS3Helper` throw their own SDK/S3 error text for the same case instead | `400` (default) |
30
+ | `[removeBucket] Invalid name to remove bucket!` | `removeBucket()` called with a name failing `isValidName()` - only reachable calling a helper directly | `400` (default) |
31
+ | <code v-pre>[removeBucket] Bucket does not exist \| name: {name}</code> | `DiskHelper.removeBucket()` - no directory at that bucket name | `400` (default) |
32
+ | <code v-pre>[removeBucket] Bucket is not empty \| name: {name}</code> | `DiskHelper.removeBucket()` - the bucket directory still has files in it | `400` (default) |
33
+ | <code v-pre>[getFile] File not found \| bucket: {bucket} \| name: {name}</code> (also `[getStat]`, `[removeObject]`) | `DiskHelper` - no file at that bucket/object path | `400` (default) |
28
34
  | <code v-pre>[parseMultipartBody] storage: {storage} \| Invalid storage type \| Valids: ['memory', 'disk']</code> | `extra.parseMultipartBody.storage` set to something other than `'memory'`/`'disk'` - a configuration error, not user input | `400` (default) |
29
35
 
30
36
  > [!NOTE]
31
- > Entries marked "default" pass no explicit `statusCode` to `getError()`; `ApplicationError` defaults `statusCode` to `400` in that case.
37
+ > Entries marked "default" pass no explicit `statusCode` to `getError()`. `ApplicationError` defaults `statusCode` to `400` in that case.
32
38
 
33
39
  ## Name validation rules
34
40
 
35
- Bucket names are validated with `isValidName()` (single segment, no path separators). Object names, which may include folder segments (e.g. `2026/uploads/report.pdf`), are validated with `isValidPath()` - every segment still runs through `isValidName()`.
41
+ Bucket names are validated with `isValidName()` (single segment, no path separators). Object names, which may include folder segments (for example `2026/uploads/report.pdf`), are validated with `isValidPath()` - every segment still runs through `isValidName()`.
36
42
 
37
43
  | Pattern | Example | Reason |
38
44
  |---------|---------|--------|
@@ -51,7 +57,7 @@ Bucket names are validated with `isValidName()` (single segment, no path separat
51
57
  ### "Invalid bucket name" / "Invalid object name or path"
52
58
 
53
59
  - **Cause:** the name fails the rules above.
54
- - **Fix:** strip path separators, leading dots, shell metacharacters, and control characters; keep names under 255 characters (1024 for a full object path); never send an empty or whitespace-only value.
60
+ - **Fix:** strip path separators, leading dots, shell metacharacters, and control characters. Keep names under 255 characters (1024 for a full object path). Never send an empty or whitespace-only value.
55
61
 
56
62
  ```typescript
57
63
  // Wrong - contains a path separator, rejected by isValidName()
@@ -77,11 +83,11 @@ await fetch('/assets/buckets/user-uploads', { method: 'POST' });
77
83
  ### "Empty file content"
78
84
 
79
85
  - **Cause:** the uploaded file's buffer is empty - either the client sent a zero-byte file field, or `parseMultipartBody` produced no readable content.
80
- - **Fix:** verify the `FormData` field actually carries file bytes before submitting; a zero-byte file is rejected, this is not a size-limit issue.
86
+ - **Fix:** verify the `FormData` field actually carries file bytes before submitting. A zero-byte file is rejected - this is not a size-limit issue.
81
87
 
82
88
  ### "Invalid maxKeys"
83
89
 
84
- - **Cause:** `listObjects`'s `maxKeys` query string does not parse to a positive integer via `Number(maxKeys)` + `Number.isInteger()` - e.g. `maxKeys=abc` or `maxKeys=-1`.
90
+ - **Cause:** `listObjects`'s `maxKeys` query string does not parse to a positive integer via `Number(maxKeys)` + `Number.isInteger()` - for example `maxKeys=abc` or `maxKeys=-1`.
85
91
  - **Fix:** only send positive integer strings, or omit the parameter entirely.
86
92
 
87
93
  ```typescript
@@ -94,6 +100,11 @@ url.searchParams.set('maxKeys', '50'); // OK
94
100
  - **Cause:** `helper.upload()` checked `isBucketExists()` and found no match - the bucket was never created, or was deleted between requests.
95
101
  - **Fix:** create the bucket first with `POST /buckets/:bucketName`, or check `GET /buckets/:bucketName` before uploading.
96
102
 
103
+ ### "Bucket already exists"
104
+
105
+ - **Cause:** `POST /buckets/:bucketName` was called for a bucket that already exists. `createBucket()` throws rather than returning `null` - it does not silently succeed on a duplicate.
106
+ - **Fix:** check `GET /buckets/:bucketName` first, or treat the throw as "already there" and continue.
107
+
97
108
  ### Controller not registering / no routes appear
98
109
 
99
110
  - **Cause:** `STATIC_ASSET_COMPONENT_OPTIONS` was never bound, or was bound to `{}` - the component's default. `binding()` iterates the options object and produces zero controllers for an empty map, without throwing.
@@ -45,12 +45,19 @@ This registers `GET`/`POST`/`DELETE` on `/assets/buckets/:bucketName`, `POST /as
45
45
 
46
46
  ## How it works
47
47
 
48
- - **One options key, one generated controller.** `TStaticAssetsComponentOptions` is a record - each key (`staticAsset` above) becomes an independently-configured storage backend with its own `basePath`, built by `AssetControllerFactory.defineAssetController()` inside `StaticAssetComponent.binding()`.
49
- - **`storage` and `helper` are a discriminated pair.** The options type forces `storage: 'disk'` with a `DiskHelper`, `'minio'` with a `MinioHelper`, or `'bun-s3'` with a `BunS3Helper` - mismatching them fails at compile time, not at runtime.
50
- - **Every backend implements the same `IStorageHelper` contract.** `DiskHelper`, `MinioHelper`, and `BunS3Helper` all extend `BaseStorageHelper`, so bucket/object operations, name validation (`isValidName`/`isValidPath`), and upload normalization behave identically regardless of backend.
51
- - **Object names can embed folder paths, encoded as one segment.** `objects/{objectName}` takes the whole `folder/file.ext` string percent-encoded (`encodeURIComponent`, which also escapes `/`) - Hono decodes it once before the handler reads it, so client code must encode but never decode a second time.
52
- - **MetaLink is opt-in.** Set `useMetaLink: true` and provide `metaLink.repository` to persist a database row (bucket, object, mimetype, size, etag, principal, variant) alongside every upload - useful for querying "which files does user X own" without listing a whole bucket.
53
- - **The default binding is empty.** `StaticAssetComponentBindingKeys.STATIC_ASSET_COMPONENT_OPTIONS` defaults to `{}` - bind it with at least one storage backend before `this.component(StaticAssetComponent)` produces any routes.
48
+ - **One options key, one generated controller.** `TStaticAssetsComponentOptions` is a record. Each key (`staticAsset` above) becomes an independently configured storage backend with its own `basePath`. `AssetControllerFactory.defineAssetController()` builds the controller inside `StaticAssetComponent.binding()`.
49
+ - **`storage` and `helper` are a discriminated pair.** Mismatching them fails at compile time, not at runtime:
50
+
51
+ | `storage` | Required `helper` |
52
+ |-----------|--------------------|
53
+ | `'disk'` | `DiskHelper` |
54
+ | `'minio'` | `MinioHelper` |
55
+ | `'bun-s3'` | `BunS3Helper` |
56
+
57
+ - **Every backend implements the same `IStorageHelper` contract.** `DiskHelper`, `MinioHelper`, and `BunS3Helper` all extend `BaseStorageHelper`. Bucket/object operations, name validation (`isValidName`/`isValidPath`), and upload normalization behave identically regardless of backend.
58
+ - **Object names can embed folder paths, encoded as one segment.** `objects/{objectName}` percent-encodes the whole `folder/file.ext` string via `encodeURIComponent()`, which also escapes `/`. Hono decodes it before your handler runs - encode the name client-side, and never decode it again.
59
+ - **MetaLink is opt-in.** Set `useMetaLink: true` and provide `metaLink.repository`. IGNIS then persists a database row (`bucket/object/mimetype/size/etag/principal/variant`) alongside every upload. That row lets you query "which files does user X own" without listing a whole bucket.
60
+ - **The default binding is empty.** `StaticAssetComponentBindingKeys.STATIC_ASSET_COMPONENT_OPTIONS` defaults to `{}`. Bind it with at least one storage backend before `this.component(StaticAssetComponent)` - an empty binding produces zero routes.
54
61
 
55
62
  ## Common tasks
56
63
 
@@ -145,9 +152,9 @@ extra: {
145
152
 
146
153
  **Files:**
147
154
 
148
- - [`packages/core/src/components/static-asset/component.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/static-asset/component.ts) - `StaticAssetComponent`
149
- - [`packages/core/src/components/static-asset/common/types.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/static-asset/common/types.ts) - `TStaticAssetsComponentOptions`, `TStaticAssetExtraOptions`, `TMetaLinkConfig`
150
- - [`packages/core/src/components/static-asset/common/constants.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/static-asset/common/constants.ts) - `StaticAssetStorageTypes`
151
- - [`packages/core/src/components/static-asset/controller/factory.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/static-asset/controller/factory.ts) - `AssetControllerFactory`
152
- - [`packages/core/src/components/static-asset/models/base.model.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/static-asset/models/base.model.ts) - `BaseMetaLinkModel`
155
+ - [`packages/core-server/src/components/static-asset/component.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/static-asset/component.ts) - `StaticAssetComponent`
156
+ - [`packages/core-server/src/components/static-asset/common/types.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/static-asset/common/types.ts) - `TStaticAssetsComponentOptions`, `TStaticAssetExtraOptions`, `TMetaLinkConfig`
157
+ - [`packages/core-server/src/components/static-asset/common/constants.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/static-asset/common/constants.ts) - `StaticAssetStorageTypes`
158
+ - [`packages/core-server/src/components/static-asset/controller/factory.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/static-asset/controller/factory.ts) - `AssetControllerFactory`
159
+ - [`packages/core-server/src/components/static-asset/models/base.model.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/static-asset/models/base.model.ts) - `BaseMetaLinkModel`
153
160
  - [`packages/helpers/src/modules/storage/base.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/helpers/src/modules/storage/base.ts) - `BaseStorageHelper`, `IStorageHelper`
@@ -44,9 +44,14 @@ const [result] = await response.json();
44
44
  // { bucketName: 'user-uploads', objectName: 'invoices/2026/document.pdf', link: '/assets/buckets/user-uploads/objects/invoices%2F2026%2Fdocument.pdf' }
45
45
  ```
46
46
 
47
- - **`folderPath` is validated separately from the filename.** Each segment must pass `isValidName()`, and the segment count must not exceed `maxFolderDepth` (default `2`) - both return `400` before the file is even parsed.
47
+ - **`folderPath` is validated separately from the filename.** Each segment must pass `isValidName()`. The segment count must stay within `maxFolderDepth` (default `2`). Both checks return `400` before the file is even parsed.
48
48
  - **`principalId` is always stored as a string**, coerced with `String()` regardless of whether you send a number or a string.
49
- - With MetaLink enabled, the response also carries `metaLink` (the created database record) or, if that write failed, `metaLink: null` plus a `metaLinkError` string - **the upload itself still succeeds either way**.
49
+ - **With MetaLink enabled, the upload always succeeds - even if the tracking write fails.** The response carries one of two shapes:
50
+
51
+ | Outcome | Response field |
52
+ |---------|-----------------|
53
+ | MetaLink write succeeded | `metaLink`: the created database record |
54
+ | MetaLink write failed | `metaLink: null` plus a `metaLinkError` string |
50
55
 
51
56
  ## Stream or download an object
52
57
 
@@ -61,10 +66,10 @@ const downloadUrl = `/assets/buckets/user-uploads/download/${encodeURIComponent(
61
66
  window.open(downloadUrl, '_blank');
62
67
  ```
63
68
 
64
- Both routes validate `bucketName` with `isValidName()` and `objectName` with `isValidPath()`, then forward a fixed whitelist of metadata headers (`content-type`, `content-encoding`, `cache-control`, `etag`, `last-modified`) plus `X-Content-Type-Options: nosniff`. See [Header Sanitization](./api#header-sanitization) for the full list and why it exists.
69
+ Both routes validate `bucketName` with `isValidName()` and `objectName` with `isValidPath()`. Both then forward a fixed whitelist of metadata headers - `content-type`, `content-encoding`, `cache-control`, `etag`, `last-modified` - plus `X-Content-Type-Options: nosniff`. See [Header Sanitization](./api#header-sanitization) for the full list and why it exists.
65
70
 
66
71
  > [!TIP]
67
- > `objectName` may embed folder segments (`invoices/2026/document.pdf`) - always pass the whole thing through `encodeURIComponent()`. Hono decodes it exactly once before the handler reads it, so a second `decodeURIComponent()` on your end is wrong and can corrupt names containing a literal `%`.
72
+ > `objectName` may embed folder segments, for example `invoices/2026/document.pdf`. Always pass the whole thing through `encodeURIComponent()`. Hono decodes it exactly once before the handler reads it - a second `decodeURIComponent()` on your end is wrong. It can corrupt names that contain a literal `%`.
68
73
 
69
74
  ## List objects in a bucket
70
75
 
@@ -92,7 +97,7 @@ const { success } = await fetch(
92
97
  ```
93
98
 
94
99
  > [!NOTE]
95
- > When MetaLink is enabled, the database record deletion is **fire-and-forget** - the response returns as soon as the storage delete completes, without waiting on the `deleteAll()` call. Deletion errors are logged but never fail the request.
100
+ > When MetaLink is enabled, the database record deletion is **fire-and-forget**. The response returns as soon as the storage delete completes - it does not wait on the `deleteAll()` call. Deletion errors are logged but never fail the request.
96
101
 
97
102
  ## Sync a MetaLink record manually
98
103
 
@@ -184,7 +189,7 @@ export class Application extends BaseApplication {
184
189
 
185
190
  ### Custom MetaLink creation
186
191
 
187
- Provide `createMetaLink` on `TMetaLinkConfig` to fully replace the default insert - e.g. to add extra fields or run validation before persisting.
192
+ Provide `createMetaLink` on `TMetaLinkConfig` to fully replace the default insert - for example to add extra fields or run validation before persisting.
188
193
 
189
194
  ```typescript
190
195
  metaLink: {
@@ -69,9 +69,9 @@ When documenting a component, find source material here:
69
69
 
70
70
  | What | Path |
71
71
  |------|------|
72
- | Binding keys | `packages/core/src/components/{name}/common/keys.ts` |
73
- | Config types | `packages/core/src/components/{name}/common/types.ts` |
74
- | Error messages | `packages/core/src/components/{name}/component.ts` -- look for `throw getError()` |
72
+ | Binding keys | `packages/core-server/src/components/{name}/common/keys.ts` |
73
+ | Config types | `packages/core-server/src/components/{name}/common/types.ts` |
74
+ | Error messages | `packages/core-server/src/components/{name}/component.ts` -- look for `throw getError()` |
75
75
  | Helper source | `packages/helpers/src/helpers/{name}/` |
76
76
 
77
77
  ## Callout Standard
@@ -10,9 +10,9 @@ Every binding key, configuration option, callback signature, and internal mechan
10
10
 
11
11
  **Files:**
12
12
 
13
- - [`packages/core/src/components/websocket/component.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/websocket/component.ts)
14
- - [`packages/core/src/components/websocket/common/types.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/websocket/common/types.ts)
15
- - [`packages/core/src/components/websocket/handlers/bun.handler.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core/src/components/websocket/handlers/bun.handler.ts)
13
+ - [`packages/core-server/src/components/websocket/component.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/websocket/component.ts)
14
+ - [`packages/core-server/src/components/websocket/common/types.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/websocket/common/types.ts)
15
+ - [`packages/core-server/src/components/websocket/handlers/bun.handler.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/core-server/src/components/websocket/handlers/bun.handler.ts)
16
16
  - [`packages/helpers/src/modules/socket/websocket/server/helper.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/helpers/src/modules/socket/websocket/server/helper.ts)
17
17
  - [`packages/helpers/src/modules/socket/websocket/emitter/helper.ts`](https://github.com/VENIZIA-AI/ignis/blob/main/packages/helpers/src/modules/socket/websocket/emitter/helper.ts)
18
18
 
@@ -65,7 +65,7 @@ import type {
65
65
 
66
66
  ## Configuration
67
67
 
68
- `WebSocketComponent`'s own `IServerOptions` interface is a **subset** of the helper's `IWebSocketServerOptions` - the component fills in `server`, `redisConnection`, callback functions, `authTimeout`, and `encryptedBatchLimit` from the DI container before constructing the helper.
68
+ `WebSocketComponent`'s own `IServerOptions` interface is a **subset** of the helper's `IWebSocketServerOptions`. Before constructing the helper, the component fills in `server` and `redisConnection` from the running application, plus every callback function resolved from the DI container. It does not forward `authTimeout` or `encryptedBatchLimit` - see the note below.
69
69
 
70
70
  ```typescript
71
71
  interface IServerOptions {
@@ -162,7 +162,7 @@ Passed straight through to Bun's native WebSocket handler via `serverOptions` in
162
162
  | `CLIENT_CONNECTED_HANDLER` | `TWebSocketClientConnectedFn` | No | Called after successful authentication |
163
163
  | `CLIENT_DISCONNECTED_HANDLER` | `TWebSocketClientDisconnectedFn` | No | Called on disconnect, after cleanup |
164
164
  | `MESSAGE_HANDLER` | `TWebSocketMessageHandler` | No | Handles non-system messages from authenticated clients |
165
- | `OUTBOUND_TRANSFORMER` | `TWebSocketOutboundTransformer` | No | Transforms outbound messages (e.g. per-client encryption) |
165
+ | `OUTBOUND_TRANSFORMER` | `TWebSocketOutboundTransformer` | No | Transforms outbound messages (for example per-client encryption) |
166
166
  | `HANDSHAKE_HANDLER` | `TWebSocketHandshakeFn` | When `requireEncryption: true` | Returns <code v-pre>{ serverPublicKey, salt }</code> or `null`/`false` to reject |
167
167
 
168
168
  ```typescript
@@ -205,14 +205,14 @@ type TWebSocketHandshakeFn<AuthDataType extends Record<string, unknown> = Record
205
205
 
206
206
  - **`VALIDATE_ROOM_HANDLER` receives sanitized rooms.** Internal `ws:`-prefixed rooms are already filtered out before this callback runs. Without it bound, **all** join requests are rejected.
207
207
  - **`CLIENT_CONNECTED_HANDLER` / `CLIENT_DISCONNECTED_HANDLER` errors are caught and logged**, never thrown - a broken hook cannot disconnect a client or crash the server.
208
- - **`MESSAGE_HANDLER` only sees non-system events** (`authenticate`, `connected`, `disconnect`, `join`, `leave`, `error`, `heartbeat`, `encrypted` are all handled internally). Unbound, non-system messages are silently dropped.
208
+ - **`MESSAGE_HANDLER` only sees non-system events.** `authenticate`, `connected`, `disconnect`, `join`, `leave`, `error`, `heartbeat`, and `encrypted` are all handled internally. Unbound, non-system messages are silently dropped.
209
209
  - **`OUTBOUND_TRANSFORMER` only runs for encrypted clients** (`client.encrypted === true`). Non-encrypted clients bypass it entirely - zero overhead until encryption is enabled.
210
210
 
211
211
  ## Architecture
212
212
 
213
213
  ### Lifecycle integration
214
214
 
215
- The component uses the application's **post-start hook** system to solve a timing problem: WebSocket needs a running Bun server instance, but components initialize before the server starts.
215
+ The component uses the application's **post-start hook** system to solve a timing problem. WebSocket needs a running Bun server instance, but components initialize before the server starts.
216
216
 
217
217
  ```
218
218
  preConfigure() <- register WebSocketComponent here
@@ -252,7 +252,7 @@ function createBunFetchHandler(opts: {
252
252
 
253
253
  ## `WebSocketEmitter` API
254
254
 
255
- Standalone, lightweight Redis-only publisher for processes that do not run a `WebSocketServerHelper`. Extends `BaseHelper`; uses a single Redis pub client.
255
+ Standalone, lightweight Redis-only publisher for processes that do not run a `WebSocketServerHelper`. Extends `BaseHelper`. Uses a single Redis pub client.
256
256
 
257
257
  ```typescript
258
258
  interface IWebSocketEmitterOptions {
@@ -261,8 +261,11 @@ interface IWebSocketEmitterOptions {
261
261
  }
262
262
  ```
263
263
 
264
- - **Constructor** calls `super({ scope })`, throws `"Invalid redis connection!"` if `redisConnection` is falsy, and calls `redisConnection.duplicateClient()` to create an isolated pub client.
265
- - **`EMITTER_SERVER_ID = 'emitter'`.** Every message the emitter publishes carries this fixed `serverId`. No `WebSocketServerHelper` ever has this ID (they use `crypto.randomUUID()`), so no server self-dedups an emitter message.
264
+ - **Constructor:**
265
+ - Calls `super({ scope })`.
266
+ - Throws `"Invalid redis connection!"` if `redisConnection` is falsy.
267
+ - Calls `redisConnection.duplicateClient()` to create an isolated pub client.
268
+ - **`EMITTER_SERVER_ID = 'emitter'`.** Every message the emitter publishes carries this fixed `serverId`. No `WebSocketServerHelper` ever has this ID - they use `crypto.randomUUID()` - so no server self-dedups an emitter message.
266
269
 
267
270
  | Method | Signature | Behavior |
268
271
  |--------|-----------|----------|
@@ -282,7 +285,7 @@ Reads all binding keys and validates the required ones, throwing before the post
282
285
  | Binding | Validation | Error on failure |
283
286
  |---------|-----------|------------------|
284
287
  | `SERVER_OPTIONS` | Optional, merged with `DEFAULT_SERVER_OPTIONS` via `Object.assign()` | -- |
285
- | `REDIS_CONNECTION` | Must be `instanceof AbstractRedisHelper` | `"Invalid instance of redisConnection ..."` |
288
+ | `REDIS_CONNECTION` | Must pass `isRedisHelper()` | `"Invalid instance of redisConnection ..."` |
286
289
  | `AUTHENTICATE_HANDLER` | Must be truthy | `"Invalid authenticateFn to setup WebSocket server!"` |
287
290
  | `VALIDATE_ROOM_HANDLER` / `CLIENT_CONNECTED_HANDLER` / `CLIENT_DISCONNECTED_HANDLER` / `MESSAGE_HANDLER` / `OUTBOUND_TRANSFORMER` / `HANDSHAKE_HANDLER` | Optional, `null` coerced to `undefined` | -- |
288
291
 
@@ -290,7 +293,7 @@ Reads all binding keys and validates the required ones, throwing before the post
290
293
 
291
294
  Registers the `websocket-initialize` post-start hook:
292
295
 
293
- 1. Gets the Bun server instance (`getServerInstance()`) and Hono server (`getServer()`) - throws `"[WebSocketComponent] Bun server instance not available!"` if the Bun instance is missing.
296
+ 1. Gets the Bun server instance via `getServerInstance()` and the Hono server via `getServer()`. Throws `"[WebSocketComponent] Bun server instance not available!"` if the Bun instance is missing.
294
297
  2. Constructs `WebSocketServerHelper` with all resolved bindings plus the running server instance.
295
298
  3. Awaits `wsHelper.configure()` - connects Redis clients, sets up subscriptions, starts the heartbeat timer.
296
299
  4. Binds the helper to `WEBSOCKET_INSTANCE`.
@@ -314,10 +317,21 @@ if (runtime === RuntimeModules.NODE) {
314
317
  | Callback | Responsibility |
315
318
  |----------|---------------|
316
319
  | `open` | Creates the `IWebSocketClient` entry in state `UNAUTHORIZED`, subscribes the socket to its own `clientId` topic, starts the auth timer (skips if `clientId` already exists) |
317
- | `message` | Updates `lastActivity`; parses JSON (sends `error` on failure); routes `heartbeat` (no-op), `authenticate`, other events on unauthenticated clients (`error`: `"Not authenticated"`), `join`, `leave`, or custom events to `messageHandler` |
320
+ | `message` | Updates `lastActivity`, then routes the parsed event - see the table below |
318
321
  | `close` | Clears the auth timer, removes the client from `users`/`rooms`/`clients`, invokes `clientDisconnectedFn` (errors caught and logged) |
319
322
  | `drain` | Resets `client.backpressured = false` |
320
323
 
324
+ The `message` callback routes each event like this:
325
+
326
+ | Event | Handling |
327
+ |-------|----------|
328
+ | Not valid JSON | Sends an `error` event back to the client |
329
+ | `heartbeat` | No-op - `lastActivity` was already updated |
330
+ | `authenticate` | Runs the authentication flow |
331
+ | Any other event, client not yet authenticated | Sends `error: "Not authenticated"` |
332
+ | `join` / `leave` | Runs the room join/leave flow |
333
+ | Any other event, client authenticated | Forwarded to `messageHandler` |
334
+
321
335
  ### `deliverToSocket()` backpressure handling
322
336
 
323
337
  | `socket.send()` return | Meaning | Action |
@@ -343,25 +357,42 @@ send({ destination, payload: { topic, data } })
343
357
 
344
358
  ### Room join / leave validation
345
359
 
346
- - **Server-side sanitization** always applies: room must be a non-empty string, at most 256 characters, and must not start with the reserved `ws:` prefix.
347
- - **`validateRoomFn` gates joins.** Only sanitized rooms reach it; it returns the subset the client may actually join. If unbound, every join is rejected with a warning log.
348
- - **Leave is filtered against joined rooms.** `handleLeave()` computes `rooms.filter(r => client.rooms.has(r))` before leaving, so a client can never unsubscribe from a room it never joined (or an internal topic). If nothing remains after filtering, the leave is silently ignored.
360
+ - **Server-side sanitization always applies:**
361
+
362
+ | Rule | Requirement |
363
+ |------|-------------|
364
+ | Type | Non-empty string |
365
+ | Length | At most 256 characters |
366
+ | Prefix | Must not start with the reserved `ws:` prefix |
367
+
368
+ - **`validateRoomFn` gates joins.** Only sanitized rooms reach it. It returns the subset the client may actually join. If unbound, every join is rejected with a warning log.
369
+ - **Leave is filtered against joined rooms.** `handleLeave()` computes `rooms.filter(r => client.rooms.has(r))` before leaving. A client can never unsubscribe from a room it never joined - or an internal topic it was auto-subscribed to.
370
+ - If nothing remains after filtering, the leave is silently ignored.
349
371
 
350
372
  ### Graceful shutdown
351
373
 
374
+ `WebSocketServerHelper` has a real `shutdown()` method, but `WebSocketComponent` never calls it automatically. Components have no `stop()` lifecycle, and the component does not register a post-stop hook for you.
375
+
376
+ To disconnect clients cleanly when your application stops, register your own hook with `registerPostStopHook()` - the same mechanism IGNIS's secrets provider uses internally:
377
+
352
378
  ```typescript
353
- override async stop(): Promise<void> {
354
- const wsHelper = this.get<WebSocketServerHelper>({
355
- key: WebSocketBindingKeys.WEBSOCKET_INSTANCE,
356
- isOptional: true,
357
- });
358
- if (wsHelper) {
359
- await wsHelper.shutdown();
360
- }
361
- if (this.redisHelper) {
362
- await this.redisHelper.disconnect();
379
+ export class Application extends BaseApplication {
380
+ preConfigure(): ValueOrPromise<void> {
381
+ // ... bind REDIS_CONNECTION, AUTHENTICATE_HANDLER, register WebSocketComponent ...
382
+
383
+ this.registerPostStopHook({
384
+ identifier: 'websocket-shutdown',
385
+ hook: async () => {
386
+ const wsHelper = this.get<WebSocketServerHelper>({
387
+ key: WebSocketBindingKeys.WEBSOCKET_INSTANCE,
388
+ isOptional: true,
389
+ });
390
+ if (wsHelper) {
391
+ await wsHelper.shutdown();
392
+ }
393
+ },
394
+ });
363
395
  }
364
- await super.stop();
365
396
  }
366
397
  ```
367
398
 
@@ -32,7 +32,7 @@ Every error condition the WebSocket component and helper can raise, plus fixes f
32
32
  |--------|-----------|---------------|
33
33
  | `binding()` | `application` is falsy | `"[binding] Invalid application to bind WebSocketComponent"` |
34
34
  | `binding()` | Node.js runtime detected | `"[WebSocketComponent] Node.js runtime is not supported yet. Please use Bun runtime."` |
35
- | `resolveBindings()` | `REDIS_CONNECTION` not `instanceof AbstractRedisHelper` | `"[WebSocketComponent][resolveBindings] Invalid instance of redisConnection ..."` |
35
+ | `resolveBindings()` | `REDIS_CONNECTION` fails `isRedisHelper()` | `"[WebSocketComponent][resolveBindings] Invalid instance of redisConnection ..."` |
36
36
  | `resolveBindings()` | `AUTHENTICATE_HANDLER` is falsy | `"[WebSocketComponent] Invalid authenticateFn to setup WebSocket server!"` |
37
37
  | `registerBunHook()` | Bun server instance not available | `"[WebSocketComponent] Bun server instance not available!"` |
38
38
 
@@ -40,12 +40,12 @@ Every error condition the WebSocket component and helper can raise, plus fixes f
40
40
 
41
41
  ### "WebSocket not initialized"
42
42
 
43
- - **Cause:** `WebSocketServerHelper` was accessed before the server started - e.g. during DI construction.
43
+ - **Cause:** `WebSocketServerHelper` was accessed before the server started - for example during DI construction.
44
44
  - **Fix:** Use the lazy getter pattern from [Usage & Examples](./usage). Never `@inject` `WEBSOCKET_INSTANCE` in a constructor - it does not exist yet at that point.
45
45
 
46
46
  ### "Invalid instance of redisConnection"
47
47
 
48
- - **Cause:** The value bound to `REDIS_CONNECTION` is not an `AbstractRedisHelper` instance - i.e. not a `RedisSingleHelper`, `RedisClusterHelper`, or `RedisSentinelHelper`.
48
+ - **Cause:** The value bound to `REDIS_CONNECTION` is not an `AbstractRedisHelper` instance. It is not a `RedisSingleHelper`, `RedisClusterHelper`, or `RedisSentinelHelper`.
49
49
  - **Fix:** Bind one of the concrete topology helpers, not a raw `ioredis` client.
50
50
 
51
51
  ```typescript