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.
- package/dist/adapters/cloudflare.d.ts +31 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -0
- package/dist/adapters/cloudflare.js +30 -0
- package/dist/adapters/cloudflare.js.map +1 -0
- package/dist/adapters/deno.d.ts +22 -0
- package/dist/adapters/deno.d.ts.map +1 -0
- package/dist/adapters/deno.js +21 -0
- package/dist/adapters/deno.js.map +1 -0
- package/dist/adapters/web.d.ts +47 -0
- package/dist/adapters/web.d.ts.map +1 -0
- package/dist/adapters/web.js +212 -0
- package/dist/adapters/web.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +61 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/hooks.d.ts +119 -0
- package/dist/client/hooks.d.ts.map +1 -0
- package/dist/client/hooks.js +220 -0
- package/dist/client/hooks.js.map +1 -0
- package/dist/client/hooks.test.d.ts +2 -0
- package/dist/client/hooks.test.d.ts.map +1 -0
- package/dist/client/hooks.test.js +45 -0
- package/dist/client/hooks.test.js.map +1 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +4 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/offline.d.ts +52 -0
- package/dist/client/offline.d.ts.map +1 -0
- package/dist/client/offline.js +90 -0
- package/dist/client/offline.js.map +1 -0
- package/dist/client/provider.d.ts +12 -0
- package/dist/client/provider.d.ts.map +1 -0
- package/dist/client/provider.js +10 -0
- package/dist/client/provider.js.map +1 -0
- package/dist/commands/build.d.ts +18 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +173 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/dev.d.ts +8 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +447 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/info.d.ts +6 -0
- package/dist/commands/info.d.ts.map +1 -0
- package/dist/commands/info.js +92 -0
- package/dist/commands/info.js.map +1 -0
- package/dist/commands/ssg.d.ts +8 -0
- package/dist/commands/ssg.d.ts.map +1 -0
- package/dist/commands/ssg.js +124 -0
- package/dist/commands/ssg.js.map +1 -0
- package/dist/commands/start.d.ts +7 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +26 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/test.d.ts +24 -0
- package/dist/commands/test.d.ts.map +1 -0
- package/dist/commands/test.js +87 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/components/ErrorBoundary.d.ts +38 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.js +46 -0
- package/dist/components/ErrorBoundary.js.map +1 -0
- package/dist/components/Font.d.ts +57 -0
- package/dist/components/Font.d.ts.map +1 -0
- package/dist/components/Font.js +33 -0
- package/dist/components/Font.js.map +1 -0
- package/dist/components/Image.d.ts +74 -0
- package/dist/components/Image.d.ts.map +1 -0
- package/dist/components/Image.js +85 -0
- package/dist/components/Image.js.map +1 -0
- package/dist/components/Link.d.ts +23 -0
- package/dist/components/Link.d.ts.map +1 -0
- package/dist/components/Link.js +48 -0
- package/dist/components/Link.js.map +1 -0
- package/dist/components/Script.d.ts +37 -0
- package/dist/components/Script.d.ts.map +1 -0
- package/dist/components/Script.js +70 -0
- package/dist/components/Script.js.map +1 -0
- package/dist/components/index.d.ts +10 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +6 -0
- package/dist/components/index.js.map +1 -0
- package/dist/i18n/i18n.test.d.ts +2 -0
- package/dist/i18n/i18n.test.d.ts.map +1 -0
- package/dist/i18n/i18n.test.js +132 -0
- package/dist/i18n/i18n.test.js.map +1 -0
- package/dist/i18n/index.d.ts +135 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +189 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/router/code-router.d.ts +204 -0
- package/dist/router/code-router.d.ts.map +1 -0
- package/dist/router/code-router.js +258 -0
- package/dist/router/code-router.js.map +1 -0
- package/dist/router/code-router.test.d.ts +2 -0
- package/dist/router/code-router.test.d.ts.map +1 -0
- package/dist/router/code-router.test.js +128 -0
- package/dist/router/code-router.test.js.map +1 -0
- package/dist/router/index.d.ts +4 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +2 -0
- package/dist/router/index.js.map +1 -0
- package/dist/router/manifest.d.ts +12 -0
- package/dist/router/manifest.d.ts.map +1 -0
- package/dist/router/manifest.js +2 -0
- package/dist/router/manifest.js.map +1 -0
- package/dist/server/app.d.ts +13 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +407 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/cache.d.ts +99 -0
- package/dist/server/cache.d.ts.map +1 -0
- package/dist/server/cache.js +161 -0
- package/dist/server/cache.js.map +1 -0
- package/dist/server/cache.test.d.ts +2 -0
- package/dist/server/cache.test.d.ts.map +1 -0
- package/dist/server/cache.test.js +150 -0
- package/dist/server/cache.test.js.map +1 -0
- package/dist/server/csrf.d.ts +28 -0
- package/dist/server/csrf.d.ts.map +1 -0
- package/dist/server/csrf.js +66 -0
- package/dist/server/csrf.js.map +1 -0
- package/dist/server/csrf.test.d.ts +2 -0
- package/dist/server/csrf.test.d.ts.map +1 -0
- package/dist/server/csrf.test.js +154 -0
- package/dist/server/csrf.test.js.map +1 -0
- package/dist/server/image.d.ts +18 -0
- package/dist/server/image.d.ts.map +1 -0
- package/dist/server/image.js +97 -0
- package/dist/server/image.js.map +1 -0
- package/dist/server/index.d.ts +57 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +58 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/middleware.d.ts +53 -0
- package/dist/server/middleware.d.ts.map +1 -0
- package/dist/server/middleware.js +80 -0
- package/dist/server/middleware.js.map +1 -0
- package/dist/server/middleware.test.d.ts +2 -0
- package/dist/server/middleware.test.d.ts.map +1 -0
- package/dist/server/middleware.test.js +125 -0
- package/dist/server/middleware.test.js.map +1 -0
- package/dist/server/revalidate.d.ts +49 -0
- package/dist/server/revalidate.d.ts.map +1 -0
- package/dist/server/revalidate.js +62 -0
- package/dist/server/revalidate.js.map +1 -0
- package/dist/server/revalidate.test.d.ts +2 -0
- package/dist/server/revalidate.test.d.ts.map +1 -0
- package/dist/server/revalidate.test.js +93 -0
- package/dist/server/revalidate.test.js.map +1 -0
- package/dist/server/server-fn.test.d.ts +2 -0
- package/dist/server/server-fn.test.d.ts.map +1 -0
- package/dist/server/server-fn.test.js +105 -0
- package/dist/server/server-fn.test.js.map +1 -0
- package/dist/server/sitemap.d.ts +9 -0
- package/dist/server/sitemap.d.ts.map +1 -0
- package/dist/server/sitemap.js +26 -0
- package/dist/server/sitemap.js.map +1 -0
- package/dist/server/sitemap.test.d.ts +2 -0
- package/dist/server/sitemap.test.d.ts.map +1 -0
- package/dist/server/sitemap.test.js +61 -0
- package/dist/server/sitemap.test.js.map +1 -0
- package/dist/server/sse.d.ts +59 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +91 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/sse.test.d.ts +2 -0
- package/dist/server/sse.test.d.ts.map +1 -0
- package/dist/server/sse.test.js +68 -0
- package/dist/server/sse.test.js.map +1 -0
- package/dist/signals/index.d.ts +101 -0
- package/dist/signals/index.d.ts.map +1 -0
- package/dist/signals/index.js +149 -0
- package/dist/signals/index.js.map +1 -0
- package/dist/signals/signals.test.d.ts +2 -0
- package/dist/signals/signals.test.d.ts.map +1 -0
- package/dist/signals/signals.test.js +146 -0
- package/dist/signals/signals.test.js.map +1 -0
- package/dist/ssr/html.d.ts +27 -0
- package/dist/ssr/html.d.ts.map +1 -0
- package/dist/ssr/html.js +107 -0
- package/dist/ssr/html.js.map +1 -0
- package/dist/ssr/html.test.d.ts +2 -0
- package/dist/ssr/html.test.d.ts.map +1 -0
- package/dist/ssr/html.test.js +178 -0
- package/dist/ssr/html.test.js.map +1 -0
- package/dist/ssr/render.d.ts +46 -0
- package/dist/ssr/render.d.ts.map +1 -0
- package/dist/ssr/render.js +87 -0
- package/dist/ssr/render.js.map +1 -0
- package/dist/ssr/router-dev.d.ts +60 -0
- package/dist/ssr/router-dev.d.ts.map +1 -0
- package/dist/ssr/router-dev.js +205 -0
- package/dist/ssr/router-dev.js.map +1 -0
- package/dist/ssr/router-dev.test.d.ts +2 -0
- package/dist/ssr/router-dev.test.d.ts.map +1 -0
- package/dist/ssr/router-dev.test.js +189 -0
- package/dist/ssr/router-dev.test.js.map +1 -0
- package/dist/test/index.d.ts +93 -0
- package/dist/test/index.d.ts.map +1 -0
- package/dist/test/index.js +146 -0
- package/dist/test/index.js.map +1 -0
- package/dist/types/index.d.ts +117 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/napi.d.ts +15 -0
- package/dist/types/napi.d.ts.map +1 -0
- package/dist/types/napi.js +2 -0
- package/dist/types/napi.js.map +1 -0
- package/package.json +107 -0
- package/src/adapters/cloudflare.ts +30 -0
- package/src/adapters/deno.ts +21 -0
- package/src/adapters/web.ts +259 -0
- package/src/cli.ts +68 -0
- package/src/client/hooks.test.ts +54 -0
- package/src/client/hooks.ts +329 -0
- package/src/client/index.ts +5 -0
- package/src/client/offline-sw.ts +191 -0
- package/src/client/offline.ts +114 -0
- package/src/client/provider.tsx +14 -0
- package/src/commands/build.ts +201 -0
- package/src/commands/dev.ts +509 -0
- package/src/commands/info.ts +111 -0
- package/src/commands/ssg.ts +177 -0
- package/src/commands/start.ts +32 -0
- package/src/commands/test.ts +102 -0
- package/src/components/ErrorBoundary.tsx +73 -0
- package/src/components/Font.tsx +100 -0
- package/src/components/Image.tsx +141 -0
- package/src/components/Link.tsx +64 -0
- package/src/components/Script.tsx +97 -0
- package/src/components/index.ts +9 -0
- package/src/i18n/i18n.test.tsx +169 -0
- package/src/i18n/index.tsx +256 -0
- package/src/index.ts +10 -0
- package/src/router/code-router.test.ts +146 -0
- package/src/router/code-router.tsx +459 -0
- package/src/router/index.ts +18 -0
- package/src/router/manifest.ts +13 -0
- package/src/server/app.ts +466 -0
- package/src/server/cache.test.ts +192 -0
- package/src/server/cache.ts +195 -0
- package/src/server/csrf.test.ts +199 -0
- package/src/server/csrf.ts +80 -0
- package/src/server/image.ts +112 -0
- package/src/server/index.ts +144 -0
- package/src/server/middleware.test.ts +151 -0
- package/src/server/middleware.ts +95 -0
- package/src/server/revalidate.test.ts +106 -0
- package/src/server/revalidate.ts +75 -0
- package/src/server/server-fn.test.ts +127 -0
- package/src/server/sitemap.test.ts +68 -0
- package/src/server/sitemap.ts +30 -0
- package/src/server/sse.test.ts +81 -0
- package/src/server/sse.ts +110 -0
- package/src/signals/index.ts +177 -0
- package/src/signals/signals.test.ts +164 -0
- package/src/ssr/html.test.ts +200 -0
- package/src/ssr/html.ts +140 -0
- package/src/ssr/render.ts +144 -0
- package/src/ssr/router-dev.test.ts +230 -0
- package/src/ssr/router-dev.ts +229 -0
- package/src/test/index.ts +206 -0
- package/src/types/compiler.d.ts +25 -0
- package/src/types/index.ts +147 -0
- package/src/types/napi.ts +20 -0
- package/src/types/plugins.d.ts +3 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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("&");
|
|
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, "&")
|
|
26
|
+
.replace(/</g, "<")
|
|
27
|
+
.replace(/>/g, ">")
|
|
28
|
+
.replace(/"/g, """)
|
|
29
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|