refactor: nitro build
This commit is contained in:
parent
62366e5edb
commit
9230bf8c4b
15 changed files with 29 additions and 454 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -36,3 +36,4 @@ playwright/temp
|
||||||
|
|
||||||
# OpenAPI error logs
|
# OpenAPI error logs
|
||||||
openapi-ts-error-*.log
|
openapi-ts-error-*.log
|
||||||
|
.output
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,7 @@ RUN bun install --production --frozen-lockfile && rm -rf $HOME/.bun/install/cach
|
||||||
COPY --from=deps /deps/restic /usr/local/bin/restic
|
COPY --from=deps /deps/restic /usr/local/bin/restic
|
||||||
COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
COPY --from=deps /deps/rclone /usr/local/bin/rclone
|
||||||
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
|
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/.output ./.output
|
||||||
COPY --from=builder /app/server.ts ./server.ts
|
|
||||||
COPY --from=builder /app/app/drizzle ./assets/migrations
|
COPY --from=builder /app/app/drizzle ./assets/migrations
|
||||||
|
|
||||||
# Include third-party licenses and attribution
|
# Include third-party licenses and attribution
|
||||||
|
|
@ -114,5 +113,4 @@ COPY ./LICENSE ./LICENSE.md
|
||||||
|
|
||||||
EXPOSE 4096
|
EXPOSE 4096
|
||||||
|
|
||||||
CMD ["bun", "server.ts"]
|
CMD ["bun", ".output/server/index.mjs"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { Bell, Plus, Trash2 } from "lucide-react";
|
import { Bell, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,6 @@ import { runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { handleRepositoryError } from "~/client/lib/errors";
|
import { handleRepositoryError } from "~/client/lib/errors";
|
||||||
import { Link } from "react-router";
|
|
||||||
import { parseError } from "~/client/lib/errors";
|
|
||||||
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ export function SettingsPage({ appContext }: Props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTabChange = (value: string) => {
|
const onTabChange = (value: string) => {
|
||||||
navigate({ to: ".", search: () => ({ tab: value }) });
|
void navigate({ to: ".", search: () => ({ tab: value }) });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$scheduleId")({
|
||||||
context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { scheduleId } }) }),
|
context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { scheduleId } }) }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
context.queryClient.prefetchQuery({
|
void context.queryClient.prefetchQuery({
|
||||||
...listSnapshotsOptions({ path: { id: schedule.repository.id }, query: { backupId: schedule.shortId } }),
|
...listSnapshotsOptions({ path: { id: schedule.repository.id }, query: { backupId: schedule.shortId } }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,7 @@ export const Route = createFileRoute("/(dashboard)/backups/create")({
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
staticData: {
|
staticData: {
|
||||||
breadcrumb: () => [
|
breadcrumb: () => [{ label: "Backup Jobs", href: "/backups" }, { label: "Create" }],
|
||||||
{ label: "Backup Jobs", href: "/backups" },
|
|
||||||
{ label: "Create" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,12 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapsho
|
||||||
...getRepositoryOptions({ path: { id: params.repoId } }),
|
...getRepositoryOptions({ path: { id: params.repoId } }),
|
||||||
});
|
});
|
||||||
|
|
||||||
context.queryClient.prefetchQuery({
|
void context.queryClient.prefetchQuery({
|
||||||
...getSnapshotDetailsOptions({
|
...getSnapshotDetailsOptions({
|
||||||
path: { id: params.repoId, snapshotId: params.snapshotId },
|
path: { id: params.repoId, snapshotId: params.snapshotId },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
context.queryClient.prefetchQuery({
|
void context.queryClient.prefetchQuery({
|
||||||
...listSnapshotFilesOptions({
|
...listSnapshotFilesOptions({
|
||||||
path: { id: params.repoId, snapshotId: params.snapshotId },
|
path: { id: params.repoId, snapshotId: params.snapshotId },
|
||||||
query: { path: "/" },
|
query: { path: "/" },
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,16 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId")(
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
errorComponent: (e) => <div>{e.error.message}</div>,
|
errorComponent: (e) => <div>{e.error.message}</div>,
|
||||||
loader: async ({ params, context }) => {
|
loader: async ({ params, context }) => {
|
||||||
context.queryClient.prefetchQuery({
|
void context.queryClient.prefetchQuery({
|
||||||
...listSnapshotsOptions({ path: { id: params.repositoryId } }),
|
...listSnapshotsOptions({ path: { id: params.repositoryId } }),
|
||||||
})
|
});
|
||||||
context.queryClient.prefetchQuery({
|
void context.queryClient.prefetchQuery({
|
||||||
...listBackupSchedulesOptions(),
|
...listBackupSchedulesOptions(),
|
||||||
})
|
});
|
||||||
|
|
||||||
const res = await context.queryClient.ensureQueryData({
|
const res = await context.queryClient.ensureQueryData({
|
||||||
...getRepositoryOptions({ path: { id: params.repositoryId } }),
|
...getRepositoryOptions({ path: { id: params.repositoryId } }),
|
||||||
})
|
});
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Outlet, HeadContent, Scripts, createRootRouteWithContext } from "@tanstack/react-router";
|
import { Outlet, HeadContent, Scripts, createRootRouteWithContext } from "@tanstack/react-router";
|
||||||
|
import appCss from "../app.css?url";
|
||||||
import { apiClientMiddleware } from "~/middleware/api-client";
|
import { apiClientMiddleware } from "~/middleware/api-client";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
import { Toaster } from "~/client/components/ui/sonner";
|
import { Toaster } from "~/client/components/ui/sonner";
|
||||||
import { useServerEvents } from "~/client/hooks/use-server-events";
|
import { useServerEvents } from "~/client/hooks/use-server-events";
|
||||||
import "../app.css";
|
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -18,6 +18,10 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
|
||||||
{ title: "Zerobyte - Open Source Backup Solution" },
|
{ title: "Zerobyte - Open Source Backup Solution" },
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
|
{
|
||||||
|
rel: "stylesheet",
|
||||||
|
href: appCss,
|
||||||
|
},
|
||||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||||
{
|
{
|
||||||
rel: "preconnect",
|
rel: "preconnect",
|
||||||
|
|
@ -48,6 +52,7 @@ function RootLayout() {
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Zerobyte" />
|
<meta name="apple-mobile-web-app-title" content="Zerobyte" />
|
||||||
<link rel="manifest" href="/images/favicon/site.webmanifest" />
|
<link rel="manifest" href="/images/favicon/site.webmanifest" />
|
||||||
|
{/* <link rel="stylesheet" href={appCss} /> */}
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
</head>
|
</head>
|
||||||
<body className="dark">
|
<body className="dark">
|
||||||
|
|
|
||||||
5
bun.lock
5
bun.lock
|
|
@ -85,6 +85,7 @@
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"bun-types": "^1.3.9",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"drizzle-kit": "^1.0.0-beta.12-a5629fb",
|
"drizzle-kit": "^1.0.0-beta.12-a5629fb",
|
||||||
"lightningcss": "^1.31.1",
|
"lightningcss": "^1.31.1",
|
||||||
|
|
@ -902,7 +903,7 @@
|
||||||
|
|
||||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||||
|
|
||||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||||
|
|
||||||
|
|
@ -1762,6 +1763,8 @@
|
||||||
|
|
||||||
"@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
"@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
|
"@types/bun/bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||||
|
|
||||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"bl/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
"bl/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"lint": "oxlint --type-aware",
|
"lint": "oxlint --type-aware",
|
||||||
"dev": "NODE_ENV=development bunx --bun vite",
|
"dev": "NODE_ENV=development bunx --bun vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"start": "bun ./dist/server/index.js",
|
"start": "bun run .output/server/index.mjs",
|
||||||
"preview": "bunx --bun vite preview",
|
"preview": "bunx --bun vite preview",
|
||||||
"cli:dev": "bun run app/server/cli/main.ts",
|
"cli:dev": "bun run app/server/cli/main.ts",
|
||||||
"cli": "ZEROBYTE_CLI=1 bun ./dist/server/index.js",
|
"cli": "ZEROBYTE_CLI=1 bun ./dist/server/index.js",
|
||||||
|
|
@ -106,6 +106,7 @@
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"bun-types": "^1.3.9",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"drizzle-kit": "^1.0.0-beta.12-a5629fb",
|
"drizzle-kit": "^1.0.0-beta.12-a5629fb",
|
||||||
"lightningcss": "^1.31.1",
|
"lightningcss": "^1.31.1",
|
||||||
|
|
|
||||||
430
server.ts
430
server.ts
|
|
@ -1,430 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const SERVER_PORT = Number(process.env.PORT ?? 3000);
|
|
||||||
const CLIENT_DIRECTORY = "./dist/client";
|
|
||||||
const SERVER_ENTRY_POINT = "./dist/server/server.js";
|
|
||||||
|
|
||||||
// Logging utilities for professional output
|
|
||||||
const log = {
|
|
||||||
info: (message: string) => {
|
|
||||||
console.log(`[INFO] ${message}`);
|
|
||||||
},
|
|
||||||
success: (message: string) => {
|
|
||||||
console.log(`[SUCCESS] ${message}`);
|
|
||||||
},
|
|
||||||
warning: (message: string) => {
|
|
||||||
console.log(`[WARNING] ${message}`);
|
|
||||||
},
|
|
||||||
error: (message: string) => {
|
|
||||||
console.log(`[ERROR] ${message}`);
|
|
||||||
},
|
|
||||||
header: (message: string) => {
|
|
||||||
console.log(`\n${message}\n`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Preloading configuration from environment variables
|
|
||||||
const MAX_PRELOAD_BYTES = Number(
|
|
||||||
process.env.ASSET_PRELOAD_MAX_SIZE ?? 5 * 1024 * 1024, // 5MB default
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse comma-separated include patterns (no defaults)
|
|
||||||
const INCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? "")
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((pattern: string) => convertGlobToRegExp(pattern));
|
|
||||||
|
|
||||||
// Parse comma-separated exclude patterns (no defaults)
|
|
||||||
const EXCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? "")
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((pattern: string) => convertGlobToRegExp(pattern));
|
|
||||||
|
|
||||||
// Verbose logging flag
|
|
||||||
const VERBOSE = process.env.ASSET_PRELOAD_VERBOSE_LOGGING === "true";
|
|
||||||
|
|
||||||
// Optional ETag feature
|
|
||||||
const ENABLE_ETAG = (process.env.ASSET_PRELOAD_ENABLE_ETAG ?? "true") === "true";
|
|
||||||
|
|
||||||
// Optional Gzip feature
|
|
||||||
const ENABLE_GZIP = (process.env.ASSET_PRELOAD_ENABLE_GZIP ?? "true") === "true";
|
|
||||||
const GZIP_MIN_BYTES = Number(process.env.ASSET_PRELOAD_GZIP_MIN_SIZE ?? 1024); // 1KB
|
|
||||||
const GZIP_TYPES = (
|
|
||||||
process.env.ASSET_PRELOAD_GZIP_MIME_TYPES ??
|
|
||||||
"text/,application/javascript,application/json,application/xml,image/svg+xml"
|
|
||||||
)
|
|
||||||
.split(",")
|
|
||||||
.map((v) => v.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a simple glob pattern to a regular expression
|
|
||||||
* Supports * wildcard for matching any characters
|
|
||||||
*/
|
|
||||||
function convertGlobToRegExp(globPattern: string): RegExp {
|
|
||||||
// Escape regex special chars except *, then replace * with .*
|
|
||||||
const escapedPattern = globPattern.replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&").replace(/\*/g, ".*");
|
|
||||||
return new RegExp(`^${escapedPattern}$`, "i");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute ETag for a given data buffer
|
|
||||||
*/
|
|
||||||
function computeEtag(data: Uint8Array): string {
|
|
||||||
const hash = Bun.hash(data);
|
|
||||||
return `W/"${hash.toString(16)}-${data.byteLength.toString()}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Metadata for preloaded static assets
|
|
||||||
*/
|
|
||||||
interface AssetMetadata {
|
|
||||||
route: string;
|
|
||||||
size: number;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory asset with ETag and Gzip support
|
|
||||||
*/
|
|
||||||
interface InMemoryAsset {
|
|
||||||
raw: Uint8Array;
|
|
||||||
gz?: Uint8Array;
|
|
||||||
etag?: string;
|
|
||||||
type: string;
|
|
||||||
immutable: boolean;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of static asset preloading process
|
|
||||||
*/
|
|
||||||
interface PreloadResult {
|
|
||||||
routes: Record<string, (req: Request) => Response | Promise<Response>>;
|
|
||||||
loaded: AssetMetadata[];
|
|
||||||
skipped: AssetMetadata[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file is eligible for preloading based on configured patterns
|
|
||||||
*/
|
|
||||||
function isFileEligibleForPreloading(relativePath: string): boolean {
|
|
||||||
return false;
|
|
||||||
const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath;
|
|
||||||
|
|
||||||
// If include patterns are specified, file must match at least one
|
|
||||||
if (INCLUDE_PATTERNS.length > 0) {
|
|
||||||
if (!INCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If exclude patterns are specified, file must not match any
|
|
||||||
if (EXCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a MIME type is compressible
|
|
||||||
*/
|
|
||||||
function isMimeTypeCompressible(mimeType: string): boolean {
|
|
||||||
return GZIP_TYPES.some((type) => (type.endsWith("/") ? mimeType.startsWith(type) : mimeType === type));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Conditionally compress data based on size and MIME type
|
|
||||||
*/
|
|
||||||
function compressDataIfAppropriate(data: Uint8Array, mimeType: string): Uint8Array | undefined {
|
|
||||||
if (!ENABLE_GZIP) return undefined;
|
|
||||||
if (data.byteLength < GZIP_MIN_BYTES) return undefined;
|
|
||||||
if (!isMimeTypeCompressible(mimeType)) return undefined;
|
|
||||||
try {
|
|
||||||
return Bun.gzipSync(data.buffer as ArrayBuffer);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create response handler function with ETag and Gzip support
|
|
||||||
*/
|
|
||||||
function createResponseHandler(asset: InMemoryAsset): (req: Request) => Response {
|
|
||||||
return (req: Request) => {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"Content-Type": asset.type,
|
|
||||||
"Cache-Control": asset.immutable ? "public, max-age=31536000, immutable" : "public, max-age=3600",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (ENABLE_ETAG && asset.etag) {
|
|
||||||
const ifNone = req.headers.get("if-none-match");
|
|
||||||
if (ifNone && ifNone === asset.etag) {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 304,
|
|
||||||
headers: { ETag: asset.etag },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
headers.ETag = asset.etag;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ENABLE_GZIP && asset.gz && req.headers.get("accept-encoding")?.includes("gzip")) {
|
|
||||||
headers["Content-Encoding"] = "gzip";
|
|
||||||
headers["Content-Length"] = String(asset.gz.byteLength);
|
|
||||||
const gzCopy = new Uint8Array(asset.gz);
|
|
||||||
return new Response(gzCopy, { status: 200, headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
headers["Content-Length"] = String(asset.raw.byteLength);
|
|
||||||
const rawCopy = new Uint8Array(asset.raw);
|
|
||||||
return new Response(rawCopy, { status: 200, headers });
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create composite glob pattern from include patterns
|
|
||||||
*/
|
|
||||||
function createCompositeGlobPattern(): Bun.Glob {
|
|
||||||
const raw = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? "")
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (raw.length === 0) return new Bun.Glob("**/*");
|
|
||||||
if (raw.length === 1) return new Bun.Glob(raw[0]);
|
|
||||||
return new Bun.Glob(`{${raw.join(",")}}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize static routes with intelligent preloading strategy
|
|
||||||
* Small files are loaded into memory, large files are served on-demand
|
|
||||||
*/
|
|
||||||
async function initializeStaticRoutes(clientDirectory: string): Promise<PreloadResult> {
|
|
||||||
const routes: Record<string, (req: Request) => Response | Promise<Response>> = {};
|
|
||||||
const loaded: AssetMetadata[] = [];
|
|
||||||
const skipped: AssetMetadata[] = [];
|
|
||||||
|
|
||||||
log.info(`Loading static assets from ${clientDirectory}...`);
|
|
||||||
|
|
||||||
let totalPreloadedBytes = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const glob = createCompositeGlobPattern();
|
|
||||||
for await (const relativePath of glob.scan({ cwd: clientDirectory })) {
|
|
||||||
const filepath = path.join(clientDirectory, relativePath);
|
|
||||||
const route = `/${relativePath.split(path.sep).join(path.posix.sep)}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get file metadata
|
|
||||||
const file = Bun.file(filepath);
|
|
||||||
|
|
||||||
// Skip if file doesn't exist or is empty
|
|
||||||
if (!(await file.exists()) || file.size === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata: AssetMetadata = {
|
|
||||||
route,
|
|
||||||
size: file.size,
|
|
||||||
type: file.type || "application/octet-stream",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine if file should be preloaded
|
|
||||||
const matchesPattern = isFileEligibleForPreloading(relativePath);
|
|
||||||
const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES;
|
|
||||||
|
|
||||||
if (matchesPattern && withinSizeLimit) {
|
|
||||||
// Preload small files into memory with ETag and Gzip support
|
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
||||||
const gz = compressDataIfAppropriate(bytes, metadata.type);
|
|
||||||
const etag = ENABLE_ETAG ? computeEtag(bytes) : undefined;
|
|
||||||
const asset: InMemoryAsset = {
|
|
||||||
raw: bytes,
|
|
||||||
gz,
|
|
||||||
etag,
|
|
||||||
type: metadata.type,
|
|
||||||
immutable: true,
|
|
||||||
size: bytes.byteLength,
|
|
||||||
};
|
|
||||||
routes[route] = createResponseHandler(asset);
|
|
||||||
|
|
||||||
loaded.push({ ...metadata, size: bytes.byteLength });
|
|
||||||
totalPreloadedBytes += bytes.byteLength;
|
|
||||||
} else {
|
|
||||||
// Serve large or filtered files on-demand
|
|
||||||
routes[route] = () => {
|
|
||||||
const fileOnDemand = Bun.file(filepath);
|
|
||||||
return new Response(fileOnDemand, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": metadata.type,
|
|
||||||
"Cache-Control": "public, max-age=3600",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
skipped.push(metadata);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof Error && error.name !== "EISDIR") {
|
|
||||||
log.error(`Failed to load ${filepath}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show detailed file overview only when verbose mode is enabled
|
|
||||||
if (VERBOSE && (loaded.length > 0 || skipped.length > 0)) {
|
|
||||||
const allFiles = [...loaded, ...skipped].sort((a, b) => a.route.localeCompare(b.route));
|
|
||||||
|
|
||||||
// Calculate max path length for alignment
|
|
||||||
const maxPathLength = Math.min(Math.max(...allFiles.map((f) => f.route.length)), 60);
|
|
||||||
|
|
||||||
// Format file size with KB and actual gzip size
|
|
||||||
const formatFileSize = (bytes: number, gzBytes?: number) => {
|
|
||||||
const kb = bytes / 1024;
|
|
||||||
const sizeStr = kb < 100 ? kb.toFixed(2) : kb.toFixed(1);
|
|
||||||
|
|
||||||
if (gzBytes !== undefined) {
|
|
||||||
const gzKb = gzBytes / 1024;
|
|
||||||
const gzStr = gzKb < 100 ? gzKb.toFixed(2) : gzKb.toFixed(1);
|
|
||||||
return {
|
|
||||||
size: sizeStr,
|
|
||||||
gzip: gzStr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rough gzip estimation (typically 30-70% compression) if no actual gzip data
|
|
||||||
const gzipKb = kb * 0.35;
|
|
||||||
return {
|
|
||||||
size: sizeStr,
|
|
||||||
gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loaded.length > 0) {
|
|
||||||
console.log("\n📁 Preloaded into memory:");
|
|
||||||
console.log("Path │ Size │ Gzip Size");
|
|
||||||
loaded
|
|
||||||
.sort((a, b) => a.route.localeCompare(b.route))
|
|
||||||
.forEach((file) => {
|
|
||||||
const { size, gzip } = formatFileSize(file.size);
|
|
||||||
const paddedPath = file.route.padEnd(maxPathLength);
|
|
||||||
const sizeStr = `${size.padStart(7)} kB`;
|
|
||||||
const gzipStr = `${gzip.padStart(7)} kB`;
|
|
||||||
console.log(`${paddedPath} │ ${sizeStr} │ ${gzipStr}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skipped.length > 0) {
|
|
||||||
console.log("\n💾 Served on-demand:");
|
|
||||||
console.log("Path │ Size │ Gzip Size");
|
|
||||||
skipped
|
|
||||||
.sort((a, b) => a.route.localeCompare(b.route))
|
|
||||||
.forEach((file) => {
|
|
||||||
const { size, gzip } = formatFileSize(file.size);
|
|
||||||
const paddedPath = file.route.padEnd(maxPathLength);
|
|
||||||
const sizeStr = `${size.padStart(7)} kB`;
|
|
||||||
const gzipStr = `${gzip.padStart(7)} kB`;
|
|
||||||
console.log(`${paddedPath} │ ${sizeStr} │ ${gzipStr}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show detailed verbose info if enabled
|
|
||||||
if (VERBOSE) {
|
|
||||||
if (loaded.length > 0 || skipped.length > 0) {
|
|
||||||
const allFiles = [...loaded, ...skipped].sort((a, b) => a.route.localeCompare(b.route));
|
|
||||||
console.log("\n📊 Detailed file information:");
|
|
||||||
console.log("Status │ Path │ MIME Type │ Reason");
|
|
||||||
allFiles.forEach((file) => {
|
|
||||||
const isPreloaded = loaded.includes(file);
|
|
||||||
const status = isPreloaded ? "MEMORY" : "ON-DEMAND";
|
|
||||||
const reason =
|
|
||||||
!isPreloaded && file.size > MAX_PRELOAD_BYTES ? "too large" : !isPreloaded ? "filtered" : "preloaded";
|
|
||||||
const route = file.route.length > 30 ? file.route.substring(0, 27) + "..." : file.route;
|
|
||||||
console.log(`${status.padEnd(12)} │ ${route.padEnd(30)} │ ${file.type.padEnd(28)} │ ${reason.padEnd(10)}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log("\n📊 No files found to display");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log summary after the file list
|
|
||||||
console.log(); // Empty line for separation
|
|
||||||
if (loaded.length > 0) {
|
|
||||||
log.success(
|
|
||||||
`Preloaded ${String(loaded.length)} files (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) into memory`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log.info("No files preloaded into memory");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skipped.length > 0) {
|
|
||||||
const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length;
|
|
||||||
const filtered = skipped.length - tooLarge;
|
|
||||||
log.info(
|
|
||||||
`${String(skipped.length)} files will be served on-demand (${String(tooLarge)} too large, ${String(filtered)} filtered)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Failed to load static files from ${clientDirectory}: ${String(error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { routes, loaded, skipped };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeServer() {
|
|
||||||
log.header("Starting Production Server");
|
|
||||||
|
|
||||||
// Load TanStack Start server handler
|
|
||||||
let handler: { fetch: (request: Request) => Response | Promise<Response> };
|
|
||||||
try {
|
|
||||||
const serverModule = (await import(SERVER_ENTRY_POINT)) as {
|
|
||||||
default: { fetch: (request: Request) => Response | Promise<Response> };
|
|
||||||
};
|
|
||||||
handler = serverModule.default;
|
|
||||||
log.success("TanStack Start application handler initialized");
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Failed to load server handler: ${String(error)}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build static routes with intelligent preloading
|
|
||||||
const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY);
|
|
||||||
|
|
||||||
// Create Bun server
|
|
||||||
const server = Bun.serve({
|
|
||||||
port: SERVER_PORT,
|
|
||||||
|
|
||||||
routes: {
|
|
||||||
// Serve static assets (preloaded or on-demand)
|
|
||||||
...routes,
|
|
||||||
|
|
||||||
// Fallback to TanStack Start handler for all other routes
|
|
||||||
"/*": (req: Request) => {
|
|
||||||
try {
|
|
||||||
return handler.fetch(req);
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Server handler error: ${String(error)}`);
|
|
||||||
return new Response("Internal Server Error", { status: 500 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Global error handler
|
|
||||||
error(error) {
|
|
||||||
log.error(`Uncaught server error: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
return new Response("Internal Server Error", { status: 500 });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
log.success(`Server listening on http://localhost:${String(server.port)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the server
|
|
||||||
initializeServer().catch((error: unknown) => {
|
|
||||||
log.error(`Failed to start server: ${String(error)}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
|
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
"types": ["node", "vite/client"],
|
"types": ["node", "vite/client", "bun-types"],
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ES2022",
|
"module": "ES2022",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||||
|
import { nitro } from "nitro/vite";
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
import viteReact from "@vitejs/plugin-react";
|
import viteReact from "@vitejs/plugin-react";
|
||||||
import babel from "vite-plugin-babel";
|
import babel from "vite-plugin-babel";
|
||||||
|
|
@ -21,6 +22,7 @@ export default defineConfig({
|
||||||
routesDirectory: "routes",
|
routesDirectory: "routes",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
nitro({ preset: "bun" }),
|
||||||
viteReact(),
|
viteReact(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue