@opencloud-eu/extension-sdk 5.1.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,23 +18,13 @@ $ yarn add @opencloud-eu/extension-sdk --dev
18
18
 
19
19
  ## Usage
20
20
 
21
- 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:
21
+ 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:
22
22
 
23
23
  ```ts
24
24
  import { defineConfig } from '@opencloud-eu/extension-sdk'
25
25
 
26
26
  export default defineConfig({
27
- name: 'your-app',
28
- server: {
29
- port: 9700
30
- },
31
- build: {
32
- rollupOptions: {
33
- output: {
34
- entryFileNames: 'your-app.js'
35
- }
36
- }
37
- }
27
+ name: 'your-app'
38
28
  })
39
29
  ```
40
30
 
@@ -0,0 +1,15 @@
1
+ // Shared dependencies provided by the host to external apps.
2
+ // Keep in sync with sharedModules in packages/web-runtime/src/container/application/index.ts
3
+ export const externalModules = [
4
+ 'vue',
5
+ 'luxon',
6
+ 'pinia',
7
+ 'vue3-gettext',
8
+ '@opencloud-eu/web-pkg',
9
+ '@opencloud-eu/web-client',
10
+ '@opencloud-eu/web-client/graph',
11
+ '@opencloud-eu/web-client/graph/generated',
12
+ '@opencloud-eu/web-client/ocs',
13
+ '@opencloud-eu/web-client/sse',
14
+ '@opencloud-eu/web-client/webdav'
15
+ ]
@@ -0,0 +1,122 @@
1
+ // Vite plugin: remote-side periodic registration with a host dev server.
2
+ // Sends registration data at a fixed interval so the host always knows
3
+ // about this remote, even after the host restarts.
4
+
5
+ import https from 'https'
6
+ import http from 'http'
7
+ import { watch } from 'fs'
8
+
9
+ /**
10
+ * Minimal fetch helper that accepts self-signed certificates (no extra deps).
11
+ * @param {string} url
12
+ * @param {{ method?: string, body?: string }} [options]
13
+ */
14
+ function devFetch(url, { method = 'GET', body } = {}) {
15
+ return new Promise((resolve, reject) => {
16
+ const mod = new URL(url).protocol === 'https:' ? https : http
17
+ const req = mod.request(url, { method, rejectUnauthorized: false }, (res) => {
18
+ let data = ''
19
+ res.on('data', (chunk) => (data += chunk))
20
+ res.on('end', () =>
21
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, data })
22
+ )
23
+ })
24
+ req.on('error', reject)
25
+ if (body) {
26
+ req.setHeader('Content-Type', 'application/json')
27
+ req.write(body)
28
+ }
29
+ req.end()
30
+ })
31
+ }
32
+
33
+ /**
34
+ * @param {object} options
35
+ * @param {string} options.hostUrl - Host dev server URL (e.g. 'https://host.docker.internal:9201')
36
+ * @param {string} options.name - Remote ID used for registration
37
+ * @param {string} options.entryPoint - Remote entry filename (e.g. 'remoteEntry.mjs'), combined with the server address to form the full URL
38
+ * @param {() => Record<string, unknown> | undefined} [options.getMetadata] - Returns optional metadata to send with registration
39
+ * @param {number} [options.interval] - Re-registration interval in ms (default: 5000)
40
+ * @param {string} [options.path] - Host endpoint path (default: '/_dev/remotes')
41
+ * @param {string[]} [options.metadataWatchFiles] - File paths to watch for immediate re-registration on metadata change
42
+ */
43
+ export function federationRegistrationClient({
44
+ hostUrl,
45
+ name,
46
+ entryPoint,
47
+ getMetadata,
48
+ interval = 5_000,
49
+ path = '/_dev/remotes',
50
+ metadataWatchFiles = []
51
+ }) {
52
+ return {
53
+ name: 'federation-registration-client',
54
+ apply: 'serve',
55
+
56
+ /** @param {import('vite').ViteDevServer} server */
57
+ configureServer(server) {
58
+ // Skip registration during test runs (Vitest uses serve mode internally)
59
+ if (server.config.mode === 'test') return
60
+
61
+ let registrationInterval = null
62
+ let entryPointUrl = ''
63
+ const watchers = []
64
+
65
+ async function register() {
66
+ if (!entryPointUrl) return
67
+
68
+ const metadata = getMetadata?.()
69
+ const url = `${hostUrl}${path}`
70
+ try {
71
+ const res = await devFetch(url, {
72
+ method: 'POST',
73
+ body: JSON.stringify({
74
+ id: name,
75
+ path: entryPointUrl,
76
+ ...(metadata && { metadata })
77
+ })
78
+ })
79
+ if (!res.ok) {
80
+ console.warn(`[federation-registration] Registration failed: ${res.status}`)
81
+ }
82
+ } catch {
83
+ // Host is likely down — will retry on next interval
84
+ }
85
+ }
86
+
87
+ server.httpServer?.on('listening', () => {
88
+ const address = server.httpServer.address()
89
+ const port = typeof address === 'object' ? address?.port : address
90
+ const hostname =
91
+ typeof server.config.server.host === 'string' ? server.config.server.host : 'localhost'
92
+ entryPointUrl = `https://${hostname}:${port}/${entryPoint}`
93
+
94
+ register()
95
+ registrationInterval = setInterval(register, interval)
96
+
97
+ // Watch files for immediate re-registration on metadata change
98
+ for (const filePath of metadataWatchFiles) {
99
+ try {
100
+ const watcher = watch(filePath, { persistent: false }, () => {
101
+ console.log(`[federation-registration] ${filePath} changed, re-registering...`)
102
+ register()
103
+ })
104
+ watchers.push(watcher)
105
+ } catch {
106
+ // File may not exist yet — that's fine
107
+ }
108
+ }
109
+ })
110
+
111
+ server.httpServer?.on('close', () => {
112
+ if (registrationInterval) {
113
+ clearInterval(registrationInterval)
114
+ registrationInterval = null
115
+ }
116
+ for (const watcher of watchers) {
117
+ watcher.close()
118
+ }
119
+ })
120
+ }
121
+ }
122
+ }
package/index.mjs CHANGED
@@ -7,20 +7,51 @@ import { join } from 'path'
7
7
  import { cwd } from 'process'
8
8
  import { readFileSync, existsSync } from 'fs'
9
9
  import tailwindcss from '@tailwindcss/vite'
10
-
10
+ import basicSsl from '@vitejs/plugin-basic-ssl'
11
11
  import vue from '@vitejs/plugin-vue'
12
+ import { federation } from '@module-federation/vite'
13
+ import { externalModules } from './externalModules.mjs'
14
+ import { federationRegistrationClient } from './federationRegistrationClient.mjs'
12
15
 
13
16
  const distDir = process.env.OPENCLOUD_EXTENSION_DIST_DIR || 'dist'
14
17
 
15
18
  const certsDir = process.env.OPENCLOUD_CERTS_DIR
16
- const defaultHttps = () =>
17
- certsDir && {
18
- key: readFileSync(join(certsDir, 'server.key')),
19
- cert: readFileSync(join(certsDir, 'server.crt'))
19
+ const customHttps = certsDir
20
+ ? {
21
+ key: readFileSync(join(certsDir, 'server.key')),
22
+ cert: readFileSync(join(certsDir, 'server.crt'))
23
+ }
24
+ : null
25
+
26
+ // Deep merge objects, replace arrays (matches mergo.WithOverride behavior on the Go side).
27
+ function deepMerge(target, source) {
28
+ const result = { ...target }
29
+ for (const key of Object.keys(source)) {
30
+ const tv = target[key]
31
+ const sv = source[key]
32
+ if (
33
+ sv &&
34
+ typeof sv === 'object' &&
35
+ !Array.isArray(sv) &&
36
+ tv &&
37
+ typeof tv === 'object' &&
38
+ !Array.isArray(tv)
39
+ ) {
40
+ result[key] = deepMerge(tv, sv)
41
+ } else {
42
+ result[key] = sv
43
+ }
20
44
  }
45
+ return result
46
+ }
21
47
 
22
48
  const manifestFile = 'manifest.json'
23
49
  const manifestPath = join('./src/', manifestFile)
50
+ const appConfigPath = join('./src/', 'config.json')
51
+ const remoteEntryName = 'remoteEntry'
52
+ const remoteEntryExt = '.mjs'
53
+
54
+ // Generates manifest.json for OpenCloud app discovery.
24
55
  const manifestPlugin = () => {
25
56
  let outputDir
26
57
 
@@ -43,9 +74,9 @@ const manifestPlugin = () => {
43
74
  return
44
75
  }
45
76
 
46
- // Find the entry chunk
77
+ // Find the remote entry chunk (emitted by the federation plugin)
47
78
  const entryChunk = Object.values(bundle).find(
48
- (chunk) => chunk.type === 'chunk' && chunk.isEntry
79
+ (chunk) => chunk.type === 'chunk' && chunk.name === remoteEntryName
49
80
  )
50
81
 
51
82
  if (!entryChunk) {
@@ -72,7 +103,7 @@ const manifestPlugin = () => {
72
103
  // Add manifest.json to the bundle
73
104
  this.emitFile({
74
105
  type: 'asset',
75
- fileName: 'manifest.json',
106
+ fileName: manifestFile,
76
107
  source: JSON.stringify(manifest, null, 2)
77
108
  })
78
109
  }
@@ -92,8 +123,35 @@ export const defineConfig = (overrides = {}) => {
92
123
  const name = overrides.name || packageJson.name
93
124
 
94
125
  // set default config
95
- const { https = defaultHttps(), port = 9210 } = overrides?.server || {}
96
- const isHttps = !!https
126
+ const { port = 9210 } = overrides?.server || {}
127
+ const hostUrl =
128
+ overrides?.opencloudWebHostUrl ||
129
+ process.env.OPENCLOUD_WEB_HOST_URL ||
130
+ 'https://host.docker.internal:9201'
131
+
132
+ // Read merged metadata: manifest.json defaults + config.json overrides
133
+ function readAppConfig() {
134
+ let config = {}
135
+ if (existsSync(manifestPath)) {
136
+ try {
137
+ const manifest = JSON.parse(readFileSync(manifestPath).toString())
138
+ if (manifest.config) {
139
+ config = manifest.config
140
+ }
141
+ } catch {
142
+ // ignore malformed manifest
143
+ }
144
+ }
145
+ if (existsSync(appConfigPath)) {
146
+ try {
147
+ const overrides = JSON.parse(readFileSync(appConfigPath).toString())
148
+ config = deepMerge(config, overrides)
149
+ } catch {
150
+ // ignore malformed config
151
+ }
152
+ }
153
+ return Object.keys(config).length > 0 ? config : undefined
154
+ }
97
155
 
98
156
  return mergeConfig(
99
157
  {
@@ -103,47 +161,63 @@ export const defineConfig = (overrides = {}) => {
103
161
  minify: isProduction,
104
162
  outDir: distDir,
105
163
  rollupOptions: {
106
- // keep in sync with packages/web-runtime/src/container/application/index.ts
107
- external: [
108
- 'vue',
109
- 'luxon',
110
- 'pinia',
111
- 'vue3-gettext',
112
-
113
- '@opencloud-eu/web-client',
114
- '@opencloud-eu/web-client/graph',
115
- '@opencloud-eu/web-client/graph/generated',
116
- '@opencloud-eu/web-client/ocs',
117
- '@opencloud-eu/web-client/sse',
118
- '@opencloud-eu/web-client/webdav',
119
- '@opencloud-eu/web-pkg',
120
- 'web-client',
121
- 'web-pkg'
122
- ],
123
- preserveEntrySignatures: 'strict',
124
164
  input: {
125
165
  [name]: './src/index.ts'
126
166
  },
127
167
  output: {
128
- format: 'amd',
129
- chunkFileNames: join('js', 'chunks', '[name]-[hash].mjs'),
130
- entryFileNames: join('js', `[name]${isProduction ? '-[hash]' : ''}.js`)
168
+ entryFileNames: join('js', `[name]${isProduction ? '-[hash]' : ''}${remoteEntryExt}`),
169
+ chunkFileNames: join('js', `[name]-[hash]${remoteEntryExt}`)
131
170
  }
132
171
  }
133
172
  },
134
173
  plugins: [
135
174
  vue({
136
- // set to true when switching to esm
137
175
  customElement: false,
138
176
  ...(isTesting && { template: { compilerOptions: { whitespace: 'preserve' } } })
139
177
  }),
178
+ ...(customHttps ? [] : [basicSsl({ name: 'opencloud' })]),
179
+ {
180
+ name: 'fix-sec-fetch-dest',
181
+ configureServer(server) {
182
+ server.middlewares.use((req, res, next) => {
183
+ // Vite skips its transform middleware for requests with sec-fetch-dest: document,
184
+ // which breaks direct browser navigation to JS files like remoteEntry.js.
185
+ if (
186
+ (req.url?.endsWith('.js') || req.url?.endsWith('.mjs')) &&
187
+ req.headers['sec-fetch-dest'] === 'document'
188
+ ) {
189
+ req.headers['sec-fetch-dest'] = 'script'
190
+ }
191
+ next()
192
+ })
193
+ }
194
+ },
195
+ federation({
196
+ name,
197
+ exposes: { '.': './src/index.ts' },
198
+ filename: `${remoteEntryName}${isProduction ? '-[hash]' : ''}${remoteEntryExt}`,
199
+ shared: Object.fromEntries(
200
+ externalModules.map((pkg) => [pkg, { singleton: true, import: false }])
201
+ ),
202
+ manifest: false,
203
+ dts: false
204
+ }),
205
+ tailwindcss(),
140
206
  manifestPlugin(),
141
- tailwindcss()
207
+ federationRegistrationClient({
208
+ hostUrl,
209
+ name,
210
+ entryPoint: `${remoteEntryName}${remoteEntryExt}`,
211
+ getMetadata: readAppConfig,
212
+ metadataWatchFiles: [manifestPath, appConfigPath]
213
+ })
142
214
  ],
143
215
  server: {
216
+ origin: `https://host.docker.internal:${port}`,
217
+ host: 'host.docker.internal',
144
218
  port,
145
- strictPort: true,
146
- ...(isHttps && https)
219
+ cors: true,
220
+ ...(customHttps && { https: customHttps })
147
221
  },
148
222
  test: {
149
223
  globals: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencloud-eu/extension-sdk",
3
- "version": "5.1.0",
3
+ "version": "6.1.0",
4
4
  "description": "OpenCloud Web Extension SDK",
5
5
  "license": "AGPL-3.0",
6
6
  "main": "index.mjs",
@@ -14,10 +14,12 @@
14
14
  "directory": "packages/extension-sdk"
15
15
  },
16
16
  "dependencies": {
17
- "@tailwindcss/vite": "^4.1.11",
18
- "@vitejs/plugin-vue": "^6.0.0"
17
+ "@module-federation/vite": "^1.13.5",
18
+ "@tailwindcss/vite": "^4.2.2",
19
+ "@vitejs/plugin-basic-ssl": "^2.3.0",
20
+ "@vitejs/plugin-vue": "^6.0.5"
19
21
  },
20
22
  "peerDependencies": {
21
- "vite": "^7.2.0"
23
+ "vite": "^7.2.0 || ^8.0.0"
22
24
  }
23
25
  }