♻️ refactor: migrate frontend from Next.js App Router to Vite SPA (#12404)

* init plan

* 📝 docs: update SPA plan for dev mode Worker cross-origin handling

- Clarified the handling of Worker cross-origin issues in dev mode, emphasizing the need for `workerPatch` to wrap cross-origin URLs as blob URLs.
- Enhanced the explanation of the dev mode's resource URL rewriting process for better understanding.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 refactor: Phase 1 - 环境变量整治

- Fix Pyodide env var mismatch (NEXT_PUBLIC_PYPI_INDEX_URL → pythonEnv.NEXT_PUBLIC_PYODIDE_PIP_INDEX_URL)
- Consolidate python.ts to use pythonEnv instead of direct process.env
- Remove NEXT_PUBLIC_ prefix from server-side MARKET_BASE_URL (5 files)

* 🏗️ chore: Phase 2 - Vite 工程搭建

- Add vite.config.ts with dual build (desktop/mobile via MOBILE env)
- Add index.html SPA template with __SERVER_CONFIG__ placeholder
- Add entry.desktop.tsx and entry.mobile.tsx SPA entry points
- Add dev:spa, dev:spa:mobile, build:spa, build:spa:copy scripts
- Install @vitejs/plugin-react and linkedom

* ♻️ refactor: Phase 3 - 第一方包 Next.js 解耦

- Replace next/link with <a> in builtin-tool-web-browsing (4 files, external links)
- Replace next/image with <img> in builtin-tool-agent-builder/InstallPlugin.tsx
- Add Vite import.meta.env compat for isDesktop in const/version.ts, builtin-tool-gtd, builtin-tool-group-management

* ♻️ refactor: Phase 4a - Auth 页面改用直接 next/navigation 和 next/link

- 9 auth files: @/libs/next/navigation → next/navigation
- 5 auth files: @/libs/next/Link → next/link
- Auth pages remain in Next.js App Router, need direct Next.js imports

* ♻️ refactor: Phase 4b - Next.js 抽象层替换为 react-router-dom/vanilla React

- navigation.ts: useRouter/usePathname/useSearchParams/useParams → react-router-dom
- navigation.ts: redirect/notFound → custom error throws
- navigation.ts: useServerInsertedHTML → no-op for SPA
- Link.tsx: next/link → react-router-dom Link adapter (href→to, external→<a>)
- Image.tsx: next/image → <img> wrapper with fill/style support
- dynamic.tsx: next/dynamic → React.lazy + Suspense wrapper

*  feat: Phase 5 - 新建 SPAGlobalProvider

- Create SPAServerConfig type (analyticsConfig, clientEnv, theme, featureFlags, locale)
- Add window.__SERVER_CONFIG__ and __MOBILE__ to global.d.ts
- Create SPAGlobalProvider (client-only Provider tree mirroring GlobalProvider)
- Includes AuthProvider for user session support
- Update entry.desktop.tsx and entry.mobile.tsx to wrap with SPAGlobalProvider

* ♻️ refactor: add SPA catch-all route handler with Vite dev proxy

- Create (spa)/[[...path]]/route.ts for serving SPA HTML
- Dev mode: proxy Vite dev server, rewrite asset URLs, inject Worker patch
- Prod mode: read pre-built HTML templates
- Build SPAServerConfig with analytics, theme, clientEnv, featureFlags
- Update middleware to pass SPA routes through to catch-all

* ♻️ refactor: skip auth checks for SPA routes in middleware

SPA pages are all public (no sensitive data in HTML).
Auth is handled client-side by SPAGlobalProvider's AuthProvider.
Only Next.js auth routes and API endpoints go through session checks.

* ♻️ refactor: replace Next.js-specific analytics with vanilla JS

- Google.tsx: replace @next/third-parties/google with direct gtag script
- ReactScan.tsx: replace react-scan/monitoring/next with generic script
- Desktop.tsx: replace next/script with native script injection

* ♻️ refactor: migrate @t3-oss/env-nextjs to @t3-oss/env-core

Replace framework-specific env validation with framework-agnostic version.
Add clientPrefix where client schemas exist.

* ♻️ refactor: replace next-mdx-remote/rsc with react-markdown

Use client-side react-markdown for MDX rendering instead of
Next.js RSC-dependent next-mdx-remote.

* 🔧 chore: update build scripts and Dockerfile for SPA integration

- build:docker now includes SPA build + copy steps
- dev defaults to Vite SPA, dev:next for Next.js backend
- Dockerfile copies public/spa/ assets for production
- Add public/spa/ to .gitignore (build artifact)

* 🗑️ chore: remove old Next.js route segment files and serwist PWA

- Delete [variants] page.tsx, error.tsx, not-found.tsx, loading.tsx
- Delete root loading.tsx and empty [[...path]] directory
- Delete unused loaders directory
- Remove @serwist/next PWA wrapper from Next.js config

* plan2

*  feat: add locale detection script to index.html for SPA dev mode

* ♻️ refactor: remove locale and theme from SPAServerConfig

*  feat: add [locale] segment with force-static and SEO meta generation

* ♻️ refactor: remove theme/locale reads from SPAGlobalProvider

*  feat: set vite base to /spa/ for production builds

*  feat: auto-generate spaHtmlTemplates from vite build output

* 🔧 chore: register dev:next task in turbo.json for parallel dev startup

* ♻️ refactor: rename (spa) route group to spa segment, rewrite SPA routes via middleware

*  feat: add Vite-compatible i18n/locale modules with import.meta.glob and resolve aliases

* 🔧 fix: use custom Vite plugin for module redirects instead of resolve.alias

* very important

* build

* 🔧 chore: update build scripts and clean up Vite configuration by removing unused plugin and code

Signed-off-by: Innei <tukon479@gmail.com>

* 🗑️ refactor: remove all electron modifier scripts

Modifiers are no longer needed with Vite SPA renderer build.

*  feat: add Vite renderer entry to electron-vite config

Add renderer build configuration to electron-vite, replacing the old
Next.js shadow workspace build flow. Delete buildNextApp.mts and
moveNextExports.ts, update package.json scripts accordingly.

*  feat: add .desktop suffix files for eager i18n loading

Create 4 .desktop files that use import.meta.glob({ eager: true })
for synchronous locale access in Electron desktop builds, replacing
the async lazy-loading used in web SPA builds.

* 🔧 refactor: adapt Electron main process for Vite renderer

Replace nextExportDir with rendererDir, update protocol from
app://next to app://renderer, simplify file resolution to SPA
fallback pattern, update _next/ asset paths to /assets/.

* 🔧 chore: update electron-builder files config for Vite renderer

Replace dist/next references with dist/renderer, remove Next.js
specific exclusion rules no longer applicable to Vite output.

* 🗑️ chore: remove @ast-grep/napi dependency

No longer needed after removing electron modifier scripts.

* 🔧 refactor: unify isDesktop to __ELECTRON__ compile-time constant

Remove NEXT_PUBLIC_IS_DESKTOP_APP and VITE_IS_DESKTOP_APP env vars.
Unify isDesktop in @lobechat/const using __ELECTRON__ defined by Vite.
Re-export from builtin-tool packages. Scripts use DESKTOP_BUILD.

* update

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 refactor: use electron-vite ELECTRON_RENDERER_URL instead of hardcoded port 3015

Replace hardcoded http://localhost:3015 with process.env.ELECTRON_RENDERER_URL
injected by electron-vite dev server. Clean up stale Next.js references.

* 🐛 fix: use local renderer-entry shim to resolve Vite root path issue

HTML entry ../../src/entry.desktop.tsx resolves to /src/entry.desktop.tsx
in URL space, which Vite cannot find within apps/desktop/ root. Add a
local shim that imports across root via module resolver instead.

* 🔧 refactor: extract shared renderer Vite config into sharedRendererConfig

Deduplicate plugins (nodeModuleStub, platformResolve, tsconfigPaths) and
define (__MOBILE__, __ELECTRON__, process.env) between root vite.config.ts
and electron.vite.config.ts renderer section.

* 🔧 refactor: move all renderer plugins and optimizeDeps into shared config

sharedRendererPlugins now includes react, codeInspectorPlugin alongside
nodeModuleStub, platformResolve, tsconfigPaths. Add sharedOptimizeDeps
for pre-bundling list. Both root and electron configs consume shared only.

* 🐛 fix: set electron renderer root to monorepo root for correct glob resolution

import.meta.glob with absolute paths (e.g. /node_modules/antd/...) resolved
within apps/desktop/ instead of monorepo root. Change renderer root to ROOT_DIR,
add electronDesktopHtmlPlugin middleware to rewrite / to /apps/desktop/index.html,
and remove the now-unnecessary renderer-entry.ts shim.

* desktop vite !!

Signed-off-by: Innei <tukon479@gmail.com>

* sync import !!

Signed-off-by: Innei <tukon479@gmail.com>

* clean ci!!

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 refactor: update SPA path structure and clean up dependencies

- Changed the path in .gitignore and related files from [locale] to [variants] for SPA templates.
- Updated index.html to set body height to 100%.
- Cleaned up package.json by removing unused dependencies and reorganizing devDependencies.
- Refactored RendererUrlManager to use a constant for SPA entry HTML path.
- Removed obsolete route.ts file from the SPA structure.
- Adjusted proxy configuration to reflect the new SPA path structure.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore: update build script to include mobile SPA build

- Modified the build script in package.json to add the mobile SPA build step.
- Ensured the build process accommodates both desktop and mobile SPA versions.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore: update build scripts and improve file encoding consistency

- Modified the build script in package.json to ensure the SPA copy step runs after the build.
- Updated file encoding in generateSpaTemplates.mts from 'utf-8' to 'utf8' for consistency.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 fix: correct Blob import syntax and update global server config type

- Fixed the Blob import syntax in route.ts to ensure proper module loading.
- Updated the global server configuration type in global.d.ts for improved type safety.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 test: update RendererUrlManager test to reflect new file path

- Modified the mock implementation in RendererUrlManager.test.ts to check for the updated file path '/mock/export/out/apps/desktop/index.html'.
- Adjusted the expected resolved path in the test to match the new structure.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 refactor: remove catch-all example file and update imports

- Deleted the catch-all example file `catch-all.eg.ts` to streamline the codebase.
- Updated import paths in `ClientResponsiveLayout.tsx` and `ClientResponsiveContent/index.tsx` to use the new dynamic import location.
- Added type declarations for HTML templates in `spaHtmlTemplates.d.ts`.
- Adjusted `tsconfig.json` to include the updated file structure.
- Enhanced type definitions in `global.d.ts` and fixed locale loading in `locale.vite.ts`.

Signed-off-by: Innei <tukon479@gmail.com>

* e2e

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore: remove unused build script for Vercel deployment

- Deleted the `build:vercel` script from package.json to streamline the build process.
- Ensured the remaining build scripts are organized and relevant.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 config: update Vite build input for mobile support

- Changed the build input path in vite.config.ts to conditionally use 'index.mobile.html' for mobile builds, enhancing support for mobile SPA versions.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 feat: add compatibility checks for import maps and cascade layers

- Implemented functions to check for browser support of import maps and CSS cascade layers.
- Redirected users to a compatibility page if their browser does not support the required features.
- Updated the build script in package.json to use the experimental analyze command for better performance.

Signed-off-by: Innei <tukon479@gmail.com>

* chore: rename

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 feat: refactor authentication layout and introduce global providers

- Created a new `RootLayout` component to streamline the layout structure.
- Removed the old layout file for variants and integrated necessary features into the new layout.
- Added `AuthGlobalProvider` to manage authentication context and server configurations.
- Introduced language and theme selection components for enhanced user experience.
- Updated various components to utilize the new context and improve modularity.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 config: exclude build artifacts from serverless functions

- Updated the `next.config.ts` to exclude SPA, desktop, and mobile build artifacts from serverless functions.
- Added paths for `public/spa/**`, `dist/**`, `apps/desktop/build/**`, and `packages/database/migrations/**` to the exclusion list.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 config: refine exclusion of build artifacts from serverless functions

- Updated `next.config.ts` to specify exclusion paths for desktop and mobile build artifacts.
- Changed exclusions from `dist/**` and `apps/desktop/build/**` to `dist/desktop/**`, `dist/mobile/**`, and `apps/desktop/**` for better clarity and organization.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 fix: update BrowserRouter basename for local development

- Modified the `ClientRouter` component to conditionally set the `basename` of `BrowserRouter` based on the `__DEBUG_PROXY__` variable, improving local development experience.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 feat: implement mobile SPA workflow and S3 asset management

- Added a new workflow for building and uploading mobile SPA assets to S3, including environment variable configurations in `.env.example`.
- Updated `package.json` to include a new script for the mobile SPA workflow.
- Enhanced the Vite configuration to support dynamic CDN base paths.
- Refactored the template generation script to handle mobile HTML templates more effectively.
- Introduced new modules for uploading assets to S3 and generating mobile HTML templates.

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix: extract origin from MOBILE_S3_PUBLIC_DOMAIN to prevent double key prefix

* 🔧 fix: update mobile HTML template to use the latest asset versions

- Modified the mobile HTML template to reference the updated JavaScript asset version for improved functionality.
- Ensured consistency in the template structure while maintaining existing styles and scripts.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore: update dependencies and refine service worker integration

- Removed outdated dependencies related to Serwist from package.json and tsconfig.json.
- Added vite-plugin-pwa to enhance PWA capabilities in the Vite configuration.
- Updated service worker registration logic in the PWA installation component.
- Introduced a new local development proxy route for debugging purposes.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore: refactor development scripts and remove Turbo configuration

- Updated the `dev` script in `package.json` to use a new startup sequence script for improved development workflow.
- Removed the outdated `turbo.json` configuration file as it is no longer needed.
- Introduced `devStartupSequence.mts` to manage the startup of Next.js and Vite processes concurrently.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 feat: update entry points and introduce debug proxy for local development

- Changed the main entry point in `index.html` from `entry.desktop.tsx` to `entry.web.tsx` for improved web compatibility.
- Added an `initialize.ts` file to enable `immer`'s `enableMapSet` functionality.
- Introduced a new `__DEBUG_PROXY__` variable in global types to support local development proxy features.
- Implemented a debug proxy route to facilitate local development with dynamic HTML injection and script handling.
- Removed outdated mobile routing components to streamline the codebase.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 refactor: replace BrowserRouter with RouterProvider for improved routing

- Updated entry points for desktop, mobile, and web to utilize RouterProvider and createAppRouter for better routing management.
- Removed the deprecated renderRoutes function in favor of a more streamlined router configuration.
- Enhanced router setup to support error boundaries and dynamic routing.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 refactor: remove direct access handling for SPA routes in proxy configuration

- Eliminated the handling of direct access to pre-rendered SPA pages in the proxy configuration.
- Simplified the request processing logic by removing checks for SPA routes, streamlining the middleware response flow.

Signed-off-by: Innei <tukon479@gmail.com>

* update

* 🔧 refactor: enhance Worker instantiation logic in mobile HTML template

* 🐛 fix: remove duplicate waitForPageWorkspaceReady calls in page CRUD e2e steps

* 🔧 refactor: simplify createTracePayload function by using btoa for base64 encoding

* 🔧 refactor: specify locales in import.meta.glob for dayjs and antd

* 🔧 refactor: replace Node.js Buffer with web-compatible btoa for base64 encoding in file upload

* 🐛 fix: disable consistent-type-imports rule for mdx files to prevent eslint crash

* 🔧 refactor: add height style to root div for consistent layout

* 🔧 refactor: replace btoa with Buffer for base64 encoding in trace and file upload handling

* 🔧 refactor: extract nextjsOnlyRoutes to a separate file for better organization

* 🔧 refactor: enable Immer MapSet plugin in tests for better state management

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 refactor: integrate sharedRollupOutput configuration and increase cache size for better performance

Signed-off-by: Innei <tukon479@gmail.com>

* 🗑️ chore: remove obsolete desktop.routes.test.ts file as it is no longer needed

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix: use cross-env for env vars in npm scripts (Windows CI)

Co-authored-by: Cursor <cursoragent@cursor.com>

* 🔧 chore: update Dockerfile for web-only build and adjust npm scripts to use pnpm

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore: enhance Dockerfile prebuild process with environment checks and add new dependencies

- Updated Dockerfile to include environment checks before removing desktop-only code.
- Added new dependencies in package.json: @aws-sdk/client-bedrock-runtime, @opentelemetry/auto-instrumentations-node, @opentelemetry/resources, @opentelemetry/sdk-metrics, and ajv.
- Configured Rollup to exclude @aws-sdk/client-bedrock-runtime from the SPA bundle.
- Introduced dockerPrebuild.mts script for environment variable validation and information logging.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore: enhance Vite and Electron configurations with environment loading and trace encoding improvements

- Updated Vite and Electron configurations to load environment variables using loadEnv.
- Modified trace encoding in utils to use TextEncoder for better compatibility.
- Adjusted sharedRendererConfig to expose only necessary public environment variables.

Signed-off-by: Innei <tukon479@gmail.com>

* 🗑️ chore: remove plans directory (migrated to discussion)

* ♻️ refactor: inject NEXT_PUBLIC_* env per key in Vite define

Co-authored-by: Cursor <cursoragent@cursor.com>

*  feat: add loading screen with animation to enhance user experience

- Introduced a loading screen with a brand logo and animations for better visual feedback during loading times.
- Implemented CSS styles for the loading screen and animations in index.html.
- Removed the loading screen from the DOM once the layout is ready using useLayoutEffect in SPAGlobalProvider.

Signed-off-by: Innei <tukon479@gmail.com>

* 🗑️ chore: remove unnecessary external dependency from Vite configuration

- Eliminated the external dependency '@aws-sdk/client-bedrock-runtime' from the Vite configuration to streamline the build process for the SPA bundle.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat: add web app manifest link in index.html and enable PWA support in Vite configuration

- Added a link to the web app manifest in index.html to enhance PWA capabilities.
- Enabled manifest support in Vite configuration for improved service worker functionality.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 chore: update link rel attributes for improved SEO and consistency

- Modified link rel attributes in multiple components to remove 'noreferrer' and standardize to 'nofollow'.
- Adjusted imports in PageContent components for better organization.

Signed-off-by: Innei <tukon479@gmail.com>

* update provider

*  feat: enhance loading experience and update package dependencies

- Added a loading screen with animations and a brand logo in index.html for improved user feedback during loading times.
- Introduced CSS styles for the loading screen and animations.
- Updated package.json files across multiple packages to include "@lobechat/const" as a dependency.

Signed-off-by: Innei <tukon479@gmail.com>

* fix: update proxy

Signed-off-by: Innei <tukon479@gmail.com>

* 🗑️ chore: remove GlobalLayout and Locale components

- Deleted GlobalLayout and Locale components from the GlobalProvider directory to streamline the codebase.
- This removal is part of a refactor to simplify the layout structure and improve maintainability.

Signed-off-by: Innei <tukon479@gmail.com>

* chore: clean up console logs and improve component structure

- Removed unnecessary console log statements from AgentForkTag components in both agent and community directories to enhance code cleanliness.
- Refactored UserAgentList component for better readability by restructuring the useUserDetailContext hook and adjusting the layout of Flexbox components.

Signed-off-by: Innei <tukon479@gmail.com>

* chore: remove console log from MemoryAnalysis component

* chore: update mobile HTML template with new asset links

- Replaced the previous asset links in the mobile HTML template with updated versions to ensure the latest resources are utilized.
- Adjusted the link rel attributes for module preloading to enhance performance and loading efficiency.

Signed-off-by: Innei <tukon479@gmail.com>

* fix: correct variable assignment in createClientTaskThread integration test

- Updated the assignment of the second parent message in the createClientTaskThread integration test to improve clarity and ensure proper data handling.
- Changed the variable name from 'secondParentMsg' to 'inserted' for better context before extracting the first message from the inserted results.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: simplify authentication check in define-config

- Removed the dependency on the isDesktop variable in the authentication check to streamline the logic.
- Enhanced the clarity of the redirection process for protected routes by focusing solely on the isLoggedIn status.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(dev): enhance local development setup with debug proxy instructions

- Added detailed instructions for starting the development environment in CLAUDE.md, including commands for SPA and full-stack modes.
- Updated README.md and README.zh-CN.md to reflect new commands and the debug proxy URL for local development.
- Introduced a Vite plugin to print the debug proxy URL upon server start, facilitating easier local development against the production backend.
- Corrected the debug proxy route in entry.web.tsx and define-config.ts for consistency.

This improves the developer experience by providing clear guidance and tools for local development.

Signed-off-by: Innei <tukon479@gmail.com>

* optimize perf

* optimize perf

* optimize perf

* remove speedy plugin

* add dayjs vendor

* Revert "remove speedy plugin"

This reverts commit bf986afeb1.

---------

Signed-off-by: Innei <tukon479@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Innei
2026-02-28 00:01:01 +08:00
committed by GitHub
parent 6c3e75634f
commit 687b36c81c
201 changed files with 4529 additions and 4294 deletions
+136
View File
@@ -0,0 +1,136 @@
import { type ChildProcess, spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import net from 'node:net';
const NEXT_HOST = 'localhost';
/**
* Parse the Next.js dev port from the `dev:next` script in the nearest package.json.
* Supports both `--port <n>` and `-p <n>` flags. Falls back to 3010.
*/
const resolveNextPort = (): number => {
try {
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8'));
const devNext: string | undefined = pkg?.scripts?.['dev:next'];
if (devNext) {
const match = devNext.match(/(?:--port|-p)\s+(\d+)/);
if (match) return Number(match[1]);
}
} catch { /* fallback */ }
return 3010;
};
const NEXT_PORT = resolveNextPort();
const NEXT_ROOT_URL = `http://${NEXT_HOST}:${NEXT_PORT}/`;
const NEXT_READY_TIMEOUT_MS = 180_000;
const NEXT_READY_RETRY_MS = 400;
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
let nextProcess: ChildProcess | undefined;
let viteProcess: ChildProcess | undefined;
let shuttingDown = false;
const runNpmScript = (scriptName: string) =>
spawn(npmCommand, ['run', scriptName], {
env: process.env,
stdio: 'inherit',
});
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const isPortOpen = (host: string, port: number) =>
new Promise<boolean>((resolve) => {
const socket = net.createConnection({ host, port });
const onDone = (result: boolean) => {
socket.removeAllListeners();
socket.destroy();
resolve(result);
};
socket.once('connect', () => onDone(true));
socket.once('error', () => onDone(false));
socket.setTimeout(1_000, () => onDone(false));
});
const waitForNextReady = async () => {
const startedAt = Date.now();
while (Date.now() - startedAt < NEXT_READY_TIMEOUT_MS) {
if (await isPortOpen(NEXT_HOST, NEXT_PORT)) return;
await wait(NEXT_READY_RETRY_MS);
}
throw new Error(
`Next server was not ready within ${NEXT_READY_TIMEOUT_MS / 1000}s on ${NEXT_HOST}:${NEXT_PORT}`,
);
};
const prewarmNextRootCompile = async () => {
const response = await fetch(NEXT_ROOT_URL, { signal: AbortSignal.timeout(120_000) });
console.log(`✅ Next prewarm request finished (${response.status}) ${NEXT_ROOT_URL}`);
};
const runNextBackgroundTasks = () => {
setTimeout(() => {
console.log(`🔁 Next server URL: ${NEXT_ROOT_URL}`);
}, 2_000);
void (async () => {
try {
await waitForNextReady();
await prewarmNextRootCompile();
} catch (error) {
console.warn('⚠️ Next prewarm skipped:', error);
}
})();
};
const terminateChild = (child?: ChildProcess) => {
if (!child || child.killed) return;
child.kill('SIGTERM');
};
const shutdownAll = (signal: NodeJS.Signals) => {
if (shuttingDown) return;
shuttingDown = true;
terminateChild(viteProcess);
terminateChild(nextProcess);
process.exitCode = signal === 'SIGINT' ? 130 : 143;
};
const watchChildExit = (child: ChildProcess, name: 'next' | 'vite') => {
child.once('exit', (code, signal) => {
if (!shuttingDown) {
console.error(
`${name} exited unexpectedly (code: ${code ?? 'null'}, signal: ${signal ?? 'null'})`,
);
shutdownAll('SIGTERM');
}
});
};
const main = async () => {
process.once('SIGINT', () => shutdownAll('SIGINT'));
process.once('SIGTERM', () => shutdownAll('SIGTERM'));
nextProcess = runNpmScript('dev:next');
watchChildExit(nextProcess, 'next');
viteProcess = runNpmScript('dev:spa');
watchChildExit(viteProcess, 'vite');
runNextBackgroundTasks();
await Promise.race([
new Promise((resolve) => nextProcess?.once('exit', resolve)),
new Promise((resolve) => viteProcess?.once('exit', resolve)),
]);
};
void main().catch((error) => {
console.error('❌ dev startup sequence failed:', error);
shutdownAll('SIGTERM');
});
@@ -1,36 +1,27 @@
/**
* Docker build pre-check: required env vars + env info.
* Run before build:docker in Dockerfile (checkDeprecatedAuth, checkRequiredEnvVars, printEnvInfo).
*/
import { execSync } from 'node:child_process';
import { createRequire } from 'node:module';
import * as dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Use createRequire for CommonJS module compatibility
const require = createRequire(import.meta.url);
const { checkDeprecatedAuth } = require('./_shared/checkDeprecatedAuth.js');
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
const isBundleAnalyzer = process.env.ANALYZE === 'true' && process.env.CI === 'true';
const isServerDB = !!process.env.DATABASE_URL;
dotenvExpand.expand(dotenv.config());
if (isDesktop) {
dotenvExpand.expand(dotenv.config({ path: '.env.desktop' }));
dotenvExpand.expand(dotenv.config({ override: true, path: '.env.desktop.local' }));
} else {
dotenvExpand.expand(dotenv.config());
}
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
const isServerDB = !!process.env.DATABASE_URL;
const AUTH_SECRET_DOC_URL =
'https://lobehub.com/docs/self-hosting/environment-variables/auth#auth-secret';
const KEY_VAULTS_SECRET_DOC_URL =
'https://lobehub.com/docs/self-hosting/environment-variables/basic#key-vaults-secret';
/**
* Check for required environment variables in server database mode
*/
const checkRequiredEnvVars = () => {
function checkRequiredEnvVars(): void {
if (isDesktop || !isServerDB) return;
const missingVars: { docUrl: string; name: string }[] = [];
@@ -59,9 +50,9 @@ const checkRequiredEnvVars = () => {
console.error('═'.repeat(70) + '\n');
process.exit(1);
}
};
}
const getCommandVersion = (command: string): string | null => {
function getCommandVersion(command: string): string | null {
try {
return execSync(`${command} --version`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
.trim()
@@ -69,13 +60,12 @@ const getCommandVersion = (command: string): string | null => {
} catch {
return null;
}
};
}
const printEnvInfo = () => {
function printEnvInfo(): void {
console.log('\n📋 Build Environment Info:');
console.log('─'.repeat(50));
// Runtime versions
console.log(` Node.js: ${process.version}`);
console.log(` npm: ${getCommandVersion('npm') ?? 'not installed'}`);
@@ -85,7 +75,6 @@ const printEnvInfo = () => {
const pnpmVersion = getCommandVersion('pnpm');
if (pnpmVersion) console.log(` pnpm: ${pnpmVersion}`);
// Auth-related env vars
console.log('\n Auth Environment Variables:');
console.log(` APP_URL: ${process.env.APP_URL ?? '(not set)'}`);
console.log(` VERCEL_URL: ${process.env.VERCEL_URL ?? '(not set)'}`);
@@ -96,7 +85,6 @@ const printEnvInfo = () => {
console.log(` AUTH_EMAIL_VERIFICATION: ${process.env.AUTH_EMAIL_VERIFICATION ?? '(not set)'}`);
console.log(` AUTH_ENABLE_MAGIC_LINK: ${process.env.AUTH_ENABLE_MAGIC_LINK ?? '(not set)'}`);
// Check SSO providers configuration
const ssoProviders = process.env.AUTH_SSO_PROVIDERS;
console.log(` AUTH_SSO_PROVIDERS: ${ssoProviders ?? '(not set)'}`);
@@ -129,108 +117,8 @@ const printEnvInfo = () => {
}
console.log('─'.repeat(50));
};
// 创建需要排除的特性映射
const partialBuildPages = [
// no need for bundle analyzer (frontend only)
{
name: 'backend-routes',
disabled: isBundleAnalyzer,
paths: ['src/app/(backend)'],
},
// no need for desktop
// {
// name: 'changelog',
// disabled: isDesktop,
// paths: ['src/app/[variants]/(main)/changelog'],
// },
{
name: 'auth',
disabled: isDesktop,
paths: ['src/app/[variants]/(auth)'],
},
// {
// name: 'mobile',
// disabled: isDesktop,
// paths: ['src/app/[variants]/(main)/(mobile)'],
// },
{
name: 'oauth',
disabled: isDesktop,
paths: ['src/app/[variants]/oauth', 'src/app/(backend)/oidc'],
},
{
name: 'api-webhooks',
disabled: isDesktop,
paths: ['src/app/(backend)/api/webhooks'],
},
{
name: 'market-auth',
disabled: isDesktop,
paths: ['src/app/market-auth-callback'],
},
{
name: 'pwa',
disabled: isDesktop,
paths: ['src/manifest.ts', 'src/sitemap.tsx', 'src/robots.tsx', 'src/sw'],
},
// no need for web
{
name: 'desktop-devtools',
disabled: !isDesktop,
paths: ['src/app/desktop'],
},
{
name: 'desktop-trpc',
disabled: !isDesktop,
paths: ['src/app/(backend)/trpc/desktop'],
},
];
/* eslint-enable */
/**
*
*/
export const runPrebuild = async (targetDir: string = 'src') => {
// 遍历 partialBuildPages 数组
for (const page of partialBuildPages) {
// 检查是否需要禁用该功能
if (page.disabled) {
for (const dirPath of page.paths) {
// Replace 'src' with targetDir
const relativePath = dirPath.replace(/^src/, targetDir);
const fullPath = path.resolve(process.cwd(), relativePath);
// 检查目录是否存在
if (existsSync(fullPath)) {
try {
// 递归删除目录
await rm(fullPath, { force: true, recursive: true });
console.log(`♻️ Removed ${relativePath} successfully`);
} catch (error) {
console.error(`Failed to remove directory ${relativePath}:`, error);
}
}
}
}
}
};
// Check if the script is being run directly
const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
if (isMainModule) {
// Check for deprecated auth env vars first - fail fast if found
checkDeprecatedAuth();
// Check for required env vars in server database mode
checkRequiredEnvVars();
printEnvInfo();
// 执行删除操作
console.log('\nStarting prebuild cleanup...');
await runPrebuild();
console.log('Prebuild cleanup completed.');
}
checkDeprecatedAuth();
checkRequiredEnvVars();
printEnvInfo();
-172
View File
@@ -1,172 +0,0 @@
import fs from 'fs-extra';
import { execSync } from 'node:child_process';
import path from 'node:path';
import { runPrebuild } from '../prebuild.mjs';
import { modifySourceForElectron } from './modifiers/index.mjs';
const PROJECT_ROOT = process.cwd();
const TEMP_DIR = path.join(PROJECT_ROOT, 'tmp', 'desktop-build');
const foldersToSymlink = [
'node_modules',
'packages',
'public',
'locales',
'docs',
'.cursor',
'apps',
];
const foldersToCopy = ['src', 'scripts'];
// Assets to remove from desktop build output (not needed for Electron app)
const assetsToRemove = [
// Icons & favicons
'apple-touch-icon.png',
'favicon.ico',
'favicon-32x32.ico',
'favicon-16x16.png',
'favicon-32x32.png',
// SEO & sitemap
'sitemap.xml',
'sitemap-index.xml',
'sitemap',
'robots.txt',
// Incompatible pages
'not-compatible.html',
'not-compatible',
// Large media assets
'videos',
'screenshots',
'og',
];
const filesToCopy = [
'package.json',
'tsconfig.json',
'next.config.ts',
'pnpm-workspace.yaml',
'bun.lockb',
'.npmrc',
'.bunfig.toml',
'.eslintrc.js',
'.eslintignore',
'.prettierrc.cjs',
'.prettierignore',
'drizzle.config.ts',
'postcss.config.js',
'tailwind.config.ts',
'tailwind.config.js',
];
const build = async () => {
console.log('🚀 Starting Electron App Build in Shadow Workspace...');
console.log(`📂 Workspace: ${TEMP_DIR}`);
if (fs.existsSync(TEMP_DIR)) {
await fs.remove(TEMP_DIR);
}
await fs.ensureDir(TEMP_DIR);
console.log('🔗 Symlinking dependencies and static assets...');
for (const folder of foldersToSymlink) {
const srcPath = path.join(PROJECT_ROOT, folder);
const destPath = path.join(TEMP_DIR, folder);
if (fs.existsSync(srcPath)) {
await fs.ensureSymlink(srcPath, destPath);
}
}
console.log('📋 Copying source code...');
for (const folder of foldersToCopy) {
const srcPath = path.join(PROJECT_ROOT, folder);
const destPath = path.join(TEMP_DIR, folder);
if (fs.existsSync(srcPath)) {
await fs.copy(srcPath, destPath);
}
}
console.log('📄 Copying configuration files...');
const allFiles = await fs.readdir(PROJECT_ROOT);
const envFiles = allFiles.filter((f) => f.startsWith('.env'));
const files = [...filesToCopy, ...envFiles];
for (const file of files) {
const srcPath = path.join(PROJECT_ROOT, file);
const destPath = path.join(TEMP_DIR, file);
if (fs.existsSync(srcPath)) {
await fs.copy(srcPath, destPath);
}
}
console.log('✂️ Pruning desktop-incompatible code...');
const relativeTempSrc = path.relative(PROJECT_ROOT, path.join(TEMP_DIR, 'src'));
await runPrebuild(relativeTempSrc);
await modifySourceForElectron(TEMP_DIR);
console.log('🏗 Running next build in shadow workspace...');
try {
execSync('next build', {
cwd: TEMP_DIR,
env: {
...process.env,
// Pass PROJECT_ROOT to next.config.ts for outputFileTracingRoot
// This fixes Turbopack symlink resolution when building in shadow workspace
ELECTRON_BUILD_PROJECT_ROOT: PROJECT_ROOT,
NODE_OPTIONS: process.env.NODE_OPTIONS || '--max-old-space-size=8192',
},
stdio: 'inherit',
});
console.log('📦 Extracting build artifacts...');
const sourceOutDir = path.join(TEMP_DIR, 'out');
const targetOutDir = path.join(PROJECT_ROOT, 'out');
// Clean up target directories
if (fs.existsSync(targetOutDir)) {
await fs.remove(targetOutDir);
}
if (fs.existsSync(sourceOutDir)) {
console.log('📦 Moving "out" directory...');
await fs.move(sourceOutDir, targetOutDir);
// Remove unnecessary assets from desktop build
console.log('🗑️ Removing unnecessary assets...');
for (const asset of assetsToRemove) {
const assetPath = path.join(targetOutDir, asset);
if (fs.existsSync(assetPath)) {
await fs.remove(assetPath);
console.log(` Removed: ${asset}`);
}
}
} else {
console.warn("⚠️ 'out' directory not found. Using '.next' instead (fallback)?");
const sourceNextDir = path.join(TEMP_DIR, '.next');
const targetNextDir = path.join(PROJECT_ROOT, '.next');
if (fs.existsSync(targetNextDir)) {
await fs.remove(targetNextDir);
}
if (fs.existsSync(sourceNextDir)) {
await fs.move(sourceNextDir, targetNextDir);
}
}
console.log('✅ Build completed successfully!');
} catch (error) {
console.error('❌ Build failed.');
throw error;
} finally {
console.log('🧹 Cleaning up workspace...');
await fs.remove(TEMP_DIR);
}
};
await build().catch((err) => {
console.error(err);
throw err;
});
@@ -1,302 +0,0 @@
import { Lang, parse } from '@ast-grep/napi';
import path from 'node:path';
import {
isDirectRun,
normalizeEol,
removePathEnsuring,
runStandalone,
updateFile,
writeFileEnsuring,
} from './utils.mjs';
const desktopOnlyVariantsPage = `import { DynamicLayoutProps } from '@/types/next';
import DesktopRouter from './router';
export default async (_props: DynamicLayoutProps) => {
return <DesktopRouter />;
};
`;
const stripDevPanel = (code: string) => {
let result = code.replace(/import DevPanel from ['"]@\/features\/DevPanel['"];\r?\n?/, '');
result = result.replace(
/[\t ]*{process\.env\.NODE_ENV === 'development' && <DevPanel \/>}\s*\r?\n?/,
'',
);
return result;
};
const assertDevPanelStripped = (code: string) =>
!/import\s+DevPanel\s+from\s+['"]@\/features\/DevPanel['"]/.test(code) &&
!/<DevPanel\b/.test(code) &&
!/NEXT_PUBLIC_ENABLE_DEV_PANEL|DevPanel\s*\/>/.test(code);
const removeSecurityTab = (code: string) => {
const componentEntryRegex =
/[\t ]*\[SettingsTabs\.Security]: dynamic\(\(\) => import\('\.\.\/security'\), {[\s\S]+?}\),\s*\r?\n/;
const securityTabRegex = /[\t ]*SettingsTabs\.Security,\s*\r?\n/;
return code.replace(componentEntryRegex, '').replace(securityTabRegex, '');
};
const assertSecurityTabRemoved = (code: string) => !/\bSettingsTabs\.Security\b/.test(code);
const removeSpeedInsightsAndAnalytics = (code: string) => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const edits: Array<{ start: number; end: number; text: string }> = [];
// Remove SpeedInsights import
const speedInsightsImport = root.find({
rule: {
pattern: 'import { SpeedInsights } from $SOURCE',
},
});
if (speedInsightsImport) {
const range = speedInsightsImport.range();
edits.push({ start: range.start.index, end: range.end.index, text: '' });
}
// Remove Analytics import
const analyticsImport = root.find({
rule: {
pattern: 'import Analytics from $SOURCE',
},
});
if (analyticsImport) {
const range = analyticsImport.range();
edits.push({ start: range.start.index, end: range.end.index, text: '' });
}
// Remove Suspense block containing Analytics and SpeedInsights
// Find all Suspense blocks and check which one contains Analytics or SpeedInsights
const allSuspenseBlocks = root.findAll({
rule: {
pattern: '<Suspense fallback={null}>$$$</Suspense>',
},
});
for (const suspenseBlock of allSuspenseBlocks) {
const hasAnalytics = suspenseBlock.find({
rule: {
pattern: '<Analytics />',
},
});
const hasSpeedInsights = suspenseBlock.find({
rule: {
pattern: '<SpeedInsights />',
},
});
if (hasAnalytics || hasSpeedInsights) {
const range = suspenseBlock.range();
edits.push({ start: range.start.index, end: range.end.index, text: '' });
break; // Only remove the first matching Suspense block
}
}
// Remove inVercel variable if it's no longer used
const inVercelVar = root.find({
rule: {
pattern: 'const inVercel = process.env.VERCEL === "1";',
},
});
if (inVercelVar) {
// Check if inVercel is still used elsewhere
const allInVercelUsages = root.findAll({
rule: {
regex: 'inVercel',
},
});
// If only the declaration remains, remove it
if (allInVercelUsages.length === 1) {
const range = inVercelVar.range();
edits.push({ start: range.start.index, end: range.end.index, text: '' });
}
}
// Apply edits
if (edits.length === 0) return code;
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
};
const assertSpeedInsightsAndAnalyticsRemoved = (code: string) =>
!/<Analytics\s*\/>/.test(code) &&
!/<SpeedInsights\s*\/>/.test(code) &&
!/import\s+\{\s*SpeedInsights\s*\}\s+from\b/.test(code) &&
!/import\s+Analytics\s+from\b/.test(code);
const removeManifestFromMetadata = (code: string) => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const edits: Array<{ start: number; end: number; text: string }> = [];
// Find generateMetadata function
const generateMetadataFunc = root.find({
rule: {
pattern: 'export const generateMetadata = async ($$$) => { $$$ }',
},
});
if (!generateMetadataFunc) return code;
// Find return statement
const returnStatement = generateMetadataFunc.find({
rule: {
kind: 'return_statement',
},
});
if (!returnStatement) return code;
// Find the object in return statement
const returnObject = returnStatement.find({
rule: {
kind: 'object',
},
});
if (!returnObject) return code;
// Find all pair nodes (key-value pairs in the object)
const allPairs = returnObject.findAll({
rule: {
kind: 'pair',
},
});
const keysToRemove = ['manifest', 'metadataBase'];
for (const pair of allPairs) {
// Find the property_identifier or identifier
const key = pair.find({
rule: {
any: [{ kind: 'property_identifier' }, { kind: 'identifier' }],
},
});
if (key && keysToRemove.includes(key.text())) {
const range = pair.range();
// Include the trailing comma if present
const afterPair = code.slice(range.end.index, range.end.index + 10);
const commaMatch = afterPair.match(/^,\s*/);
const endIndex = commaMatch ? range.end.index + commaMatch[0].length : range.end.index;
edits.push({
start: range.start.index,
end: endIndex,
text: '',
});
}
}
// Apply edits
if (edits.length === 0) return code;
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
};
const assertMetadataManifestRemoved = (code: string) =>
!/\bmanifest\s*:/.test(code) && !/\bmetadataBase\s*:/.test(code);
export const modifyAppCode = async (TEMP_DIR: string) => {
// 1. Replace src/app/[variants]/page.tsx with a desktop-only entry
const variantsPagePath = path.join(TEMP_DIR, 'src/app/[variants]/page.tsx');
console.log(' Processing src/app/[variants]/page.tsx...');
await writeFileEnsuring({
filePath: variantsPagePath,
name: 'modifyAppCode:variantsPage',
text: desktopOnlyVariantsPage,
assertAfter: (code) => normalizeEol(code) === normalizeEol(desktopOnlyVariantsPage),
});
// 2. Remove DevPanel from src/layout/GlobalProvider/index.tsx
const globalProviderPath = path.join(TEMP_DIR, 'src/layout/GlobalProvider/index.tsx');
console.log(' Processing src/layout/GlobalProvider/index.tsx...');
await updateFile({
filePath: globalProviderPath,
name: 'modifyAppCode:stripDevPanel',
transformer: stripDevPanel,
assertAfter: assertDevPanelStripped,
});
// 3. Delete src/app/[variants]/(main)/settings/security directory
const securityDirPath = path.join(TEMP_DIR, 'src/app/[variants]/(main)/settings/security');
console.log(' Deleting src/app/[variants]/(main)/settings/security directory...');
await removePathEnsuring({
name: 'modifyAppCode:deleteSecurityDir',
path: securityDirPath,
});
// 4. Remove Security tab wiring from SettingsContent
const settingsContentPath = path.join(
TEMP_DIR,
'src/app/[variants]/(main)/settings/features/SettingsContent.tsx',
);
console.log(' Processing src/app/[variants]/(main)/settings/features/SettingsContent.tsx...');
await updateFile({
filePath: settingsContentPath,
name: 'modifyAppCode:removeSecurityTab',
transformer: removeSecurityTab,
assertAfter: assertSecurityTabRemoved,
});
// 5. Remove SpeedInsights and Analytics from src/app/[variants]/layout.tsx
const variantsLayoutPath = path.join(TEMP_DIR, 'src/app/[variants]/layout.tsx');
console.log(' Processing src/app/[variants]/layout.tsx...');
await updateFile({
filePath: variantsLayoutPath,
name: 'modifyAppCode:removeSpeedInsightsAndAnalytics',
transformer: removeSpeedInsightsAndAnalytics,
assertAfter: assertSpeedInsightsAndAnalyticsRemoved,
});
// 6. Replace mdx Image component with next/image export
const mdxImagePath = path.join(TEMP_DIR, 'src/components/mdx/Image.tsx');
console.log(' Processing src/components/mdx/Image.tsx...');
await writeFileEnsuring({
filePath: mdxImagePath,
name: 'modifyAppCode:replaceMdxImage',
text: "export { default } from 'next/image';\n",
assertAfter: (code) => normalizeEol(code).trim() === "export { default } from 'next/image';",
});
// 7. Remove manifest from metadata
const metadataPath = path.join(TEMP_DIR, 'src/app/[variants]/metadata.ts');
console.log(' Processing src/app/[variants]/metadata.ts...');
await updateFile({
filePath: metadataPath,
name: 'modifyAppCode:removeManifestFromMetadata',
transformer: removeManifestFromMetadata,
assertAfter: assertMetadataManifestRemoved,
});
};
if (isDirectRun(import.meta.url)) {
await runStandalone('modifyAppCode', modifyAppCode, [
{ lang: Lang.Tsx, path: 'src/app/[variants]/page.tsx' },
{ lang: Lang.Tsx, path: 'src/layout/GlobalProvider/index.tsx' },
{ lang: Lang.Tsx, path: 'src/app/[variants]/(main)/settings/features/SettingsContent.tsx' },
{ lang: Lang.Tsx, path: 'src/app/[variants]/layout.tsx' },
{ lang: Lang.Tsx, path: 'src/components/mdx/Image.tsx' },
{ lang: Lang.Tsx, path: 'src/app/[variants]/metadata.ts' },
]);
}
@@ -1,65 +0,0 @@
import { Lang, parse } from '@ast-grep/napi';
import path from 'node:path';
import { isDirectRun, runStandalone, updateFile } from './utils.mjs';
const hasUseServerDirective = (code: string) =>
/^\s*['"]use server['"]\s*;?/m.test(code.trimStart());
export const cleanUpCode = async (TEMP_DIR: string) => {
// Remove 'use server'
const filesToRemoveUseServer = [
'src/features/DevPanel/CacheViewer/getCacheEntries.ts',
'src/server/translation.ts',
];
for (const file of filesToRemoveUseServer) {
const filePath = path.join(TEMP_DIR, file);
console.log(` Processing ${file}...`);
await updateFile({
filePath,
name: `cleanUpCode:removeUseServer:${file}`,
transformer: (code) => {
// Prefer a deterministic text rewrite for directive prologue:
// remove ONLY the top-level `'use server';` directive if present.
const next = code.replace(/^\s*['"]use server['"]\s*;\s*\r?\n?/, '');
if (next !== code) return next;
// Fallback to AST rewrite (in case of odd formatting)
const ast = parse(Lang.TypeScript, code);
const root = ast.root();
const useServer =
root.find({
rule: { pattern: "'use server'" },
}) ||
root.find({
rule: { pattern: '"use server"' },
});
if (!useServer) return code;
let curr = useServer.parent();
while (curr) {
if (curr.kind() === 'expression_statement') {
curr.replace('');
break;
}
if (curr.kind() === 'program') break;
curr = curr.parent();
}
return root.text();
},
assertAfter: (code) => !hasUseServerDirective(code),
});
}
};
if (isDirectRun(import.meta.url)) {
await runStandalone('cleanUpCode', cleanUpCode, [
{ lang: Lang.Tsx, path: 'src/components/mdx/Image.tsx' },
{ lang: Lang.TypeScript, path: 'src/features/DevPanel/CacheViewer/getCacheEntries.ts' },
{ lang: Lang.TypeScript, path: 'src/server/translation.ts' },
]);
}
@@ -1,273 +0,0 @@
/* eslint-disable no-undef */
import { Lang, parse } from '@ast-grep/napi';
import path from 'node:path';
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
interface ImportInfo {
defaultImport?: string;
namedImports: string[];
}
interface DynamicElementInfo {
componentName: string;
end: number;
importPath: string;
isNamedExport: boolean;
namedExport?: string;
start: number;
}
const toPascalCase = (str: string): string => {
return str
.split(/[_-]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
};
const generateComponentName = (
importPath: string,
namedExport?: string,
existingNames: Set<string> = new Set(),
): string => {
if (namedExport) {
let name = namedExport;
let counter = 1;
while (existingNames.has(name)) {
name = `${namedExport}${counter++}`;
}
return name;
}
const segments = importPath
.split('/')
.filter((s) => s && !s.startsWith('.'))
.map((s) => s.replace(/^\((.+)\)$/, '$1').replace(/^\[(.+)]$/, '$1'));
const meaningfulSegments = segments.slice(-3).filter(Boolean);
let baseName =
meaningfulSegments.length > 0
? meaningfulSegments.map((s) => toPascalCase(s)).join('') + 'Page'
: 'Page';
let name = baseName;
let counter = 1;
while (existingNames.has(name)) {
name = `${baseName}${counter++}`;
}
return name;
};
const extractDynamicElements = (code: string): DynamicElementInfo[] => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const results: DynamicElementInfo[] = [];
const existingNames = new Set<string>();
const dynamicCalls = root.findAll({
rule: {
pattern: 'dynamicElement($IMPORT_FN, $DEBUG_ID)',
},
});
for (const call of dynamicCalls) {
const range = call.range();
const text = call.text();
const importMatch = text.match(/import\s*\(\s*["']([^"']+)["']\s*\)/);
invariant(
importMatch,
`[convertDynamicToStatic] Failed to extract import path from dynamicElement call: ${text.slice(0, 100)}`,
);
const importPath = importMatch![1];
const thenMatch = text.match(/\.then\s*\(\s*\(\s*(\w+)\s*\)\s*=>\s*\1\.(\w+)\s*\)/);
const namedExport = thenMatch ? thenMatch[2] : undefined;
const componentName = generateComponentName(importPath, namedExport, existingNames);
existingNames.add(componentName);
results.push({
componentName,
end: range.end.index,
importPath,
isNamedExport: !!namedExport,
namedExport,
start: range.start.index,
});
}
return results;
};
const buildImportMap = (elements: DynamicElementInfo[]): Map<string, ImportInfo> => {
const importMap = new Map<string, ImportInfo>();
for (const el of elements) {
const existing = importMap.get(el.importPath) || { namedImports: [] };
if (el.isNamedExport && el.namedExport) {
if (!existing.namedImports.includes(el.namedExport)) {
existing.namedImports.push(el.namedExport);
}
} else {
existing.defaultImport = el.componentName;
}
importMap.set(el.importPath, existing);
}
return importMap;
};
const generateImportStatements = (importMap: Map<string, ImportInfo>): string => {
const statements: string[] = [];
const sortedPaths = [...importMap.keys()].sort();
for (const importPath of sortedPaths) {
const info = importMap.get(importPath)!;
const parts: string[] = [];
if (info.defaultImport) {
parts.push(info.defaultImport);
}
if (info.namedImports.length > 0) {
parts.push(`{ ${info.namedImports.join(', ')} }`);
}
if (parts.length > 0) {
statements.push(`import ${parts.join(', ')} from '${importPath}';`);
}
}
return statements.join('\n');
};
const findImportInsertPosition = (code: string): number => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const imports = root.findAll({
rule: {
kind: 'import_statement',
},
});
invariant(imports.length > 0, '[convertDynamicToStatic] No import statements found in file');
const lastImport = imports.at(-1)!;
return lastImport.range().end.index;
};
const removeDynamicElementImport = (code: string): string => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const utilsRouterImport = root.find({
rule: {
kind: 'import_statement',
pattern: "import { $$$IMPORTS } from '@/utils/router'",
},
});
if (!utilsRouterImport) {
return code;
}
const importText = utilsRouterImport.text();
if (!importText.includes('dynamicElement')) {
return code;
}
const importSpecifiers = utilsRouterImport.findAll({
rule: {
kind: 'import_specifier',
},
});
const specifiersToKeep = importSpecifiers
.map((spec) => spec.text())
.filter((text) => !text.includes('dynamicElement'));
if (specifiersToKeep.length === 0) {
const range = utilsRouterImport.range();
let endIndex = range.end.index;
if (code[endIndex] === '\n') {
endIndex++;
}
return code.slice(0, range.start.index) + code.slice(endIndex);
}
const newImport = `import { ${specifiersToKeep.join(', ')} } from '@/utils/router';`;
const range = utilsRouterImport.range();
return code.slice(0, range.start.index) + newImport + code.slice(range.end.index);
};
export const convertDynamicToStatic = async (TEMP_DIR: string) => {
const routerConfigPath = path.join(
TEMP_DIR,
'src/app/[variants]/router/desktopRouter.config.tsx',
);
console.log(' Processing dynamicElement → static imports...');
await updateFile({
assertAfter: (code) => {
const noDynamicElement = !/dynamicElement\s*\(/.test(code);
const hasStaticImports = /^import .+ from ["']\.\.\/\(main\)/m.test(code);
return noDynamicElement && hasStaticImports;
},
filePath: routerConfigPath,
name: 'convertDynamicToStatic',
transformer: (code) => {
const elements = extractDynamicElements(code);
invariant(
elements.length > 0,
'[convertDynamicToStatic] No dynamicElement calls found in desktopRouter.config.tsx',
);
console.log(` Found ${elements.length} dynamicElement calls`);
const importMap = buildImportMap(elements);
const importStatements = generateImportStatements(importMap);
const edits: Array<{ end: number; start: number; text: string }> = [];
for (const el of elements) {
edits.push({
end: el.end,
start: el.start,
text: `<${el.componentName} />`,
});
}
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
const insertPos = findImportInsertPosition(result);
result = result.slice(0, insertPos) + '\n' + importStatements + result.slice(insertPos);
result = removeDynamicElementImport(result);
return result;
},
});
};
if (isDirectRun(import.meta.url)) {
await runStandalone('convertDynamicToStatic', convertDynamicToStatic, [
{ lang: Lang.Tsx, path: 'src/app/[variants]/router/desktopRouter.config.tsx' },
]);
}
@@ -1,430 +0,0 @@
/* eslint-disable no-undef */
import { Lang, parse } from '@ast-grep/napi';
import fs from 'fs-extra';
import path from 'node:path';
import { invariant, isDirectRun, runStandalone, updateFile, writeFileEnsuring } from './utils.mjs';
interface I18nMetadata {
defaultLang: string;
locales: string[];
namespaces: string[];
}
type CodeEdit = { end: number; start: number; text: string };
const toIdentifier = (value: string) => value.replaceAll(/\W/g, '_');
const extractDefaultLang = (code: string): string => {
const match = code.match(/export const DEFAULT_LANG = '([^']+)'/);
if (!match) throw new Error('[convertI18nDynamicToStatic] Failed to extract DEFAULT_LANG');
return match[1];
};
const extractLocales = (code: string): string[] => {
const match = code.match(/export const locales = \[([\S\s]*?)] as const;/);
if (!match) throw new Error('[convertI18nDynamicToStatic] Failed to extract locales array');
const locales: string[] = [];
const regex = /'([^']+)'/g;
let result: RegExpExecArray | null;
// eslint-disable-next-line no-cond-assign
while ((result = regex.exec(match[1])) !== null) {
locales.push(result[1]);
}
invariant(locales.length > 0, '[convertI18nDynamicToStatic] No locales found');
return locales;
};
const extractNamespaces = (code: string): string[] => {
const match = code.match(/const resources = {([\S\s]*?)} as const;/);
if (!match)
throw new Error('[convertI18nDynamicToStatic] Failed to extract default resources map');
const namespaces = new Set<string>();
for (const rawLine of match[1].split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('//')) continue;
const withoutComma = line.replace(/,$/, '').trim();
if (withoutComma.includes(':')) {
const keyPart = withoutComma.split(':')[0].trim();
const keyMatch = keyPart.match(/^'([^']+)'$/);
if (keyMatch) namespaces.add(keyMatch[1]);
continue;
}
const identifierMatch = withoutComma.match(/^(\w+)$/);
if (identifierMatch) namespaces.add(identifierMatch[1]);
}
invariant(namespaces.size > 0, '[convertI18nDynamicToStatic] No namespaces found');
return [...namespaces].sort();
};
const loadI18nMetadata = async (TEMP_DIR: string): Promise<I18nMetadata> => {
const defaultLangPath = path.join(TEMP_DIR, 'src/const/locale.ts');
const localesPath = path.join(TEMP_DIR, 'src/locales/resources.ts');
const defaultResourcesPath = path.join(TEMP_DIR, 'src/locales/default/index.ts');
const [defaultLangCode, localesCode, defaultResourcesCode] = await Promise.all([
fs.readFile(defaultLangPath, 'utf8'),
fs.readFile(localesPath, 'utf8'),
fs.readFile(defaultResourcesPath, 'utf8'),
]);
const defaultLang = extractDefaultLang(defaultLangCode);
const locales = extractLocales(localesCode);
const namespaces = extractNamespaces(defaultResourcesCode);
return { defaultLang, locales, namespaces };
};
const generateLocaleNamespaceImports = (metadata: I18nMetadata) => {
const importLines: string[] = [];
const localeEntries: string[] = [];
for (const locale of metadata.locales) {
if (locale === metadata.defaultLang) continue;
const namespaceEntries: string[] = [];
for (const ns of metadata.namespaces) {
const alias = `locale_${toIdentifier(locale)}__${toIdentifier(ns)}`;
importLines.push(`import ${alias} from '@/../locales/${locale}/${ns}.json';`);
namespaceEntries.push(` '${ns}': { default: ${alias} },`);
}
localeEntries.push(` '${locale}': {\n${namespaceEntries.join('\n')}\n },`);
}
return {
imports: importLines.join('\n'),
localeEntries: localeEntries.join('\n'),
};
};
const generateBusinessUiImports = (metadata: I18nMetadata) => {
const importLines: string[] = [];
const mapEntries: string[] = [];
for (const locale of metadata.locales) {
const alias = `ui_${toIdentifier(locale)}`;
importLines.push(`import ${alias} from '@/../locales/${locale}/ui.json';`);
mapEntries.push(` '${locale}': ${alias} as UILocaleResources,`);
}
return {
imports: importLines.join('\n'),
mapEntries: mapEntries.join('\n'),
};
};
const buildElectronI18nMapContent = (metadata: I18nMetadata) => {
const { imports, localeEntries } = generateLocaleNamespaceImports(metadata);
return `import defaultResources from '@/locales/default';
${imports}
export type LocaleNamespaceModule = { default: unknown };
const toModule = (resource: unknown): LocaleNamespaceModule => ({ default: resource });
export const defaultNamespaceModules: Record<string, LocaleNamespaceModule> = Object.fromEntries(
Object.entries(defaultResources).map(([ns, resource]) => [ns, toModule(resource)]),
);
export const getDefaultNamespaceModule = (ns: string): LocaleNamespaceModule => {
const resource = defaultResources[ns as keyof typeof defaultResources] ?? defaultResources.common;
return toModule(resource);
};
export const staticLocaleNamespaceMap: Record<string, Record<string, LocaleNamespaceModule>> = {
'${metadata.defaultLang}': defaultNamespaceModules,
${localeEntries}
};
`;
};
const buildElectronUiResourcesContent = (metadata: I18nMetadata) => {
const { imports, mapEntries } = generateBusinessUiImports(metadata);
return `${imports}
export type UILocaleResources = Record<string, Record<string, string>>;
export const businessUiResources: Record<string, UILocaleResources> = {
${mapEntries}
};
`;
};
const applyEdits = (code: string, edits: CodeEdit[]): string => {
if (edits.length === 0) return code;
const sorted = [...edits].sort((a, b) => b.start - a.start);
let result = code;
for (const edit of sorted) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
};
const ensureImportAfterLastImport = (code: string, importStatement: string): string => {
if (code.includes(importStatement)) return code;
const moduleMatch = importStatement.match(/from '([^']+)'/);
if (moduleMatch) {
const modulePath = moduleMatch[1];
const hasModuleImport = new RegExp(`from ['"]${modulePath}['"]`).test(code);
if (hasModuleImport) return code;
}
const ast = parse(Lang.TypeScript, code);
const root = ast.root();
const imports = root.findAll({ rule: { kind: 'import_statement' } });
if (imports.length === 0) {
return `${importStatement}\n\n${code}`;
}
const lastImport = imports.at(-1)!;
const insertPos = lastImport.range().end.index;
return code.slice(0, insertPos) + `\n${importStatement}` + code.slice(insertPos);
};
const transformLoadNamespaceModule = (code: string) => {
const importStatement =
"import { defaultNamespaceModules, getDefaultNamespaceModule, staticLocaleNamespaceMap } from '@/utils/i18n/__electronI18nMap';";
let result = ensureImportAfterLastImport(code, importStatement);
const ast = parse(Lang.TypeScript, result);
const root = ast.root();
const edits: CodeEdit[] = [];
const defaultLangReturns = root.findAll({
rule: {
pattern: 'if (lng === defaultLang) return import(`@/locales/default/${ns}`);',
},
});
for (const node of defaultLangReturns) {
const range = node.range();
edits.push({
end: range.end.index,
start: range.start.index,
text: 'if (lng === defaultLang) return defaultNamespaceModules[ns] ?? getDefaultNamespaceModule(ns);',
});
}
const dynamicLocaleReturns = root.findAll({
rule: {
pattern: 'return import(`@/../locales/${normalizeLocale(lng)}/${ns}.json`);',
},
});
for (const node of dynamicLocaleReturns) {
const range = node.range();
edits.push({
end: range.end.index,
start: range.start.index,
text: 'return staticLocaleNamespaceMap[normalizeLocale(lng)]?.[ns] ?? getDefaultNamespaceModule(ns);',
});
}
const defaultFallbackReturns = root.findAll({
rule: {
pattern: 'return import(`@/locales/default/${ns}`);',
},
});
for (const node of defaultFallbackReturns) {
const range = node.range();
edits.push({
end: range.end.index,
start: range.start.index,
text: 'return getDefaultNamespaceModule(ns);',
});
}
result = applyEdits(result, edits);
// Fallback to robust function-level replacements if AST patterns did not match.
result = result.replace(
/export const loadI18nNamespaceModule = async[\S\s]*?};/m,
`export const loadI18nNamespaceModule = async (params: LoadI18nNamespaceModuleParams) => {
const { defaultLang, normalizeLocale, lng, ns } = params;
if (lng === defaultLang) return defaultNamespaceModules[ns] ?? getDefaultNamespaceModule(ns);
try {
const normalizedLocale = normalizeLocale(lng);
const localeResources = staticLocaleNamespaceMap[normalizedLocale];
if (localeResources?.[ns]) return localeResources[ns];
return defaultNamespaceModules[ns] ?? getDefaultNamespaceModule(ns);
} catch {
return getDefaultNamespaceModule(ns);
}
};`,
);
result = result.replace(
/export const loadI18nNamespaceModuleWithFallback = async[\S\s]*?};/m,
`export const loadI18nNamespaceModuleWithFallback = async (
params: LoadI18nNamespaceModuleWithFallbackParams,
) => {
const { onFallback, ...rest } = params;
try {
return await loadI18nNamespaceModule(rest);
} catch (error) {
onFallback?.({ error, lng: rest.lng, ns: rest.ns });
return getDefaultNamespaceModule(rest.ns);
}
};`,
);
result = result.replaceAll(/\n{3,}/g, '\n\n');
return result;
};
const replaceFunctionBody = (code: string, functionName: string, newBody: string): string => {
const ast = parse(Lang.TypeScript, code);
const root = ast.root();
const target = root.find({
rule: {
kind: 'variable_declarator',
pattern: `const ${functionName} = $EXPR`,
},
});
if (!target) return code;
const declaratorText = target.text();
const initMatch = declaratorText.match(/=\s*([\S\s]*)$/);
if (!initMatch) return code;
const initText = initMatch[1];
const initStart = declaratorText.indexOf(initText);
if (initStart < 0) return code;
const fullRange = target.range();
const initRange = {
end: fullRange.start.index + initStart + initText.length,
start: fullRange.start.index + initStart,
};
const updated = code.slice(0, initRange.start) + newBody + code.slice(initRange.end);
return updated;
};
const transformUiLocaleResources = (code: string) => {
const uiImportStatement = "import { en, zhCn } from '@lobehub/ui/es/i18n/resources/index';";
const businessImportStatement =
"import { businessUiResources } from '@/libs/__electronUiResources';";
let result = ensureImportAfterLastImport(code, uiImportStatement);
result = ensureImportAfterLastImport(result, businessImportStatement);
result = replaceFunctionBody(
result,
'loadBusinessResources',
`(locale: string): UILocaleResources | null => {
return businessUiResources[locale] ?? null;
}`,
);
result = replaceFunctionBody(
result,
'loadLobeUIBuiltinResources',
`(locale: string): UILocaleResources | null => {
if (locale.startsWith('zh')) return zhCn as UILocaleResources;
return en as UILocaleResources;
}`,
);
// Fallback to string replacements if AST patterns did not match.
result = result.replace(
/const loadBusinessResources = async[\S\s]*?};/m,
`const loadBusinessResources = (locale: string): UILocaleResources | null => {
return businessUiResources[locale] ?? null;
};`,
);
result = result.replace(
/const loadLobeUIBuiltinResources = async[\S\s]*?};/m,
`const loadLobeUIBuiltinResources = (locale: string): UILocaleResources | null => {
if (locale.startsWith('zh')) return zhCn as UILocaleResources;
return en as UILocaleResources;
};`,
);
result = result.replaceAll(/\n{3,}/g, '\n\n');
return result;
};
export const convertI18nDynamicToStatic = async (TEMP_DIR: string) => {
console.log(' Converting i18n dynamic imports to static maps...');
const metadata = await loadI18nMetadata(TEMP_DIR);
const electronI18nMapPath = path.join(TEMP_DIR, 'src/utils/i18n/__electronI18nMap.ts');
const electronUiResourcesPath = path.join(TEMP_DIR, 'src/libs/__electronUiResources.ts');
const loadNamespacePath = path.join(TEMP_DIR, 'src/utils/i18n/loadI18nNamespaceModule.ts');
const uiLocalePath = path.join(TEMP_DIR, 'src/libs/getUILocaleAndResources.ts');
await fs.ensureFile(electronI18nMapPath);
await fs.ensureFile(electronUiResourcesPath);
await writeFileEnsuring({
assertAfter: (code) => !code.includes('import(`'),
filePath: electronI18nMapPath,
name: 'convertI18nDynamicToStatic.electronI18nMap',
text: buildElectronI18nMapContent(metadata),
});
await writeFileEnsuring({
assertAfter: (code) => !code.includes('await import('),
filePath: electronUiResourcesPath,
name: 'convertI18nDynamicToStatic.electronUiResources',
text: buildElectronUiResourcesContent(metadata),
});
await updateFile({
assertAfter: (code) =>
code.includes('@/utils/i18n/__electronI18nMap') &&
!code.includes('import(`@/locales/default/${ns}`)') &&
!code.includes('import(`@/locales/default/${rest.ns}`)') &&
!code.includes('import(`@/../locales/${normalizeLocale(lng)}/${ns}.json`)'),
filePath: loadNamespacePath,
name: 'convertI18nDynamicToStatic.loadNamespace',
transformer: transformLoadNamespaceModule,
});
await updateFile({
assertAfter: (code) =>
code.includes('@/libs/__electronUiResources') &&
code.includes('@lobehub/ui/es/i18n/resources/index') &&
!code.includes('await import(`@/../locales/${locale}/ui.json`)') &&
!code.includes("await import('@lobehub/ui/es/i18n/resources/index')"),
filePath: uiLocalePath,
name: 'convertI18nDynamicToStatic.uiLocale',
transformer: transformUiLocaleResources,
});
};
if (isDirectRun(import.meta.url)) {
await runStandalone('convertI18nDynamicToStatic', convertI18nDynamicToStatic, []);
}
@@ -1,41 +0,0 @@
import { Lang } from '@ast-grep/napi';
import path from 'node:path';
import { modifyAppCode } from './appCode.mjs';
import { cleanUpCode } from './cleanUp.mjs';
import { convertDynamicToStatic } from './dynamicToStatic.mjs';
import { convertI18nDynamicToStatic } from './i18nDynamicToStatic.mjs';
import { convertNextDynamicToStatic } from './nextDynamicToStatic.mjs';
import { modifyNextConfig } from './nextConfig.mjs';
import { removeSuspenseFromConversation } from './removeSuspense.mjs';
import { modifyRoutes } from './routes.mjs';
import { convertSettingsContentToStatic } from './settingsContentToStatic.mjs';
import { modifyStaticExport } from './staticExport.mjs';
import { isDirectRun, runStandalone } from './utils.mjs';
import { wrapChildrenWithClientOnly } from './wrapChildrenWithClientOnly.mjs';
export const modifySourceForElectron = async (TEMP_DIR: string) => {
await modifyNextConfig(TEMP_DIR);
await modifyAppCode(TEMP_DIR);
await wrapChildrenWithClientOnly(TEMP_DIR);
await convertDynamicToStatic(TEMP_DIR);
await convertNextDynamicToStatic(TEMP_DIR);
await convertI18nDynamicToStatic(TEMP_DIR);
await convertSettingsContentToStatic(TEMP_DIR);
await removeSuspenseFromConversation(TEMP_DIR);
await modifyRoutes(TEMP_DIR);
await modifyStaticExport(TEMP_DIR);
await cleanUpCode(TEMP_DIR);
};
if (isDirectRun(import.meta.url)) {
await runStandalone('modifySourceForElectron', modifySourceForElectron, [
{ lang: Lang.TypeScript, path: path.join(process.cwd(), 'next.config.ts') },
{ lang: Lang.Tsx, path: 'src/app/[variants]/page.tsx' },
{ lang: Lang.Tsx, path: 'src/layout/GlobalProvider/index.tsx' },
{ lang: Lang.Tsx, path: 'src/app/[variants]/router/desktopRouter.config.tsx' },
{ lang: Lang.Tsx, path: 'src/components/mdx/Image.tsx' },
{ lang: Lang.TypeScript, path: 'src/features/DevPanel/CacheViewer/getCacheEntries.ts' },
{ lang: Lang.TypeScript, path: 'src/server/translation.ts' },
]);
}
@@ -1,214 +0,0 @@
/* eslint-disable no-undef */
import { Lang, parse } from '@ast-grep/napi';
import fs from 'fs-extra';
import path from 'node:path';
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
interface Edit {
end: number;
start: number;
text: string;
}
export const modifyNextConfig = async (TEMP_DIR: string) => {
const defineConfigPath = path.join(TEMP_DIR, 'src', 'libs', 'next', 'config', 'define-config.ts');
const legacyNextConfigPath = path.join(TEMP_DIR, 'next.config.ts');
const nextConfigPath = fs.existsSync(defineConfigPath) ? defineConfigPath : legacyNextConfigPath;
if (!fs.existsSync(nextConfigPath)) {
throw new Error(`[modifyNextConfig] next config not found: ${nextConfigPath}`);
}
console.log(` Processing ${path.relative(TEMP_DIR, nextConfigPath)}...`);
await updateFile({
assertAfter: (code) => /output\s*:\s*["']export["']/.test(code) && !/withPWA\s*\(/.test(code),
filePath: nextConfigPath,
name: 'modifyNextConfig',
transformer: (code) => {
const ast = parse(Lang.TypeScript, code);
const root = ast.root();
const edits: Edit[] = [];
// Find nextConfig declaration
const nextConfigDecl = root.find({
rule: {
pattern: 'const nextConfig: NextConfig = { $$$ }',
},
});
if (!nextConfigDecl) {
throw new Error('[modifyNextConfig] nextConfig declaration not found');
}
// 1. Remove redirects
const redirectsPair = nextConfigDecl
.findAll({
rule: {
kind: 'pair',
},
})
.find((node) => {
const text = node.text();
return text.startsWith('redirects:') || text.startsWith('redirects :');
});
invariant(redirectsPair, '[modifyNextConfig] redirects pair not found');
{
const range = redirectsPair!.range();
edits.push({ end: range.end.index, start: range.start.index, text: '' });
}
// 2. Remove headers
const headersMethod = nextConfigDecl
.findAll({
rule: {
kind: 'method_definition',
},
})
.find((node) => {
const text = node.text();
return text.startsWith('async headers') || text.startsWith('headers');
});
invariant(headersMethod, '[modifyNextConfig] headers method not found');
{
const range = headersMethod!.range();
edits.push({ end: range.end.index, start: range.start.index, text: '' });
}
// 3. Remove webVitalsAttribution
const webVitalsPair = nextConfigDecl
.findAll({
rule: {
kind: 'pair',
},
})
.find((node) => {
const text = node.text();
return (
text.startsWith('webVitalsAttribution:') || text.startsWith('webVitalsAttribution :')
);
});
invariant(webVitalsPair, '[modifyNextConfig] webVitalsAttribution pair not found');
{
const range = webVitalsPair!.range();
edits.push({ end: range.end.index, start: range.start.index, text: '' });
}
// 4. Remove spread element
const spreads = nextConfigDecl.findAll({
rule: {
kind: 'spread_element',
},
});
// eslint-disable-next-line unicorn/consistent-function-scoping
const isObjectLevelSpread = (node: any) => node.parent()?.kind() === 'object';
const standaloneSpread = spreads.find((node) => {
if (!isObjectLevelSpread(node)) return false;
const text = node.text();
return text.includes('isStandaloneMode') && text.includes('standaloneConfig');
});
const objectLevelSpread = standaloneSpread ? null : spreads.find(isObjectLevelSpread);
const spreadToRemove = standaloneSpread || objectLevelSpread;
invariant(spreadToRemove, '[modifyNextConfig] spread element not found');
{
const range = spreadToRemove!.range();
edits.push({ end: range.end.index, start: range.start.index, text: '' });
}
// 5. Inject/force output: 'export'
const outputPair = nextConfigDecl.find({
rule: {
pattern: 'output: $A',
},
});
if (outputPair) {
const range = outputPair.range();
edits.push({ end: range.end.index, start: range.start.index, text: "output: 'export'" });
} else {
const objectNode = nextConfigDecl.find({
rule: { kind: 'object' },
});
if (!objectNode) {
throw new Error('[modifyNextConfig] nextConfig object not found');
}
{
const range = objectNode.range();
// Insert after the opening brace `{
edits.push({
end: range.start.index + 1,
start: range.start.index + 1,
text: "\n output: 'export',",
});
}
}
// 6. Inject outputFileTracingRoot to fix symlink resolution for Turbopack
// When building in shadow workspace (TEMP_DIR), symlinks (e.g., node_modules) point to PROJECT_ROOT
// Turbopack's root defaults to TEMP_DIR, causing strip_prefix to fail for paths outside TEMP_DIR
// Setting outputFileTracingRoot to PROJECT_ROOT allows Turbopack to correctly resolve these symlinks
// We use ELECTRON_BUILD_PROJECT_ROOT env var which is set by buildNextApp.mts
const outputFileTracingRootPair = nextConfigDecl.find({
rule: {
pattern: 'outputFileTracingRoot: $A',
},
});
if (!outputFileTracingRootPair) {
const objectNode = nextConfigDecl.find({
rule: { kind: 'object' },
});
if (objectNode) {
const range = objectNode.range();
// Insert outputFileTracingRoot that reads from env var at build time
// Falls back to current directory if not in electron build context
edits.push({
end: range.start.index + 1,
start: range.start.index + 1,
text: '\n outputFileTracingRoot: process.env.ELECTRON_BUILD_PROJECT_ROOT || process.cwd(),',
});
}
}
// Remove withPWA wrapper
const withPWA = root.find({
rule: {
pattern: 'withPWA($A)',
},
});
if (withPWA) {
const inner = withPWA.getMatch('A');
if (!inner) {
throw new Error('[modifyNextConfig] withPWA inner config not found');
}
{
const range = withPWA.range();
edits.push({ end: range.end.index, start: range.start.index, text: inner.text() });
}
}
// Apply edits
edits.sort((a, b) => b.start - a.start);
let newCode = code;
for (const edit of edits) {
newCode = newCode.slice(0, edit.start) + edit.text + newCode.slice(edit.end);
}
// Cleanup commas (syntax fix)
// 1. Double commas ,, -> , (handle spaces/newlines between)
newCode = newCode.replaceAll(/,(\s*,)+/g, ',');
// 2. Leading comma in object { , -> {
newCode = newCode.replaceAll(/{\s*,/g, '{');
return newCode;
},
});
};
if (isDirectRun(import.meta.url)) {
await runStandalone('modifyNextConfig', modifyNextConfig, [
{ lang: Lang.TypeScript, path: 'src/libs/next/config/define-config.ts' },
{ lang: Lang.TypeScript, path: 'next.config.ts' },
]);
}
@@ -1,233 +0,0 @@
/* eslint-disable no-undef */
import { Lang, parse } from '@ast-grep/napi';
import { glob } from 'glob';
import fs from 'fs-extra';
import path from 'node:path';
import { invariant, isDirectRun, runStandalone } from './utils.mjs';
interface DynamicImportInfo {
componentName: string;
end: number;
importPath: string;
start: number;
}
const extractDynamicImports = (code: string): DynamicImportInfo[] => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const results: DynamicImportInfo[] = [];
const dynamicCalls = root.findAll({
rule: {
pattern: 'const $NAME = dynamic(() => import($PATH))',
},
});
for (const call of dynamicCalls) {
const range = call.range();
const text = call.text();
const nameMatch = text.match(/const\s+(\w+)\s*=/);
invariant(
nameMatch,
`[convertNextDynamicToStatic] Failed to extract component name from dynamic call: ${text.slice(0, 100)}`,
);
const importMatch = text.match(/import\s*\(\s*["']([^"']+)["']\s*\)/);
invariant(
importMatch,
`[convertNextDynamicToStatic] Failed to extract import path from dynamic call: ${text.slice(0, 100)}`,
);
results.push({
componentName: nameMatch![1],
end: range.end.index,
importPath: importMatch![1],
start: range.start.index,
});
}
const dynamicCallsWithOptions = root.findAll({
rule: {
pattern: 'const $NAME = dynamic(() => import($PATH), $OPTIONS)',
},
});
for (const call of dynamicCallsWithOptions) {
const range = call.range();
const text = call.text();
const nameMatch = text.match(/const\s+(\w+)\s*=/);
invariant(
nameMatch,
`[convertNextDynamicToStatic] Failed to extract component name from dynamic call: ${text.slice(0, 100)}`,
);
const importMatch = text.match(/import\s*\(\s*["']([^"']+)["']\s*\)/);
invariant(
importMatch,
`[convertNextDynamicToStatic] Failed to extract import path from dynamic call: ${text.slice(0, 100)}`,
);
const alreadyExists = results.some(
(r) => r.componentName === nameMatch![1] && r.importPath === importMatch![1],
);
if (alreadyExists) continue;
results.push({
componentName: nameMatch![1],
end: range.end.index,
importPath: importMatch![1],
start: range.start.index,
});
}
return results;
};
const generateImportStatements = (imports: DynamicImportInfo[]): string => {
const uniqueImports = new Map<string, string>();
for (const imp of imports) {
if (!uniqueImports.has(imp.importPath)) {
uniqueImports.set(imp.importPath, imp.componentName);
}
}
const sortedPaths = [...uniqueImports.keys()].sort();
return sortedPaths
.map((importPath) => {
const componentName = uniqueImports.get(importPath)!;
return `import ${componentName} from '${importPath}';`;
})
.join('\n');
};
const findImportInsertPosition = (code: string, filePath: string): number => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const imports = root.findAll({
rule: {
kind: 'import_statement',
},
});
invariant(
imports.length > 0,
`[convertNextDynamicToStatic] No import statements found in ${filePath}`,
);
const lastImport = imports.at(-1)!;
return lastImport.range().end.index;
};
const removeDynamicImport = (code: string): string => {
const patterns = [
/import dynamic from ["']@\/libs\/next\/dynamic["'];\n?/g,
/import dynamic from ["']next\/dynamic["'];\n?/g,
];
let result = code;
for (const pattern of patterns) {
result = result.replace(pattern, '');
}
return result;
};
const removeUnusedLoadingImport = (code: string): string => {
const codeWithoutImport = code.replaceAll(/import Loading from ["'][^"']+["'];?\n?/g, '');
if (!/\bLoading\b/.test(codeWithoutImport)) {
return code.replaceAll(/import Loading from ["'][^"']+["'];?\n?/g, '');
}
return code;
};
const transformFile = (code: string, filePath: string): string => {
const imports = extractDynamicImports(code);
if (imports.length === 0) {
return code;
}
const importStatements = generateImportStatements(imports);
const edits: Array<{ end: number; start: number; text: string }> = [];
for (const imp of imports) {
edits.push({
end: imp.end,
start: imp.start,
text: '',
});
}
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
let endIndex = edit.end;
while (result[endIndex] === '\n' || result[endIndex] === '\r') {
endIndex++;
}
result = result.slice(0, edit.start) + result.slice(endIndex);
}
const insertPos = findImportInsertPosition(result, filePath);
if (importStatements) {
result = result.slice(0, insertPos) + '\n' + importStatements + result.slice(insertPos);
}
result = removeDynamicImport(result);
result = removeUnusedLoadingImport(result);
result = result.replaceAll(/\n{3,}/g, '\n\n');
return result;
};
export const convertNextDynamicToStatic = async (TEMP_DIR: string) => {
const appDirs = [
{ dir: path.join(TEMP_DIR, 'src/app/(variants)'), label: 'src/app/(variants)' },
{ dir: path.join(TEMP_DIR, 'src/app/[variants]'), label: 'src/app/[variants]' },
];
console.log(' Processing next/dynamic → static imports...');
let processedCount = 0;
for (const { dir, label } of appDirs) {
if (!(await fs.pathExists(dir))) {
continue;
}
const files = await glob('**/*.tsx', { cwd: dir });
for (const file of files) {
const filePath = path.join(dir, file);
const code = await fs.readFile(filePath, 'utf8');
if (!code.includes('dynamic(')) {
continue;
}
const transformed = transformFile(code, `${label}/${file}`);
if (transformed !== code) {
await fs.writeFile(filePath, transformed);
processedCount++;
console.log(` Transformed: ${label}/${file}`);
}
}
}
console.log(` Processed ${processedCount} files with dynamic imports`);
};
if (isDirectRun(import.meta.url)) {
await runStandalone('convertNextDynamicToStatic', convertNextDynamicToStatic, []);
}
@@ -1,128 +0,0 @@
import { Lang, parse } from '@ast-grep/napi';
import path from 'node:path';
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
const removeSuspenseWrapper = (code: string): string => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const suspenseElements = root.findAll({
rule: {
has: {
has: {
kind: 'identifier',
regex: '^Suspense$',
},
kind: 'jsx_opening_element',
},
kind: 'jsx_element',
},
});
if (suspenseElements.length === 0) {
return code;
}
const edits: Array<{ end: number; start: number; text: string }> = [];
for (const suspense of suspenseElements) {
const range = suspense.range();
const children = suspense.children();
let childrenText = '';
for (const child of children) {
const kind = child.kind();
if (
kind === 'jsx_element' ||
kind === 'jsx_self_closing_element' ||
kind === 'jsx_fragment'
) {
childrenText = child.text();
break;
}
}
if (childrenText) {
edits.push({
end: range.end.index,
start: range.start.index,
text: childrenText,
});
}
}
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
};
const removeUnusedImports = (code: string): string => {
let result = code;
if (!result.includes('<Suspense')) {
result = result.replaceAll(/,?\s*Suspense\s*,?/g, (match) => {
if (match.startsWith(',') && match.endsWith(',')) {
return ',';
}
return '';
});
result = result.replaceAll(/{\s*,/g, '{');
result = result.replaceAll(/,\s*}/g, '}');
result = result.replaceAll(/{\s*}/g, '');
result = result.replaceAll(/import\s+{\s*}\s+from\s+["'][^"']+["'];\n?/g, '');
}
if (!result.includes('<Loading') && !result.includes('Loading />')) {
result = result.replaceAll(
/import Loading from ["']@\/components\/Loading\/BrandTextLoading["'];\n?/g,
'',
);
}
result = result.replaceAll(/\n{3,}/g, '\n\n');
return result;
};
export const removeSuspenseFromConversation = async (TEMP_DIR: string) => {
const filePath = path.join(
TEMP_DIR,
'src/app/[variants]/(main)/agent/features/Conversation/index.tsx',
);
console.log(' Removing Suspense from Conversation/index.tsx...');
await updateFile({
assertAfter: (code) => {
const noSuspenseElement = !/<Suspense/.test(code);
return noSuspenseElement;
},
filePath,
name: 'removeSuspenseFromConversation',
transformer: (code) => {
invariant(
/<Suspense/.test(code),
'[removeSuspenseFromConversation] No Suspense element found in Conversation/index.tsx',
);
let result = removeSuspenseWrapper(code);
result = removeUnusedImports(result);
return result;
},
});
};
if (isDirectRun(import.meta.url)) {
await runStandalone('removeSuspenseFromConversation', removeSuspenseFromConversation, [
{ lang: Lang.Tsx, path: 'src/app/[variants]/(main)/agent/features/Conversation/index.tsx' },
]);
}
@@ -1,109 +0,0 @@
import { Lang, parse } from '@ast-grep/napi';
import path from 'node:path';
import { isDirectRun, removePathEnsuring, runStandalone, updateFile } from './utils.mjs';
export const modifyRoutes = async (TEMP_DIR: string) => {
// 1. Delete routes
const filesToDelete = [
// Backend API routes
'src/app/(backend)/api',
'src/app/(backend)/webapi',
'src/app/(backend)/trpc',
'src/app/(backend)/oidc',
'src/app/(backend)/middleware',
'src/app/(backend)/f',
'src/app/(backend)/market',
// Auth & User routes
'src/app/[variants]/(auth)',
'src/app/[variants]/(mobile)',
'src/app/[variants]/(main)/(mobile)/me',
'src/app/[variants]/(main)/changelog',
'src/app/[variants]/oauth',
// Other app roots
'src/app/market-auth-callback',
'src/app/manifest.ts',
'src/app/robots.tsx',
'src/app/sitemap.tsx',
'src/app/sw.ts',
// Config files
'src/instrumentation.ts',
'src/instrumentation.node.ts',
// Desktop specific routes
'src/app/desktop/devtools',
'src/app/desktop/layout.tsx',
];
for (const file of filesToDelete) {
const fullPath = path.join(TEMP_DIR, file);
await removePathEnsuring({
name: `modifyRoutes:delete:${file}`,
path: fullPath,
});
}
// 2. Delete root loading.tsx files(not needed in Electron SPA)
const loadingFiles = ['src/app/loading.tsx', 'src/app/[variants]/loading.tsx'];
console.log(` Removing ${loadingFiles.length} root loading.tsx files...`);
for (const file of loadingFiles) {
const fullPath = path.join(TEMP_DIR, file);
await removePathEnsuring({
name: `modifyRoutes:delete:loading:${file}`,
path: fullPath,
});
}
// 3. Modify desktopRouter.config.tsx
const routerConfigPath = path.join(
TEMP_DIR,
'src/app/[variants]/router/desktopRouter.config.tsx',
);
console.log(' Processing src/app/[variants]/router/desktopRouter.config.tsx...');
await updateFile({
assertAfter: (code) => !/\bchangelog\b/.test(code),
filePath: routerConfigPath,
name: 'modifyRoutes:desktopRouterConfig',
transformer: (code) => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
const changelogNode = root.find({
rule: {
pattern: "{ path: 'changelog', $$$ }",
},
});
if (changelogNode) {
changelogNode.replace('');
}
const changelogImport = root.find({
rule: {
pattern: "import('../(main)/changelog')",
},
});
if (changelogImport) {
// Find the closest object (route definition) and remove it
let curr = changelogImport.parent();
while (curr) {
if (curr.kind() === 'object') {
curr.replace('');
break;
}
curr = curr.parent();
}
}
return root.text();
},
});
};
if (isDirectRun(import.meta.url)) {
await runStandalone('modifyRoutes', modifyRoutes, [
{ lang: Lang.Tsx, path: 'src/app/[variants]/router/desktopRouter.config.tsx' },
]);
}
@@ -1,130 +0,0 @@
/* eslint-disable no-undef */
import { Lang, parse } from '@ast-grep/napi';
import path from 'node:path';
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
interface DynamicImportInfo {
componentName: string;
end: number;
importPath: string;
key: string;
start: number;
}
const extractDynamicImportsFromMap = (code: string): DynamicImportInfo[] => {
const results: DynamicImportInfo[] = [];
const regex =
/\[SettingsTabs\.(\w+)]:\s*dynamic\(\s*\(\)\s*=>\s*import\(\s*["']([^"']+)["']\s*\)/g;
let match;
while ((match = regex.exec(code)) !== null) {
const key = match[1];
const importPath = match[2];
const componentName = key.charAt(0).toUpperCase() + key.slice(1) + 'Tab';
results.push({
componentName,
end: 0,
importPath,
key,
start: 0,
});
}
return results;
};
const generateStaticImports = (imports: DynamicImportInfo[]): string => {
return imports.map((imp) => `import ${imp.componentName} from '${imp.importPath}';`).join('\n');
};
const generateStaticComponentMap = (imports: DynamicImportInfo[]): string => {
const entries = imports.map((imp) => ` [SettingsTabs.${imp.key}]: ${imp.componentName},`);
return `const componentMap: Record<string, React.ComponentType<{ mobile?: boolean }>> = {\n${entries.join('\n')}\n}`;
};
export const convertSettingsContentToStatic = async (TEMP_DIR: string) => {
const filePath = path.join(
TEMP_DIR,
'src/app/[variants]/(main)/settings/features/SettingsContent.tsx',
);
console.log(' Processing SettingsContent.tsx dynamic imports...');
await updateFile({
assertAfter: (code) => {
const noDynamic = !/dynamic\(\s*\(\)\s*=>\s*import/.test(code);
const hasStaticMap = /componentMap:\s*Record<string,/.test(code);
return noDynamic && hasStaticMap;
},
filePath,
name: 'convertSettingsContentToStatic',
transformer: (code) => {
const imports = extractDynamicImportsFromMap(code);
invariant(
imports.length > 0,
'[convertSettingsContentToStatic] No dynamic imports found in SettingsContent.tsx',
);
console.log(` Found ${imports.length} dynamic imports in componentMap`);
const staticImports = generateStaticImports(imports);
const staticComponentMap = generateStaticComponentMap(imports);
let result = code;
result = result.replace(/import dynamic from ["']@\/libs\/next\/dynamic["'];\n?/, '');
result = result.replace(
/import Loading from ["']@\/components\/Loading\/BrandTextLoading["'];\n?/,
'',
);
const ast = parse(Lang.Tsx, result);
const root = ast.root();
const lastImport = root
.findAll({
rule: {
kind: 'import_statement',
},
})
.at(-1);
invariant(
lastImport,
'[convertSettingsContentToStatic] No import statements found in SettingsContent.tsx',
);
const insertPos = lastImport!.range().end.index;
result =
result.slice(0, insertPos) +
"\nimport type React from 'react';\n" +
staticImports +
result.slice(insertPos);
const componentMapRegex = /const componentMap = {[\S\s]*?\n};/;
invariant(
componentMapRegex.test(result),
'[convertSettingsContentToStatic] componentMap declaration not found in SettingsContent.tsx',
);
result = result.replace(componentMapRegex, staticComponentMap + ';');
result = result.replaceAll(/\n{3,}/g, '\n\n');
return result;
},
});
};
if (isDirectRun(import.meta.url)) {
await runStandalone('convertSettingsContentToStatic', convertSettingsContentToStatic, [
{ lang: Lang.Tsx, path: 'src/app/[variants]/(main)/settings/features/SettingsContent.tsx' },
]);
}
@@ -1,174 +0,0 @@
import { Lang, parse } from '@ast-grep/napi';
import fs from 'fs-extra';
import path from 'node:path';
import { isDirectRun, runStandalone, updateFile } from './utils.mjs';
/**
* Remove the URL rewrite logic from the proxy middleware.
* For Electron static export, we don't need URL rewriting since pages are pre-rendered.
*/
const removeUrlRewriteLogic = (code: string): string => {
const ast = parse(Lang.TypeScript, code);
const root = ast.root();
const edits: Array<{ end: number; start: number; text: string }> = [];
// Find the defaultMiddleware arrow function
const defaultMiddleware = root.find({
rule: {
pattern: 'const defaultMiddleware = ($REQ) => { $$$ }',
},
});
if (!defaultMiddleware) {
console.warn(' ⚠️ defaultMiddleware not found, skipping URL rewrite removal');
return code;
}
// Replace the entire defaultMiddleware function with a simplified version
// that just returns NextResponse.next() for non-API routes
const range = defaultMiddleware.range();
const simplifiedMiddleware = `const defaultMiddleware = (request: NextRequest) => {
const url = new URL(request.url);
logDefault('Processing request: %s %s', request.method, request.url);
// skip all api requests
if (backendApiEndpoints.some((path) => url.pathname.startsWith(path))) {
logDefault('Skipping API request: %s', url.pathname);
return NextResponse.next();
}
return NextResponse.next();
}`;
edits.push({ end: range.end.index, start: range.start.index, text: simplifiedMiddleware });
// Apply edits
if (edits.length === 0) return code;
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
};
const assertUrlRewriteRemoved = (code: string): boolean =>
// Ensure the URL rewrite related code is removed
!/NextResponse\.rewrite\(/.test(code) &&
!/RouteVariants\.serializeVariants/.test(code) &&
!/url\.pathname = nextPathname/.test(code);
/**
* Rename [variants] directories to (variants) under src/app
*/
const renameVariantsDirectories = async (TEMP_DIR: string): Promise<void> => {
const srcAppPath = path.join(TEMP_DIR, 'src', 'app');
// Recursively find and rename [variants] directories
const renameRecursively = async (dir: string): Promise<void> => {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const oldPath = path.join(dir, entry.name);
if (entry.name === '[variants]') {
const newPath = path.join(dir, '(variants)');
// If (variants) already exists, remove it first
if (await fs.pathExists(newPath)) {
console.log(` Removing existing: ${path.relative(TEMP_DIR, newPath)}`);
await fs.remove(newPath);
}
console.log(
` Renaming: ${path.relative(TEMP_DIR, oldPath)} -> ${path.relative(TEMP_DIR, newPath)}`,
);
await fs.rename(oldPath, newPath);
// Continue searching in the renamed directory
await renameRecursively(newPath);
} else {
// Continue searching in subdirectories
await renameRecursively(oldPath);
}
}
}
};
await renameRecursively(srcAppPath);
};
/**
* Update all imports that reference [variants] to use (variants)
*/
const updateVariantsImports = async (TEMP_DIR: string): Promise<void> => {
const srcPath = path.join(TEMP_DIR, 'src');
// Pattern to match imports containing [variants]
const variantsImportPattern = /(\[variants])/g;
const processFile = async (filePath: string): Promise<void> => {
const content = await fs.readFile(filePath, 'utf8');
if (!content.includes('[variants]')) {
return;
}
const updated = content.replaceAll('[variants]', '(variants)');
if (updated !== content) {
console.log(` Updated imports: ${path.relative(TEMP_DIR, filePath)}`);
await fs.writeFile(filePath, updated);
}
};
const processDirectory = async (dir: string): Promise<void> => {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip node_modules and other non-source directories
if (entry.name === 'node_modules' || entry.name === '.git') {
continue;
}
await processDirectory(fullPath);
} else if (entry.isFile() && /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name)) {
await processFile(fullPath);
}
}
};
await processDirectory(srcPath);
};
export const modifyStaticExport = async (TEMP_DIR: string): Promise<void> => {
// 1. Remove URL rewrite logic from define-config.ts
const defineConfigPath = path.join(TEMP_DIR, 'src', 'libs', 'next', 'proxy', 'define-config.ts');
console.log(' Processing src/libs/next/proxy/define-config.ts...');
await updateFile({
assertAfter: assertUrlRewriteRemoved,
filePath: defineConfigPath,
name: 'modifyStaticExport:removeUrlRewrite',
transformer: removeUrlRewriteLogic,
});
// 2. Rename [variants] directories to (variants)
console.log(' Renaming [variants] directories to (variants)...');
await renameVariantsDirectories(TEMP_DIR);
// 3. Update all imports referencing [variants]
console.log(' Updating imports referencing [variants]...');
await updateVariantsImports(TEMP_DIR);
};
if (isDirectRun(import.meta.url)) {
await runStandalone('modifyStaticExport', modifyStaticExport, [
{ lang: Lang.TypeScript, path: 'src/libs/next/proxy/define-config.ts' },
]);
}
@@ -1,145 +0,0 @@
import { Lang, parse } from '@ast-grep/napi';
import fs from 'fs-extra';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
interface ValidationTarget {
lang: Lang;
path: string;
}
interface UpdateFileOptions {
assertAfter?: (code: string) => boolean;
filePath: string;
name: string;
transformer: (code: string) => string;
}
interface WriteFileOptions {
assertAfter?: (code: string) => boolean;
filePath: string;
name: string;
text: string;
}
interface RemovePathOptions {
name: string;
path: string;
requireExists?: boolean;
}
export const invariant = (condition: unknown, message: string) => {
if (!condition) throw new Error(message);
};
export const normalizeEol = (code: string) => code.replaceAll('\r\n', '\n');
export const updateFile = async ({
assertAfter,
filePath,
name,
transformer,
}: UpdateFileOptions) => {
invariant(fs.existsSync(filePath), `[${name}] File not found: ${filePath}`);
const original = await fs.readFile(filePath, 'utf8');
const updated = transformer(original);
if (assertAfter) {
invariant(assertAfter(updated), `[${name}] Post-condition failed: ${filePath}`);
}
if (updated !== original) {
await fs.writeFile(filePath, updated);
}
};
export const writeFileEnsuring = async ({
assertAfter,
filePath,
name,
text,
}: WriteFileOptions) => {
await updateFile({
assertAfter,
filePath,
name,
transformer: () => text,
});
};
export const removePathEnsuring = async ({
name,
path: targetPath,
requireExists,
}: RemovePathOptions) => {
const exists = await fs.pathExists(targetPath);
if (requireExists) {
invariant(exists, `[${name}] Path not found: ${targetPath}`);
}
if (exists) {
await fs.remove(targetPath);
}
const stillExists = await fs.pathExists(targetPath);
invariant(!stillExists, `[${name}] Failed to remove path: ${targetPath}`);
};
export const isDirectRun = (importMetaUrl: string) => {
const entry = process.argv[1];
if (!entry) return false;
return importMetaUrl === pathToFileURL(entry).href;
};
export const resolveTempDir = () => {
const candidate = process.env.TEMP_DIR || process.argv[2];
const resolved = candidate
? path.resolve(candidate)
: path.resolve(process.cwd(), 'tmp', 'desktop-build');
if (!fs.existsSync(resolved)) {
throw new Error(`TEMP_DIR not found: ${resolved}`);
}
return resolved;
};
export const validateFiles = async (tempDir: string, targets: ValidationTarget[]) => {
for (const target of targets) {
const filePath = path.join(tempDir, target.path);
if (!fs.existsSync(filePath)) {
console.warn(` ⚠️ Skipped validation, missing file: ${target.path}`);
continue;
}
const code = await fs.readFile(filePath, 'utf8');
parse(target.lang, code);
console.log(` ✅ Validated: ${target.path}`);
}
};
export const runStandalone = async (
name: string,
modifier: (tempDir: string) => Promise<void>,
validateTargets: ValidationTarget[] = [],
) => {
try {
const workdir = process.cwd();
console.log(`▶️ Running ${name} with TEMP_DIR=${workdir}`);
await modifier(workdir);
if (validateTargets.length) {
console.log('🔎 Validating modified files...');
await validateFiles(workdir, validateTargets);
}
console.log(`${name} completed`);
} catch (error) {
console.error(`${name} failed`, error);
process.exitCode = 1;
}
};
@@ -1,82 +0,0 @@
import { Lang, parse } from '@ast-grep/napi';
import path from 'node:path';
import { invariant, isDirectRun, runStandalone, updateFile } from './utils.mjs';
export const wrapChildrenWithClientOnly = async (TEMP_DIR: string) => {
const layoutPath = path.join(TEMP_DIR, 'src/app/[variants]/layout.tsx');
console.log(' Wrapping children with ClientOnly in layout.tsx...');
await updateFile({
assertAfter: (code) => {
const hasClientOnlyImport =
/import ClientOnly from ["']@\/components\/client\/ClientOnly["']/.test(code);
const hasLoadingImport =
/import Loading from ["']@\/components\/Loading\/BrandTextLoading["']/.test(code);
const hasClientOnlyWrapper = /<ClientOnly fallback={<Loading/.test(code);
return hasClientOnlyImport && hasLoadingImport && hasClientOnlyWrapper;
},
filePath: layoutPath,
name: 'wrapChildrenWithClientOnly',
transformer: (code) => {
const ast = parse(Lang.Tsx, code);
const root = ast.root();
let result = code;
const hasClientOnlyImport =
/import ClientOnly from ["']@\/components\/client\/ClientOnly["']/.test(code);
const hasLoadingImport =
/import Loading from ["']@\/components\/Loading\/BrandTextLoading["']/.test(code);
const lastImport = root
.findAll({
rule: {
kind: 'import_statement',
},
})
.at(-1);
invariant(
lastImport,
'[wrapChildrenWithClientOnly] No import statements found in layout.tsx',
);
const insertPos = lastImport!.range().end.index;
let importsToAdd = '';
if (!hasClientOnlyImport) {
importsToAdd += "\nimport ClientOnly from '@/components/client/ClientOnly';";
}
if (!hasLoadingImport) {
importsToAdd += "\nimport Loading from '@/components/Loading/BrandTextLoading';";
}
if (importsToAdd) {
result = result.slice(0, insertPos) + importsToAdd + result.slice(insertPos);
}
const authProviderPattern = /<AuthProvider>\s*{children}\s*<\/AuthProvider>/;
invariant(
authProviderPattern.test(result),
'[wrapChildrenWithClientOnly] Pattern <AuthProvider>{children}</AuthProvider> not found in layout.tsx',
);
result = result.replace(
authProviderPattern,
`<AuthProvider>
<ClientOnly fallback={<Loading />}>{children}</ClientOnly>
</AuthProvider>`,
);
return result;
},
});
};
if (isDirectRun(import.meta.url)) {
await runStandalone('wrapChildrenWithClientOnly', wrapChildrenWithClientOnly, [
{ lang: Lang.Tsx, path: 'src/app/[variants]/layout.tsx' },
]);
}
@@ -1,19 +0,0 @@
import path from 'node:path';
import fs from 'fs-extra';
const rootDir = path.resolve(__dirname, '../..');
const exportSourceDir = path.join(rootDir, 'out');
const exportTargetDir = path.join(rootDir, 'apps/desktop/dist/next');
if (fs.existsSync(exportSourceDir)) {
console.log(`📦 Copying Next export assets from ${exportSourceDir} to ${exportTargetDir}...`);
fs.ensureDirSync(exportTargetDir);
fs.copySync(exportSourceDir, exportTargetDir, { overwrite: true });
console.log(`✅ Export assets copied successfully!`);
} else {
console.log(`️ No Next export output found at ${exportSourceDir}, skipping copy.`);
}
console.log(`🎉 Export move completed!`);
+47
View File
@@ -0,0 +1,47 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const root = resolve(import.meta.dirname, '..');
const desktopHtml = readFileSync(resolve(root, 'dist/desktop/index.html'), 'utf8');
const mobileHtmlPath = resolve(root, 'dist/mobile/index.mobile.html');
const mobileHtmlFallbackPath = resolve(root, 'dist/mobile/index.html');
const hasMobileBuild = existsSync(mobileHtmlPath) || existsSync(mobileHtmlFallbackPath);
let output: string;
if (hasMobileBuild) {
// Docker: mobile build exists locally, inline the template
const mobileHtml = readFileSync(
existsSync(mobileHtmlPath) ? mobileHtmlPath : mobileHtmlFallbackPath,
'utf8',
);
output = `// Auto-generated by scripts/generateSpaTemplates.mts after vite build
// Do not edit manually
export const desktopHtmlTemplate = ${JSON.stringify(desktopHtml)};
export const mobileHtmlTemplate = ${JSON.stringify(mobileHtml)};
`;
} else {
// Vercel: no mobile build, import from pre-committed source file
output = `// Auto-generated by scripts/generateSpaTemplates.mts after vite build
// Do not edit manually
import { mobileHtmlTemplate } from './mobileHtmlTemplate.source';
export const desktopHtmlTemplate = ${JSON.stringify(desktopHtml)};
export { mobileHtmlTemplate };
`;
}
writeFileSync(
resolve(root, 'src/app/spa/[variants]/[[...path]]/spaHtmlTemplates.ts'),
output,
'utf8',
);
console.log(`Generated spaHtmlTemplates.ts (mobile from ${hasMobileBuild ? 'build' : 'source'})`);
+1 -3
View File
@@ -20,8 +20,6 @@ dotenvExpand.expand(dotenv.config({ override: true, path: `.env.${env}.local` })
const migrationsFolder = join(__dirname, '../../packages/database/migrations');
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
const runMigrations = async () => {
const { serverDB } = await import('../../packages/database/src/server');
@@ -40,7 +38,7 @@ const runMigrations = async () => {
const connectionString = process.env.DATABASE_URL;
// only migrate database if the connection string is available
if (!isDesktop && connectionString) {
if (connectionString) {
runMigrations().catch((err) => {
console.error('❌ Database migrate failed:', err);
+75
View File
@@ -0,0 +1,75 @@
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import dotenv from 'dotenv';
import { generateMobileTemplate } from './template';
import { uploadAssets } from './upload';
dotenv.config();
const root = resolve(__dirname, '../..');
const distDir = resolve(root, 'dist/mobile');
const assetsDir = resolve(distDir, 'assets');
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
async function main() {
const publicDomain = new URL(requireEnv('MOBILE_S3_PUBLIC_DOMAIN')).origin;
const timestamp = new Date().toISOString().replaceAll(/[-:]/g, '').replace('T', '-').slice(0, 15); // e.g. 20260226-153012
const keyPrefix = (process.env.MOBILE_S3_KEY_PREFIX || `mobile/${timestamp}`).replaceAll(
/^\/+|\/+$/g,
'',
);
// VITE_CDN_BASE = domain + optional key prefix, e.g. https://web-assets.lobehub.com/mobile/20260226-153012/
const cdnBase = `${publicDomain.replace(/\/+$/, '')}/${keyPrefix}/`;
// Step 1: Build mobile SPA with CDN base
console.log('=== Step 1: Building mobile SPA ===');
execSync('vite build', {
cwd: root,
env: {
...process.env,
MOBILE: 'true',
NODE_OPTIONS: '--max-old-space-size=8192',
VITE_CDN_BASE: cdnBase,
},
stdio: 'inherit',
});
if (!existsSync(assetsDir)) {
throw new Error(`Build output not found at ${assetsDir}`);
}
// Step 2: Upload assets to S3
console.log('\n=== Step 2: Uploading assets to S3 ===');
await uploadAssets(assetsDir, {
accessKeyId: requireEnv('MOBILE_S3_ACCESS_KEY_ID'),
bucket: requireEnv('MOBILE_S3_BUCKET'),
endpoint: requireEnv('MOBILE_S3_ENDPOINT'),
keyPrefix: keyPrefix.replaceAll(/^\/+|\/+$/g, ''),
publicDomain,
region: process.env.MOBILE_S3_REGION || 'auto',
secretAccessKey: requireEnv('MOBILE_S3_SECRET_ACCESS_KEY'),
});
// Step 3: Generate mobile HTML template source file
console.log('\n=== Step 3: Generating mobile template ===');
generateMobileTemplate(distDir);
console.log('\n=== Workflow complete ===');
console.log('Remember to commit mobileHtmlTemplate.source.ts to the repository.');
}
main().catch((err) => {
console.error('Workflow failed:', err);
process.exit(1);
});
+27
View File
@@ -0,0 +1,27 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const root = resolve(__dirname, '../..');
export function generateMobileTemplate(distDir: string) {
const htmlPath = resolve(distDir, 'index.mobile.html');
const htmlFallback = resolve(distDir, 'index.html');
const sourcePath = existsSync(htmlPath) ? htmlPath : htmlFallback;
const html = readFileSync(sourcePath, 'utf8');
const output = `// Auto-generated by scripts/mobileSpaWorkflow
// Do not edit manually
export const mobileHtmlTemplate = ${JSON.stringify(html)};
`;
const outputPath = resolve(
root,
'src/app/spa/[variants]/[[...path]]/mobileHtmlTemplate.source.ts',
);
writeFileSync(outputPath, output, 'utf8');
console.log(`Generated mobileHtmlTemplate.source.ts`);
return outputPath;
}
+72
View File
@@ -0,0 +1,72 @@
import { readdirSync, readFileSync } from 'node:fs';
import { basename, extname, join } from 'node:path';
import pMap from 'p-map';
import s3 from '../cdnWorkflow/s3';
interface UploadConfig {
accessKeyId: string;
bucket: string;
endpoint: string;
keyPrefix: string;
publicDomain: string;
region: string;
secretAccessKey: string;
}
function collectFiles(dir: string): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...collectFiles(fullPath));
} else {
results.push(fullPath);
}
}
return results;
}
export async function uploadAssets(assetsDir: string, config: UploadConfig) {
const files = collectFiles(assetsDir);
console.log(`Found ${files.length} files to upload`);
const client = s3.createS3Client({
accessKeyId: config.accessKeyId,
bucketName: config.bucket,
endpoint: config.endpoint,
pathPrefix: '',
region: config.region,
secretAccessKey: config.secretAccessKey,
});
const results = await pMap(
files,
async (filePath) => {
const relativePath = filePath.slice(assetsDir.length + 1);
const key = `${config.keyPrefix}/assets/${relativePath}`;
const buffer = readFileSync(filePath);
const fileName = basename(filePath);
const ext = extname(filePath);
console.log(`Uploading ${key}...`);
const result = await s3.createUploadTask({
acl: 'public-read',
bucketName: config.bucket,
client,
item: { buffer, extname: ext, fileName },
path: key,
urlPrefix: config.publicDomain,
});
console.log(`Uploaded ${key} -> ${result.url}`);
return result;
},
{ concurrency: 10 },
);
console.log(`Successfully uploaded ${results.length} files`);
return results;
}
+1 -1
View File
@@ -15,7 +15,7 @@ const dotenv = require('dotenv');
const dotenvExpand = require('dotenv-expand');
const fs = require('node:fs');
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
const isDesktop = process.env.DESKTOP_BUILD === 'true';
if (isDesktop) {
const cwd = process.cwd();
+1 -1
View File
@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
const isDesktop = process.env.DESKTOP_BUILD === 'true';
if (isDesktop) {
const envDesktop = path.resolve(process.cwd(), '.env.desktop');