@bractjs/bractjs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +586 -0
- package/bin/cli.ts +101 -0
- package/package.json +58 -0
- package/src/__tests__/fixtures/app/root.tsx +9 -0
- package/src/__tests__/fixtures/app/routes/_index.tsx +20 -0
- package/src/__tests__/integration.test.ts +66 -0
- package/src/__tests__/loader.test.ts +89 -0
- package/src/__tests__/matcher.test.ts +69 -0
- package/src/__tests__/meta.test.ts +81 -0
- package/src/__tests__/scanner.test.ts +58 -0
- package/src/__tests__/session.test.ts +103 -0
- package/src/build/bundler.ts +75 -0
- package/src/build/defines.ts +16 -0
- package/src/build/directives.ts +67 -0
- package/src/build/env-plugin.ts +56 -0
- package/src/build/hash.ts +56 -0
- package/src/build/manifest.ts +60 -0
- package/src/client/ClientRouter.tsx +122 -0
- package/src/client/components/Await.tsx +26 -0
- package/src/client/components/Form.tsx +67 -0
- package/src/client/components/Image.tsx +79 -0
- package/src/client/components/Link.tsx +42 -0
- package/src/client/components/LiveReload.tsx +16 -0
- package/src/client/components/Outlet.tsx +64 -0
- package/src/client/components/Scripts.tsx +12 -0
- package/src/client/entry.tsx +49 -0
- package/src/client/form-utils.ts +12 -0
- package/src/client/hooks/useActionData.ts +14 -0
- package/src/client/hooks/useFetcher.ts +51 -0
- package/src/client/hooks/useLoaderData.ts +14 -0
- package/src/client/hooks/useNavigation.ts +12 -0
- package/src/client/hooks/useParams.ts +14 -0
- package/src/client/nav-utils.ts +35 -0
- package/src/client/prefetch.ts +32 -0
- package/src/client/route-cache.ts +20 -0
- package/src/client/router.tsx +54 -0
- package/src/client/types.ts +23 -0
- package/src/codegen/route-codegen.ts +99 -0
- package/src/dev/error-overlay.ts +33 -0
- package/src/dev/hmr-client.ts +43 -0
- package/src/dev/hmr-module-handler.ts +47 -0
- package/src/dev/hmr-server.ts +51 -0
- package/src/dev/rebuilder.ts +95 -0
- package/src/dev/server.ts +38 -0
- package/src/dev/watcher.ts +32 -0
- package/src/image/cache.ts +75 -0
- package/src/image/handler.ts +82 -0
- package/src/image/optimizer.ts +76 -0
- package/src/image/types.ts +27 -0
- package/src/index.ts +51 -0
- package/src/middleware/authGuard.ts +37 -0
- package/src/middleware/cors.ts +36 -0
- package/src/middleware/requestLogger.ts +15 -0
- package/src/server/action-handler.ts +35 -0
- package/src/server/action-registry.ts +41 -0
- package/src/server/env.ts +29 -0
- package/src/server/index.ts +8 -0
- package/src/server/layout.ts +92 -0
- package/src/server/loader.ts +80 -0
- package/src/server/matcher.ts +99 -0
- package/src/server/meta.ts +92 -0
- package/src/server/middleware.ts +47 -0
- package/src/server/render.ts +65 -0
- package/src/server/request-handler.ts +142 -0
- package/src/server/response.ts +17 -0
- package/src/server/scanner.ts +68 -0
- package/src/server/serve.ts +131 -0
- package/src/server/session.ts +111 -0
- package/src/server/static.ts +40 -0
- package/src/shared/context.ts +37 -0
- package/src/shared/deferred.ts +55 -0
- package/src/shared/error-boundary.tsx +58 -0
- package/src/shared/errors.ts +49 -0
- package/src/shared/route-types.ts +51 -0
- package/templates/new-app/app/root.tsx +20 -0
- package/templates/new-app/app/routes/_index.tsx +31 -0
- package/templates/new-app/app/routes/about.tsx +21 -0
- package/templates/new-app/bractjs.config.ts +14 -0
- package/templates/new-app/package.json +20 -0
- package/types/config.d.ts +25 -0
- package/types/index.d.ts +109 -0
- package/types/middleware.d.ts +19 -0
- package/types/route.d.ts +41 -0
- package/types/session.d.ts +32 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { BractJSConfig } from "../server/serve.ts";
|
|
2
|
+
import { useServerProxyPlugin } from "../build/directives.ts";
|
|
3
|
+
import { scanRoutes } from "../server/scanner.ts";
|
|
4
|
+
import { generateManifest, writeManifest } from "../build/manifest.ts";
|
|
5
|
+
import { mkdir, rename, rm } from "node:fs/promises";
|
|
6
|
+
import { join, resolve, basename, extname } from "node:path";
|
|
7
|
+
|
|
8
|
+
// Shim filename written inside the demo app's CWD during build (then deleted).
|
|
9
|
+
// The framework's entry.tsx lives outside CWD. When Bun sees entrypoints outside
|
|
10
|
+
// CWD it picks a common-ancestor project root, placing the entry at a nested
|
|
11
|
+
// virtual path and generating ../ traversals in chunk imports. Those traversals
|
|
12
|
+
// produce wrong URLs after we rename entry.js → client.js. A shim inside CWD
|
|
13
|
+
// keeps all entrypoints under one root so chunk refs stay flat and correct.
|
|
14
|
+
const SHIM = ".bractjs-entry.tsx";
|
|
15
|
+
|
|
16
|
+
export async function rebuildClient(
|
|
17
|
+
config?: Partial<BractJSConfig>,
|
|
18
|
+
): Promise<{ duration: number }> {
|
|
19
|
+
const start = Date.now();
|
|
20
|
+
const appDir = config?.appDir ?? "./app";
|
|
21
|
+
const pkgRoot = resolve(import.meta.dirname, "../..");
|
|
22
|
+
const outdir = resolve(process.cwd(), config?.buildDir ?? "build", "client");
|
|
23
|
+
const buildDir = resolve(process.cwd(), config?.buildDir ?? "build");
|
|
24
|
+
const entrypoint = resolve(pkgRoot, "src/client/entry.tsx");
|
|
25
|
+
|
|
26
|
+
// Clean output dir so stale artifacts from previous builds can't be served.
|
|
27
|
+
await rm(outdir, { recursive: true, force: true });
|
|
28
|
+
await mkdir(outdir, { recursive: true });
|
|
29
|
+
|
|
30
|
+
const routes = await scanRoutes(appDir);
|
|
31
|
+
const routePaths = routes.map((r) => resolve(process.cwd(), appDir, r.filePath));
|
|
32
|
+
const rootPath = resolve(process.cwd(), appDir, "root.tsx");
|
|
33
|
+
const appDirClean = appDir.replace(/^\.\//, ""); // "./app" → "app"
|
|
34
|
+
|
|
35
|
+
// Write shim, build, always delete shim.
|
|
36
|
+
const shimPath = resolve(process.cwd(), SHIM);
|
|
37
|
+
await Bun.write(shimPath, `import "${entrypoint}";\nexport {};\n`);
|
|
38
|
+
|
|
39
|
+
let result: Awaited<ReturnType<typeof Bun.build>>;
|
|
40
|
+
try {
|
|
41
|
+
result = await Bun.build({
|
|
42
|
+
entrypoints: [shimPath, rootPath, ...routePaths],
|
|
43
|
+
target: "browser",
|
|
44
|
+
splitting: true, // shared React chunk prevents dual-React / invalid hook call
|
|
45
|
+
outdir,
|
|
46
|
+
// No publicPath: relative refs resolve correctly because static.ts serves
|
|
47
|
+
// /build/client/ directly from outdir, so the URL structure matches the file
|
|
48
|
+
// structure. publicPath + ../ traversals produce wrong absolute URLs.
|
|
49
|
+
minify: false,
|
|
50
|
+
sourcemap: "inline",
|
|
51
|
+
plugins: [useServerProxyPlugin],
|
|
52
|
+
});
|
|
53
|
+
} finally {
|
|
54
|
+
await rm(shimPath, { force: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!result.success) {
|
|
58
|
+
for (const log of result.logs) console.error("[bractjs] build error:", log);
|
|
59
|
+
return { duration: Date.now() - start };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const routeChunks = new Map<string, string>();
|
|
63
|
+
const clientEntry = "/build/client/client.js";
|
|
64
|
+
const shimBase = basename(SHIM, extname(SHIM)); // ".bractjs-entry"
|
|
65
|
+
let rootChunk: string | undefined;
|
|
66
|
+
|
|
67
|
+
for (const output of result.outputs) {
|
|
68
|
+
if (output.kind !== "entry-point") continue;
|
|
69
|
+
const outBase = basename(output.path, extname(output.path));
|
|
70
|
+
// rel: path of this output relative to outdir, e.g. "app/routes/_index.js"
|
|
71
|
+
const rel = output.path.slice(outdir.length + 1);
|
|
72
|
+
|
|
73
|
+
if (outBase === shimBase) {
|
|
74
|
+
// Rename shim output → client.js
|
|
75
|
+
const target = join(outdir, "client.js");
|
|
76
|
+
if (output.path !== target) await rename(output.path, target);
|
|
77
|
+
} else if (rel === join(appDirClean, "root.js")) {
|
|
78
|
+
// Root component chunk — the shell that wraps <Outlet />
|
|
79
|
+
rootChunk = "/build/client/" + rel;
|
|
80
|
+
} else {
|
|
81
|
+
// Match by full relative path to avoid basename collisions (_index appears N times).
|
|
82
|
+
// Input: appDirClean/r.filePath. Output mirrors that structure under outdir.
|
|
83
|
+
const matched = routes.find((r) => {
|
|
84
|
+
const expected = join(appDirClean, r.filePath).replace(/\.[^.]+$/, ".js");
|
|
85
|
+
return rel === expected;
|
|
86
|
+
});
|
|
87
|
+
if (matched) {
|
|
88
|
+
routeChunks.set(matched.urlPattern, "/build/client/" + rel);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await writeManifest(generateManifest({ clientEntry, rootChunk, routeChunks }), buildDir);
|
|
94
|
+
return { duration: Date.now() - start };
|
|
95
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createServer } from "../server/serve.ts";
|
|
2
|
+
import { createHmrServer } from "./hmr-server.ts";
|
|
3
|
+
import { watchApp } from "./watcher.ts";
|
|
4
|
+
import { rebuildClient } from "./rebuilder.ts";
|
|
5
|
+
import { filePathToPattern } from "../server/scanner.ts";
|
|
6
|
+
import { basename, extname } from "node:path";
|
|
7
|
+
|
|
8
|
+
const hmr = createHmrServer(3001);
|
|
9
|
+
|
|
10
|
+
// Build client bundle before the HTTP server starts accepting requests
|
|
11
|
+
const { duration: initialMs } = await rebuildClient();
|
|
12
|
+
console.log(`[bractjs] initial client build in ${initialMs}ms`);
|
|
13
|
+
|
|
14
|
+
createServer({ port: 3000 });
|
|
15
|
+
|
|
16
|
+
watchApp("./app", async (file) => {
|
|
17
|
+
const { duration } = await rebuildClient();
|
|
18
|
+
|
|
19
|
+
// Route files (not layout): do a fine-grained module swap without full reload.
|
|
20
|
+
// Root, layouts, and other files: fall back to full page reload.
|
|
21
|
+
const isRoute =
|
|
22
|
+
file.startsWith("routes/") &&
|
|
23
|
+
!file.endsWith("layout.tsx") &&
|
|
24
|
+
!file.endsWith("layout.ts");
|
|
25
|
+
|
|
26
|
+
if (isRoute) {
|
|
27
|
+
const pattern = filePathToPattern(file);
|
|
28
|
+
// Chunk URL = same basename as route file; splitting build puts it in build/client/
|
|
29
|
+
const chunkUrl = `/build/client/${basename(file, extname(file))}.js`;
|
|
30
|
+
hmr.broadcast({ type: "hmr:route", pattern, chunkUrl, file, duration });
|
|
31
|
+
console.log(`✓ ${file} → module swap (pattern="${pattern}") in ${duration}ms`);
|
|
32
|
+
} else {
|
|
33
|
+
hmr.broadcast({ type: "hmr:reload", file, duration });
|
|
34
|
+
console.log(`✓ ${file} → full reload in ${duration}ms`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
console.log("BractJS dev server on http://localhost:3000");
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { watch } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const WATCHED_EXTENSIONS = new Set([".tsx", ".ts", ".css"]);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Watches appDir for file changes and calls onChange with the changed file path.
|
|
8
|
+
* Debounces rapid changes within 50ms to avoid duplicate rebuilds.
|
|
9
|
+
*/
|
|
10
|
+
export function watchApp(appDir: string, onChange: (file: string) => void): void {
|
|
11
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
12
|
+
let pendingFile = "";
|
|
13
|
+
|
|
14
|
+
watch(appDir, { recursive: true }, (_eventType, filename) => {
|
|
15
|
+
if (!filename) return;
|
|
16
|
+
|
|
17
|
+
const ext = path.extname(filename);
|
|
18
|
+
if (!WATCHED_EXTENSIONS.has(ext)) return;
|
|
19
|
+
|
|
20
|
+
pendingFile = filename;
|
|
21
|
+
|
|
22
|
+
if (debounceTimer !== null) {
|
|
23
|
+
clearTimeout(debounceTimer);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
debounceTimer = setTimeout(() => {
|
|
27
|
+
debounceTimer = null;
|
|
28
|
+
console.log(`✓ ${path.basename(pendingFile)} changed`);
|
|
29
|
+
onChange(pendingFile);
|
|
30
|
+
}, 50);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import type { ImageTransformParams, TransformResult, ImageFormat } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const MAX_MEM = 200;
|
|
6
|
+
const mem = new Map<string, { result: TransformResult; hits: number }>();
|
|
7
|
+
|
|
8
|
+
async function cacheKey(src: string, params: ImageTransformParams): Promise<string> {
|
|
9
|
+
const raw = new TextEncoder().encode(JSON.stringify({ src, ...params }));
|
|
10
|
+
const hash = await crypto.subtle.digest("SHA-256", raw);
|
|
11
|
+
return Array.from(new Uint8Array(hash))
|
|
12
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
13
|
+
.join("")
|
|
14
|
+
.slice(0, 16);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function getFromMemory(
|
|
18
|
+
src: string,
|
|
19
|
+
params: ImageTransformParams,
|
|
20
|
+
): Promise<TransformResult | null> {
|
|
21
|
+
const key = await cacheKey(src, params);
|
|
22
|
+
const entry = mem.get(key);
|
|
23
|
+
if (!entry) return null;
|
|
24
|
+
entry.hits++;
|
|
25
|
+
return entry.result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function setInMemory(
|
|
29
|
+
src: string,
|
|
30
|
+
params: ImageTransformParams,
|
|
31
|
+
result: TransformResult,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const key = await cacheKey(src, params);
|
|
34
|
+
if (mem.size >= MAX_MEM) {
|
|
35
|
+
let minKey = "";
|
|
36
|
+
let minHits = Infinity;
|
|
37
|
+
for (const [k, v] of mem) {
|
|
38
|
+
if (v.hits < minHits) { minHits = v.hits; minKey = k; }
|
|
39
|
+
}
|
|
40
|
+
if (minKey) mem.delete(minKey);
|
|
41
|
+
}
|
|
42
|
+
mem.set(key, { result, hits: 0 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function getFromDisk(
|
|
46
|
+
dir: string,
|
|
47
|
+
src: string,
|
|
48
|
+
params: ImageTransformParams,
|
|
49
|
+
): Promise<TransformResult | null> {
|
|
50
|
+
const key = await cacheKey(src, params);
|
|
51
|
+
const metaFile = Bun.file(join(dir, `${key}.json`));
|
|
52
|
+
const dataFile = Bun.file(join(dir, `${key}.bin`));
|
|
53
|
+
if (!(await metaFile.exists()) || !(await dataFile.exists())) return null;
|
|
54
|
+
try {
|
|
55
|
+
const meta = await metaFile.json() as { contentType: string; format: ImageFormat };
|
|
56
|
+
const data = await dataFile.arrayBuffer();
|
|
57
|
+
return { data, contentType: meta.contentType, format: meta.format };
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function setOnDisk(
|
|
64
|
+
dir: string,
|
|
65
|
+
src: string,
|
|
66
|
+
params: ImageTransformParams,
|
|
67
|
+
result: TransformResult,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
await mkdir(dir, { recursive: true });
|
|
70
|
+
const key = await cacheKey(src, params);
|
|
71
|
+
await Promise.all([
|
|
72
|
+
Bun.write(join(dir, `${key}.json`), JSON.stringify({ contentType: result.contentType, format: result.format })),
|
|
73
|
+
Bun.write(join(dir, `${key}.bin`), result.data),
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import type { ImageTransformParams, ImageFormat, ImageFit } from "./types.ts";
|
|
3
|
+
import { QUALITY_DEFAULT, FORMAT_DEFAULT, FIT_DEFAULT, MIME } from "./types.ts";
|
|
4
|
+
import { transformImage } from "./optimizer.ts";
|
|
5
|
+
import { getFromMemory, setInMemory, getFromDisk, setOnDisk } from "./cache.ts";
|
|
6
|
+
|
|
7
|
+
const MAX_DIM = 4096;
|
|
8
|
+
const CACHE_CTRL = "public, max-age=31536000, immutable";
|
|
9
|
+
|
|
10
|
+
function parseParams(
|
|
11
|
+
sp: URLSearchParams,
|
|
12
|
+
publicDir: string,
|
|
13
|
+
): { src: string; filePath: string; params: ImageTransformParams } | null {
|
|
14
|
+
const src = sp.get("src");
|
|
15
|
+
// src must be a /public/ path with no traversal sequences
|
|
16
|
+
if (!src || !src.startsWith("/public/") || src.includes("..")) return null;
|
|
17
|
+
|
|
18
|
+
const rel = src.slice("/public/".length);
|
|
19
|
+
const root = resolve(publicDir);
|
|
20
|
+
const filePath = resolve(join(root, rel));
|
|
21
|
+
if (!filePath.startsWith(root + "/") && filePath !== root) return null;
|
|
22
|
+
|
|
23
|
+
const wRaw = sp.get("w");
|
|
24
|
+
const hRaw = sp.get("h");
|
|
25
|
+
const w = wRaw ? parseInt(wRaw, 10) : undefined;
|
|
26
|
+
const h = hRaw ? parseInt(hRaw, 10) : undefined;
|
|
27
|
+
if (w !== undefined && (isNaN(w) || w < 1 || w > MAX_DIM)) return null;
|
|
28
|
+
if (h !== undefined && (isNaN(h) || h < 1 || h > MAX_DIM)) return null;
|
|
29
|
+
|
|
30
|
+
const q = Math.min(100, Math.max(1, parseInt(sp.get("q") ?? String(QUALITY_DEFAULT), 10)));
|
|
31
|
+
const fmt = (sp.get("format") ?? FORMAT_DEFAULT) as ImageFormat;
|
|
32
|
+
const fit = (sp.get("fit") ?? FIT_DEFAULT) as ImageFit;
|
|
33
|
+
if (!MIME[fmt]) return null;
|
|
34
|
+
|
|
35
|
+
return { src, filePath, params: { w, h, q, format: fmt, fit } };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function imageResponse(result: { data: ArrayBuffer; contentType: string }, cacheStatus: string): Response {
|
|
39
|
+
return new Response(result.data, {
|
|
40
|
+
headers: {
|
|
41
|
+
"Content-Type": result.contentType,
|
|
42
|
+
"Cache-Control": CACHE_CTRL,
|
|
43
|
+
"X-Image-Cache": cacheStatus,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function handleImageRequest(
|
|
49
|
+
request: Request,
|
|
50
|
+
publicDir: string,
|
|
51
|
+
cacheDir: string,
|
|
52
|
+
): Promise<Response | null> {
|
|
53
|
+
const url = new URL(request.url);
|
|
54
|
+
if (url.pathname !== "/_image") return null;
|
|
55
|
+
|
|
56
|
+
const parsed = parseParams(url.searchParams, publicDir);
|
|
57
|
+
if (!parsed) return new Response("Bad Request", { status: 400 });
|
|
58
|
+
|
|
59
|
+
const { src, filePath, params } = parsed;
|
|
60
|
+
if (!(await Bun.file(filePath).exists())) {
|
|
61
|
+
return new Response("Not Found", { status: 404 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const memHit = await getFromMemory(src, params);
|
|
65
|
+
if (memHit) return imageResponse(memHit, "MEM");
|
|
66
|
+
|
|
67
|
+
const diskHit = await getFromDisk(cacheDir, src, params);
|
|
68
|
+
if (diskHit) {
|
|
69
|
+
setInMemory(src, params, diskHit).catch(() => {});
|
|
70
|
+
return imageResponse(diskHit, "DISK");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const result = await transformImage(filePath, params);
|
|
75
|
+
setInMemory(src, params, result).catch(() => {});
|
|
76
|
+
setOnDisk(cacheDir, src, params, result).catch(() => {});
|
|
77
|
+
return imageResponse(result, "MISS");
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error("[bractjs] image optimization error:", err);
|
|
80
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ImageTransformParams, TransformResult, ImageFormat } from "./types.ts";
|
|
2
|
+
import { MIME } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
// Probe for an available ImageMagick binary once, then cache the result.
|
|
5
|
+
let _binary: string | null | undefined;
|
|
6
|
+
|
|
7
|
+
async function detectBinary(): Promise<string | null> {
|
|
8
|
+
for (const bin of ["magick", "convert"]) {
|
|
9
|
+
try {
|
|
10
|
+
const proc = Bun.spawn([bin, "-version"], { stdout: "ignore", stderr: "ignore" });
|
|
11
|
+
if ((await proc.exited) === 0) return bin;
|
|
12
|
+
} catch { /* not found */ }
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function getBinary(): Promise<string | null> {
|
|
18
|
+
if (_binary !== undefined) return _binary;
|
|
19
|
+
_binary = await detectBinary();
|
|
20
|
+
return _binary;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resizeArgs(params: ImageTransformParams): string[] {
|
|
24
|
+
if (!params.w && !params.h) return [];
|
|
25
|
+
const dim = `${params.w ?? ""}x${params.h ?? ""}`;
|
|
26
|
+
if (params.fit === "fill") return ["-resize", `${dim}!`];
|
|
27
|
+
if (params.fit === "contain") return ["-resize", dim];
|
|
28
|
+
// cover: scale to fill then crop to exact box
|
|
29
|
+
const args = ["-resize", `${dim}^`];
|
|
30
|
+
if (params.w && params.h) args.push("-gravity", "Center", "-extent", dim);
|
|
31
|
+
return args;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildArgs(binary: string, input: string, params: ImageTransformParams): string[] {
|
|
35
|
+
const base = binary === "magick" ? ["magick", "convert"] : ["convert"];
|
|
36
|
+
return [
|
|
37
|
+
...base,
|
|
38
|
+
input,
|
|
39
|
+
...resizeArgs(params),
|
|
40
|
+
"-quality", String(params.q),
|
|
41
|
+
"-strip",
|
|
42
|
+
`${params.format}:-`,
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function transformImage(
|
|
47
|
+
filePath: string,
|
|
48
|
+
params: ImageTransformParams,
|
|
49
|
+
): Promise<TransformResult> {
|
|
50
|
+
const binary = await getBinary();
|
|
51
|
+
|
|
52
|
+
// No ImageMagick available — serve the original file as-is.
|
|
53
|
+
if (!binary) {
|
|
54
|
+
const data = await Bun.file(filePath).arrayBuffer();
|
|
55
|
+
const ext = filePath.split(".").pop()?.toLowerCase() ?? "jpeg";
|
|
56
|
+
const fmt = (ext === "jpg" ? "jpeg" : ext) as ImageFormat;
|
|
57
|
+
return { data, contentType: MIME[fmt] ?? "image/jpeg", format: fmt };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const proc = Bun.spawn(buildArgs(binary, filePath, params), {
|
|
61
|
+
stdout: "pipe",
|
|
62
|
+
stderr: "pipe",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const [data, , exitCode] = await Promise.all([
|
|
66
|
+
new Response(proc.stdout!).arrayBuffer(),
|
|
67
|
+
new Response(proc.stderr!).arrayBuffer(),
|
|
68
|
+
proc.exited,
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
if (exitCode !== 0) {
|
|
72
|
+
throw new Error(`[bractjs] ImageMagick exited ${exitCode} for ${filePath}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { data, contentType: MIME[params.format], format: params.format };
|
|
76
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type ImageFormat = "webp" | "avif" | "jpeg" | "png";
|
|
2
|
+
export type ImageFit = "cover" | "contain" | "fill";
|
|
3
|
+
|
|
4
|
+
export interface ImageTransformParams {
|
|
5
|
+
w?: number;
|
|
6
|
+
h?: number;
|
|
7
|
+
q: number;
|
|
8
|
+
format: ImageFormat;
|
|
9
|
+
fit: ImageFit;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TransformResult {
|
|
13
|
+
data: ArrayBuffer;
|
|
14
|
+
contentType: string;
|
|
15
|
+
format: ImageFormat;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const QUALITY_DEFAULT = 80;
|
|
19
|
+
export const FORMAT_DEFAULT: ImageFormat = "webp";
|
|
20
|
+
export const FIT_DEFAULT: ImageFit = "cover";
|
|
21
|
+
export const BREAKPOINTS = [320, 640, 768, 1024, 1280, 1536, 1920];
|
|
22
|
+
export const MIME: Record<ImageFormat, string> = {
|
|
23
|
+
webp: "image/webp",
|
|
24
|
+
avif: "image/avif",
|
|
25
|
+
jpeg: "image/jpeg",
|
|
26
|
+
png: "image/png",
|
|
27
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Server
|
|
2
|
+
export { createServer, renderRoute, redirect, json, error } from "./server/index.ts";
|
|
3
|
+
export type { BractJSConfig, RenderOptions, ServerManifest } from "./server/index.ts";
|
|
4
|
+
|
|
5
|
+
// Shared types
|
|
6
|
+
export type {
|
|
7
|
+
LoaderArgs,
|
|
8
|
+
ActionArgs,
|
|
9
|
+
MetaDescriptor,
|
|
10
|
+
MetaArgs,
|
|
11
|
+
LoaderFunction,
|
|
12
|
+
ActionFunction,
|
|
13
|
+
MetaFunction,
|
|
14
|
+
RouteModule,
|
|
15
|
+
RouteDefinition,
|
|
16
|
+
} from "./shared/route-types.ts";
|
|
17
|
+
|
|
18
|
+
export { BractJSError, HttpError, isRedirect, isHttpError, isBractJSError } from "./shared/errors.ts";
|
|
19
|
+
export { Deferred, defer, isDeferred } from "./shared/deferred.ts";
|
|
20
|
+
export { BractJSContext, BractJSProvider, useBractJSContext } from "./shared/context.ts";
|
|
21
|
+
export type { BractJSContextValue, RouteManifest } from "./shared/context.ts";
|
|
22
|
+
|
|
23
|
+
// Middleware
|
|
24
|
+
export { pipeline, MiddlewarePipeline } from "./server/middleware.ts";
|
|
25
|
+
export type { MiddlewareFn, MiddlewareContext } from "./server/middleware.ts";
|
|
26
|
+
export { requestLogger } from "./middleware/requestLogger.ts";
|
|
27
|
+
export { cors } from "./middleware/cors.ts";
|
|
28
|
+
export type { CorsOptions } from "./middleware/cors.ts";
|
|
29
|
+
export { authGuard } from "./middleware/authGuard.ts";
|
|
30
|
+
export type { AuthGuardOptions, SessionStorageLike, SessionLike } from "./middleware/authGuard.ts";
|
|
31
|
+
|
|
32
|
+
// Session
|
|
33
|
+
export { createCookieSession } from "./server/session.ts";
|
|
34
|
+
export type { Session, SessionStorage, SessionData, CookieSessionOptions, CommitOptions } from "./server/session.ts";
|
|
35
|
+
|
|
36
|
+
// Client components
|
|
37
|
+
export { Scripts } from "./client/components/Scripts.tsx";
|
|
38
|
+
export { LiveReload } from "./client/components/LiveReload.tsx";
|
|
39
|
+
export { Outlet } from "./client/components/Outlet.tsx";
|
|
40
|
+
export { Link } from "./client/components/Link.tsx";
|
|
41
|
+
export { Form } from "./client/components/Form.tsx";
|
|
42
|
+
export { Await } from "./client/components/Await.tsx";
|
|
43
|
+
export { Image } from "./client/components/Image.tsx";
|
|
44
|
+
export type { ImageProps, ImageFormat, ImageFit } from "./client/components/Image.tsx";
|
|
45
|
+
|
|
46
|
+
// Client hooks
|
|
47
|
+
export { useLoaderData } from "./client/hooks/useLoaderData.ts";
|
|
48
|
+
export { useActionData } from "./client/hooks/useActionData.ts";
|
|
49
|
+
export { useParams } from "./client/hooks/useParams.ts";
|
|
50
|
+
export { useNavigation } from "./client/hooks/useNavigation.ts";
|
|
51
|
+
export { useFetcher } from "./client/hooks/useFetcher.ts";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MiddlewareFn } from "../server/middleware.ts";
|
|
2
|
+
|
|
3
|
+
/** Minimal session interface — will be unified with createCookieSession in 5.3. */
|
|
4
|
+
export interface SessionLike {
|
|
5
|
+
get(key: string): unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SessionStorageLike {
|
|
9
|
+
getSession(cookie?: string | null): Promise<SessionLike>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AuthGuardOptions {
|
|
13
|
+
session: SessionStorageLike;
|
|
14
|
+
required?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reads the Cookie header → gets session → sets ctx.context.user.
|
|
19
|
+
* If required=true and no user, returns 401.
|
|
20
|
+
*/
|
|
21
|
+
export function authGuard(options: AuthGuardOptions): MiddlewareFn {
|
|
22
|
+
return async (ctx, next) => {
|
|
23
|
+
const cookie = ctx.request.headers.get("Cookie");
|
|
24
|
+
const session = await options.session.getSession(cookie);
|
|
25
|
+
const user = session.get("user");
|
|
26
|
+
ctx.context.user = user ?? null;
|
|
27
|
+
|
|
28
|
+
if (options.required && !user) {
|
|
29
|
+
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
|
30
|
+
status: 401,
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return next();
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { MiddlewareFn } from "../server/middleware.ts";
|
|
2
|
+
|
|
3
|
+
export interface CorsOptions {
|
|
4
|
+
origin: string | string[];
|
|
5
|
+
methods?: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sets CORS headers. Handles OPTIONS preflight with 204.
|
|
10
|
+
*/
|
|
11
|
+
export function cors(options: CorsOptions): MiddlewareFn {
|
|
12
|
+
const allowedOrigins = Array.isArray(options.origin)
|
|
13
|
+
? options.origin
|
|
14
|
+
: [options.origin];
|
|
15
|
+
const allowedMethods = options.methods?.join(", ") ?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
16
|
+
|
|
17
|
+
return async (ctx, next) => {
|
|
18
|
+
const origin = ctx.request.headers.get("Origin") ?? "";
|
|
19
|
+
const allowed = allowedOrigins.includes("*") || allowedOrigins.includes(origin);
|
|
20
|
+
const corsHeaders: Record<string, string> = {
|
|
21
|
+
"Access-Control-Allow-Methods": allowedMethods,
|
|
22
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
23
|
+
};
|
|
24
|
+
if (allowed) corsHeaders["Access-Control-Allow-Origin"] = origin || "*";
|
|
25
|
+
|
|
26
|
+
// Preflight
|
|
27
|
+
if (ctx.request.method === "OPTIONS") {
|
|
28
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const response = await next();
|
|
32
|
+
const patched = new Response(response.body, response);
|
|
33
|
+
for (const [k, v] of Object.entries(corsHeaders)) patched.headers.set(k, v);
|
|
34
|
+
return patched;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MiddlewareFn } from "../server/middleware.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Logs "[METHOD] /path → status in Xms" for every request.
|
|
5
|
+
*/
|
|
6
|
+
export function requestLogger(): MiddlewareFn {
|
|
7
|
+
return async (ctx, next) => {
|
|
8
|
+
const start = Date.now();
|
|
9
|
+
const { pathname } = new URL(ctx.request.url);
|
|
10
|
+
const response = await next();
|
|
11
|
+
const ms = Date.now() - start;
|
|
12
|
+
console.log(`[${ctx.request.method}] ${pathname} → ${response.status} in ${ms}ms`);
|
|
13
|
+
return response;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { resolveAction } from "./action-registry.ts";
|
|
2
|
+
import { json } from "./response.ts";
|
|
3
|
+
|
|
4
|
+
export async function handleActionRequest(request: Request): Promise<Response | null> {
|
|
5
|
+
const url = new URL(request.url);
|
|
6
|
+
if (!url.pathname.startsWith("/_action")) return null;
|
|
7
|
+
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
|
|
8
|
+
|
|
9
|
+
const id = url.searchParams.get("id");
|
|
10
|
+
if (!id) return new Response("Bad Request: missing action id", { status: 400 });
|
|
11
|
+
|
|
12
|
+
const fn = resolveAction(id);
|
|
13
|
+
if (!fn) return new Response("Not Found", { status: 404 });
|
|
14
|
+
|
|
15
|
+
let args: unknown[];
|
|
16
|
+
try {
|
|
17
|
+
const ct = request.headers.get("Content-Type") ?? "";
|
|
18
|
+
if (ct.includes("multipart/form-data") || ct.includes("application/x-www-form-urlencoded")) {
|
|
19
|
+
args = [await request.formData()];
|
|
20
|
+
} else {
|
|
21
|
+
const text = await request.text();
|
|
22
|
+
args = text ? JSON.parse(text) as unknown[] : [];
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
return new Response("Bad Request: invalid body", { status: 400 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const result = await fn(...args);
|
|
30
|
+
return json(result ?? null);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.error("[bractjs] server action error:", err);
|
|
33
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
const SERVER_RE = /^["']use server["']/m;
|
|
4
|
+
const registry = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
|
5
|
+
|
|
6
|
+
async function computeId(filePath: string, name: string): Promise<string> {
|
|
7
|
+
const raw = new TextEncoder().encode(filePath + "#" + name);
|
|
8
|
+
const buf = await crypto.subtle.digest("SHA-256", raw);
|
|
9
|
+
return Array.from(new Uint8Array(buf))
|
|
10
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
11
|
+
.join("")
|
|
12
|
+
.slice(0, 16);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveAction(id: string): ((...args: unknown[]) => Promise<unknown>) | null {
|
|
16
|
+
return registry.get(id) ?? null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function loadServerActions(appDir: string): Promise<void> {
|
|
20
|
+
const glob = new Bun.Glob("**/*.{ts,tsx}");
|
|
21
|
+
for await (const rel of glob.scan(appDir)) {
|
|
22
|
+
const filePath = join(appDir, rel);
|
|
23
|
+
let src: string;
|
|
24
|
+
try { src = await Bun.file(filePath).text(); } catch { continue; }
|
|
25
|
+
if (!SERVER_RE.test(src)) continue;
|
|
26
|
+
|
|
27
|
+
let mod: Record<string, unknown>;
|
|
28
|
+
try {
|
|
29
|
+
mod = await import(filePath) as Record<string, unknown>;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error("[bractjs] failed to load server actions from", rel, err);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const [name, val] of Object.entries(mod)) {
|
|
36
|
+
if (typeof val !== "function") continue;
|
|
37
|
+
const id = await computeId(filePath, name);
|
|
38
|
+
registry.set(id, val as (...args: unknown[]) => Promise<unknown>);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function isDev(): boolean {
|
|
2
|
+
return Bun.env.NODE_ENV !== "production";
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function requireEnv(key: string): string {
|
|
6
|
+
const value = Bun.env[key];
|
|
7
|
+
if (!value) {
|
|
8
|
+
throw new Error(`Missing required environment variable: ${key}`);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function safeStringify(data: unknown): string {
|
|
14
|
+
const seen = new WeakSet();
|
|
15
|
+
const json = JSON.stringify(data, (_key, value) => {
|
|
16
|
+
if (typeof value === "object" && value !== null) {
|
|
17
|
+
if (seen.has(value)) return "[Circular]";
|
|
18
|
+
seen.add(value);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
});
|
|
22
|
+
// Escape HTML-sensitive characters so this JSON is safe to embed inside a
|
|
23
|
+
// <script> tag. \u003c / \u003e / \u0026 are valid JSON unicode escapes —
|
|
24
|
+
// JSON.parse on the client decodes them transparently.
|
|
25
|
+
return json
|
|
26
|
+
.replace(/</g, "\\u003c")
|
|
27
|
+
.replace(/>/g, "\\u003e")
|
|
28
|
+
.replace(/&/g, "\\u0026");
|
|
29
|
+
}
|