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
4 changes: 4 additions & 0 deletions dev/docker/opencloud/csp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ directives:
- 'blob:'
- 'https://raw.githubusercontent.com/opencloud-eu/awesome-apps/'
- 'https://update.opencloud.eu/'
- 'https://host.docker.internal:*'
- 'wss://host.docker.internal:*'
default-src:
- '''none'''
font-src:
Expand Down Expand Up @@ -39,6 +41,8 @@ directives:
- '''self'''
- '''unsafe-inline'''
- 'https://www.gstatic.com/'
- 'https://host.docker.internal:*'
style-src:
- '''self'''
- '''unsafe-inline'''
- 'https://host.docker.internal:*'
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
],
"devDependencies": {
"@axe-core/playwright": "^4.10.2",
"@module-federation/runtime": "2.2.3",
"@module-federation/vite": "1.13.4",
"@cucumber/cucumber": "12.7.0",
"@cucumber/messages": "32.2.0",
"@cucumber/pretty-formatter": "3.2.0",
Expand Down
15 changes: 15 additions & 0 deletions packages/extension-sdk/externalModules.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Shared dependencies provided by the host to external apps.
// Keep in sync with sharedModules in packages/web-runtime/src/container/application/index.ts
export const externalModules = [
'vue',
'luxon',
'pinia',
'vue3-gettext',
'@opencloud-eu/web-pkg',
'@opencloud-eu/web-client',
'@opencloud-eu/web-client/graph',
'@opencloud-eu/web-client/graph/generated',
'@opencloud-eu/web-client/ocs',
'@opencloud-eu/web-client/sse',
'@opencloud-eu/web-client/webdav'
]
131 changes: 52 additions & 79 deletions packages/extension-sdk/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,52 +7,27 @@ import { join } from 'path'
import { cwd } from 'process'
import { readFileSync, existsSync } from 'fs'
import tailwindcss from '@tailwindcss/vite'

import basicSsl from '@vitejs/plugin-basic-ssl'
import vue from '@vitejs/plugin-vue'
import { federation } from '@module-federation/vite'
import { externalModules } from './externalModules.mjs'

const distDir = process.env.OPENCLOUD_EXTENSION_DIST_DIR || 'dist'

/**
* Capture `document.currentScript` before the UMD factory is called.
*
* Vite's UMD URL resolution uses `document.currentScript` to resolve relative
* asset paths. However, `document.currentScript` is only set by the browser
* during synchronous script execution. When a module loader like RequireJS
* calls the UMD factory callback asynchronously, `document.currentScript` is
* `null`, causing a fallback to `document.baseURI` and incorrect URL resolution.
*
* This plugin captures `document.currentScript` at the top of the script
* (while it's still valid) and replaces references inside with the captured
* value.
*
* Upstreamed to Vite: https://github.com/dschmidt/vite/tree/fix/umd-current-script-polyfill
*/
const completeUmdCurrentScriptPlugin = () => {
return {
name: 'vite:complete-umd-current-script',
renderChunk(code, _chunk, opts) {
if (opts.format !== 'umd') return
if (!code.includes('document.currentScript')) return

return {
code:
'var __vite_currentScript = typeof document !== "undefined" ? document.currentScript : null;' +
code.replaceAll('document.currentScript', '__vite_currentScript'),
map: null
}
}
}
}

const certsDir = process.env.OPENCLOUD_CERTS_DIR
const defaultHttps = () =>
certsDir && {
key: readFileSync(join(certsDir, 'server.key')),
cert: readFileSync(join(certsDir, 'server.crt'))
}
const customHttps = certsDir
? {
key: readFileSync(join(certsDir, 'server.key')),
cert: readFileSync(join(certsDir, 'server.crt'))
}
: null

const manifestFile = 'manifest.json'
const manifestPath = join('./src/', manifestFile)
const remoteEntryName = 'remoteEntry'
const remoteEntryExt = '.mjs'

// Generates manifest.json for OpenCloud app discovery.
const manifestPlugin = () => {
let outputDir

Expand All @@ -75,9 +50,9 @@ const manifestPlugin = () => {
return
}

// Find the entry chunk
// Find the remote entry chunk (emitted by the federation plugin)
const entryChunk = Object.values(bundle).find(
(chunk) => chunk.type === 'chunk' && chunk.isEntry
(chunk) => chunk.type === 'chunk' && chunk.name === remoteEntryName
)

if (!entryChunk) {
Expand All @@ -104,7 +79,7 @@ const manifestPlugin = () => {
// Add manifest.json to the bundle
this.emitFile({
type: 'asset',
fileName: 'manifest.json',
fileName: manifestFile,
source: JSON.stringify(manifest, null, 2)
})
}
Expand All @@ -124,26 +99,7 @@ export const defineConfig = (overrides = {}) => {
const name = overrides.name || packageJson.name

// set default config
const { https = defaultHttps(), port = 9210 } = overrides?.server || {}
const isHttps = !!https

// keep in sync with packages/web-runtime/src/container/application/index.ts
const external = [
'vue',
'luxon',
'pinia',
'vue3-gettext',

'@opencloud-eu/web-client',
'@opencloud-eu/web-client/graph',
'@opencloud-eu/web-client/graph/generated',
'@opencloud-eu/web-client/ocs',
'@opencloud-eu/web-client/sse',
'@opencloud-eu/web-client/webdav',
'@opencloud-eu/web-pkg',
'web-client',
'web-pkg'
]
const { port = 9210 } = overrides?.server || {}

return mergeConfig(
{
Expand All @@ -152,41 +108,58 @@ export const defineConfig = (overrides = {}) => {
cssCodeSplit: true,
minify: isProduction,
outDir: distDir,
// use rollupOptions instead of rolldownOptions as long as we support vite 7
rollupOptions: {
external,
preserveEntrySignatures: 'strict',
input: {
[name]: './src/index.ts'
},
output: {
format: 'umd',
name,
// only used to avoid the MISSING_GLOBAL_NAME warning
globals: Object.fromEntries(
external.map((e) => [
e,
e.replace(/^@/, '').replace(/[/-](\w)/g, (_, c) => c.toUpperCase())
])
),
entryFileNames: join('js', `[name]${isProduction ? '-[hash]' : ''}.js`)
entryFileNames: join('js', `[name]${isProduction ? '-[hash]' : ''}${remoteEntryExt}`),
chunkFileNames: join('js', `[name]-[hash]${remoteEntryExt}`)
}
}
},
plugins: [
vue({
// set to true when switching to esm
customElement: false,
...(isTesting && { template: { compilerOptions: { whitespace: 'preserve' } } })
}),
manifestPlugin(),
completeUmdCurrentScriptPlugin(),
tailwindcss()
...(customHttps ? [] : [basicSsl({ name: 'opencloud' })]),
{
name: 'fix-sec-fetch-dest',
configureServer(server) {
server.middlewares.use((req, res, next) => {
// Vite skips its transform middleware for requests with sec-fetch-dest: document,
// which breaks direct browser navigation to JS files like remoteEntry.js.
if (
(req.url?.endsWith('.js') || req.url?.endsWith('.mjs')) &&
req.headers['sec-fetch-dest'] === 'document'
) {
req.headers['sec-fetch-dest'] = 'script'
}
next()
})
}
},
federation({
name,
exposes: { '.': './src/index.ts' },
filename: `${remoteEntryName}${isProduction ? '-[hash]' : ''}${remoteEntryExt}`,
shared: Object.fromEntries(
externalModules.map((pkg) => [pkg, { singleton: true, import: false }])
),
manifest: false,
dts: false
}),
tailwindcss(),
manifestPlugin()
],
server: {
origin: `https://host.docker.internal:${port}`,
host: 'host.docker.internal',
port,
strictPort: true,
...(isHttps && https)
cors: true,
...(customHttps && { https: customHttps })
},
test: {
globals: true,
Expand Down
2 changes: 2 additions & 0 deletions packages/extension-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
"directory": "packages/extension-sdk"
},
"dependencies": {
"@module-federation/vite": "^1.13.4",
"@tailwindcss/vite": "^4.1.11",
"@vitejs/plugin-basic-ssl": "^2.2.0",
"@vitejs/plugin-vue": "^6.0.0"
},
"peerDependencies": {
Expand Down
59 changes: 47 additions & 12 deletions packages/web-runtime/src/container/application/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,41 +18,71 @@ import * as webClientOcs from '@opencloud-eu/web-client/ocs'
import * as webClientSse from '@opencloud-eu/web-client/sse'
import * as webClientWebdav from '@opencloud-eu/web-client/webdav'

import type { ModuleFederation } from '@module-federation/runtime'
import { urlJoin } from '@opencloud-eu/web-client'
import { App } from 'vue'
import { AppConfigObject, ClassicApplicationScript } from '@opencloud-eu/web-pkg'

export { NextApplication } from './next'

// shim requirejs, trust me it's there... :
// shim requirejs, trust me it's there...
const { requirejs, define } = window as any

// register modules with requirejs to provide them to applications
// keep in sync with packages/extension-sdk/index.mjs
const injectionMap = {
// Shared modules provided to external apps via RequireJS (legacy AMD) and Module Federation.
// Must match externalModules in packages/extension-sdk/externalModules.mjs — verified by unit test.
export const sharedModules: Record<string, unknown> = {
vue,
luxon,
pinia,
vue,
'vue3-gettext': vueGettext,
'@opencloud-eu/web-pkg': webPkg,
'@opencloud-eu/web-client': webClient,
'@opencloud-eu/web-client/graph': webClientGraph,
'@opencloud-eu/web-client/graph/generated': webClientGraphGenerated,
'@opencloud-eu/web-client/ocs': webClientOcs,
'@opencloud-eu/web-client/sse': webClientSse,
'@opencloud-eu/web-client/webdav': webClientWebdav,
'web-pkg': webPkg,
'web-client': webClient
'@opencloud-eu/web-client/webdav': webClientWebdav
}

for (const [key, value] of Object.entries(injectionMap)) {
define(key, () => value)
/**
* Register shared modules with RequireJS (legacy AMD) and Module Federation.
* Called once during bootstrap before any applications are loaded.
*/
export function registerSharedModules(federation: ModuleFederation) {
// RequireJS (legacy AMD apps)
const { define } = window as any
for (const [key, value] of Object.entries(sharedModules)) {
define(key, () => value)
}

// Module Federation
const shared: Record<
string,
{ version: string; scope: string[]; get: () => Promise<() => unknown> }
> = {}
for (const [key, value] of Object.entries(sharedModules)) {
shared[key] = {
version: '0.0.0',
scope: ['default'],
get: () => Promise.resolve(() => value)
}
}
federation.registerShared(shared)
}

const loadScriptDynamicImport = async <T>(moduleUri: string) => {
return ((await import(/* @vite-ignore */ moduleUri)) as any).default as T
}

const loadScriptModuleFederation = async <T>(
federation: ModuleFederation,
remoteUrl: string
): Promise<T> => {
federation.registerRemotes([{ name: remoteUrl, entry: remoteUrl, type: 'module' }])
const module = await federation.loadRemote(remoteUrl)
return (module as any).default as T
}

const loadScriptRequireJS = <T>(moduleUri: string) => {
return new Promise<T>((resolve, reject) =>
requirejs(
Expand All @@ -64,12 +94,14 @@ const loadScriptRequireJS = <T>(moduleUri: string) => {
}

export const loadApplication = async ({
federation,
appName,
applicationKey,
applicationPath,
applicationConfig,
configStore
}: {
federation: ModuleFederation
appName?: string
applicationKey: string
applicationPath: string
Expand Down Expand Up @@ -97,8 +129,11 @@ export const loadApplication = async ({
applicationPath = urlJoin(configStore.serverUrl, applicationPath)
}

if (applicationPath.endsWith('.mjs') || applicationPath.endsWith('.ts')) {
applicationScript = await loadScriptDynamicImport<ClassicApplicationScript>(applicationPath)
if (applicationPath.endsWith('.mjs')) {
applicationScript = await loadScriptModuleFederation<ClassicApplicationScript>(
federation,
applicationPath
)
} else {
applicationScript = await loadScriptRequireJS<ClassicApplicationScript>(applicationPath)
}
Expand Down
Loading