alabjs 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.
Files changed (277) hide show
  1. package/dist/adapters/cloudflare.d.ts +31 -0
  2. package/dist/adapters/cloudflare.d.ts.map +1 -0
  3. package/dist/adapters/cloudflare.js +30 -0
  4. package/dist/adapters/cloudflare.js.map +1 -0
  5. package/dist/adapters/deno.d.ts +22 -0
  6. package/dist/adapters/deno.d.ts.map +1 -0
  7. package/dist/adapters/deno.js +21 -0
  8. package/dist/adapters/deno.js.map +1 -0
  9. package/dist/adapters/web.d.ts +47 -0
  10. package/dist/adapters/web.d.ts.map +1 -0
  11. package/dist/adapters/web.js +212 -0
  12. package/dist/adapters/web.js.map +1 -0
  13. package/dist/cli.d.ts +11 -0
  14. package/dist/cli.d.ts.map +1 -0
  15. package/dist/cli.js +61 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/client/hooks.d.ts +119 -0
  18. package/dist/client/hooks.d.ts.map +1 -0
  19. package/dist/client/hooks.js +220 -0
  20. package/dist/client/hooks.js.map +1 -0
  21. package/dist/client/hooks.test.d.ts +2 -0
  22. package/dist/client/hooks.test.d.ts.map +1 -0
  23. package/dist/client/hooks.test.js +45 -0
  24. package/dist/client/hooks.test.js.map +1 -0
  25. package/dist/client/index.d.ts +6 -0
  26. package/dist/client/index.d.ts.map +1 -0
  27. package/dist/client/index.js +4 -0
  28. package/dist/client/index.js.map +1 -0
  29. package/dist/client/offline.d.ts +52 -0
  30. package/dist/client/offline.d.ts.map +1 -0
  31. package/dist/client/offline.js +90 -0
  32. package/dist/client/offline.js.map +1 -0
  33. package/dist/client/provider.d.ts +12 -0
  34. package/dist/client/provider.d.ts.map +1 -0
  35. package/dist/client/provider.js +10 -0
  36. package/dist/client/provider.js.map +1 -0
  37. package/dist/commands/build.d.ts +18 -0
  38. package/dist/commands/build.d.ts.map +1 -0
  39. package/dist/commands/build.js +173 -0
  40. package/dist/commands/build.js.map +1 -0
  41. package/dist/commands/dev.d.ts +8 -0
  42. package/dist/commands/dev.d.ts.map +1 -0
  43. package/dist/commands/dev.js +447 -0
  44. package/dist/commands/dev.js.map +1 -0
  45. package/dist/commands/info.d.ts +6 -0
  46. package/dist/commands/info.d.ts.map +1 -0
  47. package/dist/commands/info.js +92 -0
  48. package/dist/commands/info.js.map +1 -0
  49. package/dist/commands/ssg.d.ts +8 -0
  50. package/dist/commands/ssg.d.ts.map +1 -0
  51. package/dist/commands/ssg.js +124 -0
  52. package/dist/commands/ssg.js.map +1 -0
  53. package/dist/commands/start.d.ts +7 -0
  54. package/dist/commands/start.d.ts.map +1 -0
  55. package/dist/commands/start.js +26 -0
  56. package/dist/commands/start.js.map +1 -0
  57. package/dist/commands/test.d.ts +24 -0
  58. package/dist/commands/test.d.ts.map +1 -0
  59. package/dist/commands/test.js +87 -0
  60. package/dist/commands/test.js.map +1 -0
  61. package/dist/components/ErrorBoundary.d.ts +38 -0
  62. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  63. package/dist/components/ErrorBoundary.js +46 -0
  64. package/dist/components/ErrorBoundary.js.map +1 -0
  65. package/dist/components/Font.d.ts +57 -0
  66. package/dist/components/Font.d.ts.map +1 -0
  67. package/dist/components/Font.js +33 -0
  68. package/dist/components/Font.js.map +1 -0
  69. package/dist/components/Image.d.ts +74 -0
  70. package/dist/components/Image.d.ts.map +1 -0
  71. package/dist/components/Image.js +85 -0
  72. package/dist/components/Image.js.map +1 -0
  73. package/dist/components/Link.d.ts +23 -0
  74. package/dist/components/Link.d.ts.map +1 -0
  75. package/dist/components/Link.js +48 -0
  76. package/dist/components/Link.js.map +1 -0
  77. package/dist/components/Script.d.ts +37 -0
  78. package/dist/components/Script.d.ts.map +1 -0
  79. package/dist/components/Script.js +70 -0
  80. package/dist/components/Script.js.map +1 -0
  81. package/dist/components/index.d.ts +10 -0
  82. package/dist/components/index.d.ts.map +1 -0
  83. package/dist/components/index.js +6 -0
  84. package/dist/components/index.js.map +1 -0
  85. package/dist/i18n/i18n.test.d.ts +2 -0
  86. package/dist/i18n/i18n.test.d.ts.map +1 -0
  87. package/dist/i18n/i18n.test.js +132 -0
  88. package/dist/i18n/i18n.test.js.map +1 -0
  89. package/dist/i18n/index.d.ts +135 -0
  90. package/dist/i18n/index.d.ts.map +1 -0
  91. package/dist/i18n/index.js +189 -0
  92. package/dist/i18n/index.js.map +1 -0
  93. package/dist/index.d.ts +4 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +3 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/router/code-router.d.ts +204 -0
  98. package/dist/router/code-router.d.ts.map +1 -0
  99. package/dist/router/code-router.js +258 -0
  100. package/dist/router/code-router.js.map +1 -0
  101. package/dist/router/code-router.test.d.ts +2 -0
  102. package/dist/router/code-router.test.d.ts.map +1 -0
  103. package/dist/router/code-router.test.js +128 -0
  104. package/dist/router/code-router.test.js.map +1 -0
  105. package/dist/router/index.d.ts +4 -0
  106. package/dist/router/index.d.ts.map +1 -0
  107. package/dist/router/index.js +2 -0
  108. package/dist/router/index.js.map +1 -0
  109. package/dist/router/manifest.d.ts +12 -0
  110. package/dist/router/manifest.d.ts.map +1 -0
  111. package/dist/router/manifest.js +2 -0
  112. package/dist/router/manifest.js.map +1 -0
  113. package/dist/server/app.d.ts +13 -0
  114. package/dist/server/app.d.ts.map +1 -0
  115. package/dist/server/app.js +407 -0
  116. package/dist/server/app.js.map +1 -0
  117. package/dist/server/cache.d.ts +99 -0
  118. package/dist/server/cache.d.ts.map +1 -0
  119. package/dist/server/cache.js +161 -0
  120. package/dist/server/cache.js.map +1 -0
  121. package/dist/server/cache.test.d.ts +2 -0
  122. package/dist/server/cache.test.d.ts.map +1 -0
  123. package/dist/server/cache.test.js +150 -0
  124. package/dist/server/cache.test.js.map +1 -0
  125. package/dist/server/csrf.d.ts +28 -0
  126. package/dist/server/csrf.d.ts.map +1 -0
  127. package/dist/server/csrf.js +66 -0
  128. package/dist/server/csrf.js.map +1 -0
  129. package/dist/server/csrf.test.d.ts +2 -0
  130. package/dist/server/csrf.test.d.ts.map +1 -0
  131. package/dist/server/csrf.test.js +154 -0
  132. package/dist/server/csrf.test.js.map +1 -0
  133. package/dist/server/image.d.ts +18 -0
  134. package/dist/server/image.d.ts.map +1 -0
  135. package/dist/server/image.js +97 -0
  136. package/dist/server/image.js.map +1 -0
  137. package/dist/server/index.d.ts +57 -0
  138. package/dist/server/index.d.ts.map +1 -0
  139. package/dist/server/index.js +58 -0
  140. package/dist/server/index.js.map +1 -0
  141. package/dist/server/middleware.d.ts +53 -0
  142. package/dist/server/middleware.d.ts.map +1 -0
  143. package/dist/server/middleware.js +80 -0
  144. package/dist/server/middleware.js.map +1 -0
  145. package/dist/server/middleware.test.d.ts +2 -0
  146. package/dist/server/middleware.test.d.ts.map +1 -0
  147. package/dist/server/middleware.test.js +125 -0
  148. package/dist/server/middleware.test.js.map +1 -0
  149. package/dist/server/revalidate.d.ts +49 -0
  150. package/dist/server/revalidate.d.ts.map +1 -0
  151. package/dist/server/revalidate.js +62 -0
  152. package/dist/server/revalidate.js.map +1 -0
  153. package/dist/server/revalidate.test.d.ts +2 -0
  154. package/dist/server/revalidate.test.d.ts.map +1 -0
  155. package/dist/server/revalidate.test.js +93 -0
  156. package/dist/server/revalidate.test.js.map +1 -0
  157. package/dist/server/server-fn.test.d.ts +2 -0
  158. package/dist/server/server-fn.test.d.ts.map +1 -0
  159. package/dist/server/server-fn.test.js +105 -0
  160. package/dist/server/server-fn.test.js.map +1 -0
  161. package/dist/server/sitemap.d.ts +9 -0
  162. package/dist/server/sitemap.d.ts.map +1 -0
  163. package/dist/server/sitemap.js +26 -0
  164. package/dist/server/sitemap.js.map +1 -0
  165. package/dist/server/sitemap.test.d.ts +2 -0
  166. package/dist/server/sitemap.test.d.ts.map +1 -0
  167. package/dist/server/sitemap.test.js +61 -0
  168. package/dist/server/sitemap.test.js.map +1 -0
  169. package/dist/server/sse.d.ts +59 -0
  170. package/dist/server/sse.d.ts.map +1 -0
  171. package/dist/server/sse.js +91 -0
  172. package/dist/server/sse.js.map +1 -0
  173. package/dist/server/sse.test.d.ts +2 -0
  174. package/dist/server/sse.test.d.ts.map +1 -0
  175. package/dist/server/sse.test.js +68 -0
  176. package/dist/server/sse.test.js.map +1 -0
  177. package/dist/signals/index.d.ts +101 -0
  178. package/dist/signals/index.d.ts.map +1 -0
  179. package/dist/signals/index.js +149 -0
  180. package/dist/signals/index.js.map +1 -0
  181. package/dist/signals/signals.test.d.ts +2 -0
  182. package/dist/signals/signals.test.d.ts.map +1 -0
  183. package/dist/signals/signals.test.js +146 -0
  184. package/dist/signals/signals.test.js.map +1 -0
  185. package/dist/ssr/html.d.ts +27 -0
  186. package/dist/ssr/html.d.ts.map +1 -0
  187. package/dist/ssr/html.js +107 -0
  188. package/dist/ssr/html.js.map +1 -0
  189. package/dist/ssr/html.test.d.ts +2 -0
  190. package/dist/ssr/html.test.d.ts.map +1 -0
  191. package/dist/ssr/html.test.js +178 -0
  192. package/dist/ssr/html.test.js.map +1 -0
  193. package/dist/ssr/render.d.ts +46 -0
  194. package/dist/ssr/render.d.ts.map +1 -0
  195. package/dist/ssr/render.js +87 -0
  196. package/dist/ssr/render.js.map +1 -0
  197. package/dist/ssr/router-dev.d.ts +60 -0
  198. package/dist/ssr/router-dev.d.ts.map +1 -0
  199. package/dist/ssr/router-dev.js +205 -0
  200. package/dist/ssr/router-dev.js.map +1 -0
  201. package/dist/ssr/router-dev.test.d.ts +2 -0
  202. package/dist/ssr/router-dev.test.d.ts.map +1 -0
  203. package/dist/ssr/router-dev.test.js +189 -0
  204. package/dist/ssr/router-dev.test.js.map +1 -0
  205. package/dist/test/index.d.ts +93 -0
  206. package/dist/test/index.d.ts.map +1 -0
  207. package/dist/test/index.js +146 -0
  208. package/dist/test/index.js.map +1 -0
  209. package/dist/types/index.d.ts +117 -0
  210. package/dist/types/index.d.ts.map +1 -0
  211. package/dist/types/index.js +2 -0
  212. package/dist/types/index.js.map +1 -0
  213. package/dist/types/napi.d.ts +15 -0
  214. package/dist/types/napi.d.ts.map +1 -0
  215. package/dist/types/napi.js +2 -0
  216. package/dist/types/napi.js.map +1 -0
  217. package/package.json +107 -0
  218. package/src/adapters/cloudflare.ts +30 -0
  219. package/src/adapters/deno.ts +21 -0
  220. package/src/adapters/web.ts +259 -0
  221. package/src/cli.ts +68 -0
  222. package/src/client/hooks.test.ts +54 -0
  223. package/src/client/hooks.ts +329 -0
  224. package/src/client/index.ts +5 -0
  225. package/src/client/offline-sw.ts +191 -0
  226. package/src/client/offline.ts +114 -0
  227. package/src/client/provider.tsx +14 -0
  228. package/src/commands/build.ts +201 -0
  229. package/src/commands/dev.ts +509 -0
  230. package/src/commands/info.ts +111 -0
  231. package/src/commands/ssg.ts +177 -0
  232. package/src/commands/start.ts +32 -0
  233. package/src/commands/test.ts +102 -0
  234. package/src/components/ErrorBoundary.tsx +73 -0
  235. package/src/components/Font.tsx +100 -0
  236. package/src/components/Image.tsx +141 -0
  237. package/src/components/Link.tsx +64 -0
  238. package/src/components/Script.tsx +97 -0
  239. package/src/components/index.ts +9 -0
  240. package/src/i18n/i18n.test.tsx +169 -0
  241. package/src/i18n/index.tsx +256 -0
  242. package/src/index.ts +10 -0
  243. package/src/router/code-router.test.ts +146 -0
  244. package/src/router/code-router.tsx +459 -0
  245. package/src/router/index.ts +18 -0
  246. package/src/router/manifest.ts +13 -0
  247. package/src/server/app.ts +466 -0
  248. package/src/server/cache.test.ts +192 -0
  249. package/src/server/cache.ts +195 -0
  250. package/src/server/csrf.test.ts +199 -0
  251. package/src/server/csrf.ts +80 -0
  252. package/src/server/image.ts +112 -0
  253. package/src/server/index.ts +144 -0
  254. package/src/server/middleware.test.ts +151 -0
  255. package/src/server/middleware.ts +95 -0
  256. package/src/server/revalidate.test.ts +106 -0
  257. package/src/server/revalidate.ts +75 -0
  258. package/src/server/server-fn.test.ts +127 -0
  259. package/src/server/sitemap.test.ts +68 -0
  260. package/src/server/sitemap.ts +30 -0
  261. package/src/server/sse.test.ts +81 -0
  262. package/src/server/sse.ts +110 -0
  263. package/src/signals/index.ts +177 -0
  264. package/src/signals/signals.test.ts +164 -0
  265. package/src/ssr/html.test.ts +200 -0
  266. package/src/ssr/html.ts +140 -0
  267. package/src/ssr/render.ts +144 -0
  268. package/src/ssr/router-dev.test.ts +230 -0
  269. package/src/ssr/router-dev.ts +229 -0
  270. package/src/test/index.ts +206 -0
  271. package/src/types/compiler.d.ts +25 -0
  272. package/src/types/index.ts +147 -0
  273. package/src/types/napi.ts +20 -0
  274. package/src/types/plugins.d.ts +3 -0
  275. package/tsconfig.json +11 -0
  276. package/tsconfig.tsbuildinfo +1 -0
  277. package/vitest.config.ts +32 -0
@@ -0,0 +1,329 @@
1
+ import { use, useReducer, useTransition, useCallback, useState, useEffect, useRef } from "react";
2
+ import type { ServerFn, InferServerOutput, RouteParams, InferServerPath } from "../types/index.js";
3
+
4
+ // Promise cache keyed by URL.
5
+ // • On the SERVER: cleared before each page render (via _clearALabSSRCache) so
6
+ // re-renders after Suspense resolution return the same promise object, which
7
+ // is required for renderToPipeableStream to correctly resolve Suspense.
8
+ // • On the CLIENT: intentionally persists for the session to avoid redundant
9
+ // network round-trips on subsequent re-renders.
10
+ const _promiseCache = new Map<string, Promise<unknown>>();
11
+
12
+ /** Clear the server-side promise cache between SSR renders. Called by alab's dev server. */
13
+ export function _clearALabSSRCache(): void {
14
+ _promiseCache.clear();
15
+ }
16
+
17
+ /**
18
+ * Fetch server data with full type inference from a `ServerFn`.
19
+ *
20
+ * Use `import type` to reference the server function — type-only imports
21
+ * are erased at compile time and never cross the server/client boundary.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * // app/posts/[id]/page.tsx
26
+ * import type { getPost } from "./page.server"; // ← import type, safe
27
+ * import { useServerData } from "alabjs/client";
28
+ *
29
+ * export default function PostPage({ params }: { params: { id: string } }) {
30
+ * // Return type is inferred from getPost — no manual type annotation needed
31
+ * const post = useServerData<typeof getPost>("getPost", params);
32
+ * post.title; // ✅ typed
33
+ * post.foo; // ✅ TS error — doesn't exist on the return type
34
+ * }
35
+ * ```
36
+ */
37
+ export function useServerData<T extends ServerFn<any, any, any>>(
38
+ fnName: string,
39
+ params?: RouteParams<InferServerPath<T>>,
40
+ ): InferServerOutput<T> {
41
+ const searchParams = params
42
+ ? new URLSearchParams(params as Record<string, string>).toString()
43
+ : "";
44
+ // When running in Node.js (SSR) fetch requires an absolute URL.
45
+ // Alab's dev/prod server sets ALAB_ORIGIN before rendering each page.
46
+ const origin =
47
+ typeof window !== "undefined"
48
+ ? ""
49
+ : (process.env["ALAB_ORIGIN"] ?? "http://localhost:3000");
50
+
51
+ const url = `${origin}/_alabjs/data/${fnName}${searchParams ? `?${searchParams}` : ""}`;
52
+
53
+ let promise = _promiseCache.get(url) as Promise<InferServerOutput<T>> | undefined;
54
+ if (!promise) {
55
+ promise = fetch(url).then((r): Promise<InferServerOutput<T>> => {
56
+ if (!r.ok) throw new Error(`[alabjs] server data fetch failed: ${r.status} ${r.statusText} — ${url}`);
57
+ return r.json() as Promise<InferServerOutput<T>>;
58
+ });
59
+ _promiseCache.set(url, promise);
60
+ }
61
+
62
+ return use(promise);
63
+ }
64
+
65
+ // ─── Mutation state machine ────────────────────────────────────────────────────
66
+
67
+ type MutationState<Output> =
68
+ | { status: "idle"; data: undefined; error: undefined; zodError: undefined }
69
+ | { status: "pending"; data: undefined; error: undefined; zodError: undefined }
70
+ | { status: "success"; data: Output; error: undefined; zodError: undefined }
71
+ | { status: "error"; data: undefined; error: Error; zodError: undefined }
72
+ | { status: "invalid"; data: undefined; error: undefined; zodError: unknown };
73
+
74
+ type MutationAction<Output> =
75
+ | { type: "start" }
76
+ | { type: "success"; data: Output }
77
+ | { type: "error"; error: Error }
78
+ | { type: "invalid"; zodError: unknown }
79
+ | { type: "reset" };
80
+
81
+ function mutationReducer<Output>(
82
+ _state: MutationState<Output>,
83
+ action: MutationAction<Output>,
84
+ ): MutationState<Output> {
85
+ switch (action.type) {
86
+ case "start": return { status: "pending", data: undefined, error: undefined, zodError: undefined };
87
+ case "success": return { status: "success", data: action.data, error: undefined, zodError: undefined };
88
+ case "error": return { status: "error", data: undefined, error: action.error, zodError: undefined };
89
+ case "invalid": return { status: "invalid", data: undefined, error: undefined, zodError: action.zodError };
90
+ case "reset": return { status: "idle", data: undefined, error: undefined, zodError: undefined };
91
+ }
92
+ }
93
+
94
+ // ─── useMutation options ───────────────────────────────────────────────────────
95
+
96
+ export interface UseMutationOptions<Output, Input> {
97
+ /**
98
+ * Compute an optimistic value from the input immediately — before the server
99
+ * responds. The component sees this value via `optimisticData` while the
100
+ * request is in flight.
101
+ *
102
+ * On server error, `optimisticData` is cleared and `onError` is called with
103
+ * a `rollback` callback you can use to undo any local side effects.
104
+ */
105
+ optimistic?: (input: Input) => Partial<Output>;
106
+ /** Called when the mutation succeeds with the server response. */
107
+ onSuccess?: (data: Output) => void;
108
+ /**
109
+ * Called when the mutation fails.
110
+ * `rollback()` clears the optimistic value and resets state to idle.
111
+ */
112
+ onError?: (err: Error, rollback: () => void) => void;
113
+ }
114
+
115
+ /**
116
+ * Trigger a server function mutation from the client, with full async state
117
+ * and optional optimistic updates.
118
+ *
119
+ * @example
120
+ * ```tsx
121
+ * import type { updateTodo } from "./page.server";
122
+ * import { useMutation } from "alabjs/client";
123
+ *
124
+ * // Basic
125
+ * const { mutate, data, isPending, error, zodError, reset } =
126
+ * useMutation<typeof updateTodo>("updateTodo");
127
+ *
128
+ * // With optimistic update
129
+ * const { mutate, optimisticData } = useMutation<typeof updateTodo>("updateTodo", {
130
+ * optimistic: (input) => ({ ...currentTodo, ...input }),
131
+ * onError: (err, rollback) => rollback(),
132
+ * });
133
+ * ```
134
+ */
135
+ export function useMutation<T extends ServerFn<any, any, any>>(
136
+ fnName: string,
137
+ options?: UseMutationOptions<InferServerOutput<T>, T extends ServerFn<infer I, any, any> ? I : never>,
138
+ ) {
139
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
140
+ type Input = T extends ServerFn<infer I, any, any> ? I : never;
141
+ type Output = InferServerOutput<T>;
142
+
143
+ const [state, dispatch] = useReducer(
144
+ mutationReducer<Output>,
145
+ { status: "idle", data: undefined, error: undefined, zodError: undefined } as MutationState<Output>,
146
+ );
147
+
148
+ // Optimistic value lives in separate state so it can be cleared independently.
149
+ const [optimisticData, setOptimisticData] = useState<Partial<Output> | undefined>(undefined);
150
+
151
+ const [isPending, startTransition] = useTransition();
152
+
153
+ const rollback = useCallback(() => {
154
+ setOptimisticData(undefined);
155
+ dispatch({ type: "reset" });
156
+ }, []);
157
+
158
+ const mutate = useCallback((input: Input): void => {
159
+ dispatch({ type: "start" });
160
+
161
+ if (options?.optimistic) {
162
+ setOptimisticData(options.optimistic(input));
163
+ }
164
+
165
+ startTransition(() => {
166
+ void (async () => {
167
+ try {
168
+ const r = await fetch(`/_alabjs/fn/${fnName}`, {
169
+ method: "POST",
170
+ headers: { "content-type": "application/json" },
171
+ body: JSON.stringify(input),
172
+ });
173
+
174
+ // Zod validation error from server
175
+ if (r.status === 422) {
176
+ const body = await r.json() as { zodError: unknown };
177
+ setOptimisticData(undefined);
178
+ dispatch({ type: "invalid", zodError: body["zodError"] });
179
+ return;
180
+ }
181
+
182
+ if (!r.ok) throw new Error(`[alabjs] mutation failed: ${r.status} ${r.statusText}`);
183
+
184
+ const data = await r.json() as Output;
185
+ setOptimisticData(undefined);
186
+ dispatch({ type: "success", data });
187
+ options?.onSuccess?.(data);
188
+ } catch (err) {
189
+ const error = err instanceof Error ? err : new Error(String(err));
190
+ setOptimisticData(undefined);
191
+ dispatch({ type: "error", error });
192
+ options?.onError?.(error, rollback);
193
+ }
194
+ })();
195
+ });
196
+ // eslint-disable-next-line react-hooks/exhaustive-deps
197
+ }, [fnName, rollback]);
198
+
199
+ const reset = useCallback(() => {
200
+ setOptimisticData(undefined);
201
+ dispatch({ type: "reset" });
202
+ }, []);
203
+
204
+ return {
205
+ mutate,
206
+ data: state.data as Output | undefined,
207
+ /** Present when `optimistic` option is set and the request is in flight. */
208
+ optimisticData,
209
+ isPending: isPending || state.status === "pending",
210
+ error: state.error as Error | undefined,
211
+ /** Zod validation errors returned by `defineServerFn` schema checks (HTTP 422). */
212
+ zodError: state.zodError,
213
+ isSuccess: state.status === "success",
214
+ isError: state.status === "error",
215
+ isInvalid: state.status === "invalid",
216
+ reset,
217
+ };
218
+ }
219
+
220
+ // ─── useSSE ───────────────────────────────────────────────────────────────────
221
+
222
+ /** Possible connection states matching the browser EventSource readyState values. */
223
+ export type SSEReadyState = "connecting" | "open" | "closed";
224
+
225
+ export interface UseSSEOptions {
226
+ /** Named event to subscribe to. Defaults to `"message"`. */
227
+ event?: string;
228
+ /** Whether to connect immediately. Set `false` to defer. @default true */
229
+ enabled?: boolean;
230
+ /** Called once when the EventSource opens. */
231
+ onOpen?: () => void;
232
+ /** Called when the EventSource closes or errors. */
233
+ onError?: (err: Event) => void;
234
+ }
235
+
236
+ export interface UseSSEResult<T> {
237
+ /** Most recently received event data (parsed JSON). `undefined` until first event. */
238
+ data: T | undefined;
239
+ /** The `lastEventId` string from the most recent event. */
240
+ lastEventId: string;
241
+ readyState: SSEReadyState;
242
+ /** Close the EventSource and stop listening. */
243
+ close: () => void;
244
+ }
245
+
246
+ /**
247
+ * Subscribe to a server-sent event stream from a `defineSSEHandler` route.
248
+ *
249
+ * The EventSource is created when the component mounts and closed on unmount.
250
+ * Data is parsed as JSON automatically.
251
+ *
252
+ * @example
253
+ * ```tsx
254
+ * import { useSSE } from "alabjs/client";
255
+ *
256
+ * export default function PricesPage() {
257
+ * const { data, readyState } = useSSE<{ ticker: string; price: number }>(
258
+ * "/api/prices?ticker=BTC",
259
+ * { event: "price" },
260
+ * );
261
+ *
262
+ * return <div>{readyState === "open" ? data?.price ?? "—" : "connecting…"}</div>;
263
+ * }
264
+ * ```
265
+ */
266
+ export function useSSE<T = unknown>(
267
+ url: string,
268
+ options: UseSSEOptions = {},
269
+ ): UseSSEResult<T> {
270
+ const { event = "message", enabled = true, onOpen, onError } = options;
271
+
272
+ const [data, setData] = useState<T | undefined>(undefined);
273
+ const [lastEventId, setLastEventId] = useState("");
274
+ const [readyState, setReadyState] = useState<SSEReadyState>("connecting");
275
+
276
+ // Keep callbacks stable across renders without re-subscribing
277
+ const onOpenRef = useRef(onOpen);
278
+ const onErrorRef = useRef(onError);
279
+ onOpenRef.current = onOpen;
280
+ onErrorRef.current = onError;
281
+
282
+ const esRef = useRef<EventSource | null>(null);
283
+
284
+ const close = useCallback(() => {
285
+ esRef.current?.close();
286
+ esRef.current = null;
287
+ setReadyState("closed");
288
+ }, []);
289
+
290
+ useEffect(() => {
291
+ if (!enabled || typeof EventSource === "undefined") return;
292
+
293
+ const es = new EventSource(url);
294
+ esRef.current = es;
295
+ setReadyState("connecting");
296
+
297
+ es.addEventListener("open", () => {
298
+ setReadyState("open");
299
+ onOpenRef.current?.();
300
+ });
301
+
302
+ es.addEventListener(event, (e: MessageEvent) => {
303
+ setLastEventId(e.lastEventId ?? "");
304
+ try {
305
+ setData(e.data ? (JSON.parse(e.data) as T) : undefined);
306
+ } catch {
307
+ // Server sent data that isn't valid JSON — log and discard rather than
308
+ // silently casting a raw string to T (which would cause runtime type errors).
309
+ console.warn("[alabjs] useSSE: received non-JSON data on", url, "— discarding:", e.data);
310
+ }
311
+ });
312
+
313
+ es.addEventListener("error", (e) => {
314
+ onErrorRef.current?.(e);
315
+ if (es.readyState === EventSource.CLOSED) {
316
+ setReadyState("closed");
317
+ }
318
+ });
319
+
320
+ return () => {
321
+ es.close();
322
+ esRef.current = null;
323
+ };
324
+ // Re-subscribe if url or event type changes
325
+ // eslint-disable-next-line react-hooks/exhaustive-deps
326
+ }, [url, event, enabled]);
327
+
328
+ return { data, lastEventId, readyState, close };
329
+ }
@@ -0,0 +1,5 @@
1
+ export { useServerData, useMutation, useSSE, _clearALabSSRCache } from "./hooks.js";
2
+ export type { UseSSEOptions, UseSSEResult, SSEReadyState } from "./hooks.js";
3
+ export { useOfflineMutations } from "./offline.js";
4
+ export type { UseOfflineMutationsResult, OfflineMutationResult } from "./offline.js";
5
+ export { AlabProvider } from "./provider.js";
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Alab offline service worker — queue-and-replay for mutations.
3
+ *
4
+ * This module is compiled to a separate SW entry point by `alab build`.
5
+ * It intercepts failed `/_alabjs/fn/*` POST requests when the network is
6
+ * unavailable, stores them in IndexedDB, and replays them when connectivity
7
+ * is restored.
8
+ *
9
+ * The SW communicates with the page via `postMessage`:
10
+ * page → SW: { type: "ALAB_REPLAY" } — manual replay trigger
11
+ * SW → page: { type: "ALAB_QUEUED", count: number }
12
+ * SW → page: { type: "ALAB_REPLAYED", fn: string, ok: boolean }
13
+ * SW → page: { type: "ALAB_QUEUE_EMPTY" }
14
+ */
15
+
16
+ // ─── Types shared with the client hook ───────────────────────────────────────
17
+
18
+ export interface QueuedMutation {
19
+ id: string;
20
+ fn: string;
21
+ body: string;
22
+ timestamp: number;
23
+ }
24
+
25
+ // ─── IndexedDB helpers ────────────────────────────────────────────────────────
26
+
27
+ const DB_NAME = "alab-offline";
28
+ const STORE = "mutations";
29
+ const DB_VERSION = 1;
30
+
31
+ function openDb(): Promise<IDBDatabase> {
32
+ return new Promise((resolve, reject) => {
33
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
34
+ req.onupgradeneeded = () => {
35
+ req.result.createObjectStore(STORE, { keyPath: "id" });
36
+ };
37
+ req.onsuccess = () => resolve(req.result);
38
+ req.onerror = () => reject(req.error);
39
+ });
40
+ }
41
+
42
+ async function enqueue(item: QueuedMutation): Promise<void> {
43
+ const db = await openDb();
44
+ await new Promise<void>((resolve, reject) => {
45
+ const tx = db.transaction(STORE, "readwrite");
46
+ tx.objectStore(STORE).put(item);
47
+ tx.oncomplete = () => resolve();
48
+ tx.onerror = () => reject(tx.error);
49
+ });
50
+ }
51
+
52
+ async function dequeue(id: string): Promise<void> {
53
+ const db = await openDb();
54
+ await new Promise<void>((resolve, reject) => {
55
+ const tx = db.transaction(STORE, "readwrite");
56
+ tx.objectStore(STORE).delete(id);
57
+ tx.oncomplete = () => resolve();
58
+ tx.onerror = () => reject(tx.error);
59
+ });
60
+ }
61
+
62
+ async function getAllQueued(): Promise<QueuedMutation[]> {
63
+ const db = await openDb();
64
+ return new Promise((resolve, reject) => {
65
+ const tx = db.transaction(STORE, "readonly");
66
+ const req = tx.objectStore(STORE).getAll();
67
+ req.onsuccess = () => resolve(req.result as QueuedMutation[]);
68
+ req.onerror = () => reject(req.error);
69
+ });
70
+ }
71
+
72
+ // ─── Minimal SW global types (avoids lib="WebWorker" conflict with DOM) ──────
73
+
74
+ interface SwClient { postMessage(msg: unknown): void }
75
+ interface SwClients {
76
+ matchAll(opts?: { includeUncontrolled?: boolean }): Promise<SwClient[]>;
77
+ claim(): Promise<void>;
78
+ }
79
+ interface SwExtendableEvent extends Event { waitUntil(p: Promise<unknown>): void }
80
+ interface SwFetchEvent extends SwExtendableEvent {
81
+ request: Request;
82
+ respondWith(r: Promise<Response>): void;
83
+ }
84
+ interface SwSyncEvent extends SwExtendableEvent { tag: string }
85
+ interface SwMessageEvent extends SwExtendableEvent { data: unknown }
86
+ interface SwGlobalScope {
87
+ clients: SwClients;
88
+ skipWaiting(): void;
89
+ addEventListener(type: "install", cb: () => void): void;
90
+ addEventListener(type: "activate", cb: (e: SwExtendableEvent) => void): void;
91
+ addEventListener(type: "fetch", cb: (e: SwFetchEvent) => void): void;
92
+ addEventListener(type: "sync", cb: (e: SwSyncEvent) => void): void;
93
+ addEventListener(type: "message", cb: (e: SwMessageEvent) => void): void;
94
+ }
95
+ declare const self: SwGlobalScope;
96
+
97
+ // ─── Broadcast to all controlled pages ───────────────────────────────────────
98
+
99
+ function broadcast(msg: Record<string, unknown>) {
100
+ self.clients.matchAll({ includeUncontrolled: true }).then((clients) => {
101
+ clients.forEach((c) => c.postMessage(msg));
102
+ });
103
+ }
104
+
105
+ // ─── Replay queued mutations ──────────────────────────────────────────────────
106
+
107
+ async function replay() {
108
+ const queued = await getAllQueued();
109
+ if (queued.length === 0) {
110
+ broadcast({ type: "ALAB_QUEUE_EMPTY" });
111
+ return;
112
+ }
113
+ for (const item of queued) {
114
+ try {
115
+ const res = await fetch(`/_alabjs/fn/${item.fn}`, {
116
+ method: "POST",
117
+ headers: { "content-type": "application/json" },
118
+ body: item.body,
119
+ });
120
+ if (res.ok || res.status === 422) {
121
+ // 422 = Zod error — not a network failure; dequeue and notify
122
+ await dequeue(item.id);
123
+ broadcast({ type: "ALAB_REPLAYED", fn: item.fn, ok: res.ok });
124
+ }
125
+ // 5xx: leave in queue and try again next time
126
+ } catch {
127
+ // Still offline — leave in queue
128
+ }
129
+ }
130
+ }
131
+
132
+ // ─── SW event listeners ───────────────────────────────────────────────────────
133
+
134
+ self.addEventListener("install", () => {
135
+ self.skipWaiting();
136
+ });
137
+
138
+ self.addEventListener("activate", (event) => {
139
+ event.waitUntil(self.clients.claim());
140
+ });
141
+
142
+ self.addEventListener("fetch", (event: SwFetchEvent) => {
143
+ const { request } = event;
144
+
145
+ // Only intercept mutation POSTs to /_alabjs/fn/*
146
+ if (
147
+ request.method !== "POST" ||
148
+ !new URL(request.url).pathname.startsWith("/_alabjs/fn/")
149
+ ) {
150
+ return;
151
+ }
152
+
153
+ event.respondWith(
154
+ (async () => {
155
+ try {
156
+ return await fetch(request.clone());
157
+ } catch {
158
+ // Network failure — queue the mutation
159
+ const fn = new URL(request.url).pathname.replace("/_alabjs/fn/", "");
160
+ const body = await request.text();
161
+ const item: QueuedMutation = {
162
+ id: `${fn}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
163
+ fn,
164
+ body,
165
+ timestamp: Date.now(),
166
+ };
167
+ await enqueue(item);
168
+ const queued = await getAllQueued();
169
+ broadcast({ type: "ALAB_QUEUED", count: queued.length });
170
+
171
+ // Return a synthetic "queued" response so the caller isn't left hanging
172
+ return new Response(JSON.stringify({ __queued: true, id: item.id }), {
173
+ status: 202,
174
+ headers: { "content-type": "application/json" },
175
+ });
176
+ }
177
+ })(),
178
+ );
179
+ });
180
+
181
+ self.addEventListener("sync", (event: SwSyncEvent) => {
182
+ if (event.tag === "alab-mutation-replay") {
183
+ event.waitUntil(replay());
184
+ }
185
+ });
186
+
187
+ self.addEventListener("message", (event: ExtendableMessageEvent) => {
188
+ if ((event.data as Record<string, unknown>)?.["type"] === "ALAB_REPLAY") {
189
+ event.waitUntil(replay());
190
+ }
191
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Alab offline support — client-side registration + queue observation.
3
+ *
4
+ * @example
5
+ * ```tsx
6
+ * // app/layout.tsx
7
+ * import { useOfflineMutations } from "alabjs/client";
8
+ *
9
+ * export default function RootLayout({ children }) {
10
+ * const { isOffline, queuedCount, replay } = useOfflineMutations();
11
+ *
12
+ * return (
13
+ * <>
14
+ * {isOffline && (
15
+ * <div className="offline-banner">
16
+ * You're offline — {queuedCount} mutation(s) will sync when reconnected.
17
+ * <button onClick={replay}>Retry now</button>
18
+ * </div>
19
+ * )}
20
+ * {children}
21
+ * </>
22
+ * );
23
+ * }
24
+ * ```
25
+ */
26
+
27
+ import { useState, useEffect, useCallback } from "react";
28
+
29
+ export interface OfflineMutationResult {
30
+ replayed: { fn: string; ok: boolean }[];
31
+ }
32
+
33
+ export interface UseOfflineMutationsResult {
34
+ /** True when `navigator.onLine` is false. */
35
+ isOffline: boolean;
36
+ /** Number of mutations currently held in the offline queue. */
37
+ queuedCount: number;
38
+ /** Manually trigger a replay of the offline queue. */
39
+ replay: () => void;
40
+ /** History of replayed mutations since mount (cleared on replay start). */
41
+ replayed: OfflineMutationResult["replayed"];
42
+ }
43
+
44
+ const SW_PATH = "/_alabjs/offline-sw.js";
45
+
46
+ /**
47
+ * Register the Alab offline service worker and observe queue state.
48
+ *
49
+ * On mount this registers `/_alabjs/offline-sw.js` (emitted by `alab build`).
50
+ * The hook listens for SW messages and browser online/offline events to keep
51
+ * state in sync.
52
+ *
53
+ * Safe to call in SSR — all browser APIs are guarded behind `typeof window`.
54
+ */
55
+ export function useOfflineMutations(): UseOfflineMutationsResult {
56
+ const [isOffline, setIsOffline] = useState(
57
+ typeof navigator !== "undefined" ? !navigator.onLine : false,
58
+ );
59
+ const [queuedCount, setQueuedCount] = useState(0);
60
+ const [replayed, setReplayed] = useState<OfflineMutationResult["replayed"]>([]);
61
+
62
+ // Stable SW controller ref
63
+ const [swReg, setSwReg] = useState<ServiceWorkerRegistration | null>(null);
64
+
65
+ useEffect(() => {
66
+ if (typeof window === "undefined" || !("serviceWorker" in navigator)) return;
67
+
68
+ // Register SW
69
+ navigator.serviceWorker.register(SW_PATH, { scope: "/" }).then((reg) => {
70
+ setSwReg(reg);
71
+ }).catch(() => { /* SW blocked (non-HTTPS, private browsing, etc.) */ });
72
+
73
+ // Online / offline events
74
+ const handleOnline = () => setIsOffline(false);
75
+ const handleOffline = () => setIsOffline(true);
76
+ window.addEventListener("online", handleOnline);
77
+ window.addEventListener("offline", handleOffline);
78
+
79
+ // Messages from the SW
80
+ const handleMessage = (event: MessageEvent) => {
81
+ const msg = event.data as Record<string, unknown>;
82
+ if (!msg) return;
83
+ switch (msg["type"]) {
84
+ case "ALAB_QUEUED":
85
+ setQueuedCount(msg["count"] as number);
86
+ break;
87
+ case "ALAB_REPLAYED":
88
+ setReplayed((prev) => [...prev, { fn: msg["fn"] as string, ok: msg["ok"] as boolean }]);
89
+ break;
90
+ case "ALAB_QUEUE_EMPTY":
91
+ setQueuedCount(0);
92
+ break;
93
+ }
94
+ };
95
+ navigator.serviceWorker.addEventListener("message", handleMessage);
96
+
97
+ return () => {
98
+ window.removeEventListener("online", handleOnline);
99
+ window.removeEventListener("offline", handleOffline);
100
+ navigator.serviceWorker.removeEventListener("message", handleMessage);
101
+ };
102
+ }, []);
103
+
104
+ const replay = useCallback(() => {
105
+ setReplayed([]);
106
+ if (swReg?.active) {
107
+ swReg.active.postMessage({ type: "ALAB_REPLAY" });
108
+ } else {
109
+ navigator.serviceWorker.controller?.postMessage({ type: "ALAB_REPLAY" });
110
+ }
111
+ }, [swReg]);
112
+
113
+ return { isOffline, queuedCount, replay, replayed };
114
+ }
@@ -0,0 +1,14 @@
1
+ import { Suspense, type ReactNode } from "react";
2
+
3
+ interface AlabProviderProps {
4
+ children: ReactNode;
5
+ fallback?: ReactNode;
6
+ }
7
+
8
+ /**
9
+ * Root provider for Alab apps.
10
+ * Wraps the app in a Suspense boundary for `useServerData` hooks.
11
+ */
12
+ export function AlabProvider({ children, fallback = null }: AlabProviderProps) {
13
+ return <Suspense fallback={fallback}>{children}</Suspense>;
14
+ }