@signalk/app-dock 0.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/.github/workflows/ci.yml +11 -0
- package/.github/workflows/publish.yml +33 -0
- package/.prettierignore +6 -0
- package/.prettierrc +6 -0
- package/LICENSE +190 -0
- package/README.md +83 -0
- package/eslint.config.js +55 -0
- package/package.json +40 -0
- package/plugin/index.js +180 -0
- package/public/540.js +2 -0
- package/public/540.js.LICENSE.txt +9 -0
- package/public/805.js +1 -0
- package/public/app-icon.svg +29 -0
- package/public/config.html +469 -0
- package/public/dock.css +412 -0
- package/public/dock.js +456 -0
- package/public/icon-settings.svg +21 -0
- package/public/index.html +41 -0
- package/public/main.js +1 -0
- package/public/remoteEntry.js +1 -0
- package/src/configpanel/PluginConfigurationPanel.js +441 -0
- package/src/configpanel/index.js +1 -0
- package/test/plugin.test.js +394 -0
- package/webpack.config.js +39 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import React, { useState, useRef, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
const S = {
|
|
4
|
+
root: {
|
|
5
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
6
|
+
color: '#333',
|
|
7
|
+
padding: '16px 0'
|
|
8
|
+
},
|
|
9
|
+
sectionTitle: {
|
|
10
|
+
fontSize: 13,
|
|
11
|
+
fontWeight: 600,
|
|
12
|
+
color: '#888',
|
|
13
|
+
textTransform: 'uppercase',
|
|
14
|
+
letterSpacing: '0.05em',
|
|
15
|
+
marginBottom: 10,
|
|
16
|
+
marginTop: 24
|
|
17
|
+
},
|
|
18
|
+
btn: {
|
|
19
|
+
display: 'inline-flex',
|
|
20
|
+
alignItems: 'center',
|
|
21
|
+
gap: 8,
|
|
22
|
+
padding: '8px 16px',
|
|
23
|
+
border: 'none',
|
|
24
|
+
borderRadius: 6,
|
|
25
|
+
fontSize: 13,
|
|
26
|
+
fontWeight: 600,
|
|
27
|
+
cursor: 'pointer'
|
|
28
|
+
},
|
|
29
|
+
btnPrimary: { background: '#3b82f6', color: '#fff' },
|
|
30
|
+
btnSave: { background: '#10b981', color: '#fff' },
|
|
31
|
+
btnDanger: { background: '#ef4444', color: '#fff', padding: '4px 10px', fontSize: 11 },
|
|
32
|
+
status: { marginTop: 8, fontSize: 12, minHeight: 18 },
|
|
33
|
+
item: {
|
|
34
|
+
display: 'flex',
|
|
35
|
+
alignItems: 'center',
|
|
36
|
+
gap: 10,
|
|
37
|
+
padding: '8px 12px',
|
|
38
|
+
background: '#f8f9fa',
|
|
39
|
+
border: '1px solid #e0e0e0',
|
|
40
|
+
borderRadius: 8,
|
|
41
|
+
marginBottom: 6,
|
|
42
|
+
cursor: 'grab'
|
|
43
|
+
},
|
|
44
|
+
itemDisabled: { opacity: 0.4 },
|
|
45
|
+
dragOver: { borderColor: '#3b82f6', background: '#eef4ff' },
|
|
46
|
+
handle: {
|
|
47
|
+
color: '#bbb',
|
|
48
|
+
fontSize: 16,
|
|
49
|
+
cursor: 'grab',
|
|
50
|
+
userSelect: 'none',
|
|
51
|
+
flexShrink: 0,
|
|
52
|
+
width: 18,
|
|
53
|
+
textAlign: 'center'
|
|
54
|
+
},
|
|
55
|
+
iconBox: {
|
|
56
|
+
width: 36,
|
|
57
|
+
height: 36,
|
|
58
|
+
borderRadius: 8,
|
|
59
|
+
display: 'flex',
|
|
60
|
+
alignItems: 'center',
|
|
61
|
+
justifyContent: 'center',
|
|
62
|
+
background: '#e2e8f0',
|
|
63
|
+
overflow: 'hidden',
|
|
64
|
+
flexShrink: 0,
|
|
65
|
+
fontSize: 18
|
|
66
|
+
},
|
|
67
|
+
iconImg: { width: '100%', height: '100%', objectFit: 'contain', padding: 5 },
|
|
68
|
+
info: { flex: 1, minWidth: 0 },
|
|
69
|
+
name: { fontSize: 13, fontWeight: 600, color: '#333' },
|
|
70
|
+
url: { fontSize: 10, color: '#999', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
71
|
+
toggle: { flexShrink: 0 },
|
|
72
|
+
checkbox: { width: 16, height: 16, cursor: 'pointer', accentColor: '#3b82f6' },
|
|
73
|
+
preview: {
|
|
74
|
+
display: 'flex',
|
|
75
|
+
alignItems: 'flex-end',
|
|
76
|
+
justifyContent: 'center',
|
|
77
|
+
gap: 6,
|
|
78
|
+
padding: '12px 18px',
|
|
79
|
+
background: 'rgba(30,30,32,0.85)',
|
|
80
|
+
border: '1px solid rgba(255,255,255,0.18)',
|
|
81
|
+
borderRadius: 18,
|
|
82
|
+
margin: '16px auto',
|
|
83
|
+
width: 'fit-content'
|
|
84
|
+
},
|
|
85
|
+
actions: { display: 'flex', gap: 10, alignItems: 'center', marginTop: 16 },
|
|
86
|
+
empty: { textAlign: 'center', padding: '30px 16px', color: '#999', fontSize: 13 },
|
|
87
|
+
fieldRow: { display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 },
|
|
88
|
+
label: { fontSize: 13, fontWeight: 500, color: '#555', width: 160, flexShrink: 0 },
|
|
89
|
+
select: {
|
|
90
|
+
padding: '6px 10px',
|
|
91
|
+
borderRadius: 6,
|
|
92
|
+
border: '1px solid #ccc',
|
|
93
|
+
fontSize: 13,
|
|
94
|
+
background: '#fff',
|
|
95
|
+
color: '#333'
|
|
96
|
+
},
|
|
97
|
+
input: {
|
|
98
|
+
padding: '6px 10px',
|
|
99
|
+
borderRadius: 6,
|
|
100
|
+
border: '1px solid #ccc',
|
|
101
|
+
fontSize: 13,
|
|
102
|
+
width: 80,
|
|
103
|
+
background: '#fff',
|
|
104
|
+
color: '#333'
|
|
105
|
+
},
|
|
106
|
+
checkboxField: { width: 16, height: 16, accentColor: '#3b82f6' },
|
|
107
|
+
hint: { fontSize: 11, color: '#aaa', marginLeft: 8 }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function IconPreview({ icon, label, color, size }) {
|
|
111
|
+
const sz = size || 36
|
|
112
|
+
const style = {
|
|
113
|
+
...S.iconBox,
|
|
114
|
+
width: sz,
|
|
115
|
+
height: sz,
|
|
116
|
+
borderRadius: sz * 0.22,
|
|
117
|
+
...(color ? { background: color } : {})
|
|
118
|
+
}
|
|
119
|
+
const iconVal = icon || ''
|
|
120
|
+
if (iconVal.startsWith('/') || iconVal.startsWith('http')) {
|
|
121
|
+
return (
|
|
122
|
+
<div style={style}>
|
|
123
|
+
<img
|
|
124
|
+
src={iconVal}
|
|
125
|
+
alt={label}
|
|
126
|
+
style={{ ...S.iconImg, padding: sz * 0.12 }}
|
|
127
|
+
onError={(e) => {
|
|
128
|
+
e.target.style.display = 'none'
|
|
129
|
+
e.target.parentNode.textContent = (label || '??').slice(0, 2).toUpperCase()
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
return <div style={style}>{iconVal || (label || '??').slice(0, 2).toUpperCase()}</div>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function SelectField({ label, value, options, onChange, hint }) {
|
|
139
|
+
return (
|
|
140
|
+
<div style={S.fieldRow}>
|
|
141
|
+
<span style={S.label}>{label}</span>
|
|
142
|
+
<select style={S.select} value={value} onChange={(e) => onChange(e.target.value)}>
|
|
143
|
+
{options.map((o) => (
|
|
144
|
+
<option key={o.value} value={o.value}>
|
|
145
|
+
{o.label}
|
|
146
|
+
</option>
|
|
147
|
+
))}
|
|
148
|
+
</select>
|
|
149
|
+
{hint && <span style={S.hint}>{hint}</span>}
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function NumberField({ label, value, onChange, hint }) {
|
|
155
|
+
return (
|
|
156
|
+
<div style={S.fieldRow}>
|
|
157
|
+
<span style={S.label}>{label}</span>
|
|
158
|
+
<input style={S.input} type="number" value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
|
159
|
+
{hint && <span style={S.hint}>{hint}</span>}
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function CheckboxField({ label, checked, onChange, hint }) {
|
|
165
|
+
return (
|
|
166
|
+
<div style={S.fieldRow}>
|
|
167
|
+
<span style={S.label}>{label}</span>
|
|
168
|
+
<input type="checkbox" style={S.checkboxField} checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
|
169
|
+
{hint && <span style={S.hint}>{hint}</span>}
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default function PluginConfigurationPanel({ configuration, save }) {
|
|
175
|
+
const cfg = configuration || {}
|
|
176
|
+
const [apps, setApps] = useState(() => cfg.apps || [])
|
|
177
|
+
const [position, setPosition] = useState(cfg.position || 'bottom')
|
|
178
|
+
const [triggerCorner, setTriggerCorner] = useState(cfg.triggerCorner || 'bottom-right')
|
|
179
|
+
const [iframeMode, setIframeMode] = useState(cfg.iframeMode || 'keep-alive')
|
|
180
|
+
const [iconSize, setIconSize] = useState(cfg.iconSize || 56)
|
|
181
|
+
const [magnification, setMagnification] = useState(cfg.magnification !== false)
|
|
182
|
+
const [magnificationScale, setMagnificationScale] = useState(cfg.magnificationScale || 1.7)
|
|
183
|
+
|
|
184
|
+
const [status, setStatus] = useState('')
|
|
185
|
+
const [statusError, setStatusError] = useState(false)
|
|
186
|
+
const [discovering, setDiscovering] = useState(false)
|
|
187
|
+
const [dragIdx, setDragIdx] = useState(null)
|
|
188
|
+
const [overIdx, setOverIdx] = useState(null)
|
|
189
|
+
|
|
190
|
+
const appsRef = useRef(apps)
|
|
191
|
+
appsRef.current = apps
|
|
192
|
+
|
|
193
|
+
const buildConfig = useCallback(
|
|
194
|
+
(appsList) => ({
|
|
195
|
+
position,
|
|
196
|
+
triggerCorner,
|
|
197
|
+
iframeMode,
|
|
198
|
+
iconSize,
|
|
199
|
+
magnification,
|
|
200
|
+
magnificationScale,
|
|
201
|
+
apps: appsList
|
|
202
|
+
}),
|
|
203
|
+
[position, triggerCorner, iframeMode, iconSize, magnification, magnificationScale]
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
const doSave = useCallback(() => {
|
|
207
|
+
save(buildConfig(apps))
|
|
208
|
+
setStatus('Saved!')
|
|
209
|
+
setStatusError(false)
|
|
210
|
+
}, [apps, buildConfig, save])
|
|
211
|
+
|
|
212
|
+
const discover = async () => {
|
|
213
|
+
setDiscovering(true)
|
|
214
|
+
setStatus('')
|
|
215
|
+
setStatusError(false)
|
|
216
|
+
try {
|
|
217
|
+
const res = await fetch('/skServer/webapps')
|
|
218
|
+
const allWebapps = await res.json()
|
|
219
|
+
const webapps = allWebapps
|
|
220
|
+
.filter((w) => w.name !== 'signalk-app-dock')
|
|
221
|
+
.map((w) => {
|
|
222
|
+
if (w.name === '@signalk/server-admin-ui') {
|
|
223
|
+
return {
|
|
224
|
+
name: w.name,
|
|
225
|
+
label: 'Settings',
|
|
226
|
+
url: '/admin/',
|
|
227
|
+
icon: '/signalk-app-dock/icon-settings.svg',
|
|
228
|
+
isAdmin: true
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
name: w.name,
|
|
233
|
+
label: w.signalk?.displayName || w.name,
|
|
234
|
+
url: `/${w.name}/`,
|
|
235
|
+
icon: w.signalk?.appIcon ? `/${w.name}/${w.signalk.appIcon}` : null
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
if (webapps.length === 0) {
|
|
240
|
+
setStatus('No webapps found. Is the server fully started?')
|
|
241
|
+
setStatusError(true)
|
|
242
|
+
setDiscovering(false)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const current = appsRef.current
|
|
247
|
+
const existingUrls = new Set(current.map((a) => a.url))
|
|
248
|
+
let added = 0
|
|
249
|
+
const merged = [...current]
|
|
250
|
+
for (const w of webapps) {
|
|
251
|
+
if (!existingUrls.has(w.url)) {
|
|
252
|
+
merged.push({
|
|
253
|
+
enabled: true,
|
|
254
|
+
autostart: false,
|
|
255
|
+
url: w.url,
|
|
256
|
+
label: w.label || '',
|
|
257
|
+
icon: w.icon || '',
|
|
258
|
+
color: w.isAdmin ? '#78788c' : ''
|
|
259
|
+
})
|
|
260
|
+
added++
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
setApps(merged)
|
|
265
|
+
setStatus(
|
|
266
|
+
added > 0
|
|
267
|
+
? `Found ${webapps.length} webapps, added ${added} new.`
|
|
268
|
+
: `All ${webapps.length} webapps already in list.`
|
|
269
|
+
)
|
|
270
|
+
setStatusError(false)
|
|
271
|
+
} catch (e) {
|
|
272
|
+
setStatus('Discovery failed: ' + e.message)
|
|
273
|
+
setStatusError(true)
|
|
274
|
+
}
|
|
275
|
+
setDiscovering(false)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const toggleApp = (i) => setApps(apps.map((a, j) => (j === i ? { ...a, enabled: !a.enabled } : a)))
|
|
279
|
+
const toggleAutostart = (i) =>
|
|
280
|
+
setApps(apps.map((a, j) => (j === i ? { ...a, autostart: !a.autostart } : { ...a, autostart: false })))
|
|
281
|
+
const removeApp = (i) => setApps(apps.filter((_, j) => j !== i))
|
|
282
|
+
|
|
283
|
+
const onDragStart = (i) => setDragIdx(i)
|
|
284
|
+
const onDragOver = (e, i) => {
|
|
285
|
+
e.preventDefault()
|
|
286
|
+
setOverIdx(i)
|
|
287
|
+
}
|
|
288
|
+
const onDragLeave = () => setOverIdx(null)
|
|
289
|
+
const onDrop = (e, dropIdx) => {
|
|
290
|
+
e.preventDefault()
|
|
291
|
+
setOverIdx(null)
|
|
292
|
+
if (dragIdx !== null && dragIdx !== dropIdx) {
|
|
293
|
+
const next = [...apps]
|
|
294
|
+
const [moved] = next.splice(dragIdx, 1)
|
|
295
|
+
next.splice(dropIdx, 0, moved)
|
|
296
|
+
setApps(next)
|
|
297
|
+
}
|
|
298
|
+
setDragIdx(null)
|
|
299
|
+
}
|
|
300
|
+
const onDragEnd = () => {
|
|
301
|
+
setDragIdx(null)
|
|
302
|
+
setOverIdx(null)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const enabledApps = apps.filter((a) => a.enabled !== false)
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<div style={S.root}>
|
|
309
|
+
<div style={S.sectionTitle}>Dock Settings</div>
|
|
310
|
+
|
|
311
|
+
<SelectField
|
|
312
|
+
label="Dock position"
|
|
313
|
+
value={position}
|
|
314
|
+
onChange={setPosition}
|
|
315
|
+
options={[
|
|
316
|
+
{ value: 'bottom', label: 'Bottom' },
|
|
317
|
+
{ value: 'top', label: 'Top' },
|
|
318
|
+
{ value: 'left', label: 'Left' },
|
|
319
|
+
{ value: 'right', label: 'Right' }
|
|
320
|
+
]}
|
|
321
|
+
/>
|
|
322
|
+
|
|
323
|
+
<SelectField
|
|
324
|
+
label="Double-tap corner"
|
|
325
|
+
value={triggerCorner}
|
|
326
|
+
onChange={setTriggerCorner}
|
|
327
|
+
options={[
|
|
328
|
+
{ value: 'bottom-right', label: 'Bottom-right' },
|
|
329
|
+
{ value: 'bottom-left', label: 'Bottom-left' },
|
|
330
|
+
{ value: 'top-right', label: 'Top-right' },
|
|
331
|
+
{ value: 'top-left', label: 'Top-left' }
|
|
332
|
+
]}
|
|
333
|
+
/>
|
|
334
|
+
|
|
335
|
+
<SelectField
|
|
336
|
+
label="iFrame lifecycle"
|
|
337
|
+
value={iframeMode}
|
|
338
|
+
onChange={setIframeMode}
|
|
339
|
+
options={[
|
|
340
|
+
{ value: 'keep-alive', label: 'Keep-alive (faster switching, more RAM)' },
|
|
341
|
+
{ value: 'destroy', label: 'Destroy (reload each time, less RAM)' }
|
|
342
|
+
]}
|
|
343
|
+
/>
|
|
344
|
+
|
|
345
|
+
<NumberField label="Icon size" value={iconSize} onChange={setIconSize} hint="px" />
|
|
346
|
+
|
|
347
|
+
<CheckboxField label="Magnification effect" checked={magnification} onChange={setMagnification} />
|
|
348
|
+
|
|
349
|
+
{magnification && (
|
|
350
|
+
<NumberField
|
|
351
|
+
label="Magnification scale"
|
|
352
|
+
value={magnificationScale}
|
|
353
|
+
onChange={setMagnificationScale}
|
|
354
|
+
hint="1.0 - 2.5"
|
|
355
|
+
/>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
<div style={S.sectionTitle}>Webapps</div>
|
|
359
|
+
<button
|
|
360
|
+
style={{ ...S.btn, ...S.btnPrimary, ...(discovering ? { opacity: 0.6 } : {}) }}
|
|
361
|
+
onClick={discover}
|
|
362
|
+
disabled={discovering}
|
|
363
|
+
>
|
|
364
|
+
{discovering ? 'Discovering\u2026' : 'Discover Installed Webapps'}
|
|
365
|
+
</button>
|
|
366
|
+
{status && <div style={{ ...S.status, color: statusError ? '#ef4444' : '#10b981' }}>{status}</div>}
|
|
367
|
+
|
|
368
|
+
<div style={S.sectionTitle}>Dock Apps (drag to reorder)</div>
|
|
369
|
+
{apps.length === 0 ? (
|
|
370
|
+
<div style={S.empty}>No apps yet. Click Discover above.</div>
|
|
371
|
+
) : (
|
|
372
|
+
<div>
|
|
373
|
+
{apps.map((app, i) => (
|
|
374
|
+
<div
|
|
375
|
+
key={app.url + i}
|
|
376
|
+
draggable
|
|
377
|
+
onDragStart={() => onDragStart(i)}
|
|
378
|
+
onDragOver={(e) => onDragOver(e, i)}
|
|
379
|
+
onDragLeave={onDragLeave}
|
|
380
|
+
onDrop={(e) => onDrop(e, i)}
|
|
381
|
+
onDragEnd={onDragEnd}
|
|
382
|
+
style={{
|
|
383
|
+
...S.item,
|
|
384
|
+
...(app.enabled === false ? S.itemDisabled : {}),
|
|
385
|
+
...(overIdx === i ? S.dragOver : {}),
|
|
386
|
+
...(dragIdx === i ? { opacity: 0.4 } : {})
|
|
387
|
+
}}
|
|
388
|
+
>
|
|
389
|
+
<span style={S.handle}>{'\u2261'}</span>
|
|
390
|
+
<IconPreview icon={app.icon} label={app.label || app.url} color={app.color} />
|
|
391
|
+
<div style={S.info}>
|
|
392
|
+
<div style={S.name}>{app.label || app.url}</div>
|
|
393
|
+
<div style={S.url}>{app.url}</div>
|
|
394
|
+
</div>
|
|
395
|
+
<div style={S.toggle} title="Enabled">
|
|
396
|
+
<input
|
|
397
|
+
type="checkbox"
|
|
398
|
+
checked={app.enabled !== false}
|
|
399
|
+
onChange={() => toggleApp(i)}
|
|
400
|
+
style={S.checkbox}
|
|
401
|
+
/>
|
|
402
|
+
</div>
|
|
403
|
+
<div
|
|
404
|
+
style={{
|
|
405
|
+
...S.toggle,
|
|
406
|
+
cursor: 'pointer',
|
|
407
|
+
fontSize: 16,
|
|
408
|
+
opacity: app.autostart ? 1 : 0.25
|
|
409
|
+
}}
|
|
410
|
+
onClick={() => toggleAutostart(i)}
|
|
411
|
+
title={app.autostart ? 'Autostart: ON (click to disable)' : 'Click to set as autostart app'}
|
|
412
|
+
>
|
|
413
|
+
{'\u25b6'}
|
|
414
|
+
</div>
|
|
415
|
+
<button style={{ ...S.btn, ...S.btnDanger }} onClick={() => removeApp(i)}>
|
|
416
|
+
Remove
|
|
417
|
+
</button>
|
|
418
|
+
</div>
|
|
419
|
+
))}
|
|
420
|
+
</div>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{enabledApps.length > 0 && (
|
|
424
|
+
<>
|
|
425
|
+
<div style={S.sectionTitle}>Preview</div>
|
|
426
|
+
<div style={S.preview}>
|
|
427
|
+
{enabledApps.map((app, i) => (
|
|
428
|
+
<IconPreview key={i} icon={app.icon} label={app.label || app.url} color={app.color} size={40} />
|
|
429
|
+
))}
|
|
430
|
+
</div>
|
|
431
|
+
</>
|
|
432
|
+
)}
|
|
433
|
+
|
|
434
|
+
<div style={S.actions}>
|
|
435
|
+
<button style={{ ...S.btn, ...S.btnSave }} onClick={doSave}>
|
|
436
|
+
Save Configuration
|
|
437
|
+
</button>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
)
|
|
441
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// Vite entry point — the actual component is loaded via Module Federation
|