@benqoder/beam 0.1.2 → 0.2.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/README.md +308 -89
- package/dist/client.d.ts +14 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +307 -129
- package/dist/collect.d.ts +1 -47
- package/dist/collect.d.ts.map +1 -1
- package/dist/collect.js +0 -64
- package/dist/createBeam.d.ts +50 -17
- package/dist/createBeam.d.ts.map +1 -1
- package/dist/createBeam.js +265 -86
- package/dist/index.d.ts +3 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -4
- package/dist/types.d.ts +83 -16
- package/dist/types.d.ts.map +1 -1
- package/dist/vite.d.ts +0 -12
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +4 -10
- package/package.json +2 -2
- package/src/beam.css +2 -0
- package/dist/DrawerFrame.d.ts +0 -16
- package/dist/DrawerFrame.d.ts.map +0 -1
- package/dist/DrawerFrame.js +0 -12
- package/dist/ModalFrame.d.ts +0 -12
- package/dist/ModalFrame.d.ts.map +0 -1
- package/dist/ModalFrame.js +0 -8
package/dist/collect.js
CHANGED
|
@@ -24,67 +24,3 @@ export function collectActions(glob) {
|
|
|
24
24
|
}
|
|
25
25
|
return handlers;
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
28
|
-
* Collects modal handlers from glob imports.
|
|
29
|
-
*
|
|
30
|
-
* @example
|
|
31
|
-
* ```typescript
|
|
32
|
-
* const modals = collectModals<Env>(
|
|
33
|
-
* import.meta.glob('./modals/*.tsx', { eager: true })
|
|
34
|
-
* )
|
|
35
|
-
* ```
|
|
36
|
-
*/
|
|
37
|
-
export function collectModals(glob) {
|
|
38
|
-
const handlers = {};
|
|
39
|
-
for (const [, module] of Object.entries(glob)) {
|
|
40
|
-
for (const [exportName, exportValue] of Object.entries(module)) {
|
|
41
|
-
if (typeof exportValue === 'function' && exportName !== 'default') {
|
|
42
|
-
handlers[exportName] = exportValue;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return handlers;
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Collects drawer handlers from glob imports.
|
|
50
|
-
*
|
|
51
|
-
* @example
|
|
52
|
-
* ```typescript
|
|
53
|
-
* const drawers = collectDrawers<Env>(
|
|
54
|
-
* import.meta.glob('./drawers/*.tsx', { eager: true })
|
|
55
|
-
* )
|
|
56
|
-
* ```
|
|
57
|
-
*/
|
|
58
|
-
export function collectDrawers(glob) {
|
|
59
|
-
const handlers = {};
|
|
60
|
-
for (const [, module] of Object.entries(glob)) {
|
|
61
|
-
for (const [exportName, exportValue] of Object.entries(module)) {
|
|
62
|
-
if (typeof exportValue === 'function' && exportName !== 'default') {
|
|
63
|
-
handlers[exportName] = exportValue;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return handlers;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Collects all handlers (actions, modals, drawers) from glob imports.
|
|
71
|
-
* This is a convenience function that collects all three at once.
|
|
72
|
-
*
|
|
73
|
-
* @example
|
|
74
|
-
* ```typescript
|
|
75
|
-
* const { actions, modals, drawers } = collectHandlers<Env>({
|
|
76
|
-
* actions: import.meta.glob('./actions/*.tsx', { eager: true }),
|
|
77
|
-
* modals: import.meta.glob('./modals/*.tsx', { eager: true }),
|
|
78
|
-
* drawers: import.meta.glob('./drawers/*.tsx', { eager: true }),
|
|
79
|
-
* })
|
|
80
|
-
*
|
|
81
|
-
* export const beam = createBeam<Env>({ actions, modals, drawers })
|
|
82
|
-
* ```
|
|
83
|
-
*/
|
|
84
|
-
export function collectHandlers(globs) {
|
|
85
|
-
return {
|
|
86
|
-
actions: globs.actions ? collectActions(globs.actions) : {},
|
|
87
|
-
modals: globs.modals ? collectModals(globs.modals) : {},
|
|
88
|
-
drawers: globs.drawers ? collectDrawers(globs.drawers) : {},
|
|
89
|
-
};
|
|
90
|
-
}
|
package/dist/createBeam.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RpcTarget } from 'capnweb';
|
|
2
|
-
import type { ActionHandler, ActionResponse,
|
|
2
|
+
import type { ActionHandler, ActionResponse, BeamConfig, BeamInstance, BeamContext, BeamSession, SessionConfig } from './types';
|
|
3
3
|
/**
|
|
4
4
|
* Session implementation using KV storage.
|
|
5
5
|
* Exported for users who need custom storage adapter.
|
|
@@ -50,21 +50,11 @@ export declare class CookieSession implements BeamSession {
|
|
|
50
50
|
declare class BeamServer<TEnv extends object> extends RpcTarget {
|
|
51
51
|
private ctx;
|
|
52
52
|
private actions;
|
|
53
|
-
|
|
54
|
-
private drawers;
|
|
55
|
-
constructor(ctx: BeamContext<TEnv>, actions: Record<string, ActionHandler<TEnv>>, modals: Record<string, ModalHandler<TEnv>>, drawers: Record<string, DrawerHandler<TEnv>>);
|
|
53
|
+
constructor(ctx: BeamContext<TEnv>, actions: Record<string, ActionHandler<TEnv>>);
|
|
56
54
|
/**
|
|
57
55
|
* Call an action handler
|
|
58
56
|
*/
|
|
59
57
|
call(action: string, data?: Record<string, unknown>): Promise<ActionResponse>;
|
|
60
|
-
/**
|
|
61
|
-
* Open a modal
|
|
62
|
-
*/
|
|
63
|
-
modal(modalId: string, data?: Record<string, unknown>): Promise<string>;
|
|
64
|
-
/**
|
|
65
|
-
* Open a drawer
|
|
66
|
-
*/
|
|
67
|
-
drawer(drawerId: string, data?: Record<string, unknown>): Promise<string>;
|
|
68
58
|
/**
|
|
69
59
|
* Register a client callback for server-initiated updates
|
|
70
60
|
* This enables bidirectional communication - server can push to client
|
|
@@ -76,7 +66,7 @@ declare class BeamServer<TEnv extends object> extends RpcTarget {
|
|
|
76
66
|
notify(event: string, data: unknown): Promise<void>;
|
|
77
67
|
}
|
|
78
68
|
/**
|
|
79
|
-
* Creates a Beam instance configured with actions
|
|
69
|
+
* Creates a Beam instance configured with actions.
|
|
80
70
|
* Uses capnweb for RPC, enabling promise pipelining and bidirectional calls.
|
|
81
71
|
*
|
|
82
72
|
* @example
|
|
@@ -86,9 +76,7 @@ declare class BeamServer<TEnv extends object> extends RpcTarget {
|
|
|
86
76
|
* import type { Env } from './types'
|
|
87
77
|
*
|
|
88
78
|
* export const beam = createBeam<Env>({
|
|
89
|
-
* actions: { createProduct, deleteProduct }
|
|
90
|
-
* modals: { confirmDelete },
|
|
91
|
-
* drawers: { productDetails }
|
|
79
|
+
* actions: { createProduct, deleteProduct, confirmDelete }
|
|
92
80
|
* })
|
|
93
81
|
*
|
|
94
82
|
* // app/server.ts
|
|
@@ -100,5 +88,50 @@ declare class BeamServer<TEnv extends object> extends RpcTarget {
|
|
|
100
88
|
* ```
|
|
101
89
|
*/
|
|
102
90
|
export declare function createBeam<TEnv extends object = object>(config: BeamConfig<TEnv>): BeamInstance<TEnv>;
|
|
103
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Public Beam RPC Server - initial unauthenticated API
|
|
93
|
+
*
|
|
94
|
+
* This follows the capnweb in-band authentication pattern:
|
|
95
|
+
* - WebSocket connections start with this unauthenticated API
|
|
96
|
+
* - Client calls authenticate(token) to get the authenticated BeamServer
|
|
97
|
+
* - This prevents Cross-Site WebSocket Hijacking (CSWSH) attacks
|
|
98
|
+
*/
|
|
99
|
+
declare class PublicBeamServer<TEnv extends object> extends RpcTarget {
|
|
100
|
+
private secret;
|
|
101
|
+
private sessionConfig;
|
|
102
|
+
private env;
|
|
103
|
+
private request;
|
|
104
|
+
private actions;
|
|
105
|
+
private auth;
|
|
106
|
+
constructor(secret: string, sessionConfig: SessionConfig<TEnv> | undefined, env: TEnv, request: Request, actions: Record<string, ActionHandler<TEnv>>, auth: ((request: Request, env: TEnv) => Promise<import('./types').BeamUser | null>) | undefined);
|
|
107
|
+
/**
|
|
108
|
+
* Authenticate with a token and return the authenticated API
|
|
109
|
+
* This is the only method available on the public API
|
|
110
|
+
*/
|
|
111
|
+
authenticate(token: string): Promise<BeamServer<TEnv>>;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Generate the auth token meta tag HTML for in-band WebSocket authentication.
|
|
115
|
+
* Call this in your layout/page to inject the token.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```tsx
|
|
119
|
+
* // app/routes/_renderer.tsx
|
|
120
|
+
* import { beamTokenMeta } from '@benqoder/beam'
|
|
121
|
+
*
|
|
122
|
+
* export default defineRenderer((c, { Layout, children }) => {
|
|
123
|
+
* const token = c.get('beamAuthToken')
|
|
124
|
+
* return (
|
|
125
|
+
* <html>
|
|
126
|
+
* <head>
|
|
127
|
+
* <RawHTML>{beamTokenMeta(token)}</RawHTML>
|
|
128
|
+
* </head>
|
|
129
|
+
* <body>{children}</body>
|
|
130
|
+
* </html>
|
|
131
|
+
* )
|
|
132
|
+
* })
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
export declare function beamTokenMeta(token: string): string;
|
|
136
|
+
export { BeamServer, PublicBeamServer };
|
|
104
137
|
//# sourceMappingURL=createBeam.d.ts.map
|
package/dist/createBeam.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createBeam.d.ts","sourceRoot":"","sources":["../src/createBeam.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,SAAS,EAAyB,MAAM,SAAS,CAAA;AAC1D,OAAO,KAAK,EACV,aAAa,EACb,cAAc,EACd,
|
|
1
|
+
{"version":3,"file":"createBeam.d.ts","sourceRoot":"","sources":["../src/createBeam.ts"],"names":[],"mappings":"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"}
|
package/dist/createBeam.js
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
import { getSignedCookie, setSignedCookie } from 'hono/cookie';
|
|
2
2
|
import { RpcTarget, newWorkersRpcResponse } from 'capnweb';
|
|
3
|
+
/** Default token lifetime: 5 minutes */
|
|
4
|
+
const DEFAULT_TOKEN_LIFETIME = 5 * 60 * 1000;
|
|
5
|
+
/**
|
|
6
|
+
* Sign an auth token payload using HMAC-SHA256
|
|
7
|
+
*/
|
|
8
|
+
async function signToken(payload, secret) {
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
const data = JSON.stringify(payload);
|
|
11
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
12
|
+
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
|
13
|
+
const sigBase64 = btoa(String.fromCharCode(...new Uint8Array(signature)));
|
|
14
|
+
return `${btoa(data)}.${sigBase64}`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Verify and decode an auth token
|
|
18
|
+
*/
|
|
19
|
+
async function verifyToken(token, secret) {
|
|
20
|
+
try {
|
|
21
|
+
const [dataBase64, sigBase64] = token.split('.');
|
|
22
|
+
if (!dataBase64 || !sigBase64)
|
|
23
|
+
return null;
|
|
24
|
+
const data = atob(dataBase64);
|
|
25
|
+
const encoder = new TextEncoder();
|
|
26
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
27
|
+
const signature = Uint8Array.from(atob(sigBase64), c => c.charCodeAt(0));
|
|
28
|
+
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
|
|
29
|
+
if (!valid)
|
|
30
|
+
return null;
|
|
31
|
+
const payload = JSON.parse(data);
|
|
32
|
+
// Check expiration
|
|
33
|
+
if (payload.exp < Date.now())
|
|
34
|
+
return null;
|
|
35
|
+
return payload;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
3
41
|
/**
|
|
4
42
|
* Session implementation using KV storage.
|
|
5
43
|
* Exported for users who need custom storage adapter.
|
|
@@ -114,19 +152,81 @@ function parseSessionDataFromRequest(request) {
|
|
|
114
152
|
}
|
|
115
153
|
}
|
|
116
154
|
/**
|
|
117
|
-
*
|
|
155
|
+
* Helper to convert JSX/Promise/string to HTML string.
|
|
156
|
+
* Handles HonoX HtmlEscapedString, Promises, and plain strings.
|
|
157
|
+
*
|
|
158
|
+
* HonoX async components have `.toString()` that returns a Promise<string>.
|
|
159
|
+
* We need to await that result as well.
|
|
160
|
+
*/
|
|
161
|
+
async function toHtml(content) {
|
|
162
|
+
// First, await if content itself is a Promise
|
|
163
|
+
const resolved = await content;
|
|
164
|
+
// Plain string - return as-is
|
|
165
|
+
if (typeof resolved === 'string')
|
|
166
|
+
return resolved;
|
|
167
|
+
// Null/undefined - return empty string
|
|
168
|
+
if (resolved == null)
|
|
169
|
+
return '';
|
|
170
|
+
// HtmlEscapedString or JSX element - call toString()
|
|
171
|
+
// For async components, toString() returns a Promise<string>
|
|
172
|
+
const maybeStringable = resolved;
|
|
173
|
+
if (typeof maybeStringable.toString === 'function') {
|
|
174
|
+
const str = await maybeStringable.toString();
|
|
175
|
+
// Ensure it's a plain string
|
|
176
|
+
return '' + str;
|
|
177
|
+
}
|
|
178
|
+
// Fallback
|
|
179
|
+
return '' + resolved;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Create a BeamContext with script(), render(), modal(), drawer() helpers
|
|
118
183
|
*/
|
|
119
184
|
function createBeamContext(base) {
|
|
120
185
|
return {
|
|
121
186
|
...base,
|
|
122
187
|
script: (code) => ({ script: code }),
|
|
123
|
-
render: (
|
|
124
|
-
|
|
125
|
-
|
|
188
|
+
render: (content, options) => {
|
|
189
|
+
// Helper to build response without undefined values
|
|
190
|
+
const buildResponse = (html) => {
|
|
191
|
+
const response = { html };
|
|
192
|
+
if (options?.script)
|
|
193
|
+
response.script = options.script;
|
|
194
|
+
if (options?.target)
|
|
195
|
+
response.target = options.target;
|
|
196
|
+
if (options?.swap)
|
|
197
|
+
response.swap = options.swap;
|
|
198
|
+
return response;
|
|
199
|
+
};
|
|
200
|
+
// Handle array of JSX/strings for multi-target rendering
|
|
201
|
+
if (Array.isArray(content)) {
|
|
202
|
+
return Promise.all(content.map(toHtml)).then(buildResponse);
|
|
126
203
|
}
|
|
127
|
-
|
|
204
|
+
// Single content - always convert via toHtml to handle HtmlEscapedString
|
|
205
|
+
return toHtml(content).then(buildResponse);
|
|
128
206
|
},
|
|
129
207
|
redirect: (url) => ({ redirect: url }),
|
|
208
|
+
modal: (html, options) => {
|
|
209
|
+
return toHtml(html).then((resolved) => {
|
|
210
|
+
const modalObj = { html: resolved };
|
|
211
|
+
if (options?.size)
|
|
212
|
+
modalObj.size = options.size;
|
|
213
|
+
if (options?.spacing !== undefined)
|
|
214
|
+
modalObj.spacing = options.spacing;
|
|
215
|
+
return { modal: modalObj };
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
drawer: (html, options) => {
|
|
219
|
+
return toHtml(html).then((resolved) => {
|
|
220
|
+
const drawerObj = { html: resolved };
|
|
221
|
+
if (options?.position)
|
|
222
|
+
drawerObj.position = options.position;
|
|
223
|
+
if (options?.size)
|
|
224
|
+
drawerObj.size = options.size;
|
|
225
|
+
if (options?.spacing !== undefined)
|
|
226
|
+
drawerObj.spacing = options.spacing;
|
|
227
|
+
return { drawer: drawerObj };
|
|
228
|
+
});
|
|
229
|
+
},
|
|
130
230
|
};
|
|
131
231
|
}
|
|
132
232
|
/**
|
|
@@ -141,14 +241,10 @@ function createBeamContext(base) {
|
|
|
141
241
|
class BeamServer extends RpcTarget {
|
|
142
242
|
ctx;
|
|
143
243
|
actions;
|
|
144
|
-
|
|
145
|
-
drawers;
|
|
146
|
-
constructor(ctx, actions, modals, drawers) {
|
|
244
|
+
constructor(ctx, actions) {
|
|
147
245
|
super();
|
|
148
246
|
this.ctx = ctx;
|
|
149
247
|
this.actions = actions;
|
|
150
|
-
this.modals = modals;
|
|
151
|
-
this.drawers = drawers;
|
|
152
248
|
}
|
|
153
249
|
/**
|
|
154
250
|
* Call an action handler
|
|
@@ -165,26 +261,6 @@ class BeamServer extends RpcTarget {
|
|
|
165
261
|
}
|
|
166
262
|
return result;
|
|
167
263
|
}
|
|
168
|
-
/**
|
|
169
|
-
* Open a modal
|
|
170
|
-
*/
|
|
171
|
-
async modal(modalId, data = {}) {
|
|
172
|
-
const handler = this.modals[modalId];
|
|
173
|
-
if (!handler) {
|
|
174
|
-
throw new Error(`Unknown modal: ${modalId}`);
|
|
175
|
-
}
|
|
176
|
-
return await handler(this.ctx, data);
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* Open a drawer
|
|
180
|
-
*/
|
|
181
|
-
async drawer(drawerId, data = {}) {
|
|
182
|
-
const handler = this.drawers[drawerId];
|
|
183
|
-
if (!handler) {
|
|
184
|
-
throw new Error(`Unknown drawer: ${drawerId}`);
|
|
185
|
-
}
|
|
186
|
-
return await handler(this.ctx, data);
|
|
187
|
-
}
|
|
188
264
|
/**
|
|
189
265
|
* Register a client callback for server-initiated updates
|
|
190
266
|
* This enables bidirectional communication - server can push to client
|
|
@@ -205,7 +281,7 @@ class BeamServer extends RpcTarget {
|
|
|
205
281
|
}
|
|
206
282
|
}
|
|
207
283
|
/**
|
|
208
|
-
* Creates a Beam instance configured with actions
|
|
284
|
+
* Creates a Beam instance configured with actions.
|
|
209
285
|
* Uses capnweb for RPC, enabling promise pipelining and bidirectional calls.
|
|
210
286
|
*
|
|
211
287
|
* @example
|
|
@@ -215,9 +291,7 @@ class BeamServer extends RpcTarget {
|
|
|
215
291
|
* import type { Env } from './types'
|
|
216
292
|
*
|
|
217
293
|
* export const beam = createBeam<Env>({
|
|
218
|
-
* actions: { createProduct, deleteProduct }
|
|
219
|
-
* modals: { confirmDelete },
|
|
220
|
-
* drawers: { productDetails }
|
|
294
|
+
* actions: { createProduct, deleteProduct, confirmDelete }
|
|
221
295
|
* })
|
|
222
296
|
*
|
|
223
297
|
* // app/server.ts
|
|
@@ -229,14 +303,12 @@ class BeamServer extends RpcTarget {
|
|
|
229
303
|
* ```
|
|
230
304
|
*/
|
|
231
305
|
export function createBeam(config) {
|
|
232
|
-
const { actions,
|
|
306
|
+
const { actions, auth, session: sessionConfig } = config;
|
|
233
307
|
// Session defaults
|
|
234
308
|
const cookieName = sessionConfig?.cookieName ?? 'beam_sid';
|
|
235
309
|
const maxAge = sessionConfig?.maxAge ?? 365 * 24 * 60 * 60; // 1 year
|
|
236
310
|
return {
|
|
237
311
|
actions,
|
|
238
|
-
modals,
|
|
239
|
-
drawers,
|
|
240
312
|
auth,
|
|
241
313
|
/**
|
|
242
314
|
* Middleware that resolves auth, session, and sets beam context in Hono.
|
|
@@ -323,8 +395,22 @@ export function createBeam(config) {
|
|
|
323
395
|
request: c.req.raw,
|
|
324
396
|
session,
|
|
325
397
|
});
|
|
398
|
+
// Generate auth token for in-band WebSocket authentication
|
|
399
|
+
const secret = sessionConfig?.secretEnvKey
|
|
400
|
+
? c.env[sessionConfig.secretEnvKey]
|
|
401
|
+
: sessionConfig?.secret;
|
|
402
|
+
let authToken = '';
|
|
403
|
+
if (secret && sessionId) {
|
|
404
|
+
const tokenPayload = {
|
|
405
|
+
sid: sessionId,
|
|
406
|
+
uid: user?.id ?? null,
|
|
407
|
+
exp: Date.now() + DEFAULT_TOKEN_LIFETIME,
|
|
408
|
+
};
|
|
409
|
+
authToken = await signToken(tokenPayload, secret);
|
|
410
|
+
}
|
|
326
411
|
// Set in Hono context for use by routes
|
|
327
412
|
c.set('beam', ctx);
|
|
413
|
+
c.set('beamAuthToken', authToken);
|
|
328
414
|
await next();
|
|
329
415
|
// If using cookie session and data was modified, save it back to cookie
|
|
330
416
|
if (cookieSession && cookieSession.isDirty() && sessionConfig) {
|
|
@@ -341,10 +427,46 @@ export function createBeam(config) {
|
|
|
341
427
|
}
|
|
342
428
|
};
|
|
343
429
|
},
|
|
430
|
+
/**
|
|
431
|
+
* Generate a short-lived auth token for in-band WebSocket authentication.
|
|
432
|
+
* Use this when you need to generate a token outside of the authMiddleware.
|
|
433
|
+
*
|
|
434
|
+
* @example
|
|
435
|
+
* ```typescript
|
|
436
|
+
* const token = await beam.generateAuthToken(ctx)
|
|
437
|
+
* // Embed in page: <meta name="beam-token" content="${token}">
|
|
438
|
+
* ```
|
|
439
|
+
*/
|
|
440
|
+
async generateAuthToken(ctx) {
|
|
441
|
+
if (!sessionConfig) {
|
|
442
|
+
throw new Error('Session config is required for auth token generation');
|
|
443
|
+
}
|
|
444
|
+
const secret = sessionConfig.secretEnvKey
|
|
445
|
+
? ctx.env[sessionConfig.secretEnvKey]
|
|
446
|
+
: sessionConfig.secret;
|
|
447
|
+
if (!secret) {
|
|
448
|
+
throw new Error('Session secret is required for auth token generation');
|
|
449
|
+
}
|
|
450
|
+
// Get session ID from request cookies
|
|
451
|
+
const sessionId = parseSessionFromRequest(ctx.request, cookieName);
|
|
452
|
+
if (!sessionId) {
|
|
453
|
+
throw new Error('No session found - ensure authMiddleware is used');
|
|
454
|
+
}
|
|
455
|
+
const tokenPayload = {
|
|
456
|
+
sid: sessionId,
|
|
457
|
+
uid: ctx.user?.id ?? null,
|
|
458
|
+
exp: Date.now() + DEFAULT_TOKEN_LIFETIME,
|
|
459
|
+
};
|
|
460
|
+
return signToken(tokenPayload, secret);
|
|
461
|
+
},
|
|
344
462
|
/**
|
|
345
463
|
* Init function for HonoX createApp().
|
|
346
464
|
* Registers the WebSocket RPC endpoint using capnweb.
|
|
347
465
|
*
|
|
466
|
+
* SECURITY: Uses in-band authentication pattern to prevent CSWSH attacks.
|
|
467
|
+
* WebSocket connections start unauthenticated, client must call authenticate(token)
|
|
468
|
+
* with a valid token obtained from a same-origin page request.
|
|
469
|
+
*
|
|
348
470
|
* @example
|
|
349
471
|
* ```typescript
|
|
350
472
|
* const app = createApp({
|
|
@@ -362,60 +484,117 @@ export function createBeam(config) {
|
|
|
362
484
|
if (upgradeHeader !== 'websocket') {
|
|
363
485
|
return c.text('Expected WebSocket', 426);
|
|
364
486
|
}
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
487
|
+
// Get the session secret for token verification
|
|
488
|
+
const secret = sessionConfig?.secretEnvKey
|
|
489
|
+
? c.env[sessionConfig.secretEnvKey]
|
|
490
|
+
: sessionConfig?.secret;
|
|
491
|
+
if (!secret) {
|
|
492
|
+
return c.text('Session secret is required for secure WebSocket connections', 500);
|
|
370
493
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const user = auth ? await auth(c.req.raw, c.env) : null;
|
|
374
|
-
// Resolve session for WebSocket (cookie storage is read-only in WebSocket context)
|
|
375
|
-
let session;
|
|
376
|
-
if (sessionConfig) {
|
|
377
|
-
const sessionId = parseSessionFromRequest(c.req.raw, cookieName);
|
|
378
|
-
if (sessionId) {
|
|
379
|
-
// Use custom storage factory if provided
|
|
380
|
-
if (sessionConfig.storageFactory) {
|
|
381
|
-
session = sessionConfig.storageFactory(sessionId, c.env);
|
|
382
|
-
}
|
|
383
|
-
else {
|
|
384
|
-
// Default: cookie-based session (read-only - can't set cookies in WebSocket)
|
|
385
|
-
const existingData = parseSessionDataFromRequest(c.req.raw);
|
|
386
|
-
session = new CookieSession(existingData);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
else {
|
|
390
|
-
// No session cookie - provide noop (rare edge case)
|
|
391
|
-
session = {
|
|
392
|
-
get: async () => null,
|
|
393
|
-
set: async () => { },
|
|
394
|
-
delete: async () => { },
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
else {
|
|
399
|
-
session = {
|
|
400
|
-
get: async () => null,
|
|
401
|
-
set: async () => { },
|
|
402
|
-
delete: async () => { },
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
ctx = createBeamContext({
|
|
406
|
-
env: c.env,
|
|
407
|
-
user,
|
|
408
|
-
request: c.req.raw,
|
|
409
|
-
session,
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
// Create BeamServer instance with capnweb RpcTarget
|
|
413
|
-
const server = new BeamServer(ctx, actions, modals, drawers);
|
|
494
|
+
// Create PublicBeamServer - client must authenticate to get full API
|
|
495
|
+
const server = new PublicBeamServer(secret, sessionConfig, c.env, c.req.raw, actions, auth);
|
|
414
496
|
// Use capnweb to handle the RPC connection
|
|
415
497
|
return newWorkersRpcResponse(c.req.raw, server);
|
|
416
498
|
});
|
|
417
499
|
},
|
|
418
500
|
};
|
|
419
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* Public Beam RPC Server - initial unauthenticated API
|
|
504
|
+
*
|
|
505
|
+
* This follows the capnweb in-band authentication pattern:
|
|
506
|
+
* - WebSocket connections start with this unauthenticated API
|
|
507
|
+
* - Client calls authenticate(token) to get the authenticated BeamServer
|
|
508
|
+
* - This prevents Cross-Site WebSocket Hijacking (CSWSH) attacks
|
|
509
|
+
*/
|
|
510
|
+
class PublicBeamServer extends RpcTarget {
|
|
511
|
+
secret;
|
|
512
|
+
sessionConfig;
|
|
513
|
+
env;
|
|
514
|
+
request;
|
|
515
|
+
actions;
|
|
516
|
+
auth;
|
|
517
|
+
constructor(secret, sessionConfig, env, request, actions, auth) {
|
|
518
|
+
super();
|
|
519
|
+
this.secret = secret;
|
|
520
|
+
this.sessionConfig = sessionConfig;
|
|
521
|
+
this.env = env;
|
|
522
|
+
this.request = request;
|
|
523
|
+
this.actions = actions;
|
|
524
|
+
this.auth = auth;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Authenticate with a token and return the authenticated API
|
|
528
|
+
* This is the only method available on the public API
|
|
529
|
+
*/
|
|
530
|
+
async authenticate(token) {
|
|
531
|
+
// Verify the token
|
|
532
|
+
const payload = await verifyToken(token, this.secret);
|
|
533
|
+
if (!payload) {
|
|
534
|
+
throw new Error('Invalid or expired auth token');
|
|
535
|
+
}
|
|
536
|
+
// Resolve auth (user info is embedded in token, but we re-resolve for fresh data)
|
|
537
|
+
const user = this.auth ? await this.auth(this.request, this.env) : null;
|
|
538
|
+
// Resolve session
|
|
539
|
+
let session;
|
|
540
|
+
if (this.sessionConfig) {
|
|
541
|
+
const cookieName = this.sessionConfig.cookieName ?? 'beam_sid';
|
|
542
|
+
const sessionId = parseSessionFromRequest(this.request, cookieName);
|
|
543
|
+
// Verify session ID matches token
|
|
544
|
+
if (sessionId !== payload.sid) {
|
|
545
|
+
throw new Error('Session mismatch');
|
|
546
|
+
}
|
|
547
|
+
if (sessionId && this.sessionConfig.storageFactory) {
|
|
548
|
+
session = this.sessionConfig.storageFactory(sessionId, this.env);
|
|
549
|
+
}
|
|
550
|
+
else if (sessionId) {
|
|
551
|
+
const existingData = parseSessionDataFromRequest(this.request);
|
|
552
|
+
session = new CookieSession(existingData);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
session = { get: async () => null, set: async () => { }, delete: async () => { } };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
session = { get: async () => null, set: async () => { }, delete: async () => { } };
|
|
560
|
+
}
|
|
561
|
+
// Create authenticated context
|
|
562
|
+
const ctx = createBeamContext({
|
|
563
|
+
env: this.env,
|
|
564
|
+
user,
|
|
565
|
+
request: this.request,
|
|
566
|
+
session,
|
|
567
|
+
});
|
|
568
|
+
// Return the authenticated BeamServer
|
|
569
|
+
return new BeamServer(ctx, this.actions);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Generate the auth token meta tag HTML for in-band WebSocket authentication.
|
|
574
|
+
* Call this in your layout/page to inject the token.
|
|
575
|
+
*
|
|
576
|
+
* @example
|
|
577
|
+
* ```tsx
|
|
578
|
+
* // app/routes/_renderer.tsx
|
|
579
|
+
* import { beamTokenMeta } from '@benqoder/beam'
|
|
580
|
+
*
|
|
581
|
+
* export default defineRenderer((c, { Layout, children }) => {
|
|
582
|
+
* const token = c.get('beamAuthToken')
|
|
583
|
+
* return (
|
|
584
|
+
* <html>
|
|
585
|
+
* <head>
|
|
586
|
+
* <RawHTML>{beamTokenMeta(token)}</RawHTML>
|
|
587
|
+
* </head>
|
|
588
|
+
* <body>{children}</body>
|
|
589
|
+
* </html>
|
|
590
|
+
* )
|
|
591
|
+
* })
|
|
592
|
+
* ```
|
|
593
|
+
*/
|
|
594
|
+
export function beamTokenMeta(token) {
|
|
595
|
+
// Escape any quotes in the token for safe HTML embedding
|
|
596
|
+
const escapedToken = token.replace(/"/g, '"');
|
|
597
|
+
return `<meta name="beam-token" content="${escapedToken}">`;
|
|
598
|
+
}
|
|
420
599
|
// Export BeamServer for advanced usage (e.g., extending with custom methods)
|
|
421
|
-
export { BeamServer };
|
|
600
|
+
export { BeamServer, PublicBeamServer };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
export { createBeam, KVSession, CookieSession } from './createBeam';
|
|
1
|
+
export { createBeam, KVSession, CookieSession, beamTokenMeta } from './createBeam';
|
|
2
2
|
export { render } from './render';
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export { collectActions, collectModals, collectDrawers, collectHandlers, } from './collect';
|
|
6
|
-
export type { ActionHandler, ModalHandler, DrawerHandler, BeamConfig, BeamInstance, BeamInitOptions, BeamUser, BeamContext, BeamVariables, AuthResolver, BeamSession, SessionConfig, SessionStorageFactory, } from './types';
|
|
3
|
+
export { collectActions, } from './collect';
|
|
4
|
+
export type { ActionHandler, ActionResponse, ModalOptions, DrawerOptions, BeamConfig, BeamInstance, BeamInitOptions, BeamUser, BeamContext, BeamVariables, AuthResolver, BeamSession, SessionConfig, SessionStorageFactory, AuthTokenPayload, } from './types';
|
|
7
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,aAAa,EAAE,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAClF,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAGjC,OAAO,EACL,cAAc,GACf,MAAM,WAAW,CAAA;AAGlB,YAAY,EACV,aAAa,EACb,cAAc,EACd,YAAY,EACZ,aAAa,EACb,UAAU,EACV,YAAY,EACZ,eAAe,EACf,QAAQ,EACR,WAAW,EACX,aAAa,EACb,YAAY,EACZ,WAAW,EACX,aAAa,EACb,qBAAqB,EACrB,gBAAgB,GACjB,MAAM,SAAS,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
// Main server-side exports for @benqoder/beam
|
|
2
|
-
export { createBeam, KVSession, CookieSession } from './createBeam';
|
|
2
|
+
export { createBeam, KVSession, CookieSession, beamTokenMeta } from './createBeam';
|
|
3
3
|
export { render } from './render';
|
|
4
|
-
export { ModalFrame } from './ModalFrame';
|
|
5
|
-
export { DrawerFrame } from './DrawerFrame';
|
|
6
4
|
// Auto-discovery utilities
|
|
7
|
-
export { collectActions,
|
|
5
|
+
export { collectActions, } from './collect';
|