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: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,9 @@ tests/report/cucumber_report.html
/dev/docker/traefik/certificates
docker-compose.override.yml
/dev/docker/apps

# module federation temp files
.__mf__temp/

# claude code
.claude/
149 changes: 149 additions & 0 deletions dev/vite-plugins/federationRegistrationHost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Vite plugin: host-side dev remote registry.
// Accepts periodic re-registration from remote dev servers,
// tracks them with TTL-based cleanup, and exposes them via plugin API.

import type { Plugin, ViteDevServer } from 'vite'

interface FederationRemote {
id: string
path: string
metadata?: Record<string, unknown>
}

interface FederationRemoteRecord extends FederationRemote {
lastSeen: number
}

interface FederationRegistrationHostOptions {
path?: string
ttl?: number
cleanupInterval?: number
onRemoteAdded?: (remote: FederationRemote, ctx: { server: ViteDevServer }) => void
onRemoteUpdated?: (remote: FederationRemote, ctx: { server: ViteDevServer }) => void
onRemoteRemoved?: (id: string, ctx: { server: ViteDevServer }) => void
}

function isEqual(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}

export function federationRegistrationHost({
path = '/_dev/remotes',
ttl = 15_000,
cleanupInterval = 10_000,
onRemoteAdded,
onRemoteUpdated,
onRemoteRemoved
}: FederationRegistrationHostOptions = {}) {
const remotes = new Map<string, FederationRemoteRecord>()

return {
name: 'federation-registration-host',
apply: 'serve',

api: {
getRemotes() {
return remotes
}
},

configureServer(server: ViteDevServer) {
const ctx = { server }

function fullReload() {
server.environments.client.hot.send({ type: 'full-reload', path: '*' })
}

// TTL-based cleanup
const interval = setInterval(() => {
const now = Date.now()
for (const [id, entry] of remotes) {
if (now - entry.lastSeen > ttl) {
remotes.delete(id)
console.log(`[federation-registration] TTL expired, removed: ${id}`)
if (onRemoteRemoved) {
onRemoteRemoved(id, ctx)
} else {
fullReload()
}
}
}
}, cleanupInterval)
server.httpServer?.on('close', () => clearInterval(interval))

server.middlewares.use((request, response, next) => {
if (!request.url?.startsWith(path)) {
return next()
}

// POST — register/update a remote
if (request.url === path && request.method === 'POST') {
let body = ''
request.on('data', (chunk: Buffer) => (body += chunk))
request.on('end', () => {
try {
const { id, path: remotePath, metadata } = JSON.parse(body)
if (!id || !remotePath) {
response.statusCode = 400
response.end(JSON.stringify({ error: 'id and path required' }))
return
}

const newData: FederationRemote = {
id,
path: remotePath,
...(metadata && { metadata })
}
const existing = remotes.get(id)

if (!existing) {
remotes.set(id, { ...newData, lastSeen: Date.now() })
console.log(`[federation-registration] Added: ${id} -> ${remotePath}`)
if (onRemoteAdded) {
onRemoteAdded(newData, ctx)
} else {
fullReload()
}
} else {
const existingData: FederationRemote = {
id: existing.id,
path: existing.path,
...(existing.metadata && { metadata: existing.metadata })
}
existing.lastSeen = Date.now()

if (!isEqual(existingData, newData)) {
remotes.set(id, { ...newData, lastSeen: Date.now() })
console.log(`[federation-registration] Updated: ${id} -> ${remotePath}`)
if (onRemoteUpdated) {
onRemoteUpdated(newData, ctx)
} else {
fullReload()
}
}
}

response.statusCode = 200
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify({ ok: true }))
} catch {
response.statusCode = 400
response.end(JSON.stringify({ error: 'invalid JSON' }))
}
})
return
}

// GET — list registered remotes (debug)
if (request.url === path && request.method === 'GET') {
response.statusCode = 200
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify([...remotes.values()]))
return
}

next()
})
}
} satisfies Plugin
}
14 changes: 2 additions & 12 deletions packages/extension-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,13 @@ $ yarn add @opencloud-eu/extension-sdk --dev

## Usage

You can use the OpenCloud vite config via the `defineConfig` method provided by this package. The following example showcases how your `vite.config.ts` file could look like:
You can use the OpenCloud vite config via the `defineConfig` method provided by this package. The following example showcases what your `vite.config.ts` file could look like:

```ts
import { defineConfig } from '@opencloud-eu/extension-sdk'

export default defineConfig({
name: 'your-app',
server: {
port: 9700
},
build: {
rolldownOptions: {
output: {
entryFileNames: 'your-app.js'
}
}
}
name: 'your-app'
})
```

Expand Down
122 changes: 122 additions & 0 deletions packages/extension-sdk/federationRegistrationClient.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Vite plugin: remote-side periodic registration with a host dev server.
// Sends registration data at a fixed interval so the host always knows
// about this remote, even after the host restarts.

import https from 'https'
import http from 'http'
import { watch } from 'fs'

/**
* Minimal fetch helper that accepts self-signed certificates (no extra deps).
* @param {string} url
* @param {{ method?: string, body?: string }} [options]
*/
function devFetch(url, { method = 'GET', body } = {}) {
return new Promise((resolve, reject) => {
const mod = new URL(url).protocol === 'https:' ? https : http
const req = mod.request(url, { method, rejectUnauthorized: false }, (res) => {
let data = ''
res.on('data', (chunk) => (data += chunk))
res.on('end', () =>
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, data })
)
})
req.on('error', reject)
if (body) {
req.setHeader('Content-Type', 'application/json')
req.write(body)
}
req.end()
})
}

/**
* @param {object} options
* @param {string} options.hostUrl - Host dev server URL (e.g. 'https://host.docker.internal:9201')
* @param {string} options.name - Remote ID used for registration
* @param {string} options.entryPoint - Remote entry filename (e.g. 'remoteEntry.mjs'), combined with the server address to form the full URL
* @param {() => Record<string, unknown> | undefined} [options.getMetadata] - Returns optional metadata to send with registration
* @param {number} [options.interval] - Re-registration interval in ms (default: 5000)
* @param {string} [options.path] - Host endpoint path (default: '/_dev/remotes')
* @param {string[]} [options.metadataWatchFiles] - File paths to watch for immediate re-registration on metadata change
*/
export function federationRegistrationClient({
hostUrl,
name,
entryPoint,
getMetadata,
interval = 5_000,
path = '/_dev/remotes',
metadataWatchFiles = []
}) {
return {
name: 'federation-registration-client',
apply: 'serve',

/** @param {import('vite').ViteDevServer} server */
configureServer(server) {
// Skip registration during test runs (Vitest uses serve mode internally)
if (server.config.mode === 'test') return

let registrationInterval = null
let entryPointUrl = ''
const watchers = []

async function register() {
if (!entryPointUrl) return

const metadata = getMetadata?.()
const url = `${hostUrl}${path}`
try {
const res = await devFetch(url, {
method: 'POST',
body: JSON.stringify({
id: name,
path: entryPointUrl,
...(metadata && { metadata })
})
})
if (!res.ok) {
console.warn(`[federation-registration] Registration failed: ${res.status}`)
}
} catch {
// Host is likely down — will retry on next interval
}
}

server.httpServer?.on('listening', () => {
const address = server.httpServer.address()
const port = typeof address === 'object' ? address?.port : address
const hostname =
typeof server.config.server.host === 'string' ? server.config.server.host : 'localhost'
entryPointUrl = `https://${hostname}:${port}/${entryPoint}`

register()
registrationInterval = setInterval(register, interval)

// Watch files for immediate re-registration on metadata change
for (const filePath of metadataWatchFiles) {
try {
const watcher = watch(filePath, { persistent: false }, () => {
console.log(`[federation-registration] ${filePath} changed, re-registering...`)
register()
})
watchers.push(watcher)
} catch {
// File may not exist yet — that's fine
}
}
})

server.httpServer?.on('close', () => {
if (registrationInterval) {
clearInterval(registrationInterval)
registrationInterval = null
}
for (const watcher of watchers) {
watcher.close()
}
})
}
}
}
Loading