@benqoder/beam 0.1.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 +21 -0
- package/README.md +1574 -0
- package/dist/DrawerFrame.d.ts +16 -0
- package/dist/DrawerFrame.d.ts.map +1 -0
- package/dist/DrawerFrame.js +12 -0
- package/dist/ModalFrame.d.ts +12 -0
- package/dist/ModalFrame.d.ts.map +1 -0
- package/dist/ModalFrame.js +8 -0
- package/dist/client.d.ts +41 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1847 -0
- package/dist/collect.d.ts +68 -0
- package/dist/collect.d.ts.map +1 -0
- package/dist/collect.js +90 -0
- package/dist/createBeam.d.ts +104 -0
- package/dist/createBeam.d.ts.map +1 -0
- package/dist/createBeam.js +421 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/render.d.ts +7 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +7 -0
- package/dist/types.d.ts +151 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/vite.d.ts +72 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +79 -0
- package/package.json +62 -0
- package/src/beam.css +288 -0
- package/src/virtual-beam.d.ts +4 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ActionHandler, ModalHandler, DrawerHandler } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Type for glob import results from import.meta.glob
|
|
4
|
+
*/
|
|
5
|
+
type GlobImport = Record<string, Record<string, unknown>>;
|
|
6
|
+
/**
|
|
7
|
+
* Collects action handlers from glob imports.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* const actions = collectActions<Env>(
|
|
12
|
+
* import.meta.glob('./actions/*.tsx', { eager: true })
|
|
13
|
+
* )
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Each file can export multiple named functions that become actions:
|
|
17
|
+
* - `./actions/demo.tsx` exports `increment`, `decrement` → `increment`, `decrement`
|
|
18
|
+
* - `./actions/cart.tsx` exports `addToCart` → `addToCart`
|
|
19
|
+
*/
|
|
20
|
+
export declare function collectActions<TEnv = object>(glob: GlobImport): Record<string, ActionHandler<TEnv>>;
|
|
21
|
+
/**
|
|
22
|
+
* Collects modal handlers from glob imports.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* const modals = collectModals<Env>(
|
|
27
|
+
* import.meta.glob('./modals/*.tsx', { eager: true })
|
|
28
|
+
* )
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export declare function collectModals<TEnv = object>(glob: GlobImport): Record<string, ModalHandler<TEnv>>;
|
|
32
|
+
/**
|
|
33
|
+
* Collects drawer handlers from glob imports.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* const drawers = collectDrawers<Env>(
|
|
38
|
+
* import.meta.glob('./drawers/*.tsx', { eager: true })
|
|
39
|
+
* )
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export declare function collectDrawers<TEnv = object>(glob: GlobImport): Record<string, DrawerHandler<TEnv>>;
|
|
43
|
+
/**
|
|
44
|
+
* Collects all handlers (actions, modals, drawers) from glob imports.
|
|
45
|
+
* This is a convenience function that collects all three at once.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* const { actions, modals, drawers } = collectHandlers<Env>({
|
|
50
|
+
* actions: import.meta.glob('./actions/*.tsx', { eager: true }),
|
|
51
|
+
* modals: import.meta.glob('./modals/*.tsx', { eager: true }),
|
|
52
|
+
* drawers: import.meta.glob('./drawers/*.tsx', { eager: true }),
|
|
53
|
+
* })
|
|
54
|
+
*
|
|
55
|
+
* export const beam = createBeam<Env>({ actions, modals, drawers })
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export declare function collectHandlers<TEnv = object>(globs: {
|
|
59
|
+
actions?: GlobImport;
|
|
60
|
+
modals?: GlobImport;
|
|
61
|
+
drawers?: GlobImport;
|
|
62
|
+
}): {
|
|
63
|
+
actions: Record<string, ActionHandler<TEnv>>;
|
|
64
|
+
modals: Record<string, ModalHandler<TEnv>>;
|
|
65
|
+
drawers: Record<string, DrawerHandler<TEnv>>;
|
|
66
|
+
};
|
|
67
|
+
export {};
|
|
68
|
+
//# sourceMappingURL=collect.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"collect.d.ts","sourceRoot":"","sources":["../src/collect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAEzE;;GAEG;AACH,KAAK,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;AAEzD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,IAAI,GAAG,MAAM,EAC1C,IAAI,EAAE,UAAU,GACf,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAarC;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,IAAI,GAAG,MAAM,EACzC,IAAI,EAAE,UAAU,GACf,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC,CAYpC;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,IAAI,GAAG,MAAM,EAC1C,IAAI,EAAE,UAAU,GACf,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAYrC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,IAAI,GAAG,MAAM,EAAE,KAAK,EAAE;IACpD,OAAO,CAAC,EAAE,UAAU,CAAA;IACpB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,OAAO,CAAC,EAAE,UAAU,CAAA;CACrB,GAAG;IACF,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAAA;IAC5C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC,CAAA;IAC1C,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAAA;CAC7C,CAMA"}
|
package/dist/collect.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collects action handlers from glob imports.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* const actions = collectActions<Env>(
|
|
7
|
+
* import.meta.glob('./actions/*.tsx', { eager: true })
|
|
8
|
+
* )
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Each file can export multiple named functions that become actions:
|
|
12
|
+
* - `./actions/demo.tsx` exports `increment`, `decrement` → `increment`, `decrement`
|
|
13
|
+
* - `./actions/cart.tsx` exports `addToCart` → `addToCart`
|
|
14
|
+
*/
|
|
15
|
+
export function collectActions(glob) {
|
|
16
|
+
const handlers = {};
|
|
17
|
+
for (const [, module] of Object.entries(glob)) {
|
|
18
|
+
for (const [exportName, exportValue] of Object.entries(module)) {
|
|
19
|
+
// Skip non-function exports and default exports
|
|
20
|
+
if (typeof exportValue === 'function' && exportName !== 'default') {
|
|
21
|
+
handlers[exportName] = exportValue;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return handlers;
|
|
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
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { RpcTarget } from 'capnweb';
|
|
2
|
+
import type { ActionHandler, ActionResponse, ModalHandler, DrawerHandler, BeamConfig, BeamInstance, BeamContext, BeamSession } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Session implementation using KV storage.
|
|
5
|
+
* Exported for users who need custom storage adapter.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* // app/session-storage.ts
|
|
10
|
+
* import { KVSession } from '@benqoder/beam'
|
|
11
|
+
* export default (sessionId: string, env: Env) => new KVSession(sessionId, env.KV)
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export declare class KVSession implements BeamSession {
|
|
15
|
+
private sessionId;
|
|
16
|
+
private kv;
|
|
17
|
+
constructor(sessionId: string, kv: KVNamespace);
|
|
18
|
+
private key;
|
|
19
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
20
|
+
set<T = unknown>(key: string, value: T): Promise<void>;
|
|
21
|
+
delete(key: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Cookie-based session storage (default).
|
|
25
|
+
* Stores all session data in a signed cookie (~4KB limit).
|
|
26
|
+
* Good for: cart, preferences, small state.
|
|
27
|
+
* For larger data, use KVSession or custom storage.
|
|
28
|
+
*/
|
|
29
|
+
export declare class CookieSession implements BeamSession {
|
|
30
|
+
private data;
|
|
31
|
+
private _dirty;
|
|
32
|
+
constructor(initialData?: Record<string, unknown>);
|
|
33
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
34
|
+
set<T = unknown>(key: string, value: T): Promise<void>;
|
|
35
|
+
delete(key: string): Promise<void>;
|
|
36
|
+
/** Check if session data has been modified */
|
|
37
|
+
isDirty(): boolean;
|
|
38
|
+
/** Get all session data (for serializing to cookie) */
|
|
39
|
+
getData(): Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Beam RPC Server - extends RpcTarget for capnweb integration
|
|
43
|
+
*
|
|
44
|
+
* This enables:
|
|
45
|
+
* - Promise pipelining (multiple calls in one round-trip)
|
|
46
|
+
* - Bidirectional RPC (server can call client callbacks)
|
|
47
|
+
* - Pass-by-reference (RpcTarget objects stay on origin)
|
|
48
|
+
* - Function callbacks (pass functions over RPC)
|
|
49
|
+
*/
|
|
50
|
+
declare class BeamServer<TEnv extends object> extends RpcTarget {
|
|
51
|
+
private ctx;
|
|
52
|
+
private actions;
|
|
53
|
+
private modals;
|
|
54
|
+
private drawers;
|
|
55
|
+
constructor(ctx: BeamContext<TEnv>, actions: Record<string, ActionHandler<TEnv>>, modals: Record<string, ModalHandler<TEnv>>, drawers: Record<string, DrawerHandler<TEnv>>);
|
|
56
|
+
/**
|
|
57
|
+
* Call an action handler
|
|
58
|
+
*/
|
|
59
|
+
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
|
+
/**
|
|
69
|
+
* Register a client callback for server-initiated updates
|
|
70
|
+
* This enables bidirectional communication - server can push to client
|
|
71
|
+
*/
|
|
72
|
+
registerCallback(callback: (event: string, data: unknown) => void): void;
|
|
73
|
+
/**
|
|
74
|
+
* Notify connected client (if callback registered)
|
|
75
|
+
*/
|
|
76
|
+
notify(event: string, data: unknown): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Creates a Beam instance configured with actions, modals, and drawers.
|
|
80
|
+
* Uses capnweb for RPC, enabling promise pipelining and bidirectional calls.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```typescript
|
|
84
|
+
* // app/beam.ts
|
|
85
|
+
* import { createBeam } from '@benqoder/beam'
|
|
86
|
+
* import type { Env } from './types'
|
|
87
|
+
*
|
|
88
|
+
* export const beam = createBeam<Env>({
|
|
89
|
+
* actions: { createProduct, deleteProduct },
|
|
90
|
+
* modals: { confirmDelete },
|
|
91
|
+
* drawers: { productDetails }
|
|
92
|
+
* })
|
|
93
|
+
*
|
|
94
|
+
* // app/server.ts
|
|
95
|
+
* import { createApp } from 'honox/server'
|
|
96
|
+
* import { beam } from './beam'
|
|
97
|
+
*
|
|
98
|
+
* const app = createApp({ init: beam.init })
|
|
99
|
+
* export default app
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export declare function createBeam<TEnv extends object = object>(config: BeamConfig<TEnv>): BeamInstance<TEnv>;
|
|
103
|
+
export { BeamServer };
|
|
104
|
+
//# sourceMappingURL=createBeam.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"createBeam.d.ts","sourceRoot":"","sources":["../src/createBeam.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { getSignedCookie, setSignedCookie } from 'hono/cookie';
|
|
2
|
+
import { RpcTarget, newWorkersRpcResponse } from 'capnweb';
|
|
3
|
+
/**
|
|
4
|
+
* Session implementation using KV storage.
|
|
5
|
+
* Exported for users who need custom storage adapter.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* // app/session-storage.ts
|
|
10
|
+
* import { KVSession } from '@benqoder/beam'
|
|
11
|
+
* export default (sessionId: string, env: Env) => new KVSession(sessionId, env.KV)
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export class KVSession {
|
|
15
|
+
sessionId;
|
|
16
|
+
kv;
|
|
17
|
+
constructor(sessionId, kv) {
|
|
18
|
+
this.sessionId = sessionId;
|
|
19
|
+
this.kv = kv;
|
|
20
|
+
}
|
|
21
|
+
key(k) {
|
|
22
|
+
return `beam:${this.sessionId}:${k}`;
|
|
23
|
+
}
|
|
24
|
+
async get(key) {
|
|
25
|
+
const data = await this.kv.get(this.key(key));
|
|
26
|
+
if (!data)
|
|
27
|
+
return null;
|
|
28
|
+
return JSON.parse(data);
|
|
29
|
+
}
|
|
30
|
+
async set(key, value) {
|
|
31
|
+
await this.kv.put(this.key(key), JSON.stringify(value));
|
|
32
|
+
}
|
|
33
|
+
async delete(key) {
|
|
34
|
+
await this.kv.delete(this.key(key));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Cookie-based session storage (default).
|
|
39
|
+
* Stores all session data in a signed cookie (~4KB limit).
|
|
40
|
+
* Good for: cart, preferences, small state.
|
|
41
|
+
* For larger data, use KVSession or custom storage.
|
|
42
|
+
*/
|
|
43
|
+
export class CookieSession {
|
|
44
|
+
data;
|
|
45
|
+
_dirty = false;
|
|
46
|
+
constructor(initialData = {}) {
|
|
47
|
+
this.data = initialData;
|
|
48
|
+
}
|
|
49
|
+
async get(key) {
|
|
50
|
+
return this.data[key] ?? null;
|
|
51
|
+
}
|
|
52
|
+
async set(key, value) {
|
|
53
|
+
this.data[key] = value;
|
|
54
|
+
this._dirty = true;
|
|
55
|
+
}
|
|
56
|
+
async delete(key) {
|
|
57
|
+
delete this.data[key];
|
|
58
|
+
this._dirty = true;
|
|
59
|
+
}
|
|
60
|
+
/** Check if session data has been modified */
|
|
61
|
+
isDirty() {
|
|
62
|
+
return this._dirty;
|
|
63
|
+
}
|
|
64
|
+
/** Get all session data (for serializing to cookie) */
|
|
65
|
+
getData() {
|
|
66
|
+
return this.data;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Cookie name for session data (separate from session ID) */
|
|
70
|
+
const SESSION_DATA_COOKIE = 'beam_data';
|
|
71
|
+
/**
|
|
72
|
+
* Parse cookies from raw request (for WebSocket context)
|
|
73
|
+
*/
|
|
74
|
+
function parseCookies(request) {
|
|
75
|
+
const cookieHeader = request.headers.get('Cookie');
|
|
76
|
+
if (!cookieHeader)
|
|
77
|
+
return {};
|
|
78
|
+
return Object.fromEntries(cookieHeader.split(';').map((c) => {
|
|
79
|
+
const [key, ...val] = c.trim().split('=');
|
|
80
|
+
return [key, val.join('=')];
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Parse session ID from raw request cookies (for WebSocket context)
|
|
85
|
+
*/
|
|
86
|
+
function parseSessionFromRequest(request, cookieName) {
|
|
87
|
+
const cookies = parseCookies(request);
|
|
88
|
+
const signedValue = cookies[cookieName];
|
|
89
|
+
if (!signedValue)
|
|
90
|
+
return null;
|
|
91
|
+
// Hono signed cookie format: value.signature
|
|
92
|
+
const parts = signedValue.split('.');
|
|
93
|
+
if (parts.length !== 2)
|
|
94
|
+
return null;
|
|
95
|
+
return parts[0] || null;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse session data from raw request cookies (for WebSocket context)
|
|
99
|
+
*/
|
|
100
|
+
function parseSessionDataFromRequest(request) {
|
|
101
|
+
const cookies = parseCookies(request);
|
|
102
|
+
const signedValue = cookies[SESSION_DATA_COOKIE];
|
|
103
|
+
if (!signedValue)
|
|
104
|
+
return {};
|
|
105
|
+
// Hono signed cookie format: value.signature
|
|
106
|
+
const parts = signedValue.split('.');
|
|
107
|
+
if (parts.length !== 2)
|
|
108
|
+
return {};
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(decodeURIComponent(parts[0]));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Create a BeamContext with script() and render() helpers
|
|
118
|
+
*/
|
|
119
|
+
function createBeamContext(base) {
|
|
120
|
+
return {
|
|
121
|
+
...base,
|
|
122
|
+
script: (code) => ({ script: code }),
|
|
123
|
+
render: (html, options) => {
|
|
124
|
+
if (html instanceof Promise) {
|
|
125
|
+
return html.then((resolved) => ({ html: resolved, script: options?.script }));
|
|
126
|
+
}
|
|
127
|
+
return { html, script: options?.script };
|
|
128
|
+
},
|
|
129
|
+
redirect: (url) => ({ redirect: url }),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Beam RPC Server - extends RpcTarget for capnweb integration
|
|
134
|
+
*
|
|
135
|
+
* This enables:
|
|
136
|
+
* - Promise pipelining (multiple calls in one round-trip)
|
|
137
|
+
* - Bidirectional RPC (server can call client callbacks)
|
|
138
|
+
* - Pass-by-reference (RpcTarget objects stay on origin)
|
|
139
|
+
* - Function callbacks (pass functions over RPC)
|
|
140
|
+
*/
|
|
141
|
+
class BeamServer extends RpcTarget {
|
|
142
|
+
ctx;
|
|
143
|
+
actions;
|
|
144
|
+
modals;
|
|
145
|
+
drawers;
|
|
146
|
+
constructor(ctx, actions, modals, drawers) {
|
|
147
|
+
super();
|
|
148
|
+
this.ctx = ctx;
|
|
149
|
+
this.actions = actions;
|
|
150
|
+
this.modals = modals;
|
|
151
|
+
this.drawers = drawers;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Call an action handler
|
|
155
|
+
*/
|
|
156
|
+
async call(action, data = {}) {
|
|
157
|
+
const handler = this.actions[action];
|
|
158
|
+
if (!handler) {
|
|
159
|
+
throw new Error(`Unknown action: ${action}`);
|
|
160
|
+
}
|
|
161
|
+
const result = await handler(this.ctx, data);
|
|
162
|
+
// Normalize string responses to ActionResponse format
|
|
163
|
+
if (typeof result === 'string') {
|
|
164
|
+
return { html: result };
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
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
|
+
/**
|
|
189
|
+
* Register a client callback for server-initiated updates
|
|
190
|
+
* This enables bidirectional communication - server can push to client
|
|
191
|
+
*/
|
|
192
|
+
registerCallback(callback) {
|
|
193
|
+
// Store callback for later use by actions that need to push updates
|
|
194
|
+
;
|
|
195
|
+
this._clientCallback = callback;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Notify connected client (if callback registered)
|
|
199
|
+
*/
|
|
200
|
+
async notify(event, data) {
|
|
201
|
+
const callback = this._clientCallback;
|
|
202
|
+
if (callback) {
|
|
203
|
+
await callback(event, data);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Creates a Beam instance configured with actions, modals, and drawers.
|
|
209
|
+
* Uses capnweb for RPC, enabling promise pipelining and bidirectional calls.
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* ```typescript
|
|
213
|
+
* // app/beam.ts
|
|
214
|
+
* import { createBeam } from '@benqoder/beam'
|
|
215
|
+
* import type { Env } from './types'
|
|
216
|
+
*
|
|
217
|
+
* export const beam = createBeam<Env>({
|
|
218
|
+
* actions: { createProduct, deleteProduct },
|
|
219
|
+
* modals: { confirmDelete },
|
|
220
|
+
* drawers: { productDetails }
|
|
221
|
+
* })
|
|
222
|
+
*
|
|
223
|
+
* // app/server.ts
|
|
224
|
+
* import { createApp } from 'honox/server'
|
|
225
|
+
* import { beam } from './beam'
|
|
226
|
+
*
|
|
227
|
+
* const app = createApp({ init: beam.init })
|
|
228
|
+
* export default app
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
export function createBeam(config) {
|
|
232
|
+
const { actions, modals, drawers = {}, auth, session: sessionConfig } = config;
|
|
233
|
+
// Session defaults
|
|
234
|
+
const cookieName = sessionConfig?.cookieName ?? 'beam_sid';
|
|
235
|
+
const maxAge = sessionConfig?.maxAge ?? 365 * 24 * 60 * 60; // 1 year
|
|
236
|
+
return {
|
|
237
|
+
actions,
|
|
238
|
+
modals,
|
|
239
|
+
drawers,
|
|
240
|
+
auth,
|
|
241
|
+
/**
|
|
242
|
+
* Middleware that resolves auth, session, and sets beam context in Hono.
|
|
243
|
+
* Use this to share context between Beam and regular Hono routes.
|
|
244
|
+
*
|
|
245
|
+
* @example
|
|
246
|
+
* ```typescript
|
|
247
|
+
* const app = createApp({
|
|
248
|
+
* init(app) {
|
|
249
|
+
* app.use('*', beam.authMiddleware())
|
|
250
|
+
* beam.init(app)
|
|
251
|
+
* }
|
|
252
|
+
* })
|
|
253
|
+
*
|
|
254
|
+
* // In a route handler:
|
|
255
|
+
* app.get('/api/products', (c) => {
|
|
256
|
+
* const { user, session } = c.get('beam')
|
|
257
|
+
* const cart = await session.get('cart')
|
|
258
|
+
* })
|
|
259
|
+
* ```
|
|
260
|
+
*/
|
|
261
|
+
authMiddleware() {
|
|
262
|
+
return async (c, next) => {
|
|
263
|
+
// Resolve auth if resolver provided
|
|
264
|
+
const user = auth ? await auth(c.req.raw, c.env) : null;
|
|
265
|
+
// Resolve session
|
|
266
|
+
let sessionId = null;
|
|
267
|
+
let session;
|
|
268
|
+
let cookieSession = null;
|
|
269
|
+
if (sessionConfig) {
|
|
270
|
+
// Resolve secret from env if secretEnvKey provided, otherwise use static secret
|
|
271
|
+
const secret = sessionConfig.secretEnvKey
|
|
272
|
+
? c.env[sessionConfig.secretEnvKey]
|
|
273
|
+
: sessionConfig.secret;
|
|
274
|
+
if (!secret) {
|
|
275
|
+
throw new Error(sessionConfig.secretEnvKey
|
|
276
|
+
? `Session secret not found in env.${sessionConfig.secretEnvKey}`
|
|
277
|
+
: 'Session secret is required');
|
|
278
|
+
}
|
|
279
|
+
// Get or create session ID
|
|
280
|
+
const cookieValue = await getSignedCookie(c, secret, cookieName);
|
|
281
|
+
sessionId = typeof cookieValue === 'string' ? cookieValue : null;
|
|
282
|
+
if (!sessionId) {
|
|
283
|
+
sessionId = crypto.randomUUID();
|
|
284
|
+
await setSignedCookie(c, cookieName, sessionId, secret, {
|
|
285
|
+
maxAge,
|
|
286
|
+
httpOnly: true,
|
|
287
|
+
sameSite: 'Lax',
|
|
288
|
+
path: '/',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
// Use custom storage factory if provided, otherwise use cookie storage
|
|
292
|
+
if (sessionConfig.storageFactory) {
|
|
293
|
+
session = sessionConfig.storageFactory(sessionId, c.env);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
// Default: cookie-based session storage
|
|
297
|
+
const existingDataCookie = await getSignedCookie(c, secret, SESSION_DATA_COOKIE);
|
|
298
|
+
let existingData = {};
|
|
299
|
+
if (typeof existingDataCookie === 'string') {
|
|
300
|
+
try {
|
|
301
|
+
existingData = JSON.parse(existingDataCookie);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
existingData = {};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
cookieSession = new CookieSession(existingData);
|
|
308
|
+
session = cookieSession;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
// No session config - provide a noop session
|
|
313
|
+
session = {
|
|
314
|
+
get: async () => null,
|
|
315
|
+
set: async () => { },
|
|
316
|
+
delete: async () => { },
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
// Create context with script() and render() helpers
|
|
320
|
+
const ctx = createBeamContext({
|
|
321
|
+
env: c.env,
|
|
322
|
+
user,
|
|
323
|
+
request: c.req.raw,
|
|
324
|
+
session,
|
|
325
|
+
});
|
|
326
|
+
// Set in Hono context for use by routes
|
|
327
|
+
c.set('beam', ctx);
|
|
328
|
+
await next();
|
|
329
|
+
// If using cookie session and data was modified, save it back to cookie
|
|
330
|
+
if (cookieSession && cookieSession.isDirty() && sessionConfig) {
|
|
331
|
+
const secret = sessionConfig.secretEnvKey
|
|
332
|
+
? c.env[sessionConfig.secretEnvKey]
|
|
333
|
+
: sessionConfig.secret;
|
|
334
|
+
const dataString = JSON.stringify(cookieSession.getData());
|
|
335
|
+
await setSignedCookie(c, SESSION_DATA_COOKIE, dataString, secret, {
|
|
336
|
+
maxAge,
|
|
337
|
+
httpOnly: true,
|
|
338
|
+
sameSite: 'Lax',
|
|
339
|
+
path: '/',
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
/**
|
|
345
|
+
* Init function for HonoX createApp().
|
|
346
|
+
* Registers the WebSocket RPC endpoint using capnweb.
|
|
347
|
+
*
|
|
348
|
+
* @example
|
|
349
|
+
* ```typescript
|
|
350
|
+
* const app = createApp({
|
|
351
|
+
* init(app) {
|
|
352
|
+
* beam.init(app) // defaults to /beam
|
|
353
|
+
* beam.init(app, { endpoint: '/rpc' }) // custom endpoint
|
|
354
|
+
* }
|
|
355
|
+
* })
|
|
356
|
+
* ```
|
|
357
|
+
*/
|
|
358
|
+
init(app, options) {
|
|
359
|
+
const endpoint = options?.endpoint ?? '/beam';
|
|
360
|
+
app.get(endpoint, async (c) => {
|
|
361
|
+
const upgradeHeader = c.req.header('Upgrade');
|
|
362
|
+
if (upgradeHeader !== 'websocket') {
|
|
363
|
+
return c.text('Expected WebSocket', 426);
|
|
364
|
+
}
|
|
365
|
+
// Try to get context from middleware, otherwise resolve fresh
|
|
366
|
+
let ctx;
|
|
367
|
+
const existingCtx = c.var.beam;
|
|
368
|
+
if (existingCtx) {
|
|
369
|
+
ctx = existingCtx;
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
// Resolve auth
|
|
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);
|
|
414
|
+
// Use capnweb to handle the RPC connection
|
|
415
|
+
return newWorkersRpcResponse(c.req.raw, server);
|
|
416
|
+
});
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// Export BeamServer for advanced usage (e.g., extending with custom methods)
|
|
421
|
+
export { BeamServer };
|