npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@stratton-cologne/remote-modules

v1.1.0

Published

Runtime loader für Vue 3: externe ESM-Module (Routes, i18n, CSS) aus /modules dynamisch laden

Readme

@stratton-cologne/remote-modules

Runtime‑Loader für Vue 3 + Vite: lädt externe ESM‑Module (Routes, i18n, CSS) zur Laufzeit aus einem Manifest (z. B. /modules/index.json). Ideal für Drop‑in‑Module, die separat entwickelt/gebaut und ohne Rebuild der Host‑App deployed werden.


Installation

npm i @stratton-cologne/remote-modules
# oder
pnpm add @stratton-cologne/remote-modules

Quick Start (Host‑App)

src/main.ts

import { createApp } from "vue";
import App from "./app.vue";
import router from "./router";
import { createPinia } from "pinia";
import i18n from "./i18n";
import { loadRemoteModules } from "@stratton-cologne/remote-modules";

async function bootstrap() {
    const app = createApp(App);
    const pinia = createPinia();

    await loadRemoteModules(
        { app, router, i18n, pinia },
        {
            manifestUrl: "/modules/index.json",
            guardDuplicateRoutes: "name", // Default – siehe unten
        }
    );

    app.use(pinia);
    app.use(i18n);
    app.use(router);

    app.mount("#app");
}
bootstrap();

/public/modules/index.json

[
    {
        "name": "admin",
        "version": "1.2.3",
        "baseUrl": "/modules/admin/1.2.3/",
        "entry": "index.js",
        "styles": ["style.css"]
    }
]

Was macht der Loader?

  • lädt style.css (falls vorhanden) via <link>
  • importiert index.js (ESM, default = ModuleBundle)
  • merged locales unter Namespace = Modulnamet('admin.*')
  • registriert routes (inkl. Parent/Children, meta.roles etc.)
  • ruft optional install({ app, router, i18n, pinia })

Entwicklungsmodi (Dev)

A) Runtime‑only Dev (Host nicht gebaut)

  • Setze .env.development (oder eine Flag in deinem Code) so, dass nur der Runtime‑Loader verwendet wird.

  • Lege das gebaute Modul unter:

    /public/modules/<name>/<version>/
      ├─ index.js
      ├─ style.css (optional)
      └─ assets/ (optional)
  • Aktualisiere /public/modules/index.json (per Script oder manuell).

  • Starte den Vite‑Dev‑Server der Host‑App → der Loader importiert direkt aus /public/modules/....

  • Tipp: Browser‑Reload reicht; der Loader lädt das Manifest mit cache: 'no-cache'.

B) Mixed Dev (lokale Build‑Time‑Module + Remote gleichzeitig)

  • Lasse deinen bestehenden Build‑Time‑Autoloader aktiv und rufe zusätzlich loadRemoteModules(...) auf.

  • Belasse guardDuplicateRoutes: 'name' (Default), damit Remote‑Routen mit gleichem Namen leise übersprungen werden.

  • Achte auf eindeutige Route‑Namen (z. B. Prefix admin-*).

  • Optional: mapManifest nutzen, um bestimmte Remote‑Einträge in Dev zu filtern:

    await loadRemoteModules(
        { app, router, i18n, pinia },
        {
            mapManifest: (refs) => refs.filter((r) => r.name !== "admin"),
        }
    );

Option: guardDuplicateRoutes

await loadRemoteModules(ctx, {
    guardDuplicateRoutes: "name" | "path" | false,
});
  • 'name' (Default): Wenn route.name bereits existiert (router.hasRoute(name)), wird die Remote‑Route übersprungen. Fallback: Falls eine Route keinen Namen hat, prüft der Loader zusätzlich den Pfad und überspringt bei identischem Pfad.
  • 'path': Prüft ausschließlich den Pfad via router.getRoutes().
  • false: keine Duplikatsprüfung (nicht empfohlen bei Mixed‑Setups).

Zweck: verhindert doppelte Registrierung, wenn dieselbe Route bereits lokal (Build‑Time) existiert.


Modul‑Contract

Ein Modul exportiert default ein ModuleBundle:

// public-entry.ts (im Modul‑Projekt)
import type { ModuleBundle } from "@stratton-cologne/remote-modules";
import de from "./locales/de.json";
import en from "./locales/en.json";

const bundle: ModuleBundle = {
    name: "admin",
    version: "1.2.3",
    routes: [
        {
            path: "/admin",
            component: () => import("./layouts/admin.layout.vue"),
            meta: { title: "Admin", roles: ["admin"] },
            children: [
                {
                    path: "",
                    name: "admin-index",
                    component: () => import("./views/index.view.vue"),
                },
            ],
        },
    ],
    locales: { de, en },
    install({ app /*, router, i18n, pinia */ }) {
        // optional: Plugins/Components registrieren
    },
};
export default bundle;

Vite Library‑Build (im Modul‑Projekt)

// vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
    plugins: [vue()],
    build: {
        lib: {
            entry: "src/public-entry.ts",
            formats: ["es"],
            fileName: () => "index.js",
        },
        rollupOptions: {
            external: ["vue", "vue-router", "pinia", "vue-i18n"], // Host liefert diese zur Laufzeit
            output: { assetFileNames: "assets/[name]-[hash][extname]" },
        },
        cssCodeSplit: true,
    },
});

Deploy: Kopiere das Modul‑dist/ nach /public/modules/<name>/<version>/ in der Host‑App und pflege index.json.


Erweiterte Nutzung

await loadRemoteModules({ app, router, i18n, pinia }, {
  manifestUrl: '/modules/index.json',  // Pfad zum Manifest (Default)
  fetch: customFetch,                  // eigener fetch (z. B. mit Credentials/CORS)
  i18nNamespaceStrategy: 'moduleName'  // oder (name, bundle) => string
  // guardDuplicateRoutes: 'name',     // s. o.
  onModuleLoaded: (bundle, ref) => {},
  onModuleError:  (ref, err) => {},
  mapManifest: (refs) => refs.filter(/* ... */),
  log: (level, ...args) => {}          // eigenes Logging
})

Vollständiger Source (Package‑Entry)

Neu: unterstützt jetzt drei Quellen pro Modul‑Eintrag

  1. URL‑Drop‑in (baseUrl + entry) – gebautes Bundle aus /modules/...
  2. Dev‑Quelle (entryDev) – z. B. Vite‑Pfad /@fs/.../src/public-entry.ts
  3. Package‑Specifier (spec) – z. B. "@org/module-admin" (per Import‑Map/CDN)
/** src/index.ts */
import type { App } from "vue";
import type { Router, RouteRecordRaw } from "vue-router";
import type { I18n } from "vue-i18n";
import type { Pinia } from "pinia";

// -------------------------------------
// Typen (Contract & Optionen)
// -------------------------------------
export type ModuleContext = {
    app: App;
    router: Router;
    i18n: I18n;
    pinia: Pinia;
};

export type ModuleBundle = {
    name: string;
    version: string;
    routes?: RouteRecordRaw[];
    locales?: Record<string, any>;
    install?: (ctx: ModuleContext) => void;
    onUnload?: () => void;
};

export type RemoteModuleRef = {
    name: string;
    version: string;
    // Variante A: URL‑Drop‑in
    baseUrl?: string; // z. B. "/modules/admin/1.2.3/"
    entry?: string; // z. B. "index.js"
    styles?: string[]; // optional CSS relativ zu baseUrl
    // Variante B: Dev‑Quelle (Vite wandelt TS/Vue)
    entryDev?: string; // z. B. "/@fs/…/admin-module/src/public-entry.ts"
    // Variante C: Package‑Specifier
    spec?: string; // z. B. "@org/module-admin" oder vollständige https‑URL
    // Priorisierung (optional)
    prefer?: "dev" | "url" | "spec";
};

export type NamespaceStrategy =
    | "moduleName"
    | ((moduleName: string, bundle: ModuleBundle) => string);
export type DuplicateRouteGuard = "name" | "path" | false;

export type ModuleLoaderOptions = {
    manifestUrl?: string;
    fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
    i18nNamespaceStrategy?: NamespaceStrategy;
    guardDuplicateRoutes?: DuplicateRouteGuard;
    onModuleLoaded?: (bundle: ModuleBundle, ref: RemoteModuleRef) => void;
    onModuleError?: (ref: RemoteModuleRef, error: unknown) => void;
    mapManifest?: (refs: RemoteModuleRef[]) => RemoteModuleRef[];
    log?: (level: "info" | "warn" | "error", ...args: any[]) => void;
    /**
     * Bevorzugt Dev‑Einträge (entryDev), wenn vorhanden.
     * Default: true in Dev, false in Prod.
     */
    preferDevEntries?: boolean;
    /**
     * Bare Specifier → URL auflösen (falls keine Import‑Map genutzt wird)
     * z. B. speisen auf CDN wie esm.sh / jsdelivr
     */
    resolveSpecifier?: (spec: string) => string | Promise<string>;
    /** Dev‑Einträge auch in Prod erlauben (normalerweise false) */
    allowDevEntryInProd?: boolean;
};

export type LoadResult = {
    loaded: Array<{ bundle: ModuleBundle; ref: RemoteModuleRef }>;
    errors: Array<{ ref: RemoteModuleRef; error: unknown }>;
};

// -------------------------------------
// Utils
// -------------------------------------
function defaultLog(level: "info" | "warn" | "error", ...args: any[]) {
    const fn =
        level === "info"
            ? console.info
            : level === "warn"
            ? console.warn
            : console.error;
    fn("[remote-modules]", ...args);
}

function loadCss(href: string) {
    const el = document.createElement("link");
    el.rel = "stylesheet";
    el.href = href;
    document.head.appendChild(el);
    return el;
}

function mergeLocales(
    i18n: I18n,
    namespace: string,
    locales: Record<string, any>
) {
    for (const [lang, msgs] of Object.entries(locales)) {
        const current = (i18n.global as any).getLocaleMessage(lang) || {};
        (i18n.global as any).setLocaleMessage(lang, {
            ...current,
            [namespace]: msgs,
        });
    }
}

function ensureArray<T>(v?: T | T[]): T[] {
    return !v ? [] : Array.isArray(v) ? v : [v];
}
function routerHasPath(router: Router, path: string): boolean {
    return router.getRoutes().some((r) => r.path === path);
}

// -------------------------------------
// Core
// -------------------------------------
export async function loadRemoteModules(
    ctx: ModuleContext,
    options: ModuleLoaderOptions = {}
): Promise<LoadResult> {
    const { app, router, i18n, pinia } = ctx;
    const fetchImpl = options.fetch ?? fetch.bind(window);
    const manifestUrl = options.manifestUrl ?? "/modules/index.json";
    const nsStrategy = options.i18nNamespaceStrategy ?? "moduleName";
    const guard: DuplicateRouteGuard = options.guardDuplicateRoutes ?? "name";
    const log = options.log ?? defaultLog;
    const isProd =
        typeof import.meta !== "undefined" && (import.meta as any).env?.PROD;
    const preferDev = options.preferDevEntries ?? !isProd;

    const result: LoadResult = { loaded: [], errors: [] };

    let refs: RemoteModuleRef[] = [];
    try {
        const res = await fetchImpl(manifestUrl, { cache: "no-cache" });
        if (!res.ok) {
            log(
                "warn",
                `Manifest nicht erreichbar (${manifestUrl}):`,
                res.status
            );
            return result;
        }
        refs = await res.json();
    } catch (e) {
        log("error", "Manifest-Fehler:", e);
        return result;
    }
    if (typeof options.mapManifest === "function")
        refs = options.mapManifest(refs);

    for (const ref of refs) {
        try {
            // 1) Styles laden (nur bei URL‑Drop‑ins relevant)
            for (const s of ensureArray(ref.styles)) {
                if (ref.baseUrl) loadCss(new URL(s, ref.baseUrl).toString());
            }

            // 2) Entry auflösen (Dev → URL → Spec)
            const entryInfo = await resolveEntry(ref, {
                preferDev,
                allowDev: options.allowDevEntryInProd,
                resolveSpecifier: options.resolveSpecifier,
            });
            if (!entryInfo) {
                log("warn", `Kein gültiger Entry für Modul ${ref.name}`);
                continue;
            }

            // 3) ESM importieren
            const mod = (await import(/* @vite-ignore */ entryInfo.source)) as {
                default?: ModuleBundle;
            };
            const bundle = mod?.default;
            if (!bundle || !bundle.name)
                throw new Error(
                    "Ungültiges Bundle: default export fehlt oder name leer"
                );

            // 4) i18n
            if (bundle.locales) {
                const ns =
                    nsStrategy === "moduleName"
                        ? bundle.name
                        : (nsStrategy as Function)(bundle.name, bundle);
                mergeLocales(i18n, ns, bundle.locales);
            }

            // 5) Routen
            if (Array.isArray(bundle.routes)) {
                for (const route of bundle.routes) {
                    if (guard === "name") {
                        const hasName = !!route.name;
                        if (hasName && router.hasRoute(route.name as string)) {
                            log(
                                "warn",
                                `Route-Name bereits vorhanden → skip: ${String(
                                    route.name
                                )}`
                            );
                            continue;
                        }
                        if (
                            !hasName &&
                            route.path &&
                            routerHasPath(router, route.path)
                        ) {
                            log(
                                "warn",
                                `Route-Pfad (Fallback) vorhanden → skip: ${route.path}`
                            );
                            continue;
                        }
                    } else if (guard === "path") {
                        if (route.path && routerHasPath(router, route.path)) {
                            log(
                                "warn",
                                `Route-Pfad vorhanden → skip: ${route.path}`
                            );
                            continue;
                        }
                    }
                    router.addRoute(route);
                }
            }

            // 6) Install‑Hook
            bundle.install?.({ app, router, i18n, pinia });

            result.loaded.push({ bundle, ref });
            options.onModuleLoaded?.(bundle, ref);
            log("info", `loaded ${bundle.name}@${bundle.version}`);
        } catch (e) {
            result.errors.push({ ref, error: e });
            options.onModuleError?.(ref, e);
            log(
                "error",
                `Fehler beim Laden von ${ref.name}@${ref.version}:`,
                e
            );
        }
    }
    return result;
}

// Entry‑Resolver
async function resolveEntry(
    ref: RemoteModuleRef,
    opts: {
        preferDev: boolean;
        allowDev?: boolean;
        resolveSpecifier?: (s: string) => string | Promise<string>;
    }
): Promise<{ kind: "dev" | "url" | "spec"; source: string } | null> {
    const wantsDev = opts.preferDev && !!ref.entryDev;
    const canDev = wantsDev && (opts.allowDev ?? true);
    if (canDev && ref.entryDev) return { kind: "dev", source: ref.entryDev };

    if (ref.baseUrl && ref.entry) {
        return {
            kind: "url",
            source: new URL(ref.entry, ref.baseUrl).toString(),
        };
    }

    if (ref.spec) {
        const isHttp = /^https?:\/\//i.test(ref.spec);
        const specUrl = isHttp
            ? ref.spec
            : opts.resolveSpecifier
            ? await opts.resolveSpecifier(ref.spec)
            : ref.spec;
        return { kind: "spec", source: specUrl };
    }

    return null;
}

// Einzelnes Modul ad‑hoc laden
export async function loadSingleModule(
    ctx: ModuleContext,
    ref: RemoteModuleRef,
    options: Omit<ModuleLoaderOptions, "manifestUrl" | "mapManifest"> = {}
) {
    const manifest: RemoteModuleRef[] = [ref];
    const tmp = URL.createObjectURL(
        new Blob([JSON.stringify(manifest)], { type: "application/json" })
    );
    const res = await loadRemoteModules(ctx, { ...options, manifestUrl: tmp });
    URL.revokeObjectURL(tmp);
    return res;
}

Weitere Beispiele & Setup

1) Ungebaute Module im Dev testen (Vite‑Pfad)

  • Baue im Modul‑Repo nicht – nutze den Quell‑Entry src/public-entry.ts.
  • Trage in /public/modules/index.json des Hosts einen Dev‑Eintrag ein:
[
    {
        "name": "admin",
        "version": "dev",
        "entryDev": "/@fs/ABSOLUTER/PFAD/zu/admin-module/src/public-entry.ts"
    }
]
  • Host vite.config.ts: Zugriff auf den Modul‑Ordner erlauben
// vite.config.ts (Host)
export default defineConfig({
    server: {
        fs: { allow: ["..", "/ABSOLUTER/PFAD/zu/admin-module"] },
    },
});
  • Loader‑Option (optional – in Dev schon default):
await loadRemoteModules(ctx, { preferDevEntries: true });

Vite transformiert TS/SFC automatisch, da /@fs/... über den Dev‑Server läuft.

2) Module als Packages (ohne Rebuild der Host‑App)

Variante A – Import‑Map (empfohlen):

<!-- index.html des Hosts -->
<script type="importmap">
    {
        "imports": {
            "@org/module-admin": "/modules/npm/module-admin/1.2.3/index.js"
        }
    }
</script>

Manifest‑Eintrag:

[{ "name": "admin", "version": "1.2.3", "spec": "@org/module-admin" }]

Variante B – CDN‑URL (esm.sh/jsDelivr):

[
    {
        "name": "admin",
        "version": "1.2.3",
        "spec": "https://esm.sh/@org/[email protected]"
    }
]

Variante C – eigene Auflösung:

await loadRemoteModules(ctx, {
    resolveSpecifier: (spec) => `https://cdn.example.com/npm/${spec}/index.js`,
});

Hinweis: Bare Specifier funktionieren im Browser nur, wenn sie per Import‑Map oder via Bundler/CDN‑URL aufgelöst werden.


Lizenz

MIT