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,127 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { defineServerFn } from "./index.js";
3
+ import { invalidateCacheKey, getCached, CACHE_MISS } from "./cache.js";
4
+
5
+ describe("defineServerFn", () => {
6
+ // ── Basic invocation ──────────────────────────────────────────────────────
7
+
8
+ it("returns a callable function", () => {
9
+ const fn = defineServerFn(async () => "hello");
10
+ expect(typeof fn).toBe("function");
11
+ });
12
+
13
+ it("invokes the handler with context and input", async () => {
14
+ const fn = defineServerFn(async (ctx, input: { name: string }) => {
15
+ return { greeting: `Hello, ${input.name}` };
16
+ });
17
+
18
+ const ctx = {
19
+ params: {},
20
+ query: {},
21
+ headers: {},
22
+ method: "POST" as const,
23
+ url: "/test",
24
+ };
25
+
26
+ const result = await fn(ctx, { name: "Ada" });
27
+ expect(result).toEqual({ greeting: "Hello, Ada" });
28
+ });
29
+
30
+ // ── Zod validation (duck-typed) ───────────────────────────────────────────
31
+
32
+ it("validates input with a Zod-like schema", async () => {
33
+ const mockSchema = {
34
+ safeParse(input: unknown) {
35
+ const obj = input as Record<string, unknown>;
36
+ if (typeof obj?.["name"] === "string" && (obj["name"] as string).length > 0) {
37
+ return { success: true as const, data: obj };
38
+ }
39
+ return { success: false as const, error: { issues: [{ message: "name required" }] } };
40
+ },
41
+ };
42
+
43
+ const fn = defineServerFn(mockSchema, async (_ctx, input) => {
44
+ return { validated: (input as { name: string }).name };
45
+ });
46
+
47
+ const ctx = {
48
+ params: {},
49
+ query: {},
50
+ headers: {},
51
+ method: "POST" as const,
52
+ url: "/test",
53
+ };
54
+
55
+ const result = await fn(ctx, { name: "Ada" });
56
+ expect(result).toEqual({ validated: "Ada" });
57
+ });
58
+
59
+ it("throws zodError on validation failure", async () => {
60
+ const mockSchema = {
61
+ safeParse(_input: unknown) {
62
+ return {
63
+ success: false as const,
64
+ error: { issues: [{ message: "name required" }] },
65
+ };
66
+ },
67
+ };
68
+
69
+ const fn = defineServerFn(mockSchema, async () => {
70
+ return { ok: true };
71
+ });
72
+
73
+ const ctx = {
74
+ params: {},
75
+ query: {},
76
+ headers: {},
77
+ method: "POST" as const,
78
+ url: "/test",
79
+ };
80
+
81
+ try {
82
+ await fn(ctx, {});
83
+ expect.fail("Should have thrown");
84
+ } catch (err) {
85
+ expect((err as Error).message).toBe("[alabjs] Validation failed");
86
+ expect((err as Error & { zodError: unknown }).zodError).toBeDefined();
87
+ }
88
+ });
89
+
90
+ // ── Caching ──────────────────────────────────────────────────────────────
91
+
92
+ describe("with cache option", () => {
93
+ let callCount: number;
94
+
95
+ beforeEach(() => {
96
+ callCount = 0;
97
+ // Clear any cache from previous test
98
+ invalidateCacheKey(":undefined");
99
+ });
100
+
101
+ it("caches the result on first call and returns cached on second", async () => {
102
+ const fn = defineServerFn(
103
+ async (_ctx, _input) => {
104
+ callCount++;
105
+ return { count: callCount };
106
+ },
107
+ { cache: { ttl: 60, tags: ["test"] } },
108
+ );
109
+
110
+ const ctx = {
111
+ params: {},
112
+ query: {},
113
+ headers: {},
114
+ method: "GET" as const,
115
+ url: "/test",
116
+ };
117
+
118
+ const first = await fn(ctx, undefined);
119
+ const second = await fn(ctx, undefined);
120
+
121
+ // Both calls should return the same result
122
+ expect(first).toEqual(second);
123
+ // Handler should only have been called once (second was cached)
124
+ expect(callCount).toBe(1);
125
+ });
126
+ });
127
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { generateSitemap } from "./sitemap.js";
3
+ import type { Route } from "../router/manifest.js";
4
+
5
+ describe("generateSitemap", () => {
6
+ it("generates valid XML envelope", () => {
7
+ const xml = generateSitemap([], "https://example.com");
8
+ expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
9
+ expect(xml).toContain("<urlset");
10
+ expect(xml).toContain("</urlset>");
11
+ });
12
+
13
+ it("includes static page routes", () => {
14
+ const routes: Route[] = [
15
+ { path: "/", file: "app/page.tsx", kind: "page", ssr: false, params: [] },
16
+ { path: "/about", file: "app/about/page.tsx", kind: "page", ssr: false, params: [] },
17
+ ];
18
+ const xml = generateSitemap(routes, "https://example.com");
19
+ expect(xml).toContain("<loc>https://example.com/</loc>");
20
+ expect(xml).toContain("<loc>https://example.com/about</loc>");
21
+ });
22
+
23
+ it("excludes dynamic routes with [param]", () => {
24
+ const routes: Route[] = [
25
+ { path: "/", file: "app/page.tsx", kind: "page", ssr: false, params: [] },
26
+ { path: "/users/[id]", file: "app/users/[id]/page.tsx", kind: "page", ssr: false, params: ["id"] },
27
+ ];
28
+ const xml = generateSitemap(routes, "https://example.com");
29
+ expect(xml).toContain("<loc>https://example.com/</loc>");
30
+ expect(xml).not.toContain("/users/[id]");
31
+ });
32
+
33
+ it("excludes API routes", () => {
34
+ const routes: Route[] = [
35
+ { path: "/", file: "app/page.tsx", kind: "page", ssr: false, params: [] },
36
+ { path: "/api/health", file: "app/api/health/route.ts", kind: "api", ssr: false, params: [] },
37
+ ];
38
+ const xml = generateSitemap(routes, "https://example.com");
39
+ expect(xml).not.toContain("/api/health");
40
+ });
41
+
42
+ it("strips trailing slash from base URL", () => {
43
+ const routes: Route[] = [
44
+ { path: "/about", file: "app/about/page.tsx", kind: "page", ssr: false, params: [] },
45
+ ];
46
+ const xml = generateSitemap(routes, "https://example.com/");
47
+ expect(xml).toContain("<loc>https://example.com/about</loc>");
48
+ // Should NOT have double slash
49
+ expect(xml).not.toContain("https://example.com//about");
50
+ });
51
+
52
+ it("escapes XML special characters", () => {
53
+ const routes: Route[] = [
54
+ { path: "/search&filter", file: "app/search/page.tsx", kind: "page", ssr: false, params: [] },
55
+ ];
56
+ const xml = generateSitemap(routes, "https://example.com");
57
+ expect(xml).toContain("&amp;");
58
+ expect(xml).not.toContain("&filter");
59
+ });
60
+
61
+ it("includes changefreq for each URL", () => {
62
+ const routes: Route[] = [
63
+ { path: "/", file: "app/page.tsx", kind: "page", ssr: false, params: [] },
64
+ ];
65
+ const xml = generateSitemap(routes, "https://example.com");
66
+ expect(xml).toContain("<changefreq>weekly</changefreq>");
67
+ });
68
+ });
@@ -0,0 +1,30 @@
1
+ import type { Route } from "../router/manifest.js";
2
+
3
+ /**
4
+ * Generate a `/sitemap.xml` from the route manifest.
5
+ *
6
+ * Only static page routes are included — dynamic routes (containing `[param]`)
7
+ * cannot be enumerated without knowing all possible param values.
8
+ */
9
+ export function generateSitemap(routes: Route[], baseUrl: string): string {
10
+ const base = baseUrl.replace(/\/$/, "");
11
+
12
+ const urls = routes
13
+ .filter((r) => r.kind === "page" && !r.path.includes("["))
14
+ .map((r) => {
15
+ const loc = r.path === "/" ? base + "/" : base + r.path;
16
+ return ` <url>\n <loc>${escXml(loc)}</loc>\n <changefreq>weekly</changefreq>\n </url>`;
17
+ })
18
+ .join("\n");
19
+
20
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>`;
21
+ }
22
+
23
+ function escXml(s: string): string {
24
+ return s
25
+ .replace(/&/g, "&amp;")
26
+ .replace(/</g, "&lt;")
27
+ .replace(/>/g, "&gt;")
28
+ .replace(/"/g, "&quot;")
29
+ .replace(/'/g, "&apos;");
30
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { defineSSEHandler, type SSEEvent } from "./sse.js";
3
+
4
+ describe("defineSSEHandler", () => {
5
+ it("returns a function", () => {
6
+ const handler = defineSSEHandler(async function* () {});
7
+ expect(typeof handler).toBe("function");
8
+ });
9
+
10
+ it("returns a Response with correct SSE headers", () => {
11
+ const handler = defineSSEHandler(async function* () {});
12
+ const req = new Request("http://localhost/api/events");
13
+ const res = handler(req);
14
+
15
+ expect(res).toBeInstanceOf(Response);
16
+ expect(res.status).toBe(200);
17
+ expect(res.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
18
+ expect(res.headers.get("cache-control")).toBe("no-cache, no-transform");
19
+ expect(res.headers.get("connection")).toBe("keep-alive");
20
+ expect(res.headers.get("x-accel-buffering")).toBe("no");
21
+ });
22
+
23
+ it("streams SSE events with correct wire format", async () => {
24
+ const handler = defineSSEHandler(async function* () {
25
+ yield { event: "price", data: { ticker: "BTC", price: 42000 }, id: "1" } as SSEEvent;
26
+ yield { data: "plain message" } as SSEEvent;
27
+ });
28
+
29
+ const req = new Request("http://localhost/api/events");
30
+ const res = handler(req);
31
+ const text = await res.text();
32
+
33
+ // Should start with the connection comment
34
+ expect(text).toContain(": connected\n\n");
35
+ // Named event
36
+ expect(text).toContain("event: price\n");
37
+ expect(text).toContain("id: 1\n");
38
+ expect(text).toContain('data: {"ticker":"BTC","price":42000}\n');
39
+ // Default message event
40
+ expect(text).toContain('data: "plain message"\n');
41
+ });
42
+
43
+ it("handles events with retry field", async () => {
44
+ const handler = defineSSEHandler(async function* () {
45
+ yield { data: "reconnect", retry: 5000 } as SSEEvent;
46
+ });
47
+
48
+ const req = new Request("http://localhost/api/events");
49
+ const res = handler(req);
50
+ const text = await res.text();
51
+
52
+ expect(text).toContain("retry: 5000\n");
53
+ });
54
+
55
+ it("handles null data (ping frames)", async () => {
56
+ const handler = defineSSEHandler(async function* () {
57
+ yield { event: "ping", data: null } as SSEEvent;
58
+ });
59
+
60
+ const req = new Request("http://localhost/api/events");
61
+ const res = handler(req);
62
+ const text = await res.text();
63
+
64
+ expect(text).toContain("event: ping\n");
65
+ expect(text).toContain("data: \n");
66
+ });
67
+
68
+ it("closes stream cleanly when generator throws", async () => {
69
+ const handler = defineSSEHandler(async function* () {
70
+ yield { data: "before error" } as SSEEvent;
71
+ throw new Error("stream error");
72
+ });
73
+
74
+ const req = new Request("http://localhost/api/events");
75
+ const res = handler(req);
76
+ // Should not throw — error is caught internally
77
+ const text = await res.text();
78
+ expect(text).toContain(': connected');
79
+ expect(text).toContain('data: "before error"');
80
+ });
81
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Alab SSE — Server-Sent Events for API routes.
3
+ *
4
+ * `defineSSEHandler` wraps an async generator into a standard `Response` that
5
+ * streams SSE to the browser. Drop it into any `route.ts` as the `GET` export.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * // app/api/prices/route.ts
10
+ * import { defineSSEHandler } from "alabjs/server";
11
+ *
12
+ * export const GET = defineSSEHandler(async function* (req) {
13
+ * const url = new URL(req.url);
14
+ * const ticker = url.searchParams.get("ticker") ?? "BTC";
15
+ *
16
+ * for (let i = 0; i < 100; i++) {
17
+ * yield { event: "price", data: { ticker, price: Math.random() * 1000 }, id: String(i) };
18
+ * await new Promise((r) => setTimeout(r, 1000));
19
+ * }
20
+ *
21
+ * // Signal the client the stream is done
22
+ * yield { event: "done", data: null };
23
+ * });
24
+ * ```
25
+ */
26
+
27
+ // ─── SSE event shape ──────────────────────────────────────────────────────────
28
+
29
+ export interface SSEEvent<T = unknown> {
30
+ /** Named event type. Defaults to `"message"` when omitted. */
31
+ event?: string;
32
+ /** Payload — serialised to JSON automatically. Pass `null` for ping frames. */
33
+ data: T;
34
+ /** Optional event ID for `lastEventId` reconnect support. */
35
+ id?: string;
36
+ /** Retry hint in milliseconds (sent as `retry:` field). */
37
+ retry?: number;
38
+ }
39
+
40
+ // ─── Serialise one event to the SSE wire format ───────────────────────────────
41
+
42
+ function encodeEvent(evt: SSEEvent): string {
43
+ let frame = "";
44
+ if (evt.id !== undefined) frame += `id: ${evt.id}\n`;
45
+ if (evt.event) frame += `event: ${evt.event}\n`;
46
+ if (evt.retry !== undefined) frame += `retry: ${evt.retry}\n`;
47
+ frame += `data: ${evt.data === null ? "" : JSON.stringify(evt.data)}\n`;
48
+ frame += "\n"; // blank line terminates the event
49
+ return frame;
50
+ }
51
+
52
+ // ─── defineSSEHandler ─────────────────────────────────────────────────────────
53
+
54
+ type SSEGenerator<T> = (req: Request) => AsyncGenerator<SSEEvent<T>>;
55
+
56
+ /**
57
+ * Wrap an async generator into an SSE-streaming `GET` handler.
58
+ *
59
+ * The returned function is a standard `(req: Request) => Response` that is
60
+ * directly usable as `export const GET` in an `app/.../ route.ts`.
61
+ *
62
+ * The generator can `yield` as many events as needed. When it returns (or
63
+ * throws), the stream is closed. The client can reconnect automatically via
64
+ * the browser's native `EventSource` retry behaviour.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * export const GET = defineSSEHandler(async function* (req) {
69
+ * while (true) {
70
+ * yield { data: { time: Date.now() } };
71
+ * await new Promise((r) => setTimeout(r, 2000));
72
+ * }
73
+ * });
74
+ * ```
75
+ */
76
+ export function defineSSEHandler<T = unknown>(
77
+ generator: SSEGenerator<T>,
78
+ ): (req: Request) => Response {
79
+ return (req: Request): Response => {
80
+ const encoder = new TextEncoder();
81
+
82
+ const body = new ReadableStream({
83
+ async start(controller) {
84
+ // Send an initial comment to flush the connection through proxies / nginx
85
+ controller.enqueue(encoder.encode(": connected\n\n"));
86
+
87
+ try {
88
+ for await (const evt of generator(req)) {
89
+ controller.enqueue(encoder.encode(encodeEvent(evt as SSEEvent)));
90
+ }
91
+ } catch {
92
+ // Generator threw — close stream cleanly so the client can retry
93
+ } finally {
94
+ controller.close();
95
+ }
96
+ },
97
+ });
98
+
99
+ return new Response(body, {
100
+ status: 200,
101
+ headers: {
102
+ "content-type": "text/event-stream; charset=utf-8",
103
+ "cache-control": "no-cache, no-transform",
104
+ "connection": "keep-alive",
105
+ // Disable nginx / Cloudflare buffering
106
+ "x-accel-buffering": "no",
107
+ },
108
+ });
109
+ };
110
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Alab Signals — granular reactivity within a React tree.
3
+ *
4
+ * Signals are observable values that trigger precise re-renders only in the
5
+ * components that read them — no context provider needed, no full-tree
6
+ * reconciliation. Inspired by SolidJS signals, built on React 18's
7
+ * `useSyncExternalStore` for correctness in concurrent mode.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * // signals.ts — define once, use anywhere
12
+ * import { signal } from "alabjs/signals";
13
+ *
14
+ * export const count = signal(0);
15
+ * export const user = signal<User | null>(null);
16
+ * ```
17
+ *
18
+ * ```tsx
19
+ * // Counter.tsx — only this component re-renders when count changes
20
+ * import { useSignal } from "alabjs/signals";
21
+ * import { count } from "../signals.js";
22
+ *
23
+ * export function Counter() {
24
+ * const [value, setCount] = useSignal(count);
25
+ * return <button onClick={() => setCount(v => v + 1)}>{value}</button>;
26
+ * }
27
+ * ```
28
+ */
29
+
30
+ import { useSyncExternalStore, useCallback } from "react";
31
+
32
+ // ─── Signal primitive ─────────────────────────────────────────────────────────
33
+
34
+ export interface Signal<T> {
35
+ /** Read the current value (non-reactive — use `useSignalValue` inside components). */
36
+ get(): T;
37
+ /** Write a new value and notify all subscribers. */
38
+ set(value: T | ((prev: T) => T)): void;
39
+ /** @internal Subscribe to changes — consumed by `useSyncExternalStore`. */
40
+ subscribe(listener: () => void): () => void;
41
+ /** @internal Snapshot getter for SSR — returns current value synchronously. */
42
+ getSnapshot(): T;
43
+ }
44
+
45
+ /**
46
+ * Create a new signal with an initial value.
47
+ *
48
+ * Signals are plain objects — they don't require a React tree to exist.
49
+ * Define them at module scope and import them wherever needed.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * export const darkMode = signal(false);
54
+ * export const cart = signal<CartItem[]>([]);
55
+ * ```
56
+ */
57
+ export function signal<T>(initial: T): Signal<T> {
58
+ let _value = initial;
59
+ const _listeners = new Set<() => void>();
60
+
61
+ const notify = () => _listeners.forEach((l) => l());
62
+
63
+ return {
64
+ get() {
65
+ return _value;
66
+ },
67
+ set(next) {
68
+ const resolved = typeof next === "function"
69
+ ? (next as (prev: T) => T)(_value)
70
+ : next;
71
+ if (Object.is(resolved, _value)) return; // bail if unchanged
72
+ _value = resolved;
73
+ notify();
74
+ },
75
+ subscribe(listener) {
76
+ _listeners.add(listener);
77
+ return () => _listeners.delete(listener);
78
+ },
79
+ getSnapshot() {
80
+ return _value;
81
+ },
82
+ };
83
+ }
84
+
85
+ // ─── React hooks ──────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Subscribe to a signal's value — re-renders only when the signal changes.
89
+ * Returns `[value, setter]`, matching the `useState` API.
90
+ *
91
+ * @example
92
+ * ```tsx
93
+ * const [isDark, setDark] = useSignal(darkMode);
94
+ * <button onClick={() => setDark(v => !v)}>{isDark ? "☀️" : "🌙"}</button>
95
+ * ```
96
+ */
97
+ export function useSignal<T>(sig: Signal<T>): [T, (value: T | ((prev: T) => T)) => void] {
98
+ const value = useSyncExternalStore(sig.subscribe, sig.getSnapshot, sig.getSnapshot);
99
+ // Stable setter — signal.set is already referentially stable
100
+ const set = useCallback((next: T | ((prev: T) => T)) => sig.set(next), [sig]);
101
+ return [value, set];
102
+ }
103
+
104
+ /**
105
+ * Subscribe to a signal's value (read-only).
106
+ * Slightly cheaper than `useSignal` when you only need to read.
107
+ *
108
+ * @example
109
+ * ```tsx
110
+ * const count = useSignalValue(counter);
111
+ * return <span>{count}</span>;
112
+ * ```
113
+ */
114
+ export function useSignalValue<T>(sig: Signal<T>): T {
115
+ return useSyncExternalStore(sig.subscribe, sig.getSnapshot, sig.getSnapshot);
116
+ }
117
+
118
+ /**
119
+ * Returns a stable setter for a signal without subscribing to its value.
120
+ * Use this in components that only write but never display the signal's value —
121
+ * they won't re-render when the signal changes.
122
+ *
123
+ * @example
124
+ * ```tsx
125
+ * const setCount = useSignalSetter(counter);
126
+ * return <button onClick={() => setCount(v => v + 1)}>Increment</button>;
127
+ * ```
128
+ */
129
+ export function useSignalSetter<T>(sig: Signal<T>): (value: T | ((prev: T) => T)) => void {
130
+ return useCallback((next: T | ((prev: T) => T)) => sig.set(next), [sig]);
131
+ }
132
+
133
+ /**
134
+ * Derive a computed value from one or more signals.
135
+ * The derived signal updates automatically when any source signal changes.
136
+ *
137
+ * @example
138
+ * ```ts
139
+ * const firstName = signal("Ada");
140
+ * const lastName = signal("Lovelace");
141
+ * const fullName = computed([firstName, lastName], ([f, l]) => `${f} ${l}`);
142
+ * // fullName.get() === "Ada Lovelace"
143
+ * ```
144
+ */
145
+ export function computed<Sources extends Signal<any>[], T>(
146
+ sources: [...Sources],
147
+ derive: (values: { [K in keyof Sources]: Sources[K] extends Signal<infer V> ? V : never }) => T,
148
+ ): Omit<Signal<T>, "set"> {
149
+ // Read initial value
150
+ const readValues = () =>
151
+ sources.map((s) => s.get()) as { [K in keyof Sources]: Sources[K] extends Signal<infer V> ? V : never };
152
+
153
+ let _cached = derive(readValues());
154
+ const _listeners = new Set<() => void>();
155
+
156
+ const recompute = () => {
157
+ const next = derive(readValues());
158
+ if (!Object.is(next, _cached)) {
159
+ _cached = next;
160
+ _listeners.forEach((l) => l());
161
+ }
162
+ };
163
+
164
+ // Subscribe to all source signals
165
+ for (const src of sources) {
166
+ src.subscribe(recompute);
167
+ }
168
+
169
+ return {
170
+ get() { return _cached; },
171
+ getSnapshot() { return _cached; },
172
+ subscribe(listener) {
173
+ _listeners.add(listener);
174
+ return () => _listeners.delete(listener);
175
+ },
176
+ };
177
+ }