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 throughloadVuePiletsfrom@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
cd fe-integrations/packages/vue3-example
pnpm install
pnpm devThe 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):
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.json2. Pilet Entry Point (index.ts)
// 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
// 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
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:
manager_url: http://manager.localhost # Through the Productify proxy
token: pat_<your_token_here>Upload using the CLI:
pfy pilet upload \
--project-id <YOUR_PROJECT_ID> \
--name example-pilet \
--version 1.0.5 \
--file dist/index.js6. 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/:applicationIdResponse format:
{
"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>:
<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:
<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:
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:
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 | nullpfy.getConfig(key) reads top-level keys of config only (see the core guide), so it does not find maxItemsPerPage.
Related Documentation
- @productifyfw/core Documentation
- Vue 3 Documentation
- Vite Documentation
- Example source:
fe-integrations/packages/vue3-example; two more minimal pilets (Vue and React) live infe-integrations/examples/
Troubleshooting
Pilets Not Loading
- Check browser console for errors
- Verify
/pilet-feed/:app_idreturns pilet list - Ensure modules are enabled in
frontend_settings.module_config.enabled_modules - Check
pilet_packages.enabled = truein database - Verify pilet JavaScript files are accessible at
/pilets/:project_id/:name/:version
Configuration Not Available
- Verify
window.__PRODUCTIFY__is defined in browser console - Check proxy is injecting the configuration (view page source, search for
productify-config) - Ensure HTML middleware is configured in Caddyfile
- Check OAuth session is valid
ES Module Import Errors
- Ensure all dependencies are bundled in pilet build (no
externalin vite.config) - Check
process.env.NODE_ENVis defined in vite.config - Verify pilet exports
setupfunction - Check for syntax errors in pilet code
Vue Reactivity Warnings
- Use the Vue pilet glue from
@productifyfw/vue, which wraps registered components withmarkRawto avoid reactivity overhead - Use
reactive(), notshallowReactive(), for the registry