Skip to content
Merged
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
6 changes: 5 additions & 1 deletion scripts/vite-plugin-miniapps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,11 @@ function scanRemoteMiniappsForBuild(
): Array<MiniappManifest & { url: string; runtime?: MiniappRuntime; wujieConfig?: WujieRuntimeConfig }> {
if (!existsSync(miniappsPath)) return [];

const configByDirName = new Map(remoteConfigs.map((c) => [c.dirName, c]));
const configByDirName = new Map(
remoteConfigs
.map((c) => (c.build?.locale ? [c.build.locale.dirName, c] : null))
.filter((item): item is [string, RemoteMiniappConfig] => item !== null),
);
const remoteApps: Array<
MiniappManifest & { url: string; runtime?: MiniappRuntime; wujieConfig?: WujieRuntimeConfig }
> = [];
Expand Down
99 changes: 68 additions & 31 deletions scripts/vite-plugin-remote-miniapps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,26 @@ import type { WujieRuntimeConfig } from '../src/services/ecosystem/types';

type MiniappRuntime = 'iframe' | 'wujie';

interface MiniappLocaleConfig {
metadataUrl: string;
dirName: string;
}

interface MiniappRemoteSourceConfig {
name: string;
sourceUrl: string;
}

interface MiniappServerConfig {
locale?: MiniappLocaleConfig;
remote?: MiniappRemoteSourceConfig;
runtime?: MiniappRuntime;
wujieConfig?: WujieRuntimeConfig;
}

interface MiniappBuildConfig {
locale?: MiniappLocaleConfig;
remote?: MiniappRemoteSourceConfig;
runtime?: MiniappRuntime;
wujieConfig?: WujieRuntimeConfig;
/**
Expand All @@ -39,8 +53,6 @@ interface MiniappBuildConfig {
}

export interface RemoteMiniappConfig {
metadataUrl: string;
dirName: string;
server?: MiniappServerConfig;
build?: MiniappBuildConfig;
}
Expand Down Expand Up @@ -105,26 +117,49 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug
const servers: RemoteMiniappServer[] = [];
const downloadFailures: string[] = [];

const resolveLocale = (config: RemoteMiniappConfig, target: 'server' | 'build'): MiniappLocaleConfig | null => {
const section = target === 'build' ? config.build : config.server;
if (!section) return null;
if (section.locale && section.remote) {
throw new Error(`[remote-miniapps] ${target} config can only set one of locale or remote`);
}
return section.locale ?? null;
};

const getLocaleTargets = (target: 'server' | 'build') =>
miniapps
.map((config) => {
const locale = resolveLocale(config, target);
return locale ? { config, locale } : null;
})
.filter((item): item is { config: RemoteMiniappConfig; locale: MiniappLocaleConfig } => item !== null);

return {
name: 'vite-plugin-remote-miniapps',

configResolved(config) {
root = config.root;
isBuild = config.command === 'build';

for (const cfg of miniapps) {
resolveLocale(cfg, 'server');
resolveLocale(cfg, 'build');
}
},

async buildStart() {
if (miniapps.length === 0) return;
const targets = getLocaleTargets(isBuild ? 'build' : 'server');
if (targets.length === 0) return;

const miniappsPath = resolve(root, miniappsDir);

for (const config of miniapps) {
for (const { config, locale } of targets) {
try {
await downloadAndExtract(config, miniappsPath, fetchOptions);
await downloadAndExtract(locale, miniappsPath, fetchOptions);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
console.error(`[remote-miniapps] ❌ Failed to download ${config.dirName}: ${errorMsg}`);
downloadFailures.push(config.dirName);
console.error(`[remote-miniapps] ❌ Failed to download ${locale.dirName}: ${errorMsg}`);
downloadFailures.push(locale.dirName);
}
}

Expand All @@ -143,20 +178,21 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug
const miniappsOutputDir = resolve(outputOptions.dir, 'miniapps');
const missing: string[] = [];

for (const config of miniapps) {
const srcDir = join(miniappsPath, config.dirName);
const destDir = join(miniappsOutputDir, config.dirName);
const targets = getLocaleTargets('build');
for (const { config, locale } of targets) {
const srcDir = join(miniappsPath, locale.dirName);
const destDir = join(miniappsOutputDir, locale.dirName);

if (existsSync(srcDir)) {
mkdirSync(destDir, { recursive: true });
cpSync(srcDir, destDir, { recursive: true });
console.log(`[remote-miniapps] ✅ Copied ${config.dirName} to dist`);
console.log(`[remote-miniapps] ✅ Copied ${locale.dirName} to dist`);

if (config.build?.injectBaseTag) {
const basePath =
typeof config.build.injectBaseTag === 'string'
? config.build.injectBaseTag
: `/miniapps/${config.dirName}/`;
: `/miniapps/${locale.dirName}/`;
rewriteHtmlBase(destDir, basePath);
}

Expand All @@ -169,7 +205,7 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug
}
}
} else {
missing.push(config.dirName);
missing.push(locale.dirName);
}
}

Expand All @@ -182,27 +218,28 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug
},

async configureServer(server) {
if (miniapps.length === 0) return;
const targets = getLocaleTargets('server');
if (targets.length === 0) return;

const miniappsPath = resolve(root, miniappsDir);

for (const config of miniapps) {
for (const { locale } of targets) {
try {
await downloadAndExtract(config, miniappsPath, fetchOptions);
await downloadAndExtract(locale, miniappsPath, fetchOptions);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
console.warn(`[remote-miniapps] ⚠️ Failed to download ${config.dirName} (dev mode): ${errorMsg}`);
console.warn(`[remote-miniapps] ⚠️ Failed to download ${locale.dirName} (dev mode): ${errorMsg}`);
continue;
}
}

// 启动静态服务器为每个远程 miniapp
for (const config of miniapps) {
const miniappDir = join(miniappsPath, config.dirName);
for (const { config, locale } of targets) {
const miniappDir = join(miniappsPath, locale.dirName);
const manifestPath = join(miniappDir, 'manifest.json');

if (!existsSync(manifestPath)) {
console.warn(`[remote-miniapps] ${config.dirName}: manifest.json not found, skipping`);
console.warn(`[remote-miniapps] ${locale.dirName}: manifest.json not found, skipping`);
continue;
}

Expand All @@ -214,7 +251,7 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug

const serverInfo: RemoteMiniappServer = {
id: manifest.id,
dirName: config.dirName,
dirName: locale.dirName,
port,
server: httpServer,
baseUrl,
Expand Down Expand Up @@ -250,15 +287,15 @@ export function remoteMiniappsPlugin(options: RemoteMiniappsPluginOptions): Plug
// ==================== Helpers ====================

async function downloadAndExtract(
config: RemoteMiniappConfig,
locale: MiniappLocaleConfig,
miniappsPath: string,
fetchOptions: FetchWithEtagOptions = {},
): Promise<void> {
const targetDir = join(miniappsPath, config.dirName);
const targetDir = join(miniappsPath, locale.dirName);

console.log(`[remote-miniapps] Syncing ${config.dirName}...`);
console.log(`[remote-miniapps] Syncing ${locale.dirName}...`);

const metadataBuffer = await fetchWithEtag(config.metadataUrl, fetchOptions);
const metadataBuffer = await fetchWithEtag(locale.metadataUrl, fetchOptions);
const metadata = JSON.parse(metadataBuffer.toString('utf-8')) as RemoteMetadata;

const localManifestPath = join(targetDir, 'manifest.json');
Expand All @@ -267,25 +304,25 @@ async function downloadAndExtract(
_zipEtag?: string;
};
if (localManifest.version === metadata.version && localManifest._zipEtag) {
const baseUrl = config.metadataUrl.replace(/\/[^/]+$/, '');
const baseUrl = locale.metadataUrl.replace(/\/[^/]+$/, '');
const zipUrl = metadata.zipUrl.startsWith('.') ? `${baseUrl}/${metadata.zipUrl.slice(2)}` : metadata.zipUrl;
try {
const headResponse = await fetch(zipUrl, { method: 'HEAD' });
const remoteEtag = headResponse.headers.get('etag') || '';
if (remoteEtag === localManifest._zipEtag) {
console.log(`[remote-miniapps] ${config.dirName} is up-to-date (v${metadata.version}, etag match)`);
console.log(`[remote-miniapps] ${locale.dirName} is up-to-date (v${metadata.version}, etag match)`);
return;
}
console.log(
`[remote-miniapps] ${config.dirName} zip changed (etag: ${localManifest._zipEtag} -> ${remoteEtag})`,
`[remote-miniapps] ${locale.dirName} zip changed (etag: ${localManifest._zipEtag} -> ${remoteEtag})`,
);
} catch {
// HEAD request failed, continue with download
}
}
}

const baseUrl = config.metadataUrl.replace(/\/[^/]+$/, '');
const baseUrl = locale.metadataUrl.replace(/\/[^/]+$/, '');
const manifestUrl = metadata.manifestUrl.startsWith('.')
? `${baseUrl}/${metadata.manifestUrl.slice(2)}`
: metadata.manifestUrl;
Expand Down Expand Up @@ -316,10 +353,10 @@ async function downloadAndExtract(
}
}

const manifestWithDir = { ...manifest, dirName: config.dirName, _zipEtag: zipEtag };
const manifestWithDir = { ...manifest, dirName: locale.dirName, _zipEtag: zipEtag };
writeFileSync(localManifestPath, JSON.stringify(manifestWithDir, null, 2));

console.log(`[remote-miniapps] ${config.dirName} updated to v${manifest.version} (etag: ${zipEtag})`);
console.log(`[remote-miniapps] ${locale.dirName} updated to v${manifest.version} (etag: ${zipEtag})`);
}

function rewriteHtmlBase(targetDir: string, basePath: string): void {
Expand Down
32 changes: 31 additions & 1 deletion src/stores/ecosystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,35 @@ export interface EcosystemState {
const STORAGE_KEY = 'ecosystem_store';
const getDefaultSourceName = () => i18n.t('ecosystem:sources.defaultName');

interface InjectedSource {
name: string;
url: string;
icon?: string;
}

function getInjectedSources(): SourceRecord[] {
if (!Array.isArray(__ECOSYSTEM_SOURCES__)) return [];
const now = new Date().toISOString();

return __ECOSYSTEM_SOURCES__
.filter((source): source is InjectedSource => Boolean(source?.url && source?.name))
.map((source) => ({
url: source.url,
name: source.name,
lastUpdated: now,
enabled: true,
status: 'idle' as const,
icon: source.icon,
}));
}

function arraysEqual<T>(a: T[], b: T[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}

function getDefaultSources(): SourceRecord[] {
const defaultName = getDefaultSourceName();
return [
const defaults: SourceRecord[] = [
{
url: `${import.meta.env.BASE_URL}miniapps/ecosystem.json`,
name: defaultName,
Expand All @@ -81,6 +103,14 @@ function getDefaultSources(): SourceRecord[] {
status: 'idle' as const,
},
];

for (const injected of getInjectedSources()) {
if (!defaults.some((source) => source.url === injected.url)) {
defaults.push(injected);
}
}

return defaults;
}

function mergeSourcesWithDefault(sources: SourceRecord[]): SourceRecord[] {
Expand Down
3 changes: 3 additions & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ declare global {
/** App 版本号 - 通过 vite.config.ts define 配置 */
const __APP_VERSION__: string

/** 默认生态源列表 - 通过 vite.config.ts define 配置 */
const __ECOSYSTEM_SOURCES__: Array<{ name: string; url: string; icon?: string }>

/** API Keys 映射 - 通过 vite.config.ts / Storybook viteFinal define 配置 */
const __API_KEYS__: Record<string, string>

Expand Down
34 changes: 32 additions & 2 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,47 @@ import { buildCheckPlugin } from './scripts/vite-plugin-build-check';

const remoteMiniappsConfig: RemoteMiniappConfig[] = [
{
metadataUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/metadata.json',
dirName: 'rwa-hub',
server: {
locale: {
metadataUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/metadata.json',
dirName: 'rwa-hub',
},
runtime: 'wujie',
wujieConfig: { rewriteAbsolutePaths: true },
},
build: {
remote: {
name: 'RWA',
sourceUrl: 'https://iweb.xin/rwahub.bfmeta.com.miniapp/source.json',
},
runtime: 'wujie',
wujieConfig: { rewriteAbsolutePaths: true },
},
},
];

type EcosystemSourceConfig = { name: string; url: string };

function collectEcosystemSources(configs: RemoteMiniappConfig[]): EcosystemSourceConfig[] {
const sources: EcosystemSourceConfig[] = [];
const seen = new Set<string>();

for (const config of configs) {
const candidates = [config.server?.remote, config.build?.remote].filter(
(item): item is NonNullable<(typeof config.server)['remote']> => Boolean(item),
);
for (const remote of candidates) {
if (!remote?.sourceUrl || seen.has(remote.sourceUrl)) continue;
seen.add(remote.sourceUrl);
sources.push({ name: remote.name, url: remote.sourceUrl });
}
}

return sources;
}

const ecosystemSources = collectEcosystemSources(remoteMiniappsConfig);

function getPreferredLanIPv4(): string | undefined {
const ifaces = networkInterfaces();
const ips: string[] = [];
Expand Down Expand Up @@ -177,6 +205,8 @@ export default defineConfig(({ mode }) => {
__DEV_MODE__: JSON.stringify((env.VITE_DEV_MODE ?? process.env.VITE_DEV_MODE) === 'true'),
// App 版本号(stable=package.json,dev=追加 -MMDDHH,UTC)
__APP_VERSION__: JSON.stringify(appVersion),
// 默认生态源列表(用于订阅源管理展示)
__ECOSYSTEM_SOURCES__: JSON.stringify(ecosystemSources),
// API Keys 对象(用于动态读取环境变量)
__API_KEYS__: JSON.stringify({
TRONGRID_API_KEY: tronGridApiKey,
Expand Down