json-rest-api 1.0.28 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +10 -670
- package/LICENSE-GPL-3.0 +674 -0
- package/LICENSE-MIT +21 -0
- package/README.md +39 -30
- package/docs/API.md +746 -1273
- package/docs/GUIDE/01-getting-started.md +338 -0
- package/docs/GUIDE/02-resources-and-relationships.md +33 -0
- package/docs/GUIDE/03-running-example.md +87 -0
- package/docs/GUIDE/04-creating-and-querying.md +181 -0
- package/docs/GUIDE/05-belongs-to.md +177 -0
- package/docs/GUIDE/06-has-many.md +184 -0
- package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
- package/docs/GUIDE/08-many-to-many.md +184 -0
- package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
- package/docs/GUIDE/10-put-and-patch.md +174 -0
- package/docs/GUIDE/11-relationship-endpoints.md +174 -0
- package/docs/GUIDE/12-field-transformations.md +287 -0
- package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
- package/docs/GUIDE/14-custom-resource-methods.md +125 -0
- package/docs/GUIDE/15-query-projections.md +266 -0
- package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
- package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
- package/docs/GUIDE/18-labels.md +86 -0
- package/docs/GUIDE/19-managed-transactions.md +179 -0
- package/docs/GUIDE/20-transaction-outcomes.md +207 -0
- package/docs/GUIDE/21-schema-and-migrations.md +634 -0
- package/docs/GUIDE/22-version-field-migrations.md +208 -0
- package/docs/GUIDE/23-fastify.md +179 -0
- package/docs/GUIDE/24-cors.md +172 -0
- package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
- package/docs/GUIDE/26-file-uploads.md +278 -0
- package/docs/GUIDE/27-bulk-operations.md +277 -0
- package/docs/GUIDE/28-socketio.md +325 -0
- package/docs/GUIDE/29-writing-plugins.md +278 -0
- package/docs/GUIDE/30-backend-capabilities.md +100 -0
- package/docs/GUIDE/31-positioning.md +181 -0
- package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +90 -31
- package/docs/GUIDE/33-migrating-to-v2.md +3827 -0
- package/docs/GUIDE/index.md +51 -59
- package/docs/QUICKSTART.md +120 -287
- package/docs/architecture.md +75 -0
- package/docs/contributing.md +178 -0
- package/index.d.ts +36 -0
- package/index.js +1 -22
- package/lib/enhanced-logger.js +91 -85
- package/lib/error-context.js +445 -5
- package/lib/error-formatter.js +219 -59
- package/lib/knex-transaction.js +64 -0
- package/lib/logger-types.d.ts +22 -0
- package/lib/missing-package.js +4 -0
- package/lib/rest-api-errors.js +103 -8
- package/lib/runtime/json-rest-api.js +172 -0
- package/lib/transaction-types.d.ts +59 -0
- package/package.json +97 -27
- package/plugins/core/bulk-operations-plugin.js +191 -184
- package/plugins/core/connectors/express-plugin.js +148 -219
- package/plugins/core/connectors/fastify-plugin.js +125 -82
- package/plugins/core/connectors/lib/busboy-detector.js +74 -156
- package/plugins/core/connectors/lib/connector-core.js +144 -20
- package/plugins/core/connectors/lib/formidable-detector.js +63 -120
- package/plugins/core/connectors/lib/http-validators.js +77 -0
- package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
- package/plugins/core/connectors/lib/request-helpers.js +10 -1
- package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
- package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
- package/plugins/core/file-handling-plugin.js +87 -174
- package/plugins/core/lib/anyapi/anyapi-registry.js +213 -164
- package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
- package/plugins/core/lib/anyapi/schema-utils.js +25 -16
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
- package/plugins/core/lib/dbIntrospection.js +228 -21
- package/plugins/core/lib/dbTablesOperations.js +409 -92
- package/plugins/core/lib/fractional-positioning.js +12 -48
- package/plugins/core/lib/querying/identifier-query.js +19 -0
- package/plugins/core/lib/querying/include-query-helpers.js +364 -0
- package/plugins/core/lib/querying/include-to-many.js +428 -0
- package/plugins/core/lib/querying/include-to-one.js +579 -0
- package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
- package/plugins/core/lib/querying/knex-pagination-helpers.js +67 -330
- package/plugins/core/lib/querying/knex-process-includes.js +5 -110
- package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
- package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
- package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
- package/plugins/core/lib/querying/knex-window-queries.js +62 -253
- package/plugins/core/lib/querying/query-builder-utils.js +44 -4
- package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
- package/plugins/core/lib/querying/query-constraint.js +27 -0
- package/plugins/core/lib/querying/query-field-sort-helpers.js +205 -30
- package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
- package/plugins/core/lib/querying/sort-helpers.js +20 -1
- package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
- package/plugins/core/lib/querying/url-helpers.js +11 -4
- package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
- package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +238 -187
- package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
- package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
- package/plugins/core/lib/querying-writing/field-utils.js +113 -92
- package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
- package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +111 -287
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
- package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
- package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
- package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
- package/plugins/core/lib/querying-writing/request-contracts.js +109 -44
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
- package/plugins/core/lib/querying-writing/response-options.js +35 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
- package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
- package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
- package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
- package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
- package/plugins/core/lib/storage/storage-adapter.js +142 -62
- package/plugins/core/lib/storage/storage-mapping.js +78 -28
- package/plugins/core/lib/storage/storage-types.d.ts +502 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
- package/plugins/core/lib/writing/relationship-processor.js +94 -158
- package/plugins/core/lib/writing/resource-version.js +172 -0
- package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +116 -2509
- package/plugins/core/rest-api-autofilter-plugin.js +17 -19
- package/plugins/core/rest-api-cors-plugin.js +52 -205
- package/plugins/core/rest-api-knex-plugin.js +45 -1069
- package/plugins/core/rest-api-label-plugin.js +25 -38
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
- package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
- package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
- package/plugins/core/rest-api-plugin-methods/common.js +291 -385
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
- package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -145
- package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
- package/plugins/core/rest-api-plugin-methods/get.js +38 -69
- package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
- package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
- package/plugins/core/rest-api-plugin-methods/post.js +51 -51
- package/plugins/core/rest-api-plugin-methods/put.js +69 -148
- package/plugins/core/rest-api-plugin-methods/query.js +39 -82
- package/plugins/core/rest-api-plugin-methods/release.js +2 -1
- package/plugins/core/rest-api-plugin.js +32 -46
- package/plugins/core/rest-api-positioning-plugin.js +183 -628
- package/plugins/core/rest-api-query-projections-plugin.js +19 -102
- package/plugins/core/rest-api-row-policy-plugin.js +3 -3
- package/plugins/core/socketio-plugin.js +364 -344
- package/plugins/storage/local-storage.js +40 -36
- package/types/autofilter.d.ts +57 -0
- package/types/bulk-methods.d.ts +63 -0
- package/types/errors.d.ts +118 -0
- package/types/fastify-options.d.ts +19 -0
- package/types/file-detectors.d.ts +33 -0
- package/types/file-storage.d.ts +48 -0
- package/types/hook-context.d.ts +180 -0
- package/types/plugin-options.d.ts +53 -0
- package/types/relationship-methods.d.ts +74 -0
- package/types/representations.d.ts +50 -0
- package/types/resource-methods.d.ts +95 -0
- package/types/resource-schema.d.ts +157 -0
- package/types/row-policy.d.ts +31 -0
- package/types/runtime.d.ts +132 -0
- package/types/transactions.d.ts +21 -0
- package/.claude/agents/library-guide-expert.md +0 -30
- package/.claude/agents/rest-api-internals-expert.md +0 -54
- package/.claude/settings.local.json +0 -70
- package/AGENTS.md +0 -34
- package/CLAUDE.md +0 -160
- package/CRUSH.md +0 -55
- package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
- package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
- package/TODO.todo +0 -13
- package/connectors_ideas/fastify.js +0 -217
- package/connectors_ideas/koa-plugin.js +0 -112
- package/docs/.bundle/config +0 -2
- package/docs/COMPARISON.md +0 -433
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1238
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
- package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
- package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
- package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
- package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
- package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
- package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
- package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
- package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
- package/docs/Gemfile +0 -6
- package/docs/Gemfile.lock +0 -308
- package/docs/ONBOARDING.md +0 -315
- package/docs/_config.yml +0 -36
- package/docs/_layouts/default.html +0 -123
- package/docs/apple-touch-icon.svg +0 -4
- package/docs/assets/css/style.scss +0 -392
- package/docs/favicon.svg +0 -4
- package/docs/index.md +0 -164
- package/docs/mini-guides/file-uploads-complete.md +0 -161
- package/eslint.config.js +0 -13
- package/examples/url-override-example.js +0 -137
- package/fixing_plan.md +0 -112
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
- package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
- package/plugins/storage/s3-storage.js +0 -93
- package/quickTest.js +0 -136
- package/scripts/debug-anyapi-review-filter.mjs +0 -63
- package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
- package/scripts/inspect-anyapi-descriptor.mjs +0 -19
- package/scripts/run-docs.js +0 -133
- package/test-debug.js +0 -59
- package/tests/README.md +0 -202
- package/tests/TEST_TEMPLATE.test.js +0 -178
- package/tests/anyapi-basic.test.js +0 -105
- package/tests/anyapi-cursor-pagination.test.js +0 -150
- package/tests/anyapi-custom-idproperty.test.js +0 -163
- package/tests/autofilter.test.js +0 -463
- package/tests/bulk-operations.test.js +0 -657
- package/tests/computed-fields.test.js +0 -586
- package/tests/cors.test.js +0 -834
- package/tests/curl.test.js +0 -467
- package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
- package/tests/custom-id-simplified-post.test.js +0 -75
- package/tests/custom-idproperty-relationships.test.js +0 -932
- package/tests/db-introspection.test.js +0 -521
- package/tests/db-table-operations.test.js +0 -798
- package/tests/fastify-plugin.test.js +0 -184
- package/tests/field-getters.test.js +0 -370
- package/tests/field-setters.test.js +0 -306
- package/tests/file-handling.test.js +0 -118
- package/tests/fixtures/api-configs.js +0 -2509
- package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
- package/tests/helpers/fake-fastify.js +0 -99
- package/tests/helpers/storage-mode.js +0 -65
- package/tests/helpers/test-utils.js +0 -203
- package/tests/http-connectors-parity.test.js +0 -395
- package/tests/id-normalization.test.js +0 -444
- package/tests/include-depth-validation.test.js +0 -331
- package/tests/includes.test.js +0 -427
- package/tests/local-storage.test.js +0 -96
- package/tests/logical-id-write-validation.test.js +0 -151
- package/tests/nested-includes.test.js +0 -617
- package/tests/pagination-cursor-multifield.test.js +0 -643
- package/tests/pagination-enhanced.test.js +0 -563
- package/tests/polymorphic-relationships.test.js +0 -347
- package/tests/polymorphic-simple.test.js +0 -148
- package/tests/positioning-advanced.test.js +0 -692
- package/tests/positioning-extended.test.js +0 -915
- package/tests/positioning-multifilter.test.js +0 -699
- package/tests/positioning.test.js +0 -560
- package/tests/post-commit-hooks.test.js +0 -120
- package/tests/projected-fields.test.js +0 -319
- package/tests/public-exports.test.js +0 -15
- package/tests/put-create-parity.test.js +0 -216
- package/tests/queries.test.js +0 -521
- package/tests/query-limits.test.js +0 -584
- package/tests/relationship-endpoints.test.js +0 -620
- package/tests/relationship-include-storage-adapter.test.js +0 -56
- package/tests/relationships.test.js +0 -681
- package/tests/rest-api.test.js +0 -337
- package/tests/return-record-settings.test.js +0 -184
- package/tests/row-policy.test.js +0 -489
- package/tests/s3-storage.test.js +0 -38
- package/tests/searchschema-knex-behavior.test.js +0 -187
- package/tests/searchschema-merge.test.js +0 -151
- package/tests/socketio.test.js +0 -860
- package/tests/storage-mapping.test.js +0 -362
- package/tests/test-development-guide.md +0 -370
- package/tests/virtual-fields.test.js +0 -404
|
@@ -1,1040 +0,0 @@
|
|
|
1
|
-
# WebSocket Real-time Updates with Socket.IO
|
|
2
|
-
|
|
3
|
-
## Table of Contents
|
|
4
|
-
|
|
5
|
-
1. [Overview](#overview)
|
|
6
|
-
2. [Server Setup](#server-setup)
|
|
7
|
-
3. [Core Concepts](#core-concepts)
|
|
8
|
-
4. [The Filter System](#the-filter-system)
|
|
9
|
-
5. [Security Architecture](#security-architecture)
|
|
10
|
-
6. [Advanced Features](#advanced-features)
|
|
11
|
-
7. [Client Usage](#client-usage)
|
|
12
|
-
8. [Performance Considerations](#performance-considerations)
|
|
13
|
-
9. [Troubleshooting](#troubleshooting)
|
|
14
|
-
|
|
15
|
-
## Overview
|
|
16
|
-
|
|
17
|
-
The Socket.IO plugin provides real-time notifications when resources change in your json-rest-api application. It implements a **notification-only pattern** - instead of broadcasting full data (which could leak sensitive information), it only sends minimal notifications about what changed. Clients then fetch the updated data through the regular REST API, ensuring all permissions and transformations are properly applied.
|
|
18
|
-
|
|
19
|
-
### Key Benefits
|
|
20
|
-
|
|
21
|
-
- **Security First**: No data leaks possible - notifications contain only resource type and ID
|
|
22
|
-
- **Performance**: One broadcast per change, not N database queries for N subscribers
|
|
23
|
-
- **Consistency**: Uses the same searchSchema as REST API for filtering
|
|
24
|
-
- **Transaction Safe**: Only broadcasts after database commits succeed
|
|
25
|
-
- **Scalable**: Supports Redis adapter for multi-server deployments
|
|
26
|
-
|
|
27
|
-
## Server Setup
|
|
28
|
-
|
|
29
|
-
### Installation
|
|
30
|
-
|
|
31
|
-
The Socket.IO plugin is included in json-rest-api core plugins. To use it, you need to:
|
|
32
|
-
|
|
33
|
-
1. Install Socket.IO. Redis packages are only needed when you configure the Redis adapter:
|
|
34
|
-
```bash
|
|
35
|
-
npm install socket.io
|
|
36
|
-
npm install @socket.io/redis-adapter redis
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
2. Use the plugin and start the Socket.IO server:
|
|
40
|
-
|
|
41
|
-
```javascript
|
|
42
|
-
import { Api } from 'hooked-api';
|
|
43
|
-
import { RestApiPlugin, RestApiKnexPlugin, SocketIOPlugin } from 'json-rest-api';
|
|
44
|
-
|
|
45
|
-
// Create your API instance
|
|
46
|
-
const api = new Api({
|
|
47
|
-
name: 'my-api',
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
// Add required plugins
|
|
51
|
-
await api.use(RestApiPlugin);
|
|
52
|
-
await api.use(RestApiKnexPlugin, { knex: knexInstance });
|
|
53
|
-
await api.use(SocketIOPlugin, {
|
|
54
|
-
auth: {
|
|
55
|
-
requireAuth: true,
|
|
56
|
-
authenticate: async ({ socket }) => {
|
|
57
|
-
const token = socket.handshake.auth?.token;
|
|
58
|
-
return verifyToken(token); // App-defined token verification
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
// Start your HTTP server
|
|
64
|
-
const server = app.listen(3000).on('error', (err) => {
|
|
65
|
-
console.error('Failed to start server:', err);
|
|
66
|
-
process.exit(1)
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
// Start Socket.IO server
|
|
70
|
-
const io = await api.startSocketServer(server, {
|
|
71
|
-
path: '/socket.io', // Socket.IO path (default: '/socket.io')
|
|
72
|
-
cors: { // CORS configuration
|
|
73
|
-
origin: '*', // Configure for your security needs
|
|
74
|
-
methods: ['GET', 'POST']
|
|
75
|
-
},
|
|
76
|
-
redis: { // Optional: Redis adapter for scaling
|
|
77
|
-
host: 'localhost',
|
|
78
|
-
port: 6379
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
### Configuration Options
|
|
84
|
-
|
|
85
|
-
The `startSocketServer` method accepts these options:
|
|
86
|
-
|
|
87
|
-
| Option | Type | Default | Description |
|
|
88
|
-
|--------|------|---------|-------------|
|
|
89
|
-
| `path` | string | '/socket.io' | URL path for Socket.IO endpoint |
|
|
90
|
-
| `cors` | object | `{ origin: '*', methods: ['GET', 'POST'] }` | CORS configuration |
|
|
91
|
-
| `redis` | object | null | Redis configuration for multi-server setup |
|
|
92
|
-
|
|
93
|
-
### How It Works
|
|
94
|
-
|
|
95
|
-
1. **REST API Integration**: The plugin hooks into the REST API's `finish` event
|
|
96
|
-
2. **Transaction Awareness**: Waits for database commits before broadcasting
|
|
97
|
-
3. **Filter Matching**: Uses `context.minimalRecord` to check subscription filters
|
|
98
|
-
4. **Notification Broadcasting**: Sends minimal notifications to matching subscribers
|
|
99
|
-
|
|
100
|
-
## Core Concepts
|
|
101
|
-
|
|
102
|
-
### Notification-Only Pattern
|
|
103
|
-
|
|
104
|
-
Traditional WebSocket implementations often broadcast full data:
|
|
105
|
-
|
|
106
|
-
```javascript
|
|
107
|
-
// ❌ INSECURE: Broadcasting full data
|
|
108
|
-
io.emit('user.updated', {
|
|
109
|
-
id: 123,
|
|
110
|
-
name: 'John Doe',
|
|
111
|
-
email: 'john@example.com',
|
|
112
|
-
ssn: '123-45-6789', // LEAKED to all subscribers!
|
|
113
|
-
salary: 150000, // LEAKED to all subscribers!
|
|
114
|
-
medical_notes: '...' // LEAKED to all subscribers!
|
|
115
|
-
});
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
Our implementation broadcasts only notifications:
|
|
119
|
-
|
|
120
|
-
```javascript
|
|
121
|
-
// ✅ SECURE: Notification only
|
|
122
|
-
socket.emit('subscription.update', {
|
|
123
|
-
type: 'resource.updated',
|
|
124
|
-
resource: 'users',
|
|
125
|
-
id: '123',
|
|
126
|
-
action: 'update',
|
|
127
|
-
subscriptionId: 'users-12345-abc',
|
|
128
|
-
meta: { timestamp: '2024-01-15T10:00:00Z' }
|
|
129
|
-
});
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
Clients then fetch data through REST API with proper permissions:
|
|
133
|
-
|
|
134
|
-
```javascript
|
|
135
|
-
// Client fetches with their permissions applied
|
|
136
|
-
const response = await fetch('/api/users/123', {
|
|
137
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
138
|
-
});
|
|
139
|
-
// Server applies all permission checks, field hiding, etc.
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
### searchSchema Integration
|
|
143
|
-
|
|
144
|
-
The plugin reuses your existing searchSchema definitions for filtering subscriptions. This ensures consistency between REST API queries and WebSocket subscriptions:
|
|
145
|
-
|
|
146
|
-
```javascript
|
|
147
|
-
// Define your resource with searchSchema
|
|
148
|
-
await api.addResource('posts', {
|
|
149
|
-
schema: {
|
|
150
|
-
title: { type: 'string', required: true },
|
|
151
|
-
content: { type: 'string' },
|
|
152
|
-
status: { type: 'string', defaultTo: 'draft' },
|
|
153
|
-
author_id: { type: 'id', required: true },
|
|
154
|
-
published_at: { type: 'dateTime', nullable: true },
|
|
155
|
-
view_count: { type: 'number', defaultTo: 0 }
|
|
156
|
-
},
|
|
157
|
-
|
|
158
|
-
searchSchema: {
|
|
159
|
-
// These filters work for both REST and WebSocket
|
|
160
|
-
status: { type: 'string', filterOperator: '=' },
|
|
161
|
-
author_id: { type: 'id', filterOperator: '=' },
|
|
162
|
-
published_at: { type: 'dateTime', filterOperator: '>=' },
|
|
163
|
-
view_count: { type: 'number', filterOperator: '>' }
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
// REST API query
|
|
168
|
-
GET /api/posts?filter[status]=published&filter[view_count]=100
|
|
169
|
-
|
|
170
|
-
// WebSocket subscription - SAME filters!
|
|
171
|
-
socket.emit('subscribe', {
|
|
172
|
-
resource: 'posts',
|
|
173
|
-
filters: {
|
|
174
|
-
status: 'published',
|
|
175
|
-
view_count: 100
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
### Transaction Safety
|
|
181
|
-
|
|
182
|
-
The plugin ensures broadcasts only happen after successful database commits:
|
|
183
|
-
|
|
184
|
-
```javascript
|
|
185
|
-
// In a transaction
|
|
186
|
-
const trx = await knex.transaction();
|
|
187
|
-
try {
|
|
188
|
-
// Create a post
|
|
189
|
-
const post = await api.resources.posts.post({
|
|
190
|
-
inputRecord: { /* ... */ },
|
|
191
|
-
transaction: trx
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// At this point, NO broadcast has been sent
|
|
195
|
-
|
|
196
|
-
await trx.commit();
|
|
197
|
-
// NOW the broadcast is sent
|
|
198
|
-
} catch (error) {
|
|
199
|
-
await trx.rollback();
|
|
200
|
-
// No broadcast is ever sent
|
|
201
|
-
}
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
## The Filter System
|
|
205
|
-
|
|
206
|
-
### Simple Operator Filters
|
|
207
|
-
|
|
208
|
-
Filters using simple operators (`=`, `>`, `>=`, `<`, `<=`, `!=`, `like`, `in`, `between`) work automatically for both REST and WebSocket:
|
|
209
|
-
|
|
210
|
-
```javascript
|
|
211
|
-
searchSchema: {
|
|
212
|
-
// Equality
|
|
213
|
-
status: { type: 'string', filterOperator: '=' },
|
|
214
|
-
|
|
215
|
-
// Comparison
|
|
216
|
-
price: { type: 'number', filterOperator: '>=' },
|
|
217
|
-
stock: { type: 'number', filterOperator: '>' },
|
|
218
|
-
|
|
219
|
-
// Pattern matching
|
|
220
|
-
title: { type: 'string', filterOperator: 'like' },
|
|
221
|
-
|
|
222
|
-
// Multiple values
|
|
223
|
-
category_id: { type: 'array', filterOperator: 'in' },
|
|
224
|
-
|
|
225
|
-
// Range
|
|
226
|
-
created_at: { type: 'date', filterOperator: 'between' }
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// These work for both REST and WebSocket
|
|
230
|
-
socket.emit('subscribe', {
|
|
231
|
-
resource: 'products',
|
|
232
|
-
filters: {
|
|
233
|
-
status: 'active',
|
|
234
|
-
price: 99.99,
|
|
235
|
-
title: 'phone',
|
|
236
|
-
category_id: [1, 2, 3],
|
|
237
|
-
created_at: ['2024-01-01', '2024-12-31']
|
|
238
|
-
}
|
|
239
|
-
});
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
### Complex Filters with filterRecord
|
|
243
|
-
|
|
244
|
-
When `filterOperator` is a function (for complex SQL queries), you must provide `filterRecord` for WebSocket support:
|
|
245
|
-
|
|
246
|
-
```javascript
|
|
247
|
-
searchSchema: {
|
|
248
|
-
// Complex multi-field search
|
|
249
|
-
search: {
|
|
250
|
-
type: 'string',
|
|
251
|
-
|
|
252
|
-
// For REST API - builds SQL query
|
|
253
|
-
filterOperator: function(query, value, { tableName }) {
|
|
254
|
-
query.where(function() {
|
|
255
|
-
this.where(`${tableName}.title`, 'like', `%${value}%`)
|
|
256
|
-
.orWhere(`${tableName}.description`, 'like', `%${value}%`)
|
|
257
|
-
.orWhere(`${tableName}.tags`, 'like', `%${value}%`);
|
|
258
|
-
});
|
|
259
|
-
},
|
|
260
|
-
|
|
261
|
-
// For WebSocket - evaluates single record (REQUIRED!)
|
|
262
|
-
filterRecord: function(record, value) {
|
|
263
|
-
const search = value.toLowerCase();
|
|
264
|
-
const title = (record.title || '').toLowerCase();
|
|
265
|
-
const desc = (record.description || '').toLowerCase();
|
|
266
|
-
const tags = (record.tags || []).join(' ').toLowerCase();
|
|
267
|
-
|
|
268
|
-
return title.includes(search) ||
|
|
269
|
-
desc.includes(search) ||
|
|
270
|
-
tags.includes(search);
|
|
271
|
-
}
|
|
272
|
-
},
|
|
273
|
-
|
|
274
|
-
// Location-based search
|
|
275
|
-
near_location: {
|
|
276
|
-
type: 'object',
|
|
277
|
-
|
|
278
|
-
// REST: Haversine formula in SQL
|
|
279
|
-
filterOperator: function(query, value, { tableName }) {
|
|
280
|
-
const { lat, lng, radius = 10 } = value;
|
|
281
|
-
query.whereRaw(`
|
|
282
|
-
(6371 * acos(
|
|
283
|
-
cos(radians(?)) * cos(radians(${tableName}.latitude)) *
|
|
284
|
-
cos(radians(${tableName}.longitude) - radians(?)) +
|
|
285
|
-
sin(radians(?)) * sin(radians(${tableName}.latitude))
|
|
286
|
-
)) <= ?
|
|
287
|
-
`, [lat, lng, lat, radius]);
|
|
288
|
-
},
|
|
289
|
-
|
|
290
|
-
// WebSocket: JavaScript distance calculation
|
|
291
|
-
filterRecord: function(record, value) {
|
|
292
|
-
const { lat, lng, radius = 10 } = value;
|
|
293
|
-
const distance = calculateDistance(
|
|
294
|
-
lat, lng,
|
|
295
|
-
record.latitude, record.longitude
|
|
296
|
-
);
|
|
297
|
-
return distance <= radius;
|
|
298
|
-
}
|
|
299
|
-
},
|
|
300
|
-
|
|
301
|
-
// Custom business logic
|
|
302
|
-
available_for_user: {
|
|
303
|
-
type: 'object',
|
|
304
|
-
|
|
305
|
-
// REST: Complex JOIN with user permissions
|
|
306
|
-
filterOperator: function(query, value, { tableName }) {
|
|
307
|
-
const { user_id, include_private } = value;
|
|
308
|
-
query.where(`${tableName}.owner_id`, user_id);
|
|
309
|
-
if (!include_private) {
|
|
310
|
-
query.orWhere(`${tableName}.is_public`, true);
|
|
311
|
-
}
|
|
312
|
-
},
|
|
313
|
-
|
|
314
|
-
// WebSocket: Same logic in JavaScript
|
|
315
|
-
filterRecord: function(record, value) {
|
|
316
|
-
const { user_id, include_private } = value;
|
|
317
|
-
if (record.owner_id === user_id) return true;
|
|
318
|
-
if (record.is_public) return true;
|
|
319
|
-
return include_private && record.shared_with?.includes(user_id);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
### Filter Validation
|
|
326
|
-
|
|
327
|
-
All filters are validated against searchSchema before subscription:
|
|
328
|
-
|
|
329
|
-
```javascript
|
|
330
|
-
// This subscription
|
|
331
|
-
socket.emit('subscribe', {
|
|
332
|
-
resource: 'posts',
|
|
333
|
-
filters: {
|
|
334
|
-
status: 'published', // ✅ Valid: defined in searchSchema
|
|
335
|
-
invalid_field: 'value' // ❌ Error: not in searchSchema
|
|
336
|
-
}
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
// Returns error:
|
|
340
|
-
{
|
|
341
|
-
error: {
|
|
342
|
-
code: 'INVALID_FILTERS',
|
|
343
|
-
message: 'Invalid filter values',
|
|
344
|
-
details: {
|
|
345
|
-
invalid_field: {
|
|
346
|
-
code: 'UNKNOWN_FIELD',
|
|
347
|
-
message: 'Field not defined in searchSchema'
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
## Security Architecture
|
|
355
|
-
|
|
356
|
-
### Authentication
|
|
357
|
-
|
|
358
|
-
All connections must be authenticated using JWT tokens:
|
|
359
|
-
|
|
360
|
-
```javascript
|
|
361
|
-
// Client must provide valid JWT
|
|
362
|
-
const socket = io('http://localhost:3000', {
|
|
363
|
-
auth: {
|
|
364
|
-
token: 'eyJhbGciOiJIUzI1NiIs...' // Your JWT token
|
|
365
|
-
}
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
// Without valid token, connection is rejected
|
|
369
|
-
socket.on('connect_error', (error) => {
|
|
370
|
-
console.error('Authentication failed:', error.message);
|
|
371
|
-
});
|
|
372
|
-
```
|
|
373
|
-
|
|
374
|
-
### Permission Checking
|
|
375
|
-
|
|
376
|
-
Subscriptions require 'query' permission on the resource:
|
|
377
|
-
|
|
378
|
-
```javascript
|
|
379
|
-
// In your scope definition
|
|
380
|
-
await api.addResource('secret-documents', {
|
|
381
|
-
// ... schema ...
|
|
382
|
-
|
|
383
|
-
checkPermissions: async ({ method, auth }) => {
|
|
384
|
-
if (method === 'query') {
|
|
385
|
-
// Check if user can query/subscribe to this resource
|
|
386
|
-
return auth.roles?.includes('admin');
|
|
387
|
-
}
|
|
388
|
-
// ... other permission checks
|
|
389
|
-
}
|
|
390
|
-
});
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
### Filter Injection with Hooks
|
|
394
|
-
|
|
395
|
-
Use the `subscriptionFilters` hook to enforce security policies:
|
|
396
|
-
|
|
397
|
-
```javascript
|
|
398
|
-
// Multi-tenancy plugin example
|
|
399
|
-
export const MultiTenancyPlugin = {
|
|
400
|
-
name: 'multi-tenancy',
|
|
401
|
-
|
|
402
|
-
install({ addHook }) {
|
|
403
|
-
// This hook runs for EVERY subscription
|
|
404
|
-
addHook('subscriptionFilters', 'workspace-isolation', {},
|
|
405
|
-
async ({ subscription, auth }) => {
|
|
406
|
-
// Force workspace isolation
|
|
407
|
-
if (!auth.workspace_id) {
|
|
408
|
-
throw new Error('User must belong to a workspace');
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Always add workspace filter
|
|
412
|
-
subscription.filters.workspace_id = auth.workspace_id;
|
|
413
|
-
|
|
414
|
-
// Prevent bypassing workspace isolation
|
|
415
|
-
if (subscription.filters.workspace_id &&
|
|
416
|
-
subscription.filters.workspace_id !== auth.workspace_id) {
|
|
417
|
-
throw new Error('Cannot subscribe to other workspaces');
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
);
|
|
421
|
-
}
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
// Now ALL subscriptions automatically include workspace filter
|
|
425
|
-
socket.emit('subscribe', {
|
|
426
|
-
resource: 'projects',
|
|
427
|
-
filters: { status: 'active' }
|
|
428
|
-
});
|
|
429
|
-
// Server automatically adds: filters.workspace_id = user's workspace
|
|
430
|
-
```
|
|
431
|
-
|
|
432
|
-
### Data Isolation Example
|
|
433
|
-
|
|
434
|
-
Here's a complete example showing how data isolation works:
|
|
435
|
-
|
|
436
|
-
```javascript
|
|
437
|
-
// User Roles Plugin
|
|
438
|
-
export const UserRolesPlugin = {
|
|
439
|
-
name: 'user-roles',
|
|
440
|
-
|
|
441
|
-
install({ addHook }) {
|
|
442
|
-
// Filter subscriptions based on user role
|
|
443
|
-
addHook('subscriptionFilters', 'role-based-filters', {},
|
|
444
|
-
async ({ subscription, auth }) => {
|
|
445
|
-
const { resource, filters } = subscription;
|
|
446
|
-
|
|
447
|
-
// Regular users can only see their own data
|
|
448
|
-
if (!auth.roles?.includes('admin')) {
|
|
449
|
-
switch (resource) {
|
|
450
|
-
case 'orders':
|
|
451
|
-
subscription.filters.customer_id = auth.user_id;
|
|
452
|
-
break;
|
|
453
|
-
|
|
454
|
-
case 'invoices':
|
|
455
|
-
subscription.filters.user_id = auth.user_id;
|
|
456
|
-
break;
|
|
457
|
-
|
|
458
|
-
case 'messages':
|
|
459
|
-
// Can see messages where they're sender or recipient
|
|
460
|
-
subscription.filters.$or = [
|
|
461
|
-
{ sender_id: auth.user_id },
|
|
462
|
-
{ recipient_id: auth.user_id }
|
|
463
|
-
];
|
|
464
|
-
break;
|
|
465
|
-
|
|
466
|
-
case 'admin-logs':
|
|
467
|
-
throw new Error('Access denied to admin resources');
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
);
|
|
472
|
-
}
|
|
473
|
-
};
|
|
474
|
-
```
|
|
475
|
-
|
|
476
|
-
## Advanced Features
|
|
477
|
-
|
|
478
|
-
### Redis Adapter for Scaling
|
|
479
|
-
|
|
480
|
-
When running multiple servers, use Redis adapter for proper broadcasting:
|
|
481
|
-
|
|
482
|
-
```javascript
|
|
483
|
-
// Server configuration
|
|
484
|
-
const io = await api.startSocketServer(server, {
|
|
485
|
-
redis: {
|
|
486
|
-
host: process.env.REDIS_HOST || 'localhost',
|
|
487
|
-
port: process.env.REDIS_PORT || 6379,
|
|
488
|
-
password: process.env.REDIS_PASSWORD,
|
|
489
|
-
db: 0
|
|
490
|
-
}
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
// Now broadcasts work across all servers
|
|
494
|
-
// Server A: Record updated → broadcast
|
|
495
|
-
// Server B: Receives broadcast → notifies its connected clients
|
|
496
|
-
```
|
|
497
|
-
|
|
498
|
-
### Subscription Management
|
|
499
|
-
|
|
500
|
-
Each socket can have multiple subscriptions with different filters:
|
|
501
|
-
|
|
502
|
-
```javascript
|
|
503
|
-
// Subscribe to different filtered views
|
|
504
|
-
const sub1 = await subscribeToResource(socket, {
|
|
505
|
-
resource: 'orders',
|
|
506
|
-
filters: { status: 'pending' }
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
const sub2 = await subscribeToResource(socket, {
|
|
510
|
-
resource: 'orders',
|
|
511
|
-
filters: { status: 'processing', priority: 'high' }
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
const sub3 = await subscribeToResource(socket, {
|
|
515
|
-
resource: 'products',
|
|
516
|
-
filters: { category_id: 5, in_stock: true }
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
// Unsubscribe from specific subscription
|
|
520
|
-
socket.emit('unsubscribe', {
|
|
521
|
-
subscriptionId: sub1.subscriptionId
|
|
522
|
-
});
|
|
523
|
-
```
|
|
524
|
-
|
|
525
|
-
### Include and Fields Storage
|
|
526
|
-
|
|
527
|
-
While notifications don't include data, subscriptions can store include/fields preferences:
|
|
528
|
-
|
|
529
|
-
```javascript
|
|
530
|
-
// Subscribe with preferred includes and fields
|
|
531
|
-
socket.emit('subscribe', {
|
|
532
|
-
resource: 'posts',
|
|
533
|
-
filters: { status: 'published' },
|
|
534
|
-
include: ['author', 'comments.user'],
|
|
535
|
-
fields: {
|
|
536
|
-
posts: ['title', 'summary', 'published_at'],
|
|
537
|
-
users: ['name', 'avatar'],
|
|
538
|
-
comments: ['body', 'created_at']
|
|
539
|
-
}
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
// Client can use these when fetching
|
|
543
|
-
socket.on('subscription.update', async (notification) => {
|
|
544
|
-
// Use the stored preferences for fetching
|
|
545
|
-
const url = `/api/posts/${notification.id}?` +
|
|
546
|
-
'include=author,comments.user&' +
|
|
547
|
-
'fields[posts]=title,summary,published_at&' +
|
|
548
|
-
'fields[users]=name,avatar';
|
|
549
|
-
|
|
550
|
-
const response = await fetch(url);
|
|
551
|
-
});
|
|
552
|
-
```
|
|
553
|
-
|
|
554
|
-
### Reconnection Support
|
|
555
|
-
|
|
556
|
-
Restore subscriptions after reconnection:
|
|
557
|
-
|
|
558
|
-
```javascript
|
|
559
|
-
// Store active subscriptions
|
|
560
|
-
const activeSubscriptions = new Map();
|
|
561
|
-
|
|
562
|
-
socket.on('subscription.created', (response) => {
|
|
563
|
-
activeSubscriptions.set(response.subscriptionId, response);
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
// On reconnect, restore all subscriptions
|
|
567
|
-
socket.on('connect', async () => {
|
|
568
|
-
if (activeSubscriptions.size > 0) {
|
|
569
|
-
const { restored, failed } = await restoreSubscriptions(
|
|
570
|
-
socket,
|
|
571
|
-
Array.from(activeSubscriptions.values())
|
|
572
|
-
);
|
|
573
|
-
|
|
574
|
-
console.log(`Restored ${restored.length} subscriptions`);
|
|
575
|
-
if (failed.length > 0) {
|
|
576
|
-
console.error(`Failed to restore ${failed.length} subscriptions`);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
async function restoreSubscriptions(socket, subscriptions) {
|
|
582
|
-
return new Promise((resolve) => {
|
|
583
|
-
socket.emit('restore-subscriptions',
|
|
584
|
-
{ subscriptions },
|
|
585
|
-
resolve
|
|
586
|
-
);
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
```
|
|
590
|
-
|
|
591
|
-
### Error Handling
|
|
592
|
-
|
|
593
|
-
The plugin provides detailed error information:
|
|
594
|
-
|
|
595
|
-
```javascript
|
|
596
|
-
socket.on('subscription.error', (error) => {
|
|
597
|
-
switch (error.code) {
|
|
598
|
-
case 'RESOURCE_NOT_FOUND':
|
|
599
|
-
console.error(`Resource type '${error.resource}' doesn't exist`);
|
|
600
|
-
break;
|
|
601
|
-
|
|
602
|
-
case 'PERMISSION_DENIED':
|
|
603
|
-
console.error('You lack permission to subscribe to this resource');
|
|
604
|
-
break;
|
|
605
|
-
|
|
606
|
-
case 'INVALID_FILTERS':
|
|
607
|
-
console.error('Filter validation failed:', error.details);
|
|
608
|
-
break;
|
|
609
|
-
|
|
610
|
-
case 'UNSUPPORTED_FILTER':
|
|
611
|
-
console.error(`Filter requires 'filterRecord' for WebSocket support`);
|
|
612
|
-
break;
|
|
613
|
-
|
|
614
|
-
case 'FILTERING_NOT_ENABLED':
|
|
615
|
-
console.error('Resource does not have searchSchema defined');
|
|
616
|
-
break;
|
|
617
|
-
}
|
|
618
|
-
});
|
|
619
|
-
```
|
|
620
|
-
|
|
621
|
-
## Client Usage
|
|
622
|
-
|
|
623
|
-
### Basic Setup
|
|
624
|
-
|
|
625
|
-
```javascript
|
|
626
|
-
import { io } from 'socket.io-client';
|
|
627
|
-
|
|
628
|
-
// Connect with authentication
|
|
629
|
-
const socket = io('http://localhost:3000', {
|
|
630
|
-
auth: {
|
|
631
|
-
token: localStorage.getItem('jwt_token')
|
|
632
|
-
}
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
// Handle connection events
|
|
636
|
-
socket.on('connect', () => {
|
|
637
|
-
console.log('Connected to WebSocket server');
|
|
638
|
-
});
|
|
639
|
-
|
|
640
|
-
socket.on('connect_error', (error) => {
|
|
641
|
-
console.error('Connection failed:', error.message);
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
socket.on('disconnect', (reason) => {
|
|
645
|
-
console.log('Disconnected:', reason);
|
|
646
|
-
});
|
|
647
|
-
```
|
|
648
|
-
|
|
649
|
-
### Subscribing to Resources
|
|
650
|
-
|
|
651
|
-
```javascript
|
|
652
|
-
// Helper function for subscribing
|
|
653
|
-
async function subscribeToResource(socket, options) {
|
|
654
|
-
return new Promise((resolve, reject) => {
|
|
655
|
-
socket.emit('subscribe', options, (response) => {
|
|
656
|
-
if (response.error) {
|
|
657
|
-
reject(response.error);
|
|
658
|
-
} else {
|
|
659
|
-
resolve(response.data);
|
|
660
|
-
}
|
|
661
|
-
});
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// Subscribe to filtered resources
|
|
666
|
-
try {
|
|
667
|
-
const subscription = await subscribeToResource(socket, {
|
|
668
|
-
resource: 'posts',
|
|
669
|
-
filters: {
|
|
670
|
-
status: 'published',
|
|
671
|
-
category_id: 5
|
|
672
|
-
}
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
console.log('Subscribed:', subscription.subscriptionId);
|
|
676
|
-
} catch (error) {
|
|
677
|
-
console.error('Subscription failed:', error);
|
|
678
|
-
}
|
|
679
|
-
```
|
|
680
|
-
|
|
681
|
-
### Handling Updates
|
|
682
|
-
|
|
683
|
-
```javascript
|
|
684
|
-
// Set up update handler
|
|
685
|
-
socket.on('subscription.update', async (notification) => {
|
|
686
|
-
console.log('Resource updated:', notification);
|
|
687
|
-
// {
|
|
688
|
-
// type: 'resource.updated',
|
|
689
|
-
// resource: 'posts',
|
|
690
|
-
// id: '123',
|
|
691
|
-
// action: 'update',
|
|
692
|
-
// subscriptionId: 'posts-1234567-abc',
|
|
693
|
-
// meta: { timestamp: '2024-01-15T10:00:00Z' }
|
|
694
|
-
// }
|
|
695
|
-
|
|
696
|
-
// Handle different actions
|
|
697
|
-
switch (notification.action) {
|
|
698
|
-
case 'post':
|
|
699
|
-
await handleNewResource(notification);
|
|
700
|
-
break;
|
|
701
|
-
|
|
702
|
-
case 'update':
|
|
703
|
-
case 'patch':
|
|
704
|
-
await handleUpdatedResource(notification);
|
|
705
|
-
break;
|
|
706
|
-
|
|
707
|
-
case 'delete':
|
|
708
|
-
await handleDeletedResource(notification);
|
|
709
|
-
break;
|
|
710
|
-
}
|
|
711
|
-
});
|
|
712
|
-
|
|
713
|
-
// Fetch updated data when needed
|
|
714
|
-
async function handleUpdatedResource(notification) {
|
|
715
|
-
// Check if user is viewing this resource
|
|
716
|
-
if (isCurrentlyViewing(notification.resource, notification.id)) {
|
|
717
|
-
// Fetch immediately
|
|
718
|
-
const response = await fetch(
|
|
719
|
-
`/api/${notification.resource}/${notification.id}`,
|
|
720
|
-
{ headers: { Authorization: `Bearer ${token}` } }
|
|
721
|
-
);
|
|
722
|
-
|
|
723
|
-
if (response.ok) {
|
|
724
|
-
const data = await response.json();
|
|
725
|
-
updateUI(data);
|
|
726
|
-
}
|
|
727
|
-
} else {
|
|
728
|
-
// Just invalidate cache
|
|
729
|
-
cacheManager.invalidate(notification.resource, notification.id);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
```
|
|
733
|
-
|
|
734
|
-
### Complete Client Example
|
|
735
|
-
|
|
736
|
-
```javascript
|
|
737
|
-
class RealtimeResourceManager {
|
|
738
|
-
constructor(apiUrl, token) {
|
|
739
|
-
this.apiUrl = apiUrl;
|
|
740
|
-
this.token = token;
|
|
741
|
-
this.subscriptions = new Map();
|
|
742
|
-
this.cache = new Map();
|
|
743
|
-
|
|
744
|
-
this.socket = io(apiUrl, {
|
|
745
|
-
auth: { token }
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
this.setupEventHandlers();
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
setupEventHandlers() {
|
|
752
|
-
this.socket.on('connect', () => {
|
|
753
|
-
console.log('WebSocket connected');
|
|
754
|
-
this.restoreSubscriptions();
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
this.socket.on('subscription.update', (notification) => {
|
|
758
|
-
this.handleUpdate(notification);
|
|
759
|
-
});
|
|
760
|
-
|
|
761
|
-
this.socket.on('subscription.created', (response) => {
|
|
762
|
-
this.subscriptions.set(response.subscriptionId, response);
|
|
763
|
-
});
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
async subscribe(resource, filters = {}, options = {}) {
|
|
767
|
-
return new Promise((resolve, reject) => {
|
|
768
|
-
this.socket.emit('subscribe', {
|
|
769
|
-
resource,
|
|
770
|
-
filters,
|
|
771
|
-
...options
|
|
772
|
-
}, (response) => {
|
|
773
|
-
if (response.error) {
|
|
774
|
-
reject(response.error);
|
|
775
|
-
} else {
|
|
776
|
-
resolve(response.data);
|
|
777
|
-
}
|
|
778
|
-
});
|
|
779
|
-
});
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
async handleUpdate(notification) {
|
|
783
|
-
const { resource, id, action } = notification;
|
|
784
|
-
|
|
785
|
-
// Invalidate cache
|
|
786
|
-
const cacheKey = `${resource}:${id}`;
|
|
787
|
-
this.cache.delete(cacheKey);
|
|
788
|
-
|
|
789
|
-
// Emit custom event for UI updates
|
|
790
|
-
this.emit('resource:updated', {
|
|
791
|
-
resource,
|
|
792
|
-
id,
|
|
793
|
-
action,
|
|
794
|
-
notification
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
async fetchResource(resource, id, options = {}) {
|
|
799
|
-
const cacheKey = `${resource}:${id}`;
|
|
800
|
-
|
|
801
|
-
// Check cache first
|
|
802
|
-
if (this.cache.has(cacheKey)) {
|
|
803
|
-
return this.cache.get(cacheKey);
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
// Fetch from API
|
|
807
|
-
const queryString = new URLSearchParams(options).toString();
|
|
808
|
-
const url = `${this.apiUrl}/${resource}/${id}${queryString ? '?' + queryString : ''}`;
|
|
809
|
-
|
|
810
|
-
const response = await fetch(url, {
|
|
811
|
-
headers: {
|
|
812
|
-
Authorization: `Bearer ${this.token}`
|
|
813
|
-
}
|
|
814
|
-
});
|
|
815
|
-
|
|
816
|
-
if (!response.ok) {
|
|
817
|
-
throw new Error(`Failed to fetch ${resource}/${id}`);
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
const data = await response.json();
|
|
821
|
-
|
|
822
|
-
// Cache the result
|
|
823
|
-
this.cache.set(cacheKey, data);
|
|
824
|
-
|
|
825
|
-
return data;
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
async restoreSubscriptions() {
|
|
829
|
-
if (this.subscriptions.size === 0) return;
|
|
830
|
-
|
|
831
|
-
const subscriptions = Array.from(this.subscriptions.values());
|
|
832
|
-
|
|
833
|
-
return new Promise((resolve) => {
|
|
834
|
-
this.socket.emit('restore-subscriptions',
|
|
835
|
-
{ subscriptions },
|
|
836
|
-
(response) => {
|
|
837
|
-
if (response.error) {
|
|
838
|
-
console.error('Failed to restore subscriptions:', response.error);
|
|
839
|
-
} else {
|
|
840
|
-
console.log(`Restored ${response.restored.length} subscriptions`);
|
|
841
|
-
}
|
|
842
|
-
resolve(response);
|
|
843
|
-
}
|
|
844
|
-
);
|
|
845
|
-
});
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
// Usage
|
|
850
|
-
const realtime = new RealtimeResourceManager(
|
|
851
|
-
'http://localhost:3000',
|
|
852
|
-
localStorage.getItem('jwt_token')
|
|
853
|
-
);
|
|
854
|
-
|
|
855
|
-
// Subscribe to posts
|
|
856
|
-
await realtime.subscribe('posts', {
|
|
857
|
-
status: 'published',
|
|
858
|
-
author_id: currentUser.id
|
|
859
|
-
});
|
|
860
|
-
|
|
861
|
-
// React to updates
|
|
862
|
-
realtime.on('resource:updated', async ({ resource, id, action }) => {
|
|
863
|
-
if (resource === 'posts' && isViewingPost(id)) {
|
|
864
|
-
const post = await realtime.fetchResource('posts', id, {
|
|
865
|
-
include: 'author,comments'
|
|
866
|
-
});
|
|
867
|
-
updatePostUI(post);
|
|
868
|
-
}
|
|
869
|
-
});
|
|
870
|
-
```
|
|
871
|
-
|
|
872
|
-
## Performance Considerations
|
|
873
|
-
|
|
874
|
-
### Subscription Limits
|
|
875
|
-
|
|
876
|
-
Each socket is limited to 100 subscriptions to prevent memory exhaustion:
|
|
877
|
-
|
|
878
|
-
```javascript
|
|
879
|
-
// After 100 subscriptions, new ones are rejected
|
|
880
|
-
socket.emit('subscribe', { resource: 'posts' }, (response) => {
|
|
881
|
-
if (response.error?.code === 'SUBSCRIPTION_LIMIT_EXCEEDED') {
|
|
882
|
-
console.error('Too many active subscriptions');
|
|
883
|
-
}
|
|
884
|
-
});
|
|
885
|
-
```
|
|
886
|
-
|
|
887
|
-
### Filter Efficiency
|
|
888
|
-
|
|
889
|
-
- **Simple operators** (`=`, `>`, etc.) are very fast - just property comparisons
|
|
890
|
-
- **Complex filters** with `filterRecord` functions should be kept lightweight
|
|
891
|
-
- **Avoid expensive operations** in filterRecord (no async calls, minimal computation)
|
|
892
|
-
|
|
893
|
-
### Broadcast Optimization
|
|
894
|
-
|
|
895
|
-
The plugin optimizes broadcasts by:
|
|
896
|
-
|
|
897
|
-
1. **Single broadcast per change** - Not N broadcasts for N subscribers
|
|
898
|
-
2. **Room-based delivery** - Socket.IO efficiently handles room broadcasts
|
|
899
|
-
3. **Minimal payload** - Notifications are tiny (< 200 bytes)
|
|
900
|
-
4. **In-memory filtering** - Uses context.minimalRecord, no database queries
|
|
901
|
-
|
|
902
|
-
### Client-Side Optimization
|
|
903
|
-
|
|
904
|
-
Optimize your client implementation:
|
|
905
|
-
|
|
906
|
-
```javascript
|
|
907
|
-
// Batch fetch requests
|
|
908
|
-
const pendingFetches = new Set();
|
|
909
|
-
|
|
910
|
-
socket.on('subscription.update', (notification) => {
|
|
911
|
-
pendingFetches.add(`${notification.resource}:${notification.id}`);
|
|
912
|
-
});
|
|
913
|
-
|
|
914
|
-
// Fetch in batches every 100ms
|
|
915
|
-
setInterval(async () => {
|
|
916
|
-
if (pendingFetches.size === 0) return;
|
|
917
|
-
|
|
918
|
-
const toFetch = Array.from(pendingFetches);
|
|
919
|
-
pendingFetches.clear();
|
|
920
|
-
|
|
921
|
-
// Batch fetch multiple resources
|
|
922
|
-
const results = await Promise.all(
|
|
923
|
-
toFetch.map(key => {
|
|
924
|
-
const [resource, id] = key.split(':');
|
|
925
|
-
return fetchResource(resource, id);
|
|
926
|
-
})
|
|
927
|
-
);
|
|
928
|
-
|
|
929
|
-
// Update UI with all results
|
|
930
|
-
updateBatchUI(results);
|
|
931
|
-
}, 100);
|
|
932
|
-
```
|
|
933
|
-
|
|
934
|
-
## Troubleshooting
|
|
935
|
-
|
|
936
|
-
### Common Issues
|
|
937
|
-
|
|
938
|
-
**1. Subscriptions not receiving updates**
|
|
939
|
-
|
|
940
|
-
Check:
|
|
941
|
-
- Are filters too restrictive?
|
|
942
|
-
- Does the record pass `context.minimalRecord` filtering?
|
|
943
|
-
- Are you in a transaction that hasn't committed?
|
|
944
|
-
|
|
945
|
-
**2. "UNSUPPORTED_FILTER" errors**
|
|
946
|
-
|
|
947
|
-
If using custom filterOperator functions, you must provide filterRecord:
|
|
948
|
-
|
|
949
|
-
```javascript
|
|
950
|
-
// ❌ This will error for WebSocket
|
|
951
|
-
searchSchema: {
|
|
952
|
-
complex_search: {
|
|
953
|
-
type: 'string',
|
|
954
|
-
filterOperator: function(query, value) { /* SQL */ }
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
// ✅ This works for both REST and WebSocket
|
|
959
|
-
searchSchema: {
|
|
960
|
-
complex_search: {
|
|
961
|
-
type: 'string',
|
|
962
|
-
filterOperator: function(query, value) { /* SQL */ },
|
|
963
|
-
filterRecord: function(record, value) { /* JavaScript */ }
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
```
|
|
967
|
-
|
|
968
|
-
**3. Authentication failures**
|
|
969
|
-
|
|
970
|
-
Ensure:
|
|
971
|
-
- JWT token is valid and not expired
|
|
972
|
-
- Token is sent in auth.token, not headers
|
|
973
|
-
- JWT plugin is configured correctly
|
|
974
|
-
|
|
975
|
-
**4. Redis connection issues**
|
|
976
|
-
|
|
977
|
-
If using Redis adapter:
|
|
978
|
-
- Check Redis server is running
|
|
979
|
-
- Verify connection credentials
|
|
980
|
-
- Ensure all servers use same Redis instance
|
|
981
|
-
|
|
982
|
-
### Debug Logging
|
|
983
|
-
|
|
984
|
-
Enable debug logging to troubleshoot:
|
|
985
|
-
|
|
986
|
-
```javascript
|
|
987
|
-
// Server-side
|
|
988
|
-
const api = new Api({
|
|
989
|
-
name: 'my-api',
|
|
990
|
-
logging: { level: 'debug' }
|
|
991
|
-
});
|
|
992
|
-
|
|
993
|
-
// Client-side
|
|
994
|
-
localStorage.debug = 'socket.io-client:*';
|
|
995
|
-
```
|
|
996
|
-
|
|
997
|
-
### Testing WebSocket Functionality
|
|
998
|
-
|
|
999
|
-
```javascript
|
|
1000
|
-
// Test helper for WebSocket subscriptions
|
|
1001
|
-
async function testWebSocketSubscription() {
|
|
1002
|
-
const socket = io('http://localhost:3000', {
|
|
1003
|
-
auth: { token: testToken }
|
|
1004
|
-
});
|
|
1005
|
-
|
|
1006
|
-
return new Promise((resolve, reject) => {
|
|
1007
|
-
socket.on('connect', async () => {
|
|
1008
|
-
console.log('✓ Connected to WebSocket');
|
|
1009
|
-
|
|
1010
|
-
// Test subscription
|
|
1011
|
-
socket.emit('subscribe', {
|
|
1012
|
-
resource: 'posts',
|
|
1013
|
-
filters: { status: 'published' }
|
|
1014
|
-
}, (response) => {
|
|
1015
|
-
if (response.error) {
|
|
1016
|
-
console.error('✗ Subscription failed:', response.error);
|
|
1017
|
-
reject(response.error);
|
|
1018
|
-
} else {
|
|
1019
|
-
console.log('✓ Subscription successful:', response.data);
|
|
1020
|
-
|
|
1021
|
-
// Wait for an update
|
|
1022
|
-
socket.once('subscription.update', (notification) => {
|
|
1023
|
-
console.log('✓ Received update:', notification);
|
|
1024
|
-
socket.close();
|
|
1025
|
-
resolve(notification);
|
|
1026
|
-
});
|
|
1027
|
-
|
|
1028
|
-
// Trigger an update
|
|
1029
|
-
createTestPost();
|
|
1030
|
-
}
|
|
1031
|
-
});
|
|
1032
|
-
});
|
|
1033
|
-
|
|
1034
|
-
socket.on('connect_error', (error) => {
|
|
1035
|
-
console.error('✗ Connection failed:', error.message);
|
|
1036
|
-
reject(error);
|
|
1037
|
-
});
|
|
1038
|
-
});
|
|
1039
|
-
}
|
|
1040
|
-
```
|