Skip to content

Frontend Integration - Vue 3 + Pilet Loading Example ​

fe-integrations/packages/vue3-example is a small Vue 3 host application that runs behind the Productify proxy, loads pilets (independently built ES-module bundles) from the Manager's feed, and renders the widgets they register. Use it as a template for your own host, or to see how the pieces from the core library guide fit together. It is published as the public image ghcr.io/productifyfw/example-frontend:0.1.0, an unprivileged nginx serving the built SPA on port 8080; the Compose bundle starts it with --profile example, and the Manager renders its vhost once the application is onboarded.

Overview ​

The example (fe-integrations/packages/vue3-example in the repository) showcases a full implementation of:

  • A Vue 3 shell application using the Composition API
  • Dynamic ES module pilet loading from the feed service
  • Typed configuration access through @productifyfw/core; pilet loading through loadVuePilets from @productifyfw/vue
  • Proxy integration: configuration injection and authentication

Architecture ​

┌─────────────────────────────────────────────────────┐
│                 Productify Proxy                    │
│  - OAuth/PAT Authentication                         │
│  - Configuration Injection (window.__PRODUCTIFY__)  │
│  - HTML Middleware                                  │
└──────────────┬──────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────┐
│              Vue 3 Shell Application                │
│  - Uses @productifyfw/vue loadVuePilets for pilets  │
│  - Fetches pilets from /pilet-feed                  │
│  - Dynamic ES module imports                        │
│  - Renders pilet extensions                         │
└──────────────┬──────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────┐
│             ES Module Pilets                        │
│  - Built with Vite as ES modules                    │
│  - All dependencies bundled                         │
│  - Register components via PiletApi                 │
│  - Access Productify config via @productifyfw/core  │
└─────────────────────────────────────────────────────┘

Quick Start ​

1. Set Up the Example App ​

bash
cd fe-integrations/packages/vue3-example
pnpm install
pnpm dev

The Vite dev server listens on port 3001. The app is meant to be reached at the URL configured in your proxy (e.g., http://vue3-example.localhost), which forwards to that port.

2. Configure the Proxy ​

A Manager-rendered vhost already carries these routes. For a hand-written development Caddyfile, this is the same shape (172.17.0.1:8080 is the Manager; the three Manager legs need the proxy secret because their Manager routes are gated on it):

caddyfile
http://vue3-example.localhost {
	vars app_id 64e163af-2f6b-4b2c-9a84-ec3c8dfbbe67

	@auth path /auth/*
	@pilet_feed path /pilet-feed /pilet-feed/*
	@language_packs path /language-packs/*
	@pilets path /pilets/*

	# Login portal
	route @auth {
		productify_sanitize
		productify_before_auth with {vars.app_id}
		authenticate with pocketportal
	}

	# Pilet feed: no login, absolute rewrite (no client remainder)
	route @pilet_feed {
		productify_sanitize
		reverse_proxy 172.17.0.1:8080 {
			rewrite /pilet-feed/{vars.app_id}
			header_up X-Proxy-Auth {env.PFY_MANAGER_TOKEN}
		}
	}

	# Language packs: no login, path forwarded unchanged. A pack is public
	# given a known application id, so this leg needs no identity.
	route @language_packs {
		productify_sanitize
		reverse_proxy 172.17.0.1:8080 {
			header_up X-Proxy-Auth {env.PFY_MANAGER_TOKEN}
		}
	}

	# Pilet bundles: /pilets/<project>/<name>/<version> becomes
	# /pilet-bundle/<app_id>/<project>/<name>/<version> on the Manager
	route @pilets {
		productify_sanitize
		uri strip_prefix /pilets
		uri path_regexp ^/ /pilet-bundle/{vars.app_id}/
		reverse_proxy 172.17.0.1:8080 {
			header_up X-Proxy-Auth {env.PFY_MANAGER_TOKEN}
		}
	}

	# Everything else: PAT or browser login, tenant, headers, injection
	route /* {
		productify_protect {vars.app_id} with pocketpolicy
		reverse_proxy localhost:3001  # Vite dev server
	}
}

3. Access the Application ​

http://vue3-example.localhost shows:

  • User information from OAuth/PAT
  • Application configuration
  • Enabled modules and pilets
  • Dashboard widgets from loaded pilets

Creating Pilets ​

1. Pilet Structure ​

See fe-integrations/packages/vue3-example/example-pilet for the complete implementation the snippets below are based on.

example-pilet/
├── src/
│   ├── index.ts              # Pilet entry point
│   └── components/
│       └── DashboardWidget.vue
├── vite.config.ts            # ES module build config
└── package.json

2. Pilet Entry Point (index.ts) ​

typescript
// src/index.ts
import type { PiletApi } from "@productifyfw/core";
import { ProductifyClient } from "@productifyfw/core";
import DashboardWidget from "./components/DashboardWidget.vue";
import AdminPanel from "./components/AdminPanel.vue";

const pfy = new ProductifyClient();

export function setup(api: PiletApi) {
  // Register a dashboard widget
  api.registerExtension("dashboard-widgets", () => ({
    component: DashboardWidget,
    defaults: {
      title: "User Dashboard",
    },
  }));

  // Register a page route
  api.registerPage("/dashboard", {
    component: DashboardWidget,
  });

  // Optional: register something only for some users
  if (pfy.getUser()?.username === "admin") {
    api.registerExtension("admin-tools", () => ({
      component: AdminPanel,
    }));
  }
}

The shipped example-pilet registers only the widget and the page; the admin block above is an addition to show conditional registration. The shipped file imports its PiletApi type from piral; the type exported by @productifyfw/core is the one to use.

3. Vite Configuration ​

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: "ExamplePilet",
      fileName: "index",
      formats: ["es"], // ES module format
    },
  },
});

4. Build the Pilet ​

bash
cd example-pilet
pnpm install
pnpm build
# Output: dist/index.js (ES module, ~33 KB with all dependencies bundled)

5. Upload to Manager ​

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

yaml
manager_url: http://manager.localhost # Through the Productify proxy
token: pat_<your_token_here>

Upload using the CLI:

bash
pfy pilet upload \
  --project-id <YOUR_PROJECT_ID> \
  --name example-pilet \
  --version 1.0.5 \
  --file dist/index.js

6. Enable in Frontend Settings ​

In the Manager UI, add the pilet (example-pilet@1.0.5) to the application's enabled modules in its frontend settings. The feed only lists pilets that are both enabled as packages and present in that list.

The pilet is loaded on the next page refresh.

Pilet Feed Service ​

The application fetches available pilets from:

GET /pilet-feed/:applicationId

Response format:

json
{
  "items": [
    {
      "name": "example-pilet",
      "version": "1.0.5",
      "link": "/pilets/c00833ec-958f-4977-9bd3-274b58bbfdf6/example-pilet/1.0.5",
      "hash": "9f2c…",
      "signature": "<base64 detached Ed25519 signature>",
      "keyId": "prod-2026-01",
      "spec": "v2"
    }
  ]
}

hash, signature and keyId are what the loader checks before executing the bundle: the hex SHA-256 of the exact bytes, a detached Ed25519 signature over those same bytes, and the id of the key to resolve from the injected trust anchor. The Manager fills them in at upload; a legacy unsigned package simply omits signature/keyId, and the loader then refuses it.

The link is root-relative, so the loader fetches it same-origin, which is why the vhost above needs its own @pilets route. The Manager percent-encodes each segment of the link, so a scoped name such as @myapp/dashboard resolves.

The feed automatically filters pilets based on:

  • Database enabled flag (pilet_packages.enabled)
  • Application's enabled modules list (frontend_settings.module_config.enabled_modules)
  • Project association

The pilet loader fetches each pilet's JavaScript file, verifies its hash and signature, and only then evaluates it as an ES module.

The @pilets route forwards to /pilet-bundle/<appID>/..., the anonymous, app-scoped read route, not to the Manager's /pilets/* REST surface, which is the PAT-gated management API and would answer 401/403 for every browser. The browser-facing URL is the same either way. See Browser download path.

Configuration Injection ​

The proxy injects configuration before </head>:

html
<script id="productify-config" type="application/json">
  {
    "user": {
      "id": "user-456",
      "email": "john@example.com",
      "name": "John Doe",
      "username": "john"
    },
    "tenant": {
      "id": "tenant-123",
      "name": "ACME Corp"
    },
    "application": {
      "id": "00000000-0000-0000-0000-000000000001"
    },
    "config": {
      "enabled_modules": ["example-pilet@1.0.5", "settings-pilet@1.0.5"],
      "default_theme": "dark",
      "default_locale": "en",
      "key_value_pairs": [{ "key": "maxItemsPerPage", "value": "25" }]
    }
  }
</script>
<script>
  window.__PRODUCTIFY__ = JSON.parse(
    document.getElementById("productify-config").textContent
  );
</script>

Development Tips ​

Local Development Without Proxy ​

For development, mock the configuration in index.html:

html
<script id="productify-config" type="application/json">
  {
    "user": {
      "id": "dev-1",
      "email": "dev@example.com",
      "name": "Developer",
      "username": "dev"
    },
    "tenant": { "id": "dev-tenant", "name": "Dev Tenant" },
    "application": { "id": "test-app" }
  }
</script>
<script>
  window.__PRODUCTIFY__ = JSON.parse(
    document.getElementById("productify-config").textContent
  );
</script>

Proxy Configuration ​

Update vite.config.ts to proxy pilet requests:

typescript
export default defineConfig({
  server: {
    proxy: {
      "/pilet-feed": {
        target: "http://localhost:8080",
        changeOrigin: true,
      },
    },
  },
});

Reading Your Own Settings ​

The key_value_pairs you configure in the Manager arrive as strings:

typescript
const kv = Object.fromEntries(pfy.getKeyValuePairs().map((p) => [p.key, p.value]));
const maxItems = Number(kv.maxItemsPerPage ?? 10);

// Standard keys have typed getters
const theme = pfy.getDefaultTheme(); // string | null

pfy.getConfig(key) reads top-level keys of config only (see the core guide), so it does not find maxItemsPerPage.

Troubleshooting ​

Pilets Not Loading ​

  1. Check browser console for errors
  2. Verify /pilet-feed/:app_id returns pilet list
  3. Ensure modules are enabled in frontend_settings.module_config.enabled_modules
  4. Check pilet_packages.enabled = true in database
  5. Verify pilet JavaScript files are accessible at /pilets/:project_id/:name/:version

Configuration Not Available ​

  1. Verify window.__PRODUCTIFY__ is defined in browser console
  2. Check proxy is injecting the configuration (view page source, search for productify-config)
  3. Ensure HTML middleware is configured in Caddyfile
  4. Check OAuth session is valid

ES Module Import Errors ​

  1. Ensure all dependencies are bundled in pilet build (no external in vite.config)
  2. Check process.env.NODE_ENV is defined in vite.config
  3. Verify pilet exports setup function
  4. Check for syntax errors in pilet code

Vue Reactivity Warnings ​

  1. Use the Vue pilet glue from @productifyfw/vue, which wraps registered components with markRaw to avoid reactivity overhead
  2. Use reactive(), not shallowReactive(), for the registry