@lastshotlabs/bunshot 0.0.21 → 0.0.27
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/README.md +3035 -1249
- package/dist/adapters/localStorage.d.ts +6 -0
- package/dist/adapters/localStorage.js +59 -0
- package/dist/adapters/memoryAuth.d.ts +13 -0
- package/dist/adapters/memoryAuth.js +261 -2
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +217 -1
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +30 -0
- package/dist/adapters/sqliteAuth.js +352 -2
- package/dist/app.d.ts +203 -3
- package/dist/app.js +352 -48
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +69 -8
- package/dist/index.js +46 -5
- package/dist/lib/HttpError.d.ts +7 -1
- package/dist/lib/HttpError.js +10 -1
- package/dist/lib/appConfig.d.ts +157 -0
- package/dist/lib/appConfig.js +54 -0
- package/dist/lib/auditLog.d.ts +58 -0
- package/dist/lib/auditLog.js +218 -0
- package/dist/lib/authAdapter.d.ts +140 -1
- package/dist/lib/authRateLimit.js +36 -0
- package/dist/lib/breachedPassword.d.ts +13 -0
- package/dist/lib/breachedPassword.js +48 -0
- package/dist/lib/captcha.d.ts +25 -0
- package/dist/lib/captcha.js +37 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +24 -1
- package/dist/lib/context.js +17 -3
- package/dist/lib/createRoute.d.ts +28 -2
- package/dist/lib/createRoute.js +54 -3
- package/dist/lib/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- package/dist/lib/groups.d.ts +113 -0
- package/dist/lib/groups.js +133 -0
- package/dist/lib/idempotency.d.ts +22 -0
- package/dist/lib/idempotency.js +182 -0
- package/dist/lib/jwks.d.ts +25 -0
- package/dist/lib/jwks.js +51 -0
- package/dist/lib/jwt.d.ts +15 -2
- package/dist/lib/jwt.js +92 -5
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.js +6 -0
- package/dist/lib/m2m.d.ts +29 -0
- package/dist/lib/m2m.js +48 -0
- package/dist/lib/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -0
- package/dist/lib/mfaChallenge.d.ts +14 -1
- package/dist/lib/mfaChallenge.js +111 -6
- package/dist/lib/mongo.js +1 -1
- package/dist/lib/oauthCode.js +23 -18
- package/dist/lib/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- package/dist/lib/resetPassword.js +3 -1
- package/dist/lib/saml.d.ts +25 -0
- package/dist/lib/saml.js +64 -0
- package/dist/lib/scim.d.ts +44 -0
- package/dist/lib/scim.js +54 -0
- package/dist/lib/securityEvents.d.ts +28 -0
- package/dist/lib/securityEvents.js +26 -0
- package/dist/lib/session.d.ts +14 -0
- package/dist/lib/session.js +121 -5
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +183 -0
- package/dist/lib/storageAdapter.d.ts +30 -0
- package/dist/lib/storageAdapter.js +1 -0
- package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
- package/dist/lib/stripUnreferencedSchemas.js +79 -0
- package/dist/lib/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/tenant.js +2 -2
- package/dist/lib/upload.d.ts +39 -0
- package/dist/lib/upload.js +112 -0
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +28 -0
- package/dist/lib/wsHeartbeat.d.ts +12 -0
- package/dist/lib/wsHeartbeat.js +57 -0
- package/dist/lib/wsMessages.d.ts +40 -0
- package/dist/lib/wsMessages.js +330 -0
- package/dist/lib/wsPresence.d.ts +25 -0
- package/dist/lib/wsPresence.js +99 -0
- package/dist/middleware/auditLog.d.ts +22 -0
- package/dist/middleware/auditLog.js +39 -0
- package/dist/middleware/bearerAuth.js +1 -1
- package/dist/middleware/cacheResponse.js +5 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +18 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +89 -14
- package/dist/middleware/metrics.d.ts +9 -0
- package/dist/middleware/metrics.js +26 -0
- package/dist/middleware/requestId.d.ts +3 -0
- package/dist/middleware/requestId.js +7 -0
- package/dist/middleware/requestLogger.d.ts +38 -0
- package/dist/middleware/requestLogger.js +68 -0
- package/dist/middleware/requestSigning.d.ts +20 -0
- package/dist/middleware/requestSigning.js +100 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +37 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- package/dist/middleware/requireScope.d.ts +10 -0
- package/dist/middleware/requireScope.js +25 -0
- package/dist/middleware/requireStepUp.d.ts +18 -0
- package/dist/middleware/requireStepUp.js +29 -0
- package/dist/middleware/scimAuth.d.ts +8 -0
- package/dist/middleware/scimAuth.js +29 -0
- package/dist/middleware/upload.d.ts +5 -0
- package/dist/middleware/upload.js +27 -0
- package/dist/middleware/webhookAuth.d.ts +30 -0
- package/dist/middleware/webhookAuth.js +58 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -0
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -0
- package/dist/models/Group.d.ts +21 -0
- package/dist/models/Group.js +28 -0
- package/dist/models/GroupMembership.d.ts +21 -0
- package/dist/models/GroupMembership.js +25 -0
- package/dist/models/M2MClient.d.ts +18 -0
- package/dist/models/M2MClient.js +18 -0
- package/dist/routes/auth.d.ts +3 -2
- package/dist/routes/auth.js +238 -21
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +66 -46
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +8 -0
- package/dist/routes/metrics.js +55 -0
- package/dist/routes/mfa.js +13 -1
- package/dist/routes/oauth.js +6 -0
- package/dist/routes/oidc.d.ts +2 -0
- package/dist/routes/oidc.js +29 -0
- package/dist/routes/passkey.d.ts +1 -0
- package/dist/routes/passkey.js +157 -0
- package/dist/routes/saml.d.ts +2 -0
- package/dist/routes/saml.js +86 -0
- package/dist/routes/scim.d.ts +2 -0
- package/dist/routes/scim.js +255 -0
- package/dist/routes/uploads.d.ts +14 -0
- package/dist/routes/uploads.js +227 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- package/dist/services/auth.d.ts +2 -0
- package/dist/services/auth.js +101 -22
- package/dist/services/mfa.js +2 -2
- package/dist/ws/index.js +5 -1
- package/docs/sections/auth-flow/full.md +203 -47
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +388 -0
- package/docs/sections/authentication/full.md +130 -0
- package/docs/sections/authentication/overview.md +5 -0
- package/docs/sections/cli/full.md +13 -1
- package/docs/sections/configuration/full.md +17 -0
- package/docs/sections/configuration/overview.md +1 -0
- package/docs/sections/exports/full.md +34 -3
- package/docs/sections/logging/full.md +83 -0
- package/docs/sections/metrics/full.md +131 -0
- package/docs/sections/oauth/full.md +189 -189
- package/docs/sections/oauth/overview.md +1 -1
- package/docs/sections/pagination/full.md +93 -0
- package/docs/sections/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -0
- package/docs/sections/roles/full.md +224 -135
- package/docs/sections/roles/overview.md +3 -1
- package/docs/sections/signing/full.md +203 -0
- package/docs/sections/uploads/full.md +208 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +95 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- package/package.json +18 -5
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { HttpError } from "./HttpError";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
let _adapter = null;
|
|
4
|
+
let _config = {};
|
|
5
|
+
export const setStorageAdapter = (adapter) => { _adapter = adapter; };
|
|
6
|
+
export const getStorageAdapter = () => _adapter;
|
|
7
|
+
export const setUploadConfig = (config) => { _config = config; };
|
|
8
|
+
export const getUploadConfig = () => _config;
|
|
9
|
+
export const generateUploadKey = (file, ctx, opts) => {
|
|
10
|
+
const merged = { ..._config, ...opts };
|
|
11
|
+
if (merged.generateKey)
|
|
12
|
+
return merged.generateKey(file, ctx);
|
|
13
|
+
const rawExt = extname(file.name);
|
|
14
|
+
const ext = /^\.[a-zA-Z0-9]{1,10}$/.test(rawExt) ? rawExt : "";
|
|
15
|
+
const uuid = crypto.randomUUID();
|
|
16
|
+
const prefix = merged.keyPrefix ?? "uploads/";
|
|
17
|
+
const tenantPrefix = merged.tenantScopedKeys && ctx.tenantId ? `${ctx.tenantId}/` : "";
|
|
18
|
+
return `${prefix}${tenantPrefix}${uuid}${ext}`;
|
|
19
|
+
};
|
|
20
|
+
export const generateUploadKeyFromFilename = (filename, ctx, opts) => {
|
|
21
|
+
const merged = { ..._config, ...opts };
|
|
22
|
+
if (merged.generateKey) {
|
|
23
|
+
// Create minimal File-like for custom generators that expect a File object
|
|
24
|
+
const stub = new File([], filename ?? "upload");
|
|
25
|
+
return merged.generateKey(stub, ctx);
|
|
26
|
+
}
|
|
27
|
+
const rawExt = filename ? extname(filename) : "";
|
|
28
|
+
const ext = /^\.[a-zA-Z0-9]{1,10}$/.test(rawExt) ? rawExt : "";
|
|
29
|
+
const uuid = crypto.randomUUID();
|
|
30
|
+
const prefix = merged.keyPrefix ?? "uploads/";
|
|
31
|
+
const tenantPrefix = merged.tenantScopedKeys && ctx.tenantId ? `${ctx.tenantId}/` : "";
|
|
32
|
+
return `${prefix}${tenantPrefix}${uuid}${ext}`;
|
|
33
|
+
};
|
|
34
|
+
const mimeMatches = (mimeType, pattern) => {
|
|
35
|
+
if (pattern.endsWith("/*")) {
|
|
36
|
+
return mimeType.startsWith(pattern.slice(0, -1));
|
|
37
|
+
}
|
|
38
|
+
return mimeType === pattern;
|
|
39
|
+
};
|
|
40
|
+
export const validateFile = (file, opts) => {
|
|
41
|
+
const maxFileSize = opts.maxFileSize ?? _config.maxFileSize ?? 10 * 1024 * 1024;
|
|
42
|
+
if (file.size > maxFileSize) {
|
|
43
|
+
return `File "${file.name}" exceeds maximum size of ${maxFileSize} bytes`;
|
|
44
|
+
}
|
|
45
|
+
const allowedMimeTypes = opts.allowedMimeTypes ?? _config.allowedMimeTypes;
|
|
46
|
+
if (allowedMimeTypes && allowedMimeTypes.length > 0) {
|
|
47
|
+
const allowed = allowedMimeTypes.some((pattern) => mimeMatches(file.type, pattern));
|
|
48
|
+
if (!allowed) {
|
|
49
|
+
return `File "${file.name}" has disallowed MIME type "${file.type}"`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
export const processUpload = async (file, opts) => {
|
|
55
|
+
const adapter = _adapter;
|
|
56
|
+
if (!adapter)
|
|
57
|
+
throw new HttpError(500, "No storage adapter configured");
|
|
58
|
+
const validationError = validateFile(file, opts);
|
|
59
|
+
if (validationError)
|
|
60
|
+
throw new HttpError(400, validationError);
|
|
61
|
+
const key = generateUploadKey(file, opts.ctx ?? {}, opts);
|
|
62
|
+
const { url } = await adapter.put(key, file, {
|
|
63
|
+
mimeType: file.type,
|
|
64
|
+
size: file.size,
|
|
65
|
+
bucket: opts.bucket,
|
|
66
|
+
});
|
|
67
|
+
// Register the upload for ownership tracking
|
|
68
|
+
const { registerUpload } = await import("./uploadRegistry");
|
|
69
|
+
await registerUpload({
|
|
70
|
+
key,
|
|
71
|
+
ownerUserId: opts.ctx?.userId,
|
|
72
|
+
tenantId: opts.ctx?.tenantId,
|
|
73
|
+
mimeType: file.type,
|
|
74
|
+
bucket: opts.bucket,
|
|
75
|
+
createdAt: Date.now(),
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
key,
|
|
79
|
+
originalName: file.name,
|
|
80
|
+
mimeType: file.type,
|
|
81
|
+
size: file.size,
|
|
82
|
+
...(url !== undefined ? { url } : {}),
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
export const parseUpload = async (c, opts) => {
|
|
86
|
+
const merged = { ..._config, ...opts };
|
|
87
|
+
const fields = merged.field
|
|
88
|
+
? (Array.isArray(merged.field) ? merged.field : [merged.field])
|
|
89
|
+
: ["file"];
|
|
90
|
+
const maxFiles = merged.maxFiles ?? 10;
|
|
91
|
+
const body = await c.req.parseBody({ all: true });
|
|
92
|
+
const results = [];
|
|
93
|
+
const userId = c.get("authUserId") ?? undefined;
|
|
94
|
+
const tenantId = c.get("tenantId") ?? undefined;
|
|
95
|
+
const bucket = c.get("uploadBucket");
|
|
96
|
+
for (const field of fields) {
|
|
97
|
+
const raw = body[field];
|
|
98
|
+
if (!raw)
|
|
99
|
+
continue;
|
|
100
|
+
const files = Array.isArray(raw) ? raw : [raw];
|
|
101
|
+
for (const f of files) {
|
|
102
|
+
if (!(f instanceof File))
|
|
103
|
+
continue;
|
|
104
|
+
if (results.length >= maxFiles) {
|
|
105
|
+
throw new HttpError(400, `Too many files. Maximum is ${maxFiles}`);
|
|
106
|
+
}
|
|
107
|
+
const result = await processUpload(f, { ...merged, ctx: { userId, tenantId }, bucket: bucket ?? undefined });
|
|
108
|
+
results.push(result);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return results;
|
|
112
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface UploadRecord {
|
|
2
|
+
key: string;
|
|
3
|
+
ownerUserId?: string;
|
|
4
|
+
tenantId?: string;
|
|
5
|
+
mimeType?: string;
|
|
6
|
+
bucket?: string;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
}
|
|
9
|
+
type UploadRegistryStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
10
|
+
export declare const setUploadRegistryStore: (store: UploadRegistryStore) => void;
|
|
11
|
+
export declare const clearUploadRegistry: () => void;
|
|
12
|
+
/** Store a new upload record. Keyed by the storage key. */
|
|
13
|
+
export declare const registerUpload: (record: UploadRecord) => Promise<void>;
|
|
14
|
+
/** Retrieve an upload record by key. Returns null if not found. */
|
|
15
|
+
export declare const getUploadRecord: (key: string) => Promise<UploadRecord | null>;
|
|
16
|
+
/** Delete an upload record by key. Returns true if it existed. */
|
|
17
|
+
export declare const deleteUploadRecord: (key: string) => Promise<boolean>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { getRedis } from "./redis";
|
|
2
|
+
import { appConnection, mongoose } from "./mongo";
|
|
3
|
+
import { getAppName } from "./appConfig";
|
|
4
|
+
let _store = "memory";
|
|
5
|
+
export const setUploadRegistryStore = (store) => { _store = store; };
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Memory backend
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const _memoryRegistry = new Map();
|
|
10
|
+
export const clearUploadRegistry = () => { _memoryRegistry.clear(); };
|
|
11
|
+
function getUploadRegistryModel() {
|
|
12
|
+
if (appConnection.models["UploadRegistry"])
|
|
13
|
+
return appConnection.models["UploadRegistry"];
|
|
14
|
+
const { Schema } = mongoose;
|
|
15
|
+
const schema = new Schema({
|
|
16
|
+
key: { type: String, required: true, unique: true },
|
|
17
|
+
ownerUserId: { type: String },
|
|
18
|
+
tenantId: { type: String },
|
|
19
|
+
mimeType: { type: String },
|
|
20
|
+
bucket: { type: String },
|
|
21
|
+
createdAt: { type: Number, required: true },
|
|
22
|
+
}, { collection: "upload_registry" });
|
|
23
|
+
return appConnection.model("UploadRegistry", schema);
|
|
24
|
+
}
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Public API
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/** Store a new upload record. Keyed by the storage key. */
|
|
29
|
+
export const registerUpload = async (record) => {
|
|
30
|
+
if (_store === "memory") {
|
|
31
|
+
_memoryRegistry.set(record.key, record);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (_store === "sqlite") {
|
|
35
|
+
const { sqliteRegisterUpload } = await import("../adapters/sqliteAuth");
|
|
36
|
+
sqliteRegisterUpload(record);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (_store === "mongo") {
|
|
40
|
+
await getUploadRegistryModel().updateOne({ key: record.key }, { $set: record }, { upsert: true });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Redis — no TTL (upload records are permanent until deleted)
|
|
44
|
+
await getRedis().set(`ur:${getAppName()}:${record.key}`, JSON.stringify(record));
|
|
45
|
+
};
|
|
46
|
+
/** Retrieve an upload record by key. Returns null if not found. */
|
|
47
|
+
export const getUploadRecord = async (key) => {
|
|
48
|
+
if (_store === "memory") {
|
|
49
|
+
return _memoryRegistry.get(key) ?? null;
|
|
50
|
+
}
|
|
51
|
+
if (_store === "sqlite") {
|
|
52
|
+
const { sqliteGetUploadRecord } = await import("../adapters/sqliteAuth");
|
|
53
|
+
return sqliteGetUploadRecord(key);
|
|
54
|
+
}
|
|
55
|
+
if (_store === "mongo") {
|
|
56
|
+
const doc = await getUploadRegistryModel().findOne({ key }).lean();
|
|
57
|
+
if (!doc)
|
|
58
|
+
return null;
|
|
59
|
+
return { key: doc.key, ownerUserId: doc.ownerUserId, tenantId: doc.tenantId, mimeType: doc.mimeType, bucket: doc.bucket, createdAt: doc.createdAt };
|
|
60
|
+
}
|
|
61
|
+
// Redis
|
|
62
|
+
const raw = await getRedis().get(`ur:${getAppName()}:${key}`);
|
|
63
|
+
if (!raw)
|
|
64
|
+
return null;
|
|
65
|
+
return JSON.parse(raw);
|
|
66
|
+
};
|
|
67
|
+
/** Delete an upload record by key. Returns true if it existed. */
|
|
68
|
+
export const deleteUploadRecord = async (key) => {
|
|
69
|
+
if (_store === "memory") {
|
|
70
|
+
return _memoryRegistry.delete(key);
|
|
71
|
+
}
|
|
72
|
+
if (_store === "sqlite") {
|
|
73
|
+
const { sqliteDeleteUploadRecord } = await import("../adapters/sqliteAuth");
|
|
74
|
+
return sqliteDeleteUploadRecord(key);
|
|
75
|
+
}
|
|
76
|
+
if (_store === "mongo") {
|
|
77
|
+
const result = await getUploadRegistryModel().deleteOne({ key });
|
|
78
|
+
return result.deletedCount > 0;
|
|
79
|
+
}
|
|
80
|
+
// Redis
|
|
81
|
+
const deleted = await getRedis().del(`ur:${getAppName()}:${key}`);
|
|
82
|
+
return deleted > 0;
|
|
83
|
+
};
|
package/dist/lib/validate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { ValidationError } from "./HttpError";
|
|
3
3
|
export const validate = async (schema, req) => {
|
|
4
4
|
try {
|
|
5
5
|
const body = await req.json();
|
|
@@ -7,7 +7,7 @@ export const validate = async (schema, req) => {
|
|
|
7
7
|
}
|
|
8
8
|
catch (err) {
|
|
9
9
|
if (err instanceof z.ZodError) {
|
|
10
|
-
throw new
|
|
10
|
+
throw new ValidationError(err.issues);
|
|
11
11
|
}
|
|
12
12
|
throw err;
|
|
13
13
|
}
|
package/dist/lib/ws.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ type WithSocketId = {
|
|
|
6
6
|
id: string;
|
|
7
7
|
} & WithRooms;
|
|
8
8
|
export declare const setWsServer: (server: Server<any>) => void;
|
|
9
|
+
export declare const setPresenceEnabled: (enabled: boolean) => void;
|
|
9
10
|
export declare const publish: (topic: string, data: unknown) => void;
|
|
10
11
|
/** All rooms that currently have at least one subscriber */
|
|
11
12
|
export declare const getRooms: () => string[];
|
package/dist/lib/ws.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { addPresence, removePresence, cleanupPresence } from "./wsPresence";
|
|
1
2
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2
3
|
let _server = null;
|
|
4
|
+
let _presenceEnabled = false;
|
|
3
5
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
4
6
|
export const setWsServer = (server) => { _server = server; };
|
|
7
|
+
export const setPresenceEnabled = (enabled) => { _presenceEnabled = enabled; };
|
|
5
8
|
export const publish = (topic, data) => {
|
|
6
9
|
_server?.publish(topic, JSON.stringify(data));
|
|
7
10
|
};
|
|
@@ -12,6 +15,9 @@ export const getRooms = () => [..._roomRegistry.keys()];
|
|
|
12
15
|
/** Socket IDs subscribed to a given room */
|
|
13
16
|
export const getRoomSubscribers = (room) => [...(_roomRegistry.get(room) ?? [])];
|
|
14
17
|
const MAX_ROOM_ACTION_SIZE = 4096; // 4 KB — room actions are small JSON payloads
|
|
18
|
+
function isValidRoomName(room) {
|
|
19
|
+
return typeof room === "string" && /^[a-zA-Z0-9_:./\-]{1,128}$/.test(room);
|
|
20
|
+
}
|
|
15
21
|
export const handleRoomActions = async (ws, message, onSubscribe) => {
|
|
16
22
|
try {
|
|
17
23
|
const raw = typeof message === "string" ? message : Buffer.from(message).toString();
|
|
@@ -19,6 +25,8 @@ export const handleRoomActions = async (ws, message, onSubscribe) => {
|
|
|
19
25
|
return false; // not a room action
|
|
20
26
|
const data = JSON.parse(raw);
|
|
21
27
|
if (data.action === "subscribe" && typeof data.room === "string") {
|
|
28
|
+
if (!isValidRoomName(data.room))
|
|
29
|
+
return true; // silently drop invalid room names
|
|
22
30
|
if (onSubscribe && !(await onSubscribe(ws, data.room))) {
|
|
23
31
|
ws.send(JSON.stringify({ event: "subscribe_denied", room: data.room }));
|
|
24
32
|
}
|
|
@@ -29,6 +37,8 @@ export const handleRoomActions = async (ws, message, onSubscribe) => {
|
|
|
29
37
|
return true;
|
|
30
38
|
}
|
|
31
39
|
if (data.action === "unsubscribe" && typeof data.room === "string") {
|
|
40
|
+
if (!isValidRoomName(data.room))
|
|
41
|
+
return true; // silently drop invalid room names
|
|
32
42
|
unsubscribe(ws, data.room);
|
|
33
43
|
ws.send(JSON.stringify({ event: "unsubscribed", room: data.room }));
|
|
34
44
|
return true;
|
|
@@ -43,6 +53,12 @@ export const subscribe = (ws, room) => {
|
|
|
43
53
|
if (!_roomRegistry.has(room))
|
|
44
54
|
_roomRegistry.set(room, new Set());
|
|
45
55
|
_roomRegistry.get(room).add(ws.data.id);
|
|
56
|
+
if (_presenceEnabled) {
|
|
57
|
+
const result = addPresence(ws.data.id, room);
|
|
58
|
+
if (result?.isNewUser) {
|
|
59
|
+
publish(room, { event: "presence_join", room, userId: result.userId });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
46
62
|
};
|
|
47
63
|
export const unsubscribe = (ws, room) => {
|
|
48
64
|
ws.unsubscribe(room);
|
|
@@ -53,10 +69,22 @@ export const unsubscribe = (ws, room) => {
|
|
|
53
69
|
if (ids.size === 0)
|
|
54
70
|
_roomRegistry.delete(room);
|
|
55
71
|
}
|
|
72
|
+
if (_presenceEnabled) {
|
|
73
|
+
const result = removePresence(ws.data.id, room);
|
|
74
|
+
if (result?.isLastSocket) {
|
|
75
|
+
publish(room, { event: "presence_leave", room, userId: result.userId });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
56
78
|
};
|
|
57
79
|
export const getSubscriptions = (ws) => [...ws.data.rooms];
|
|
58
80
|
/** Called on socket close to prune the registry. Internal use only. */
|
|
59
81
|
export const cleanupSocket = (socketId, rooms) => {
|
|
82
|
+
if (_presenceEnabled) {
|
|
83
|
+
const departed = cleanupPresence(socketId, rooms);
|
|
84
|
+
for (const { room, userId } of departed) {
|
|
85
|
+
publish(room, { event: "presence_leave", room, userId });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
60
88
|
for (const room of rooms) {
|
|
61
89
|
const ids = _roomRegistry.get(room);
|
|
62
90
|
if (ids) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
export interface HeartbeatConfig {
|
|
3
|
+
intervalMs?: number;
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare const registerSocket: (ws: ServerWebSocket<any>, id: string) => void;
|
|
7
|
+
export declare const deregisterSocket: (id: string) => void;
|
|
8
|
+
export declare const handlePong: (id: string) => void;
|
|
9
|
+
export declare const startHeartbeat: (config?: HeartbeatConfig | boolean) => void;
|
|
10
|
+
export declare const stopHeartbeat: () => void;
|
|
11
|
+
/** Reset all heartbeat state. Useful for test isolation. */
|
|
12
|
+
export declare const clearHeartbeatState: () => void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// State
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
/** All live sockets — socketId → ServerWebSocket */
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
|
+
const _activeSockets = new Map();
|
|
7
|
+
/** socketId → last pong timestamp (epoch ms) */
|
|
8
|
+
const _lastPong = new Map();
|
|
9
|
+
let _pingInterval = null;
|
|
10
|
+
const DEFAULT_INTERVAL_MS = 30_000;
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Public API
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
export const registerSocket = (ws, id) => {
|
|
16
|
+
_activeSockets.set(id, ws);
|
|
17
|
+
_lastPong.set(id, Date.now());
|
|
18
|
+
};
|
|
19
|
+
export const deregisterSocket = (id) => {
|
|
20
|
+
_activeSockets.delete(id);
|
|
21
|
+
_lastPong.delete(id);
|
|
22
|
+
};
|
|
23
|
+
export const handlePong = (id) => {
|
|
24
|
+
_lastPong.set(id, Date.now());
|
|
25
|
+
};
|
|
26
|
+
export const startHeartbeat = (config) => {
|
|
27
|
+
if (_pingInterval)
|
|
28
|
+
return; // already running
|
|
29
|
+
const opts = typeof config === "object" ? config : {};
|
|
30
|
+
const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
31
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
32
|
+
_pingInterval = setInterval(() => {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
for (const [id, ws] of _activeSockets) {
|
|
35
|
+
const lastPong = _lastPong.get(id) ?? 0;
|
|
36
|
+
if (now - lastPong > timeoutMs) {
|
|
37
|
+
ws.close(1001, "Heartbeat timeout");
|
|
38
|
+
// deregisterSocket will be called by the close handler
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
ws.ping();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}, intervalMs);
|
|
45
|
+
};
|
|
46
|
+
export const stopHeartbeat = () => {
|
|
47
|
+
if (_pingInterval) {
|
|
48
|
+
clearInterval(_pingInterval);
|
|
49
|
+
_pingInterval = null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/** Reset all heartbeat state. Useful for test isolation. */
|
|
53
|
+
export const clearHeartbeatState = () => {
|
|
54
|
+
stopHeartbeat();
|
|
55
|
+
_activeSockets.clear();
|
|
56
|
+
_lastPong.clear();
|
|
57
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface StoredMessage {
|
|
2
|
+
id: string;
|
|
3
|
+
room: string;
|
|
4
|
+
senderId: string | null;
|
|
5
|
+
payload: unknown;
|
|
6
|
+
createdAt: number;
|
|
7
|
+
}
|
|
8
|
+
export type WsMessageStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
9
|
+
export interface WsMessageDefaults {
|
|
10
|
+
maxCount?: number;
|
|
11
|
+
ttlSeconds?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface RoomPersistenceConfig {
|
|
14
|
+
persist: boolean;
|
|
15
|
+
maxCount?: number;
|
|
16
|
+
ttlSeconds?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare const setWsMessageStore: (store: WsMessageStore) => void;
|
|
19
|
+
export declare const setWsMessageDefaults: (defaults: WsMessageDefaults) => void;
|
|
20
|
+
/** Opt a room into message persistence. */
|
|
21
|
+
export declare const configureRoom: (room: string, options: RoomPersistenceConfig) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Persist a message to a room. Returns null if room is not configured for persistence.
|
|
24
|
+
* On store errors, logs a warning and returns null (non-blocking).
|
|
25
|
+
*/
|
|
26
|
+
export declare const persistMessage: (room: string, data: {
|
|
27
|
+
senderId?: string | null;
|
|
28
|
+
payload: unknown;
|
|
29
|
+
}) => Promise<StoredMessage | null>;
|
|
30
|
+
/**
|
|
31
|
+
* Get message history for a room.
|
|
32
|
+
* Cursor-based pagination using message `id` as cursor.
|
|
33
|
+
*/
|
|
34
|
+
export declare const getMessageHistory: (room: string, opts?: {
|
|
35
|
+
limit?: number;
|
|
36
|
+
before?: string;
|
|
37
|
+
after?: string;
|
|
38
|
+
}) => Promise<StoredMessage[]>;
|
|
39
|
+
/** Reset memory store. Useful for test isolation. */
|
|
40
|
+
export declare const clearWsMessageMemoryStore: () => void;
|