@slopware/sloppy-linux-x64 0.1.0-alpha.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 +201 -0
- package/README.md +5 -0
- package/bin/sloppy +0 -0
- package/bin/sloppyc +0 -0
- package/docs/KNOWN_LIMITATIONS.md +16 -0
- package/docs/LICENSES.md +6 -0
- package/docs/NOTICE.md +8 -0
- package/examples/README.md +140 -0
- package/examples/auth-api/README.md +20 -0
- package/examples/auth-api/app.js +61 -0
- package/examples/auth-api/appsettings.json +7 -0
- package/examples/auth-api/sloppy.json +5 -0
- package/examples/cache-basic/README.md +9 -0
- package/examples/cache-basic/app.js +32 -0
- package/examples/cache-hybrid-postgres/README.md +10 -0
- package/examples/cache-hybrid-postgres/app.js +27 -0
- package/examples/cache-output-api/README.md +10 -0
- package/examples/cache-output-api/app.js +35 -0
- package/examples/codec-base64-hex/README.md +14 -0
- package/examples/codec-base64-hex/app.js +15 -0
- package/examples/codec-checksums/README.md +15 -0
- package/examples/codec-checksums/app.js +8 -0
- package/examples/codec-compression/README.md +13 -0
- package/examples/codec-compression/app.js +9 -0
- package/examples/codec-streaming-compression/README.md +19 -0
- package/examples/codec-streaming-compression/app.js +16 -0
- package/examples/codec-text-binary/README.md +16 -0
- package/examples/codec-text-binary/app.js +17 -0
- package/examples/compiler-hello/README.md +71 -0
- package/examples/compiler-hello/app.js +7 -0
- package/examples/compiler-hello/expected/app.js +8 -0
- package/examples/compiler-hello/expected/app.js.map +53 -0
- package/examples/compiler-hello/expected/app.plan.json +229 -0
- package/examples/compiler-hello/expected/routes.slrt +0 -0
- package/examples/config-basic/README.md +13 -0
- package/examples/config-basic/app.js +13 -0
- package/examples/config-basic/appsettings.json +7 -0
- package/examples/config-secrets-redaction/README.md +9 -0
- package/examples/config-secrets-redaction/app.js +9 -0
- package/examples/config-secrets-redaction/appsettings.json +5 -0
- package/examples/config-strict-mode/README.md +7 -0
- package/examples/config-strict-mode/app.js +10 -0
- package/examples/config-strict-mode/appsettings.json +7 -0
- package/examples/configured-api/README.md +38 -0
- package/examples/configured-api/app.js +12 -0
- package/examples/configured-api/appsettings.Development.json +5 -0
- package/examples/configured-api/appsettings.json +6 -0
- package/examples/configured-api/sloppy.json +5 -0
- package/examples/core-config-secrets/README.md +10 -0
- package/examples/core-config-secrets/app.js +15 -0
- package/examples/core-fs-time-codec/README.md +9 -0
- package/examples/core-fs-time-codec/app.js +8 -0
- package/examples/core-network-time-codec/README.md +11 -0
- package/examples/core-network-time-codec/app.js +20 -0
- package/examples/core-policy-audit/README.md +7 -0
- package/examples/core-policy-audit/app.js +22 -0
- package/examples/core-process-time-codec/README.md +8 -0
- package/examples/core-process-time-codec/app.js +28 -0
- package/examples/core-worker-time/README.md +8 -0
- package/examples/core-worker-time/app.js +17 -0
- package/examples/crypto-hash-hmac/README.md +17 -0
- package/examples/crypto-hash-hmac/app.js +29 -0
- package/examples/crypto-password/README.md +21 -0
- package/examples/crypto-password/app.js +12 -0
- package/examples/crypto-random-token/README.md +16 -0
- package/examples/crypto-random-token/app.js +12 -0
- package/examples/crypto-secret-constant-time/README.md +21 -0
- package/examples/crypto-secret-constant-time/app.js +15 -0
- package/examples/data-foundation/README.md +39 -0
- package/examples/data-foundation/app.js +63 -0
- package/examples/dependency-graph/README.md +19 -0
- package/examples/dependency-graph/fixtures/graph-helper/index.js +3 -0
- package/examples/dependency-graph/fixtures/graph-helper/package.json +6 -0
- package/examples/dependency-graph/package.json +7 -0
- package/examples/dependency-graph/public/message.txt +1 -0
- package/examples/dependency-graph/sloppy.json +9 -0
- package/examples/dependency-graph/src/main.ts +8 -0
- package/examples/dogfood/README.md +23 -0
- package/examples/dogfood/dogfood.json +136 -0
- package/examples/dynamic-module-include/README.md +20 -0
- package/examples/dynamic-module-include/public/readme.txt +1 -0
- package/examples/dynamic-module-include/sloppy.json +12 -0
- package/examples/dynamic-module-include/src/main.ts +6 -0
- package/examples/dynamic-module-include/src/plugins/alpha.js +3 -0
- package/examples/dynamic-module-include/src/plugins/beta.js +3 -0
- package/examples/ergonomics/README.md +42 -0
- package/examples/ergonomics/app.js +38 -0
- package/examples/framework-controller/README.md +12 -0
- package/examples/framework-controller/app.js +31 -0
- package/examples/framework-di-services/README.md +17 -0
- package/examples/framework-di-services/app.ts +40 -0
- package/examples/framework-explicit-binding/README.md +12 -0
- package/examples/framework-explicit-binding/app.ts +34 -0
- package/examples/framework-hello/README.md +16 -0
- package/examples/framework-hello/app.ts +16 -0
- package/examples/framework-postgres-crud/README.md +73 -0
- package/examples/framework-postgres-crud/app.ts +64 -0
- package/examples/framework-sqlite-crud/README.md +52 -0
- package/examples/framework-sqlite-crud/app.ts +90 -0
- package/examples/framework-sqlite-crud/appsettings.json +11 -0
- package/examples/framework-sqlserver-crud/README.md +73 -0
- package/examples/framework-sqlserver-crud/app.ts +64 -0
- package/examples/framework-validation-errors/README.md +12 -0
- package/examples/framework-validation-errors/app.ts +16 -0
- package/examples/fs-basic/README.md +24 -0
- package/examples/fs-basic/app.js +12 -0
- package/examples/fs-roots-policy/README.md +14 -0
- package/examples/fs-roots-policy/app.js +4 -0
- package/examples/fs-streams/README.md +18 -0
- package/examples/fs-streams/app.js +11 -0
- package/examples/fs-watch/README.md +19 -0
- package/examples/fs-watch/app.js +11 -0
- package/examples/hello/README.md +63 -0
- package/examples/hello/app.js +19 -0
- package/examples/hello-minimal/README.md +51 -0
- package/examples/hello-minimal/sloppy.json +5 -0
- package/examples/hello-minimal/src/main.ts +9 -0
- package/examples/http-client-basic/README.md +11 -0
- package/examples/http-client-basic/app.js +46 -0
- package/examples/http-client-generated/README.md +22 -0
- package/examples/http-client-generated/openapi.json +45 -0
- package/examples/http-client-resilience/README.md +4 -0
- package/examples/http-client-resilience/app.js +38 -0
- package/examples/http-client-runtime-loopback/README.md +24 -0
- package/examples/http-client-testhost/README.md +4 -0
- package/examples/http-client-testhost/app.js +27 -0
- package/examples/http-client-testhost-package-mock/README.md +26 -0
- package/examples/http-client-typed/README.md +5 -0
- package/examples/http-client-typed/app.js +33 -0
- package/examples/modules-api/README.md +30 -0
- package/examples/modules-api/app.js +9 -0
- package/examples/modules-api/modules/routes.js +16 -0
- package/examples/modules-api/sloppy.json +5 -0
- package/examples/modules-basic/README.md +32 -0
- package/examples/modules-basic/app.js +41 -0
- package/examples/net-deadline-cancel/README.md +13 -0
- package/examples/net-deadline-cancel/app.js +34 -0
- package/examples/net-local-ipc/README.md +12 -0
- package/examples/net-local-ipc/app.js +46 -0
- package/examples/net-policy-strict/README.md +12 -0
- package/examples/net-policy-strict/app.js +34 -0
- package/examples/net-tcp-client/README.md +10 -0
- package/examples/net-tcp-client/app.js +23 -0
- package/examples/net-tcp-echo/README.md +11 -0
- package/examples/net-tcp-echo/app.js +45 -0
- package/examples/net-tcp-server/README.md +10 -0
- package/examples/net-tcp-server/app.js +28 -0
- package/examples/node-compat-path-events/README.md +15 -0
- package/examples/node-compat-path-events/sloppy.json +6 -0
- package/examples/node-compat-path-events/src/main.ts +15 -0
- package/examples/ops-compiler/README.md +9 -0
- package/examples/ops-compiler/app.js +26 -0
- package/examples/ops-health-metrics-management/README.md +14 -0
- package/examples/ops-health-metrics-management/app.js +24 -0
- package/examples/orm-basic/README.md +17 -0
- package/examples/orm-basic/app.js +82 -0
- package/examples/orm-cursor-export/README.md +16 -0
- package/examples/orm-cursor-export/app.js +28 -0
- package/examples/orm-migrations/README.md +14 -0
- package/examples/orm-migrations/migrations/.gitkeep +1 -0
- package/examples/orm-migrations/sloppy.json +9 -0
- package/examples/orm-migrations/src/app.ts +34 -0
- package/examples/orm-relations-includes/README.md +10 -0
- package/examples/orm-relations-includes/app.js +47 -0
- package/examples/orm-testservices/README.md +37 -0
- package/examples/orm-testservices/test.mjs +32 -0
- package/examples/os-runtime-api/README.md +11 -0
- package/examples/os-runtime-api/app.js +44 -0
- package/examples/package-zod-like/README.md +28 -0
- package/examples/package-zod-like/fixtures/zod-like/index.js +48 -0
- package/examples/package-zod-like/fixtures/zod-like/package.json +12 -0
- package/examples/package-zod-like/package.json +7 -0
- package/examples/package-zod-like/sloppy.json +6 -0
- package/examples/package-zod-like/src/main.ts +16 -0
- package/examples/postgres-basic/README.md +31 -0
- package/examples/postgres-basic/app.js +50 -0
- package/examples/prealpha-control-plane/README.md +50 -0
- package/examples/prealpha-control-plane/appsettings.Development.json +11 -0
- package/examples/prealpha-control-plane/appsettings.json +15 -0
- package/examples/prealpha-control-plane/sloppy.json +5 -0
- package/examples/prealpha-control-plane/src/db/schema.js +7 -0
- package/examples/prealpha-control-plane/src/db/seed.js +6 -0
- package/examples/prealpha-control-plane/src/main.js +21 -0
- package/examples/prealpha-control-plane/src/routes/apps.js +34 -0
- package/examples/prealpha-control-plane/src/routes/builds.js +25 -0
- package/examples/prealpha-control-plane/src/routes/deployments.js +19 -0
- package/examples/prealpha-control-plane/src/routes/diagnostics.js +11 -0
- package/examples/prealpha-control-plane/src/routes/health.js +27 -0
- package/examples/prealpha-control-plane/src/routes/projects.js +38 -0
- package/examples/prealpha-control-plane/src/services/diagnosticsSink.js +11 -0
- package/examples/prealpha-control-plane/src/services/repositories.js +9 -0
- package/examples/prealpha-control-plane/src/validation/schemas.js +6 -0
- package/examples/program-fs-process/README.md +31 -0
- package/examples/program-fs-process/sloppy.json +9 -0
- package/examples/program-fs-process/src/main.ts +27 -0
- package/examples/program-hello/README.md +32 -0
- package/examples/program-hello/main.ts +8 -0
- package/examples/program-hello/message.ts +1 -0
- package/examples/program-hello/sloppy.json +5 -0
- package/examples/rate-limit-auth/README.md +3 -0
- package/examples/rate-limit-auth/app.js +14 -0
- package/examples/rate-limit-basic/README.md +3 -0
- package/examples/rate-limit-basic/app.js +13 -0
- package/examples/rate-limit-redis/README.md +5 -0
- package/examples/rate-limit-redis/app.js +20 -0
- package/examples/rate-limit-testhost/README.md +4 -0
- package/examples/rate-limit-testhost/app.js +13 -0
- package/examples/rate-limit-websocket/README.md +3 -0
- package/examples/rate-limit-websocket/app.js +16 -0
- package/examples/realtime-auth/README.md +8 -0
- package/examples/realtime-auth/app.js +25 -0
- package/examples/realtime-auth/test.mjs +43 -0
- package/examples/realtime-chat/README.md +8 -0
- package/examples/realtime-chat/app.js +32 -0
- package/examples/realtime-chat/test.mjs +52 -0
- package/examples/realtime-dashboard/README.md +20 -0
- package/examples/realtime-dashboard/app.js +37 -0
- package/examples/realtime-presence/README.md +8 -0
- package/examples/realtime-presence/app.js +32 -0
- package/examples/realtime-presence/test.mjs +50 -0
- package/examples/realtime-testhost/README.md +8 -0
- package/examples/realtime-testhost/test.mjs +31 -0
- package/examples/redis-basic/README.md +17 -0
- package/examples/redis-basic/app.js +39 -0
- package/examples/redis-cache/README.md +14 -0
- package/examples/redis-cache/app.js +36 -0
- package/examples/redis-locks/README.md +13 -0
- package/examples/redis-locks/app.js +49 -0
- package/examples/request-context/README.md +32 -0
- package/examples/request-context/app.js +15 -0
- package/examples/sqlite-basic/README.md +52 -0
- package/examples/sqlite-basic/app.js +56 -0
- package/examples/sqlserver-basic/README.md +36 -0
- package/examples/sqlserver-basic/app.js +59 -0
- package/examples/static-files-basic/README.md +11 -0
- package/examples/static-files-basic/app.js +12 -0
- package/examples/static-files-basic/public/app.js +1 -0
- package/examples/static-files-basic/public/site.css +3 -0
- package/examples/static-files-package/README.md +12 -0
- package/examples/static-files-package/app.js +10 -0
- package/examples/static-files-package/public/index.html +2 -0
- package/examples/static-files-precompressed/README.md +12 -0
- package/examples/static-files-precompressed/app.js +11 -0
- package/examples/static-files-precompressed/public/app.js +1 -0
- package/examples/static-files-precompressed/public/app.js.br +0 -0
- package/examples/static-files-precompressed/public/app.js.gz +0 -0
- package/examples/static-files-spa/README.md +12 -0
- package/examples/static-files-spa/app.js +16 -0
- package/examples/static-files-spa/dist/assets/app.js +1 -0
- package/examples/static-files-spa/dist/index.html +4 -0
- package/examples/static-files-testhost/README.md +8 -0
- package/examples/static-files-testhost/app.js +13 -0
- package/examples/static-files-testhost/public/app.js +1 -0
- package/examples/static-files-testhost/public/app.js.gz +0 -0
- package/examples/static-files-testhost/test.mjs +38 -0
- package/examples/testhost-basic/README.md +26 -0
- package/examples/testhost-db/README.md +31 -0
- package/examples/testservices-postgres/README.md +68 -0
- package/examples/testservices-redis/README.md +71 -0
- package/examples/testservices-sqlserver/README.md +75 -0
- package/examples/time-basic/README.md +18 -0
- package/examples/time-basic/app.js +12 -0
- package/examples/time-deadline-cancellation/README.md +11 -0
- package/examples/time-deadline-cancellation/app.js +27 -0
- package/examples/time-fake-clock/README.md +14 -0
- package/examples/time-fake-clock/app.js +25 -0
- package/examples/time-interval-schedule/README.md +13 -0
- package/examples/time-interval-schedule/app.js +60 -0
- package/examples/users-api-sqlite/README.md +74 -0
- package/examples/users-api-sqlite/app.js +11 -0
- package/examples/users-api-sqlite/appsettings.Development.json +11 -0
- package/examples/users-api-sqlite/appsettings.json +11 -0
- package/examples/users-api-sqlite/modules/users.js +40 -0
- package/examples/users-api-sqlite/sloppy.json +5 -0
- package/examples/validation-errors/README.md +36 -0
- package/examples/validation-errors/app.js +14 -0
- package/examples/validation-errors/invalid-user.http +6 -0
- package/examples/validation-errors/sloppy.json +5 -0
- package/examples/web-dynamic-routes/README.md +17 -0
- package/examples/web-dynamic-routes/app.ts +27 -0
- package/examples/webhooks-basic/README.md +11 -0
- package/examples/webhooks-basic/app.js +48 -0
- package/examples/websocket-auth/README.md +8 -0
- package/examples/websocket-auth/app.js +16 -0
- package/examples/websocket-echo/README.md +9 -0
- package/examples/websocket-echo/app.js +36 -0
- package/examples/websocket-json-schema/README.md +5 -0
- package/examples/websocket-json-schema/app.js +25 -0
- package/examples/websocket-testhost/README.md +11 -0
- package/examples/websocket-testhost/test.mjs +49 -0
- package/examples/workers-background-service/README.md +7 -0
- package/examples/workers-background-service/app.js +16 -0
- package/examples/workers-js-isolate/README.md +8 -0
- package/examples/workers-js-isolate/app.js +19 -0
- package/examples/workers-js-isolate/workers/parser.ts +11 -0
- package/examples/workers-shutdown/README.md +6 -0
- package/examples/workers-shutdown/app.js +26 -0
- package/examples/workers-workerpool/README.md +6 -0
- package/examples/workers-workerpool/app.js +23 -0
- package/examples/workers-workqueue/README.md +8 -0
- package/examples/workers-workqueue/app.js +24 -0
- package/manifest.json +59 -0
- package/package.json +34 -0
- package/stdlib/sloppy/README.md +177 -0
- package/stdlib/sloppy/app.js +2142 -0
- package/stdlib/sloppy/auth.js +1813 -0
- package/stdlib/sloppy/bootstrap.manifest.json +83 -0
- package/stdlib/sloppy/cache.js +1542 -0
- package/stdlib/sloppy/codec.js +1153 -0
- package/stdlib/sloppy/config.js +61 -0
- package/stdlib/sloppy/crypto.js +312 -0
- package/stdlib/sloppy/data.js +2945 -0
- package/stdlib/sloppy/ffi.js +185 -0
- package/stdlib/sloppy/fs.js +795 -0
- package/stdlib/sloppy/health.js +603 -0
- package/stdlib/sloppy/http.js +1595 -0
- package/stdlib/sloppy/index.js +59 -0
- package/stdlib/sloppy/internal/bytes.js +31 -0
- package/stdlib/sloppy/internal/capabilities.js +155 -0
- package/stdlib/sloppy/internal/config.js +640 -0
- package/stdlib/sloppy/internal/disposable.js +31 -0
- package/stdlib/sloppy/internal/headers.js +63 -0
- package/stdlib/sloppy/internal/intrinsics.js +2 -0
- package/stdlib/sloppy/internal/json.js +20 -0
- package/stdlib/sloppy/internal/logging.js +278 -0
- package/stdlib/sloppy/internal/modules.js +405 -0
- package/stdlib/sloppy/internal/redaction.js +87 -0
- package/stdlib/sloppy/internal/routes.js +2279 -0
- package/stdlib/sloppy/internal/runtime-classic.js +19837 -0
- package/stdlib/sloppy/internal/services.js +690 -0
- package/stdlib/sloppy/internal/shared.js +32 -0
- package/stdlib/sloppy/internal/testhost-diagnostics.js +88 -0
- package/stdlib/sloppy/internal/testhost-http-server.js +238 -0
- package/stdlib/sloppy/internal/testhost-http.js +118 -0
- package/stdlib/sloppy/internal/testhost-loopback.js +50 -0
- package/stdlib/sloppy/internal/testservices-docker.js +154 -0
- package/stdlib/sloppy/internal/validation.js +117 -0
- package/stdlib/sloppy/metrics.js +427 -0
- package/stdlib/sloppy/net.js +5208 -0
- package/stdlib/sloppy/node/assert/strict.js +39 -0
- package/stdlib/sloppy/node/assert.js +228 -0
- package/stdlib/sloppy/node/buffer.js +247 -0
- package/stdlib/sloppy/node/console.js +33 -0
- package/stdlib/sloppy/node/constants.js +9 -0
- package/stdlib/sloppy/node/crypto.js +89 -0
- package/stdlib/sloppy/node/diagnostics_channel.js +41 -0
- package/stdlib/sloppy/node/events.js +113 -0
- package/stdlib/sloppy/node/fs/promises.js +27 -0
- package/stdlib/sloppy/node/fs.js +280 -0
- package/stdlib/sloppy/node/http.js +11 -0
- package/stdlib/sloppy/node/https.js +11 -0
- package/stdlib/sloppy/node/module.js +40 -0
- package/stdlib/sloppy/node/os.js +22 -0
- package/stdlib/sloppy/node/path.js +78 -0
- package/stdlib/sloppy/node/perf_hooks.js +12 -0
- package/stdlib/sloppy/node/process.js +129 -0
- package/stdlib/sloppy/node/querystring.js +21 -0
- package/stdlib/sloppy/node/stream/promises.js +3 -0
- package/stdlib/sloppy/node/stream.js +132 -0
- package/stdlib/sloppy/node/string_decoder.js +23 -0
- package/stdlib/sloppy/node/timers.js +26 -0
- package/stdlib/sloppy/node/tty.js +18 -0
- package/stdlib/sloppy/node/url.js +17 -0
- package/stdlib/sloppy/node/util.js +95 -0
- package/stdlib/sloppy/node/zlib.js +72 -0
- package/stdlib/sloppy/orm.js +2188 -0
- package/stdlib/sloppy/os.js +580 -0
- package/stdlib/sloppy/problem-details.js +29 -0
- package/stdlib/sloppy/providers/sqlite.js +26 -0
- package/stdlib/sloppy/rate-limit.js +856 -0
- package/stdlib/sloppy/realtime.js +1508 -0
- package/stdlib/sloppy/redis.js +1272 -0
- package/stdlib/sloppy/request-id.js +184 -0
- package/stdlib/sloppy/request-logging.js +101 -0
- package/stdlib/sloppy/results.js +933 -0
- package/stdlib/sloppy/schema.js +546 -0
- package/stdlib/sloppy/testing.js +4081 -0
- package/stdlib/sloppy/testservices.js +1041 -0
- package/stdlib/sloppy/time.js +894 -0
- package/stdlib/sloppy/webhooks.js +1330 -0
- package/stdlib/sloppy/workers.js +986 -0
- package/templates/api/README.md +82 -0
- package/templates/api/appsettings.Development.json +14 -0
- package/templates/api/appsettings.json +13 -0
- package/templates/api/data/.gitkeep +1 -0
- package/templates/api/gitignore +4 -0
- package/templates/api/migrations/0001_create_users.sql +1 -0
- package/templates/api/package.json +16 -0
- package/templates/api/public/hello.txt +1 -0
- package/templates/api/sloppy.json +14 -0
- package/templates/api/src/config.ts +1 -0
- package/templates/api/src/db/migrate.ts +14 -0
- package/templates/api/src/db/schema.ts +4 -0
- package/templates/api/src/db/usersRepository.ts +23 -0
- package/templates/api/src/main.ts +18 -0
- package/templates/api/src/models/user.ts +7 -0
- package/templates/api/src/routes/health.ts +20 -0
- package/templates/api/src/routes/users.ts +40 -0
- package/templates/api/src/services/usersService.ts +21 -0
- package/templates/api/tsconfig.json +15 -0
- package/templates/cli/README.md +16 -0
- package/templates/cli/gitignore +2 -0
- package/templates/cli/package.json +13 -0
- package/templates/cli/sloppy.json +6 -0
- package/templates/cli/src/commands/echo.ts +9 -0
- package/templates/cli/src/commands/inspect.ts +20 -0
- package/templates/cli/src/main.ts +50 -0
- package/templates/cli/tsconfig.json +15 -0
- package/templates/minimal-api/README.md +14 -0
- package/templates/minimal-api/gitignore +3 -0
- package/templates/minimal-api/package.json +14 -0
- package/templates/minimal-api/sloppy.json +5 -0
- package/templates/minimal-api/src/main.ts +9 -0
- package/templates/minimal-api/tsconfig.json +15 -0
- package/templates/node-compat/README.md +40 -0
- package/templates/node-compat/gitignore +2 -0
- package/templates/node-compat/package.json +11 -0
- package/templates/node-compat/sloppy.json +6 -0
- package/templates/node-compat/src/main.ts +40 -0
- package/templates/package-api/README.md +44 -0
- package/templates/package-api/fixtures/validator-lite/index.js +7 -0
- package/templates/package-api/fixtures/validator-lite/package.json +6 -0
- package/templates/package-api/gitignore +3 -0
- package/templates/package-api/package.json +17 -0
- package/templates/package-api/sloppy.json +5 -0
- package/templates/package-api/src/main.ts +10 -0
- package/templates/package-api/src/routes/health.ts +5 -0
- package/templates/package-api/src/routes/users.ts +12 -0
- package/templates/package-api/tsconfig.json +15 -0
- package/templates/program/README.md +12 -0
- package/templates/program/gitignore +1 -0
- package/templates/program/package.json +10 -0
- package/templates/program/sloppy.json +6 -0
- package/templates/program/src/main.ts +9 -0
|
@@ -0,0 +1,1508 @@
|
|
|
1
|
+
import { isPlainObject } from "./internal/validation.js";
|
|
2
|
+
import { deepFreeze, snapshotJson } from "./internal/json.js";
|
|
3
|
+
import { Text } from "./codec.js";
|
|
4
|
+
import { Results } from "./results.js";
|
|
5
|
+
import { Schema, isSchema, isValidationError } from "./schema.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_QUEUE_LIMIT = 64;
|
|
8
|
+
const EVENT_NAME_PATTERN = /^[A-Za-z0-9_.:-]+$/u;
|
|
9
|
+
const REALTIME_IDENTIFIER_PATTERN = /^[A-Za-z][0-9A-Za-z_.:-]*$/u;
|
|
10
|
+
const WEBSOCKET_PROTOCOL_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
|
|
11
|
+
const WEBSOCKET_PROTOCOL_UNSAFE_PATTERN = /[^!#$%&'*+\-.^_`|~0-9A-Za-z]/gu;
|
|
12
|
+
const DEFAULT_WEBSOCKET_MAX_MESSAGE_BYTES = 64 * 1024;
|
|
13
|
+
const DEFAULT_WEBSOCKET_MAX_SEND_QUEUE_BYTES = 1024 * 1024;
|
|
14
|
+
const DEFAULT_WEBSOCKET_CLOSE_TIMEOUT_MS = 5000;
|
|
15
|
+
const WEBSOCKET_ROUTE_HANDLER = Symbol.for("sloppy.websocket.routeHandler");
|
|
16
|
+
const WEBSOCKET_ROUTE_OPTIONS = Symbol.for("sloppy.websocket.routeOptions");
|
|
17
|
+
const REALTIME_CHANNEL = Symbol.for("sloppy.realtime.channel");
|
|
18
|
+
const REALTIME_EVENT = Symbol.for("sloppy.realtime.event");
|
|
19
|
+
const REALTIME_ROUTE_METADATA = Symbol.for("sloppy.realtime.routeMetadata");
|
|
20
|
+
const RESERVED_REALTIME_EVENT_NAMES = new Set([
|
|
21
|
+
"connect",
|
|
22
|
+
"disconnect",
|
|
23
|
+
"error",
|
|
24
|
+
"ping",
|
|
25
|
+
"pong",
|
|
26
|
+
"join",
|
|
27
|
+
"leave",
|
|
28
|
+
"system",
|
|
29
|
+
]);
|
|
30
|
+
const MAX_ERROR_ISSUES = 8;
|
|
31
|
+
const MAX_ERROR_TEXT = 160;
|
|
32
|
+
const MAX_GROUP_NAME_LENGTH = 256;
|
|
33
|
+
const MAX_PRESENCE_METADATA_BYTES = 4096;
|
|
34
|
+
|
|
35
|
+
export class SloppyRealtimeError extends Error {
|
|
36
|
+
constructor(code, message, options = undefined) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "SloppyRealtimeError";
|
|
39
|
+
this.code = code;
|
|
40
|
+
this.event = options?.event;
|
|
41
|
+
this.issues = options?.issues === undefined
|
|
42
|
+
? Object.freeze([])
|
|
43
|
+
: Object.freeze([...options.issues]);
|
|
44
|
+
this.closeCode = options?.closeCode;
|
|
45
|
+
this.__sloppyRealtimeError = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function assertRealtimeIdentifier(value, subject) {
|
|
50
|
+
if (typeof value !== "string" || value.length === 0 || !REALTIME_IDENTIFIER_PATTERN.test(value)) {
|
|
51
|
+
throw new TypeError(`Sloppy Realtime ${subject} must be a stable identifier.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function assertRealtimeEventName(value, subject = "event name") {
|
|
56
|
+
assertRealtimeIdentifier(value, subject);
|
|
57
|
+
if (RESERVED_REALTIME_EVENT_NAMES.has(value)) {
|
|
58
|
+
throw new TypeError(`Sloppy Realtime event name '${value}' is reserved.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sanitizeIssueText(value) {
|
|
63
|
+
const text = String(value ?? "");
|
|
64
|
+
return text.length <= MAX_ERROR_TEXT ? text : `${text.slice(0, MAX_ERROR_TEXT)}...`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sanitizeValidationIssues(issues) {
|
|
68
|
+
return Object.freeze((issues ?? []).slice(0, MAX_ERROR_ISSUES).map((issue) => Object.freeze({
|
|
69
|
+
path: Object.freeze((issue.path ?? []).slice(0, 8).map((part) => sanitizeIssueText(part))),
|
|
70
|
+
code: sanitizeIssueText(issue.code ?? "invalid"),
|
|
71
|
+
message: sanitizeIssueText(issue.message ?? "Validation failed."),
|
|
72
|
+
})));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function realtimeErrorEnvelope(error, event = undefined) {
|
|
76
|
+
const code = error instanceof SloppyRealtimeError
|
|
77
|
+
? error.code
|
|
78
|
+
: "SLOPPY_E_REALTIME_HANDLER_ERROR";
|
|
79
|
+
return deepFreeze({
|
|
80
|
+
type: "error",
|
|
81
|
+
error: {
|
|
82
|
+
code,
|
|
83
|
+
message: error instanceof SloppyRealtimeError
|
|
84
|
+
? error.message
|
|
85
|
+
: "Realtime message handling failed.",
|
|
86
|
+
...(event ?? error?.event ? { event: event ?? error.event } : {}),
|
|
87
|
+
...((error instanceof SloppyRealtimeError && error.issues.length !== 0)
|
|
88
|
+
? { issues: sanitizeValidationIssues(error.issues) }
|
|
89
|
+
: {}),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeRealtimeAuth(auth = undefined) {
|
|
95
|
+
if (auth === undefined) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
return deepFreeze({
|
|
99
|
+
required: auth.required === true,
|
|
100
|
+
scopes: Object.freeze([...(auth.scopes ?? [])]),
|
|
101
|
+
roles: Object.freeze([...(auth.roles ?? [])]),
|
|
102
|
+
policy: auth.policy,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createRealtimeEvent(schemaValue, auth = undefined) {
|
|
107
|
+
if (!isSchema(schemaValue)) {
|
|
108
|
+
throw new TypeError("Sloppy Realtime event schema must be a Sloppy schema.");
|
|
109
|
+
}
|
|
110
|
+
const normalizedAuth = normalizeRealtimeAuth(auth);
|
|
111
|
+
const event = {
|
|
112
|
+
[REALTIME_EVENT]: true,
|
|
113
|
+
schema: schemaValue,
|
|
114
|
+
metadata: deepFreeze({
|
|
115
|
+
schema: schemaValue.metadata,
|
|
116
|
+
...(normalizedAuth === undefined ? {} : { auth: normalizedAuth }),
|
|
117
|
+
}),
|
|
118
|
+
validate(value) {
|
|
119
|
+
return Schema.validate(value, schemaValue);
|
|
120
|
+
},
|
|
121
|
+
requiresAuth() {
|
|
122
|
+
return createRealtimeEvent(schemaValue, {
|
|
123
|
+
...(normalizedAuth ?? {}),
|
|
124
|
+
required: true,
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
requiresScope(...scopes) {
|
|
128
|
+
for (const scope of scopes) {
|
|
129
|
+
assertRealtimeIdentifier(scope, "event authorization scope");
|
|
130
|
+
}
|
|
131
|
+
return createRealtimeEvent(schemaValue, {
|
|
132
|
+
...(normalizedAuth ?? {}),
|
|
133
|
+
required: true,
|
|
134
|
+
scopes: Object.freeze([...new Set([...(normalizedAuth?.scopes ?? []), ...scopes])]),
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
requiresRole(...roles) {
|
|
138
|
+
for (const role of roles) {
|
|
139
|
+
assertRealtimeIdentifier(role, "event authorization role");
|
|
140
|
+
}
|
|
141
|
+
return createRealtimeEvent(schemaValue, {
|
|
142
|
+
...(normalizedAuth ?? {}),
|
|
143
|
+
required: true,
|
|
144
|
+
roles: Object.freeze([...new Set([...(normalizedAuth?.roles ?? []), ...roles])]),
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
authorize(policy) {
|
|
148
|
+
assertRealtimeIdentifier(policy, "event authorization policy");
|
|
149
|
+
return createRealtimeEvent(schemaValue, {
|
|
150
|
+
...(normalizedAuth ?? {}),
|
|
151
|
+
required: true,
|
|
152
|
+
policy,
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
return Object.freeze(event);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isRealtimeEvent(value) {
|
|
160
|
+
return value !== null && typeof value === "object" && value[REALTIME_EVENT] === true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizeEventDescriptor(value, subject) {
|
|
164
|
+
if (isRealtimeEvent(value)) {
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
if (isSchema(value)) {
|
|
168
|
+
return createRealtimeEvent(value);
|
|
169
|
+
}
|
|
170
|
+
throw new TypeError(`Sloppy Realtime ${subject} must be a Sloppy schema or Realtime.event(...).`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeEventMap(value, subject) {
|
|
174
|
+
if (value === undefined) {
|
|
175
|
+
return Object.freeze({});
|
|
176
|
+
}
|
|
177
|
+
if (!isPlainObject(value)) {
|
|
178
|
+
throw new TypeError(`Sloppy Realtime ${subject} events must be a plain object.`);
|
|
179
|
+
}
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const [name, descriptor] of Object.entries(value)) {
|
|
182
|
+
assertRealtimeEventName(name, `${subject} event name`);
|
|
183
|
+
out[name] = normalizeEventDescriptor(descriptor, `${subject}.${name}`);
|
|
184
|
+
}
|
|
185
|
+
return Object.freeze(out);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function eventMetadataMap(events) {
|
|
189
|
+
const out = {};
|
|
190
|
+
for (const [name, descriptor] of Object.entries(events)) {
|
|
191
|
+
out[name] = descriptor.metadata;
|
|
192
|
+
}
|
|
193
|
+
return deepFreeze(out);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function assertNoDuplicateEventNames(client, server) {
|
|
197
|
+
const duplicates = Object.keys(client).filter((name) => Object.hasOwn(server, name));
|
|
198
|
+
if (duplicates.length !== 0) {
|
|
199
|
+
throw new TypeError(`Sloppy Realtime event '${duplicates[0]}' cannot be both a client and server event.`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeEnvelope(value, direction) {
|
|
204
|
+
let envelope = value;
|
|
205
|
+
if (typeof value === "string") {
|
|
206
|
+
try {
|
|
207
|
+
envelope = JSON.parse(value);
|
|
208
|
+
} catch {
|
|
209
|
+
throw new SloppyRealtimeError(
|
|
210
|
+
"SLOPPY_E_REALTIME_MALFORMED_JSON",
|
|
211
|
+
"Realtime message must be valid JSON.",
|
|
212
|
+
{ closeCode: 1003 },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!isPlainObject(envelope) ||
|
|
217
|
+
typeof envelope.type !== "string" ||
|
|
218
|
+
envelope.type.length === 0 ||
|
|
219
|
+
!Object.hasOwn(envelope, "data") ||
|
|
220
|
+
(envelope.id !== undefined && typeof envelope.id !== "string"))
|
|
221
|
+
{
|
|
222
|
+
throw new SloppyRealtimeError(
|
|
223
|
+
"SLOPPY_E_REALTIME_MALFORMED_ENVELOPE",
|
|
224
|
+
`Realtime ${direction} message envelope is invalid.`,
|
|
225
|
+
{ closeCode: 1003 },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return envelope;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function validateEnvelopeEvent(events, envelope, direction) {
|
|
232
|
+
const descriptor = events[envelope.type];
|
|
233
|
+
if (descriptor === undefined) {
|
|
234
|
+
throw new SloppyRealtimeError(
|
|
235
|
+
"SLOPPY_E_REALTIME_UNKNOWN_EVENT",
|
|
236
|
+
`Realtime ${direction} event is not registered.`,
|
|
237
|
+
{ event: envelope.type, closeCode: 1008 },
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
return descriptor.validate(envelope.data);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (isValidationError(error)) {
|
|
244
|
+
throw new SloppyRealtimeError(
|
|
245
|
+
"SLOPPY_E_REALTIME_VALIDATION_FAILED",
|
|
246
|
+
"Realtime message validation failed.",
|
|
247
|
+
{ event: envelope.type, issues: sanitizeValidationIssues(error.issues), closeCode: 1007 },
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function validateNamedEvent(events, eventName, data, direction) {
|
|
255
|
+
assertRealtimeEventName(eventName, `${direction} event name`);
|
|
256
|
+
return validateEnvelopeEvent(events, { type: eventName, data }, direction);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function createEnvelope(type, data, id = undefined) {
|
|
260
|
+
return deepFreeze({
|
|
261
|
+
type,
|
|
262
|
+
data,
|
|
263
|
+
...(id === undefined ? {} : { id }),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function validateGroupName(name) {
|
|
268
|
+
if (typeof name !== "string" ||
|
|
269
|
+
name.length === 0 ||
|
|
270
|
+
name.length > MAX_GROUP_NAME_LENGTH ||
|
|
271
|
+
/[\x00-\x1F\x7F]/u.test(name))
|
|
272
|
+
{
|
|
273
|
+
throw new TypeError("Sloppy Realtime group names must be non-empty bounded strings without control characters.");
|
|
274
|
+
}
|
|
275
|
+
return name;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function safeUserId(user) {
|
|
279
|
+
return typeof user?.sub === "string" && user.sub.length !== 0
|
|
280
|
+
? user.sub
|
|
281
|
+
: typeof user?.id === "string" && user.id.length !== 0
|
|
282
|
+
? user.id
|
|
283
|
+
: undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function createMemoryRealtimeBackplane() {
|
|
287
|
+
const connections = new Map();
|
|
288
|
+
const groups = new Map();
|
|
289
|
+
const presence = new Map();
|
|
290
|
+
let disposed = false;
|
|
291
|
+
|
|
292
|
+
function assertOpen() {
|
|
293
|
+
if (disposed) {
|
|
294
|
+
throw new SloppyRealtimeError(
|
|
295
|
+
"SLOPPY_E_REALTIME_BACKPLANE_ERROR",
|
|
296
|
+
"Realtime backplane is disposed.",
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function groupMembers(name) {
|
|
302
|
+
let members = groups.get(name);
|
|
303
|
+
if (members === undefined) {
|
|
304
|
+
members = new Set();
|
|
305
|
+
groups.set(name, members);
|
|
306
|
+
}
|
|
307
|
+
return members;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function pruneGroup(name) {
|
|
311
|
+
const members = groups.get(name);
|
|
312
|
+
if (members !== undefined && members.size === 0) {
|
|
313
|
+
groups.delete(name);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function removeConnectionState(connectionId) {
|
|
318
|
+
const connection = connections.get(connectionId);
|
|
319
|
+
if (connection !== undefined) {
|
|
320
|
+
for (const groupName of connection.groups) {
|
|
321
|
+
groups.get(groupName)?.delete(connectionId);
|
|
322
|
+
pruneGroup(groupName);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
connections.delete(connectionId);
|
|
326
|
+
presence.delete(connectionId);
|
|
327
|
+
return connection !== undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function snapshotPresence(record) {
|
|
331
|
+
return deepFreeze({
|
|
332
|
+
connectionId: record.connectionId,
|
|
333
|
+
userId: record.userId,
|
|
334
|
+
groups: Object.freeze([...record.groups]),
|
|
335
|
+
connectedAt: record.connectedAt,
|
|
336
|
+
metadata: record.metadata,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const backplane = {
|
|
341
|
+
kind: "memory",
|
|
342
|
+
connect(connection) {
|
|
343
|
+
assertOpen();
|
|
344
|
+
if (!isPlainObject(connection) || typeof connection.connectionId !== "string") {
|
|
345
|
+
throw new TypeError("Sloppy Realtime backplane connection must include a connectionId.");
|
|
346
|
+
}
|
|
347
|
+
removeConnectionState(connection.connectionId);
|
|
348
|
+
connections.set(connection.connectionId, {
|
|
349
|
+
...connection,
|
|
350
|
+
groups: new Set(),
|
|
351
|
+
});
|
|
352
|
+
return Promise.resolve();
|
|
353
|
+
},
|
|
354
|
+
disconnect(connectionId) {
|
|
355
|
+
if (disposed) {
|
|
356
|
+
return Promise.resolve(false);
|
|
357
|
+
}
|
|
358
|
+
const connection = connections.get(connectionId);
|
|
359
|
+
if (connection === undefined) {
|
|
360
|
+
presence.delete(connectionId);
|
|
361
|
+
return Promise.resolve(false);
|
|
362
|
+
}
|
|
363
|
+
removeConnectionState(connectionId);
|
|
364
|
+
return Promise.resolve(true);
|
|
365
|
+
},
|
|
366
|
+
join(connectionId, groupName) {
|
|
367
|
+
assertOpen();
|
|
368
|
+
validateGroupName(groupName);
|
|
369
|
+
const connection = connections.get(connectionId);
|
|
370
|
+
if (connection === undefined) {
|
|
371
|
+
throw new SloppyRealtimeError(
|
|
372
|
+
"SLOPPY_E_REALTIME_CLOSED_CONNECTION",
|
|
373
|
+
"Realtime connection is closed.",
|
|
374
|
+
{ closeCode: 1001 },
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
connection.groups.add(groupName);
|
|
378
|
+
groupMembers(groupName).add(connectionId);
|
|
379
|
+
return Promise.resolve({ count: groups.get(groupName)?.size ?? 0 });
|
|
380
|
+
},
|
|
381
|
+
leave(connectionId, groupName) {
|
|
382
|
+
assertOpen();
|
|
383
|
+
validateGroupName(groupName);
|
|
384
|
+
const connection = connections.get(connectionId);
|
|
385
|
+
connection?.groups.delete(groupName);
|
|
386
|
+
groups.get(groupName)?.delete(connectionId);
|
|
387
|
+
pruneGroup(groupName);
|
|
388
|
+
return Promise.resolve({ count: groups.get(groupName)?.size ?? 0 });
|
|
389
|
+
},
|
|
390
|
+
leaveAll(connectionId) {
|
|
391
|
+
assertOpen();
|
|
392
|
+
const connection = connections.get(connectionId);
|
|
393
|
+
if (connection === undefined) {
|
|
394
|
+
return Promise.resolve({ count: 0 });
|
|
395
|
+
}
|
|
396
|
+
const count = connection.groups.size;
|
|
397
|
+
for (const groupName of connection.groups) {
|
|
398
|
+
groups.get(groupName)?.delete(connectionId);
|
|
399
|
+
pruneGroup(groupName);
|
|
400
|
+
}
|
|
401
|
+
connection.groups.clear();
|
|
402
|
+
return Promise.resolve({ count });
|
|
403
|
+
},
|
|
404
|
+
groups(connectionId) {
|
|
405
|
+
assertOpen();
|
|
406
|
+
return Promise.resolve(Object.freeze([...(connections.get(connectionId)?.groups ?? [])]));
|
|
407
|
+
},
|
|
408
|
+
groupSize(groupName) {
|
|
409
|
+
assertOpen();
|
|
410
|
+
validateGroupName(groupName);
|
|
411
|
+
return Promise.resolve(groups.get(groupName)?.size ?? 0);
|
|
412
|
+
},
|
|
413
|
+
async send(connectionId, envelope) {
|
|
414
|
+
assertOpen();
|
|
415
|
+
const connection = connections.get(connectionId);
|
|
416
|
+
if (connection === undefined) {
|
|
417
|
+
return { count: 0 };
|
|
418
|
+
}
|
|
419
|
+
await connection.send(envelope);
|
|
420
|
+
return { count: 1 };
|
|
421
|
+
},
|
|
422
|
+
async broadcast(groupName, envelope, options = undefined) {
|
|
423
|
+
assertOpen();
|
|
424
|
+
validateGroupName(groupName);
|
|
425
|
+
const except = new Set(options?.except ?? []);
|
|
426
|
+
if (options?.exceptSelf === true && typeof options.senderId === "string") {
|
|
427
|
+
except.add(options.senderId);
|
|
428
|
+
}
|
|
429
|
+
let count = 0;
|
|
430
|
+
for (const connectionId of [...(groups.get(groupName) ?? [])]) {
|
|
431
|
+
if (except.has(connectionId)) {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const connection = connections.get(connectionId);
|
|
435
|
+
if (connection === undefined) {
|
|
436
|
+
groups.get(groupName)?.delete(connectionId);
|
|
437
|
+
pruneGroup(groupName);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
await connection.send(envelope);
|
|
441
|
+
count += 1;
|
|
442
|
+
}
|
|
443
|
+
return { count };
|
|
444
|
+
},
|
|
445
|
+
presenceSet(connectionId, record) {
|
|
446
|
+
assertOpen();
|
|
447
|
+
const connection = connections.get(connectionId);
|
|
448
|
+
if (connection === undefined) {
|
|
449
|
+
throw new SloppyRealtimeError(
|
|
450
|
+
"SLOPPY_E_REALTIME_CLOSED_CONNECTION",
|
|
451
|
+
"Realtime connection is closed.",
|
|
452
|
+
{ closeCode: 1001 },
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
const metadata = record?.metadata ?? {};
|
|
456
|
+
const encoded = JSON.stringify(metadata);
|
|
457
|
+
if (encoded === undefined || Text.utf8.encode(encoded).byteLength > MAX_PRESENCE_METADATA_BYTES) {
|
|
458
|
+
throw new TypeError("Sloppy Realtime presence metadata must be bounded JSON.");
|
|
459
|
+
}
|
|
460
|
+
presence.set(connectionId, {
|
|
461
|
+
connectionId,
|
|
462
|
+
userId: record?.userId ?? connection.userId,
|
|
463
|
+
groups: new Set(connection.groups),
|
|
464
|
+
connectedAt: record?.connectedAt ?? connection.connectedAt,
|
|
465
|
+
metadata: deepFreeze(JSON.parse(encoded)),
|
|
466
|
+
});
|
|
467
|
+
return Promise.resolve(snapshotPresence(presence.get(connectionId)));
|
|
468
|
+
},
|
|
469
|
+
presenceGet(connectionId) {
|
|
470
|
+
assertOpen();
|
|
471
|
+
const record = presence.get(connectionId);
|
|
472
|
+
return Promise.resolve(record === undefined ? undefined : snapshotPresence(record));
|
|
473
|
+
},
|
|
474
|
+
presenceInGroup(groupName) {
|
|
475
|
+
assertOpen();
|
|
476
|
+
validateGroupName(groupName);
|
|
477
|
+
const records = [];
|
|
478
|
+
for (const connectionId of groups.get(groupName) ?? []) {
|
|
479
|
+
const record = presence.get(connectionId);
|
|
480
|
+
if (record !== undefined) {
|
|
481
|
+
record.groups = new Set(connections.get(connectionId)?.groups ?? record.groups);
|
|
482
|
+
records.push(snapshotPresence(record));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return Promise.resolve(Object.freeze(records));
|
|
486
|
+
},
|
|
487
|
+
dispose() {
|
|
488
|
+
disposed = true;
|
|
489
|
+
connections.clear();
|
|
490
|
+
groups.clear();
|
|
491
|
+
presence.clear();
|
|
492
|
+
return Promise.resolve();
|
|
493
|
+
},
|
|
494
|
+
health() {
|
|
495
|
+
return Object.freeze({
|
|
496
|
+
status: disposed ? "unhealthy" : "healthy",
|
|
497
|
+
connections: connections.size,
|
|
498
|
+
groups: groups.size,
|
|
499
|
+
});
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
return Object.freeze(backplane);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function defaultRealtimeProtocol(name) {
|
|
506
|
+
return `sloppy.realtime.${name.replace(WEBSOCKET_PROTOCOL_UNSAFE_PATTERN, "-")}.v1`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function validateBackplane(backplane) {
|
|
510
|
+
const required = [
|
|
511
|
+
"connect",
|
|
512
|
+
"disconnect",
|
|
513
|
+
"join",
|
|
514
|
+
"leave",
|
|
515
|
+
"leaveAll",
|
|
516
|
+
"broadcast",
|
|
517
|
+
"send",
|
|
518
|
+
"presenceSet",
|
|
519
|
+
"presenceGet",
|
|
520
|
+
"presenceInGroup",
|
|
521
|
+
"dispose",
|
|
522
|
+
];
|
|
523
|
+
for (const method of required) {
|
|
524
|
+
if (typeof backplane?.[method] !== "function") {
|
|
525
|
+
throw new TypeError(`Sloppy Realtime backplane must implement ${method}().`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return backplane;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function incrementMetric(ctx, name, labels = undefined, value = 1) {
|
|
532
|
+
const metrics = ctx?.__sloppyTestHostMetrics ?? ctx?.metrics;
|
|
533
|
+
metrics?.increment?.(name, labels, value);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function gaugeMetric(ctx, name, labels = undefined, value = 0) {
|
|
537
|
+
const metrics = ctx?.__sloppyTestHostMetrics ?? ctx?.metrics;
|
|
538
|
+
metrics?.gauge?.(name, labels, value);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function authPolicyFromHandlerOptions(options = undefined) {
|
|
542
|
+
if (options === undefined) {
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
if (!isPlainObject(options)) {
|
|
546
|
+
throw new TypeError("Sloppy Realtime ctx.on options must be a plain object.");
|
|
547
|
+
}
|
|
548
|
+
return deepFreeze({
|
|
549
|
+
required: options.requiresAuth === true || options.required === true,
|
|
550
|
+
scopes: Object.freeze([
|
|
551
|
+
...((typeof options.requiresScope === "string") ? [options.requiresScope] : []),
|
|
552
|
+
...((Array.isArray(options.requiresScope)) ? options.requiresScope : []),
|
|
553
|
+
...((Array.isArray(options.scopes)) ? options.scopes : []),
|
|
554
|
+
]),
|
|
555
|
+
roles: Object.freeze([
|
|
556
|
+
...((typeof options.requiresRole === "string") ? [options.requiresRole] : []),
|
|
557
|
+
...((Array.isArray(options.requiresRole)) ? options.requiresRole : []),
|
|
558
|
+
...((Array.isArray(options.roles)) ? options.roles : []),
|
|
559
|
+
]),
|
|
560
|
+
policy: options.policy,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function mergeMessagePolicies(eventPolicy = undefined, handlerPolicy = undefined) {
|
|
565
|
+
if (eventPolicy === undefined && handlerPolicy === undefined) {
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
return deepFreeze({
|
|
569
|
+
required: eventPolicy?.required === true || handlerPolicy?.required === true,
|
|
570
|
+
scopes: Object.freeze([...new Set([...(eventPolicy?.scopes ?? []), ...(handlerPolicy?.scopes ?? [])])]),
|
|
571
|
+
roles: Object.freeze([...new Set([...(eventPolicy?.roles ?? []), ...(handlerPolicy?.roles ?? [])])]),
|
|
572
|
+
policy: eventPolicy?.policy,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function authorizeMessage(ctx, policy, eventName, resource = undefined) {
|
|
577
|
+
if (policy === undefined) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const user = ctx.user;
|
|
581
|
+
if (policy.required === true && user?.authenticated !== true) {
|
|
582
|
+
throw new SloppyRealtimeError(
|
|
583
|
+
"SLOPPY_E_REALTIME_UNAUTHORIZED_EVENT",
|
|
584
|
+
"Realtime event requires an authenticated user.",
|
|
585
|
+
{ event: eventName, closeCode: 1008 },
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
for (const scope of policy.scopes ?? []) {
|
|
589
|
+
if (typeof user?.hasScope === "function" ? !user.hasScope(scope) : !(user?.scopes ?? []).includes(scope)) {
|
|
590
|
+
throw new SloppyRealtimeError(
|
|
591
|
+
"SLOPPY_E_REALTIME_UNAUTHORIZED_EVENT",
|
|
592
|
+
"Realtime event requires a missing scope.",
|
|
593
|
+
{ event: eventName, closeCode: 1008 },
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
for (const role of policy.roles ?? []) {
|
|
598
|
+
if (typeof user?.hasRole === "function" ? !user.hasRole(role) : !(user?.roles ?? []).includes(role)) {
|
|
599
|
+
throw new SloppyRealtimeError(
|
|
600
|
+
"SLOPPY_E_REALTIME_UNAUTHORIZED_EVENT",
|
|
601
|
+
"Realtime event requires a missing role.",
|
|
602
|
+
{ event: eventName, closeCode: 1008 },
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (policy.policy !== undefined) {
|
|
607
|
+
if (typeof ctx.authorize !== "function") {
|
|
608
|
+
throw new SloppyRealtimeError(
|
|
609
|
+
"SLOPPY_E_REALTIME_UNAUTHORIZED_EVENT",
|
|
610
|
+
"Realtime event authorization policy is unavailable in this runtime.",
|
|
611
|
+
{ event: eventName, closeCode: 1008 },
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
if (await ctx.authorize(policy.policy, resource) !== true) {
|
|
615
|
+
throw new SloppyRealtimeError(
|
|
616
|
+
"SLOPPY_E_REALTIME_UNAUTHORIZED_EVENT",
|
|
617
|
+
"Realtime event authorization policy denied the message.",
|
|
618
|
+
{ event: eventName, closeCode: 1008 },
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function createChannel(name, definition) {
|
|
625
|
+
assertRealtimeIdentifier(name, "channel name");
|
|
626
|
+
if (!isPlainObject(definition)) {
|
|
627
|
+
throw new TypeError("Sloppy Realtime channel definition must be a plain object.");
|
|
628
|
+
}
|
|
629
|
+
const client = normalizeEventMap(definition.client, "client");
|
|
630
|
+
const server = normalizeEventMap(definition.server, "server");
|
|
631
|
+
assertNoDuplicateEventNames(client, server);
|
|
632
|
+
const metadata = deepFreeze({
|
|
633
|
+
name,
|
|
634
|
+
protocol: definition.protocol ?? defaultRealtimeProtocol(name),
|
|
635
|
+
client: eventMetadataMap(client),
|
|
636
|
+
server: eventMetadataMap(server),
|
|
637
|
+
});
|
|
638
|
+
const channel = {
|
|
639
|
+
[REALTIME_CHANNEL]: true,
|
|
640
|
+
name,
|
|
641
|
+
client,
|
|
642
|
+
server,
|
|
643
|
+
metadata,
|
|
644
|
+
validateClientEvent(eventName, data) {
|
|
645
|
+
return validateNamedEvent(client, eventName, data, "client");
|
|
646
|
+
},
|
|
647
|
+
validateServerEvent(eventName, data) {
|
|
648
|
+
return validateNamedEvent(server, eventName, data, "server");
|
|
649
|
+
},
|
|
650
|
+
parseClientMessage(value) {
|
|
651
|
+
const envelope = normalizeEnvelope(value, "client");
|
|
652
|
+
const data = validateEnvelopeEvent(client, envelope, "client");
|
|
653
|
+
return createEnvelope(envelope.type, data, envelope.id);
|
|
654
|
+
},
|
|
655
|
+
serializeClientMessage(eventName, data, options = undefined) {
|
|
656
|
+
const value = this.validateClientEvent(eventName, data);
|
|
657
|
+
return createEnvelope(eventName, value, options?.id);
|
|
658
|
+
},
|
|
659
|
+
parseServerMessage(value) {
|
|
660
|
+
const envelope = normalizeEnvelope(value, "server");
|
|
661
|
+
const data = validateEnvelopeEvent(server, envelope, "server");
|
|
662
|
+
return createEnvelope(envelope.type, data, envelope.id);
|
|
663
|
+
},
|
|
664
|
+
serializeServerMessage(eventName, data, options = undefined) {
|
|
665
|
+
const value = this.validateServerEvent(eventName, data);
|
|
666
|
+
return createEnvelope(eventName, value, options?.id);
|
|
667
|
+
},
|
|
668
|
+
stringifyClientMessage(eventName, data, options = undefined) {
|
|
669
|
+
return JSON.stringify(this.serializeClientMessage(eventName, data, options));
|
|
670
|
+
},
|
|
671
|
+
stringifyServerMessage(eventName, data, options = undefined) {
|
|
672
|
+
return JSON.stringify(this.serializeServerMessage(eventName, data, options));
|
|
673
|
+
},
|
|
674
|
+
errorEnvelope(error, event = undefined) {
|
|
675
|
+
return realtimeErrorEnvelope(error, event);
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
return deepFreeze(channel);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export function isRealtimeChannel(value) {
|
|
682
|
+
return value !== null && typeof value === "object" && value[REALTIME_CHANNEL] === true;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function validateEventName(name) {
|
|
686
|
+
if (typeof name !== "string" || name.length === 0 || !EVENT_NAME_PATTERN.test(name)) {
|
|
687
|
+
throw new TypeError("Sloppy SSE event names must be non-empty token strings.");
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function validateFieldText(value, subject) {
|
|
692
|
+
const text = String(value);
|
|
693
|
+
if (/[\r\n]/u.test(text)) {
|
|
694
|
+
throw new TypeError(`Sloppy SSE ${subject} must not contain CR or LF.`);
|
|
695
|
+
}
|
|
696
|
+
return text;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function appendDataLines(lines, value) {
|
|
700
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
701
|
+
for (const line of String(text).split("\n")) {
|
|
702
|
+
lines.push(`data: ${line}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function sseFrame(data, options = undefined) {
|
|
707
|
+
const lines = [];
|
|
708
|
+
if (options !== undefined && !isPlainObject(options)) {
|
|
709
|
+
throw new TypeError("Sloppy SSE event options must be a plain object.");
|
|
710
|
+
}
|
|
711
|
+
if (options?.comment !== undefined) {
|
|
712
|
+
lines.push(`: ${validateFieldText(options.comment, "comment")}`);
|
|
713
|
+
}
|
|
714
|
+
if (options?.event !== undefined) {
|
|
715
|
+
validateEventName(options.event);
|
|
716
|
+
lines.push(`event: ${options.event}`);
|
|
717
|
+
}
|
|
718
|
+
if (options?.id !== undefined) {
|
|
719
|
+
lines.push(`id: ${validateFieldText(options.id, "id")}`);
|
|
720
|
+
}
|
|
721
|
+
if (options?.retry !== undefined) {
|
|
722
|
+
if (!Number.isInteger(options.retry) || options.retry < 0) {
|
|
723
|
+
throw new TypeError("Sloppy SSE retry must be a non-negative integer.");
|
|
724
|
+
}
|
|
725
|
+
lines.push(`retry: ${options.retry}`);
|
|
726
|
+
}
|
|
727
|
+
appendDataLines(lines, data);
|
|
728
|
+
lines.push("", "");
|
|
729
|
+
return lines.join("\n");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function createSseStream(userHandler, options = undefined) {
|
|
733
|
+
if (typeof userHandler !== "function") {
|
|
734
|
+
throw new TypeError("Sloppy Realtime.sse handler must be a function.");
|
|
735
|
+
}
|
|
736
|
+
if (options !== undefined && !isPlainObject(options)) {
|
|
737
|
+
throw new TypeError("Sloppy Realtime.sse options must be a plain object.");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return async function sloppySseHandler(ctx) {
|
|
741
|
+
let closed = false;
|
|
742
|
+
let queued = 0;
|
|
743
|
+
const maxQueuedEvents = options?.maxQueuedEvents ?? DEFAULT_QUEUE_LIMIT;
|
|
744
|
+
if (!Number.isInteger(maxQueuedEvents) || maxQueuedEvents <= 0) {
|
|
745
|
+
throw new TypeError("Sloppy Realtime.sse maxQueuedEvents must be a positive integer.");
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return Results.stream(async (writer) => {
|
|
749
|
+
function writeFrame(frame) {
|
|
750
|
+
if (closed) {
|
|
751
|
+
throw new TypeError("Sloppy SSE stream is closed.");
|
|
752
|
+
}
|
|
753
|
+
if (queued >= maxQueuedEvents) {
|
|
754
|
+
throw new TypeError("Sloppy SSE bounded write queue is full.");
|
|
755
|
+
}
|
|
756
|
+
queued += 1;
|
|
757
|
+
writer.writeText(frame);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const stream = Object.freeze({
|
|
761
|
+
send(data) {
|
|
762
|
+
writeFrame(sseFrame(data));
|
|
763
|
+
},
|
|
764
|
+
event(name, data, eventOptions = undefined) {
|
|
765
|
+
validateEventName(name);
|
|
766
|
+
writeFrame(sseFrame(data, { ...(eventOptions ?? {}), event: name }));
|
|
767
|
+
},
|
|
768
|
+
comment(text) {
|
|
769
|
+
writeFrame(`: ${validateFieldText(text, "comment")}\n\n`);
|
|
770
|
+
},
|
|
771
|
+
heartbeat() {
|
|
772
|
+
writeFrame(": heartbeat\n\n");
|
|
773
|
+
},
|
|
774
|
+
close() {
|
|
775
|
+
if (closed) {
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
closed = true;
|
|
779
|
+
writer.close();
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
await userHandler(ctx, stream);
|
|
784
|
+
if (!closed) {
|
|
785
|
+
stream.close();
|
|
786
|
+
}
|
|
787
|
+
}, {
|
|
788
|
+
contentType: "text/event-stream",
|
|
789
|
+
headers: {
|
|
790
|
+
"Cache-Control": "no-cache",
|
|
791
|
+
"X-Slop-Realtime": "sse",
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function createUnavailableWebSocketHandler() {
|
|
798
|
+
return function sloppyWebSocketUnavailable() {
|
|
799
|
+
return Results.problem({
|
|
800
|
+
status: 501,
|
|
801
|
+
title: "WebSocket runtime is not available",
|
|
802
|
+
code: "SLOPPY_E_REALTIME_WEBSOCKET_UNAVAILABLE",
|
|
803
|
+
}, {
|
|
804
|
+
status: 501,
|
|
805
|
+
headers: {
|
|
806
|
+
"X-Slop-Realtime": "websocket",
|
|
807
|
+
},
|
|
808
|
+
});
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function positiveIntegerOption(value, name, defaultValue = undefined) {
|
|
813
|
+
if (value === undefined) {
|
|
814
|
+
return defaultValue;
|
|
815
|
+
}
|
|
816
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
817
|
+
throw new TypeError(`Sloppy WebSocket ${name} must be a positive integer.`);
|
|
818
|
+
}
|
|
819
|
+
return value;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function validateProtocolToken(value) {
|
|
823
|
+
if (typeof value !== "string" || value.length === 0 || !WEBSOCKET_PROTOCOL_PATTERN.test(value)) {
|
|
824
|
+
throw new TypeError("Sloppy WebSocket protocols must be non-empty WebSocket subprotocol tokens.");
|
|
825
|
+
}
|
|
826
|
+
return value;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function normalizeWebSocketProtocols(value) {
|
|
830
|
+
if (value === undefined) {
|
|
831
|
+
return Object.freeze([]);
|
|
832
|
+
}
|
|
833
|
+
if (!Array.isArray(value)) {
|
|
834
|
+
throw new TypeError("Sloppy WebSocket protocols must be an array when provided.");
|
|
835
|
+
}
|
|
836
|
+
return Object.freeze([...new Set(value.map(validateProtocolToken))]);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function normalizeWebSocketOrigins(value) {
|
|
840
|
+
if (value === undefined) {
|
|
841
|
+
return undefined;
|
|
842
|
+
}
|
|
843
|
+
if (value === "*") {
|
|
844
|
+
return "*";
|
|
845
|
+
}
|
|
846
|
+
if (typeof value === "string") {
|
|
847
|
+
if (value.length === 0) {
|
|
848
|
+
throw new TypeError("Sloppy WebSocket origins must be non-empty strings.");
|
|
849
|
+
}
|
|
850
|
+
return Object.freeze([value]);
|
|
851
|
+
}
|
|
852
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
853
|
+
throw new TypeError("Sloppy WebSocket origins must be '*', a string, or a non-empty string array.");
|
|
854
|
+
}
|
|
855
|
+
for (const origin of value) {
|
|
856
|
+
if (typeof origin !== "string" || origin.length === 0) {
|
|
857
|
+
throw new TypeError("Sloppy WebSocket origins must be non-empty strings.");
|
|
858
|
+
}
|
|
859
|
+
if (origin === "*" && value.length !== 1) {
|
|
860
|
+
throw new TypeError("Sloppy WebSocket '*' origin cannot be combined with explicit origins.");
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return value[0] === "*" ? "*" : Object.freeze([...new Set(value)]);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export function normalizeWebSocketRouteOptions(options = undefined) {
|
|
867
|
+
if (options === undefined) {
|
|
868
|
+
return Object.freeze({
|
|
869
|
+
protocols: Object.freeze([]),
|
|
870
|
+
maxMessageBytes: DEFAULT_WEBSOCKET_MAX_MESSAGE_BYTES,
|
|
871
|
+
maxSendQueueBytes: DEFAULT_WEBSOCKET_MAX_SEND_QUEUE_BYTES,
|
|
872
|
+
closeTimeoutMs: DEFAULT_WEBSOCKET_CLOSE_TIMEOUT_MS,
|
|
873
|
+
compression: false,
|
|
874
|
+
slowClientPolicy: "error",
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
if (!isPlainObject(options)) {
|
|
878
|
+
throw new TypeError("Sloppy WebSocket options must be a plain object.");
|
|
879
|
+
}
|
|
880
|
+
if (options.compression !== undefined && options.compression !== false) {
|
|
881
|
+
throw new TypeError("Sloppy WebSocket compression is not supported by this runtime.");
|
|
882
|
+
}
|
|
883
|
+
const slowClientPolicy = options.slowClientPolicy ?? "error";
|
|
884
|
+
if (slowClientPolicy !== "error" && slowClientPolicy !== "close") {
|
|
885
|
+
throw new TypeError("Sloppy WebSocket slowClientPolicy must be 'error' or 'close'.");
|
|
886
|
+
}
|
|
887
|
+
const heartbeatMs = positiveIntegerOption(options.heartbeatMs, "heartbeatMs");
|
|
888
|
+
const idleTimeoutMs = positiveIntegerOption(options.idleTimeoutMs, "idleTimeoutMs");
|
|
889
|
+
return Object.freeze({
|
|
890
|
+
protocols: normalizeWebSocketProtocols(options.protocols),
|
|
891
|
+
origins: normalizeWebSocketOrigins(options.origins),
|
|
892
|
+
maxMessageBytes: positiveIntegerOption(
|
|
893
|
+
options.maxMessageBytes,
|
|
894
|
+
"maxMessageBytes",
|
|
895
|
+
DEFAULT_WEBSOCKET_MAX_MESSAGE_BYTES,
|
|
896
|
+
),
|
|
897
|
+
maxSendQueueBytes: positiveIntegerOption(
|
|
898
|
+
options.maxSendQueueBytes,
|
|
899
|
+
"maxSendQueueBytes",
|
|
900
|
+
DEFAULT_WEBSOCKET_MAX_SEND_QUEUE_BYTES,
|
|
901
|
+
),
|
|
902
|
+
...(heartbeatMs === undefined ? {} : { heartbeatMs }),
|
|
903
|
+
...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
|
|
904
|
+
closeTimeoutMs: positiveIntegerOption(
|
|
905
|
+
options.closeTimeoutMs,
|
|
906
|
+
"closeTimeoutMs",
|
|
907
|
+
DEFAULT_WEBSOCKET_CLOSE_TIMEOUT_MS,
|
|
908
|
+
),
|
|
909
|
+
compression: false,
|
|
910
|
+
slowClientPolicy,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
export function webSocketRouteOptions(handler) {
|
|
915
|
+
return handler?.[WEBSOCKET_ROUTE_OPTIONS];
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
export function webSocketUserHandler(handler) {
|
|
919
|
+
return handler?.[WEBSOCKET_ROUTE_HANDLER];
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
export function realtimeRouteMetadata(handler) {
|
|
923
|
+
return handler?.[REALTIME_ROUTE_METADATA];
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function createHub(name) {
|
|
927
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
928
|
+
throw new TypeError("Sloppy Realtime.hub name must be a non-empty string.");
|
|
929
|
+
}
|
|
930
|
+
const connections = new Map();
|
|
931
|
+
const groups = new Map();
|
|
932
|
+
let nextConnectionId = 1;
|
|
933
|
+
|
|
934
|
+
function snapshotMessage(message) {
|
|
935
|
+
if (message.type === "json") {
|
|
936
|
+
return deepFreeze({ type: "json", json: snapshotJson(message.json) });
|
|
937
|
+
}
|
|
938
|
+
return deepFreeze({ type: message.type, text: message.text });
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function ensureGroup(groupName) {
|
|
942
|
+
if (typeof groupName !== "string" || groupName.length === 0) {
|
|
943
|
+
throw new TypeError("Sloppy realtime group name must be a non-empty string.");
|
|
944
|
+
}
|
|
945
|
+
let group = groups.get(groupName);
|
|
946
|
+
if (group === undefined) {
|
|
947
|
+
group = new Set();
|
|
948
|
+
groups.set(groupName, group);
|
|
949
|
+
}
|
|
950
|
+
return group;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function connection(id) {
|
|
954
|
+
const client = connections.get(id);
|
|
955
|
+
return Object.freeze({
|
|
956
|
+
sendText(text) {
|
|
957
|
+
if (client === undefined || client.closed) {
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
client.messages.push(deepFreeze({ type: "text", text: String(text) }));
|
|
961
|
+
return true;
|
|
962
|
+
},
|
|
963
|
+
sendJson(value) {
|
|
964
|
+
if (client === undefined || client.closed) {
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
client.messages.push(deepFreeze({ type: "json", json: snapshotJson(value) }));
|
|
968
|
+
return true;
|
|
969
|
+
},
|
|
970
|
+
close(code = 1000, reason = "") {
|
|
971
|
+
if (client === undefined || client.closed) {
|
|
972
|
+
return false;
|
|
973
|
+
}
|
|
974
|
+
client.closed = true;
|
|
975
|
+
client.close = { code, reason: String(reason) };
|
|
976
|
+
hub.unregister(id);
|
|
977
|
+
return true;
|
|
978
|
+
},
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async function sendTo(ids, kind, value) {
|
|
983
|
+
for (const id of ids) {
|
|
984
|
+
const target = connection(id);
|
|
985
|
+
if (kind === "json") {
|
|
986
|
+
target.sendJson(value);
|
|
987
|
+
} else {
|
|
988
|
+
target.sendText(value);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const hub = {
|
|
994
|
+
name,
|
|
995
|
+
socket(handler) {
|
|
996
|
+
if (typeof handler !== "function") {
|
|
997
|
+
throw new TypeError("Sloppy Realtime.hub socket handler must be a function.");
|
|
998
|
+
}
|
|
999
|
+
return createUnavailableWebSocketHandler();
|
|
1000
|
+
},
|
|
1001
|
+
register(id = undefined) {
|
|
1002
|
+
const connectionId = id ?? `${name}:${nextConnectionId++}`;
|
|
1003
|
+
if (connections.has(connectionId)) {
|
|
1004
|
+
throw new Error(`Sloppy realtime connection '${connectionId}' is already registered.`);
|
|
1005
|
+
}
|
|
1006
|
+
const client = { id: connectionId, groups: new Set(), messages: [], closed: false };
|
|
1007
|
+
connections.set(connectionId, client);
|
|
1008
|
+
return Object.freeze({
|
|
1009
|
+
id: connectionId,
|
|
1010
|
+
join(groupName) {
|
|
1011
|
+
ensureGroup(groupName).add(connectionId);
|
|
1012
|
+
client.groups.add(groupName);
|
|
1013
|
+
},
|
|
1014
|
+
leave(groupName) {
|
|
1015
|
+
groups.get(groupName)?.delete(connectionId);
|
|
1016
|
+
client.groups.delete(groupName);
|
|
1017
|
+
},
|
|
1018
|
+
sendText(text) {
|
|
1019
|
+
if (client.closed || connections.get(connectionId) !== client) {
|
|
1020
|
+
return false;
|
|
1021
|
+
}
|
|
1022
|
+
client.messages.push(deepFreeze({ type: "text", text: String(text) }));
|
|
1023
|
+
return true;
|
|
1024
|
+
},
|
|
1025
|
+
sendJson(value) {
|
|
1026
|
+
if (client.closed || connections.get(connectionId) !== client) {
|
|
1027
|
+
return false;
|
|
1028
|
+
}
|
|
1029
|
+
client.messages.push(deepFreeze({ type: "json", json: snapshotJson(value) }));
|
|
1030
|
+
return true;
|
|
1031
|
+
},
|
|
1032
|
+
close(code = 1000, reason = "") {
|
|
1033
|
+
if (client.closed || connections.get(connectionId) !== client) {
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
client.closed = true;
|
|
1037
|
+
client.close = { code, reason: String(reason) };
|
|
1038
|
+
hub.unregister(connectionId);
|
|
1039
|
+
return true;
|
|
1040
|
+
},
|
|
1041
|
+
});
|
|
1042
|
+
},
|
|
1043
|
+
unregister(id) {
|
|
1044
|
+
const client = connections.get(id);
|
|
1045
|
+
if (client === undefined) {
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
for (const groupName of client.groups) {
|
|
1049
|
+
groups.get(groupName)?.delete(id);
|
|
1050
|
+
}
|
|
1051
|
+
connections.delete(id);
|
|
1052
|
+
return true;
|
|
1053
|
+
},
|
|
1054
|
+
connection,
|
|
1055
|
+
group(groupName) {
|
|
1056
|
+
const members = ensureGroup(groupName);
|
|
1057
|
+
return Object.freeze({
|
|
1058
|
+
sendText(text) {
|
|
1059
|
+
return sendTo(members, "text", String(text));
|
|
1060
|
+
},
|
|
1061
|
+
sendJson(value) {
|
|
1062
|
+
return sendTo(members, "json", value);
|
|
1063
|
+
},
|
|
1064
|
+
close(code = 1001, reason = "server shutdown") {
|
|
1065
|
+
for (const id of [...members]) {
|
|
1066
|
+
connection(id).close(code, reason);
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
});
|
|
1070
|
+
},
|
|
1071
|
+
broadcastText(text) {
|
|
1072
|
+
return sendTo(connections.keys(), "text", String(text));
|
|
1073
|
+
},
|
|
1074
|
+
broadcastJson(value) {
|
|
1075
|
+
return sendTo(connections.keys(), "json", value);
|
|
1076
|
+
},
|
|
1077
|
+
__debug() {
|
|
1078
|
+
return Object.freeze({
|
|
1079
|
+
connections: Object.freeze([...connections.values()].map((client) => Object.freeze({
|
|
1080
|
+
id: client.id,
|
|
1081
|
+
groups: Object.freeze([...client.groups]),
|
|
1082
|
+
messages: Object.freeze(client.messages.map(snapshotMessage)),
|
|
1083
|
+
closed: client.closed,
|
|
1084
|
+
close: client.close,
|
|
1085
|
+
}))),
|
|
1086
|
+
groups: Object.freeze([...groups.entries()].map(([groupName, ids]) => Object.freeze({
|
|
1087
|
+
name: groupName,
|
|
1088
|
+
connections: Object.freeze([...ids]),
|
|
1089
|
+
}))),
|
|
1090
|
+
});
|
|
1091
|
+
},
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
return Object.freeze(hub);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function sse(handler, options = undefined) {
|
|
1098
|
+
return createSseStream(handler, options);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function websocket(handler, options = undefined) {
|
|
1102
|
+
return createWebSocketRouteHandler(handler, options);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function validateRealtimePolicy(value, name, allowed) {
|
|
1106
|
+
if (value === undefined) {
|
|
1107
|
+
return undefined;
|
|
1108
|
+
}
|
|
1109
|
+
if (!allowed.includes(value)) {
|
|
1110
|
+
throw new TypeError(`Sloppy Realtime ${name} must be ${allowed.map((entry) => `'${entry}'`).join(" or ")}.`);
|
|
1111
|
+
}
|
|
1112
|
+
return value;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function normalizeRealtimeRouteOptions(channel, options = undefined) {
|
|
1116
|
+
if (options !== undefined && !isPlainObject(options)) {
|
|
1117
|
+
throw new TypeError("Sloppy Realtime route options must be a plain object.");
|
|
1118
|
+
}
|
|
1119
|
+
const websocketOptions = normalizeWebSocketRouteOptions({
|
|
1120
|
+
protocols: options?.protocols ?? [channel.metadata.protocol],
|
|
1121
|
+
origins: options?.origins,
|
|
1122
|
+
maxMessageBytes: options?.maxMessageBytes,
|
|
1123
|
+
maxSendQueueBytes: options?.maxSendQueueBytes,
|
|
1124
|
+
heartbeatMs: options?.heartbeatMs,
|
|
1125
|
+
idleTimeoutMs: options?.idleTimeoutMs,
|
|
1126
|
+
closeTimeoutMs: options?.closeTimeoutMs,
|
|
1127
|
+
slowClientPolicy: options?.slowClientPolicy,
|
|
1128
|
+
compression: options?.compression,
|
|
1129
|
+
});
|
|
1130
|
+
const unknownEventPolicy = validateRealtimePolicy(
|
|
1131
|
+
options?.unknownEventPolicy,
|
|
1132
|
+
"unknownEventPolicy",
|
|
1133
|
+
["error", "close"],
|
|
1134
|
+
) ?? "error";
|
|
1135
|
+
const validationFailurePolicy = validateRealtimePolicy(
|
|
1136
|
+
options?.validationFailurePolicy,
|
|
1137
|
+
"validationFailurePolicy",
|
|
1138
|
+
["error", "close"],
|
|
1139
|
+
) ?? "error";
|
|
1140
|
+
const handlerErrorPolicy = validateRealtimePolicy(
|
|
1141
|
+
options?.handlerErrorPolicy,
|
|
1142
|
+
"handlerErrorPolicy",
|
|
1143
|
+
["error", "close"],
|
|
1144
|
+
) ?? "close";
|
|
1145
|
+
if (options?.presence !== undefined && typeof options.presence !== "boolean") {
|
|
1146
|
+
throw new TypeError("Sloppy Realtime presence option must be a boolean.");
|
|
1147
|
+
}
|
|
1148
|
+
const backplane = validateBackplane(options?.backplane ?? createMemoryRealtimeBackplane());
|
|
1149
|
+
return deepFreeze({
|
|
1150
|
+
websocket: websocketOptions,
|
|
1151
|
+
backplane,
|
|
1152
|
+
presence: options?.presence === true,
|
|
1153
|
+
backplaneKind: backplane.kind ?? "custom",
|
|
1154
|
+
unknownEventPolicy,
|
|
1155
|
+
validationFailurePolicy,
|
|
1156
|
+
handlerErrorPolicy,
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
export function createSseRouteHandler(handler, options = undefined) {
|
|
1161
|
+
return createSseStream(handler, options);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
export function createWebSocketRouteHandler(handler, options = undefined) {
|
|
1165
|
+
if (typeof handler !== "function") {
|
|
1166
|
+
throw new TypeError("Sloppy WebSocket route handler must be a function.");
|
|
1167
|
+
}
|
|
1168
|
+
const routeOptions = normalizeWebSocketRouteOptions(options);
|
|
1169
|
+
function sloppyWebSocketRoute(ctx) {
|
|
1170
|
+
if (ctx?.__sloppyWebSocketHandshake === true && ctx.__sloppyWebSocket !== undefined) {
|
|
1171
|
+
ctx.__sloppyWebSocket.__setContext?.(ctx);
|
|
1172
|
+
if (handler.length >= 2) {
|
|
1173
|
+
return handler(ctx, ctx.__sloppyWebSocket);
|
|
1174
|
+
}
|
|
1175
|
+
return handler(ctx.__sloppyWebSocket);
|
|
1176
|
+
}
|
|
1177
|
+
return createUnavailableWebSocketHandler()();
|
|
1178
|
+
}
|
|
1179
|
+
Object.defineProperties(sloppyWebSocketRoute, {
|
|
1180
|
+
[WEBSOCKET_ROUTE_HANDLER]: {
|
|
1181
|
+
value: handler,
|
|
1182
|
+
},
|
|
1183
|
+
[WEBSOCKET_ROUTE_OPTIONS]: {
|
|
1184
|
+
value: routeOptions,
|
|
1185
|
+
},
|
|
1186
|
+
});
|
|
1187
|
+
return sloppyWebSocketRoute;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
export function createRealtimeRouteHandler(channel, handler, options = undefined) {
|
|
1191
|
+
if (!isRealtimeChannel(channel)) {
|
|
1192
|
+
throw new TypeError("Sloppy app.realtime channel must come from Realtime.channel(...).");
|
|
1193
|
+
}
|
|
1194
|
+
if (typeof handler !== "function") {
|
|
1195
|
+
throw new TypeError("Sloppy app.realtime handler must be a function.");
|
|
1196
|
+
}
|
|
1197
|
+
const routeOptions = normalizeRealtimeRouteOptions(channel, options);
|
|
1198
|
+
let activeConnections = 0;
|
|
1199
|
+
const routeHandler = createWebSocketRouteHandler(async (ctx, socket) => {
|
|
1200
|
+
const backplane = routeOptions.backplane;
|
|
1201
|
+
const eventHandlers = new Map();
|
|
1202
|
+
let accepted = false;
|
|
1203
|
+
let cleanedUp = false;
|
|
1204
|
+
const connectionId = socket.id;
|
|
1205
|
+
const routePattern = ctx.routePattern ?? ctx.request?.path ?? "";
|
|
1206
|
+
const routeBroadcastGroup = `route:${channel.name}:${routePattern}`;
|
|
1207
|
+
const metricLabels = Object.freeze({
|
|
1208
|
+
route: routePattern,
|
|
1209
|
+
channel: channel.name,
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
async function cleanup() {
|
|
1213
|
+
if (cleanedUp) {
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
cleanedUp = true;
|
|
1217
|
+
await backplane.disconnect(connectionId);
|
|
1218
|
+
if (accepted && activeConnections > 0) {
|
|
1219
|
+
activeConnections -= 1;
|
|
1220
|
+
gaugeMetric(ctx, "realtime.connections.active", metricLabels, activeConnections);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function sendError(error, eventName = undefined) {
|
|
1225
|
+
const envelope = channel.errorEnvelope(error, eventName);
|
|
1226
|
+
incrementMetric(ctx, "realtime.errors.total", {
|
|
1227
|
+
...metricLabels,
|
|
1228
|
+
code: envelope.error.code,
|
|
1229
|
+
});
|
|
1230
|
+
await socket.sendJson(envelope);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
async function handleDispatchError(error, eventName = undefined) {
|
|
1234
|
+
const code = error instanceof SloppyRealtimeError
|
|
1235
|
+
? error.code
|
|
1236
|
+
: "SLOPPY_E_REALTIME_HANDLER_ERROR";
|
|
1237
|
+
if (code === "SLOPPY_E_REALTIME_BACKPLANE_ERROR") {
|
|
1238
|
+
incrementMetric(ctx, "realtime.backplane.errors.total", metricLabels);
|
|
1239
|
+
}
|
|
1240
|
+
const policy = code === "SLOPPY_E_REALTIME_UNKNOWN_EVENT"
|
|
1241
|
+
? routeOptions.unknownEventPolicy
|
|
1242
|
+
: code === "SLOPPY_E_REALTIME_VALIDATION_FAILED"
|
|
1243
|
+
? routeOptions.validationFailurePolicy
|
|
1244
|
+
: code === "SLOPPY_E_REALTIME_UNAUTHORIZED_EVENT"
|
|
1245
|
+
? "error"
|
|
1246
|
+
: routeOptions.handlerErrorPolicy;
|
|
1247
|
+
if (policy === "error") {
|
|
1248
|
+
await sendError(error, eventName);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
await socket.close(error?.closeCode ?? 1011, code);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function send(eventName, data, sendOptions = undefined) {
|
|
1255
|
+
const envelope = channel.serializeServerMessage(eventName, data, sendOptions);
|
|
1256
|
+
await socket.sendJson(envelope);
|
|
1257
|
+
incrementMetric(ctx, "realtime.messages.out.total", {
|
|
1258
|
+
...metricLabels,
|
|
1259
|
+
event: eventName,
|
|
1260
|
+
outcome: "sent",
|
|
1261
|
+
});
|
|
1262
|
+
return envelope;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function groupHandle(groupName) {
|
|
1266
|
+
validateGroupName(groupName);
|
|
1267
|
+
return Object.freeze({
|
|
1268
|
+
async sendTo(targetConnectionId, eventName, data, sendOptions = undefined) {
|
|
1269
|
+
const envelope = channel.serializeServerMessage(eventName, data, sendOptions);
|
|
1270
|
+
const result = await backplane.send(targetConnectionId, envelope);
|
|
1271
|
+
incrementMetric(ctx, "realtime.messages.out.total", {
|
|
1272
|
+
...metricLabels,
|
|
1273
|
+
event: eventName,
|
|
1274
|
+
outcome: "group-send",
|
|
1275
|
+
}, result.count);
|
|
1276
|
+
return result;
|
|
1277
|
+
},
|
|
1278
|
+
async broadcast(eventName, data, broadcastOptions = undefined) {
|
|
1279
|
+
const envelope = channel.serializeServerMessage(eventName, data, broadcastOptions);
|
|
1280
|
+
const result = await backplane.broadcast(groupName, envelope, {
|
|
1281
|
+
...(broadcastOptions ?? {}),
|
|
1282
|
+
senderId: connectionId,
|
|
1283
|
+
});
|
|
1284
|
+
incrementMetric(ctx, "realtime.groups.broadcast.total", {
|
|
1285
|
+
...metricLabels,
|
|
1286
|
+
outcome: "ok",
|
|
1287
|
+
});
|
|
1288
|
+
incrementMetric(ctx, "realtime.messages.out.total", {
|
|
1289
|
+
...metricLabels,
|
|
1290
|
+
event: eventName,
|
|
1291
|
+
outcome: "broadcast",
|
|
1292
|
+
}, result.count);
|
|
1293
|
+
return result;
|
|
1294
|
+
},
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const realtimeContext = Object.freeze({
|
|
1299
|
+
socket,
|
|
1300
|
+
channel,
|
|
1301
|
+
params: ctx.params ?? ctx.route ?? Object.freeze({}),
|
|
1302
|
+
query: ctx.query ?? ctx.request?.query,
|
|
1303
|
+
headers: ctx.request?.headers,
|
|
1304
|
+
user: ctx.user,
|
|
1305
|
+
metrics: ctx.metrics,
|
|
1306
|
+
requireUser() {
|
|
1307
|
+
return ctx.requireUser();
|
|
1308
|
+
},
|
|
1309
|
+
services: ctx.services,
|
|
1310
|
+
async accept() {
|
|
1311
|
+
if (!accepted) {
|
|
1312
|
+
await socket.accept();
|
|
1313
|
+
accepted = true;
|
|
1314
|
+
await backplane.connect({
|
|
1315
|
+
connectionId,
|
|
1316
|
+
userId: safeUserId(ctx.user),
|
|
1317
|
+
connectedAt: new Date().toISOString(),
|
|
1318
|
+
routePattern,
|
|
1319
|
+
channel: channel.name,
|
|
1320
|
+
async send(envelope) {
|
|
1321
|
+
await socket.sendJson(envelope);
|
|
1322
|
+
},
|
|
1323
|
+
});
|
|
1324
|
+
await backplane.join(connectionId, routeBroadcastGroup);
|
|
1325
|
+
incrementMetric(ctx, "realtime.connections.total", metricLabels);
|
|
1326
|
+
activeConnections += 1;
|
|
1327
|
+
gaugeMetric(ctx, "realtime.connections.active", metricLabels, activeConnections);
|
|
1328
|
+
}
|
|
1329
|
+
},
|
|
1330
|
+
close(code = 1000, reason = "") {
|
|
1331
|
+
return socket.close(code, reason);
|
|
1332
|
+
},
|
|
1333
|
+
on(eventName, optionsOrHandler, maybeHandler = undefined) {
|
|
1334
|
+
assertRealtimeEventName(eventName, "client event name");
|
|
1335
|
+
const eventHandler = typeof optionsOrHandler === "function" ? optionsOrHandler : maybeHandler;
|
|
1336
|
+
if (typeof eventHandler !== "function") {
|
|
1337
|
+
throw new TypeError("Sloppy Realtime ctx.on handler must be a function.");
|
|
1338
|
+
}
|
|
1339
|
+
if (eventHandlers.has(eventName)) {
|
|
1340
|
+
throw new TypeError(`Sloppy Realtime client event '${eventName}' already has a handler.`);
|
|
1341
|
+
}
|
|
1342
|
+
const handlerPolicy = typeof optionsOrHandler === "function"
|
|
1343
|
+
? undefined
|
|
1344
|
+
: authPolicyFromHandlerOptions(optionsOrHandler);
|
|
1345
|
+
eventHandlers.set(eventName, Object.freeze({
|
|
1346
|
+
handler: eventHandler,
|
|
1347
|
+
policy: handlerPolicy,
|
|
1348
|
+
}));
|
|
1349
|
+
return realtimeContext;
|
|
1350
|
+
},
|
|
1351
|
+
send,
|
|
1352
|
+
async broadcast(eventName, data, broadcastOptions = undefined) {
|
|
1353
|
+
const envelope = channel.serializeServerMessage(eventName, data, broadcastOptions);
|
|
1354
|
+
const result = await backplane.broadcast(routeBroadcastGroup, envelope, {
|
|
1355
|
+
...(broadcastOptions ?? {}),
|
|
1356
|
+
senderId: connectionId,
|
|
1357
|
+
});
|
|
1358
|
+
incrementMetric(ctx, "realtime.messages.out.total", {
|
|
1359
|
+
...metricLabels,
|
|
1360
|
+
event: eventName,
|
|
1361
|
+
outcome: "broadcast",
|
|
1362
|
+
}, result.count);
|
|
1363
|
+
return result;
|
|
1364
|
+
},
|
|
1365
|
+
group: groupHandle,
|
|
1366
|
+
groups: Object.freeze({
|
|
1367
|
+
async join(groupName) {
|
|
1368
|
+
const result = await backplane.join(connectionId, validateGroupName(groupName));
|
|
1369
|
+
incrementMetric(ctx, "realtime.groups.join.total", metricLabels);
|
|
1370
|
+
return result;
|
|
1371
|
+
},
|
|
1372
|
+
async leave(groupName) {
|
|
1373
|
+
const result = await backplane.leave(connectionId, validateGroupName(groupName));
|
|
1374
|
+
incrementMetric(ctx, "realtime.groups.leave.total", metricLabels);
|
|
1375
|
+
return result;
|
|
1376
|
+
},
|
|
1377
|
+
list() {
|
|
1378
|
+
return backplane.groups(connectionId).then((groups) => Object.freeze(
|
|
1379
|
+
groups.filter((groupName) => groupName !== routeBroadcastGroup),
|
|
1380
|
+
));
|
|
1381
|
+
},
|
|
1382
|
+
}),
|
|
1383
|
+
presence: Object.freeze({
|
|
1384
|
+
async set(record) {
|
|
1385
|
+
if (routeOptions.presence !== true) {
|
|
1386
|
+
throw new SloppyRealtimeError(
|
|
1387
|
+
"SLOPPY_E_REALTIME_PRESENCE_DISABLED",
|
|
1388
|
+
"Realtime presence is not enabled for this route.",
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
const result = await backplane.presenceSet(connectionId, {
|
|
1392
|
+
...(record ?? {}),
|
|
1393
|
+
userId: record?.userId ?? safeUserId(ctx.user),
|
|
1394
|
+
});
|
|
1395
|
+
incrementMetric(ctx, "realtime.presence.set.total", metricLabels);
|
|
1396
|
+
return result;
|
|
1397
|
+
},
|
|
1398
|
+
get(targetConnectionId = connectionId) {
|
|
1399
|
+
return backplane.presenceGet(targetConnectionId);
|
|
1400
|
+
},
|
|
1401
|
+
inGroup(groupName) {
|
|
1402
|
+
return backplane.presenceInGroup(validateGroupName(groupName));
|
|
1403
|
+
},
|
|
1404
|
+
}),
|
|
1405
|
+
connectionId: socket.id,
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
try {
|
|
1409
|
+
await handler(realtimeContext);
|
|
1410
|
+
if (!accepted) {
|
|
1411
|
+
return undefined;
|
|
1412
|
+
}
|
|
1413
|
+
for await (const message of socket.messages()) {
|
|
1414
|
+
if (message.kind !== "json" && message.kind !== "text") {
|
|
1415
|
+
await handleDispatchError(new SloppyRealtimeError(
|
|
1416
|
+
"SLOPPY_E_REALTIME_MALFORMED_ENVELOPE",
|
|
1417
|
+
"Realtime messages must be JSON envelopes.",
|
|
1418
|
+
{ closeCode: 1003 },
|
|
1419
|
+
));
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
let envelope;
|
|
1423
|
+
try {
|
|
1424
|
+
envelope = channel.parseClientMessage(message.kind === "json" ? message.json() : message.text);
|
|
1425
|
+
const registration = eventHandlers.get(envelope.type);
|
|
1426
|
+
if (registration === undefined) {
|
|
1427
|
+
throw new SloppyRealtimeError(
|
|
1428
|
+
"SLOPPY_E_REALTIME_UNKNOWN_EVENT",
|
|
1429
|
+
"Realtime client event has no handler.",
|
|
1430
|
+
{ event: envelope.type, closeCode: 1008 },
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
const eventPolicy = channel.client[envelope.type]?.metadata.auth;
|
|
1434
|
+
await authorizeMessage(ctx, mergeMessagePolicies(eventPolicy, registration.policy), envelope.type, {
|
|
1435
|
+
event: envelope.type,
|
|
1436
|
+
data: envelope.data,
|
|
1437
|
+
id: envelope.id,
|
|
1438
|
+
connectionId,
|
|
1439
|
+
channel: channel.name,
|
|
1440
|
+
});
|
|
1441
|
+
incrementMetric(ctx, "realtime.messages.in.total", {
|
|
1442
|
+
...metricLabels,
|
|
1443
|
+
event: envelope.type,
|
|
1444
|
+
outcome: "accepted",
|
|
1445
|
+
});
|
|
1446
|
+
await registration.handler(envelope.data, Object.freeze({
|
|
1447
|
+
id: envelope.id,
|
|
1448
|
+
event: envelope.type,
|
|
1449
|
+
}));
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
if (error instanceof SloppyRealtimeError &&
|
|
1452
|
+
error.code === "SLOPPY_E_REALTIME_VALIDATION_FAILED")
|
|
1453
|
+
{
|
|
1454
|
+
incrementMetric(ctx, "realtime.messages.validation_failed.total", {
|
|
1455
|
+
...metricLabels,
|
|
1456
|
+
event: error.event ?? "",
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
if (error instanceof SloppyRealtimeError &&
|
|
1460
|
+
error.code === "SLOPPY_E_REALTIME_UNAUTHORIZED_EVENT")
|
|
1461
|
+
{
|
|
1462
|
+
incrementMetric(ctx, "realtime.messages.unauthorized.total", {
|
|
1463
|
+
...metricLabels,
|
|
1464
|
+
event: error.event ?? "",
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
await handleDispatchError(error, envelope?.type ?? error?.event);
|
|
1468
|
+
if (socket.closed) {
|
|
1469
|
+
break;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
return undefined;
|
|
1474
|
+
} finally {
|
|
1475
|
+
await cleanup();
|
|
1476
|
+
}
|
|
1477
|
+
}, routeOptions.websocket);
|
|
1478
|
+
Object.defineProperty(routeHandler, REALTIME_ROUTE_METADATA, {
|
|
1479
|
+
value: deepFreeze({
|
|
1480
|
+
kind: "realtime",
|
|
1481
|
+
channel: channel.metadata,
|
|
1482
|
+
options: {
|
|
1483
|
+
presence: routeOptions.presence,
|
|
1484
|
+
backplaneKind: routeOptions.backplaneKind,
|
|
1485
|
+
unknownEventPolicy: routeOptions.unknownEventPolicy,
|
|
1486
|
+
validationFailurePolicy: routeOptions.validationFailurePolicy,
|
|
1487
|
+
handlerErrorPolicy: routeOptions.handlerErrorPolicy,
|
|
1488
|
+
},
|
|
1489
|
+
websocket: routeOptions.websocket,
|
|
1490
|
+
}),
|
|
1491
|
+
});
|
|
1492
|
+
return routeHandler;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
export const Realtime = Object.freeze({
|
|
1496
|
+
sse,
|
|
1497
|
+
websocket,
|
|
1498
|
+
hub: createHub,
|
|
1499
|
+
channel: createChannel,
|
|
1500
|
+
event: createRealtimeEvent,
|
|
1501
|
+
isChannel: isRealtimeChannel,
|
|
1502
|
+
backplane: Object.freeze({
|
|
1503
|
+
memory: createMemoryRealtimeBackplane,
|
|
1504
|
+
}),
|
|
1505
|
+
textBytes(value) {
|
|
1506
|
+
return Text.utf8.encode(String(value));
|
|
1507
|
+
},
|
|
1508
|
+
});
|