diff --git a/examples/basic-project/src/app.tsx b/examples/basic-project/src/app.tsx index c224046c6..b9054210c 100644 --- a/examples/basic-project/src/app.tsx +++ b/examples/basic-project/src/app.tsx @@ -1,5 +1,9 @@ import { defineAppConfig } from 'ice'; +if (process.env.ICE_RUNTIME_ERROR_BOUNDARY) { + console.log('__REMOVED__'); +} + export default defineAppConfig({ app: { // @ts-expect-error loss tslib dependency diff --git a/packages/build-webpack-config/src/clientEnv.ts b/packages/build-webpack-config/src/clientEnv.ts new file mode 100644 index 000000000..1306a3704 --- /dev/null +++ b/packages/build-webpack-config/src/clientEnv.ts @@ -0,0 +1,21 @@ +import type { NormalModule } from 'webpack'; + +interface RuntimeEnvironment { + [x: string]: (args: {module: NormalModule; key: string; version?: string}) => string; +} + +const RUNTIME_PREFIX = /^ICE_RUNTIME_/i; +const isBooleanString = (str: string) => ['true', 'false'].includes(str); +export const getRuntimeEnvironment = (customEnv?: Record): RuntimeEnvironment => { + // Grab ICE_RUNTIME_* environment variables and prepare them to be + // injected into the application via DefinePlugin in webpack configuration. + const raw = Object.keys({ ...process.env, ...(customEnv || {}) }).filter(key => RUNTIME_PREFIX.test(key)) + .reduce((env, key) => { + env[key.startsWith('process.env.') ? key : `process.env.${key}`] = () => { + const envValue = process.env[key] ?? customEnv[key]; + return isBooleanString(envValue) ? envValue : JSON.stringify(envValue); + }; + return env; + }, {}); + return raw; +}; diff --git a/packages/build-webpack-config/src/index.ts b/packages/build-webpack-config/src/index.ts index e9a297daf..078c9c7ce 100644 --- a/packages/build-webpack-config/src/index.ts +++ b/packages/build-webpack-config/src/index.ts @@ -11,6 +11,7 @@ import type { Configuration as DevServerConfiguration } from 'webpack-dev-server import type { Config } from '@ice/types'; import type { CommandArgs } from 'build-scripts'; import { createUnplugin } from 'unplugin'; +import { getRuntimeEnvironment } from './clientEnv.js'; import AssetsManifestPlugin from './webpackPlugins/AssetsManifestPlugin.js'; import getTransformPlugins from './unPlugins/index.js'; @@ -42,11 +43,19 @@ const getWebpackConfig: GetWebpackConfig = ({ rootDir, config, commandArgs = {} } = config; const dev = mode !== 'production'; - const defineVariables = { + const defineStaticVariables = { 'process.env.NODE_ENV': JSON.stringify(mode || 'development'), 'process.env.SERVER_PORT': JSON.stringify(commandArgs.port), 'process.env.__IS_SERVER__': false, }; + const runtimeEnv = getRuntimeEnvironment(); + const defineRuntimeVariables = {}; + Object.keys(runtimeEnv).forEach((key) => { + const runtimeValue = runtimeEnv[key]; + // set true to flag the module as uncacheable + defineRuntimeVariables[key] = webpack.DefinePlugin.runtimeValue(runtimeValue, true); + }); + // create plugins const webpackPlugins = getTransformPlugins(rootDir, config).map((plugin) => createUnplugin(() => plugin).webpack()); @@ -147,7 +156,10 @@ const getWebpackConfig: GetWebpackConfig = ({ rootDir, config, commandArgs = {} new MiniCssExtractPlugin({ filename: '[name].css', }), - new webpack.DefinePlugin(defineVariables), + new webpack.DefinePlugin({ + ...defineStaticVariables, + ...defineRuntimeVariables, + }), new webpack.ProvidePlugin({ process: 'process/browser' }), new AssetsManifestPlugin({ fileName: 'assets-manifest.json', diff --git a/packages/ice/src/analyzeRuntime.ts b/packages/ice/src/analyzeRuntime.ts new file mode 100644 index 000000000..9f428414b --- /dev/null +++ b/packages/ice/src/analyzeRuntime.ts @@ -0,0 +1,29 @@ +import * as path from 'path'; +import consola from 'consola'; +import type { EsbuildCompile } from '@ice/types/esm/plugin.js'; +import type { AppConfig } from './utils/runtimeEnv.js'; + +interface Options { + esbuildCompile: EsbuildCompile; + rootDir: string; +} + +export const getAppConfig = async (options: Options): Promise => { + const { esbuildCompile, rootDir } = options; + const outfile = path.join(rootDir, 'node_modules', 'entry.mjs'); + try { + await esbuildCompile({ + // TODO: detect src/app if it is exists + entryPoints: [path.join(rootDir, 'src/app')], + outfile, + format: 'esm', + external: ['./node_modules/*'], + }, { isServer: true }); + + const appConfig = (await import(outfile)).default; + consola.debug('app config:', appConfig); + return appConfig; + } catch (err) { + consola.error('[ERROR]', 'Fail to analyze app config', err); + } +}; diff --git a/packages/ice/src/constant.ts b/packages/ice/src/constant.ts index c0ccc1577..c60373383 100644 --- a/packages/ice/src/constant.ts +++ b/packages/ice/src/constant.ts @@ -1,4 +1,4 @@ export const builtInPlugins = [ '@ice/plugin-app', '@ice/plugin-auth', -]; \ No newline at end of file +]; diff --git a/packages/ice/src/index.ts b/packages/ice/src/index.ts index 714ad46f0..e21fffea4 100644 --- a/packages/ice/src/index.ts +++ b/packages/ice/src/index.ts @@ -11,6 +11,8 @@ import createWatch from './service/watchSource.js'; import start from './commands/start.js'; import build from './commands/build.js'; import getContextConfig from './utils/getContextConfig.js'; +import { getAppConfig } from './analyzeRuntime.js'; +import { defineRuntimeEnv, updateRuntimeEnv } from './utils/runtimeEnv.js'; import { generateRoutesInfo } from './routes.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -28,7 +30,7 @@ async function createService({ rootDir, command, commandArgs, getBuiltInPlugins const tmpDirName = '.ice'; const tmpDir = path.join(rootDir, tmpDirName); - const { routes, routesStr } = generateRoutesInfo(rootDir); + const { routeManifest, routes, routesStr } = generateRoutesInfo(rootDir); const generator = new Generator({ rootDir, targetDir: tmpDirName, @@ -96,7 +98,8 @@ async function createService({ rootDir, command, commandArgs, getBuiltInPlugins const renderStart = new Date().getTime(); generator.render(); consola.debug('template render cost:', new Date().getTime() - renderStart); - + // define runtime env before get webpack config + defineRuntimeEnv(); const contextConfig = getContextConfig(ctx); const webTask = contextConfig.find(({ name }) => name === 'web'); const esbuildCompile = createEsbuildCompiler({ @@ -109,6 +112,8 @@ async function createService({ rootDir, command, commandArgs, getBuiltInPlugins if (command === 'start') { return await start(ctx, contextConfig, esbuildCompile); } else if (command === 'build') { + const appConfig = await getAppConfig({ esbuildCompile, rootDir }); + updateRuntimeEnv(appConfig, routeManifest); return await build(ctx, contextConfig, esbuildCompile); } }, diff --git a/packages/ice/src/routes.ts b/packages/ice/src/routes.ts index 3d2428682..48dce9008 100644 --- a/packages/ice/src/routes.ts +++ b/packages/ice/src/routes.ts @@ -8,6 +8,7 @@ export function generateRoutesInfo(rootDir: string) { const str = generateNestRoutesStr(routes); return { + routeManifest, routesStr: `[${str}]`, routes, }; diff --git a/packages/ice/src/service/analyze.ts b/packages/ice/src/service/analyze.ts index b57f17f93..22850ad68 100644 --- a/packages/ice/src/service/analyze.ts +++ b/packages/ice/src/service/analyze.ts @@ -8,6 +8,7 @@ import consola from 'consola'; interface Options { parallel?: number; + analyzeRelativeImport?: boolean; alias?: Alias; } @@ -79,7 +80,7 @@ export function getImportPath( } export async function analyzeImports(files: string[], options: Options) { - const { parallel, alias = {} } = options; + const { parallel, analyzeRelativeImport, alias = {} } = options; const parallelNum = parallel ?? 10; const entries = [...files]; const analyzedSet = new Set(); @@ -113,7 +114,7 @@ export async function analyzeImports(files: string[], options: Options) { if (!importSet.has(importStr)) importSet.add(importStr); }); } - } else { + } else if (analyzeRelativeImport) { let importPath = importName; if (!path.isAbsolute(importPath)) { importPath = getImportPath(importPath, filePath, alias); diff --git a/packages/ice/src/utils/runtimeEnv.ts b/packages/ice/src/utils/runtimeEnv.ts new file mode 100644 index 000000000..00cd8b97b --- /dev/null +++ b/packages/ice/src/utils/runtimeEnv.ts @@ -0,0 +1,27 @@ +import type { RouteManifest } from '@ice/route-manifest'; + +export type AppConfig = Record; + +export const defineRuntimeEnv = () => { + const runtimeEnvironment = { + ROUTER: 'true', + ERROR_BOUNDARY: 'true', + AUTH: 'true', + INITIAL_DATA: 'true', + }; + Object.keys(runtimeEnvironment).forEach((key) => { + process.env[`ICE_RUNTIME_${key}`] = runtimeEnvironment[key]; + }); +}; + +export const updateRuntimeEnv = (appConfig?: AppConfig, routeManifest?: RouteManifest) => { + if (!appConfig?.app?.getInitialData) { + process.env['ICE_RUNTIME_INITIAL_DATA'] = 'false'; + } + if (!appConfig?.app?.errorBoundary) { + process.env['ICE_RUNTIME_ERROR_BOUNDARY'] = 'false'; + } + if (routeManifest && Object.keys(routeManifest).length <= 1) { + process.env['ICE_RUNTIME_ROUTER'] = 'false'; + } +}; diff --git a/packages/ice/tests/preAnalyze.test.ts b/packages/ice/tests/preAnalyze.test.ts index 63a1b32f7..841c177a8 100644 --- a/packages/ice/tests/preAnalyze.test.ts +++ b/packages/ice/tests/preAnalyze.test.ts @@ -45,7 +45,12 @@ describe('getImportPath', () => { describe('analyzeImports', () => { it('basic usage', async () => { const entryFile = path.join(__dirname, './fixtures/preAnalyze/app.ts'); - const analyzeSet = await analyzeImports([entryFile], { alias: {'@': path.join(__dirname, './fixtures/preAnalyze')}}) + const analyzeSet = await analyzeImports([entryFile], { + analyzeRelativeImport: true, + alias: { + '@': path.join(__dirname, './fixtures/preAnalyze'), + }, + }); expect([...(analyzeSet || [])]).toStrictEqual(['runApp', 'request', 'store']); }) }); \ No newline at end of file diff --git a/packages/runtime/package.json b/packages/runtime/package.json index df6daa382..71cc66b92 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -18,6 +18,7 @@ "react": "^17.0.2", "react-dom": "^17.0.2" }, + "sideEffects": false, "dependencies": { "history": "^5.3.0", "react-router-dom": "^6.2.2", diff --git a/packages/runtime/src/getInitialData.ts b/packages/runtime/src/getInitialData.ts new file mode 100644 index 000000000..9c33199dd --- /dev/null +++ b/packages/runtime/src/getInitialData.ts @@ -0,0 +1,25 @@ +import { createSearchParams } from 'react-router-dom'; +import type { AppContext, InitialContext, AppConfig } from './types'; + +const getInitialData = async (appConfig: AppConfig): Promise => { + // ssr enabled and the server has returned data + if ((window as any).__ICE_APP_DATA__) { + return (window as any).__ICE_APP_DATA__; + // context.pageInitialProps = (window as any).__ICE_PAGE_PROPS__; + } else if (appConfig?.app?.getInitialData) { + const { href, origin, pathname, search } = window.location; + const path = href.replace(origin, ''); + const query = Object.fromEntries(createSearchParams(search)); + const ssrError = (window as any).__ICE_SSR_ERROR__; + const initialContext: InitialContext = { + pathname, + path, + query, + ssrError, + }; + return await appConfig.app.getInitialData(initialContext); + } + return null; +}; + +export default getInitialData; \ No newline at end of file diff --git a/packages/runtime/src/runBrowserApp.tsx b/packages/runtime/src/runBrowserApp.tsx index ffd7e489c..d447deef4 100644 --- a/packages/runtime/src/runBrowserApp.tsx +++ b/packages/runtime/src/runBrowserApp.tsx @@ -1,11 +1,11 @@ import React, { useLayoutEffect, useReducer } from 'react'; import type { Update } from 'history'; import { createHashHistory, createBrowserHistory } from 'history'; -import { createSearchParams } from 'react-router-dom'; import Runtime from './runtime.js'; import App from './App.js'; -import type { AppContext, InitialContext, AppConfig, RouteItem } from './types'; +import type { AppContext, AppConfig, RouteItem } from './types'; import { loadRouteModules, loadPageData, matchRoutes } from './routes.js'; +import getInitialData from './getInitialData.js'; export default async function runBrowserApp( appConfig: AppConfig, @@ -21,6 +21,9 @@ export default async function runBrowserApp( routeModules, pageData, }; + if (process.env.ICE_RUNTIME_INITIAL_DATA) { + appContext.initialData = await getInitialData(appConfig); + } const runtime = new Runtime(appContext); runtimeModules.forEach(m => { diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index f052e8b2f..6b07b9343 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -122,6 +122,8 @@ export interface CommonJsRuntime { export type GetWrapperPageRegistration = () => PageWrapper[]; +export type RuntimeModules = (RuntimePlugin | CommonJsRuntime)[]; + export interface AppRouterProps { action: Action; location: Location; @@ -152,4 +154,4 @@ export interface RouteMatch { * The route object that was used to match. */ route: RouteItem; -} \ No newline at end of file +} diff --git a/tests/integration/basic-project.test.ts b/tests/integration/basic-project.test.ts index 8d716bad9..912ad9aa2 100644 --- a/tests/integration/basic-project.test.ts +++ b/tests/integration/basic-project.test.ts @@ -1,7 +1,12 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; import { buildFixture, setupBrowser } from '../utils/build'; import { startFixture, setupStartBrowser } from '../utils/start'; import { Page } from '../utils/browser'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const example = 'basic-project'; describe(`build ${example}`, () => { @@ -15,6 +20,8 @@ describe(`build ${example}`, () => { page = res.page; browser = res.browser; expect(await page.$$text('h2')).toStrictEqual(['Home Page']); + const bundleContent = fs.readFileSync(path.join(__dirname, `../../examples/${example}/build/main.js`), 'utf-8'); + expect(bundleContent.includes('__REMOVED__')).toBe(false); }); afterAll(async () => {