Skip to content

Frontend Integration - @productifyfw/core

Complete guide for integrating Productify configuration access and pilet loading in your applications.

Table of Contents

Overview

The @productifyfw/core library provides:

  • Type-safe configuration access - Access to window.__PRODUCTIFY__ configuration injected by the proxy
  • Pilet loading - Dynamic loading of ES module pilets from feed service
  • Framework agnostic - Works with Vue, React, or vanilla JavaScript

The types are directly derived from the Go struct definitions in proxy/caddy/productify/html_injection_middleware.go, ensuring complete type safety and compatibility.

How It Works

The Productify proxy (Caddy middleware) intercepts HTML responses and injects configuration before the closing </head> tag:

  1. User/Tenant Data: Extracted from HTTP headers set by authentication middleware:

    • X-User-ID, X-User-Email, X-User-Name, X-User-Username
    • X-Tenant-ID, X-Tenant-Name
    • X-Auth-Type
  2. Application Config: Fetched from Manager API endpoint /api/frontend-config/:application_id

  3. Injection: Combined into window.__PRODUCTIFY__ object before your application loads

  4. Pilet Loading: Feed service returns enabled pilets which are dynamically imported as ES modules

This means the configuration is available immediately when your JavaScript executes, with no additional API calls needed.

Installation

From GitHub Packages (Public)

ProductifyFW packages are published as public packages on GitHub Packages. Configure npm to use the GitHub registry for @productifyfw scoped packages:

Create or update .npmrc in your project root:

ini
@productifyfw:registry=https://npm.pkg.github.com

Then install:

bash
npm install @productifyfw/core
# or
pnpm add @productifyfw/core
# or
yarn add @productifyfw/core

No Authentication Required

Public packages don't require a GitHub token for installation. The .npmrc configuration just tells npm where to find @productifyfw packages.

Quick Start

Configuration Access

typescript
import { ProductifyClient } from "@productifyfw/core";

const pfy = new ProductifyClient();

// Get user information
const user = pfy.getUser();
console.log(`Welcome, ${user?.name}!`);

// Get configuration values
const theme = pfy.getConfig<string>("theme", { defaultValue: "light" });
const maxItems = pfy.getConfig<number>("maxItemsPerPage", { defaultValue: 10 });

// Get tenant information
const tenantId = pfy.getTenantId();
const tenantName = pfy.getTenantName();

Pilet Loading

typescript
import { loadPilets, type PiletRegistry } from "@productifyfw/core";
import { reactive } from "vue";

// Create reactive registry
const piletRegistry: PiletRegistry = reactive({
  extensions: {},
  pages: {},
});

// Load pilets from feed
await loadPilets("/pilet-feed", piletRegistry, {
  onLoad: (pilet) => console.log(`✓ Loaded: ${pilet.name}@${pilet.version}`),
  onError: (pilet, error) => console.error(`✗ Failed: ${pilet.name}`, error),
});

Configuration Structure

The proxy injects configuration based on the FrontendConfig Go struct:

typescript
window.__PRODUCTIFY__ = {
  user: {
    id: "user-uuid",
    email: "user@example.com",
    name: "User Name",
    username: "username",
  },
  tenant: {
    id: "tenant-id",
    name: "Tenant Name",
  },
  application: {
    id: "app-uuid",
  },
  auth_type: "oauth",
  config: {
    // Application configuration from Manager
    available_modules: ["example-pilet@1.0.5", "settings-pilet@1.0.5"],
    enabled_modules: ["example-pilet@1.0.5", "settings-pilet@1.0.5"],
    available_themes: ["light", "dark", "auto"],
    enabled_themes: ["light", "dark"],
    default_theme: "light",
    available_locales: ["en", "es", "fr"],
    enabled_locales: ["en", "es"],
    default_locale: "en",
    key_value_pairs: [{ key: "custom_setting", value: "custom_value" }],
  },
};

Pilet Loading

Overview

The @productifyfw/core package includes a pilet loader for dynamically loading microfrontend modules (pilets) from a feed service. Pilets are built as ES modules and loaded at runtime.

Loading Pilets

typescript
import { loadPilets, type PiletRegistry } from "@productifyfw/core";

const piletRegistry: PiletRegistry = {
  extensions: {},
  pages: {},
};

await loadPilets("/pilet-feed", piletRegistry, {
  onLoad: (pilet) => {
    console.log(`✓ Loaded: ${pilet.name}@${pilet.version}`);
  },
  onError: (pilet, error) => {
    console.error(`✗ Failed to load ${pilet.name}:`, error);
  },
  onComplete: () => {
    console.log("All pilets loaded");
  },
});

Pilets are executed without integrity verification

The loader does not verify pilet integrity. A feed item's hash field is declared on the type but never read: loadPilets fetches the feed and goes straight to await import(pilet.link), executing whatever the feed points at. There is no verifyIntegrity option and no requestTimeoutMs option — the only options are onLoad, onError, and onComplete.

Because pilets are remote code executed in your users' browsers, treat the feed and its origins as fully trusted infrastructure: serve them over HTTPS from a host you control, and do not point the loader at a feed you do not own. See Known Issues.

Creating a Pilet

Each pilet exports a setup function that receives the PiletApi:

typescript
// my-pilet/src/index.ts
import type { PiletApi } from "@productifyfw/core";
import MyWidget from "./components/MyWidget.vue";

export function setup(api: PiletApi) {
  // Register a widget extension
  api.registerExtension("dashboard-widgets", () => ({
    component: MyWidget,
    defaults: { title: "My Widget", refreshInterval: 30000 },
  }));

  // Register a page route
  api.registerPage("/my-page", {
    component: MyPageComponent,
  });
}

Building Pilets

Configure Vite to build pilets as ES modules with all dependencies bundled:

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

export default defineConfig({
  plugins: [vue()],
  define: {
    "process.env.NODE_ENV": JSON.stringify("production"),
  },
  build: {
    lib: {
      entry: "./src/index.ts",
      name: "MyPilet",
      fileName: "index",
      formats: ["es"],
    },
  },
});

Build the pilet:

bash
pnpm build
# Output: dist/index.js (ES module with all dependencies bundled)

Uploading Pilets

Use the Productify CLI to upload pilets to the Manager:

bash
pfy pilet upload \
  --project-id <PROJECT_ID> \
  --name my-pilet \
  --version 1.0.0 \
  --file dist/index.js

Configure CLI in ~/.pfy/config.yaml:

yaml
manager_url: http://manager.localhost # Through the Productify proxy
token: pat_your_token_here

Vue 3 Integration Example

typescript
import { createApp, reactive } from "vue";
import { loadPilets, type PiletRegistry } from "@productifyfw/core";
import App from "./App.vue";

// Create reactive registry
// (Vue-specific pilet glue, e.g. markRaw handling for registered
// components, is provided by @productifyfw/vue)
const piletRegistry: PiletRegistry = reactive({
  extensions: {},
  pages: {},
});

const app = createApp(App);
app.provide("piletRegistry", piletRegistry);
app.mount("#app");

// Load pilets after mounting
loadPilets("/pilet-feed", piletRegistry);

Rendering Pilet Widgets

vue
<template>
  <div class="widgets-grid">
    <component
      v-for="(widget, index) in dashboardWidgets"
      :key="index"
      :is="widget.component"
      v-bind="widget.defaults || {}"
    />
  </div>
</template>

<script setup lang="ts">
import { computed, inject } from "vue";
import type { PiletRegistry } from "@productifyfw/core";

const piletRegistry = inject<PiletRegistry>("piletRegistry");

const dashboardWidgets = computed(
  () => piletRegistry.extensions["dashboard-widgets"] || []
);
</script>

Language Pack Loading (i18n)

Overview

The @productifyfw/core package includes utilities for loading language packs dynamically from the Manager API. Language packs enable multi-language support with translations fetched at runtime.

Quick Start

typescript
import { createI18n } from "@byjohann/vue-i18n";
import { loadLanguagePack, createI18nComposable } from "@productifyfw/core";

// Create i18n instance
const i18n = createI18n({
  defaultLocale: "en",
  locales: ["en", "hu", "de"],
  messages: { en: {}, hu: {}, de: {} },
});

// Load default locale
loadLanguagePack("en").then((messages) => {
  i18n.messages.value.en = messages;
});

// Create composable
const useProductifyI18n = createI18nComposable(i18n);

// In component
const { changeLocale, enabledLocales } = useProductifyI18n();
await changeLocale("hu"); // Loads and switches to Hungarian

API Endpoint

Language packs are fetched from the Manager route:

GET /language-packs/{applicationId}/{locale}

The client does not build that path itself: it requests {languagePacksPath}/{locale} and relies on the proxy to inject the application ID. There is no way to pass an explicit application ID — loadLanguagePack(locale, options?) takes an I18nOptions object as its second argument, and the loader's internal applicationId parameter is ignored. To point at a different application, set languagePacksPath.

Response:

json
{
  "locale": "hu",
  "messages": {
    "app": {
      "title": "Alkalmazásom"
    },
    "common": {
      "save": "Mentés",
      "cancel": "Mégse"
    }
  }
}

Language Pack Storage

In the Manager database, translations are stored as key-value pairs with dot notation:

  • Database: {"key": "app.title", "value": "My Application"}
  • API converts to: {"app": {"title": "My Application"}}

Example: Language Switcher Component

vue
<script setup lang="ts">
import { ref, watch } from "vue";
import { useI18n } from "@byjohann/vue-i18n";
import { useProductifyI18n } from "../plugins/i18n";

const { locale } = useI18n();
const { changeLocale, enabledLocales } = useProductifyI18n();

const isChanging = ref(false);
const selectedLocale = ref(locale.value);

watch(locale, (newLocale) => {
  selectedLocale.value = newLocale;
});

const switchLanguage = async (event: Event) => {
  const newLocale = (event.target as HTMLSelectElement).value;
  if (newLocale === locale.value) return;

  isChanging.value = true;
  try {
    await changeLocale(newLocale);
  } catch (error) {
    console.error("Failed to change language:", error);
    selectedLocale.value = locale.value;
  } finally {
    isChanging.value = false;
  }
};
</script>

<template>
  <select
    v-model="selectedLocale"
    @change="switchLanguage"
    :disabled="isChanging"
  >
    <option v-for="loc in enabledLocales" :key="loc" :value="loc">
      {{ loc.toUpperCase() }}
    </option>
  </select>
</template>

Framework Packages (planned)

Not yet implemented

Neither @productifyfw/vue nor @productifyfw/react exists — fe-integrations/ publishes only @productifyfw/core (plus a vue3-example app). npm install for either package will fail, and every symbol in this section (productifyPlugin, useProductify, ProductifyProvider, useProductifyConfig, useLocale) is unimplemented. Use @productifyfw/core directly; the Vue example app shows the wiring.

This section describes the intended split. See Known Issues.

The plan is for @productifyfw/core to stay framework-agnostic, with Vue and React bindings shipping as their own packages.

@productifyfw/vue (planned)

bash
npm install @productifyfw/vue   # package does not exist yet

A Vue plugin providing a shared client plus composables:

typescript
// main.ts
import { createApp } from "vue";
import { productifyPlugin } from "@productifyfw/vue";

createApp(App).use(productifyPlugin()).mount("#app");
vue
<script setup lang="ts">
import { useProductify } from "@productifyfw/vue";

const { user, tenant, isModuleEnabled, locales, getConfig } = useProductify();
// reactive: refreshes when the injected config changes (e.g. after a tenant switch)
</script>

The vue-i18n glue and the pilet markRaw handling would move here. Today both live in core: createI18nComposable is exported from @productifyfw/core, and core's pilet loader does its own markRaw handling — so core does currently carry Vue-aware code.

@productifyfw/react (planned)

bash
npm install @productifyfw/react   # package does not exist yet
tsx
import { ProductifyProvider, useProductify, useProductifyConfig, useLocale } from "@productifyfw/react";

function App() {
  return (
    <ProductifyProvider>
      <Profile />
    </ProductifyProvider>
  );
}

function Profile() {
  const { user, tenant } = useProductify();
  const theme = useProductifyConfig<string>("theme", { defaultValue: "light" });
  const { locale, setLocale, available } = useLocale(); // loads language packs on change
  return <span>{user?.name} @ {tenant?.name}</span>;
}

Development & Testing

Vite dev shim — @productifyfw/dev (planned)

Not yet implemented

@productifyfw/dev does not exist, and neither does the pfy dev seed command that would generate its config file. Stub window.__PRODUCTIFY__ yourself in a dev-only entry script instead. See Known Issues.

Running npm run dev without the proxy means no injected window.__PRODUCTIFY__. The dev package would fake it:

typescript
// vite.config.ts
import { productifyDevShim } from "@productifyfw/dev";

export default defineConfig({
  plugins: [vue(), productifyDevShim({ configFile: "dev.productify.json" })],
});

The shim would inject the payload from dev.productify.json (which pfy dev seed would generate) into index.html during dev, hot-reload when the file changes, and no-op in production builds.

Test helper

typescript
// vitest setup
import { setupProductifyGlobal } from "@productifyfw/dev";

setupProductifyGlobal({
  user: { id: "u1", email: "test@local", name: "Test", username: "test" },
  tenant: { id: "t1", name: "acme" },
  config: { enabled_modules: ["dashboard@1.0"] },
});

Component tests then exercise the real @productifyfw/core client against a controlled global — no mocking of library internals.

Best Practices

  1. Initialize Early: Create ProductifyClient instance at app startup
  2. Use Singleton: Share one instance across your application using dependency injection
  3. Provide Defaults: Always provide sensible defaults for configuration values
  4. Cache Results: Cache expensive checks in component state/computed properties
  5. Handle Missing Config: Gracefully handle cases where config might not be available
  6. Don't Mutate: Never modify window.__PRODUCTIFY__ directly
  7. Type Safety: Use TypeScript generics for type-safe configuration access

Troubleshooting

Configuration Not Found

If window.__PRODUCTIFY__ is undefined:

  1. Verify application is accessed through Productify proxy
  2. Check proxy configuration and middleware
  3. Ensure correct domain/URL is used
  4. Check browser console for injection errors
  5. Verify HTML middleware is enabled in Caddyfile

Missing Configuration Values

If specific configuration values are missing:

  1. Check Manager API /api/frontend-config/:application_id endpoint
  2. Verify application ID is correct in proxy configuration
  3. Check Manager has configuration set for the application
  4. Use default values as fallback

Type Errors

If you're getting TypeScript errors:

  1. Ensure @productifyfw/core is properly installed
  2. Check TypeScript version compatibility (requires 5.0+)
  3. Verify types are being loaded correctly
  4. Use explicit type parameters: getConfig<string>('key')

Advanced Usage

Strict Mode

Enable strict mode to throw errors if configuration is missing:

typescript
const pfy = new ProductifyClient({ strict: true });
// Throws error if window.__PRODUCTIFY__ is not available

Configuration Changes

Listen for configuration changes at runtime:

typescript
const pfy = new ProductifyClient({
  onChange: (config) => {
    console.log("Configuration updated:", config);
  },
});

// Later, trigger refresh
pfy.refresh();

Singleton Pattern

Use the built-in singleton for global access:

typescript
import { getProductifyClient } from "@productifyfw/core";

const pfy = getProductifyClient();
// Returns the same instance every time

Type Reference

typescript
interface ProductifyFrontendConfig {
  user: ProductifyUser;
  tenant: ProductifyTenant;
  application: ProductifyApplication;
  authType?: AuthType;
  config?: ProductifyConfig;
}

interface ProductifyUser {
  id: string;
  email: string;
  name: string;
  username: string;
}

interface ProductifyTenant {
  id: string;
  name: string;
}

interface ProductifyApplication {
  id: string;
}

interface ProductifyConfig {
  [key: string]: unknown;
}

See Also