Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions admin-ui/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions admin-ui/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));

export default {
output: 'export',
// Keep the dev server's state out of .next: `next build` writes production
// artifacts there (with output: 'export' a custom distDir would become the
// export destination, so build must stay on the default), and dev runs on a
// .next polluted by a build break dynamic-route matching (/ext/[[...slug]]
// 404s) under Turbopack.
distDir: process.env.NODE_ENV === 'production' ? '.next' : '.next-dev',
basePath: '',
trailingSlash: true,
assetPrefix: '',
Expand Down
6 changes: 5 additions & 1 deletion admin-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -100,7 +104,7 @@
"scripts": {
"prepublishOnly": "npm run build",
"dev": "next dev",
"build": "NODE_ENV=production next build && tsup && tsc -p tsconfig.sdk.json",
"build": "NODE_ENV=production next build && npm run build:sdk",
"build:sdk": "tsup && tsc -p tsconfig.sdk.json",
"start": "next start",
"lint": "eslint .",
Expand Down
3 changes: 1 addition & 2 deletions admin-ui/src/gql/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,7 @@ export type IColor = {
};

export type IConfigurableOrBundleProduct =
| IBundleProduct
| IConfigurableProduct;
IBundleProduct | IConfigurableProduct;

/** Configurable Product (Proxy) */
export type IConfigurableProduct = IProduct & {
Expand Down
7 changes: 1 addition & 6 deletions admin-ui/src/modules/order/components/OrderStatusBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ import Badge from '@/components/ui/Badge';
import { ORDER_STATUSES } from '../../common/data/miscellaneous';

type OrderStatus =
| 'PENDING'
| 'CONFIRMED'
| 'OPEN'
| 'FULFILLED'
| 'REJECTED'
| string;
'PENDING' | 'CONFIRMED' | 'OPEN' | 'FULFILLED' | 'REJECTED' | string;

type OrderStatusBadgeProps = {
status: OrderStatus;
Expand Down
160 changes: 109 additions & 51 deletions admin-ui/src/modules/plugins/PluginContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,24 @@ 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 ReactHookForm from 'react-hook-form';
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<string, any>;
__UNCHAINED_PLUGINS__: Record<string, Record<string, any>>;
}
}

Expand Down Expand Up @@ -73,42 +75,102 @@ 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,
'react-hook-form': ReactHookForm,
'@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 loadPluginScript = (url: string): Promise<HTMLScriptElement> => {
return new Promise((resolve, reject) => {
const ensureImportMap = async (baseUrl: string): Promise<boolean> => {
if (
document.querySelector('script[type="importmap"][data-unchained-admin-ui]')
)
return true;

// 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.src = url;
script.onload = () => resolve(script);
script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
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 (
manifest: PluginManifest,
baseUrl: string,
): Promise<PluginModule | null> => {
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(
`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 }) => {
Expand All @@ -118,7 +180,6 @@ export const PluginProvider = ({ children }: { children: ReactNode }) => {

useEffect(() => {
setupPluginRuntime();
const scriptElements: HTMLScriptElement[] = [];
let cancelled = false;

(async () => {
Expand All @@ -132,25 +193,24 @@ 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);

if (!(await ensureImportMap(baseUrl))) {
setLoading(false);
return;
}
if (cancelled) return;

const loaded = new Map<string, PluginModule>();
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);
}
Expand All @@ -166,8 +226,6 @@ export const PluginProvider = ({ children }: { children: ReactNode }) => {

return () => {
cancelled = true;
scriptElements.forEach((script) => script.remove());
window.__UNCHAINED_PLUGINS__ = {};
};
}, []);

Expand Down
Loading