Skip to content

Tonic UI Public ESM Deployment

cheton_wu edited this page Oct 8, 2026 · 4 revisions

Tonic UI:Vite/Webpack 建置與獨立 CDN-first Demo

日期:2026-10-08
使用公開 esm.sh;不部署自架 server、不需要私有 registry credential。Tonic、React、Emotion 各自 CDN-first/local fallback,不再整份 app 一起切換三組 runtime。

文件入口:分組 fallback 架構 · 建置設定與獨立 demo

快速入口與目前狀態

本地 Tonic UI branch:feat/cdn-first-runtime-demo。獨立 demo 已在工作區實作並以真實 browser 驗證;該 branch 尚未提交、推送或部署到官方 docs。Wiki 更新不等於發布 demo。

在已有依賴與已建置 Tonic workspaces 的 repository 中,從 packages/react-docs 執行:

yarn build:cdn-runtime

此命令不呼叫 build:mfe。正常 docs build/dev 也會組裝 demo。開啟 Getting Started → CDN-first runtime,或在 docs base path 下直接開啟 /demos/cdn-runtime/index.html。生成產物只需 static hosting;iframe 只隔離 docs 的 React runtime,demo 不使用 Module Federation、Wujie 或 MFE server。

若尚未取得此本地 branch,下面提供完整 demo sources 與兩種 bundler 設定,可在既有 Tonic UI workspace 的 packages/react-docs/demos/cdn-runtime/ 重建示範。不需要替換產品現有 plugins/loaders。

四組 build,不是八份 app

Build 打包的內容 保留 external
react React/React DOM/JSX runtime 的多入口共享 chunks 無 shared runtime external
emotion Emotion 多入口共享 chunks React family
tonic-cdn App、另一個 ESM widget React、Emotion、全部所用 Tonic imports
tonic-local App、widget、Tonic 與其他非共享 dependencies React、Emotion

三組來源可以各自選 CDN 或 local。兩份 Tonic app variant 加兩份 vendor builds 支援八種組合,無需針對每種組合重新編譯 app。Local Tonic 不可以帶入另一份 React/Emotion。

同一組 vendor entries 必須一次 multi-entry build。Vite 以共享 ESM chunks 維持 identity;Webpack 還需要 runtimeChunk: 'single',避免各 entry 使用獨立 module cache。App 與 widget 同樣共享 Tonic provider/context。

套件與 entry inventory

以下只列 browser runtime packages,包含目前使用的 internal subpaths。新入口必須加入 inventory;local build 出現 unmapped external,或 local Tonic 仍保留任何 @tonic-ui/* import,assembly 會失敗。

entries.mjs
export const localEntries = {
  react: {
    react: { specifier: 'react', file: 'entries/react.js' },
    'react-dom': { specifier: 'react-dom', file: 'entries/react-dom.js' },
    'react-dom-client': { specifier: 'react-dom/client', file: 'entries/react-dom-client.js' },
    'jsx-runtime': { specifier: 'react/jsx-runtime', file: 'entries/jsx-runtime.js' },
    'jsx-dev-runtime': { specifier: 'react/jsx-dev-runtime', file: 'entries/jsx-dev-runtime.js' },
  },
  emotion: {
    'emotion-react': { specifier: '@emotion/react', file: 'entries/emotion-react.js' },
    'emotion-jsx-runtime': { specifier: '@emotion/react/jsx-runtime', file: 'entries/emotion-jsx-runtime.js' },
    'emotion-jsx-dev-runtime': { specifier: '@emotion/react/jsx-dev-runtime', file: 'entries/emotion-jsx-dev-runtime.js' },
    'emotion-styled': { specifier: '@emotion/styled', file: 'entries/emotion-styled.js' },
    'emotion-styled-base': { specifier: '@emotion/styled/base', file: 'entries/emotion-styled-base.js' },
    'emotion-cache': { specifier: '@emotion/cache', file: 'entries/emotion-cache.js' },
    'emotion-is-prop-valid': { specifier: '@emotion/is-prop-valid', file: 'entries/emotion-is-prop-valid.js' },
  },
};

export const tonicPackages = [
  '@tonic-ui/react', '@tonic-ui/react-base', '@tonic-ui/react-hooks',
  '@tonic-ui/react-icons', '@tonic-ui/styled-system', '@tonic-ui/theme', '@tonic-ui/utils',
];
export const tonicSpecifiers = [
  ...tonicPackages, '@tonic-ui/react-base/internal', '@tonic-ui/utils/internal',
];
export const isReact = id => /^(react|react-dom)(\/|$)/.test(id);
export const isEmotion = id => id.startsWith('@emotion/');
export const isTonic = id => id.startsWith('@tonic-ui/');
建立薄 wrapper entries(新目錄使用一次)

在 demo 目錄保存以下內容為 create-entries.mjs,執行 node create-entries.mjs。Repository branch 已包含這些 entries,不需要再次建立。

import { mkdir, writeFile } from 'node:fs/promises';

const entries = {
  "react.js": "export * from 'react';\nexport { default } from 'react';\n",
  "react-dom.js": "export * from 'react-dom';\nexport { default } from 'react-dom';\n",
  "react-dom-client.js": "export * from 'react-dom/client';\n",
  "jsx-runtime.js": "export * from 'react/jsx-runtime';\n",
  "jsx-dev-runtime.js": "export * from 'react/jsx-dev-runtime';\n",
  "emotion-react.js": "export * from '@emotion/react';\n",
  "emotion-jsx-runtime.js": "export * from '@emotion/react/jsx-runtime';\n",
  "emotion-jsx-dev-runtime.js": "export * from '@emotion/react/jsx-dev-runtime';\n",
  "emotion-styled.js": "export * from '@emotion/styled';\nexport { default } from '@emotion/styled';\n",
  "emotion-styled-base.js": "export * from '@emotion/styled/base';\nexport { default } from '@emotion/styled/base';\n",
  "emotion-cache.js": "export * from '@emotion/cache';\nexport { default } from '@emotion/cache';\n",
  "emotion-is-prop-valid.js": "export * from '@emotion/is-prop-valid';\nexport { default } from '@emotion/is-prop-valid';\n"
};
await mkdir('entries', { recursive: true });
for (const [name, source] of Object.entries(entries)) {
  await writeFile(`entries/${name}`, source);
}

App 與 Widget:載入後才 mount

兩個普通 ESM entries 都使用同一張 import map。App export mount(element, widget),頂層不 mount;widget 的 hooks 與 theme 在 app 的同一個 TonicProvider 下執行。

src/app.js
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Box, Button, TonicProvider, useColorMode } from '@tonic-ui/react';

function App({ Widget }) {
  const [count, setCount] = useState(0);
  const [colorMode, setColorMode] = useColorMode();
  const toggleColorMode = () => setColorMode(value => value === 'dark' ? 'light' : 'dark');
  return React.createElement(Box, { p: '4x' },
    React.createElement('h2', null, 'Standalone CDN-first app'),
    React.createElement('p', { id: 'app-theme' }, `App theme: ${colorMode}`),
    React.createElement(Button, { variant: 'primary', onClick: () => setCount(value => value + 1), id: 'app-counter' }, `App clicks: ${count}`),
    React.createElement(Button, { ml: '2x', variant: 'secondary', onClick: toggleColorMode, id: 'theme-toggle' }, 'Toggle shared theme'),
    React.createElement(Widget));
}

export function mount(element, widget) {
  createRoot(element).render(React.createElement(TonicProvider, null,
    React.createElement(App, { Widget: widget.Widget })));
}
src/widget.js
import React, { useState } from 'react';
import styled from '@emotion/styled';
import { Box, Button, useColorMode } from '@tonic-ui/react';
import { CodeIcon } from '@tonic-ui/react-icons';

const Frame = styled.section({ border: '2px solid rgb(0, 128, 0)', padding: 16, marginTop: 16 });

export function Widget() {
  const [count, setCount] = useState(0);
  const [colorMode] = useColorMode();
  return React.createElement(Frame, { id: 'widget-frame' },
    React.createElement(Box, { display: 'flex', alignItems: 'center', mb: '2x' },
      React.createElement(CodeIcon, { mr: '2x' }),
      React.createElement('h3', null, 'Shared-runtime ESM widget')),
    React.createElement('p', { id: 'widget-theme' }, `Widget theme: ${colorMode}`),
    React.createElement(Button, { variant: 'primary', onClick: () => setCount(value => value + 1), id: 'widget-counter' }, `Widget clicks: ${count}`));
}

Vite 設定與 assembly

已以現有 Vite 6.4.3 執行。以下保留 mount/Widget exports,不以 HTML entry 自動啟動 app。React 的 CJS forwarding index 不能只依靠 export *;plugin 明確轉出所選版本的 named exports。

vite.config.mjs
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import { localEntries, isReact, isEmotion, isTonic } from './entries.mjs';

const root = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);

export default defineConfig(({ mode }) => {
  const vendor = localEntries[mode];
  if (!vendor && mode !== 'tonic-cdn' && mode !== 'tonic-local') {
    throw new Error(`Unknown runtime build: ${mode}`);
  }
  const input = vendor
    ? Object.fromEntries(Object.entries(vendor).map(([name, entry]) => [name, resolve(root, entry.file)]))
    : { app: resolve(root, 'src/app.js'), widget: resolve(root, 'src/widget.js') };
  const external = mode === 'react'
    ? undefined
    : mode === 'emotion'
      ? isReact
      : id => isReact(id) || isEmotion(id) || (mode === 'tonic-cdn' && isTonic(id));
  return {
    root,
    base: './',
    publicDir: false,
    plugins: mode === 'react'
      ? [{
          name: 'react-commonjs-named-exports',
          transform(code, id) {
            const entry = Object.values(localEntries.react).find(item => resolve(root, item.file) === id);
            if (!entry) {
              return null;
            }
            // React's CJS index forwards module.exports. export * alone loses its names.
            const names = Object.keys(require(entry.specifier)).filter(name => name !== 'default' && name !== '__esModule');
            return `${code}\nexport { ${names.join(', ')} } from '${entry.specifier}';\n`;
          },
        }]
      : [],
    build: {
      outDir: resolve(root, `../../public/demos/cdn-runtime/${mode}`),
      emptyOutDir: true,
      manifest: true,
      target: 'es2022',
      modulePreload: false,
      rollupOptions: { input, external, preserveEntrySignatures: 'strict' },
    },
  };
});

Assembly 從實際 emitted chunks 取得 entry/CSS,從已安裝 package metadata 取得 exact root versions。cdnUrl 不使用 latest。CDN 轉換時不把 Tonic 自己的 scope 全部 externalize:esm.sh 可能輸出未公開的 private self-import;app bundler 的 Tonic external 策略和 esm.sh 的 dependency query 不是同一件事。

react-focus-lock/dist/cjs 明確映射到同一個已安裝版本的 ESM root。這保留 focus-lock 功能,不是吞掉轉換例外。仍需產品的 modal/focus restoration 驗收。

build.mjs
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
import { build } from 'vite';
import { localEntries, tonicPackages, tonicSpecifiers, isTonic } from './entries.mjs';

const root = dirname(fileURLToPath(import.meta.url));
const outputRoot = resolve(root, '../../public/demos/cdn-runtime');
const require = createRequire(import.meta.url);
const packageName = specifier => specifier.startsWith('@')
  ? specifier.split('/').slice(0, 2).join('/')
  : specifier.split('/')[0];
const packages = [...new Set([
  ...Object.values(localEntries).flatMap(entries => Object.values(entries).map(entry => packageName(entry.specifier))),
  ...tonicPackages, 'react-focus-lock',
])];
const versions = Object.fromEntries(packages.map(name => [name, require(`${name}/package.json`).version]));
if (versions.react !== versions['react-dom']) {
  throw new Error('React and React DOM versions must match');
}

function cdnUrl(specifier, external = []) {
  const name = packageName(specifier);
  const subpath = specifier.slice(name.length);
  const query = new URLSearchParams({ target: 'es2022' });
  if (external.length) {
    query.set('external', external.join(','));
  }
  return `https://esm.sh/${name}@${versions[name]}${subpath}?${query}`;
}

const groups = {};
const allowedImports = new Set([
  ...Object.values(localEntries).flatMap(entries => Object.values(entries).map(entry => entry.specifier)),
  ...tonicSpecifiers,
]);
const apps = {};

for (const mode of ['react', 'emotion', 'tonic-cdn', 'tonic-local']) {
  const result = await build({ configFile: resolve(root, 'vite.config.mjs'), mode });
  const chunks = result.output.filter(item => item.type === 'chunk');
  const files = new Set(chunks.map(chunk => chunk.fileName));
  for (const chunk of chunks) {
    for (const id of [...chunk.imports, ...chunk.dynamicImports]) {
      if (mode === 'tonic-local' && isTonic(id)) {
        throw new Error(`Local Tonic build still imports ${id}`);
      }
      if (!files.has(id) && !allowedImports.has(id)) {
        throw new Error(`Unmapped runtime import: ${id}`);
      }
    }
  }
  const entryChunks = new Map(chunks.filter(chunk => chunk.isEntry).map(chunk => [chunk.name, chunk]));
  const descriptor = name => {
    const entry = entryChunks.get(name);
    if (!entry) {
      throw new Error(`Missing ${mode} entry: ${name}`);
    }
    const css = new Set();
    const seen = new Set();
    function collect(chunk) {
      if (seen.has(chunk.fileName)) {
        return;
      }
      seen.add(chunk.fileName);
      for (const file of chunk.viteMetadata.importedCss) {
        css.add(`./${mode}/${file}`);
      }
      for (const file of chunk.imports) {
        const dependency = chunks.find(item => item.fileName === file);
        if (dependency) {
          collect(dependency);
        }
      }
    }
    collect(entry);
    return { entry: `./${mode}/${entry.fileName}`, css: [...css] };
  };
  if (localEntries[mode]) {
    const imports = Object.fromEntries(Object.entries(localEntries[mode]).map(([name, entry]) => [entry.specifier, descriptor(name).entry]));
    groups[mode] = { local: { imports, probes: Object.keys(imports) }, cdn: null };
  } else {
    apps[mode === 'tonic-cdn' ? 'cdn' : 'local'] = { app: descriptor('app'), widget: descriptor('widget') };
  }
}

const reactExternal = ['react', 'react-dom'];
const emotionExternal = [...reactExternal, '@emotion/react', '@emotion/styled', '@emotion/cache', '@emotion/is-prop-valid'];
for (const group of ['react', 'emotion']) {
  const probes = groups[group].local.probes;
  const imports = Object.fromEntries(probes.map(specifier => {
    const external = group === 'react'
      ? specifier === 'react' ? [] : reactExternal
      : packageName(specifier) === '@emotion/styled'
        ? [...reactExternal, '@emotion/react']
        : reactExternal;
    return [specifier, cdnUrl(specifier, external)];
  }));
  groups[group].cdn = { imports, probes };
}
// Do not externalize a package's own scope: esm.sh may expose private self-imports.
const tonicExternal = [...emotionExternal, 'react-focus-lock'];
const tonicImports = Object.fromEntries(tonicSpecifiers.map(specifier => [specifier, cdnUrl(specifier, tonicExternal)]));
tonicImports['react-focus-lock/dist/cjs'] = cdnUrl('react-focus-lock', reactExternal);
groups.tonic = {
  local: { imports: {}, probes: [] },
  cdn: { imports: tonicImports, probes: tonicSpecifiers },
};

await mkdir(outputRoot, { recursive: true });
await writeFile(resolve(outputRoot, 'runtime-manifest.json'), `${JSON.stringify({ versions, groups, apps }, null, 2)}\n`);
for (const name of ['index.html', 'bootstrap.js']) {
  await copyFile(resolve(root, name), resolve(outputRoot, name));
}
console.log('Built isolated CDN-first demo with paired local vendors and two Tonic app variants.');

從 demo 目錄可執行 node build.mjs;repository 專用命令指向同一個 script。

Webpack 5 的等價設定

保存以下內容為 demo 目錄的 webpack.config.mjs,重用上面的 entries、app、widget。產品原有 JSX/TSX、CSS loaders 與 plugins 應保留。externalsType: 'module' 使用 native ESM import,不依賴 window.React。

import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { localEntries, isReact, isEmotion, isTonic } from './entries.mjs';

const root = dirname(fileURLToPath(import.meta.url));
export default (env = {}) => {
  const mode = env.runtime || 'tonic-local';
  const vendor = localEntries[mode];
  if (!vendor && mode !== 'tonic-cdn' && mode !== 'tonic-local') {
    throw new Error(`Unknown runtime build: ${mode}`);
  }
  const entry = vendor
    ? Object.fromEntries(Object.entries(vendor).map(([name, item]) => [name, resolve(root, item.file)]))
    : { app: resolve(root, 'src/app.js'), widget: resolve(root, 'src/widget.js') };
  const external = mode === 'react'
    ? () => false
    : mode === 'emotion'
      ? isReact
      : id => isReact(id) || isEmotion(id) || (mode === 'tonic-cdn' && isTonic(id));
  return {
    mode: 'production',
    context: root,
    target: ['web', 'es2022'],
    entry,
    experiments: { outputModule: true },
    output: {
      path: resolve(root, `../../public/demos/cdn-runtime-webpack/${mode}`),
      filename: '[name].[contenthash].mjs',
      chunkFilename: '[name].[contenthash].mjs',
      publicPath: 'auto',
      module: true,
      library: { type: 'module' },
      clean: true,
    },
    externalsType: 'module',
    externals: [({ request }, callback) => {
      if (request && external(request)) {
        callback(null, request, 'module');
        return;
      }
      callback();
    }],
    optimization: {
      runtimeChunk: 'single',
      splitChunks: { chunks: 'all', minSize: 0 },
    },
  };
};

以產品既有的 Webpack 5/CLI 執行,不在此安裝或升級工具:

npx --no-install webpack --config webpack.config.mjs --env runtime=react --json=react-stats.json
npx --no-install webpack --config webpack.config.mjs --env runtime=emotion --json=emotion-stats.json
npx --no-install webpack --config webpack.config.mjs --env runtime=tonic-cdn --json=tonic-cdn-stats.json
npx --no-install webpack --config webpack.config.mjs --env runtime=tonic-local --json=tonic-local-stats.json

Webpack 與 Vite assembly 使用相同 descriptor contract,但不要用 Vite 的 build script 讀 Webpack 輸出:

  1. 每個 stats 的 entrypoints[name].assets 取出以該 entry name 開頭的 entry .mjs,並收集 CSS。Native entry imports 會載入其 runtime/shared chunks;部署必須保留全部輸出。
  2. 依相同 inventory 建立 groups.react.local.imports、groups.emotion.local.imports,並填入兩種 apps entries/CSS。
  3. CDN URLs 與 startup probes 使用同一份 installed-version metadata。Webpack asset URLs 改為相對於同源 manifest 的 cdn-runtime-webpack/ 輸出。
  4. 將 bootstrap、HTML、descriptor 與四組完整 output 一起原子部署。不要只上傳 entry,或將兩次 app build 的 manifest 混用。

本次使用 Next 所附 Webpack 5.98.0 API 產生四組 ESM output 並在 browser 驗證,不是 Webpack CLI production 驗收。環境缺少該 bundled API 所要求的 Terser plugin,原始 minification 執行失敗;smoke 明確關閉 minification,未改上面產品 config 的預設值。正式 Webpack/minifier 流程仍須在產品既有工具鏈執行。

Runtime descriptor 與啟動規則

runtime-manifest.json 由 assembly 生成,不手寫 hashed filenames:

欄位 內容
versions 已安裝 root package 的 exact versions
groups.react/groups.emotion/groups.tonic 各組的 local 與 cdn descriptor
各 descriptor 的 imports Bare specifier → 相對同源 entry 或 exact CDN URL
各 descriptor 的 probes 啟動時真正 dynamic import 的 entry inventory
apps.cdn/apps.local 對應 Tonic source 的 app/widget entry 與啟動 CSS

某組未配對或未核准的 CDN descriptor 設為 null。Local Tonic 的 imports/probes 為空,因為所有 Tonic code 已在 local app/widget chunks;React/Emotion 仍由其 group maps 決定。

Bootstrap 先安裝一張完整 import map,再依序 probe React、Emotion、Tonic。某組 CDN 失敗時只加入該組到 __local,重新載入後丟棄舊 realm。其他組維持 CDN-first;但它們仍可能因自己的 network/budget 失敗而獨立改用 local。不能要求真實公開網路測試中的其他組必定成功。

示範的 budget 是每組 8 秒,不是存在性檢查或 production SLA;冷建置/無快取下載可能超時。Native imports 不能取消或原地換綁定。Local、app assets、CSS、mount errors 顯示錯誤,不做自動 fallback。後續任意 lazy imports 不在此示範涵蓋範圍。

HTML 與 Bootstrap

Classic bootstrap 必須先於任何 app module/CDN preload 執行。CSP nonce 可放在 bootstrap script,會傳給動態 import map;仍須配置產品的 script/style policy。以下 HTML 含故障選單、來源表、log 與 descriptor 連結:

index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tonic UI CDN-first runtime demo</title>
  <style>
    body { margin: 0; padding: 16px; font: 14px/1.5 system-ui, sans-serif; color: #20242a; background: #fff; }
    h1 { margin: 0 0 8px; font-size: 22px; }
    label, select, button { font: inherit; }
    select, button { margin: 4px 8px 4px 0; padding: 6px; }
    table { width: 100%; border-collapse: collapse; margin: 12px 0; }
    th, td { border: 1px solid #c8ced5; padding: 8px; text-align: left; }
    pre { white-space: pre-wrap; overflow-wrap: anywhere; background: #f5f6f8; padding: 12px; }
    #startup-error { color: #b00020; }
  </style>
</head>
<body>
  <h1>CDN-first runtime groups</h1>
  <p>This standalone demo shows esm.sh CDN modules and local build artifacts. The app and its ESM widget share one selected runtime per family. No Module Federation or Wujie is used.</p>
  <label for="scenario">Failure scenario</label>
  <select id="scenario">
    <option value="cdn-first">CDN-first (real esm.sh modules)</option>
    <option value="tonic-package">One Tonic package fails: react-icons</option>
    <option value="tonic-internal">One Tonic internal subpath fails</option>
    <option value="tonic-dependency">One Tonic transitive dependency fails: focus-lock</option>
    <option value="react-package">React DOM client fails</option>
    <option value="emotion-package">Emotion styled/base fails</option>
    <option value="no-tonic-cdn">No paired Tonic CDN descriptor</option>
    <option value="all-cdn-fail">All three CDN groups fail</option>
    <option value="all-local">Use local for all groups</option>
  </select>
  <button id="retry" type="button">Retry from CDN-first</button>
  <table aria-label="Selected runtime sources">
    <thead><tr><th>Atomic family</th><th>Selected source</th></tr></thead>
    <tbody>
      <tr><th scope="row">React + React DOM + JSX runtimes</th><td id="react-source">Selecting...</td></tr>
      <tr><th scope="row">Emotion + its mapped subpaths</th><td id="emotion-source">Selecting...</td></tr>
      <tr><th scope="row">All @tonic-ui/*</th><td id="tonic-source">Selecting...</td></tr>
    </tbody>
  </table>
  <div id="root"></div>
  <p id="startup-error" role="alert" hidden>Cannot start the demo. Check the load log and browser console.</p>
  <details open><summary>Load log</summary><pre id="runtime-log" aria-live="polite"></pre></details>
  <details><summary>Exact package versions from this build</summary><pre id="versions"></pre></details>
  <p><a href="./runtime-manifest.json" target="_blank" rel="noopener noreferrer">Inspect the generated runtime descriptor and esm.sh URLs</a></p>
  <p>Failure presets map a chosen CDN import to an intentionally missing same-origin module. Use DevTools with cache disabled to test a real CDN outage. Group failures reload this iframe before mounting; they do not change the parent docs runtime.</p>
  <script defer src="./bootstrap.js" data-manifest="./runtime-manifest.json"></script>
</body>
</html>
bootstrap.js
(() => {
  const script = document.currentScript;
  const manifestUrl = new URL(script.dataset.manifest, location.href);
  const nonce = script.nonce;
  const groupNames = ['react', 'emotion', 'tonic'];
  const url = new URL(location.href);
  const forced = new Set((url.searchParams.get('__local') || '').split(',').filter(group => groupNames.includes(group)));
  const scenario = url.searchParams.get('scenario') || 'cdn-first';
  const log = document.getElementById('runtime-log');
  const scenarioControl = document.getElementById('scenario');
  scenarioControl.value = scenario;
  scenarioControl.addEventListener('change', () => {
    const next = new URL(location.href);
    next.searchParams.set('scenario', scenarioControl.value);
    next.searchParams.delete('__local');
    next.searchParams.delete('__failed');
    location.assign(next);
  });
  document.getElementById('retry').addEventListener('click', () => {
    const next = new URL(location.href);
    next.searchParams.delete('__local');
    next.searchParams.delete('__failed');
    location.assign(next);
  });

  function note(message) {
    log.textContent += `${message}\n`;
  }
  const failed = (url.searchParams.get('__failed') || '').split(',').filter(group => groupNames.includes(group));
  if (failed.length) {
    note(`Previous document: CDN load failed for ${failed.join(', ')}. Reloaded before mounting.`);
  }

  function injectFailure(runtime) {
    const missing = new URL('./deliberately-missing-module.js', manifestUrl).href;
    const fail = (group, specifier) => {
      runtime.groups[group].cdn.imports[specifier] = missing;
    };
    if (scenario === 'tonic-package') {
      fail('tonic', '@tonic-ui/react-icons');
    }
    if (scenario === 'tonic-internal') {
      fail('tonic', '@tonic-ui/react-base/internal');
    }
    if (scenario === 'tonic-dependency') {
      fail('tonic', 'react-focus-lock/dist/cjs');
    }
    if (scenario === 'react-package') {
      fail('react', 'react-dom/client');
    }
    if (scenario === 'emotion-package') {
      fail('emotion', '@emotion/styled/base');
    }
    if (scenario === 'all-cdn-fail') {
      fail('react', 'react-dom/client');
      fail('emotion', '@emotion/styled/base');
      fail('tonic', '@tonic-ui/react-icons');
    }
    if (scenario === 'no-tonic-cdn') {
      runtime.groups.tonic.cdn = null;
    }
    if (scenario === 'all-local') {
      groupNames.forEach(group => forced.add(group));
    }
    if (scenario !== 'cdn-first') {
      note(`Demonstration scenario: ${scenario}. Missing-module scenarios inject a real same-origin module 404, not a simulated successful import.`);
    }
  }

  function withBudget(promise) {
    let timer;
    const timeout = new Promise((resolve, reject) => {
      timer = setTimeout(() => reject(new Error('CDN load budget exceeded (8 seconds)')), 8000);
    });
    return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
  }

  function loadCss(files) {
    return Promise.all(files.map(file => new Promise((resolve, reject) => {
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      link.href = new URL(file, manifestUrl).href;
      link.onload = resolve;
      link.onerror = () => reject(new Error(`Cannot load ${link.href}`));
      document.head.append(link);
    })));
  }

  async function run() {
    const response = await fetch(manifestUrl);
    if (!response.ok) {
      throw new Error(`Cannot load runtime manifest: ${response.status}`);
    }
    const runtime = await response.json();
    injectFailure(runtime);
    const selected = Object.fromEntries(groupNames.map(group => [group,
      !forced.has(group) && runtime.groups[group].cdn !== null ? 'cdn' : 'local',
    ]));
    const imports = {};
    for (const group of groupNames) {
      const candidate = runtime.groups[group][selected[group]];
      for (const [specifier, target] of Object.entries(candidate.imports)) {
        if (Object.hasOwn(imports, specifier)) {
          throw new Error(`Conflicting import map entry: ${specifier}`);
        }
        imports[specifier] = new URL(target, manifestUrl).href;
      }
      document.getElementById(`${group}-source`).textContent = selected[group] === 'cdn' ? 'CDN (esm.sh)' : 'Local (same origin)';
      document.body.dataset[group] = selected[group];
    }
    document.getElementById('versions').textContent = JSON.stringify(runtime.versions, null, 2);
    const map = document.createElement('script');
    map.type = 'importmap';
    if (nonce) {
      map.nonce = nonce;
    }
    map.textContent = JSON.stringify({ imports });
    document.head.append(map);

    // Probe complete families in order. Emotion/Tonic imports use the chosen React,
    // and Tonic imports use the chosen Emotion through this one immutable map.
    for (const group of groupNames) {
      const candidate = runtime.groups[group][selected[group]];
      document.body.dataset.stage = group;
      note(`Loading ${group}: ${selected[group]}...`);
      try {
        const loading = Promise.all(candidate.probes.map(specifier => import(specifier)));
        await (selected[group] === 'cdn' ? withBudget(loading) : loading);
      } catch (error) {
        console.error(`Cannot load ${selected[group]} ${group} runtime`, error);
        if (selected[group] === 'local') {
          throw error;
        }
        forced.add(group);
        const next = new URL(location.href);
        next.searchParams.set('__local', groupNames.filter(name => forced.has(name)).join(','));
        next.searchParams.set('__failed', [...new Set([...failed, group])].join(','));
        // Native imports cannot be cancelled or safely rebound. Discard this realm.
        location.replace(next);
        return;
      }
      note(`Ready: ${group} (${selected[group]}).`);
    }
    const assets = runtime.apps[selected.tonic];
    document.body.dataset.stage = 'app';
    await loadCss([...new Set([...assets.app.css, ...assets.widget.css])]);
    const [app, widget] = await Promise.all([
      import(new URL(assets.app.entry, manifestUrl).href),
      import(new URL(assets.widget.entry, manifestUrl).href),
    ]);
    // App/CSS/mount failures are not evidence that a runtime family is unavailable.
    await app.mount(document.getElementById('root'), widget);
    document.body.dataset.stage = 'mounted';
    note('App and widget loaded. Both use the same selected React, Emotion and Tonic runtime.');
  }

  run().catch(error => {
    console.error(error);
    document.body.dataset.stage = 'error';
    note(`Startup error: ${error.message}`);
    document.getElementById('startup-error').hidden = false;
  });
})();

故障 presets 將指定 import 改為同源不存在的 module,實際得到 404/import rejection;不是宣稱 esm.sh outage。測試真實 outage 時須停用 browser cache,再封鎖對應 CDN requests。

已觀察的驗證

示範直接版本:Tonic UI 3.0.0-alpha.1、React/React DOM 19.2.7、Emotion React 11.14.0、Emotion Styled 11.14.1。以下不是後面 React 18 PoC 的同一張 map。

檢查 觀察結果
Vite 四組 build 與 scoped ESLint build 成功;修正新 config 的 indentation 後 lint 無錯誤/warnings
八種 React/Emotion/Tonic CDN-local 組合 兩個 counters 可互動;共享 theme 一起改變;Emotion 綠色 border 正常;Network 無同組混載
Tonic package、internal subpath、focus-lock 依賴故障 presets Tonic 整組 local,React/Emotion 維持 CDN
React 或 Emotion 故障 preset 只切換該組,其他組維持 CDN
無 Tonic CDN descriptor/三組故障/全 local 選擇正確;最終全 local document 不請求 esm.sh modules
Tonic module evaluation 不完成 8 秒 budget 後重載一次,只切換 Tonic;新 document 不請求 CDN Tonic
Local Emotion module 缺失/app mount 主動拋錯 顯示 startup error,不再重載或自動切換其他組
Webpack 全 local 與兩種相反的混合來源 真實 Webpack ESM output 可 mount;兩個 counters、共享 theme、Emotion border 正常;全 local 無 CDN requests
Docs 展示頁內選擇 Tonic package 故障 只有 iframe 重載;父 docs URL 不變;React/Emotion CDN、Tonic local;兩個 counters 與共享 theme 正常

額外無快取公開 CDN 封鎖測試在 focus-lock case 觀察到 React 也獨立載入失敗,最終為 React local/Emotion CDN/Tonic local。該 run 的固定期望值 assertion 失敗,沒有宣稱整個真實 outage matrix 通過,也沒有擴大 Tonic fallback 範圍或提高 budget 來掩蓋結果。

固定 root versions 不等於固定全部遞迴 dependencies。本例不宣稱完成 SSR、全部 components/modals、後續 lazy routes、production module graph audit 或完整產品 release gates。

獨立的直接 CDN PoC

以下舊 PoC 保留作為 import 格式示範;沒有 local fallback。一般產品使用上面的分組 vendor builds、兩種 Tonic app 與 bootstrap,不將 React 18 PoC map 配給 React 19 local build。

Tonic UI:可操作的公開 esm.sh 範例

直接使用已發布套件

本範例使用:Tonic UI 3.0.0-alpha.1、React/React DOM 18.3.1、Emotion 11.14.0。這是固定的 PoC 組合,不代表 alpha 版本已通過產品上線驗收。

將以下內容保存為獨立 PoC 目錄的 index.html。不需要 npm install、不使用 JSX,也不會把應用原始碼傳到遠端轉譯服務。

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tonic UI ESM CDN PoC</title>
  <script type="importmap">
  {
    "imports": {
      "react": "https://esm.sh/react@18.3.1?target=es2022",
      "react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime?target=es2022",
      "react-dom": "https://esm.sh/react-dom@18.3.1?external=react&target=es2022",
      "react-dom/client": "https://esm.sh/react-dom@18.3.1/client?external=react&target=es2022",
      "@emotion/react": "https://esm.sh/@emotion/react@11.14.0?external=react&target=es2022",
      "@emotion/react/jsx-runtime": "https://esm.sh/@emotion/react@11.14.0/jsx-runtime?external=react&target=es2022",
      "@emotion/styled": "https://esm.sh/@emotion/styled@11.14.0?external=react,@emotion/react&target=es2022",
      "@emotion/styled/base": "https://esm.sh/@emotion/styled@11.14.0/base?external=react,@emotion/react&target=es2022",
      "react-focus-lock/dist/cjs": "https://esm.sh/react-focus-lock@2.13.7?external=react&target=es2022",
      "@tonic-ui/react": "https://esm.sh/@tonic-ui/react@3.0.0-alpha.1?external=react,react-dom,@emotion/react,@emotion/styled,react-focus-lock&target=es2022"
    }
  }
  </script>
</head>
<body>
  <div id="root"></div>
  <script type="module">
    import React, { useState } from 'react';
    import { createRoot } from 'react-dom/client';
    import { Button, TonicProvider } from '@tonic-ui/react';

    function Demo() {
      const [count, setCount] = useState(0);
      return React.createElement(
        TonicProvider,
        null,
        React.createElement(
          Button,
          { onClick: () => setCount(value => value + 1) },
          `Clicked ${count} times`
        )
      );
    }

    createRoot(document.getElementById('root')).render(
      React.createElement(Demo)
    );
  </script>
</body>
</html>

在保存 HTML 的目錄啟動靜態 server;Python 需已安裝:

python3 -m http.server 4173 --bind 127.0.0.1

開啟 http://127.0.0.1:4173/。驗收:按鈕初始顯示 Clicked 0 times;每次點擊加一;Console 無 module resolution 或 invalid hook call 錯誤;Emotion style 已注入。

已驗證的相容性設定

這個版本的 Tonic UI 會要求 react-focus-lock/dist/cjs。本次瀏覽器 smoke 中,該 CJS 路徑的轉換產物在 Babel helper interop 發生 TypeError: x is not a function。範例將 react-focus-lock 保留 external,並由 import map 將其 CJS specifier 解析到同套件固定版本 2.13.7 的 ESM root。

這是明確的 entry routing,不是忽略例外,也不是移除 focus-lock。Production lock/graph 必須記錄這個 mapping,並驗證 modal/focus restoration 等實際功能後才能發布;Button smoke 不代表所有 focus-lock 行為已通過。

同樣必須映射 @emotion/styled/base;只映射 @emotion/styled 不會自動解析這個 subpath。

本次 smoke 證據

  • 在真實 Chromium 載入本頁所附 HTML,使用公開 esm.sh。
  • 按鈕由 Clicked 0 times 點擊後變成 Clicked 1 times。
  • 觀察到一個 Emotion style element;修正後該次頁面沒有 browser error。
  • 這是載入/hook/click smoke,尚未完成 theme 切換、modal、charts 或 production release gates。

HTTP 建置探測可另外執行:

curl --fail --show-error --silent --max-time 120 -i \
  'https://esm.sh/@tonic-ui/react@3.0.0-alpha.1?target=es2022&external=react,react-dom,@emotion/react,@emotion/styled'

HTTP 200 不是 UI 驗收。還需追蹤 wrapper 的 imports 與實際 module graph,並觀察元件互動。

需要修改 Tonic UI 時的套件建置

直接使用已發布版本時可跳過此節。需要修改套件時,在 Tonic UI repository 根目錄執行:

yarn install --immutable
yarn build-public
yarn lint
yarn test

環境需已有 repository 指定的 Yarn 4.17.1 與相容 Node。build-public 使用現有 Lerna 流程建置非 private workspaces;lint/test 依現有 root scripts 執行。

現有 React package 產出 dist/esm、dist/cjs 與 types,Rollup 將 bare imports 保留為 external。新增 exports 時,必須保留既有可用入口,並驗證 internal subpaths;不要為了 CDN 而任意公開整個 source tree。

本地 build 不會自動讓公開 esm.sh 取得修改。 修改後的版本必須經既有、獲授權的 package release 流程發布,或在內部 registry 驗證。本文不執行 npm publish、commit、push 或 deploy。

Tonic UI 參考