@lastshotlabs/bunshot 0.0.13 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +2510 -1747
  2. package/dist/adapters/memoryAuth.d.ts +4 -0
  3. package/dist/adapters/memoryAuth.js +131 -2
  4. package/dist/adapters/mongoAuth.js +56 -0
  5. package/dist/adapters/sqliteAuth.d.ts +6 -0
  6. package/dist/adapters/sqliteAuth.js +137 -2
  7. package/dist/app.d.ts +77 -2
  8. package/dist/app.js +29 -4
  9. package/dist/entrypoints/queue.d.ts +2 -2
  10. package/dist/entrypoints/queue.js +1 -1
  11. package/dist/index.d.ts +14 -5
  12. package/dist/index.js +9 -3
  13. package/dist/lib/appConfig.d.ts +46 -0
  14. package/dist/lib/appConfig.js +20 -0
  15. package/dist/lib/authAdapter.d.ts +30 -0
  16. package/dist/lib/constants.d.ts +2 -0
  17. package/dist/lib/constants.js +2 -0
  18. package/dist/lib/context.d.ts +2 -0
  19. package/dist/lib/createDtoMapper.d.ts +33 -0
  20. package/dist/lib/createDtoMapper.js +69 -0
  21. package/dist/lib/jwt.d.ts +1 -1
  22. package/dist/lib/jwt.js +2 -2
  23. package/dist/lib/mfaChallenge.d.ts +20 -0
  24. package/dist/lib/mfaChallenge.js +184 -0
  25. package/dist/lib/queue.d.ts +33 -0
  26. package/dist/lib/queue.js +98 -0
  27. package/dist/lib/roles.d.ts +4 -0
  28. package/dist/lib/roles.js +27 -0
  29. package/dist/lib/session.d.ts +12 -0
  30. package/dist/lib/session.js +163 -5
  31. package/dist/lib/tenant.d.ts +15 -0
  32. package/dist/lib/tenant.js +65 -0
  33. package/dist/lib/zodToMongoose.d.ts +38 -0
  34. package/dist/lib/zodToMongoose.js +84 -0
  35. package/dist/middleware/cacheResponse.js +4 -1
  36. package/dist/middleware/rateLimit.d.ts +2 -1
  37. package/dist/middleware/rateLimit.js +5 -2
  38. package/dist/middleware/requireRole.d.ts +14 -3
  39. package/dist/middleware/requireRole.js +46 -6
  40. package/dist/middleware/tenant.d.ts +5 -0
  41. package/dist/middleware/tenant.js +116 -0
  42. package/dist/models/AuthUser.d.ts +8 -0
  43. package/dist/models/AuthUser.js +8 -0
  44. package/dist/models/TenantRole.d.ts +15 -0
  45. package/dist/models/TenantRole.js +23 -0
  46. package/dist/routes/auth.d.ts +5 -3
  47. package/dist/routes/auth.js +153 -22
  48. package/dist/routes/jobs.d.ts +2 -0
  49. package/dist/routes/jobs.js +270 -0
  50. package/dist/routes/mfa.d.ts +1 -0
  51. package/dist/routes/mfa.js +409 -0
  52. package/dist/routes/oauth.js +107 -16
  53. package/dist/server.js +9 -0
  54. package/dist/services/auth.d.ts +17 -5
  55. package/dist/services/auth.js +95 -17
  56. package/dist/services/mfa.d.ts +37 -0
  57. package/dist/services/mfa.js +276 -0
  58. package/docs/sections/adding-middleware/full.md +35 -0
  59. package/docs/sections/adding-models/full.md +125 -0
  60. package/docs/sections/adding-models/overview.md +13 -0
  61. package/docs/sections/adding-routes/full.md +182 -0
  62. package/docs/sections/adding-routes/overview.md +23 -0
  63. package/docs/sections/auth-flow/full.md +456 -0
  64. package/docs/sections/auth-flow/overview.md +10 -0
  65. package/docs/sections/cli/full.md +30 -0
  66. package/docs/sections/configuration/full.md +135 -0
  67. package/docs/sections/configuration/overview.md +17 -0
  68. package/docs/sections/configuration-example/full.md +99 -0
  69. package/docs/sections/configuration-example/overview.md +30 -0
  70. package/docs/sections/documentation/full.md +171 -0
  71. package/docs/sections/environment-variables/full.md +55 -0
  72. package/docs/sections/exports/full.md +83 -0
  73. package/docs/sections/extending-context/full.md +59 -0
  74. package/docs/sections/header.md +3 -0
  75. package/docs/sections/installation/full.md +6 -0
  76. package/docs/sections/jobs/full.md +140 -0
  77. package/docs/sections/jobs/overview.md +15 -0
  78. package/docs/sections/mongodb-connections/full.md +45 -0
  79. package/docs/sections/mongodb-connections/overview.md +7 -0
  80. package/docs/sections/multi-tenancy/full.md +62 -0
  81. package/docs/sections/multi-tenancy/overview.md +15 -0
  82. package/docs/sections/oauth/full.md +119 -0
  83. package/docs/sections/oauth/overview.md +16 -0
  84. package/docs/sections/package-development/full.md +7 -0
  85. package/docs/sections/peer-dependencies/full.md +43 -0
  86. package/docs/sections/quick-start/full.md +43 -0
  87. package/docs/sections/response-caching/full.md +115 -0
  88. package/docs/sections/response-caching/overview.md +13 -0
  89. package/docs/sections/roles/full.md +136 -0
  90. package/docs/sections/roles/overview.md +12 -0
  91. package/docs/sections/running-without-redis/full.md +16 -0
  92. package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
  93. package/docs/sections/stack/full.md +10 -0
  94. package/docs/sections/websocket/full.md +100 -0
  95. package/docs/sections/websocket/overview.md +5 -0
  96. package/docs/sections/websocket-rooms/full.md +97 -0
  97. package/docs/sections/websocket-rooms/overview.md +5 -0
  98. package/package.json +19 -10
@@ -0,0 +1,115 @@
1
+ ## Response Caching
2
+
3
+ Cache GET responses and bust them from mutation endpoints. Supports Redis, MongoDB, SQLite, and memory stores. The cache key is automatically namespaced by `appName` (`cache:{appName}:{key}`), so shared instances across tenant apps never collide.
4
+
5
+ ### Basic usage
6
+
7
+ ```ts
8
+ import { cacheResponse, bustCache } from "@lastshotlabs/bunshot";
9
+
10
+ // GET — cache the response for 60 seconds in Redis (default)
11
+ router.use("/products", cacheResponse({ ttl: 60, key: "products" }));
12
+
13
+ // indefinite — cached until busted
14
+ router.use("/config", cacheResponse({ key: "config" }));
15
+
16
+ router.get("/products", async (c) => {
17
+ const items = await Product.find();
18
+ return c.json({ items });
19
+ });
20
+
21
+ // POST — write data, then bust the shared key (hits all connected stores)
22
+ router.post("/products", userAuth, async (c) => {
23
+ const body = await c.req.json();
24
+ await Product.create(body);
25
+ await bustCache("products");
26
+ return c.json({ ok: true }, 201);
27
+ });
28
+ ```
29
+
30
+ The `key` string is the shared contract — `cacheResponse` stores under it, `bustCache` deletes it. Responses include an `x-cache: HIT` or `x-cache: MISS` header.
31
+
32
+ ### Choosing a cache store
33
+
34
+ Pass `store` to select where the response is cached. Defaults to `"redis"`.
35
+
36
+ ```ts
37
+ // Redis (default)
38
+ cacheResponse({ key: "products", ttl: 60 })
39
+
40
+ // MongoDB — uses appConnection, stores in the `cache_entries` collection
41
+ // TTL is handled natively via a MongoDB expiry index on the expiresAt field
42
+ cacheResponse({ key: "products", ttl: 300, store: "mongo" })
43
+
44
+ // SQLite — uses the same .db file as sqliteAuthAdapter; requires setSqliteDb or sqliteDb config
45
+ cacheResponse({ key: "products", ttl: 60, store: "sqlite" })
46
+
47
+ // Memory — in-process Map, ephemeral (cleared on restart), no external dependencies
48
+ cacheResponse({ key: "products", ttl: 60, store: "memory" })
49
+ ```
50
+
51
+ Use SQLite when running without Redis or MongoDB. Use MongoDB when you want cache entries co-located with your app data. Use Redis for lower-latency hot caches. Use Memory for tests or single-process apps where persistence isn't needed.
52
+
53
+ **Connection requirements:** The chosen store must be initialized when the route is first hit. If `store: "sqlite"` is used but `setSqliteDb` has not been called (e.g. `sqliteDb` was not passed to `createServer`), the middleware throws a clear error on the first request. The same applies to the other stores.
54
+
55
+ ### Busting cached entries
56
+
57
+ `bustCache` always attempts all four stores (Redis, Mongo, SQLite, Memory), skipping any that aren't connected. This means it works correctly regardless of which `store` option your routes use, and is safe to call in apps that don't use all stores:
58
+
59
+ ```ts
60
+ await bustCache("products"); // hits whichever stores are connected
61
+ ```
62
+
63
+ ### Per-user caching
64
+
65
+ The `key` function receives the full Hono context, so you can scope cache entries to the authenticated user:
66
+
67
+ ```ts
68
+ router.use("/feed", userAuth, cacheResponse({
69
+ ttl: 60,
70
+ key: (c) => `feed:${c.get("authUserId")}`,
71
+ }));
72
+ ```
73
+
74
+ `authUserId` is populated by `identify`, which always runs before route middleware, so it's safe to use here.
75
+
76
+ ### Per-resource caching
77
+
78
+ For routes with dynamic segments, use the function form of `key`. Produce the same string in `bustCache`:
79
+
80
+ ```ts
81
+ // GET /products/:id
82
+ router.use("/products/:id", cacheResponse({
83
+ ttl: 60,
84
+ key: (c) => `product:${c.req.param("id")}`,
85
+ }));
86
+
87
+ router.get("/products/:id", async (c) => {
88
+ const item = await Product.findById(c.req.param("id"));
89
+ return c.json(item);
90
+ });
91
+
92
+ // PUT /products/:id
93
+ router.put("/products/:id", userAuth, async (c) => {
94
+ const id = c.req.param("id");
95
+ await Product.findByIdAndUpdate(id, await c.req.json());
96
+ await bustCache(`product:${id}`);
97
+ return c.json({ ok: true });
98
+ });
99
+ ```
100
+
101
+ Only 2xx responses are cached. Non-2xx responses pass through uncached. Omit `ttl` to cache indefinitely — the entry will persist until explicitly busted with `bustCache`.
102
+
103
+ ### Busting by pattern
104
+
105
+ When cache keys include variable parts (e.g. query params), use `bustCachePattern` to invalidate an entire logical group at once. It runs against all four stores — Redis (via SCAN), Mongo (via regex), SQLite (via LIKE), and Memory (via regex) — in parallel:
106
+
107
+ ```ts
108
+ import { bustCachePattern } from "@lastshotlabs/bunshot";
109
+
110
+ // key includes query params: `balance:${userId}:${from}:${to}:${groupBy}`
111
+ // bust all balance entries for this user regardless of params
112
+ await bustCachePattern(`balance:${userId}:*`);
113
+ ```
114
+
115
+ The `*` wildcard is translated to a Redis glob, a Mongo/Memory regex, and a SQLite LIKE pattern automatically. Like `bustCache`, it silently skips any store that isn't connected, so it's safe to call in apps that only use one store.
@@ -0,0 +1,13 @@
1
+ ## Response Caching
2
+
3
+ Cache GET responses with `cacheResponse({ ttl, key })` and bust them with `bustCache(key)`. Supports Redis, MongoDB, SQLite, and memory stores. Cache keys are auto-namespaced by app name and tenant (when multi-tenancy is active).
4
+
5
+ ```ts
6
+ import { cacheResponse, bustCache } from "@lastshotlabs/bunshot";
7
+
8
+ router.use("/products", cacheResponse({ ttl: 60, key: "products" }));
9
+ // ...
10
+ await bustCache("products"); // hits all connected stores
11
+ ```
12
+
13
+ Supports per-user caching via `key: (c) => ...`, per-resource caching, and wildcard invalidation via `bustCachePattern("products:*")`.
@@ -0,0 +1,136 @@
1
+ ## Roles
2
+
3
+ ### Setup
4
+
5
+ Declare the valid roles for your app in `createServer` / `createApp`:
6
+
7
+ ```ts
8
+ await createServer({
9
+ auth: {
10
+ roles: ["admin", "editor", "user"],
11
+ defaultRole: "user", // automatically assigned on /auth/register
12
+ },
13
+ // ...
14
+ });
15
+ ```
16
+
17
+ `roles` makes the list available anywhere via `getAppRoles()`. `defaultRole` is assigned to every new user that registers via `POST /auth/register` — no extra code needed.
18
+
19
+ ### Assigning roles to a user
20
+
21
+ Three helpers are available depending on what you need:
22
+
23
+ | Helper | Behaviour |
24
+ |---|---|
25
+ | `setUserRoles(userId, roles)` | Replace all roles — pass the full desired set |
26
+ | `addUserRole(userId, role)` | Add a single role, leaving others unchanged |
27
+ | `removeUserRole(userId, role)` | Remove a single role, leaving others unchanged |
28
+
29
+ ```ts
30
+ import { setUserRoles, addUserRole, removeUserRole, userAuth, requireRole } from "@lastshotlabs/bunshot";
31
+
32
+ // promote a user to admin
33
+ router.post("/admin/users/:id/promote", userAuth, requireRole("admin"), async (c) => {
34
+ await addUserRole(c.req.param("id"), "admin");
35
+ return c.json({ ok: true });
36
+ });
37
+
38
+ // revoke a role
39
+ router.post("/admin/users/:id/demote", userAuth, requireRole("admin"), async (c) => {
40
+ await removeUserRole(c.req.param("id"), "admin");
41
+ return c.json({ ok: true });
42
+ });
43
+
44
+ // replace all roles at once
45
+ router.put("/admin/users/:id/roles", userAuth, requireRole("admin"), async (c) => {
46
+ const { roles } = await c.req.json();
47
+ await setUserRoles(c.req.param("id"), roles);
48
+ return c.json({ ok: true });
49
+ });
50
+ ```
51
+
52
+ ### Protecting routes by role
53
+
54
+ `requireRole` is a middleware factory. It lazy-fetches roles on the first role-checked request and caches them on the Hono context, so multiple `requireRole` calls in a middleware chain only hit the DB once.
55
+
56
+ ```ts
57
+ import { userAuth, requireRole } from "@lastshotlabs/bunshot";
58
+
59
+ router.use("/admin", userAuth, requireRole("admin"));
60
+ router.use("/content", userAuth, requireRole("admin", "editor")); // allow either role
61
+ ```
62
+
63
+ | Scenario | Response |
64
+ |---|---|
65
+ | No session | `401 Unauthorized` |
66
+ | Authenticated, wrong role | `403 Forbidden` |
67
+ | Authenticated, correct role | passes through |
68
+
69
+ ### Custom adapter with roles
70
+
71
+ If you're using a custom `authAdapter`, implement the role methods to back role operations with your own store:
72
+
73
+ | Method | Required for |
74
+ |---|---|
75
+ | `getRoles(userId)` | `requireRole` middleware |
76
+ | `setRoles(userId, roles)` | `defaultRole` assignment on registration, full replace |
77
+ | `addRole(userId, role)` | Granular role addition |
78
+ | `removeRole(userId, role)` | Granular role removal |
79
+
80
+ All are optional — only implement what your app uses. `setRoles` is **required** if you configure `defaultRole` (the app will throw at startup if this combination is misconfigured). The exported helpers `setUserRoles`, `addUserRole`, and `removeUserRole` route through your adapter, so they work regardless of which store you use.
81
+
82
+ ```ts
83
+ const myAdapter: AuthAdapter = {
84
+ findByEmail: ...,
85
+ create: ...,
86
+ async getRoles(userId) {
87
+ const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
88
+ return user?.roles ?? [];
89
+ },
90
+ async setRoles(userId, roles) {
91
+ await db.update(users).set({ roles }).where(eq(users.id, userId));
92
+ },
93
+ async addRole(userId, role) {
94
+ const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
95
+ if (user && !user.roles.includes(role)) {
96
+ await db.update(users).set({ roles: [...user.roles, role] }).where(eq(users.id, userId));
97
+ }
98
+ },
99
+ async removeRole(userId, role) {
100
+ const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
101
+ if (user) {
102
+ await db.update(users).set({ roles: user.roles.filter((r: string) => r !== role) }).where(eq(users.id, userId));
103
+ }
104
+ },
105
+ };
106
+ ```
107
+
108
+ ### Tenant-scoped roles
109
+
110
+ When multi-tenancy is enabled (see below), `requireRole` automatically checks **tenant-scoped roles** instead of app-wide roles when a `tenantId` is present in the request context.
111
+
112
+ ```ts
113
+ // Assign a tenant-scoped role
114
+ import { addTenantRole, setTenantRoles, removeTenantRole, getTenantRoles } from "@lastshotlabs/bunshot";
115
+
116
+ await addTenantRole(userId, "acme", "admin");
117
+ await setTenantRoles(userId, "acme", ["admin", "editor"]);
118
+ await removeTenantRole(userId, "acme", "editor");
119
+ const roles = await getTenantRoles(userId, "acme"); // ["admin"]
120
+ ```
121
+
122
+ `requireRole("admin")` checks tenant-scoped roles when `tenantId` is in context, and falls back to app-wide roles when there is no tenant context. Use `requireRole.global("superadmin")` to always check app-wide roles regardless of tenant.
123
+
124
+ ```ts
125
+ router.use("/tenant-admin", userAuth, requireRole("admin")); // checks tenant roles when in tenant context
126
+ router.use("/super-admin", userAuth, requireRole.global("superadmin")); // always checks app-wide roles
127
+ ```
128
+
129
+ If you're using a custom `authAdapter`, implement the tenant role methods:
130
+
131
+ | Method | Purpose |
132
+ |---|---|
133
+ | `getTenantRoles(userId, tenantId)` | Required for tenant-scoped `requireRole` |
134
+ | `setTenantRoles(userId, tenantId, roles)` | Full replace |
135
+ | `addTenantRole(userId, tenantId, role)` | Granular addition |
136
+ | `removeTenantRole(userId, tenantId, role)` | Granular removal |
@@ -0,0 +1,12 @@
1
+ ## Roles
2
+
3
+ Declare roles in `createServer({ auth: { roles: ["admin", "editor", "user"], defaultRole: "user" } })`. The default role is auto-assigned on registration.
4
+
5
+ ```ts
6
+ import { userAuth, requireRole, addUserRole } from "@lastshotlabs/bunshot";
7
+
8
+ router.use("/admin", userAuth, requireRole("admin"));
9
+ await addUserRole(userId, "admin"); // also: setUserRoles, removeUserRole
10
+ ```
11
+
12
+ Tenant-scoped roles are supported when multi-tenancy is enabled — `requireRole` checks tenant roles when `tenantId` is in context, falls back to app-wide roles otherwise. Use `requireRole.global("superadmin")` to always check app-wide roles.
@@ -0,0 +1,16 @@
1
+ ## Running without Redis
2
+
3
+ Set `db.redis: false` and `db.sessions: "mongo"` to run the entire auth flow on MongoDB only. Sessions, OAuth state, and response caching (when `store: "mongo"`) all work without Redis. The only feature that still requires Redis is BullMQ queues.
4
+
5
+ ```ts
6
+ await createServer({
7
+ db: {
8
+ mongo: "single",
9
+ redis: false,
10
+ sessions: "mongo", // sessions + OAuth state → MongoDB
11
+ cache: "mongo", // or omit cacheResponse entirely if not using it
12
+ },
13
+ });
14
+ ```
15
+
16
+ Redis key namespacing: when Redis is used, all keys are prefixed with `appName` (`session:{appName}:{sessionId}`, `usersessions:{appName}:{userId}`, `oauth:{appName}:state:{state}`, `cache:{appName}:{key}`) so multiple apps sharing one Redis instance never collide.
@@ -0,0 +1,60 @@
1
+ ## Running without Redis or MongoDB
2
+
3
+ Two lightweight options for local dev, tests, or small projects with no external services:
4
+
5
+ ### SQLite — persisted to disk
6
+
7
+ Uses `bun:sqlite` (built into Bun, zero npm deps). A single `.db` file holds all users, sessions, OAuth state, and cache.
8
+
9
+ ```ts
10
+ await createServer({
11
+ routesDir: import.meta.dir + "/routes",
12
+ app: { name: "My App", version: "1.0.0" },
13
+ db: {
14
+ auth: "sqlite",
15
+ sqlite: import.meta.dir + "/../data.db", // created automatically on first run
16
+ mongo: false,
17
+ redis: false,
18
+ sessions: "sqlite",
19
+ cache: "sqlite",
20
+ },
21
+ });
22
+ ```
23
+
24
+ #### Optional: periodic cleanup of expired rows
25
+
26
+ Expired rows are filtered out lazily on read. For long-running servers, sweep them periodically:
27
+
28
+ ```ts
29
+ import { startSqliteCleanup } from "@lastshotlabs/bunshot";
30
+
31
+ startSqliteCleanup(); // default: every hour
32
+ startSqliteCleanup(5 * 60_000); // custom interval (ms)
33
+ ```
34
+
35
+ ### Memory — ephemeral, great for tests
36
+
37
+ Pure in-memory Maps. No files, no external services. All state is lost on process restart.
38
+
39
+ ```ts
40
+ import { createServer, clearMemoryStore } from "@lastshotlabs/bunshot";
41
+
42
+ await createServer({
43
+ routesDir: import.meta.dir + "/routes",
44
+ app: { name: "My App", version: "1.0.0" },
45
+ db: {
46
+ auth: "memory",
47
+ mongo: false,
48
+ redis: false,
49
+ sessions: "memory",
50
+ cache: "memory",
51
+ },
52
+ });
53
+
54
+ // In tests — reset all state between test cases:
55
+ clearMemoryStore();
56
+ ```
57
+
58
+ ### Limitations (both sqlite and memory)
59
+
60
+ - BullMQ queues still require Redis
@@ -0,0 +1,10 @@
1
+ ## Stack
2
+
3
+ - **Runtime**: [Bun](https://bun.sh)
4
+ - **Framework**: [Hono](https://hono.dev) + [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi)
5
+ - **Docs UI**: [Scalar](https://scalar.com)
6
+ - **Data / Auth**: MongoDB, SQLite, or in-memory — configurable via `db.auth` (default: MongoDB via [Mongoose](https://mongoosejs.com))
7
+ - **Cache / Sessions**: Redis, MongoDB, SQLite, or in-memory — configurable via `db.sessions` / `db.cache` (default: Redis via [ioredis](https://github.com/redis/ioredis))
8
+ - **Auth**: JWT via [jose](https://github.com/panva/jose), HttpOnly cookies + `x-user-token` header
9
+ - **Queues**: [BullMQ](https://docs.bullmq.io) (requires Redis with `noeviction` policy)
10
+ - **Validation**: [Zod v4](https://zod.dev)
@@ -0,0 +1,100 @@
1
+ ## WebSocket
2
+
3
+ The `/ws` endpoint is mounted automatically by `createServer`. No extra setup needed.
4
+
5
+ ### Default behaviour
6
+
7
+ | What | Default |
8
+ |---|---|
9
+ | Upgrade / auth | Reads `auth-token` cookie → verifies JWT → checks session → sets `ws.data.userId` |
10
+ | `open` | Logs connection, sends `{ event: "connected", id }` |
11
+ | `message` | Handles room actions (see below), echoes everything else |
12
+ | `close` | Clears `ws.data.rooms`, logs disconnection |
13
+
14
+ ### Socket data (`SocketData`)
15
+
16
+ `SocketData` is generic — pass a type parameter to add your own fields:
17
+
18
+ ```ts
19
+ type SocketData<T extends object = object> = {
20
+ id: string; // unique connection ID (UUID)
21
+ userId: string | null; // null if unauthenticated
22
+ rooms: Set<string>; // rooms this socket is subscribed to
23
+ } & T;
24
+ ```
25
+
26
+ **Extending with custom fields:**
27
+
28
+ ```ts
29
+ import { createServer, type SocketData } from "@lastshotlabs/bunshot";
30
+
31
+ type MyData = { tenantId: string; role: "admin" | "user" };
32
+
33
+ await createServer<MyData>({
34
+ ws: {
35
+ upgradeHandler: async (req, server) => {
36
+ const tenantId = req.headers.get("x-tenant-id") ?? "default";
37
+ const upgraded = server.upgrade(req, {
38
+ data: { id: crypto.randomUUID(), userId: null, rooms: new Set(), tenantId, role: "user" },
39
+ });
40
+ return upgraded ? undefined : Response.json({ error: "Upgrade failed" }, { status: 400 });
41
+ },
42
+ handler: {
43
+ open(ws) {
44
+ // ws.data.tenantId and ws.data.role are fully typed
45
+ console.log(ws.data.tenantId, ws.data.role);
46
+ },
47
+ },
48
+ onRoomSubscribe(ws, room) {
49
+ return ws.data.role === "admin" || !room.startsWith("admin:");
50
+ },
51
+ },
52
+ });
53
+ ```
54
+
55
+ With no type parameter, `SocketData` defaults to `{ id, userId, rooms }` — the base shape used by the default upgrade handler.
56
+
57
+ ### Overriding the message handler
58
+
59
+ Pass `ws.handler` to `createServer` to replace the default echo. Room action handling always runs first — your handler only receives non-room messages:
60
+
61
+ ```ts
62
+ await createServer({
63
+ ws: {
64
+ handler: {
65
+ open(ws) {
66
+ ws.send(JSON.stringify({ event: "connected", id: ws.data.id }));
67
+ },
68
+ message(ws, message) {
69
+ // room subscribe/unsubscribe already handled — put your logic here
70
+ const parsed = JSON.parse(message as string);
71
+ if (parsed.action === "ping") ws.send(JSON.stringify({ event: "pong" }));
72
+ },
73
+ close(ws, code, reason) {
74
+ // ws.data.rooms already cleared
75
+ },
76
+ },
77
+ },
78
+ });
79
+ ```
80
+
81
+ You can supply any subset of `open`, `message`, `close`, `drain` — unset handlers fall back to the defaults.
82
+
83
+ ### Overriding the upgrade / auth handler
84
+
85
+ Replace the default cookie-JWT handshake entirely via `ws.upgradeHandler`. You must call `server.upgrade()` yourself and include `rooms: new Set()` in data:
86
+
87
+ ```ts
88
+ await createServer({
89
+ ws: {
90
+ upgradeHandler: async (req, server) => {
91
+ const token = req.headers.get("x-my-token");
92
+ const userId = token ? await verifyMyToken(token) : null;
93
+ const upgraded = server.upgrade(req, {
94
+ data: { id: crypto.randomUUID(), userId, rooms: new Set() },
95
+ });
96
+ return upgraded ? undefined : Response.json({ error: "Upgrade failed" }, { status: 400 });
97
+ },
98
+ },
99
+ });
100
+ ```
@@ -0,0 +1,5 @@
1
+ ## WebSocket
2
+
3
+ The `/ws` endpoint is mounted automatically by `createServer`. Default behavior: cookie-JWT auth on upgrade, room action handling, and echo for other messages.
4
+
5
+ `SocketData` carries `id`, `userId`, and `rooms` per connection. Pass a type parameter to `createServer<T>` to extend with custom fields. Override `ws.handler` (open/message/close) and `ws.upgradeHandler` for custom behavior.
@@ -0,0 +1,97 @@
1
+ ## WebSocket Rooms / Channels
2
+
3
+ Rooms are built on Bun's native pub/sub. `createServer` always intercepts room action messages first via `handleRoomActions` — so room subscribe/unsubscribe works regardless of whether you provide a custom `websocket.message`.
4
+
5
+ ### WS utilities
6
+
7
+ | Export | Description |
8
+ |---|---|
9
+ | `publish(room, data)` | Broadcast `data` to all sockets subscribed to `room` |
10
+ | `subscribe(ws, room)` | Subscribe a socket to a room and track it in `ws.data.rooms` |
11
+ | `unsubscribe(ws, room)` | Unsubscribe a socket from a room |
12
+ | `getSubscriptions(ws)` | Returns `string[]` of rooms the socket is currently in |
13
+ | `getRooms()` | Returns `string[]` of all rooms with at least one active subscriber |
14
+ | `getRoomSubscribers(room)` | Returns `string[]` of socket IDs currently subscribed to `room` |
15
+ | `handleRoomActions(ws, message, onSubscribe?)` | Parses and dispatches subscribe/unsubscribe actions. Returns `true` if the message was a room action (consumed), `false` otherwise. Pass an optional async guard as the third argument. |
16
+
17
+ ### Client → server: join or leave a room
18
+
19
+ Send a JSON message with `action: "subscribe"` or `action: "unsubscribe"`:
20
+
21
+ ```ts
22
+ ws.send(JSON.stringify({ action: "subscribe", room: "chat:general" }));
23
+ ws.send(JSON.stringify({ action: "unsubscribe", room: "chat:general" }));
24
+ ```
25
+
26
+ Server responses:
27
+
28
+ | Event | Meaning |
29
+ |---|---|
30
+ | `{ event: "subscribed", room }` | Successfully joined |
31
+ | `{ event: "unsubscribed", room }` | Successfully left |
32
+ | `{ event: "subscribe_denied", room }` | Blocked by `onRoomSubscribe` guard |
33
+
34
+ Any non-room message is passed through to your `websocket.message` handler unchanged.
35
+
36
+ ### Server → room: broadcast
37
+
38
+ ```ts
39
+ import { publish } from "@lastshotlabs/bunshot";
40
+
41
+ publish("chat:general", { text: "Hello room!", from: "system" });
42
+ ```
43
+
44
+ All sockets subscribed to `"chat:general"` receive the message. Works from anywhere — routes, workers, anywhere after `createServer` resolves.
45
+
46
+ ### Server-side: manage subscriptions in code
47
+
48
+ Use `subscribe` / `unsubscribe` anywhere you have a `ws` reference (e.g. in `ws.handler.open` to auto-join personal rooms):
49
+
50
+ ```ts
51
+ import { subscribe, unsubscribe, getSubscriptions } from "@lastshotlabs/bunshot";
52
+
53
+ await createServer({
54
+ ws: {
55
+ handler: {
56
+ open(ws) {
57
+ // auto-subscribe authenticated users to their personal room
58
+ if (ws.data.userId) subscribe(ws, `user:${ws.data.userId}`);
59
+ },
60
+ message(ws, message) {
61
+ // handleRoomActions already ran — only non-room messages reach here
62
+ const rooms = getSubscriptions(ws); // current room list
63
+ },
64
+ close(ws) {
65
+ // ws.data.rooms is cleared automatically — no cleanup needed
66
+ },
67
+ },
68
+ },
69
+ });
70
+ ```
71
+
72
+ ### Room permission guard
73
+
74
+ Pass `ws.onRoomSubscribe` to `createServer` to gate which rooms a socket can join. Return `true` to allow, `false` to deny. Uses `ws.data.userId` for auth-based checks. Can be async.
75
+
76
+ ```ts
77
+ await createServer({
78
+ ws: {
79
+ onRoomSubscribe(ws, room) {
80
+ if (!ws.data.userId) return false; // must be logged in
81
+ if (room.startsWith("admin:")) return isAdmin(ws.data.userId); // role check
82
+ if (room.startsWith("user:")) return room === `user:${ws.data.userId}`; // ownership
83
+ return true;
84
+ },
85
+ },
86
+ });
87
+
88
+ // async guard — query DB or cache
89
+ await createServer({
90
+ ws: {
91
+ onRoomSubscribe: async (ws, room) => {
92
+ const ok = await db.roomMembers.findOne({ room, userId: ws.data.userId });
93
+ return !!ok;
94
+ },
95
+ },
96
+ });
97
+ ```
@@ -0,0 +1,5 @@
1
+ ## WebSocket Rooms / Channels
2
+
3
+ Rooms are built on Bun's native pub/sub. Clients send `{ action: "subscribe", room: "chat:general" }` to join; servers broadcast via `publish("chat:general", data)`.
4
+
5
+ Utilities: `publish`, `subscribe`, `unsubscribe`, `getSubscriptions`, `getRooms`, `getRoomSubscribers`. Gate room access with `ws.onRoomSubscribe` (sync or async guard).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lastshotlabs/bunshot",
3
- "version": "0.0.13",
3
+ "version": "0.0.16",
4
4
  "description": "Batteries-included Bun + Hono API framework — auth, sessions, rate limiting, WebSocket, queues, and OpenAPI docs out of the box",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,20 +34,24 @@
34
34
  "./queue": {
35
35
  "import": "./dist/entrypoints/queue.js",
36
36
  "types": "./dist/entrypoints/queue.d.ts"
37
- }
37
+ },
38
+ "./docs/*": "./docs/sections/*"
38
39
  },
39
40
  "bin": {
40
41
  "bunshot": "./dist/cli.js"
41
42
  },
42
43
  "files": [
43
- "dist"
44
+ "dist",
45
+ "docs/sections"
44
46
  ],
45
47
  "scripts": {
46
48
  "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && bun build src/cli.ts --outdir dist --minify --target bun",
47
49
  "prepublishOnly": "bun run build",
48
50
  "release": "npm version patch && npm publish",
49
51
  "dev": "bun --watch src/index.ts",
50
- "start": "bun src/index.ts"
52
+ "start": "bun src/index.ts",
53
+ "readme": "bun docs/build-readme.ts",
54
+ "readme:npm": "bun docs/build-readme.ts npm"
51
55
  },
52
56
  "dependencies": {
53
57
  "@hono/zod-openapi": "1.2.2",
@@ -60,7 +64,8 @@
60
64
  "zod": ">=4.0 <5",
61
65
  "mongoose": ">=9.0 <10",
62
66
  "ioredis": ">=5.0 <6",
63
- "bullmq": ">=5.0 <6"
67
+ "bullmq": ">=5.0 <6",
68
+ "otpauth": ">=9.0 <10"
64
69
  },
65
70
  "peerDependenciesMeta": {
66
71
  "mongoose": {
@@ -71,17 +76,21 @@
71
76
  },
72
77
  "bullmq": {
73
78
  "optional": true
79
+ },
80
+ "otpauth": {
81
+ "optional": true
74
82
  }
75
83
  },
76
84
  "devDependencies": {
77
85
  "@types/bun": "1.3.10",
78
- "tsc-alias": "^1.8.16",
79
- "typescript": "^5.9.3",
86
+ "bullmq": "^5.70.4",
80
87
  "hono": ">=4.12",
81
- "zod": ">=4.0",
82
- "mongoose": "9.2.4",
83
88
  "ioredis": "5.10.0",
84
- "bullmq": "^5.70.4"
89
+ "mongoose": "9.2.4",
90
+ "otpauth": "^9.5.0",
91
+ "tsc-alias": "^1.8.16",
92
+ "typescript": "^5.9.3",
93
+ "zod": ">=4.0"
85
94
  },
86
95
  "publishConfig": {
87
96
  "access": "public"