blumenjs 0.2.4 → 0.2.6

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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Blumen Server Actions
3
+ *
4
+ * Define server-side functions that can be called directly from client
5
+ * components without writing API routes. Actions run on the server (Node)
6
+ * and are routed through Go.
7
+ *
8
+ * @example
9
+ * // app/actions/auth.ts
10
+ * import { action } from "@/shared/serverAction";
11
+ *
12
+ * export const loginAction = action("login", async (data: { email: string; password: string }) => {
13
+ * const user = await db.users.findByEmail(data.email);
14
+ * if (!user || !verifyPassword(data.password, user.hash)) {
15
+ * return { success: false, error: "Invalid credentials" };
16
+ * }
17
+ * return { success: true, user: { id: user.id, name: user.name } };
18
+ * });
19
+ */
20
+
21
+ // ── Types ──────────────────────────────────────────────────────
22
+
23
+ export interface ActionResult<T = any> {
24
+ success: boolean;
25
+ data?: T;
26
+ error?: string;
27
+ }
28
+
29
+ export interface ActionDefinition<TInput = any, TOutput = any> {
30
+ /** Unique action name */
31
+ name: string;
32
+ /** The server-side handler */
33
+ handler: (input: TInput) => Promise<TOutput>;
34
+ }
35
+
36
+ // ── Server-Side: Action Registry ───────────────────────────────
37
+
38
+ // Global registry of all defined actions (server-side only)
39
+ const actionRegistry = new Map<string, (input: any) => Promise<any>>();
40
+
41
+ /**
42
+ * Define a server action. This function registers the handler on the server
43
+ * and returns a callable reference for use with useServerAction on the client.
44
+ */
45
+ export function action<TInput, TOutput>(
46
+ name: string,
47
+ handler: (input: TInput) => Promise<TOutput>,
48
+ ): ActionDefinition<TInput, TOutput> {
49
+ actionRegistry.set(name, handler);
50
+ return { name, handler };
51
+ }
52
+
53
+ /**
54
+ * Execute a registered action by name (called by the Node SSR server).
55
+ */
56
+ export async function executeAction(
57
+ name: string,
58
+ input: any,
59
+ ): Promise<ActionResult> {
60
+ const handler = actionRegistry.get(name);
61
+ if (!handler) {
62
+ return { success: false, error: `Action "${name}" not found` };
63
+ }
64
+
65
+ try {
66
+ const result = await handler(input);
67
+ return { success: true, data: result };
68
+ } catch (err: any) {
69
+ return {
70
+ success: false,
71
+ error:
72
+ process.env.NODE_ENV === "development"
73
+ ? err.message || String(err)
74
+ : "Server action failed",
75
+ };
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Get all registered action names (for debugging/monitoring).
81
+ */
82
+ export function getRegisteredActions(): string[] {
83
+ return Array.from(actionRegistry.keys());
84
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Blumen Framework Types
3
+ *
4
+ * Shared type definitions for the BlumenJS framework.
5
+ * These types are used by both page components and the SSR engine.
6
+ */
7
+
8
+ /**
9
+ * Context object passed to getServerProps.
10
+ * Contains everything the server knows about the incoming request.
11
+ */
12
+ export interface BlumenContext {
13
+ /** Dynamic route parameters (e.g. { id: "42" } for /users/[id]) */
14
+ params: Record<string, string>;
15
+ /** URL query string parameters */
16
+ query: Record<string, string[]>;
17
+ /** The request URL path (e.g. "/users/42") */
18
+ path: string;
19
+ /** A subset of request headers (forwarded from Go) */
20
+ headers?: Record<string, string>;
21
+ }
22
+
23
+ /**
24
+ * Return type for getServerProps.
25
+ * Must return an object with a `props` key containing the page props.
26
+ * Optionally include `revalidate` to enable page caching (in seconds).
27
+ */
28
+ export interface ServerPropsResult {
29
+ props: Record<string, any>;
30
+ /** Cache TTL in seconds. The page will be cached and served from Go memory.
31
+ * After this time, stale content is served while Go revalidates in the background.
32
+ * Set to 0 or omit to disable caching for this page. */
33
+ revalidate?: number;
34
+ }
35
+
36
+ /**
37
+ * The getServerProps function signature.
38
+ *
39
+ * Export this from any page component to fetch data on the server
40
+ * before the page is rendered. The returned props are merged into
41
+ * the page component's props.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * // app/pages/users/[id].tsx
46
+ * import type { BlumenContext, ServerPropsResult } from '../../shared/types';
47
+ *
48
+ * export async function getServerProps(ctx: BlumenContext): Promise<ServerPropsResult> {
49
+ * const user = await fetch(`https://api.example.com/users/${ctx.params.id}`);
50
+ * return { props: { user: await user.json() } };
51
+ * }
52
+ *
53
+ * export default function UserPage({ user }) {
54
+ * return <h1>{user.name}</h1>;
55
+ * }
56
+ * ```
57
+ */
58
+ export type GetServerProps = (
59
+ ctx: BlumenContext,
60
+ ) => Promise<ServerPropsResult> | ServerPropsResult;
61
+
62
+ /**
63
+ * Metadata object for per-page SEO and `<head>` tag management.
64
+ *
65
+ * Export a `metadata` constant from any page to set static head tags,
66
+ * or export a `generateMetadata(ctx)` function for dynamic metadata
67
+ * based on route params, query strings, or fetched data.
68
+ *
69
+ * @example
70
+ * ```tsx
71
+ * // Static metadata
72
+ * export const metadata: BlumenMetadata = {
73
+ * title: 'About Us | My App',
74
+ * description: 'Learn about our team and mission.',
75
+ * openGraph: { image: '/static/og-about.jpg' },
76
+ * };
77
+ * ```
78
+ *
79
+ * @example
80
+ * ```tsx
81
+ * // Dynamic metadata
82
+ * export function generateMetadata(ctx: BlumenContext): BlumenMetadata {
83
+ * return {
84
+ * title: `User ${ctx.params.id} | My App`,
85
+ * description: `Profile page for user ${ctx.params.id}`,
86
+ * };
87
+ * }
88
+ * ```
89
+ */
90
+ export interface BlumenMetadata {
91
+ /** Page title — rendered as `<title>` */
92
+ title?: string;
93
+ /** Page description — `<meta name="description">` */
94
+ description?: string;
95
+ /** Keywords — `<meta name="keywords">` */
96
+ keywords?: string[];
97
+ /** Robots directive — `<meta name="robots">` (e.g. "noindex, nofollow") */
98
+ robots?: string;
99
+ /** Canonical URL — `<link rel="canonical">` */
100
+ canonical?: string;
101
+ /** Open Graph metadata for social sharing (Facebook, LinkedIn, etc.) */
102
+ openGraph?: {
103
+ title?: string;
104
+ description?: string;
105
+ image?: string;
106
+ url?: string;
107
+ type?: string;
108
+ siteName?: string;
109
+ };
110
+ /** Twitter Card metadata */
111
+ twitter?: {
112
+ card?: "summary" | "summary_large_image" | "app" | "player";
113
+ title?: string;
114
+ description?: string;
115
+ image?: string;
116
+ creator?: string;
117
+ site?: string;
118
+ };
119
+ /** Escape hatch: arbitrary `<meta name="key" content="value">` tags */
120
+ other?: Record<string, string>;
121
+ }
122
+
123
+ /**
124
+ * Function signature for dynamic metadata generation.
125
+ *
126
+ * Export this from a page to generate metadata based on the request context.
127
+ * Receives the same `BlumenContext` as `getServerProps`.
128
+ */
129
+ export type GenerateMetadata = (
130
+ ctx: BlumenContext,
131
+ ) => Promise<BlumenMetadata> | BlumenMetadata;
132
+
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Blumen useServerAction Hook
3
+ *
4
+ * Call server actions directly from React components without writing
5
+ * API routes. Actions run on the server and return typed results.
6
+ *
7
+ * Includes built-in CSRF protection via double-submit cookie pattern.
8
+ *
9
+ * @example
10
+ * import { useServerAction } from "@/shared/useServerAction";
11
+ *
12
+ * function LoginForm() {
13
+ * const { execute, isPending, error, data } = useServerAction("login");
14
+ *
15
+ * const handleSubmit = async (e: React.FormEvent) => {
16
+ * e.preventDefault();
17
+ * const result = await execute({ email, password });
18
+ * if (result.success) router.push("/dashboard");
19
+ * };
20
+ *
21
+ * return (
22
+ * <form onSubmit={handleSubmit}>
23
+ * <input name="email" />
24
+ * <input name="password" type="password" />
25
+ * <button disabled={isPending}>
26
+ * {isPending ? "Logging in..." : "Login"}
27
+ * </button>
28
+ * {error && <p>{error}</p>}
29
+ * </form>
30
+ * );
31
+ * }
32
+ */
33
+
34
+ import { useState, useCallback, useRef, useEffect } from "react";
35
+
36
+ // ── Types ──────────────────────────────────────────────────────
37
+
38
+ export interface ActionResult<T = any> {
39
+ success: boolean;
40
+ data?: T;
41
+ error?: string;
42
+ }
43
+
44
+ export interface UseServerActionOptions {
45
+ /** Called on successful execution */
46
+ onSuccess?: (data: any) => void;
47
+ /** Called on error */
48
+ onError?: (error: string) => void;
49
+ }
50
+
51
+ export interface UseServerActionReturn<TInput = any, TOutput = any> {
52
+ /** Execute the server action */
53
+ execute: (input?: TInput) => Promise<ActionResult<TOutput>>;
54
+ /** Whether the action is currently executing */
55
+ isPending: boolean;
56
+ /** The last error message */
57
+ error: string | null;
58
+ /** The last successful result data */
59
+ data: TOutput | null;
60
+ /** Reset the state */
61
+ reset: () => void;
62
+ }
63
+
64
+ // ── CSRF Token ─────────────────────────────────────────────────
65
+
66
+ let csrfToken: string | null = null;
67
+
68
+ function getCSRFToken(): string {
69
+ if (csrfToken) return csrfToken;
70
+
71
+ // Generate a random CSRF token
72
+ if (typeof window !== "undefined" && window.crypto) {
73
+ const array = new Uint8Array(32);
74
+ window.crypto.getRandomValues(array);
75
+ csrfToken = Array.from(array, (b) => b.toString(16).padStart(2, "0")).join(
76
+ "",
77
+ );
78
+ } else {
79
+ csrfToken = Math.random().toString(36).slice(2) + Date.now().toString(36);
80
+ }
81
+
82
+ // Set as a cookie so the server can validate (double-submit pattern)
83
+ if (typeof document !== "undefined") {
84
+ document.cookie = `_blumen_csrf=${csrfToken}; path=/; SameSite=Strict`;
85
+ }
86
+
87
+ return csrfToken;
88
+ }
89
+
90
+ // ── Hook ───────────────────────────────────────────────────────
91
+
92
+ export function useServerAction<TInput = any, TOutput = any>(
93
+ actionName: string,
94
+ options: UseServerActionOptions = {},
95
+ ): UseServerActionReturn<TInput, TOutput> {
96
+ const { onSuccess, onError } = options;
97
+
98
+ const [isPending, setIsPending] = useState(false);
99
+ const [error, setError] = useState<string | null>(null);
100
+ const [data, setData] = useState<TOutput | null>(null);
101
+ const mountedRef = useRef(true);
102
+
103
+ useEffect(() => {
104
+ mountedRef.current = true;
105
+ return () => {
106
+ mountedRef.current = false;
107
+ };
108
+ }, []);
109
+
110
+ const execute = useCallback(
111
+ async (input?: TInput): Promise<ActionResult<TOutput>> => {
112
+ setIsPending(true);
113
+ setError(null);
114
+
115
+ try {
116
+ const token = getCSRFToken();
117
+
118
+ const response = await fetch("/_blumen/action", {
119
+ method: "POST",
120
+ headers: {
121
+ "Content-Type": "application/json",
122
+ "X-CSRF-Token": token,
123
+ },
124
+ body: JSON.stringify({
125
+ action: actionName,
126
+ input: input || {},
127
+ }),
128
+ credentials: "same-origin",
129
+ });
130
+
131
+ if (!response.ok) {
132
+ const errText = await response.text();
133
+ throw new Error(errText || `Action failed: ${response.status}`);
134
+ }
135
+
136
+ const result: ActionResult<TOutput> = await response.json();
137
+
138
+ if (mountedRef.current) {
139
+ if (result.success) {
140
+ setData(result.data || null);
141
+ setError(null);
142
+ onSuccess?.(result.data);
143
+ } else {
144
+ setError(result.error || "Action failed");
145
+ onError?.(result.error || "Action failed");
146
+ }
147
+ setIsPending(false);
148
+ }
149
+
150
+ return result;
151
+ } catch (err: any) {
152
+ const errorMsg = err.message || "Network error";
153
+ if (mountedRef.current) {
154
+ setError(errorMsg);
155
+ setIsPending(false);
156
+ onError?.(errorMsg);
157
+ }
158
+ return { success: false, error: errorMsg };
159
+ }
160
+ },
161
+ [actionName, onSuccess, onError],
162
+ );
163
+
164
+ const reset = useCallback(() => {
165
+ setIsPending(false);
166
+ setError(null);
167
+ setData(null);
168
+ }, []);
169
+
170
+ return { execute, isPending, error, data, reset };
171
+ }
172
+
173
+ export default useServerAction;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Blumen useWebSocket Hook
3
+ *
4
+ * Connects to the Go-powered WebSocket server at /_blumen/ws.
5
+ * Provides send/receive functions, room management, and auto-reconnect.
6
+ *
7
+ * Go handles each connection in a goroutine — 100K+ concurrent connections
8
+ * with minimal memory. This is Blumen's real-time superpower.
9
+ *
10
+ * @example
11
+ * const { send, lastMessage, isConnected } = useWebSocket();
12
+ * send({ type: "chat", payload: { text: "Hello!" } });
13
+ */
14
+
15
+ import { useState, useEffect, useRef, useCallback } from "react";
16
+
17
+ // ── Types ──────────────────────────────────────────────────────
18
+
19
+ export interface WSMessage {
20
+ type: string;
21
+ room?: string;
22
+ payload?: any;
23
+ sender?: string;
24
+ }
25
+
26
+ export interface UseWebSocketOptions {
27
+ /** Auto-connect on mount. Defaults to true. */
28
+ autoConnect?: boolean;
29
+ /** Auto-reconnect on disconnect. Defaults to true. */
30
+ reconnect?: boolean;
31
+ /** Reconnect delay in ms. Defaults to 1000. */
32
+ reconnectDelay?: number;
33
+ /** Max reconnect attempts. Defaults to 10. */
34
+ maxReconnectAttempts?: number;
35
+ /** Filter messages by type. Only messages matching these types are stored in lastMessage. */
36
+ messageTypes?: string[];
37
+ /** Custom WebSocket URL. Defaults to ws://hostname/_blumen/ws */
38
+ url?: string;
39
+ /** Called when connection opens */
40
+ onOpen?: () => void;
41
+ /** Called when a message is received */
42
+ onMessage?: (msg: WSMessage) => void;
43
+ /** Called when connection closes */
44
+ onClose?: (event: CloseEvent) => void;
45
+ /** Called on error */
46
+ onError?: (event: Event) => void;
47
+ }
48
+
49
+ export interface UseWebSocketReturn {
50
+ /** Send a message to the server */
51
+ send: (msg: Omit<WSMessage, "sender">) => void;
52
+ /** Send raw JSON */
53
+ sendJSON: (data: any) => void;
54
+ /** The last received message */
55
+ lastMessage: WSMessage | null;
56
+ /** All received messages (capped at 100) */
57
+ messages: WSMessage[];
58
+ /** Whether the socket is connected */
59
+ isConnected: boolean;
60
+ /** The client's unique ID (assigned by the server) */
61
+ clientId: string | null;
62
+ /** Join a room for scoped messaging */
63
+ joinRoom: (room: string) => void;
64
+ /** Leave a room */
65
+ leaveRoom: (room: string) => void;
66
+ /** Manually connect */
67
+ connect: () => void;
68
+ /** Manually disconnect */
69
+ disconnect: () => void;
70
+ }
71
+
72
+ const MAX_MESSAGES = 100;
73
+
74
+ // ── Hook ───────────────────────────────────────────────────────
75
+
76
+ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketReturn {
77
+ const {
78
+ autoConnect = true,
79
+ reconnect = true,
80
+ reconnectDelay = 1000,
81
+ maxReconnectAttempts = 10,
82
+ messageTypes,
83
+ url,
84
+ onOpen,
85
+ onMessage,
86
+ onClose,
87
+ onError,
88
+ } = options;
89
+
90
+ const [isConnected, setIsConnected] = useState(false);
91
+ const [lastMessage, setLastMessage] = useState<WSMessage | null>(null);
92
+ const [messages, setMessages] = useState<WSMessage[]>([]);
93
+ const [clientId, setClientId] = useState<string | null>(null);
94
+
95
+ const wsRef = useRef<WebSocket | null>(null);
96
+ const reconnectAttemptsRef = useRef(0);
97
+ const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
98
+ const mountedRef = useRef(true);
99
+
100
+ // Build the WebSocket URL
101
+ const getUrl = useCallback(() => {
102
+ if (url) return url;
103
+ if (typeof window === "undefined") return "";
104
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
105
+ return `${protocol}//${window.location.host}/_blumen/ws`;
106
+ }, [url]);
107
+
108
+ // ── Connect ────────────────────────────────────────────────
109
+ const connect = useCallback(() => {
110
+ if (wsRef.current?.readyState === WebSocket.OPEN) return;
111
+
112
+ const wsUrl = getUrl();
113
+ if (!wsUrl) return;
114
+
115
+ const ws = new WebSocket(wsUrl);
116
+ wsRef.current = ws;
117
+
118
+ ws.onopen = () => {
119
+ if (!mountedRef.current) return;
120
+ setIsConnected(true);
121
+ reconnectAttemptsRef.current = 0;
122
+ onOpen?.();
123
+ };
124
+
125
+ ws.onmessage = (event) => {
126
+ if (!mountedRef.current) return;
127
+
128
+ try {
129
+ const msg: WSMessage = JSON.parse(event.data);
130
+
131
+ // Handle the server's "connected" message to store client ID
132
+ if (msg.type === "connected" && typeof msg.payload === "string") {
133
+ setClientId(msg.payload);
134
+ return;
135
+ }
136
+
137
+ // Filter by message types if specified
138
+ if (messageTypes && !messageTypes.includes(msg.type)) {
139
+ // Still call the onMessage callback even if filtered
140
+ onMessage?.(msg);
141
+ return;
142
+ }
143
+
144
+ setLastMessage(msg);
145
+ setMessages((prev) => {
146
+ const next = [...prev, msg];
147
+ return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
148
+ });
149
+
150
+ onMessage?.(msg);
151
+ } catch {
152
+ // Not JSON — ignore
153
+ }
154
+ };
155
+
156
+ ws.onclose = (event) => {
157
+ if (!mountedRef.current) return;
158
+ setIsConnected(false);
159
+ onClose?.(event);
160
+
161
+ // Auto-reconnect
162
+ if (reconnect && reconnectAttemptsRef.current < maxReconnectAttempts) {
163
+ const delay = reconnectDelay * Math.pow(1.5, reconnectAttemptsRef.current);
164
+ reconnectAttemptsRef.current++;
165
+ reconnectTimerRef.current = setTimeout(() => {
166
+ if (mountedRef.current) connect();
167
+ }, delay);
168
+ }
169
+ };
170
+
171
+ ws.onerror = (event) => {
172
+ onError?.(event);
173
+ };
174
+ }, [getUrl, onOpen, onMessage, onClose, onError, reconnect, reconnectDelay, maxReconnectAttempts, messageTypes]);
175
+
176
+ // ── Disconnect ─────────────────────────────────────────────
177
+ const disconnect = useCallback(() => {
178
+ if (reconnectTimerRef.current) {
179
+ clearTimeout(reconnectTimerRef.current);
180
+ }
181
+ reconnectAttemptsRef.current = maxReconnectAttempts; // Prevent auto-reconnect
182
+ wsRef.current?.close(1000, "Client disconnect");
183
+ wsRef.current = null;
184
+ setIsConnected(false);
185
+ }, [maxReconnectAttempts]);
186
+
187
+ // ── Send ───────────────────────────────────────────────────
188
+ const send = useCallback((msg: Omit<WSMessage, "sender">) => {
189
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
190
+ wsRef.current.send(JSON.stringify(msg));
191
+ }
192
+ }, []);
193
+
194
+ const sendJSON = useCallback((data: any) => {
195
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
196
+ wsRef.current.send(JSON.stringify(data));
197
+ }
198
+ }, []);
199
+
200
+ // ── Room Management ────────────────────────────────────────
201
+ const joinRoom = useCallback((room: string) => {
202
+ send({ type: "join", payload: room });
203
+ }, [send]);
204
+
205
+ const leaveRoom = useCallback((room: string) => {
206
+ send({ type: "leave", payload: room });
207
+ }, [send]);
208
+
209
+ // ── Lifecycle ──────────────────────────────────────────────
210
+ useEffect(() => {
211
+ mountedRef.current = true;
212
+ if (autoConnect) connect();
213
+
214
+ return () => {
215
+ mountedRef.current = false;
216
+ if (reconnectTimerRef.current) {
217
+ clearTimeout(reconnectTimerRef.current);
218
+ }
219
+ wsRef.current?.close(1000, "Component unmount");
220
+ };
221
+ }, [autoConnect, connect]);
222
+
223
+ return {
224
+ send,
225
+ sendJSON,
226
+ lastMessage,
227
+ messages,
228
+ isConnected,
229
+ clientId,
230
+ joinRoom,
231
+ leaveRoom,
232
+ connect,
233
+ disconnect,
234
+ };
235
+ }
236
+
237
+ export default useWebSocket;
@@ -0,0 +1,8 @@
1
+ module blumenjs/go-server
2
+
3
+ go 1.25.4
4
+
5
+ require (
6
+ github.com/gorilla/websocket v1.5.3 // indirect
7
+ golang.org/x/image v0.40.0 // indirect
8
+ )
@@ -0,0 +1,4 @@
1
+ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
2
+ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
3
+ golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
4
+ golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=