From b7f8a2006d698c7891a6dcbf556539bd0521ecb2 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Wed, 8 Jul 2026 23:57:14 +0300 Subject: [PATCH 01/11] Fix stripe version --- packages/plugins/src/payment/stripe/stripe.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/src/payment/stripe/stripe.ts b/packages/plugins/src/payment/stripe/stripe.ts index 26ba6e2a8..05c447545 100644 --- a/packages/plugins/src/payment/stripe/stripe.ts +++ b/packages/plugins/src/payment/stripe/stripe.ts @@ -14,7 +14,7 @@ if (STRIPE_SECRET) { try { const { default: Stripe } = await import('stripe'); stripe = new Stripe(STRIPE_SECRET, { - apiVersion: '2026-04-22.dahlia', + apiVersion: '2026-06-24.dahlia', }); } catch { logger.warn(`optional peer npm package 'stripe' not installed, stripe adapter will not work`); From 15987384ee433a53bd14d84f6d20b0c83d0c3f4c Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Mon, 6 Jul 2026 17:00:59 +0300 Subject: [PATCH 02/11] Change admin ui plugin from using IIFE to esm module loading --- admin-ui/package.json | 4 + .../src/modules/plugins/PluginContext.tsx | 149 ++++++++++----- admin-ui/src/sdk/plugin-build.mjs | 179 ++++++++---------- admin-ui/src/sdk/plugin-runtime.d.mts | 8 + admin-ui/src/sdk/plugin-runtime.mjs | 69 +++++++ admin-ui/src/sdk/shims/admin-ui-plugins.ts | 6 + admin-ui/src/sdk/shims/apollo-client-react.ts | 17 ++ admin-ui/src/sdk/shims/apollo-client.ts | 18 ++ admin-ui/src/sdk/shims/formik.ts | 22 +++ admin-ui/src/sdk/shims/host.ts | 18 ++ admin-ui/src/sdk/shims/next-head.ts | 5 + admin-ui/src/sdk/shims/next-image.ts | 5 + admin-ui/src/sdk/shims/next-link.ts | 5 + admin-ui/src/sdk/shims/next-router.ts | 9 + admin-ui/src/sdk/shims/react-dom-client.ts | 6 + admin-ui/src/sdk/shims/react-dom.ts | 13 ++ admin-ui/src/sdk/shims/react-intl.ts | 22 +++ admin-ui/src/sdk/shims/react-jsx-runtime.ts | 7 + admin-ui/src/sdk/shims/react-toastify.ts | 11 ++ admin-ui/src/sdk/shims/react.ts | 43 +++++ admin-ui/tsup.config.ts | 15 ++ examples/kitchensink-express/src/boot.ts | 2 +- examples/kitchensink/src/boot.ts | 2 +- packages/api/src/adminUiPlugins.ts | 140 ++++++++------ packages/api/src/express/index.ts | 46 +++-- packages/api/src/fastify/index.ts | 73 +++++-- 26 files changed, 655 insertions(+), 239 deletions(-) create mode 100644 admin-ui/src/sdk/plugin-runtime.d.mts create mode 100644 admin-ui/src/sdk/plugin-runtime.mjs create mode 100644 admin-ui/src/sdk/shims/admin-ui-plugins.ts create mode 100644 admin-ui/src/sdk/shims/apollo-client-react.ts create mode 100644 admin-ui/src/sdk/shims/apollo-client.ts create mode 100644 admin-ui/src/sdk/shims/formik.ts create mode 100644 admin-ui/src/sdk/shims/host.ts create mode 100644 admin-ui/src/sdk/shims/next-head.ts create mode 100644 admin-ui/src/sdk/shims/next-image.ts create mode 100644 admin-ui/src/sdk/shims/next-link.ts create mode 100644 admin-ui/src/sdk/shims/next-router.ts create mode 100644 admin-ui/src/sdk/shims/react-dom-client.ts create mode 100644 admin-ui/src/sdk/shims/react-dom.ts create mode 100644 admin-ui/src/sdk/shims/react-intl.ts create mode 100644 admin-ui/src/sdk/shims/react-jsx-runtime.ts create mode 100644 admin-ui/src/sdk/shims/react-toastify.ts create mode 100644 admin-ui/src/sdk/shims/react.ts diff --git a/admin-ui/package.json b/admin-ui/package.json index e68dea540..1469c465b 100644 --- a/admin-ui/package.json +++ b/admin-ui/package.json @@ -29,6 +29,10 @@ }, "./theme": "./src/sdk/theme.ts", "./plugins": "./src/sdk/plugins.ts", + "./plugin-runtime": { + "types": "./src/sdk/plugin-runtime.d.mts", + "import": "./src/sdk/plugin-runtime.mjs" + }, "./plugin-build": { "import": "./src/sdk/plugin-build.mjs" }, diff --git a/admin-ui/src/modules/plugins/PluginContext.tsx b/admin-ui/src/modules/plugins/PluginContext.tsx index b9532190b..f3c21a01e 100644 --- a/admin-ui/src/modules/plugins/PluginContext.tsx +++ b/admin-ui/src/modules/plugins/PluginContext.tsx @@ -6,22 +6,25 @@ import React, { type ReactNode, } from 'react'; import * as jsxRuntime from 'react/jsx-runtime'; -import { gql } from '@apollo/client'; -import { - useQuery, - useMutation, - useLazyQuery, - useApolloClient, -} from '@apollo/client/react'; -import { useRouter } from 'next/router'; -import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; -import { toast } from 'react-toastify'; +import * as ReactDOM from 'react-dom'; +import * as ReactDOMClient from 'react-dom/client'; +import * as ApolloClient from '@apollo/client'; +import * as ApolloClientReact from '@apollo/client/react'; +import * as NextRouter from 'next/router'; +import NextLink from 'next/link'; +import NextImage from 'next/image'; +import NextHead from 'next/head'; +import * as ReactIntl from 'react-intl'; +import * as ReactToastify from 'react-toastify'; +import * as Formik from 'formik'; +import { definePlugin } from '../../sdk/plugins'; import { usePluginRuntime } from './PluginRuntimeContext'; declare global { interface Window { __UNCHAINED_PLUGIN_DEPS__: Record; - __UNCHAINED_PLUGINS__: Record>; + /** Legacy registry used by pre-ESM (IIFE) plugin bundles. */ + __UNCHAINED_PLUGINS__?: Record>; } } @@ -73,42 +76,91 @@ const getPluginBaseUrl = () => { } }; +/** + * Expose the host app's module instances to plugin bundles. Plugin bundles are + * standard ESM with shared dependencies left external; the browser import map + * resolves those bare specifiers to shim modules that re-export the instances + * registered here, so plugins run on the exact same React/Apollo as the host. + */ const setupPluginRuntime = () => { if (typeof window === 'undefined') return; if (window.__UNCHAINED_PLUGIN_DEPS__) return; - window.__UNCHAINED_PLUGINS__ = {}; window.__UNCHAINED_PLUGIN_DEPS__ = { react: React, 'react/jsx-runtime': jsxRuntime, - '@apollo/client': { - gql, - useQuery, - useMutation, - useLazyQuery, - useApolloClient, - }, - '@apollo/client/react': { - useQuery, - useMutation, - useLazyQuery, - useApolloClient, - }, - 'next/router': { useRouter }, - 'react-intl': { useIntl, FormattedMessage, defineMessages }, - 'react-toastify': { toast }, - '@unchainedshop/admin-ui/plugins': { usePluginRuntime }, + 'react-dom': ReactDOM, + 'react-dom/client': ReactDOMClient, + '@apollo/client': ApolloClient, + '@apollo/client/react': ApolloClientReact, + 'next/router': NextRouter, + 'next/link': { default: NextLink }, + 'next/image': { default: NextImage }, + 'next/head': { default: NextHead }, + 'react-intl': ReactIntl, + 'react-toastify': ReactToastify, + formik: Formik, + '@unchainedshop/admin-ui/plugins': { definePlugin, usePluginRuntime }, }; }; -const loadPluginScript = (url: string): Promise => { - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = url; - script.onload = () => resolve(script); - script.onerror = () => reject(new Error(`Failed to load script: ${url}`)); - document.head.appendChild(script); +/** + * Make sure an import map covering the shared plugin dependencies is present + * before the first plugin module is imported. + * + * When the engine serves the admin-ui itself, it injects the import map into + * the HTML head and this is a no-op. When the admin-ui runs on another origin + * (e.g. `next dev`), the map is fetched from the engine and injected with + * absolute URLs. Import maps only apply to modules not yet resolved, which is + * guaranteed here because plugins are the only native ESM on the page. + */ +const ensureImportMap = async (baseUrl: string): Promise => { + if ( + document.querySelector('script[type="importmap"][data-unchained-admin-ui]') + ) + return; + + const res = await fetch(`${baseUrl}/admin-ui-importmap.json`, { + cache: 'no-cache', }); + if (!res.ok) { + throw new Error(`Failed to fetch import map: HTTP ${res.status}`); + } + const map = await res.json(); + const base = baseUrl || window.location.origin; + const imports: Record = {}; + for (const [specifier, target] of Object.entries(map?.imports || {})) { + if (typeof target !== 'string') continue; + imports[specifier] = new URL(target, base).href; + } + if (Object.keys(imports).length === 0) return; + + const script = document.createElement('script'); + script.type = 'importmap'; + script.setAttribute('data-unchained-admin-ui', ''); + script.textContent = JSON.stringify({ imports }); + document.head.appendChild(script); +}; + +const loadPluginModule = async ( + manifest: PluginManifest, + baseUrl: string, +): Promise => { + const url = new URL( + manifest.bundleUrl, + baseUrl || window.location.origin, + ).href; + const mod = await import(/* webpackIgnore: true */ url); + if (mod && Object.keys(mod).length > 0) return mod; + // Legacy IIFE bundles execute fine as modules but export nothing; they + // register themselves on the global registry instead. + const legacy = window.__UNCHAINED_PLUGINS__?.[manifest.name]; + if (legacy) return legacy; + console.error( + `Plugin "${manifest.name}" loaded but exports no components. ` + + 'Rebuild it with the current @unchainedshop/admin-ui/plugin-build.', + ); + return null; }; export const PluginProvider = ({ children }: { children: ReactNode }) => { @@ -118,7 +170,6 @@ export const PluginProvider = ({ children }: { children: ReactNode }) => { useEffect(() => { setupPluginRuntime(); - const scriptElements: HTMLScriptElement[] = []; let cancelled = false; (async () => { @@ -132,25 +183,21 @@ export const PluginProvider = ({ children }: { children: ReactNode }) => { return; } const data: PluginManifest[] = await res.json(); - if (cancelled) return; + if (cancelled || !Array.isArray(data) || data.length === 0) { + setLoading(false); + return; + } setManifests(data); + await ensureImportMap(baseUrl); + if (cancelled) return; + const loaded = new Map(); await Promise.all( data.map(async (manifest) => { try { - const script = await loadPluginScript( - `${baseUrl}${manifest.bundleUrl}`, - ); - scriptElements.push(script); - const mod = window.__UNCHAINED_PLUGINS__?.[manifest.name]; - if (mod) { - loaded.set(manifest.name, mod); - } else { - console.error( - `Plugin "${manifest.name}" loaded but did not register on window.__UNCHAINED_PLUGINS__`, - ); - } + const mod = await loadPluginModule(manifest, baseUrl); + if (mod) loaded.set(manifest.name, mod); } catch (err) { console.error(`Failed to load plugin "${manifest.name}":`, err); } @@ -166,8 +213,6 @@ export const PluginProvider = ({ children }: { children: ReactNode }) => { return () => { cancelled = true; - scriptElements.forEach((script) => script.remove()); - window.__UNCHAINED_PLUGINS__ = {}; }; }, []); diff --git a/admin-ui/src/sdk/plugin-build.mjs b/admin-ui/src/sdk/plugin-build.mjs index ad46cd69d..71068f246 100644 --- a/admin-ui/src/sdk/plugin-build.mjs +++ b/admin-ui/src/sdk/plugin-build.mjs @@ -1,112 +1,95 @@ +import { readFileSync } from 'node:fs'; import { defineConfig } from 'tsup'; +import { PLUGIN_EXTERNALS } from './plugin-runtime.mjs'; -const SHARED_DEPS = { - react: { - default: ['default'], - named: [ - 'useState', - 'useEffect', - 'useCallback', - 'useRef', - 'useMemo', - 'useContext', - 'useReducer', - 'useLayoutEffect', - 'createContext', - 'createElement', - 'Fragment', - 'Component', - 'forwardRef', - 'memo', - 'lazy', - 'Suspense', - 'Children', - 'cloneElement', - 'isValidElement', - 'createRef', - 'StrictMode', - ], - }, - 'react/jsx-runtime': { - named: ['jsx', 'jsxs', 'Fragment'], - }, - '@apollo/client': { - named: ['gql', 'useQuery', 'useMutation', 'useLazyQuery', 'useApolloClient'], - }, - '@apollo/client/react': { - named: ['useQuery', 'useMutation', 'useLazyQuery', 'useApolloClient'], - }, - 'next/router': { - named: ['useRouter'], - }, - 'react-intl': { - named: ['useIntl', 'FormattedMessage', 'defineMessages'], - }, - 'react-toastify': { - named: ['toast'], - }, - 'react-hook-form': { - named: ['useForm', 'useFormContext', 'useController', 'useFieldArray', 'useWatch', 'FormProvider', 'Controller'], - }, - '@unchainedshop/admin-ui/plugins': { - named: ['usePluginRuntime'], - }, -}; - -function generateShim(specifier) { - const config = SHARED_DEPS[specifier]; - if (!config) return ''; - - const lines = [ - `var __dep = (typeof window !== 'undefined' && window.__UNCHAINED_PLUGIN_DEPS__) ? window.__UNCHAINED_PLUGIN_DEPS__[${JSON.stringify(specifier)}] : {};`, - ]; - - if (config.default) { - lines.push(`export default __dep;`); - } - - if (config.named) { - for (const name of config.named) { - lines.push(`export var ${name} = __dep.${name};`); - } +// Version of the admin-ui SDK this plugin is built against, stamped into the +// bundle banner so the engine can warn about plugin/host version skew. +const SDK_VERSION = (() => { + try { + return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf-8')).version; + } catch { + return null; } +})(); - return lines.join('\n'); -} - -export const unchainedPluginShims = () => ({ - name: 'unchained-plugin-shims', - setup(build) { - const shimmable = Object.keys(SHARED_DEPS); - const filter = new RegExp( - `^(${shimmable.map((s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')).join('|')})$`, - ); - - build.onResolve({ filter }, (args) => ({ - path: args.path, - namespace: 'unchained-shim', - })); - - build.onLoad({ filter: /.*/, namespace: 'unchained-shim' }, (args) => ({ - contents: generateShim(args.path), - loader: 'js', - })); - }, -}); - +/** + * Build configuration for admin-ui plugins. + * + * Plugins are compiled to a single standard ESM bundle. Dependencies provided + * by the host admin-ui (react, @apollo/client, @unchainedshop/admin-ui/*, ...) + * are left as bare import specifiers; at runtime the admin-ui resolves them + * through a browser import map to the host's own module instances, so plugins + * share one React/Apollo instance with the app. Everything else (plugin-local + * dependencies) is bundled in. + * + * The bundle is loaded by the admin-ui with a native dynamic import(); its + * module exports are the components referenced from the plugin manifest. + */ export function definePluginConfig(pluginName, entry = 'src/index.tsx') { return defineConfig({ entry: { index: entry }, - format: ['iife'], + format: ['esm'], + platform: 'browser', + target: 'es2022', outDir: 'dist', - outExtension: () => ({ js: '.global.js' }), + outExtension: () => ({ js: '.js' }), dts: false, splitting: false, + sourcemap: false, clean: true, - globalName: '_pluginExports', - footer: { - js: `if(typeof window!=='undefined'){window.__UNCHAINED_PLUGINS__=window.__UNCHAINED_PLUGINS__||{};window.__UNCHAINED_PLUGINS__[${JSON.stringify(pluginName)}]=_pluginExports;}`, + banner: { + js: `/* unchained admin-ui plugin: ${JSON.stringify(pluginName)} (esm${SDK_VERSION ? `, sdk ${SDK_VERSION}` : ''}) */`, }, - esbuildPlugins: [unchainedPluginShims()], + esbuildPlugins: [handleExternals(PLUGIN_EXTERNALS)], }); } + +const SHIM_NS = 'unchained-cjs-esm-bridge'; + +/** + * Unified esbuild plugin that handles shared dependencies declared as external. + * + * ESM imports (kind "import-statement") are marked external so the bare + * specifier is preserved in the output for the browser import map. + * + * CJS require() calls (kind "require-call") from bundled CJS dependencies + * (e.g. @unchainedshop/client) are redirected to virtual ESM re-export modules. + * Without this, esbuild wraps them as __require() which throws in the browser. + * + * This plugin replaces both tsup's built-in `external` option (which cannot + * distinguish import kinds) and the previous rewriteCjsExternals plugin. + */ +function handleExternals(externals) { + const isExternal = (id) => + externals.some((ext) => + ext instanceof RegExp ? ext.test(id) : ext === id, + ); + + return { + name: 'unchained-handle-externals', + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + if (!isExternal(args.path)) return undefined; + + if (args.kind === 'require-call' || args.kind === 'require-resolve') { + return { path: args.path, namespace: SHIM_NS }; + } + + return { path: args.path, external: true }; + }); + + build.onLoad({ filter: /.*/, namespace: SHIM_NS }, (args) => { + const spec = JSON.stringify(args.path); + return { + contents: [ + `import * as _ns from ${spec};`, + `export default _ns.default;`, + `export * from ${spec};`, + ].join('\n'), + loader: 'js', + resolveDir: '.', + }; + }); + }, + }; +} \ No newline at end of file diff --git a/admin-ui/src/sdk/plugin-runtime.d.mts b/admin-ui/src/sdk/plugin-runtime.d.mts new file mode 100644 index 000000000..d3590659a --- /dev/null +++ b/admin-ui/src/sdk/plugin-runtime.d.mts @@ -0,0 +1,8 @@ +/** Bare specifiers provided by the host app, mapped to their shim file in dist/. */ +export declare const SHARED_DEP_SHIMS: Record; + +/** SDK subpath exports resolvable from plugin bundles, mapped to their dist file. */ +export declare const SDK_MODULE_FILES: Record; + +/** All bare specifiers a plugin bundle may leave external. */ +export declare const PLUGIN_EXTERNALS: (string | RegExp)[]; diff --git a/admin-ui/src/sdk/plugin-runtime.mjs b/admin-ui/src/sdk/plugin-runtime.mjs new file mode 100644 index 000000000..336361c81 --- /dev/null +++ b/admin-ui/src/sdk/plugin-runtime.mjs @@ -0,0 +1,69 @@ +/** + * Single source of truth for the admin-ui plugin runtime module graph. + * + * Plugins are built as pure ESM bundles (see plugin-build.mjs) with shared + * dependencies left as bare import specifiers. At runtime the browser resolves + * those specifiers through an import map: + * + * - Host-owned dependencies (react, @apollo/client, ...) map to tiny shim + * modules (built from src/sdk/shims/) that re-export the running admin-ui + * app's instances via window.__UNCHAINED_PLUGIN_DEPS__, so plugins share the + * exact same React/Apollo instances as the host. + * - SDK subpaths (@unchainedshop/admin-ui/ui, ...) map to the prebuilt ESM + * files in dist/, served by the engine under /admin-ui-sdk/. + * + * This file is imported by plugin build configs (via tsup, plain Node ESM), + * and by @unchainedshop/api for server-side import map generation. It must + * stay dependency-free and browser/Node neutral. + */ + +/** Bare specifiers provided by the host app, mapped to their shim file in dist/. */ +export const SHARED_DEP_SHIMS = { + react: 'shims/react.js', + 'react/jsx-runtime': 'shims/react-jsx-runtime.js', + 'react-dom': 'shims/react-dom.js', + 'react-dom/client': 'shims/react-dom-client.js', + '@apollo/client': 'shims/apollo-client.js', + '@apollo/client/react': 'shims/apollo-client-react.js', + 'next/router': 'shims/next-router.js', + 'next/link': 'shims/next-link.js', + 'next/image': 'shims/next-image.js', + 'next/head': 'shims/next-head.js', + 'react-intl': 'shims/react-intl.js', + 'react-toastify': 'shims/react-toastify.js', + formik: 'shims/formik.js', + '@unchainedshop/admin-ui/plugins': 'shims/admin-ui-plugins.js', +}; + +/** SDK subpath exports resolvable from plugin bundles, mapped to their dist file. */ +export const SDK_MODULE_FILES = { + '@unchainedshop/admin-ui/ui': 'ui.js', + '@unchainedshop/admin-ui/form': 'form.js', + '@unchainedshop/admin-ui/hooks': 'hooks.js', + '@unchainedshop/admin-ui/providers': 'providers.js', + '@unchainedshop/admin-ui/modal': 'modal.js', + '@unchainedshop/admin-ui/theme': 'theme.js', + '@unchainedshop/admin-ui/modules/accounts': 'modules/accounts.js', + '@unchainedshop/admin-ui/modules/assortment': 'modules/assortment.js', + '@unchainedshop/admin-ui/modules/country': 'modules/country.js', + '@unchainedshop/admin-ui/modules/currency': 'modules/currency.js', + '@unchainedshop/admin-ui/modules/delivery-provider': 'modules/delivery-provider.js', + '@unchainedshop/admin-ui/modules/enrollment': 'modules/enrollment.js', + '@unchainedshop/admin-ui/modules/event': 'modules/event.js', + '@unchainedshop/admin-ui/modules/filter': 'modules/filter.js', + '@unchainedshop/admin-ui/modules/language': 'modules/language.js', + '@unchainedshop/admin-ui/modules/order': 'modules/order.js', + '@unchainedshop/admin-ui/modules/payment-providers': 'modules/payment-providers.js', + '@unchainedshop/admin-ui/modules/product': 'modules/product.js', + '@unchainedshop/admin-ui/modules/product-review': 'modules/product-review.js', + '@unchainedshop/admin-ui/modules/quotation': 'modules/quotation.js', + '@unchainedshop/admin-ui/modules/token': 'modules/token.js', + '@unchainedshop/admin-ui/modules/warehousing-providers': 'modules/warehousing-providers.js', + '@unchainedshop/admin-ui/modules/work': 'modules/work.js', +}; + +/** All bare specifiers a plugin bundle may leave external. */ +export const PLUGIN_EXTERNALS = [ + ...Object.keys(SHARED_DEP_SHIMS), + /^@unchainedshop\/admin-ui\//, +]; diff --git a/admin-ui/src/sdk/shims/admin-ui-plugins.ts b/admin-ui/src/sdk/shims/admin-ui-plugins.ts new file mode 100644 index 000000000..d336b32dd --- /dev/null +++ b/admin-ui/src/sdk/shims/admin-ui-plugins.ts @@ -0,0 +1,6 @@ +import { hostDep } from './host'; + +const pluginsRuntime = hostDep('@unchainedshop/admin-ui/plugins'); + +export const definePlugin = pluginsRuntime.definePlugin; +export const usePluginRuntime = pluginsRuntime.usePluginRuntime; diff --git a/admin-ui/src/sdk/shims/apollo-client-react.ts b/admin-ui/src/sdk/shims/apollo-client-react.ts new file mode 100644 index 000000000..86f48a08e --- /dev/null +++ b/admin-ui/src/sdk/shims/apollo-client-react.ts @@ -0,0 +1,17 @@ +import { hostDep } from './host'; + +const apolloReact = hostDep('@apollo/client/react'); + +export const ApolloProvider = apolloReact.ApolloProvider; +export const skipToken = apolloReact.skipToken; +export const useApolloClient = apolloReact.useApolloClient; +export const useBackgroundQuery = apolloReact.useBackgroundQuery; +export const useFragment = apolloReact.useFragment; +export const useLazyQuery = apolloReact.useLazyQuery; +export const useLoadableQuery = apolloReact.useLoadableQuery; +export const useMutation = apolloReact.useMutation; +export const useQuery = apolloReact.useQuery; +export const useReactiveVar = apolloReact.useReactiveVar; +export const useReadQuery = apolloReact.useReadQuery; +export const useSubscription = apolloReact.useSubscription; +export const useSuspenseQuery = apolloReact.useSuspenseQuery; diff --git a/admin-ui/src/sdk/shims/apollo-client.ts b/admin-ui/src/sdk/shims/apollo-client.ts new file mode 100644 index 000000000..25858164c --- /dev/null +++ b/admin-ui/src/sdk/shims/apollo-client.ts @@ -0,0 +1,18 @@ +import { hostDep } from './host'; + +const apollo = hostDep('@apollo/client'); + +export const ApolloClient = apollo.ApolloClient; +export const ApolloError = apollo.ApolloError; +export const ApolloLink = apollo.ApolloLink; +export const CombinedGraphQLErrors = apollo.CombinedGraphQLErrors; +export const HttpLink = apollo.HttpLink; +export const InMemoryCache = apollo.InMemoryCache; +export const NetworkStatus = apollo.NetworkStatus; +export const Observable = apollo.Observable; +export const concat = apollo.concat; +export const createHttpLink = apollo.createHttpLink; +export const from = apollo.from; +export const gql = apollo.gql; +export const makeVar = apollo.makeVar; +export const split = apollo.split; diff --git a/admin-ui/src/sdk/shims/formik.ts b/admin-ui/src/sdk/shims/formik.ts new file mode 100644 index 000000000..5791fa994 --- /dev/null +++ b/admin-ui/src/sdk/shims/formik.ts @@ -0,0 +1,22 @@ +import { hostDep } from './host'; + +const Formik$ = hostDep('formik'); + +export const ErrorMessage = Formik$.ErrorMessage; +export const FastField = Formik$.FastField; +export const Field = Formik$.Field; +export const FieldArray = Formik$.FieldArray; +export const Form = Formik$.Form; +export const Formik = Formik$.Formik; +export const FormikConsumer = Formik$.FormikConsumer; +export const FormikContext = Formik$.FormikContext; +export const FormikProvider = Formik$.FormikProvider; +export const connect = Formik$.connect; +export const getIn = Formik$.getIn; +export const setIn = Formik$.setIn; +export const useField = Formik$.useField; +export const useFormik = Formik$.useFormik; +export const useFormikContext = Formik$.useFormikContext; +export const validateYupSchema = Formik$.validateYupSchema; +export const withFormik = Formik$.withFormik; +export const yupToFormErrors = Formik$.yupToFormErrors; diff --git a/admin-ui/src/sdk/shims/host.ts b/admin-ui/src/sdk/shims/host.ts new file mode 100644 index 000000000..6d3c9649a --- /dev/null +++ b/admin-ui/src/sdk/shims/host.ts @@ -0,0 +1,18 @@ +/** + * Bridge between native ESM plugin modules and the webpack-bundled host app. + * The admin-ui app exposes its own module instances on + * window.__UNCHAINED_PLUGIN_DEPS__ (see modules/plugins/PluginContext.tsx) + * before any plugin bundle is imported. + */ +export function hostDep(specifier: string): any { + const deps = + typeof window !== 'undefined' && (window as any).__UNCHAINED_PLUGIN_DEPS__; + const dep = deps?.[specifier]; + if (!dep) { + throw new Error( + `Unchained admin-ui plugin runtime: host dependency "${specifier}" is not available. ` + + 'Plugin modules can only be loaded by the admin-ui after its plugin runtime is initialized.', + ); + } + return dep; +} diff --git a/admin-ui/src/sdk/shims/next-head.ts b/admin-ui/src/sdk/shims/next-head.ts new file mode 100644 index 000000000..d3df6f9c6 --- /dev/null +++ b/admin-ui/src/sdk/shims/next-head.ts @@ -0,0 +1,5 @@ +import { hostDep } from './host'; + +const NextHead = hostDep('next/head'); + +export default NextHead.default ?? NextHead; diff --git a/admin-ui/src/sdk/shims/next-image.ts b/admin-ui/src/sdk/shims/next-image.ts new file mode 100644 index 000000000..61fd3ddb3 --- /dev/null +++ b/admin-ui/src/sdk/shims/next-image.ts @@ -0,0 +1,5 @@ +import { hostDep } from './host'; + +const NextImage = hostDep('next/image'); + +export default NextImage.default ?? NextImage; diff --git a/admin-ui/src/sdk/shims/next-link.ts b/admin-ui/src/sdk/shims/next-link.ts new file mode 100644 index 000000000..8c1538ba2 --- /dev/null +++ b/admin-ui/src/sdk/shims/next-link.ts @@ -0,0 +1,5 @@ +import { hostDep } from './host'; + +const NextLink = hostDep('next/link'); + +export default NextLink.default ?? NextLink; diff --git a/admin-ui/src/sdk/shims/next-router.ts b/admin-ui/src/sdk/shims/next-router.ts new file mode 100644 index 000000000..07d45ceef --- /dev/null +++ b/admin-ui/src/sdk/shims/next-router.ts @@ -0,0 +1,9 @@ +import { hostDep } from './host'; + +const NextRouter = hostDep('next/router'); + +export default NextRouter.default ?? NextRouter; + +export const useRouter = NextRouter.useRouter; +export const withRouter = NextRouter.withRouter; +export const Router = NextRouter.Router; diff --git a/admin-ui/src/sdk/shims/react-dom-client.ts b/admin-ui/src/sdk/shims/react-dom-client.ts new file mode 100644 index 000000000..59ff0801e --- /dev/null +++ b/admin-ui/src/sdk/shims/react-dom-client.ts @@ -0,0 +1,6 @@ +import { hostDep } from './host'; + +const ReactDOMClient = hostDep('react-dom/client'); + +export const createRoot = ReactDOMClient.createRoot; +export const hydrateRoot = ReactDOMClient.hydrateRoot; diff --git a/admin-ui/src/sdk/shims/react-dom.ts b/admin-ui/src/sdk/shims/react-dom.ts new file mode 100644 index 000000000..efbb3e165 --- /dev/null +++ b/admin-ui/src/sdk/shims/react-dom.ts @@ -0,0 +1,13 @@ +import { hostDep } from './host'; + +const ReactDOM = hostDep('react-dom'); + +export default ReactDOM.default ?? ReactDOM; + +export const createPortal = ReactDOM.createPortal; +export const flushSync = ReactDOM.flushSync; +export const preconnect = ReactDOM.preconnect; +export const prefetchDNS = ReactDOM.prefetchDNS; +export const preinit = ReactDOM.preinit; +export const preload = ReactDOM.preload; +export const version = ReactDOM.version; diff --git a/admin-ui/src/sdk/shims/react-intl.ts b/admin-ui/src/sdk/shims/react-intl.ts new file mode 100644 index 000000000..22c8bec1c --- /dev/null +++ b/admin-ui/src/sdk/shims/react-intl.ts @@ -0,0 +1,22 @@ +import { hostDep } from './host'; + +const ReactIntl = hostDep('react-intl'); + +export const FormattedDate = ReactIntl.FormattedDate; +export const FormattedDateParts = ReactIntl.FormattedDateParts; +export const FormattedDisplayName = ReactIntl.FormattedDisplayName; +export const FormattedList = ReactIntl.FormattedList; +export const FormattedMessage = ReactIntl.FormattedMessage; +export const FormattedNumber = ReactIntl.FormattedNumber; +export const FormattedNumberParts = ReactIntl.FormattedNumberParts; +export const FormattedPlural = ReactIntl.FormattedPlural; +export const FormattedRelativeTime = ReactIntl.FormattedRelativeTime; +export const FormattedTime = ReactIntl.FormattedTime; +export const IntlContext = ReactIntl.IntlContext; +export const IntlProvider = ReactIntl.IntlProvider; +export const RawIntlProvider = ReactIntl.RawIntlProvider; +export const createIntl = ReactIntl.createIntl; +export const createIntlCache = ReactIntl.createIntlCache; +export const defineMessage = ReactIntl.defineMessage; +export const defineMessages = ReactIntl.defineMessages; +export const useIntl = ReactIntl.useIntl; diff --git a/admin-ui/src/sdk/shims/react-jsx-runtime.ts b/admin-ui/src/sdk/shims/react-jsx-runtime.ts new file mode 100644 index 000000000..e26f8c1a7 --- /dev/null +++ b/admin-ui/src/sdk/shims/react-jsx-runtime.ts @@ -0,0 +1,7 @@ +import { hostDep } from './host'; + +const runtime = hostDep('react/jsx-runtime'); + +export const jsx = runtime.jsx; +export const jsxs = runtime.jsxs; +export const Fragment = runtime.Fragment; diff --git a/admin-ui/src/sdk/shims/react-toastify.ts b/admin-ui/src/sdk/shims/react-toastify.ts new file mode 100644 index 000000000..e20c83789 --- /dev/null +++ b/admin-ui/src/sdk/shims/react-toastify.ts @@ -0,0 +1,11 @@ +import { hostDep } from './host'; + +const ReactToastify = hostDep('react-toastify'); + +export const Bounce = ReactToastify.Bounce; +export const Flip = ReactToastify.Flip; +export const Slide = ReactToastify.Slide; +export const ToastContainer = ReactToastify.ToastContainer; +export const Zoom = ReactToastify.Zoom; +export const cssTransition = ReactToastify.cssTransition; +export const toast = ReactToastify.toast; diff --git a/admin-ui/src/sdk/shims/react.ts b/admin-ui/src/sdk/shims/react.ts new file mode 100644 index 000000000..be490735c --- /dev/null +++ b/admin-ui/src/sdk/shims/react.ts @@ -0,0 +1,43 @@ +import { hostDep } from './host'; + +const React = hostDep('react'); + +export default React.default ?? React; + +export const Children = React.Children; +export const Component = React.Component; +export const Fragment = React.Fragment; +export const Profiler = React.Profiler; +export const PureComponent = React.PureComponent; +export const StrictMode = React.StrictMode; +export const Suspense = React.Suspense; +export const act = React.act; +export const cache = React.cache; +export const cloneElement = React.cloneElement; +export const createContext = React.createContext; +export const createElement = React.createElement; +export const createRef = React.createRef; +export const forwardRef = React.forwardRef; +export const isValidElement = React.isValidElement; +export const lazy = React.lazy; +export const memo = React.memo; +export const startTransition = React.startTransition; +export const use = React.use; +export const useActionState = React.useActionState; +export const useCallback = React.useCallback; +export const useContext = React.useContext; +export const useDebugValue = React.useDebugValue; +export const useDeferredValue = React.useDeferredValue; +export const useEffect = React.useEffect; +export const useId = React.useId; +export const useImperativeHandle = React.useImperativeHandle; +export const useInsertionEffect = React.useInsertionEffect; +export const useLayoutEffect = React.useLayoutEffect; +export const useMemo = React.useMemo; +export const useOptimistic = React.useOptimistic; +export const useReducer = React.useReducer; +export const useRef = React.useRef; +export const useState = React.useState; +export const useSyncExternalStore = React.useSyncExternalStore; +export const useTransition = React.useTransition; +export const version = React.version; diff --git a/admin-ui/tsup.config.ts b/admin-ui/tsup.config.ts index 8dabc96e6..22c1d053d 100644 --- a/admin-ui/tsup.config.ts +++ b/admin-ui/tsup.config.ts @@ -9,6 +9,20 @@ export default defineConfig({ modal: 'src/sdk/modal.ts', theme: 'src/sdk/theme.ts', plugins: 'src/sdk/plugins.ts', + 'shims/react': 'src/sdk/shims/react.ts', + 'shims/react-jsx-runtime': 'src/sdk/shims/react-jsx-runtime.ts', + 'shims/react-dom': 'src/sdk/shims/react-dom.ts', + 'shims/react-dom-client': 'src/sdk/shims/react-dom-client.ts', + 'shims/apollo-client': 'src/sdk/shims/apollo-client.ts', + 'shims/apollo-client-react': 'src/sdk/shims/apollo-client-react.ts', + 'shims/next-router': 'src/sdk/shims/next-router.ts', + 'shims/next-link': 'src/sdk/shims/next-link.ts', + 'shims/next-image': 'src/sdk/shims/next-image.ts', + 'shims/next-head': 'src/sdk/shims/next-head.ts', + 'shims/react-intl': 'src/sdk/shims/react-intl.ts', + 'shims/react-toastify': 'src/sdk/shims/react-toastify.ts', + 'shims/formik': 'src/sdk/shims/formik.ts', + 'shims/admin-ui-plugins': 'src/sdk/shims/admin-ui-plugins.ts', 'modules/accounts': 'src/modules/accounts/index.ts', 'modules/assortment': 'src/modules/assortment/index.ts', 'modules/country': 'src/modules/country/index.ts', @@ -29,6 +43,7 @@ export default defineConfig({ 'modules/work': 'src/modules/work/index.ts', }, format: ['esm'], + platform: 'browser', dts: false, splitting: true, treeshake: true, diff --git a/examples/kitchensink-express/src/boot.ts b/examples/kitchensink-express/src/boot.ts index a6f89c7f4..7e2229148 100644 --- a/examples/kitchensink-express/src/boot.ts +++ b/examples/kitchensink-express/src/boot.ts @@ -85,7 +85,7 @@ try { definePlugin({ name: 'bookmark-manager', version: '1.0.0', - bundlePath: resolve(__dirname, '../plugins/bookmark-manager/dist/index.global.js'), + bundlePath: resolve(__dirname, '../plugins/bookmark-manager/dist/index.js'), slots: { entities: [ { diff --git a/examples/kitchensink/src/boot.ts b/examples/kitchensink/src/boot.ts index 3eb4b1beb..2694bb1ea 100644 --- a/examples/kitchensink/src/boot.ts +++ b/examples/kitchensink/src/boot.ts @@ -99,7 +99,7 @@ try { definePlugin({ name: 'bookmark-manager', version: '1.0.0', - bundlePath: resolve(__dirname, '../plugins/bookmark-manager/dist/index.global.js'), + bundlePath: resolve(__dirname, '../plugins/bookmark-manager/dist/index.js'), slots: { entities: [ { diff --git a/packages/api/src/adminUiPlugins.ts b/packages/api/src/adminUiPlugins.ts index 004e17c76..55e4029bc 100644 --- a/packages/api/src/adminUiPlugins.ts +++ b/packages/api/src/adminUiPlugins.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; -import { readFileSync, statSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, resolve, sep } from 'node:path'; +import { SHARED_DEP_SHIMS, SDK_MODULE_FILES } from '@unchainedshop/admin-ui/plugin-runtime'; const PLUGIN_NAME_RE = /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9_-]*[a-z0-9])?)*$/i; @@ -83,6 +84,7 @@ interface StaticAsset { export interface PreparedPluginAssets { routes: Map; validPlugins: AdminUIPluginConfig[]; + importMapTag: string | null; } export const resolveAdminUIPath = (): string | null => { @@ -94,47 +96,23 @@ export const resolveAdminUIPath = (): string | null => { } }; -const contentHash = (content: string) => createHash('sha256').update(content).digest('hex').slice(0, 8); +const contentHash = (content: string) => + createHash('sha256').update(content).digest('hex').slice(0, 8); /** - * Extract the entry export names from an IIFE plugin bundle built by - * @unchainedshop/admin-ui/plugin-build. tsup/esbuild registers the entry's - * exports as `__export(, { Name: () => ..., ... })` and the IIFE returns - * that same object via `return __toCommonJS();`. Returns null when the - * bundle doesn't match this shape (custom build, minified) so validation is - * skipped rather than producing false warnings. + * Enumerate the prebuilt ESM files of the admin-ui SDK (entries plus their + * code-split chunks). Only files discovered here are ever served, so request + * paths never touch the filesystem. */ -export const parseBundleExports = (bundle: string): Set | null => { - const returnMatch = bundle.match(/return __toCommonJS\(([\w$]+)\);/); - if (!returnMatch) return null; - const blockMatch = bundle.match( - new RegExp(`__export\\(${returnMatch[1].replace(/\$/g, '\\$')},\\s*\\{([\\s\\S]*?)\\}\\);`), - ); - if (!blockMatch) return null; - const names = new Set(); - for (const m of blockMatch[1].matchAll(/(?:^|,)\s*(?:"([^"]+)"|([\w$]+)):\s*\(\)\s*=>/g)) { - names.add(m[1] ?? m[2]); - } - return names.size > 0 ? names : null; -}; - -/** All component names a plugin's slot configuration references. */ -const collectReferencedComponents = (plugin: AdminUIPluginConfig): string[] => { - const names = new Set(); - for (const [slotId, configs] of Object.entries(plugin.slots || {})) { - if (!Array.isArray(configs)) continue; - for (const config of configs) { - if (slotId === 'entities') { - const components = (config as AdminUIPluginEntityConfig).components; - if (components?.list) names.add(components.list); - if (components?.detail) names.add(components.detail); - if (components?.create) names.add(components.create); - } else if (typeof (config as { component?: unknown }).component === 'string') { - names.add((config as { component: string }).component); - } - } +const listSDKDistFiles = (distPath: string): string[] => { + try { + if (!statSync(distPath).isDirectory()) return []; + return readdirSync(distPath, { recursive: true }) + .map((file) => String(file).split(sep).join('/')) + .filter((file) => file.endsWith('.js')); + } catch { + return []; } - return [...names]; }; export function preparePluginAssets( @@ -186,19 +164,6 @@ export function preparePluginAssets( const pluginsWithBundles = validPlugins.filter((p) => pluginBundles.has(p.name)); - // Warn early when the config references components the bundle doesn't - // export — otherwise the first signal is a runtime "Component not found". - for (const plugin of pluginsWithBundles) { - const exportNames = parseBundleExports(pluginBundles.get(plugin.name)!.content); - if (!exportNames) continue; - const missing = collectReferencedComponents(plugin).filter((name) => !exportNames.has(name)); - if (missing.length > 0) { - log.warn( - `admin-ui plugin "${plugin.name}" references component(s) not exported by its bundle: ${missing.join(', ')}. Exported: ${[...exportNames].join(', ')}`, - ); - } - } - // In dev mode, use mtime-based cache invalidation to avoid re-reading every // bundle file on every manifest request. Only re-hash when a file changes. const bundleMtimes = new Map(); @@ -258,18 +223,83 @@ export function preparePluginAssets( if (devMode) { routes.set(`/admin-plugins/${plugin.name}.js`, { content: () => readFileSync(bundlePath, 'utf-8'), - contentType: 'application/javascript', + contentType: 'text/javascript', cacheControl: devCacheControl, }); } else { const { content } = pluginBundles.get(plugin.name)!; routes.set(`/admin-plugins/${plugin.name}.js`, { content, - contentType: 'application/javascript', + contentType: 'text/javascript', cacheControl: IMMUTABLE_CACHE, }); } } - return { routes, validPlugins }; + let importMapTag: string | null = null; + + if (pluginBundles.size > 0) { + const adminUIPath = resolveAdminUIPath(); + const distPath = adminUIPath ? join(adminUIPath, '..', 'dist') : null; + const sdkFiles = distPath ? listSDKDistFiles(distPath) : []; + + if (distPath && sdkFiles.length > 0) { + // Serve every prebuilt SDK file. Entry URLs get a content-hash query + // for cache busting; chunk filenames are content-hashed by the bundler + // itself, so immutable caching is safe throughout. + const sdkFileHashes = new Map(); + for (const file of sdkFiles) { + const filePath = join(distPath, file); + if (devMode) { + routes.set(`/admin-ui-sdk/${file}`, { + content: () => readFileSync(filePath, 'utf-8'), + contentType: 'text/javascript', + cacheControl: devCacheControl, + }); + } else { + const content = readFileSync(filePath, 'utf-8'); + sdkFileHashes.set(file, contentHash(content)); + routes.set(`/admin-ui-sdk/${file}`, { + content, + contentType: 'text/javascript', + cacheControl: IMMUTABLE_CACHE, + }); + } + } + + const imports: Record = {}; + for (const [specifier, file] of [ + ...Object.entries(SDK_MODULE_FILES), + ...Object.entries(SHARED_DEP_SHIMS), + ]) { + if (!sdkFiles.includes(file)) { + log.warn( + `admin-ui plugin runtime: expected SDK file "${file}" for "${specifier}" not found in ${distPath}`, + ); + continue; + } + const version = sdkFileHashes.get(file); + imports[specifier] = `/admin-ui-sdk/${file}${version ? `?v=${version}` : ''}`; + } + + const importMapJSON = JSON.stringify({ imports }); + const importMapHash = contentHash(importMapJSON); + routes.set('/admin-ui-importmap.json', { + content: importMapJSON, + contentType: 'application/json', + cacheControl: devMode ? devCacheControl : 'public, max-age=0, must-revalidate', + etag: `"${importMapHash}"`, + }); + + // The marker attribute lets the client plugin loader detect that the + // import map is already present (see admin-ui PluginContext.tsx). + importMapTag = ``; + } else { + log.warn( + 'admin-ui plugin runtime: SDK dist files not found; plugins depending on shared modules will fail to load', + ); + } + } + + return { routes, validPlugins, importMapTag }; } diff --git a/packages/api/src/express/index.ts b/packages/api/src/express/index.ts index 330273536..8407802c7 100644 --- a/packages/api/src/express/index.ts +++ b/packages/api/src/express/index.ts @@ -4,7 +4,7 @@ import type { YogaServerInstance } from 'graphql-yoga'; import type { UnchainedCore } from '@unchainedshop/core'; import { pluginRegistry } from '@unchainedshop/core'; import { createHash } from 'node:crypto'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { getCurrentContextResolver } from '../context.ts'; @@ -63,7 +63,7 @@ export const adminUIRouter = ( const log = { info: console.info, warn: console.warn }; const devMode = process.env.NODE_ENV !== 'production'; - const { routes: pluginRoutes } = preparePluginAssets(plugins, log, { devMode }); + const { routes: pluginRoutes, importMapTag } = preparePluginAssets(plugins, log, { devMode }); for (const [path, asset] of pluginRoutes) { router.get(path, (req, res) => { @@ -83,22 +83,44 @@ export const adminUIRouter = ( }); } - router.use(e.static(adminUIPath)); + // With an import map to inject, index.html must not be served directly by + // the static handler so that / falls through to the injecting catch-all. + router.use(e.static(adminUIPath, importMapTag ? { index: false } : undefined)); // SPA fallback: the admin-ui is a Next.js static export where plugin // entity/page routes live under /ext/*, pre-rendered to a dedicated HTML // file. Hard loads of /ext/* must get that file, everything else gets the // root index.html. - const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); - const hasExtHtml = existsSync(extHtmlPath); + if (importMapTag) { + const injectImportMap = (html: string) => html.replace('', `${importMapTag}`); - router.get(/(.*)/, (req, res) => { - const urlPath = req.path.replace(/\/+$/, ''); - if (hasExtHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { - return res.sendFile(extHtmlPath); - } - return res.sendFile(join(adminUIPath, 'index.html')); - }); + const indexHtml = readFileSync(join(adminUIPath, 'index.html'), 'utf-8'); + const injectedHtml = injectImportMap(indexHtml); + + const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); + const extHtml = existsSync(extHtmlPath) + ? injectImportMap(readFileSync(extHtmlPath, 'utf-8')) + : null; + + router.get(/(.*)/, (req, res) => { + const urlPath = req.path.replace(/\/+$/, ''); + if (extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { + return res.type('text/html').send(extHtml); + } + return res.type('text/html').send(injectedHtml); + }); + } else { + const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); + const hasExtHtml = existsSync(extHtmlPath); + + router.get(/(.*)/, (req, res) => { + const urlPath = req.path.replace(/\/+$/, ''); + if (hasExtHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { + return res.sendFile(extHtmlPath); + } + return res.sendFile(join(adminUIPath, 'index.html')); + }); + } } return router; diff --git a/packages/api/src/fastify/index.ts b/packages/api/src/fastify/index.ts index 62f4a5744..379cb9995 100644 --- a/packages/api/src/fastify/index.ts +++ b/packages/api/src/fastify/index.ts @@ -34,6 +34,7 @@ export interface AdminUIRouterOptions { enabled?: boolean; theme?: AdminUIThemeConfig; plugins?: AdminUIPluginConfig[]; + importMapTag?: string | null; } /** @@ -245,7 +246,7 @@ export const connect = async ( }); const devMode = process.env.NODE_ENV !== 'production'; - const { routes: pluginRoutes } = preparePluginAssets(adminUIPlugins, fastify.log, { + const { routes: pluginRoutes, importMapTag } = preparePluginAssets(adminUIPlugins, fastify.log, { devMode, }); @@ -272,6 +273,7 @@ export const connect = async ( enabled: true, prefix: adminUIOptions?.prefix || '/', plugins: adminUIPlugins, + importMapTag, }); } }; @@ -313,26 +315,57 @@ export const adminUIRouter: FastifyPluginAsync = async ( // entity/page routes live under /ext/*, pre-rendered to a dedicated // HTML file. Hard loads of /ext/* must get that file, other non-file // paths fall back to the root index.html. - const indexHtml = readFileSync(join(adminUIPath, 'index.html'), 'utf-8'); - const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); - const extHtml = existsSync(extHtmlPath) ? readFileSync(extHtmlPath, 'utf-8') : null; - - await fastify.register(fastifyStatic, { - root: adminUIPath, - prefix: opts.prefix || '/', - wildcard: false, - }); - - fastify.setNotFoundHandler(async (request, reply) => { - if (request.method === 'GET' && !request.url.includes('.')) { - const urlPath = request.url.split('?')[0].replace(/\/+$/, ''); - if (extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { - return reply.type('text/html').send(extHtml); + if (opts.importMapTag) { + const injectImportMap = (html: string) => + html.replace('', `${opts.importMapTag}`); + + const indexHtml = readFileSync(join(adminUIPath, 'index.html'), 'utf-8'); + const injectedHtml = injectImportMap(indexHtml); + + const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); + const extHtml = existsSync(extHtmlPath) + ? injectImportMap(readFileSync(extHtmlPath, 'utf-8')) + : null; + + await fastify.register(fastifyStatic, { + root: adminUIPath, + prefix: opts.prefix || '/', + wildcard: false, + index: false, + }); + + fastify.setNotFoundHandler(async (request, reply) => { + if (request.method === 'GET' && !request.url.includes('.')) { + const urlPath = request.url.split('?')[0].replace(/\/+$/, ''); + if (extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { + return reply.type('text/html').send(extHtml); + } + return reply.type('text/html').send(injectedHtml); } - return reply.type('text/html').send(indexHtml); - } - return reply.code(404).send({ error: 'Not Found' }); - }); + return reply.code(404).send({ error: 'Not Found' }); + }); + } else { + const indexHtml = readFileSync(join(adminUIPath, 'index.html'), 'utf-8'); + const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); + const extHtml = existsSync(extHtmlPath) ? readFileSync(extHtmlPath, 'utf-8') : null; + + await fastify.register(fastifyStatic, { + root: adminUIPath, + prefix: opts.prefix || '/', + wildcard: false, + }); + + fastify.setNotFoundHandler(async (request, reply) => { + if (request.method === 'GET' && !request.url.includes('.')) { + const urlPath = request.url.split('?')[0].replace(/\/+$/, ''); + if (extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { + return reply.type('text/html').send(extHtml); + } + return reply.type('text/html').send(indexHtml); + } + return reply.code(404).send({ error: 'Not Found' }); + }); + } return; } } From b8742c9af4792d015f35870cb2dd82bdfe477535 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Mon, 6 Jul 2026 22:01:35 +0300 Subject: [PATCH 03/11] Fetch admin-ui plugin on every load on dev mode so that changes in plugin projects are reflected --- .../src/modules/plugins/PluginContext.tsx | 12 ---- admin-ui/src/sdk/shims/host.ts | 61 ++++++++++++++++--- 2 files changed, 51 insertions(+), 22 deletions(-) diff --git a/admin-ui/src/modules/plugins/PluginContext.tsx b/admin-ui/src/modules/plugins/PluginContext.tsx index f3c21a01e..a88cb2772 100644 --- a/admin-ui/src/modules/plugins/PluginContext.tsx +++ b/admin-ui/src/modules/plugins/PluginContext.tsx @@ -104,16 +104,6 @@ const setupPluginRuntime = () => { }; }; -/** - * Make sure an import map covering the shared plugin dependencies is present - * before the first plugin module is imported. - * - * When the engine serves the admin-ui itself, it injects the import map into - * the HTML head and this is a no-op. When the admin-ui runs on another origin - * (e.g. `next dev`), the map is fetched from the engine and injected with - * absolute URLs. Import maps only apply to modules not yet resolved, which is - * guaranteed here because plugins are the only native ESM on the page. - */ const ensureImportMap = async (baseUrl: string): Promise => { if ( document.querySelector('script[type="importmap"][data-unchained-admin-ui]') @@ -152,8 +142,6 @@ const loadPluginModule = async ( ).href; const mod = await import(/* webpackIgnore: true */ url); if (mod && Object.keys(mod).length > 0) return mod; - // Legacy IIFE bundles execute fine as modules but export nothing; they - // register themselves on the global registry instead. const legacy = window.__UNCHAINED_PLUGINS__?.[manifest.name]; if (legacy) return legacy; console.error( diff --git a/admin-ui/src/sdk/shims/host.ts b/admin-ui/src/sdk/shims/host.ts index 6d3c9649a..2513c15a0 100644 --- a/admin-ui/src/sdk/shims/host.ts +++ b/admin-ui/src/sdk/shims/host.ts @@ -1,18 +1,59 @@ /** * Bridge between native ESM plugin modules and the webpack-bundled host app. + * * The admin-ui app exposes its own module instances on * window.__UNCHAINED_PLUGIN_DEPS__ (see modules/plugins/PluginContext.tsx) * before any plugin bundle is imported. + * + * Returns a Proxy that defers the underlying lookup until the first property + * access at call time. This makes shim modules resilient to module-evaluation + * ordering — the shim's top-level `export const X = dep.X` binds X to a + * proxy getter, and the real dependency is only resolved when plugin code + * actually reads or calls X. */ export function hostDep(specifier: string): any { - const deps = - typeof window !== 'undefined' && (window as any).__UNCHAINED_PLUGIN_DEPS__; - const dep = deps?.[specifier]; - if (!dep) { - throw new Error( - `Unchained admin-ui plugin runtime: host dependency "${specifier}" is not available. ` + - 'Plugin modules can only be loaded by the admin-ui after its plugin runtime is initialized.', - ); - } - return dep; + let resolved: any = undefined; + let didResolve = false; + + const resolve = () => { + if (didResolve) return resolved; + const deps = + typeof window !== 'undefined' && + (window as any).__UNCHAINED_PLUGIN_DEPS__; + const dep = deps?.[specifier]; + if (!dep) { + throw new Error( + `Unchained admin-ui plugin runtime: host dependency "${specifier}" is not available. ` + + 'Plugin modules can only be loaded by the admin-ui after its plugin runtime is initialized.', + ); + } + resolved = dep; + didResolve = true; + return dep; + }; + + return new Proxy(Object.create(null), { + get(_target, prop) { + if (prop === Symbol.toPrimitive || prop === Symbol.toStringTag) { + return undefined; + } + return resolve()[prop]; + }, + set(_target, prop, value) { + resolve()[prop] = value; + return true; + }, + has(_target, prop) { + return prop in resolve(); + }, + ownKeys() { + return Reflect.ownKeys(resolve()); + }, + getOwnPropertyDescriptor(_target, prop) { + return Object.getOwnPropertyDescriptor(resolve(), prop); + }, + getPrototypeOf() { + return Object.getPrototypeOf(resolve()); + }, + }); } From 0561082fb6bae4472162f6bf9004bcd3a5d2c7af Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Mon, 6 Jul 2026 22:52:58 +0300 Subject: [PATCH 04/11] Build dependecy exports dynamically --- admin-ui/.gitignore | 4 + admin-ui/package.json | 5 +- admin-ui/src/sdk/generate-shims.mjs | 33 +++++++ admin-ui/src/sdk/shims/admin-ui-plugins.ts | 6 -- admin-ui/src/sdk/shims/apollo-client-react.ts | 17 ---- admin-ui/src/sdk/shims/apollo-client.ts | 18 ---- admin-ui/src/sdk/shims/formik.ts | 22 ----- admin-ui/src/sdk/shims/next-head.ts | 5 -- admin-ui/src/sdk/shims/next-image.ts | 5 -- admin-ui/src/sdk/shims/next-link.ts | 5 -- admin-ui/src/sdk/shims/next-router.ts | 9 -- admin-ui/src/sdk/shims/react-dom-client.ts | 6 -- admin-ui/src/sdk/shims/react-dom.ts | 13 --- admin-ui/src/sdk/shims/react-intl.ts | 22 ----- admin-ui/src/sdk/shims/react-jsx-runtime.ts | 7 -- admin-ui/src/sdk/shims/react-toastify.ts | 11 --- admin-ui/src/sdk/shims/react.ts | 43 --------- admin-ui/tsup.config.ts | 89 ++++++++++++++++--- 18 files changed, 115 insertions(+), 205 deletions(-) create mode 100644 admin-ui/src/sdk/generate-shims.mjs delete mode 100644 admin-ui/src/sdk/shims/admin-ui-plugins.ts delete mode 100644 admin-ui/src/sdk/shims/apollo-client-react.ts delete mode 100644 admin-ui/src/sdk/shims/apollo-client.ts delete mode 100644 admin-ui/src/sdk/shims/formik.ts delete mode 100644 admin-ui/src/sdk/shims/next-head.ts delete mode 100644 admin-ui/src/sdk/shims/next-image.ts delete mode 100644 admin-ui/src/sdk/shims/next-link.ts delete mode 100644 admin-ui/src/sdk/shims/next-router.ts delete mode 100644 admin-ui/src/sdk/shims/react-dom-client.ts delete mode 100644 admin-ui/src/sdk/shims/react-dom.ts delete mode 100644 admin-ui/src/sdk/shims/react-intl.ts delete mode 100644 admin-ui/src/sdk/shims/react-jsx-runtime.ts delete mode 100644 admin-ui/src/sdk/shims/react-toastify.ts delete mode 100644 admin-ui/src/sdk/shims/react.ts diff --git a/admin-ui/.gitignore b/admin-ui/.gitignore index ec7ee8cf4..1cd713e26 100644 --- a/admin-ui/.gitignore +++ b/admin-ui/.gitignore @@ -47,6 +47,10 @@ manifest.json .qodo dist + +# Generated shim files (produced by generate-shims.mjs) +src/sdk/shims/*.ts +!src/sdk/shims/host.ts # TypeScript build info tsconfig.tsbuildinfo client/tsconfig.tsbuildinfo diff --git a/admin-ui/package.json b/admin-ui/package.json index 1469c465b..1adf95709 100644 --- a/admin-ui/package.json +++ b/admin-ui/package.json @@ -104,8 +104,9 @@ "scripts": { "prepublishOnly": "npm run build", "dev": "next dev", - "build": "NODE_ENV=production next build && tsup && tsc -p tsconfig.sdk.json", - "build:sdk": "tsup && tsc -p tsconfig.sdk.json", + "build": "NODE_ENV=production next build && npm run build:sdk", + "generate:shims": "node src/sdk/generate-shims.mjs", + "build:sdk": "npm run generate:shims && tsup && tsc -p tsconfig.sdk.json", "start": "next start", "lint": "eslint .", "format": "eslint --fix .", diff --git a/admin-ui/src/sdk/generate-shims.mjs b/admin-ui/src/sdk/generate-shims.mjs new file mode 100644 index 000000000..ac9c81a69 --- /dev/null +++ b/admin-ui/src/sdk/generate-shims.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/** + * Writes minimal stub files for each shim entry so tsup can resolve them. + * The actual shim content (with dynamically discovered exports) is generated + * at build time by the esbuild plugin in tsup.config.ts — these stubs are + * never used as-is. + * + * Usage: node src/sdk/generate-shims.mjs + */ +import { existsSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SHARED_DEP_SHIMS } from './plugin-runtime.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const shimsDir = join(__dirname, 'shims'); + +let created = 0; +for (const [specifier, distFile] of Object.entries(SHARED_DEP_SHIMS)) { + const tsFile = distFile.replace('shims/', '').replace('.js', '.ts'); + const filePath = join(shimsDir, tsFile); + if (!existsSync(filePath)) { + writeFileSync( + filePath, + `// Stub — real content generated at build time by tsup.config.ts\nimport { hostDep } from './host';\nexport default hostDep(${JSON.stringify(specifier)});\n`, + ); + created++; + } +} + +if (created > 0) { + console.log(`Created ${created} shim stub(s)`); +} diff --git a/admin-ui/src/sdk/shims/admin-ui-plugins.ts b/admin-ui/src/sdk/shims/admin-ui-plugins.ts deleted file mode 100644 index d336b32dd..000000000 --- a/admin-ui/src/sdk/shims/admin-ui-plugins.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { hostDep } from './host'; - -const pluginsRuntime = hostDep('@unchainedshop/admin-ui/plugins'); - -export const definePlugin = pluginsRuntime.definePlugin; -export const usePluginRuntime = pluginsRuntime.usePluginRuntime; diff --git a/admin-ui/src/sdk/shims/apollo-client-react.ts b/admin-ui/src/sdk/shims/apollo-client-react.ts deleted file mode 100644 index 86f48a08e..000000000 --- a/admin-ui/src/sdk/shims/apollo-client-react.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { hostDep } from './host'; - -const apolloReact = hostDep('@apollo/client/react'); - -export const ApolloProvider = apolloReact.ApolloProvider; -export const skipToken = apolloReact.skipToken; -export const useApolloClient = apolloReact.useApolloClient; -export const useBackgroundQuery = apolloReact.useBackgroundQuery; -export const useFragment = apolloReact.useFragment; -export const useLazyQuery = apolloReact.useLazyQuery; -export const useLoadableQuery = apolloReact.useLoadableQuery; -export const useMutation = apolloReact.useMutation; -export const useQuery = apolloReact.useQuery; -export const useReactiveVar = apolloReact.useReactiveVar; -export const useReadQuery = apolloReact.useReadQuery; -export const useSubscription = apolloReact.useSubscription; -export const useSuspenseQuery = apolloReact.useSuspenseQuery; diff --git a/admin-ui/src/sdk/shims/apollo-client.ts b/admin-ui/src/sdk/shims/apollo-client.ts deleted file mode 100644 index 25858164c..000000000 --- a/admin-ui/src/sdk/shims/apollo-client.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { hostDep } from './host'; - -const apollo = hostDep('@apollo/client'); - -export const ApolloClient = apollo.ApolloClient; -export const ApolloError = apollo.ApolloError; -export const ApolloLink = apollo.ApolloLink; -export const CombinedGraphQLErrors = apollo.CombinedGraphQLErrors; -export const HttpLink = apollo.HttpLink; -export const InMemoryCache = apollo.InMemoryCache; -export const NetworkStatus = apollo.NetworkStatus; -export const Observable = apollo.Observable; -export const concat = apollo.concat; -export const createHttpLink = apollo.createHttpLink; -export const from = apollo.from; -export const gql = apollo.gql; -export const makeVar = apollo.makeVar; -export const split = apollo.split; diff --git a/admin-ui/src/sdk/shims/formik.ts b/admin-ui/src/sdk/shims/formik.ts deleted file mode 100644 index 5791fa994..000000000 --- a/admin-ui/src/sdk/shims/formik.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { hostDep } from './host'; - -const Formik$ = hostDep('formik'); - -export const ErrorMessage = Formik$.ErrorMessage; -export const FastField = Formik$.FastField; -export const Field = Formik$.Field; -export const FieldArray = Formik$.FieldArray; -export const Form = Formik$.Form; -export const Formik = Formik$.Formik; -export const FormikConsumer = Formik$.FormikConsumer; -export const FormikContext = Formik$.FormikContext; -export const FormikProvider = Formik$.FormikProvider; -export const connect = Formik$.connect; -export const getIn = Formik$.getIn; -export const setIn = Formik$.setIn; -export const useField = Formik$.useField; -export const useFormik = Formik$.useFormik; -export const useFormikContext = Formik$.useFormikContext; -export const validateYupSchema = Formik$.validateYupSchema; -export const withFormik = Formik$.withFormik; -export const yupToFormErrors = Formik$.yupToFormErrors; diff --git a/admin-ui/src/sdk/shims/next-head.ts b/admin-ui/src/sdk/shims/next-head.ts deleted file mode 100644 index d3df6f9c6..000000000 --- a/admin-ui/src/sdk/shims/next-head.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { hostDep } from './host'; - -const NextHead = hostDep('next/head'); - -export default NextHead.default ?? NextHead; diff --git a/admin-ui/src/sdk/shims/next-image.ts b/admin-ui/src/sdk/shims/next-image.ts deleted file mode 100644 index 61fd3ddb3..000000000 --- a/admin-ui/src/sdk/shims/next-image.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { hostDep } from './host'; - -const NextImage = hostDep('next/image'); - -export default NextImage.default ?? NextImage; diff --git a/admin-ui/src/sdk/shims/next-link.ts b/admin-ui/src/sdk/shims/next-link.ts deleted file mode 100644 index 8c1538ba2..000000000 --- a/admin-ui/src/sdk/shims/next-link.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { hostDep } from './host'; - -const NextLink = hostDep('next/link'); - -export default NextLink.default ?? NextLink; diff --git a/admin-ui/src/sdk/shims/next-router.ts b/admin-ui/src/sdk/shims/next-router.ts deleted file mode 100644 index 07d45ceef..000000000 --- a/admin-ui/src/sdk/shims/next-router.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { hostDep } from './host'; - -const NextRouter = hostDep('next/router'); - -export default NextRouter.default ?? NextRouter; - -export const useRouter = NextRouter.useRouter; -export const withRouter = NextRouter.withRouter; -export const Router = NextRouter.Router; diff --git a/admin-ui/src/sdk/shims/react-dom-client.ts b/admin-ui/src/sdk/shims/react-dom-client.ts deleted file mode 100644 index 59ff0801e..000000000 --- a/admin-ui/src/sdk/shims/react-dom-client.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { hostDep } from './host'; - -const ReactDOMClient = hostDep('react-dom/client'); - -export const createRoot = ReactDOMClient.createRoot; -export const hydrateRoot = ReactDOMClient.hydrateRoot; diff --git a/admin-ui/src/sdk/shims/react-dom.ts b/admin-ui/src/sdk/shims/react-dom.ts deleted file mode 100644 index efbb3e165..000000000 --- a/admin-ui/src/sdk/shims/react-dom.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { hostDep } from './host'; - -const ReactDOM = hostDep('react-dom'); - -export default ReactDOM.default ?? ReactDOM; - -export const createPortal = ReactDOM.createPortal; -export const flushSync = ReactDOM.flushSync; -export const preconnect = ReactDOM.preconnect; -export const prefetchDNS = ReactDOM.prefetchDNS; -export const preinit = ReactDOM.preinit; -export const preload = ReactDOM.preload; -export const version = ReactDOM.version; diff --git a/admin-ui/src/sdk/shims/react-intl.ts b/admin-ui/src/sdk/shims/react-intl.ts deleted file mode 100644 index 22c8bec1c..000000000 --- a/admin-ui/src/sdk/shims/react-intl.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { hostDep } from './host'; - -const ReactIntl = hostDep('react-intl'); - -export const FormattedDate = ReactIntl.FormattedDate; -export const FormattedDateParts = ReactIntl.FormattedDateParts; -export const FormattedDisplayName = ReactIntl.FormattedDisplayName; -export const FormattedList = ReactIntl.FormattedList; -export const FormattedMessage = ReactIntl.FormattedMessage; -export const FormattedNumber = ReactIntl.FormattedNumber; -export const FormattedNumberParts = ReactIntl.FormattedNumberParts; -export const FormattedPlural = ReactIntl.FormattedPlural; -export const FormattedRelativeTime = ReactIntl.FormattedRelativeTime; -export const FormattedTime = ReactIntl.FormattedTime; -export const IntlContext = ReactIntl.IntlContext; -export const IntlProvider = ReactIntl.IntlProvider; -export const RawIntlProvider = ReactIntl.RawIntlProvider; -export const createIntl = ReactIntl.createIntl; -export const createIntlCache = ReactIntl.createIntlCache; -export const defineMessage = ReactIntl.defineMessage; -export const defineMessages = ReactIntl.defineMessages; -export const useIntl = ReactIntl.useIntl; diff --git a/admin-ui/src/sdk/shims/react-jsx-runtime.ts b/admin-ui/src/sdk/shims/react-jsx-runtime.ts deleted file mode 100644 index e26f8c1a7..000000000 --- a/admin-ui/src/sdk/shims/react-jsx-runtime.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { hostDep } from './host'; - -const runtime = hostDep('react/jsx-runtime'); - -export const jsx = runtime.jsx; -export const jsxs = runtime.jsxs; -export const Fragment = runtime.Fragment; diff --git a/admin-ui/src/sdk/shims/react-toastify.ts b/admin-ui/src/sdk/shims/react-toastify.ts deleted file mode 100644 index e20c83789..000000000 --- a/admin-ui/src/sdk/shims/react-toastify.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { hostDep } from './host'; - -const ReactToastify = hostDep('react-toastify'); - -export const Bounce = ReactToastify.Bounce; -export const Flip = ReactToastify.Flip; -export const Slide = ReactToastify.Slide; -export const ToastContainer = ReactToastify.ToastContainer; -export const Zoom = ReactToastify.Zoom; -export const cssTransition = ReactToastify.cssTransition; -export const toast = ReactToastify.toast; diff --git a/admin-ui/src/sdk/shims/react.ts b/admin-ui/src/sdk/shims/react.ts deleted file mode 100644 index be490735c..000000000 --- a/admin-ui/src/sdk/shims/react.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { hostDep } from './host'; - -const React = hostDep('react'); - -export default React.default ?? React; - -export const Children = React.Children; -export const Component = React.Component; -export const Fragment = React.Fragment; -export const Profiler = React.Profiler; -export const PureComponent = React.PureComponent; -export const StrictMode = React.StrictMode; -export const Suspense = React.Suspense; -export const act = React.act; -export const cache = React.cache; -export const cloneElement = React.cloneElement; -export const createContext = React.createContext; -export const createElement = React.createElement; -export const createRef = React.createRef; -export const forwardRef = React.forwardRef; -export const isValidElement = React.isValidElement; -export const lazy = React.lazy; -export const memo = React.memo; -export const startTransition = React.startTransition; -export const use = React.use; -export const useActionState = React.useActionState; -export const useCallback = React.useCallback; -export const useContext = React.useContext; -export const useDebugValue = React.useDebugValue; -export const useDeferredValue = React.useDeferredValue; -export const useEffect = React.useEffect; -export const useId = React.useId; -export const useImperativeHandle = React.useImperativeHandle; -export const useInsertionEffect = React.useInsertionEffect; -export const useLayoutEffect = React.useLayoutEffect; -export const useMemo = React.useMemo; -export const useOptimistic = React.useOptimistic; -export const useReducer = React.useReducer; -export const useRef = React.useRef; -export const useState = React.useState; -export const useSyncExternalStore = React.useSyncExternalStore; -export const useTransition = React.useTransition; -export const version = React.version; diff --git a/admin-ui/tsup.config.ts b/admin-ui/tsup.config.ts index 22c1d053d..48328bd1b 100644 --- a/admin-ui/tsup.config.ts +++ b/admin-ui/tsup.config.ts @@ -1,4 +1,77 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'tsup'; +import { SHARED_DEP_SHIMS } from './src/sdk/plugin-runtime.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const VALID_IDENT = /^[a-zA-Z$_][a-zA-Z0-9$_]*$/; + +async function generateShimSource(specifier: string): Promise { + let hasDefault = false; + let named: string[] = []; + + try { + const mod = await import(specifier); + const allKeys = Object.keys(mod); + hasDefault = allKeys.includes('default'); + named = allKeys.filter( + (k) => + k !== 'default' && + k !== '__esModule' && + !k.startsWith('__') && + VALID_IDENT.test(k), + ); + } catch { + hasDefault = true; + } + + const lines = [ + `import { hostDep } from './host';`, + `const dep = hostDep(${JSON.stringify(specifier)});`, + ]; + + if (hasDefault) { + lines.push(`export default dep.default ?? dep;`); + } + + for (const name of named) { + lines.push(`export const ${name} = dep.${name};`); + } + + return lines.join('\n'); +} + +const shimDir = resolve(__dirname, 'src/sdk/shims'); + +const specifierByPath = new Map( + Object.entries(SHARED_DEP_SHIMS).map(([specifier, distFile]) => { + const tsFile = distFile.replace('shims/', '').replace('.js', '.ts'); + return [resolve(shimDir, tsFile), specifier]; + }), +); + +const shimEntries = Object.fromEntries( + Object.entries(SHARED_DEP_SHIMS).map(([, distFile]) => { + const key = distFile.replace('.js', ''); + const tsFile = distFile.replace('shims/', '').replace('.js', '.ts'); + return [key, `src/sdk/shims/${tsFile}`]; + }), +); + +const shimPlugin = { + name: 'unchained-shim-generator', + setup(build: any) { + build.onLoad( + { filter: /src\/sdk\/shims\/.*\.ts$/ }, + async (args: any) => { + const specifier = specifierByPath.get(args.path); + if (!specifier) return undefined; // host.ts and unknown files pass through + const contents = await generateShimSource(specifier); + return { contents, loader: 'ts', resolveDir: shimDir }; + }, + ); + }, +}; export default defineConfig({ entry: { @@ -9,20 +82,7 @@ export default defineConfig({ modal: 'src/sdk/modal.ts', theme: 'src/sdk/theme.ts', plugins: 'src/sdk/plugins.ts', - 'shims/react': 'src/sdk/shims/react.ts', - 'shims/react-jsx-runtime': 'src/sdk/shims/react-jsx-runtime.ts', - 'shims/react-dom': 'src/sdk/shims/react-dom.ts', - 'shims/react-dom-client': 'src/sdk/shims/react-dom-client.ts', - 'shims/apollo-client': 'src/sdk/shims/apollo-client.ts', - 'shims/apollo-client-react': 'src/sdk/shims/apollo-client-react.ts', - 'shims/next-router': 'src/sdk/shims/next-router.ts', - 'shims/next-link': 'src/sdk/shims/next-link.ts', - 'shims/next-image': 'src/sdk/shims/next-image.ts', - 'shims/next-head': 'src/sdk/shims/next-head.ts', - 'shims/react-intl': 'src/sdk/shims/react-intl.ts', - 'shims/react-toastify': 'src/sdk/shims/react-toastify.ts', - 'shims/formik': 'src/sdk/shims/formik.ts', - 'shims/admin-ui-plugins': 'src/sdk/shims/admin-ui-plugins.ts', + ...shimEntries, 'modules/accounts': 'src/modules/accounts/index.ts', 'modules/assortment': 'src/modules/assortment/index.ts', 'modules/country': 'src/modules/country/index.ts', @@ -64,6 +124,7 @@ export default defineConfig({ 'react-toastify', 'graphql', ], + esbuildPlugins: [shimPlugin], esbuildOptions(options) { options.alias = { '@/*': './src/*', From 417bd3af67649cf0a2da8d3c554d58f3871471da Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Mon, 6 Jul 2026 23:17:29 +0300 Subject: [PATCH 05/11] Fix tsup config to work with packages that require .js ext in plain Node ESM --- admin-ui/tsup.config.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/admin-ui/tsup.config.ts b/admin-ui/tsup.config.ts index 48328bd1b..dadd3965a 100644 --- a/admin-ui/tsup.config.ts +++ b/admin-ui/tsup.config.ts @@ -10,8 +10,18 @@ async function generateShimSource(specifier: string): Promise { let hasDefault = false; let named: string[] = []; + let mod: Record | null = null; try { - const mod = await import(specifier); + mod = await import(specifier); + } catch { + // Some packages (e.g. next/router) need a .js extension in plain Node ESM + try { + mod = await import(specifier + '.js'); + } catch { + // Fall through — default-only shim + } + } + if (mod) { const allKeys = Object.keys(mod); hasDefault = allKeys.includes('default'); named = allKeys.filter( @@ -21,7 +31,7 @@ async function generateShimSource(specifier: string): Promise { !k.startsWith('__') && VALID_IDENT.test(k), ); - } catch { + } else { hasDefault = true; } From bd52e6112c36daee889f7137453720f3f3eb9db6 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Tue, 7 Jul 2026 06:28:49 +0300 Subject: [PATCH 06/11] Harden plugin runtime: drift guard, cache headers, dead code cleanup --- .../src/modules/plugins/PluginContext.tsx | 48 ++++++++----------- admin-ui/tsup.config.ts | 19 ++++++-- examples/kitchensink/package.json | 6 +-- packages/api/src/adminUiPlugins.ts | 3 +- packages/api/src/express/index.ts | 1 + packages/api/src/fastify/index.ts | 1 + 6 files changed, 43 insertions(+), 35 deletions(-) diff --git a/admin-ui/src/modules/plugins/PluginContext.tsx b/admin-ui/src/modules/plugins/PluginContext.tsx index a88cb2772..e72dca715 100644 --- a/admin-ui/src/modules/plugins/PluginContext.tsx +++ b/admin-ui/src/modules/plugins/PluginContext.tsx @@ -18,13 +18,12 @@ import * as ReactIntl from 'react-intl'; import * as ReactToastify from 'react-toastify'; import * as Formik from 'formik'; import { definePlugin } from '../../sdk/plugins'; +import { SHARED_DEP_SHIMS } from '../../sdk/plugin-runtime.mjs'; import { usePluginRuntime } from './PluginRuntimeContext'; declare global { interface Window { __UNCHAINED_PLUGIN_DEPS__: Record; - /** Legacy registry used by pre-ESM (IIFE) plugin bundles. */ - __UNCHAINED_PLUGINS__?: Record>; } } @@ -102,34 +101,31 @@ const setupPluginRuntime = () => { formik: Formik, '@unchainedshop/admin-ui/plugins': { definePlugin, usePluginRuntime }, }; + + if (process.env.NODE_ENV !== 'production') { + const missing = Object.keys(SHARED_DEP_SHIMS).filter( + (k) => !(k in window.__UNCHAINED_PLUGIN_DEPS__), + ); + if (missing.length > 0) { + console.warn( + `admin-ui plugin runtime: SHARED_DEP_SHIMS has entries not registered in __UNCHAINED_PLUGIN_DEPS__: ${missing.join(', ')}`, + ); + } + } }; -const ensureImportMap = async (baseUrl: string): Promise => { +const checkImportMap = (): boolean => { if ( document.querySelector('script[type="importmap"][data-unchained-admin-ui]') ) - return; - - const res = await fetch(`${baseUrl}/admin-ui-importmap.json`, { - cache: 'no-cache', - }); - if (!res.ok) { - throw new Error(`Failed to fetch import map: HTTP ${res.status}`); - } - const map = await res.json(); - const base = baseUrl || window.location.origin; - const imports: Record = {}; - for (const [specifier, target] of Object.entries(map?.imports || {})) { - if (typeof target !== 'string') continue; - imports[specifier] = new URL(target, base).href; - } - if (Object.keys(imports).length === 0) return; + return true; - const script = document.createElement('script'); - script.type = 'importmap'; - script.setAttribute('data-unchained-admin-ui', ''); - script.textContent = JSON.stringify({ imports }); - document.head.appendChild(script); + console.warn( + 'admin-ui plugin runtime: import map not found in HTML. ' + + 'The server must inject the import map into before any module scripts run. ' + + 'Plugins that depend on shared host dependencies will fail to load.', + ); + return false; }; const loadPluginModule = async ( @@ -142,8 +138,6 @@ const loadPluginModule = async ( ).href; const mod = await import(/* webpackIgnore: true */ url); if (mod && Object.keys(mod).length > 0) return mod; - const legacy = window.__UNCHAINED_PLUGINS__?.[manifest.name]; - if (legacy) return legacy; console.error( `Plugin "${manifest.name}" loaded but exports no components. ` + 'Rebuild it with the current @unchainedshop/admin-ui/plugin-build.', @@ -177,7 +171,7 @@ export const PluginProvider = ({ children }: { children: ReactNode }) => { } setManifests(data); - await ensureImportMap(baseUrl); + checkImportMap(); if (cancelled) return; const loaded = new Map(); diff --git a/admin-ui/tsup.config.ts b/admin-ui/tsup.config.ts index dadd3965a..7a256f581 100644 --- a/admin-ui/tsup.config.ts +++ b/admin-ui/tsup.config.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'tsup'; @@ -18,7 +19,7 @@ async function generateShimSource(specifier: string): Promise { try { mod = await import(specifier + '.js'); } catch { - // Fall through — default-only shim + // Fall through — try resolving the source file } } if (mod) { @@ -32,7 +33,20 @@ async function generateShimSource(specifier: string): Promise { VALID_IDENT.test(k), ); } else { - hasDefault = true; + // Last resort: resolve the package entry and extract exports from source + try { + const resolved = import.meta.resolve(specifier); + const source = readFileSync(new URL(resolved).pathname, 'utf-8'); + const exportRe = /\bexport\s+(?:function|const|let|var|class)\s+([a-zA-Z$_][a-zA-Z0-9$_]*)/g; + let m; + while ((m = exportRe.exec(source)) !== null) { + if (VALID_IDENT.test(m[1])) named.push(m[1]); + } + hasDefault = /\bexport\s+default\b/.test(source); + if (named.length === 0 && !hasDefault) hasDefault = true; + } catch { + hasDefault = true; + } } const lines = [ @@ -132,7 +146,6 @@ export default defineConfig({ '@apollo/client/react', 'react-intl', 'react-toastify', - 'graphql', ], esbuildPlugins: [shimPlugin], esbuildOptions(options) { diff --git a/examples/kitchensink/package.json b/examples/kitchensink/package.json index b28cb4043..6b0fa21e4 100644 --- a/examples/kitchensink/package.json +++ b/examples/kitchensink/package.json @@ -29,9 +29,9 @@ "lint": "prettier -w .", "clean": "tsc -b --clean", "build": "tsc -b", - "start": "npx -y node@22 --no-warnings --env-file=.env.defaults --env-file-if-exists=.env --import ./load_env.js lib/boot.js", - "dev": "npx -y node@22 --no-warnings --env-file=.env.defaults --env-file-if-exists=.env --import ./load_env.js --watch --experimental-strip-types src/boot.ts", - "integration-test": "npx -y node@22 --no-warnings --env-file=.env.defaults --env-file ../../.env.tests --env-file-if-exists=../../.env --import ./load_env.js --watch --experimental-strip-types src/boot.ts" + "start": "node --no-warnings --env-file=.env.defaults --env-file-if-exists=.env --import ./load_env.js lib/boot.js", + "dev": "node --no-warnings --env-file=.env.defaults --env-file-if-exists=.env --import ./load_env.js --watch --experimental-strip-types src/boot.ts", + "integration-test": "node --no-warnings --env-file=.env.defaults --env-file ../../.env.tests --env-file-if-exists=../../.env --import ./load_env.js --watch --experimental-strip-types src/boot.ts" }, "dependencies": { "@ai-sdk/openai": "^3.0.7", diff --git a/packages/api/src/adminUiPlugins.ts b/packages/api/src/adminUiPlugins.ts index 55e4029bc..c4c374384 100644 --- a/packages/api/src/adminUiPlugins.ts +++ b/packages/api/src/adminUiPlugins.ts @@ -96,8 +96,7 @@ export const resolveAdminUIPath = (): string | null => { } }; -const contentHash = (content: string) => - createHash('sha256').update(content).digest('hex').slice(0, 8); +const contentHash = (content: string) => createHash('sha256').update(content).digest('hex').slice(0, 8); /** * Enumerate the prebuilt ESM files of the admin-ui SDK (entries plus their diff --git a/packages/api/src/express/index.ts b/packages/api/src/express/index.ts index 8407802c7..3007e05c1 100644 --- a/packages/api/src/express/index.ts +++ b/packages/api/src/express/index.ts @@ -103,6 +103,7 @@ export const adminUIRouter = ( : null; router.get(/(.*)/, (req, res) => { + if (devMode) res.set('Cache-Control', 'no-cache'); const urlPath = req.path.replace(/\/+$/, ''); if (extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { return res.type('text/html').send(extHtml); diff --git a/packages/api/src/fastify/index.ts b/packages/api/src/fastify/index.ts index 379cb9995..c5666d798 100644 --- a/packages/api/src/fastify/index.ts +++ b/packages/api/src/fastify/index.ts @@ -336,6 +336,7 @@ export const adminUIRouter: FastifyPluginAsync = async ( fastify.setNotFoundHandler(async (request, reply) => { if (request.method === 'GET' && !request.url.includes('.')) { + if (process.env.NODE_ENV !== 'production') reply.header('Cache-Control', 'no-cache'); const urlPath = request.url.split('?')[0].replace(/\/+$/, ''); if (extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { return reply.type('text/html').send(extHtml); From 126a2acdc85fb4c6626d80840649ee7c25e39ac4 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Tue, 7 Jul 2026 12:19:25 +0300 Subject: [PATCH 07/11] Harden plugin runtime: build-time validation, CSP nonce, CORS, mtime caching --- .../src/modules/plugins/PluginContext.tsx | 11 +-- admin-ui/src/sdk/generate-shims.mjs | 33 ------- admin-ui/src/sdk/plugin-runtime.d.mts | 3 + admin-ui/src/sdk/plugin-runtime.mjs | 60 +++++++------ admin-ui/tsup.config.ts | 88 +++++++++++++------ packages/api/src/adminUiPlugins.ts | 12 ++- packages/api/src/express/index.ts | 37 +++++--- packages/api/src/fastify/index.ts | 41 ++++++--- 8 files changed, 170 insertions(+), 115 deletions(-) delete mode 100644 admin-ui/src/sdk/generate-shims.mjs diff --git a/admin-ui/src/modules/plugins/PluginContext.tsx b/admin-ui/src/modules/plugins/PluginContext.tsx index e72dca715..b52e847a7 100644 --- a/admin-ui/src/modules/plugins/PluginContext.tsx +++ b/admin-ui/src/modules/plugins/PluginContext.tsx @@ -132,10 +132,8 @@ const loadPluginModule = async ( manifest: PluginManifest, baseUrl: string, ): Promise => { - const url = new URL( - manifest.bundleUrl, - baseUrl || window.location.origin, - ).href; + const url = new URL(manifest.bundleUrl, baseUrl || window.location.origin) + .href; const mod = await import(/* webpackIgnore: true */ url); if (mod && Object.keys(mod).length > 0) return mod; console.error( @@ -171,7 +169,10 @@ export const PluginProvider = ({ children }: { children: ReactNode }) => { } setManifests(data); - checkImportMap(); + if (!checkImportMap()) { + setLoading(false); + return; + } if (cancelled) return; const loaded = new Map(); diff --git a/admin-ui/src/sdk/generate-shims.mjs b/admin-ui/src/sdk/generate-shims.mjs deleted file mode 100644 index ac9c81a69..000000000 --- a/admin-ui/src/sdk/generate-shims.mjs +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node -/** - * Writes minimal stub files for each shim entry so tsup can resolve them. - * The actual shim content (with dynamically discovered exports) is generated - * at build time by the esbuild plugin in tsup.config.ts — these stubs are - * never used as-is. - * - * Usage: node src/sdk/generate-shims.mjs - */ -import { existsSync, writeFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { SHARED_DEP_SHIMS } from './plugin-runtime.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const shimsDir = join(__dirname, 'shims'); - -let created = 0; -for (const [specifier, distFile] of Object.entries(SHARED_DEP_SHIMS)) { - const tsFile = distFile.replace('shims/', '').replace('.js', '.ts'); - const filePath = join(shimsDir, tsFile); - if (!existsSync(filePath)) { - writeFileSync( - filePath, - `// Stub — real content generated at build time by tsup.config.ts\nimport { hostDep } from './host';\nexport default hostDep(${JSON.stringify(specifier)});\n`, - ); - created++; - } -} - -if (created > 0) { - console.log(`Created ${created} shim stub(s)`); -} diff --git a/admin-ui/src/sdk/plugin-runtime.d.mts b/admin-ui/src/sdk/plugin-runtime.d.mts index d3590659a..0635c0179 100644 --- a/admin-ui/src/sdk/plugin-runtime.d.mts +++ b/admin-ui/src/sdk/plugin-runtime.d.mts @@ -1,6 +1,9 @@ /** Bare specifiers provided by the host app, mapped to their shim file in dist/. */ export declare const SHARED_DEP_SHIMS: Record; +/** SDK entry keys built by tsup.config.ts (excluding shim entries). */ +export declare const SDK_ENTRY_KEYS: string[]; + /** SDK subpath exports resolvable from plugin bundles, mapped to their dist file. */ export declare const SDK_MODULE_FILES: Record; diff --git a/admin-ui/src/sdk/plugin-runtime.mjs b/admin-ui/src/sdk/plugin-runtime.mjs index 336361c81..a4374a5fd 100644 --- a/admin-ui/src/sdk/plugin-runtime.mjs +++ b/admin-ui/src/sdk/plugin-runtime.mjs @@ -35,32 +35,42 @@ export const SHARED_DEP_SHIMS = { '@unchainedshop/admin-ui/plugins': 'shims/admin-ui-plugins.js', }; +/** + * SDK entry keys built by tsup.config.ts (excluding shim entries). Each key + * maps to `@unchainedshop/admin-ui/{key}` as the import specifier and + * `{key}.js` as the dist file. Add new SDK entries here — tsup.config.ts + * validates that its entry map stays in sync at build time. + */ +export const SDK_ENTRY_KEYS = [ + 'ui', + 'form', + 'hooks', + 'providers', + 'modal', + 'theme', + 'modules/accounts', + 'modules/assortment', + 'modules/country', + 'modules/currency', + 'modules/delivery-provider', + 'modules/enrollment', + 'modules/event', + 'modules/filter', + 'modules/language', + 'modules/order', + 'modules/payment-providers', + 'modules/product', + 'modules/product-review', + 'modules/quotation', + 'modules/token', + 'modules/warehousing-providers', + 'modules/work', +]; + /** SDK subpath exports resolvable from plugin bundles, mapped to their dist file. */ -export const SDK_MODULE_FILES = { - '@unchainedshop/admin-ui/ui': 'ui.js', - '@unchainedshop/admin-ui/form': 'form.js', - '@unchainedshop/admin-ui/hooks': 'hooks.js', - '@unchainedshop/admin-ui/providers': 'providers.js', - '@unchainedshop/admin-ui/modal': 'modal.js', - '@unchainedshop/admin-ui/theme': 'theme.js', - '@unchainedshop/admin-ui/modules/accounts': 'modules/accounts.js', - '@unchainedshop/admin-ui/modules/assortment': 'modules/assortment.js', - '@unchainedshop/admin-ui/modules/country': 'modules/country.js', - '@unchainedshop/admin-ui/modules/currency': 'modules/currency.js', - '@unchainedshop/admin-ui/modules/delivery-provider': 'modules/delivery-provider.js', - '@unchainedshop/admin-ui/modules/enrollment': 'modules/enrollment.js', - '@unchainedshop/admin-ui/modules/event': 'modules/event.js', - '@unchainedshop/admin-ui/modules/filter': 'modules/filter.js', - '@unchainedshop/admin-ui/modules/language': 'modules/language.js', - '@unchainedshop/admin-ui/modules/order': 'modules/order.js', - '@unchainedshop/admin-ui/modules/payment-providers': 'modules/payment-providers.js', - '@unchainedshop/admin-ui/modules/product': 'modules/product.js', - '@unchainedshop/admin-ui/modules/product-review': 'modules/product-review.js', - '@unchainedshop/admin-ui/modules/quotation': 'modules/quotation.js', - '@unchainedshop/admin-ui/modules/token': 'modules/token.js', - '@unchainedshop/admin-ui/modules/warehousing-providers': 'modules/warehousing-providers.js', - '@unchainedshop/admin-ui/modules/work': 'modules/work.js', -}; +export const SDK_MODULE_FILES = Object.fromEntries( + SDK_ENTRY_KEYS.map((key) => [`@unchainedshop/admin-ui/${key}`, `${key}.js`]), +); /** All bare specifiers a plugin bundle may leave external. */ export const PLUGIN_EXTERNALS = [ diff --git a/admin-ui/tsup.config.ts b/admin-ui/tsup.config.ts index 7a256f581..86c26a3b5 100644 --- a/admin-ui/tsup.config.ts +++ b/admin-ui/tsup.config.ts @@ -1,8 +1,8 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'tsup'; -import { SHARED_DEP_SHIMS } from './src/sdk/plugin-runtime.mjs'; +import { SHARED_DEP_SHIMS, SDK_ENTRY_KEYS } from './src/sdk/plugin-runtime.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const VALID_IDENT = /^[a-zA-Z$_][a-zA-Z0-9$_]*$/; @@ -67,6 +67,21 @@ async function generateShimSource(specifier: string): Promise { const shimDir = resolve(__dirname, 'src/sdk/shims'); +// Generate shim stub files at config-evaluation time so tsup can resolve them +// as entry points. The esbuild onLoad plugin replaces their content with the +// real shim source at build time. This replaces the standalone generate-shims.mjs. +if (!existsSync(shimDir)) mkdirSync(shimDir, { recursive: true }); +for (const [specifier, distFile] of Object.entries(SHARED_DEP_SHIMS)) { + const tsFile = distFile.replace('shims/', '').replace('.js', '.ts'); + const filePath = resolve(shimDir, tsFile); + if (!existsSync(filePath)) { + writeFileSync( + filePath, + `// Stub — real content generated at build time by tsup.config.ts\nimport { hostDep } from './host';\nexport default hostDep(${JSON.stringify(specifier)});\n`, + ); + } +} + const specifierByPath = new Map( Object.entries(SHARED_DEP_SHIMS).map(([specifier, distFile]) => { const tsFile = distFile.replace('shims/', '').replace('.js', '.ts'); @@ -97,34 +112,51 @@ const shimPlugin = { }, }; +// Validate SDK_ENTRY_KEYS matches the actual tsup entries at config-evaluation time. +// This catches forgotten additions in plugin-runtime.mjs when a new SDK module is added. +const sdkKeySet = new Set(SDK_ENTRY_KEYS); +const hardcodedSdkEntries: Record = { + ui: 'src/components/ui/index.ts', + form: 'src/components/ui/form/index.ts', + hooks: 'src/sdk/hooks.ts', + providers: 'src/sdk/providers.ts', + modal: 'src/sdk/modal.ts', + theme: 'src/sdk/theme.ts', + 'modules/accounts': 'src/modules/accounts/index.ts', + 'modules/assortment': 'src/modules/assortment/index.ts', + 'modules/country': 'src/modules/country/index.ts', + 'modules/currency': 'src/modules/currency/index.ts', + 'modules/delivery-provider': 'src/modules/delivery-provider/index.ts', + 'modules/enrollment': 'src/modules/enrollment/index.ts', + 'modules/event': 'src/modules/event/index.ts', + 'modules/filter': 'src/modules/filter/index.ts', + 'modules/language': 'src/modules/language/index.ts', + 'modules/order': 'src/modules/order/index.ts', + 'modules/payment-providers': 'src/modules/payment-providers/index.ts', + 'modules/product': 'src/modules/product/index.ts', + 'modules/product-review': 'src/modules/product-review/index.ts', + 'modules/quotation': 'src/modules/quotation/index.ts', + 'modules/token': 'src/modules/token/index.ts', + 'modules/warehousing-providers': 'src/modules/warehousing-providers/index.ts', + 'modules/work': 'src/modules/work/index.ts', +}; + +const missingFromRuntime = Object.keys(hardcodedSdkEntries).filter((k) => !sdkKeySet.has(k)); +const extraInRuntime = SDK_ENTRY_KEYS.filter((k) => !(k in hardcodedSdkEntries)); +if (missingFromRuntime.length > 0 || extraInRuntime.length > 0) { + const parts: string[] = []; + if (missingFromRuntime.length > 0) + parts.push(`Missing from SDK_ENTRY_KEYS in plugin-runtime.mjs: ${missingFromRuntime.join(', ')}`); + if (extraInRuntime.length > 0) + parts.push(`In SDK_ENTRY_KEYS but not in tsup entries: ${extraInRuntime.join(', ')}`); + throw new Error(`admin-ui SDK entry sync check failed:\n${parts.join('\n')}`); +} + export default defineConfig({ entry: { - ui: 'src/components/ui/index.ts', - form: 'src/components/ui/form/index.ts', - hooks: 'src/sdk/hooks.ts', - providers: 'src/sdk/providers.ts', - modal: 'src/sdk/modal.ts', - theme: 'src/sdk/theme.ts', + ...hardcodedSdkEntries, plugins: 'src/sdk/plugins.ts', ...shimEntries, - 'modules/accounts': 'src/modules/accounts/index.ts', - 'modules/assortment': 'src/modules/assortment/index.ts', - 'modules/country': 'src/modules/country/index.ts', - 'modules/currency': 'src/modules/currency/index.ts', - 'modules/delivery-provider': 'src/modules/delivery-provider/index.ts', - 'modules/enrollment': 'src/modules/enrollment/index.ts', - 'modules/event': 'src/modules/event/index.ts', - 'modules/filter': 'src/modules/filter/index.ts', - 'modules/language': 'src/modules/language/index.ts', - 'modules/order': 'src/modules/order/index.ts', - 'modules/payment-providers': 'src/modules/payment-providers/index.ts', - 'modules/product': 'src/modules/product/index.ts', - 'modules/product-review': 'src/modules/product-review/index.ts', - 'modules/quotation': 'src/modules/quotation/index.ts', - 'modules/token': 'src/modules/token/index.ts', - 'modules/warehousing-providers': - 'src/modules/warehousing-providers/index.ts', - 'modules/work': 'src/modules/work/index.ts', }, format: ['esm'], platform: 'browser', @@ -141,7 +173,7 @@ export default defineConfig({ 'next/link', 'next/image', 'next/router', - 'react-hook-form', + 'formik', '@apollo/client', '@apollo/client/react', 'react-intl', @@ -153,4 +185,4 @@ export default defineConfig({ '@/*': './src/*', }; }, -}); +}); \ No newline at end of file diff --git a/packages/api/src/adminUiPlugins.ts b/packages/api/src/adminUiPlugins.ts index c4c374384..502fd0721 100644 --- a/packages/api/src/adminUiPlugins.ts +++ b/packages/api/src/adminUiPlugins.ts @@ -85,6 +85,7 @@ export interface PreparedPluginAssets { routes: Map; validPlugins: AdminUIPluginConfig[]; importMapTag: string | null; + importMapJSON: string | null; } export const resolveAdminUIPath = (): string | null => { @@ -236,6 +237,7 @@ export function preparePluginAssets( } let importMapTag: string | null = null; + let importMapContent: string | null = null; if (pluginBundles.size > 0) { const adminUIPath = resolveAdminUIPath(); @@ -290,8 +292,7 @@ export function preparePluginAssets( etag: `"${importMapHash}"`, }); - // The marker attribute lets the client plugin loader detect that the - // import map is already present (see admin-ui PluginContext.tsx). + importMapContent = importMapJSON; importMapTag = ``; } else { log.warn( @@ -300,5 +301,10 @@ export function preparePluginAssets( } } - return { routes, validPlugins, importMapTag }; + return { routes, validPlugins, importMapTag, importMapJSON: importMapContent }; +} + +export function buildImportMapTag(importMapJSON: string, nonce?: string): string { + const nonceAttr = nonce ? ` nonce="${nonce}"` : ''; + return ``; } diff --git a/packages/api/src/express/index.ts b/packages/api/src/express/index.ts index 3007e05c1..8ba65242f 100644 --- a/packages/api/src/express/index.ts +++ b/packages/api/src/express/index.ts @@ -16,7 +16,12 @@ import { connectChat } from './chatHandler.ts'; import { mountRoutes } from './mountRoutes.ts'; import { createBackchannelLogoutRoute } from '../handlers/createBackchannelLogoutHandler.ts'; import { generateThemeCSS, type AdminUIThemeConfig } from '@unchainedshop/admin-ui/theme'; -import { preparePluginAssets, resolveAdminUIPath, type AdminUIPluginConfig } from '../adminUiPlugins.ts'; +import { + preparePluginAssets, + buildImportMapTag, + resolveAdminUIPath, + type AdminUIPluginConfig, +} from '../adminUiPlugins.ts'; export type { AdminUIPluginConfig, @@ -63,7 +68,11 @@ export const adminUIRouter = ( const log = { info: console.info, warn: console.warn }; const devMode = process.env.NODE_ENV !== 'production'; - const { routes: pluginRoutes, importMapTag } = preparePluginAssets(plugins, log, { devMode }); + const { + routes: pluginRoutes, + importMapTag, + importMapJSON, + } = preparePluginAssets(plugins, log, { devMode }); for (const [path, asset] of pluginRoutes) { router.get(path, (req, res) => { @@ -91,20 +100,28 @@ export const adminUIRouter = ( // entity/page routes live under /ext/*, pre-rendered to a dedicated HTML // file. Hard loads of /ext/* must get that file, everything else gets the // root index.html. - if (importMapTag) { - const injectImportMap = (html: string) => html.replace('', `${importMapTag}`); - + if (importMapJSON) { const indexHtml = readFileSync(join(adminUIPath, 'index.html'), 'utf-8'); - const injectedHtml = injectImportMap(indexHtml); - const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); - const extHtml = existsSync(extHtmlPath) - ? injectImportMap(readFileSync(extHtmlPath, 'utf-8')) - : null; + const extHtmlRaw = existsSync(extHtmlPath) ? readFileSync(extHtmlPath, 'utf-8') : null; + + // Pre-build non-nonce versions for when no CSP nonce is present + const defaultTag = importMapTag!; + const injectedHtml = indexHtml.replace('', `${defaultTag}`); + const extHtml = extHtmlRaw ? extHtmlRaw.replace('', `${defaultTag}`) : null; router.get(/(.*)/, (req, res) => { if (devMode) res.set('Cache-Control', 'no-cache'); const urlPath = req.path.replace(/\/+$/, ''); + + const nonce = (res as any).locals?.cspNonce as string | undefined; + if (nonce) { + const tag = buildImportMapTag(importMapJSON, nonce); + const baseHtml = + extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/')) ? extHtmlRaw! : indexHtml; + return res.type('text/html').send(baseHtml.replace('', `${tag}`)); + } + if (extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { return res.type('text/html').send(extHtml); } diff --git a/packages/api/src/fastify/index.ts b/packages/api/src/fastify/index.ts index c5666d798..a91a4544f 100644 --- a/packages/api/src/fastify/index.ts +++ b/packages/api/src/fastify/index.ts @@ -16,7 +16,12 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { createBackchannelLogoutRoute } from '../handlers/createBackchannelLogoutHandler.ts'; import { generateThemeCSS, type AdminUIThemeConfig } from '@unchainedshop/admin-ui/theme'; -import { preparePluginAssets, resolveAdminUIPath, type AdminUIPluginConfig } from '../adminUiPlugins.ts'; +import { + preparePluginAssets, + buildImportMapTag, + resolveAdminUIPath, + type AdminUIPluginConfig, +} from '../adminUiPlugins.ts'; export type { AdminUIPluginConfig, @@ -35,6 +40,7 @@ export interface AdminUIRouterOptions { theme?: AdminUIThemeConfig; plugins?: AdminUIPluginConfig[]; importMapTag?: string | null; + importMapJSON?: string | null; } /** @@ -246,7 +252,11 @@ export const connect = async ( }); const devMode = process.env.NODE_ENV !== 'production'; - const { routes: pluginRoutes, importMapTag } = preparePluginAssets(adminUIPlugins, fastify.log, { + const { + routes: pluginRoutes, + importMapTag, + importMapJSON, + } = preparePluginAssets(adminUIPlugins, fastify.log, { devMode, }); @@ -274,6 +284,7 @@ export const connect = async ( prefix: adminUIOptions?.prefix || '/', plugins: adminUIPlugins, importMapTag, + importMapJSON, }); } }; @@ -315,17 +326,14 @@ export const adminUIRouter: FastifyPluginAsync = async ( // entity/page routes live under /ext/*, pre-rendered to a dedicated // HTML file. Hard loads of /ext/* must get that file, other non-file // paths fall back to the root index.html. - if (opts.importMapTag) { - const injectImportMap = (html: string) => - html.replace('', `${opts.importMapTag}`); - + if (opts.importMapJSON) { const indexHtml = readFileSync(join(adminUIPath, 'index.html'), 'utf-8'); - const injectedHtml = injectImportMap(indexHtml); - const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html'); - const extHtml = existsSync(extHtmlPath) - ? injectImportMap(readFileSync(extHtmlPath, 'utf-8')) - : null; + const extHtmlRaw = existsSync(extHtmlPath) ? readFileSync(extHtmlPath, 'utf-8') : null; + + const defaultTag = opts.importMapTag!; + const injectedHtml = indexHtml.replace('', `${defaultTag}`); + const extHtml = extHtmlRaw ? extHtmlRaw.replace('', `${defaultTag}`) : null; await fastify.register(fastifyStatic, { root: adminUIPath, @@ -338,6 +346,17 @@ export const adminUIRouter: FastifyPluginAsync = async ( if (request.method === 'GET' && !request.url.includes('.')) { if (process.env.NODE_ENV !== 'production') reply.header('Cache-Control', 'no-cache'); const urlPath = request.url.split('?')[0].replace(/\/+$/, ''); + + const nonce = (reply as any).cspNonce?.script as string | undefined; + if (nonce) { + const tag = buildImportMapTag(opts.importMapJSON!, nonce); + const baseHtml = + extHtmlRaw && (urlPath === '/ext' || urlPath.startsWith('/ext/')) + ? extHtmlRaw + : indexHtml; + return reply.type('text/html').send(baseHtml.replace('', `${tag}`)); + } + if (extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) { return reply.type('text/html').send(extHtml); } From 6206237035d8d30920fbed86fbc69222c3c663c2 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Tue, 7 Jul 2026 23:29:53 +0300 Subject: [PATCH 08/11] Fix rebase --- admin-ui/package.json | 3 +- .../src/modules/plugins/PluginContext.tsx | 46 +++++++++++++++---- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/admin-ui/package.json b/admin-ui/package.json index 1adf95709..c51f4b69b 100644 --- a/admin-ui/package.json +++ b/admin-ui/package.json @@ -105,8 +105,7 @@ "prepublishOnly": "npm run build", "dev": "next dev", "build": "NODE_ENV=production next build && npm run build:sdk", - "generate:shims": "node src/sdk/generate-shims.mjs", - "build:sdk": "npm run generate:shims && tsup && tsc -p tsconfig.sdk.json", + "build:sdk": "tsup && tsc -p tsconfig.sdk.json", "start": "next start", "lint": "eslint .", "format": "eslint --fix .", diff --git a/admin-ui/src/modules/plugins/PluginContext.tsx b/admin-ui/src/modules/plugins/PluginContext.tsx index b52e847a7..5441e6f42 100644 --- a/admin-ui/src/modules/plugins/PluginContext.tsx +++ b/admin-ui/src/modules/plugins/PluginContext.tsx @@ -114,18 +114,48 @@ const setupPluginRuntime = () => { } }; -const checkImportMap = (): boolean => { +const ensureImportMap = async (baseUrl: string): Promise => { if ( document.querySelector('script[type="importmap"][data-unchained-admin-ui]') ) return true; - console.warn( - 'admin-ui plugin runtime: import map not found in HTML. ' + - 'The server must inject the import map into before any module scripts run. ' + - 'Plugins that depend on shared host dependencies will fail to load.', - ); - return false; + // Import map not pre-injected (e.g. Next.js dev server on port 3000). + // Fetch it from the backend and inject it dynamically. Import maps must be + // added before any module scripts run, but since plugin ESM hasn't loaded + // yet at this point the timing is safe. + try { + const res = await fetch(`${baseUrl}/admin-ui-importmap.json`, { + cache: 'no-cache', + }); + if (!res.ok) { + console.warn( + 'admin-ui plugin runtime: could not fetch import map from server. ' + + 'Plugins depending on shared host dependencies will fail to load.', + ); + return false; + } + const importMap = await res.json(); + // The import map uses relative URLs (e.g. /admin-ui-sdk/...). When + // injected on a different origin (Next.js dev on port 3000 vs backend + // on port 4010), rewrite them to absolute URLs pointing at the backend. + if (baseUrl && importMap.imports) { + for (const [key, value] of Object.entries(importMap.imports)) { + if (typeof value === 'string' && value.startsWith('/')) { + importMap.imports[key] = `${baseUrl}${value}`; + } + } + } + const script = document.createElement('script'); + script.type = 'importmap'; + script.setAttribute('data-unchained-admin-ui', ''); + script.textContent = JSON.stringify(importMap); + document.head.appendChild(script); + return true; + } catch (err) { + console.warn('admin-ui plugin runtime: failed to inject import map:', err); + return false; + } }; const loadPluginModule = async ( @@ -169,7 +199,7 @@ export const PluginProvider = ({ children }: { children: ReactNode }) => { } setManifests(data); - if (!checkImportMap()) { + if (!(await ensureImportMap(baseUrl))) { setLoading(false); return; } From 3be8ad783b4d3560bb0c7eb19bc10bc98c3d9993 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Wed, 8 Jul 2026 12:35:57 +0300 Subject: [PATCH 09/11] Validate plugin manifests at startup: bundle export checks, SDK version handshake, duplicate-name dedupe, and plugin author docs --- admin-ui/src/sdk/README.md | 170 +++++++++++++++++++++++++++++ admin-ui/src/sdk/shims/host.ts | 12 +- examples/kitchensink/package.json | 6 +- packages/api/src/adminUiPlugins.ts | 109 +++++++++++++++++- packages/api/src/express/index.ts | 4 + packages/api/src/fastify/index.ts | 4 + 6 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 admin-ui/src/sdk/README.md diff --git a/admin-ui/src/sdk/README.md b/admin-ui/src/sdk/README.md new file mode 100644 index 000000000..7ddb72af4 --- /dev/null +++ b/admin-ui/src/sdk/README.md @@ -0,0 +1,170 @@ +# Admin UI Plugins + +Extend the Unchained admin-ui with your own pages, entity managers, tabs, and +dashboard widgets — without forking it. A plugin is a single ESM bundle plus a +manifest you register on the engine; the admin-ui discovers and loads it at +runtime. + +## How it works + +- Plugins are built with `definePluginConfig()` from + `@unchainedshop/admin-ui/plugin-build` into one standard ESM bundle. +- Dependencies the host app already ships (React, Apollo, react-intl, the + `@unchainedshop/admin-ui/*` SDK modules, ...) are left as bare import + specifiers. In the browser, an import map resolves them to the host's own + module instances, so your plugin runs on the exact same React/Apollo as the + admin-ui. Everything else you import gets bundled in. +- The engine serves your bundle under `/admin-plugins/.js` and a + manifest under `/admin-ui-plugins.json`; the admin-ui loads bundles with a + native dynamic `import()`. + +The module graph (which specifiers are shared vs. bundled) is defined in +[`plugin-runtime.mjs`](./plugin-runtime.mjs) — see `SHARED_DEP_SHIMS` and +`SDK_ENTRY_KEYS`. + +## Writing a plugin + +Minimal project layout (see `examples/kitchensink/plugins/bookmark-manager` +for a complete one): + +``` +my-plugin/ +├── package.json +├── tsup.config.ts +└── src/ + └── index.tsx +``` + +`package.json`: + +```json +{ + "name": "my-plugin", + "type": "module", + "scripts": { "build": "tsup", "build:watch": "tsup --watch" }, + "devDependencies": { + "@types/react": "^19.0.0", + "@unchainedshop/admin-ui": "^5.0.0", + "esbuild": "^0.25.0", + "react": "^19.0.0", + "tsup": "^8.0.0" + } +} +``` + +`tsup.config.ts`: + +```ts +import { definePluginConfig } from '@unchainedshop/admin-ui/plugin-build'; + +export default definePluginConfig('my-plugin'); +``` + +`src/index.tsx` — export every component your manifest references, as named +exports: + +```tsx +export { default as ThingList } from './components/ThingList'; +export { default as ThingDetail } from './components/ThingDetail'; +export { default as ThingWidget } from './components/ThingWidget'; +``` + +Inside components you can import from the host SDK: + +```tsx +import { Button } from '@unchainedshop/admin-ui/ui'; +import { useForm } from '@unchainedshop/admin-ui/form'; +import { useProducts } from '@unchainedshop/admin-ui/modules/product'; +import { usePluginRuntime } from '@unchainedshop/admin-ui/plugins'; +import { useQuery } from '@apollo/client/react'; +import { FormattedMessage } from 'react-intl'; +``` + +## Registering a plugin on the engine + +Pass the manifest in the `adminUI.plugins` option of the Express or Fastify +`connect()`: + +```ts +adminUI: { + plugins: [ + { + name: 'my-plugin', // [a-z0-9._-], used in URLs + version: '1.0.0', // optional, informational + bundlePath: resolve(__dirname, '../plugins/my-plugin/dist/index.js'), + navigation: { // optional: groups nav items in a submenu + label: 'My Plugin', + icon: 'bookmark', // heroicon name + requiredRole: 'viewProducts', + sortOrder: 75, // position among sidebar items, see below + }, + slots: { + entities: [ + { + path: '/things', // page lives at /ext/things + label: 'Things', + icon: 'bookmark', + requiredRole: 'viewProducts', + sortOrder: 75, + components: { + list: 'ThingList', // must match a named export of the bundle + detail: 'ThingDetail', + create: 'ThingCreate' // optional + }, + }, + ], + pages: [ + { path: '/reports', label: 'Reports', component: 'ReportsPage' }, + ], + 'dashboard:widgets': [ + { component: 'ThingWidget', width: 'half' }, // full | half | third + ], + 'product:tabs': [ + { label: 'Things', component: 'ProductThingsTab' }, + ], + }, + }, + ], +} +``` + +Slot types: `entities` (list/detail/create pages under `/ext/`), +`pages` (single custom page under `/ext/`), `dashboard:widgets`, and +`:tabs` for `product`, `assortment`, `filter`, `user`, `order`. + +- `requiredRole` — role name checked against the logged-in user; the item is + hidden without it. +- `sortOrder` — position in the sidebar. Built-in items use 0–130 in steps of + 10 (Orders 30, Products 40, Users 70, System settings 110, ...). Items + without a `sortOrder` keep their relative order after ordered ones. + +At startup the engine validates the manifest against the bundle and logs a +warning if a referenced component is not exported, if two plugins share a +name, or if the bundle was built against a different admin-ui SDK version +than the one running. + +## Development workflow + +Run the plugin build in watch mode next to the engine: + +```bash +cd my-plugin && npm run build:watch +``` + +In dev mode (`NODE_ENV !== 'production'`) the engine picks up bundle changes +on the next manifest request — reload the admin-ui to get the new bundle. In +production, bundles are read once at startup and served with immutable +caching, keyed by content hash. + +## Content-Security-Policy + +The engine injects the import map as an inline `