From 825d46c934cdf3449ba1917984302c9e07eccd97 Mon Sep 17 00:00:00 2001
From: Nico <47644445+nicotsx@users.noreply.github.com>
Date: Wed, 11 Feb 2026 21:41:06 +0100
Subject: [PATCH] refactor: react-router -> tanstack start (#498)
* refactor: move to tanstack start
* refactor: auth flow & volumes
* refactor: repo & snapshot details
* refactor: backups, create repo, volumes
* refactor: create volume & restore snapshot
* refactor: notifications
* refactor: settings
* refactor: breadcrumbs
* fix: ts issues
* refactor: prod deployment
* fix: import css production
* refactor: nitro build
* refactor: winston -> consola
* fix: memory leak is sse events cleanup
* fix: cli usage
* chore: remove rr routes file
* refactor: pr feedbacks
* refactor: patch api client to have a global client per call
* refactor: pr feedbacks
* fix(dockerfile): add explicit port
* fix(e2e): healthcheck under /api
---
.dockerignore | 4 +
.github/workflows/e2e.yml | 2 +-
.gitignore | 1 +
Dockerfile | 8 +-
app/client/api-client/client.gen.ts | 28 +-
app/client/api-client/types.gen.ts | 2 +
app/client/components/app-breadcrumb.tsx | 15 +-
app/client/components/app-sidebar.tsx | 6 +-
app/client/components/layout.tsx | 26 +-
app/client/components/restore-form.tsx | 6 +-
app/client/components/snapshots-table.tsx | 9 +-
app/client/components/sortable-card.tsx | 17 +-
app/client/lib/auth-client.ts | 9 +-
app/client/lib/types.ts | 5 +
.../auth/routes/download-recovery-key.tsx | 22 +-
app/client/modules/auth/routes/login.tsx | 26 +-
app/client/modules/auth/routes/onboarding.tsx | 17 +-
.../backups/components/backup-card.tsx | 4 +-
.../components/schedule-mirrors-config.tsx | 49 +-
.../schedule-notifications-config.tsx | 32 +-
.../backups/components/schedule-summary.tsx | 14 +-
.../components/snapshot-file-browser.tsx | 9 +-
.../modules/backups/routes/backup-details.tsx | 74 +-
app/client/modules/backups/routes/backups.tsx | 39 +-
.../modules/backups/routes/create-backup.tsx | 45 +-
.../backups/routes/restore-snapshot.tsx | 62 --
.../routes/create-notification.tsx | 23 +-
.../routes/notification-details.tsx | 39 +-
.../notifications/routes/notifications.tsx | 59 +-
.../repositories/routes/create-repository.tsx | 23 +-
.../repositories/routes/repositories.tsx | 54 +-
.../routes/repository-details.tsx | 50 +-
.../repositories/routes/restore-snapshot.tsx | 53 +-
.../repositories/routes/snapshot-details.tsx | 238 +++--
app/client/modules/repositories/tabs/info.tsx | 6 +-
.../modules/settings/routes/settings.tsx | 44 +-
.../modules/volumes/routes/create-volume.tsx | 23 +-
.../modules/volumes/routes/volume-details.tsx | 57 +-
app/client/modules/volumes/routes/volumes.tsx | 45 +-
app/client/modules/volumes/tabs/info.tsx | 4 +-
app/client/routes/root.tsx | 9 -
app/context.ts | 9 +-
app/lib/request-client.ts | 61 ++
app/middleware/api-client.ts | 17 +
app/middleware/auth.ts | 30 +-
app/root.tsx | 96 --
app/routeTree.gen.ts | 534 +++++++++++
app/router.tsx | 52 ++
app/routes.ts | 26 -
app/routes/(auth)/download-recovery-key.tsx | 15 +
app/routes/(auth)/login.tsx | 15 +
app/routes/(auth)/onboarding.tsx | 15 +
.../backups/$backupId.$snapshotId.restore.tsx | 55 ++
.../(dashboard)/backups/$scheduleId.tsx | 53 ++
app/routes/(dashboard)/backups/create.tsx | 20 +
app/routes/(dashboard)/backups/index.tsx | 28 +
.../notifications/$notificationId.tsx | 33 +
.../(dashboard)/notifications/create.tsx | 25 +
.../(dashboard)/notifications/index.tsx | 26 +
.../repositories/$repoId.$snapId.restore.tsx | 49 +
.../repositories/$repoId.$snapshotId.tsx | 53 ++
.../repositories/$repositoryId.tsx | 49 +
.../(dashboard)/repositories/create.tsx | 25 +
app/routes/(dashboard)/repositories/index.tsx | 29 +
app/routes/(dashboard)/route.tsx | 33 +
app/routes/(dashboard)/settings/index.tsx | 22 +
app/routes/(dashboard)/volumes/$volumeId.tsx | 36 +
app/routes/(dashboard)/volumes/create.tsx | 22 +
app/routes/(dashboard)/volumes/index.tsx | 23 +
app/routes/__root.tsx | 62 ++
app/routes/api.$.ts | 18 +
app/routes/index.tsx | 16 +
app/server.ts | 53 ++
app/server/app.ts | 2 +-
app/server/core/config.ts | 5 +-
app/server/index.ts | 50 -
app/server/lib/auth.ts | 2 +
.../__tests__/events.controller.test.ts | 27 +
.../modules/events/events.controller.ts | 42 +-
.../repositories/repositories.controller.ts | 1 +
.../modules/repositories/repositories.dto.ts | 1 +
app/server/utils/logger.ts | 102 ++-
bun.lock | 854 ++++++++----------
docker-compose.yml | 3 +-
openapi-ts.config.ts | 5 +-
package.json | 31 +-
react-router.config.ts | 9 -
scripts/patch-api-client.ts | 50 +
tsconfig.json | 2 +-
vite.config.ts | 25 +-
90 files changed, 2496 insertions(+), 1573 deletions(-)
delete mode 100644 app/client/modules/backups/routes/restore-snapshot.tsx
delete mode 100644 app/client/routes/root.tsx
create mode 100644 app/lib/request-client.ts
create mode 100644 app/middleware/api-client.ts
delete mode 100644 app/root.tsx
create mode 100644 app/routeTree.gen.ts
create mode 100644 app/router.tsx
delete mode 100644 app/routes.ts
create mode 100644 app/routes/(auth)/download-recovery-key.tsx
create mode 100644 app/routes/(auth)/login.tsx
create mode 100644 app/routes/(auth)/onboarding.tsx
create mode 100644 app/routes/(dashboard)/backups/$backupId.$snapshotId.restore.tsx
create mode 100644 app/routes/(dashboard)/backups/$scheduleId.tsx
create mode 100644 app/routes/(dashboard)/backups/create.tsx
create mode 100644 app/routes/(dashboard)/backups/index.tsx
create mode 100644 app/routes/(dashboard)/notifications/$notificationId.tsx
create mode 100644 app/routes/(dashboard)/notifications/create.tsx
create mode 100644 app/routes/(dashboard)/notifications/index.tsx
create mode 100644 app/routes/(dashboard)/repositories/$repoId.$snapId.restore.tsx
create mode 100644 app/routes/(dashboard)/repositories/$repoId.$snapshotId.tsx
create mode 100644 app/routes/(dashboard)/repositories/$repositoryId.tsx
create mode 100644 app/routes/(dashboard)/repositories/create.tsx
create mode 100644 app/routes/(dashboard)/repositories/index.tsx
create mode 100644 app/routes/(dashboard)/route.tsx
create mode 100644 app/routes/(dashboard)/settings/index.tsx
create mode 100644 app/routes/(dashboard)/volumes/$volumeId.tsx
create mode 100644 app/routes/(dashboard)/volumes/create.tsx
create mode 100644 app/routes/(dashboard)/volumes/index.tsx
create mode 100644 app/routes/__root.tsx
create mode 100644 app/routes/api.$.ts
create mode 100644 app/routes/index.tsx
create mode 100644 app/server.ts
delete mode 100644 app/server/index.ts
delete mode 100644 react-router.config.ts
create mode 100755 scripts/patch-api-client.ts
diff --git a/.dockerignore b/.dockerignore
index 1322a729..d9bea189 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -3,6 +3,8 @@
!turbo.json
!bun.lock
!package.json
+!server.ts
+!.gitignore
!**/package.json
!**/bun.lock
@@ -22,3 +24,5 @@
!LICENSES/**
node_modules/**
+dist/**
+.output/**
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 35d83aa2..b2fbb804 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -57,7 +57,7 @@ jobs:
- name: Wait for zerobyte to be ready
run: |
- timeout 30s bash -c 'until curl -f http://localhost:4096/healthcheck; do echo "Waiting for server..." && sleep 2; done'
+ timeout 30s bash -c 'until curl -f http://localhost:4096/api/healthcheck; do echo "Waiting for server..." && sleep 2; done'
- name: Print docker logs if failed to start
if: failure()
diff --git a/.gitignore b/.gitignore
index b0cc64c6..6d1d89b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,4 @@ playwright/temp
# OpenAPI error logs
openapi-ts-error-*.log
+.output
diff --git a/Dockerfile b/Dockerfile
index a743cf9e..f069687f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -79,6 +79,7 @@ FROM base AS builder
ARG APP_VERSION=dev
ENV VITE_APP_VERSION=${APP_VERSION}
+ENV PORT=4096
WORKDIR /app
@@ -94,6 +95,7 @@ FROM base AS production
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
ENV NODE_ENV="production"
+ENV PORT=4096
WORKDIR /app
@@ -103,8 +105,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/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
-COPY --from=builder /app/dist/client ./dist/client
-COPY --from=builder /app/dist/server ./dist/server
+COPY --from=builder /app/.output ./.output
COPY --from=builder /app/app/drizzle ./assets/migrations
# Include third-party licenses and attribution
@@ -114,5 +115,4 @@ COPY ./LICENSE ./LICENSE.md
EXPOSE 4096
-CMD ["bun", "run", "start"]
-
+CMD ["bun", ".output/server/index.mjs"]
diff --git a/app/client/api-client/client.gen.ts b/app/client/api-client/client.gen.ts
index 457f3c3d..a5761642 100644
--- a/app/client/api-client/client.gen.ts
+++ b/app/client/api-client/client.gen.ts
@@ -1,19 +1,27 @@
// @ts-nocheck
// This file is auto-generated by @hey-api/openapi-ts
-
import { type ClientOptions, type Config, createClient, createConfig } from "./client";
import type { ClientOptions as ClientOptions2 } from "./types.gen";
+import { getRequestClient } from "../../lib/request-client";
-/**
- * The `createClientConfig()` function will be called on client initialization
- * and the returned object will become the client's initial configuration.
- *
- * You may want to initialize your client this way instead of calling
- * `setConfig()`. This is useful for example if you're using Next.js
- * to ensure your client always has the correct values.
- */
export type CreateClientConfig = (
override?: Config,
) => Config & T>;
-export const client = createClient(createConfig({ baseUrl: "http://localhost:4096" }));
+const fallbackClient = createClient(createConfig({ baseUrl: "http://localhost:4096" }));
+
+/**
+ * Proxy client that automatically uses the per-request client from request-scoped server storage.
+ * Falls back to a default client if no request context is available.
+ */
+export const client = new Proxy(fallbackClient, {
+ get(target, prop, receiver) {
+ try {
+ const requestClient = getRequestClient();
+ return Reflect.get(requestClient, prop, receiver);
+ } catch {
+ // No request context available, use fallback
+ return Reflect.get(target, prop, receiver);
+ }
+ },
+});
diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts
index 46689078..0492947e 100644
--- a/app/client/api-client/types.gen.ts
+++ b/app/client/api-client/types.gen.ts
@@ -1649,6 +1649,7 @@ export type ListSnapshotsResponses = {
size: number;
tags: Array;
time: number;
+ hostname?: string;
}>;
};
@@ -1718,6 +1719,7 @@ export type GetSnapshotDetailsResponses = {
size: number;
tags: Array;
time: number;
+ hostname?: string;
};
};
diff --git a/app/client/components/app-breadcrumb.tsx b/app/client/components/app-breadcrumb.tsx
index 6f602f62..d419708a 100644
--- a/app/client/components/app-breadcrumb.tsx
+++ b/app/client/components/app-breadcrumb.tsx
@@ -1,4 +1,4 @@
-import { Link, useMatches, type UIMatch } from "react-router";
+import { useMatches, Link } from "@tanstack/react-router";
import {
Breadcrumb,
BreadcrumbItem,
@@ -13,25 +13,22 @@ export interface BreadcrumbItemData {
href?: string;
}
-interface RouteHandle {
- breadcrumb?: (match: UIMatch) => BreadcrumbItemData[] | null;
-}
+type BreadcrumbFunction = (match: ReturnType[number]) => BreadcrumbItemData[] | null;
export function AppBreadcrumb() {
const matches = useMatches();
- // Find the last match with a breadcrumb handler
const lastMatchWithBreadcrumb = [...matches].reverse().find((match) => {
- const handle = match.handle as RouteHandle | undefined;
- return handle?.breadcrumb;
+ const breadcrumbFn = match.staticData?.breadcrumb as BreadcrumbFunction | undefined;
+ return breadcrumbFn;
});
if (!lastMatchWithBreadcrumb) {
return null;
}
- const handle = lastMatchWithBreadcrumb.handle as RouteHandle;
- const breadcrumbs = handle.breadcrumb?.(lastMatchWithBreadcrumb);
+ const breadcrumbFn = lastMatchWithBreadcrumb.staticData?.breadcrumb as BreadcrumbFunction;
+ const breadcrumbs = breadcrumbFn?.(lastMatchWithBreadcrumb);
if (!breadcrumbs || breadcrumbs.length === 0) {
return null;
diff --git a/app/client/components/app-sidebar.tsx b/app/client/components/app-sidebar.tsx
index 2ed0cd5e..9081013e 100644
--- a/app/client/components/app-sidebar.tsx
+++ b/app/client/components/app-sidebar.tsx
@@ -1,5 +1,4 @@
import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react";
-import { Link, NavLink } from "react-router";
import { useState } from "react";
import {
Sidebar,
@@ -19,6 +18,7 @@ import { cn } from "~/client/lib/utils";
import { APP_VERSION, RCLONE_VERSION, RESTIC_VERSION, SHOUTRRR_VERSION } from "~/client/lib/version";
import { useUpdates } from "~/client/hooks/use-updates";
import { ReleaseNotesDialog } from "./release-notes-dialog";
+import { Link } from "@tanstack/react-router";
const items = [
{
@@ -83,14 +83,14 @@ export function AppSidebar() {
-
+
{({ isActive }) => (
<>
{item.title}
>
)}
-
+
diff --git a/app/client/components/layout.tsx b/app/client/components/layout.tsx
index 5d9d5fc3..e0ee8a14 100644
--- a/app/client/components/layout.tsx
+++ b/app/client/components/layout.tsx
@@ -1,37 +1,27 @@
import { LifeBuoy } from "lucide-react";
-import { Outlet, redirect, useNavigate } from "react-router";
import { toast } from "sonner";
-import { appContext } from "~/context";
-import { authMiddleware } from "~/middleware/auth";
-import type { Route } from "./+types/layout";
-import { AppBreadcrumb } from "./app-breadcrumb";
+import { type AppContext } from "~/context";
import { GridBackground } from "./grid-background";
import { Button } from "./ui/button";
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
import { AppSidebar } from "./app-sidebar";
import { authClient } from "../lib/auth-client";
import { DevPanelListener } from "./dev-panel-listener";
+import { Outlet, useNavigate } from "@tanstack/react-router";
+import { AppBreadcrumb } from "./app-breadcrumb";
-export const clientMiddleware = [authMiddleware];
+type Props = {
+ loaderData: AppContext;
+};
-export async function clientLoader({ context }: Route.LoaderArgs) {
- const ctx = context.get(appContext);
-
- if (ctx.user && !ctx.user.hasDownloadedResticPassword) {
- throw redirect("/download-recovery-key");
- }
-
- return ctx;
-}
-
-export default function Layout({ loaderData }: Route.ComponentProps) {
+export function Layout({ loaderData }: Props) {
const navigate = useNavigate();
const handleLogout = async () => {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
- void navigate("/login", { replace: true });
+ void navigate({ to: "/login", replace: true });
},
onError: ({ error }) => {
toast.error("Logout failed", { description: error.message });
diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx
index b6a8c6b4..043e9b3f 100644
--- a/app/client/components/restore-form.tsx
+++ b/app/client/components/restore-form.tsx
@@ -1,6 +1,5 @@
import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { useNavigate } from "react-router";
import { toast } from "sonner";
import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react";
import { Button } from "~/client/components/ui/button";
@@ -16,6 +15,7 @@ import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
import type { Repository, Snapshot } from "~/client/lib/types";
import { handleRepositoryError } from "~/client/lib/errors";
+import { useNavigate } from "@tanstack/react-router";
type RestoreLocation = "original" | "custom";
@@ -101,7 +101,7 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
...restoreSnapshotMutation(),
onSuccess: () => {
toast.success("Restore completed");
- void navigate(returnPath);
+ void navigate({ to: returnPath });
},
onError: (error) => {
handleRepositoryError("Restore failed", error, repository.id);
@@ -156,7 +156,7 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
-