[WEB-5386] refactor: update all apps to use react-router for development and enable SSR for space app. (#8095)
This commit is contained in:
parent
4ae0763d0f
commit
433b5a4fe1
23 changed files with 358 additions and 1109 deletions
|
|
@ -6,9 +6,9 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development PORT=3001 node server.mjs",
|
||||
"dev": "react-router dev --port 3001",
|
||||
"build": "react-router build",
|
||||
"preview": "react-router build && cross-env NODE_ENV=production PORT=3001 node server.mjs",
|
||||
"preview": "react-router build && serve -s build/client -l 3001",
|
||||
"start": "serve -s build/client -l 3001",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist && rm -rf build",
|
||||
"check:lint": "eslint . --max-warnings 19",
|
||||
|
|
@ -27,23 +27,17 @@
|
|||
"@plane/types": "workspace:*",
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@react-router/express": "^7.9.3",
|
||||
"@react-router/node": "^7.9.3",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tanstack/virtual-core": "^3.13.12",
|
||||
"@vercel/edge": "1.2.2",
|
||||
"axios": "catalog:",
|
||||
"compression": "^1.8.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^5.1.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"isbot": "^5.1.31",
|
||||
"lodash-es": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
"morgan": "^1.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
|
|
@ -59,10 +53,7 @@
|
|||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@react-router/dev": "^7.9.1",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "4.17.23",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import type { Config } from "@react-router/dev/config";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
|
||||
const basePath = joinUrlPath(process.env.NEXT_PUBLIC_ADMIN_BASE_PATH ?? "", "/") ?? "/";
|
||||
|
||||
export default {
|
||||
appDirectory: "app",
|
||||
basename: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH,
|
||||
basename: basePath,
|
||||
// Admin runs as a client-side app; build a static client bundle only
|
||||
ssr: false,
|
||||
} satisfies Config;
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import compression from "compression";
|
||||
import dotenv from "dotenv";
|
||||
import express from "express";
|
||||
import morgan from "morgan";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env") });
|
||||
|
||||
const BUILD_PATH = "./build/server/index.js";
|
||||
const DEVELOPMENT = process.env.NODE_ENV !== "production";
|
||||
|
||||
// Derive the port from NEXT_PUBLIC_ADMIN_BASE_URL when available, otherwise
|
||||
// default to http://localhost:3001 and fall back to PORT env if explicitly set.
|
||||
const DEFAULT_BASE_URL = "http://localhost:3001";
|
||||
const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || DEFAULT_BASE_URL;
|
||||
let parsedBaseUrl;
|
||||
try {
|
||||
parsedBaseUrl = new URL(ADMIN_BASE_URL);
|
||||
} catch {
|
||||
parsedBaseUrl = new URL(DEFAULT_BASE_URL);
|
||||
}
|
||||
|
||||
const PORT = Number.parseInt(parsedBaseUrl.port, 10);
|
||||
|
||||
async function start() {
|
||||
const app = express();
|
||||
|
||||
app.use(compression());
|
||||
app.disable("x-powered-by");
|
||||
|
||||
if (DEVELOPMENT) {
|
||||
console.log("Starting development server");
|
||||
|
||||
const vite = await import("vite").then((vite) =>
|
||||
vite.createServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: "custom",
|
||||
})
|
||||
);
|
||||
|
||||
app.use(vite.middlewares);
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
try {
|
||||
const source = await vite.ssrLoadModule("./server/app.ts");
|
||||
return source.app(req, res, next);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
vite.ssrFixStacktrace(error);
|
||||
}
|
||||
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log("Starting production server");
|
||||
|
||||
app.use("/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" }));
|
||||
app.use(morgan("tiny"));
|
||||
app.use(express.static("build/client", { maxAge: "1h" }));
|
||||
app.use(await import(BUILD_PATH).then((mod) => mod.app));
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
const origin = `${parsedBaseUrl.protocol}//${parsedBaseUrl.hostname}:${PORT}`;
|
||||
console.log(`Server is running on ${origin}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import "react-router";
|
||||
import { createRequestHandler } from "@react-router/express";
|
||||
import express from "express";
|
||||
import type { Express } from "express";
|
||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
|
||||
const NEXT_PUBLIC_API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL
|
||||
? process.env.NEXT_PUBLIC_API_BASE_URL.replace(/\/$/, "")
|
||||
: "http://127.0.0.1:8000";
|
||||
const NEXT_PUBLIC_API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH
|
||||
? process.env.NEXT_PUBLIC_API_BASE_PATH.replace(/\/+$/, "")
|
||||
: "/api";
|
||||
const NORMALIZED_API_BASE_PATH = NEXT_PUBLIC_API_BASE_PATH.startsWith("/")
|
||||
? NEXT_PUBLIC_API_BASE_PATH
|
||||
: `/${NEXT_PUBLIC_API_BASE_PATH}`;
|
||||
const NEXT_PUBLIC_ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH
|
||||
? process.env.NEXT_PUBLIC_ADMIN_BASE_PATH.replace(/\/$/, "")
|
||||
: "/";
|
||||
|
||||
export const app: Express = express();
|
||||
|
||||
// Ensure proxy-aware hostname/URL handling (e.g., X-Forwarded-Host/Proto)
|
||||
// so generated URLs/redirects reflect the public host when behind Nginx.
|
||||
// See related fix in Remix Express adapter.
|
||||
app.set("trust proxy", true);
|
||||
|
||||
app.use(
|
||||
"/api",
|
||||
createProxyMiddleware({
|
||||
target: NEXT_PUBLIC_API_BASE_URL,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
pathRewrite: (path: string) =>
|
||||
NORMALIZED_API_BASE_PATH === "/api" ? path : path.replace(/^\/api/, NORMALIZED_API_BASE_PATH),
|
||||
})
|
||||
);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(
|
||||
createRequestHandler({
|
||||
build: () => import("virtual:react-router/server-build"),
|
||||
})
|
||||
);
|
||||
|
||||
app.use(NEXT_PUBLIC_ADMIN_BASE_PATH, router);
|
||||
|
|
@ -1,59 +1,39 @@
|
|||
import path from "node:path";
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import dotenv from "dotenv";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
|
||||
const PUBLIC_ENV_KEYS = [
|
||||
"NEXT_PUBLIC_API_BASE_URL",
|
||||
"NEXT_PUBLIC_API_BASE_PATH",
|
||||
"NEXT_PUBLIC_ADMIN_BASE_URL",
|
||||
"NEXT_PUBLIC_ADMIN_BASE_PATH",
|
||||
"NEXT_PUBLIC_SPACE_BASE_URL",
|
||||
"NEXT_PUBLIC_SPACE_BASE_PATH",
|
||||
"NEXT_PUBLIC_LIVE_BASE_URL",
|
||||
"NEXT_PUBLIC_LIVE_BASE_PATH",
|
||||
"NEXT_PUBLIC_WEB_BASE_URL",
|
||||
"NEXT_PUBLIC_WEB_BASE_PATH",
|
||||
"NEXT_PUBLIC_WEBSITE_URL",
|
||||
"NEXT_PUBLIC_SUPPORT_EMAIL",
|
||||
];
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env") });
|
||||
|
||||
const publicEnv = PUBLIC_ENV_KEYS.reduce<Record<string, string>>((acc, key) => {
|
||||
acc[key] = process.env[key] ?? "";
|
||||
return acc;
|
||||
}, {});
|
||||
// Automatically expose all environment variables prefixed with NEXT_PUBLIC_
|
||||
const publicEnv = Object.keys(process.env)
|
||||
.filter((key) => key.startsWith("NEXT_PUBLIC_"))
|
||||
.reduce<Record<string, string>>((acc, key) => {
|
||||
acc[key] = process.env[key] ?? "";
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export default defineConfig(({ isSsrBuild }) => {
|
||||
// Only produce an SSR bundle when explicitly enabled.
|
||||
// For static deployments (default), we skip the server build entirely.
|
||||
const enableSsrBuild = process.env.ADMIN_ENABLE_SSR_BUILD === "true";
|
||||
const basePath = joinUrlPath(process.env.NEXT_PUBLIC_ADMIN_BASE_PATH ?? "", "/") ?? "/";
|
||||
const basePath = joinUrlPath(process.env.NEXT_PUBLIC_ADMIN_BASE_PATH ?? "", "/") ?? "/";
|
||||
|
||||
return {
|
||||
base: basePath,
|
||||
define: {
|
||||
"process.env": JSON.stringify(publicEnv),
|
||||
export default defineConfig(() => ({
|
||||
base: basePath,
|
||||
define: {
|
||||
"process.env": JSON.stringify(publicEnv),
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
plugins: [reactRouter(), tsconfigPaths({ projects: [path.resolve(__dirname, "tsconfig.json")] })],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Next.js compatibility shims used within admin
|
||||
"next/image": path.resolve(__dirname, "app/compat/next/image.tsx"),
|
||||
"next/link": path.resolve(__dirname, "app/compat/next/link.tsx"),
|
||||
"next/navigation": path.resolve(__dirname, "app/compat/next/navigation.ts"),
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: 0,
|
||||
rollupOptions:
|
||||
isSsrBuild && enableSsrBuild
|
||||
? {
|
||||
input: path.resolve(__dirname, "server/app.ts"),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
plugins: [reactRouter(), tsconfigPaths({ projects: [path.resolve(__dirname, "tsconfig.json")] })],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Next.js compatibility shims used within admin
|
||||
"next/image": path.resolve(__dirname, "app/compat/next/image.tsx"),
|
||||
"next/link": path.resolve(__dirname, "app/compat/next/link.tsx"),
|
||||
"next/navigation": path.resolve(__dirname, "app/compat/next/navigation.ts"),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
// No SSR-specific overrides needed; alias resolves to ESM build
|
||||
};
|
||||
});
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
// No SSR-specific overrides needed; alias resolves to ESM build
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -73,14 +73,17 @@ RUN pnpm turbo run build --filter=space
|
|||
|
||||
# =========================================================================== #
|
||||
|
||||
FROM nginx:1.27-alpine AS production
|
||||
FROM base AS runner
|
||||
|
||||
COPY apps/space/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=installer /app/apps/space/build/client /usr/share/nginx/html/spaces
|
||||
COPY --from=installer /app/apps/space/build ./apps/space/build
|
||||
COPY --from=installer /app/apps/space/node_modules ./apps/space/node_modules
|
||||
COPY --from=installer /app/node_modules ./node_modules
|
||||
|
||||
WORKDIR /app/apps/space
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["npx", "react-router-serve", "./build/server/index.js"]
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Outlet } from "react-router";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PoweredBy } from "@/components/common/powered-by";
|
||||
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
|
||||
import { IssuesNavbarRoot } from "@/components/issues/navbar";
|
||||
// hooks
|
||||
import { usePublish, usePublishList } from "@/hooks/store/publish";
|
||||
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
|
||||
import type { Route } from "./+types/client-layout";
|
||||
|
||||
const IssuesClientLayout = observer((props: Route.ComponentProps) => {
|
||||
const { anchor } = props.params;
|
||||
// store hooks
|
||||
const { fetchPublishSettings } = usePublishList();
|
||||
const publishSettings = usePublish(anchor);
|
||||
const { updateLayoutOptions } = useIssueFilter();
|
||||
// fetch publish settings
|
||||
const { error } = useSWR(
|
||||
anchor ? `PUBLISH_SETTINGS_${anchor}` : null,
|
||||
anchor
|
||||
? async () => {
|
||||
const response = await fetchPublishSettings(anchor);
|
||||
if (response.view_props) {
|
||||
updateLayoutOptions({
|
||||
list: !!response.view_props.list,
|
||||
kanban: !!response.view_props.kanban,
|
||||
calendar: !!response.view_props.calendar,
|
||||
gantt: !!response.view_props.gantt,
|
||||
spreadsheet: !!response.view_props.spreadsheet,
|
||||
});
|
||||
}
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (!publishSettings && !error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen w-full">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) return <SomethingWentWrongError />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
|
||||
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
|
||||
<IssuesNavbarRoot publishSettings={publishSettings} />
|
||||
</div>
|
||||
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
<PoweredBy />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default IssuesClientLayout;
|
||||
|
|
@ -1,54 +1,142 @@
|
|||
"use server";
|
||||
import { observer } from "mobx-react";
|
||||
import { Outlet } from "react-router";
|
||||
import type { ShouldRevalidateFunctionArgs } from "react-router";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PoweredBy } from "@/components/common/powered-by";
|
||||
import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error";
|
||||
import { IssuesNavbarRoot } from "@/components/issues/navbar";
|
||||
// hooks
|
||||
import { PageNotFound } from "@/components/ui/not-found";
|
||||
import { usePublish, usePublishList } from "@/hooks/store/publish";
|
||||
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
params: {
|
||||
anchor: string;
|
||||
};
|
||||
};
|
||||
const DEFAULT_TITLE = "Plane";
|
||||
const DEFAULT_DESCRIPTION = "Made with Plane, an AI-powered work management platform with publishing capabilities.";
|
||||
|
||||
// TODO: Convert into SSR in order to generate metadata
|
||||
export async function generateMetadata({ params }: Props) {
|
||||
interface IssueMetadata {
|
||||
name?: string;
|
||||
description?: string;
|
||||
cover_image?: string;
|
||||
}
|
||||
|
||||
// Loader function runs on the server and fetches metadata
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const { anchor } = params;
|
||||
const DEFAULT_TITLE = "Plane";
|
||||
const DEFAULT_DESCRIPTION = "Made with Plane, an AI-powered work management platform with publishing capabilities.";
|
||||
|
||||
// Validate anchor before using in request (only allow alphanumeric, -, _)
|
||||
const ANCHOR_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
if (!ANCHOR_REGEX.test(anchor)) {
|
||||
return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION };
|
||||
return { metadata: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/public/anchor/${anchor}/meta/`);
|
||||
const data = await response.json();
|
||||
return {
|
||||
title: data?.name || DEFAULT_TITLE,
|
||||
description: data?.description || DEFAULT_DESCRIPTION,
|
||||
openGraph: {
|
||||
title: data?.name || DEFAULT_TITLE,
|
||||
description: data?.description || DEFAULT_DESCRIPTION,
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: data?.cover_image,
|
||||
width: 800,
|
||||
height: 600,
|
||||
alt: data?.name || DEFAULT_TITLE,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: data?.name || DEFAULT_TITLE,
|
||||
description: data?.description || DEFAULT_DESCRIPTION,
|
||||
images: [data?.cover_image],
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION };
|
||||
|
||||
if (!response.ok) {
|
||||
return { metadata: null };
|
||||
}
|
||||
|
||||
const metadata: IssueMetadata = await response.json();
|
||||
return { metadata };
|
||||
} catch (error) {
|
||||
console.error("Error fetching issue metadata:", error);
|
||||
return { metadata: null };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function IssuesLayout(_props: Props) {
|
||||
// return <IssuesClientLayout params={{ anchor }}>{children}</IssuesClientLayout>;
|
||||
return null;
|
||||
// Meta function uses the loader data to generate metadata
|
||||
export function meta({ loaderData }: Route.MetaArgs) {
|
||||
const metadata = loaderData?.metadata;
|
||||
|
||||
const title = metadata?.name || DEFAULT_TITLE;
|
||||
const description = metadata?.description || DEFAULT_DESCRIPTION;
|
||||
const coverImage = metadata?.cover_image;
|
||||
|
||||
const metaTags = [
|
||||
{ title },
|
||||
{ name: "description", content: description },
|
||||
// OpenGraph metadata
|
||||
{ property: "og:title", content: title },
|
||||
{ property: "og:description", content: description },
|
||||
{ property: "og:type", content: "website" },
|
||||
// Twitter metadata
|
||||
{ name: "twitter:card", content: "summary_large_image" },
|
||||
{ name: "twitter:title", content: title },
|
||||
{ name: "twitter:description", content: description },
|
||||
];
|
||||
|
||||
// Add images if cover image exists
|
||||
if (coverImage) {
|
||||
metaTags.push(
|
||||
{ property: "og:image", content: coverImage },
|
||||
{ property: "og:image:width", content: "800" },
|
||||
{ property: "og:image:height", content: "600" },
|
||||
{ property: "og:image:alt", content: title },
|
||||
{ name: "twitter:image", content: coverImage }
|
||||
);
|
||||
}
|
||||
|
||||
return metaTags;
|
||||
}
|
||||
|
||||
// Prevent loader from re-running on anchor param changes
|
||||
export function shouldRevalidate({ currentParams, nextParams }: ShouldRevalidateFunctionArgs) {
|
||||
return currentParams.anchor !== nextParams.anchor;
|
||||
}
|
||||
|
||||
function IssuesLayout(props: Route.ComponentProps) {
|
||||
const { anchor } = props.params;
|
||||
// store hooks
|
||||
const { fetchPublishSettings } = usePublishList();
|
||||
const publishSettings = usePublish(anchor);
|
||||
const { updateLayoutOptions } = useIssueFilter();
|
||||
// fetch publish settings
|
||||
const { error } = useSWR(
|
||||
anchor ? `PUBLISH_SETTINGS_${anchor}` : null,
|
||||
anchor
|
||||
? async () => {
|
||||
const response = await fetchPublishSettings(anchor);
|
||||
if (response.view_props) {
|
||||
updateLayoutOptions({
|
||||
list: !!response.view_props.list,
|
||||
kanban: !!response.view_props.kanban,
|
||||
calendar: !!response.view_props.calendar,
|
||||
gantt: !!response.view_props.gantt,
|
||||
spreadsheet: !!response.view_props.spreadsheet,
|
||||
});
|
||||
}
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (!publishSettings && !error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen w-full">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error?.status === 404) return <PageNotFound />;
|
||||
|
||||
if (error) return <SomethingWentWrongError />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
|
||||
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
|
||||
<IssuesNavbarRoot publishSettings={publishSettings} />
|
||||
</div>
|
||||
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
<PoweredBy />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(IssuesLayout);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { Links, Meta, Outlet, Scripts } from "react-router";
|
||||
import type { LinksFunction } from "react-router";
|
||||
import type { HeadersFunction, LinksFunction } from "react-router";
|
||||
// assets
|
||||
import appleTouchIcon from "@/app/assets/favicon/apple-touch-icon.png?url";
|
||||
import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url";
|
||||
import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url";
|
||||
import faviconIco from "@/app/assets/favicon/favicon.ico?url";
|
||||
import siteWebmanifest from "@/app/assets/favicon/site.webmanifest?url";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import globalStyles from "@/styles/globals.css?url";
|
||||
// types
|
||||
|
|
@ -21,10 +22,18 @@ export const links: LinksFunction = () => [
|
|||
{ rel: "icon", type: "image/png", sizes: "32x32", href: favicon32 },
|
||||
{ rel: "icon", type: "image/png", sizes: "16x16", href: favicon16 },
|
||||
{ rel: "shortcut icon", href: faviconIco },
|
||||
{ rel: "manifest", href: `/site.webmanifest.json` },
|
||||
{ rel: "manifest", href: siteWebmanifest },
|
||||
{ rel: "stylesheet", href: globalStyles },
|
||||
];
|
||||
|
||||
export const headers: HeadersFunction = () => ({
|
||||
"Referrer-Policy": "origin-when-cross-origin",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-DNS-Prefetch-Control": "on",
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
|
||||
});
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { index, layout, route } from "@react-router/dev/routes";
|
|||
export default [
|
||||
index("./page.tsx"),
|
||||
route(":workspaceSlug/:projectId", "./[workspaceSlug]/[projectId]/page.tsx"),
|
||||
layout("./issues/[anchor]/client-layout.tsx", [route("issues/:anchor", "./issues/[anchor]/page.tsx")]),
|
||||
layout("./issues/[anchor]/layout.tsx", [route("issues/:anchor", "./issues/[anchor]/page.tsx")]),
|
||||
// Catch-all route for 404 handling
|
||||
route("*", "./not-found.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
|
|
@ -25,19 +25,19 @@ export const IssuePeekOverview: React.FC<TIssuePeekOverview> = observer((props)
|
|||
const state = searchParams.get("state") || undefined;
|
||||
const priority = searchParams.get("priority") || undefined;
|
||||
const labels = searchParams.get("labels") || undefined;
|
||||
// states
|
||||
const [isSidePeekOpen, setIsSidePeekOpen] = useState(false);
|
||||
const [isModalPeekOpen, setIsModalPeekOpen] = useState(false);
|
||||
// store
|
||||
const issueDetailStore = useIssueDetails();
|
||||
|
||||
const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : undefined;
|
||||
const { peekMode, setPeekId, getIssueById, fetchIssueDetails } = useIssueDetails();
|
||||
// derived values
|
||||
const issueDetails = peekId ? getIssueById(peekId.toString()) : undefined;
|
||||
// state
|
||||
const isSidePeekOpen = !!peekId && peekMode === "side";
|
||||
const isModalPeekOpen = !!peekId && (peekMode === "modal" || peekMode === "full");
|
||||
|
||||
useEffect(() => {
|
||||
if (anchor && peekId) {
|
||||
issueDetailStore.fetchIssueDetails(anchor, peekId.toString());
|
||||
fetchIssueDetails(anchor, peekId.toString());
|
||||
}
|
||||
}, [anchor, issueDetailStore, peekId]);
|
||||
}, [anchor, fetchIssueDetails, peekId]);
|
||||
|
||||
const handleClose = () => {
|
||||
// if close logic is passed down, call that instead of the below logic
|
||||
|
|
@ -46,7 +46,7 @@ export const IssuePeekOverview: React.FC<TIssuePeekOverview> = observer((props)
|
|||
return;
|
||||
}
|
||||
|
||||
issueDetailStore.setPeekId(null);
|
||||
setPeekId(null);
|
||||
let queryParams: any = {
|
||||
board,
|
||||
};
|
||||
|
|
@ -57,21 +57,6 @@ export const IssuePeekOverview: React.FC<TIssuePeekOverview> = observer((props)
|
|||
router.push(`/issues/${anchor}?${queryParams}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (peekId) {
|
||||
if (issueDetailStore.peekMode === "side") {
|
||||
setIsSidePeekOpen(true);
|
||||
setIsModalPeekOpen(false);
|
||||
} else {
|
||||
setIsModalPeekOpen(true);
|
||||
setIsSidePeekOpen(false);
|
||||
}
|
||||
} else {
|
||||
setIsSidePeekOpen(false);
|
||||
setIsModalPeekOpen(false);
|
||||
}
|
||||
}, [peekId, issueDetailStore.peekMode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition.Root appear show={isSidePeekOpen} as={Fragment}>
|
||||
|
|
@ -116,13 +101,13 @@ export const IssuePeekOverview: React.FC<TIssuePeekOverview> = observer((props)
|
|||
<Dialog.Panel>
|
||||
<div
|
||||
className={`fixed left-1/2 top-1/2 z-20 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-custom-background-100 shadow-custom-shadow-xl transition-all duration-300 ${
|
||||
issueDetailStore.peekMode === "modal" ? "h-[70%] w-3/5" : "h-[95%] w-[95%]"
|
||||
peekMode === "modal" ? "h-[70%] w-3/5" : "h-[95%] w-[95%]"
|
||||
}`}
|
||||
>
|
||||
{issueDetailStore.peekMode === "modal" && (
|
||||
{peekMode === "modal" && (
|
||||
<SidePeekView anchor={anchor} handleClose={handleClose} issueDetails={issueDetails} />
|
||||
)}
|
||||
{issueDetailStore.peekMode === "full" && (
|
||||
{peekMode === "full" && (
|
||||
<FullScreenPeekView anchor={anchor} handleClose={handleClose} issueDetails={issueDetails} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
import { next } from "@vercel/edge";
|
||||
|
||||
export default function middleware() {
|
||||
return next({
|
||||
headers: {
|
||||
"Referrer-Policy": "origin-when-cross-origin",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-DNS-Prefetch-Control": "on",
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -5,10 +5,10 @@
|
|||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development PORT=3002 node server.mjs",
|
||||
"dev": "react-router dev --port 3002",
|
||||
"build": "react-router build",
|
||||
"preview": "react-router build && cross-env NODE_ENV=production PORT=3002 node server.mjs",
|
||||
"start": "serve -s build/client -l 3002",
|
||||
"preview": "react-router build && PORT=3002 react-router-serve ./build/server/index.js",
|
||||
"start": "PORT=3002 react-router-serve ./build/server/index.js",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf .react-router && rm -rf node_modules && rm -rf dist && rm -rf build",
|
||||
"check:lint": "eslint . --max-warnings 28",
|
||||
"check:types": "react-router typegen && tsc --noEmit",
|
||||
|
|
@ -30,24 +30,18 @@
|
|||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@react-router/express": "^7.9.3",
|
||||
"@react-router/node": "^7.9.3",
|
||||
"@vercel/edge": "1.2.2",
|
||||
"@react-router/serve": "^7.9.5",
|
||||
"axios": "catalog:",
|
||||
"clsx": "^2.0.0",
|
||||
"compression": "^1.8.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^5.1.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"isbot": "^5.1.31",
|
||||
"lodash-es": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
"mobx-utils": "catalog:",
|
||||
"morgan": "^1.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
|
|
@ -56,7 +50,6 @@
|
|||
"react-popper": "^2.3.0",
|
||||
"react-router": "^7.9.1",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"serve": "14.2.5",
|
||||
"swr": "catalog:",
|
||||
"uuid": "catalog:"
|
||||
},
|
||||
|
|
@ -65,10 +58,7 @@
|
|||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@react-router/dev": "^7.9.1",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "4.17.23",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import type { Config } from "@react-router/dev/config";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
|
||||
const basePath = joinUrlPath(process.env.NEXT_PUBLIC_SPACE_BASE_PATH ?? "", "/") ?? "/";
|
||||
|
||||
export default {
|
||||
appDirectory: "app",
|
||||
basename: process.env.NEXT_PUBLIC_SPACE_BASE_PATH,
|
||||
// Space runs as a client-side app; build a static client bundle only
|
||||
ssr: false,
|
||||
basename: basePath,
|
||||
ssr: true,
|
||||
} satisfies Config;
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import compression from "compression";
|
||||
import dotenv from "dotenv";
|
||||
import express from "express";
|
||||
import morgan from "morgan";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env") });
|
||||
|
||||
const BUILD_PATH = "./build/server/index.js";
|
||||
const DEVELOPMENT = process.env.NODE_ENV !== "production";
|
||||
|
||||
// Derive the port from NEXT_PUBLIC_SPACE_BASE_URL when available, otherwise
|
||||
// default to http://localhost:3002 and fall back to PORT env if explicitly set.
|
||||
const DEFAULT_BASE_URL = "http://localhost:3002";
|
||||
const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || DEFAULT_BASE_URL;
|
||||
let parsedBaseUrl;
|
||||
try {
|
||||
parsedBaseUrl = new URL(SPACE_BASE_URL);
|
||||
} catch {
|
||||
parsedBaseUrl = new URL(DEFAULT_BASE_URL);
|
||||
}
|
||||
|
||||
const PORT = Number.parseInt(parsedBaseUrl.port, 10);
|
||||
|
||||
async function start() {
|
||||
const app = express();
|
||||
|
||||
app.use(compression());
|
||||
app.disable("x-powered-by");
|
||||
|
||||
if (DEVELOPMENT) {
|
||||
console.log("Starting development server");
|
||||
|
||||
const vite = await import("vite").then((vite) =>
|
||||
vite.createServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: "custom",
|
||||
})
|
||||
);
|
||||
|
||||
app.use(vite.middlewares);
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
try {
|
||||
const source = await vite.ssrLoadModule("./server/app.ts");
|
||||
return source.app(req, res, next);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
vite.ssrFixStacktrace(error);
|
||||
}
|
||||
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log("Starting production server");
|
||||
|
||||
app.use("/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" }));
|
||||
app.use(morgan("tiny"));
|
||||
app.use(express.static("build/client", { maxAge: "1h" }));
|
||||
app.use(await import(BUILD_PATH).then((mod) => mod.app));
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
const origin = `${parsedBaseUrl.protocol}//${parsedBaseUrl.hostname}:${PORT}`;
|
||||
console.log(`Server is running on ${origin}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import "react-router";
|
||||
import { createRequestHandler } from "@react-router/express";
|
||||
import express from "express";
|
||||
import type { Express } from "express";
|
||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
|
||||
const NEXT_PUBLIC_API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL
|
||||
? process.env.NEXT_PUBLIC_API_BASE_URL.replace(/\/$/, "")
|
||||
: "http://127.0.0.1:8000";
|
||||
const NEXT_PUBLIC_API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH
|
||||
? process.env.NEXT_PUBLIC_API_BASE_PATH.replace(/\/+$/, "")
|
||||
: "/api";
|
||||
const NORMALIZED_API_BASE_PATH = NEXT_PUBLIC_API_BASE_PATH.startsWith("/")
|
||||
? NEXT_PUBLIC_API_BASE_PATH
|
||||
: `/${NEXT_PUBLIC_API_BASE_PATH}`;
|
||||
const NEXT_PUBLIC_SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH
|
||||
? process.env.NEXT_PUBLIC_SPACE_BASE_PATH.replace(/\/$/, "")
|
||||
: "/";
|
||||
|
||||
export const app: Express = express();
|
||||
|
||||
// Ensure proxy-aware hostname/URL handling (e.g., X-Forwarded-Host/Proto)
|
||||
// so generated URLs/redirects reflect the public host when behind Nginx.
|
||||
// See related fix in Remix Express adapter.
|
||||
app.set("trust proxy", true);
|
||||
|
||||
app.use(
|
||||
"/api",
|
||||
createProxyMiddleware({
|
||||
target: NEXT_PUBLIC_API_BASE_URL,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
pathRewrite: (path: string) =>
|
||||
NORMALIZED_API_BASE_PATH === "/api" ? path : path.replace(/^\/api/, NORMALIZED_API_BASE_PATH),
|
||||
})
|
||||
);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(
|
||||
createRequestHandler({
|
||||
build: () => import("virtual:react-router/server-build"),
|
||||
})
|
||||
);
|
||||
|
||||
app.use(NEXT_PUBLIC_SPACE_BASE_PATH, router);
|
||||
|
|
@ -1,59 +1,38 @@
|
|||
import path from "node:path";
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import dotenv from "dotenv";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { joinUrlPath } from "@plane/utils";
|
||||
|
||||
const PUBLIC_ENV_KEYS = [
|
||||
"NEXT_PUBLIC_API_BASE_URL",
|
||||
"NEXT_PUBLIC_API_BASE_PATH",
|
||||
"NEXT_PUBLIC_ADMIN_BASE_URL",
|
||||
"NEXT_PUBLIC_ADMIN_BASE_PATH",
|
||||
"NEXT_PUBLIC_SPACE_BASE_URL",
|
||||
"NEXT_PUBLIC_SPACE_BASE_PATH",
|
||||
"NEXT_PUBLIC_LIVE_BASE_URL",
|
||||
"NEXT_PUBLIC_LIVE_BASE_PATH",
|
||||
"NEXT_PUBLIC_WEB_BASE_URL",
|
||||
"NEXT_PUBLIC_WEB_BASE_PATH",
|
||||
"NEXT_PUBLIC_WEBSITE_URL",
|
||||
"NEXT_PUBLIC_SUPPORT_EMAIL",
|
||||
];
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env") });
|
||||
|
||||
const publicEnv = PUBLIC_ENV_KEYS.reduce<Record<string, string>>((acc, key) => {
|
||||
acc[key] = process.env[key] ?? "";
|
||||
return acc;
|
||||
}, {});
|
||||
// Automatically expose all environment variables prefixed with NEXT_PUBLIC_
|
||||
const publicEnv = Object.keys(process.env)
|
||||
.filter((key) => key.startsWith("NEXT_PUBLIC_"))
|
||||
.reduce<Record<string, string>>((acc, key) => {
|
||||
acc[key] = process.env[key] ?? "";
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export default defineConfig(({ isSsrBuild }) => {
|
||||
// Only produce an SSR bundle when explicitly enabled.
|
||||
// For static deployments (default), we skip the server build entirely.
|
||||
const enableSsrBuild = process.env.SPACE_ENABLE_SSR_BUILD === "true";
|
||||
const basePath = joinUrlPath(process.env.NEXT_PUBLIC_SPACE_BASE_PATH ?? "", "/") ?? "/";
|
||||
const basePath = joinUrlPath(process.env.NEXT_PUBLIC_SPACE_BASE_PATH ?? "", "/") ?? "/";
|
||||
|
||||
return {
|
||||
base: basePath,
|
||||
define: {
|
||||
"process.env": JSON.stringify(publicEnv),
|
||||
export default defineConfig(() => ({
|
||||
base: basePath,
|
||||
define: {
|
||||
"process.env": JSON.stringify(publicEnv),
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
plugins: [reactRouter(), tsconfigPaths({ projects: [path.resolve(__dirname, "tsconfig.json")] })],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Next.js compatibility shims used within space
|
||||
"next/image": path.resolve(__dirname, "app/compat/next/image.tsx"),
|
||||
"next/link": path.resolve(__dirname, "app/compat/next/link.tsx"),
|
||||
"next/navigation": path.resolve(__dirname, "app/compat/next/navigation.ts"),
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: 0,
|
||||
rollupOptions:
|
||||
isSsrBuild && enableSsrBuild
|
||||
? {
|
||||
input: path.resolve(__dirname, "server/app.ts"),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
plugins: [reactRouter(), tsconfigPaths({ projects: [path.resolve(__dirname, "tsconfig.json")] })],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Next.js compatibility shims used within space
|
||||
"next/image": path.resolve(__dirname, "app/compat/next/image.tsx"),
|
||||
"next/link": path.resolve(__dirname, "app/compat/next/link.tsx"),
|
||||
"next/navigation": path.resolve(__dirname, "app/compat/next/navigation.ts"),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
// No SSR-specific overrides needed; alias resolves to ESM build
|
||||
};
|
||||
});
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development PORT=3000 node server.mjs",
|
||||
"dev": "react-router dev --port 3000",
|
||||
"build": "react-router build",
|
||||
"preview": "react-router build && cross-env NODE_ENV=production PORT=3000 node server.mjs",
|
||||
"preview": "react-router build && serve -s build/client -l 3000",
|
||||
"start": "serve -s build/client -l 3000",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf .react-router && rm -rf node_modules && rm -rf dist && rm -rf build",
|
||||
"check:lint": "eslint . --max-warnings 821",
|
||||
|
|
@ -35,28 +35,22 @@
|
|||
"@plane/utils": "workspace:*",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@react-pdf/renderer": "^3.4.5",
|
||||
"@react-router/express": "^7.9.3",
|
||||
"@react-router/node": "^7.9.3",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "catalog:",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^1.0.0",
|
||||
"comlink": "^4.4.1",
|
||||
"compression": "^1.8.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"emoji-picker-react": "^4.5.16",
|
||||
"export-to-csv": "^1.4.0",
|
||||
"express": "^5.1.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"isbot": "^5.1.31",
|
||||
"lodash-es": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mobx-react": "catalog:",
|
||||
"mobx-utils": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"morgan": "^1.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"posthog-js": "^1.131.3",
|
||||
"react-color": "^2.19.3",
|
||||
|
|
@ -85,10 +79,7 @@
|
|||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@react-router/dev": "^7.9.1",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "4.17.23",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react-color": "^3.0.6",
|
||||
"@types/react-dom": "catalog:",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { Config } from "@react-router/dev/config";
|
|||
|
||||
export default {
|
||||
appDirectory: "app",
|
||||
basename: process.env.NEXT_PUBLIC_WEB_BASE_PATH,
|
||||
// Web runs as a client-side app; build a static client bundle only
|
||||
ssr: false,
|
||||
} satisfies Config;
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import compression from "compression";
|
||||
import dotenv from "dotenv";
|
||||
import express from "express";
|
||||
import morgan from "morgan";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env") });
|
||||
|
||||
const BUILD_PATH = "./build/server/index.js";
|
||||
const DEVELOPMENT = process.env.NODE_ENV !== "production";
|
||||
|
||||
// Derive the port from NEXT_PUBLIC_WEB_BASE_URL when available, otherwise
|
||||
// default to http://localhost:3000 and fall back to PORT env if explicitly set.
|
||||
const DEFAULT_BASE_URL = "http://localhost:3000";
|
||||
const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || DEFAULT_BASE_URL;
|
||||
let parsedBaseUrl;
|
||||
try {
|
||||
parsedBaseUrl = new URL(WEB_BASE_URL);
|
||||
} catch {
|
||||
parsedBaseUrl = new URL(DEFAULT_BASE_URL);
|
||||
}
|
||||
|
||||
const PORT = Number.parseInt(parsedBaseUrl.port, 10);
|
||||
|
||||
async function start() {
|
||||
const app = express();
|
||||
|
||||
app.use(compression());
|
||||
app.disable("x-powered-by");
|
||||
|
||||
if (DEVELOPMENT) {
|
||||
console.log("Starting development server");
|
||||
|
||||
const vite = await import("vite").then((vite) =>
|
||||
vite.createServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: "custom",
|
||||
})
|
||||
);
|
||||
|
||||
app.use(vite.middlewares);
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
try {
|
||||
const source = await vite.ssrLoadModule("./server/app.ts");
|
||||
return source.app(req, res, next);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
vite.ssrFixStacktrace(error);
|
||||
}
|
||||
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log("Starting production server");
|
||||
|
||||
app.use("/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" }));
|
||||
app.use(morgan("tiny"));
|
||||
app.use(express.static("build/client", { maxAge: "1h" }));
|
||||
app.use(await import(BUILD_PATH).then((mod) => mod.app));
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
const origin = `${parsedBaseUrl.protocol}//${parsedBaseUrl.hostname}:${PORT}`;
|
||||
console.log(`Server is running on ${origin}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import "react-router";
|
||||
import { createRequestHandler } from "@react-router/express";
|
||||
import express from "express";
|
||||
import type { Express } from "express";
|
||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
import type { ServerBuild } from "react-router";
|
||||
|
||||
const NEXT_PUBLIC_API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL
|
||||
? process.env.NEXT_PUBLIC_API_BASE_URL.replace(/\/$/, "")
|
||||
: "http://127.0.0.1:8000";
|
||||
const NEXT_PUBLIC_API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH
|
||||
? process.env.NEXT_PUBLIC_API_BASE_PATH.replace(/\/+$/, "")
|
||||
: "/api";
|
||||
const NORMALIZED_API_BASE_PATH = NEXT_PUBLIC_API_BASE_PATH.startsWith("/")
|
||||
? NEXT_PUBLIC_API_BASE_PATH
|
||||
: `/${NEXT_PUBLIC_API_BASE_PATH}`;
|
||||
const NEXT_PUBLIC_WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH
|
||||
? process.env.NEXT_PUBLIC_WEB_BASE_PATH.replace(/\/$/, "")
|
||||
: "/";
|
||||
|
||||
export const app: Express = express();
|
||||
|
||||
// Ensure proxy-aware hostname/URL handling (e.g., X-Forwarded-Host/Proto)
|
||||
// so generated URLs/redirects reflect the public host when behind Nginx.
|
||||
// See related fix in Remix Express adapter.
|
||||
app.set("trust proxy", true);
|
||||
|
||||
app.use(
|
||||
"/api",
|
||||
createProxyMiddleware({
|
||||
target: NEXT_PUBLIC_API_BASE_URL,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
pathRewrite: (path: string) =>
|
||||
NORMALIZED_API_BASE_PATH === "/api" ? path : path.replace(/^\/api/, NORMALIZED_API_BASE_PATH),
|
||||
})
|
||||
);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(
|
||||
createRequestHandler({
|
||||
build: () => import("virtual:react-router/server-build") as Promise<ServerBuild>,
|
||||
})
|
||||
);
|
||||
|
||||
app.use(NEXT_PUBLIC_WEB_BASE_PATH, router);
|
||||
|
|
@ -1,67 +1,36 @@
|
|||
import path from "node:path";
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import dotenv from "dotenv";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
const PUBLIC_ENV_KEYS = [
|
||||
"ENABLE_EXPERIMENTAL_COREPACK",
|
||||
"NEXT_PUBLIC_ADMIN_BASE_PATH",
|
||||
"NEXT_PUBLIC_ADMIN_BASE_URL",
|
||||
"NEXT_PUBLIC_API_BASE_PATH",
|
||||
"NEXT_PUBLIC_API_BASE_URL",
|
||||
"NEXT_PUBLIC_CRISP_ID",
|
||||
"NEXT_PUBLIC_ENABLE_SESSION_RECORDER",
|
||||
"NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS",
|
||||
"NEXT_PUBLIC_LIVE_BASE_PATH",
|
||||
"NEXT_PUBLIC_LIVE_BASE_URL",
|
||||
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
||||
"NEXT_PUBLIC_POSTHOG_DEBUG",
|
||||
"NEXT_PUBLIC_POSTHOG_HOST",
|
||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||
"NEXT_PUBLIC_SESSION_RECORDER_KEY",
|
||||
"NEXT_PUBLIC_SPACE_BASE_PATH",
|
||||
"NEXT_PUBLIC_SPACE_BASE_URL",
|
||||
"NEXT_PUBLIC_SUPPORT_EMAIL",
|
||||
"NEXT_PUBLIC_WEB_BASE_PATH",
|
||||
"NEXT_PUBLIC_WEB_BASE_URL",
|
||||
"NEXT_PUBLIC_WEBSITE_URL",
|
||||
"NODE_ENV",
|
||||
];
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env") });
|
||||
|
||||
const publicEnv = PUBLIC_ENV_KEYS.reduce<Record<string, string>>((acc, key) => {
|
||||
acc[key] = process.env[key] ?? "";
|
||||
return acc;
|
||||
}, {});
|
||||
// Automatically expose all environment variables prefixed with NEXT_PUBLIC_
|
||||
const publicEnv = Object.keys(process.env)
|
||||
.filter((key) => key.startsWith("NEXT_PUBLIC_"))
|
||||
.reduce<Record<string, string>>((acc, key) => {
|
||||
acc[key] = process.env[key] ?? "";
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export default defineConfig(({ isSsrBuild }) => {
|
||||
// Only produce an SSR bundle when explicitly enabled.
|
||||
// For static deployments (default), we skip the server build entirely.
|
||||
const enableSsrBuild = process.env.WEB_ENABLE_SSR_BUILD === "true";
|
||||
|
||||
return {
|
||||
define: {
|
||||
"process.env": JSON.stringify(publicEnv),
|
||||
export default defineConfig(() => ({
|
||||
define: {
|
||||
"process.env": JSON.stringify(publicEnv),
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
plugins: [reactRouter(), tsconfigPaths({ projects: [path.resolve(__dirname, "tsconfig.json")] })],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Next.js compatibility shims used within web
|
||||
"next/image": path.resolve(__dirname, "app/compat/next/image.tsx"),
|
||||
"next/link": path.resolve(__dirname, "app/compat/next/link.tsx"),
|
||||
"next/navigation": path.resolve(__dirname, "app/compat/next/navigation.ts"),
|
||||
"next/script": path.resolve(__dirname, "app/compat/next/script.tsx"),
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: 0,
|
||||
rollupOptions:
|
||||
isSsrBuild && enableSsrBuild
|
||||
? {
|
||||
input: path.resolve(__dirname, "server/app.ts"),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
plugins: [reactRouter(), tsconfigPaths({ projects: [path.resolve(__dirname, "tsconfig.json")] })],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Next.js compatibility shims used within web
|
||||
"next/image": path.resolve(__dirname, "app/compat/next/image.tsx"),
|
||||
"next/link": path.resolve(__dirname, "app/compat/next/link.tsx"),
|
||||
"next/navigation": path.resolve(__dirname, "app/compat/next/navigation.ts"),
|
||||
"next/script": path.resolve(__dirname, "app/compat/next/script.tsx"),
|
||||
},
|
||||
dedupe: ["react", "react-dom", "@headlessui/react"],
|
||||
},
|
||||
// No SSR-specific overrides needed; alias resolves to ESM build
|
||||
};
|
||||
});
|
||||
dedupe: ["react", "react-dom", "@headlessui/react"],
|
||||
},
|
||||
// No SSR-specific overrides needed; alias resolves to ESM build
|
||||
}));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue