@n8n/frontend-module-sdk 0.9.3 → 0.10.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/.gitignore +24 -0
- package/README.md +40 -5
- package/package.json +7 -7
- package/src/defineFrontendModule.test.ts +126 -0
- package/src/defineFrontendModule.ts +29 -0
- package/src/index.ts +2 -0
- package/src/registries/componentRegistry.test.ts +73 -0
- package/src/registries/componentRegistry.ts +42 -0
- package/src/types/component.ts +22 -0
- package/src/types/index.ts +1 -0
package/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Logs
|
|
2
|
+
logs
|
|
3
|
+
*.log
|
|
4
|
+
npm-debug.log*
|
|
5
|
+
yarn-debug.log*
|
|
6
|
+
yarn-error.log*
|
|
7
|
+
pnpm-debug.log*
|
|
8
|
+
lerna-debug.log*
|
|
9
|
+
|
|
10
|
+
node_modules
|
|
11
|
+
dist
|
|
12
|
+
dist-ssr
|
|
13
|
+
*.local
|
|
14
|
+
|
|
15
|
+
# Editor directories and files
|
|
16
|
+
.vscode/*
|
|
17
|
+
!.vscode/extensions.json
|
|
18
|
+
.idea
|
|
19
|
+
.DS_Store
|
|
20
|
+
*.suo
|
|
21
|
+
*.ntvs*
|
|
22
|
+
*.njsproj
|
|
23
|
+
*.sln
|
|
24
|
+
*.sw?
|
package/README.md
CHANGED
|
@@ -2,19 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
The frontend module contract and registries for n8n editor modules.
|
|
4
4
|
|
|
5
|
-
This package owns the `FrontendModuleDescription` descriptor type
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
This package owns the `FrontendModuleDescription` descriptor type, the
|
|
6
|
+
`defineFrontendModule()` helper that declares one, plus the modal and resource
|
|
7
|
+
registries that features register against. The editor shell keeps the wiring
|
|
8
|
+
(`moduleInitializer`) that drives the two-phase lifecycle; this package only
|
|
9
|
+
defines the contract and the registry state.
|
|
9
10
|
|
|
10
11
|
It is a source-only package (no build step): consumers resolve it from `src`
|
|
11
12
|
via the editor-ui Vite alias and `tsconfig` paths, and `src/index.ts` is the
|
|
12
13
|
only public entry point.
|
|
13
14
|
|
|
14
15
|
```ts
|
|
15
|
-
import { modalRegistry, registerResource
|
|
16
|
+
import { defineFrontendModule, modalRegistry, registerResource } from '@n8n/frontend-module-sdk';
|
|
16
17
|
```
|
|
17
18
|
|
|
19
|
+
## Declaring a module
|
|
20
|
+
|
|
21
|
+
`defineFrontendModule()` is the canonical descriptor form. Use it for every
|
|
22
|
+
module, in the shell and in a module package alike.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
export const MyFeatureModule = defineFrontendModule({
|
|
26
|
+
// Must match the backend module id: both gate off `/rest/module-settings`.
|
|
27
|
+
id: 'my-feature',
|
|
28
|
+
name: 'My Feature',
|
|
29
|
+
description: 'What this module does',
|
|
30
|
+
icon: 'box',
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The helper is the identity function at runtime. It returns the object that it
|
|
35
|
+
gets. It does not wrap, clone, or freeze it, so a getter-backed field such as
|
|
36
|
+
`available` stays lazy.
|
|
37
|
+
|
|
38
|
+
Two reasons to prefer it over a `: FrontendModuleDescription` annotation:
|
|
39
|
+
|
|
40
|
+
- **One seam.** The SDK gets one function to attach descriptor validation or a
|
|
41
|
+
dev-mode check to, instead of declaration sites it cannot see.
|
|
42
|
+
- **Inference.** `const T` keeps the literal type of each field, so
|
|
43
|
+
`MyFeatureModule.id` is `'my-feature'` and not `string`.
|
|
44
|
+
|
|
45
|
+
Do not use both. An annotation on top of the helper widens the types again.
|
|
46
|
+
|
|
47
|
+
Keep the descriptor file import-light: types and the SDK at module scope only.
|
|
48
|
+
Load a view with `async () => await import('./views/X.vue')`, and read a store
|
|
49
|
+
inside a route guard, a push handler, or `setup`. The shell manifest imports
|
|
50
|
+
every descriptor eagerly, so a top-level store import pulls the module into the
|
|
51
|
+
boot chunk even when the module is off.
|
|
52
|
+
|
|
18
53
|
## Push handlers
|
|
19
54
|
|
|
20
55
|
A module owns a push message type when it declares it in `pushHandlers`. The
|
package/package.json
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@n8n/frontend-module-sdk",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.10.0",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"import": "src/index.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@n8n/api-types": "1.
|
|
12
|
-
"@n8n/design-system": "2.
|
|
13
|
-
"@n8n/utils": "1.
|
|
14
|
-
"n8n-workflow": "2.
|
|
11
|
+
"@n8n/api-types": "1.40.0",
|
|
12
|
+
"@n8n/design-system": "2.39.0",
|
|
13
|
+
"@n8n/utils": "1.48.0",
|
|
14
|
+
"n8n-workflow": "2.40.0",
|
|
15
15
|
"vue": "^3.5.13",
|
|
16
16
|
"vue-router": "^4.5.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@n8n/eslint-config": "0.0.1",
|
|
20
|
-
"@n8n/typescript-config": "1.
|
|
21
|
-
"@n8n/vitest-config": "1.
|
|
20
|
+
"@n8n/typescript-config": "1.12.0",
|
|
21
|
+
"@n8n/vitest-config": "1.22.0",
|
|
22
22
|
"typescript": "6.0.2",
|
|
23
23
|
"unplugin-icons": "^23.0.1",
|
|
24
24
|
"vite": "^8.0.2",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect, expectTypeOf } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { defineFrontendModule } from './defineFrontendModule';
|
|
4
|
+
import type { FrontendModuleDescription } from './types/descriptor';
|
|
5
|
+
|
|
6
|
+
describe('defineFrontendModule', () => {
|
|
7
|
+
it('should return the same object it was given', () => {
|
|
8
|
+
const descriptor = {
|
|
9
|
+
id: 'identity',
|
|
10
|
+
name: 'Identity',
|
|
11
|
+
description: 'A descriptor passed straight through',
|
|
12
|
+
icon: 'box',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
expect(defineFrontendModule(descriptor)).toBe(descriptor);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should not freeze, seal, or clone the descriptor', () => {
|
|
19
|
+
const module: FrontendModuleDescription = defineFrontendModule({
|
|
20
|
+
id: 'mutable',
|
|
21
|
+
name: 'Mutable',
|
|
22
|
+
description: 'A descriptor that stays writable',
|
|
23
|
+
icon: 'box',
|
|
24
|
+
resources: [{ key: 'mutable', displayName: 'Mutable' }],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(Object.isFrozen(module)).toBe(false);
|
|
28
|
+
expect(Object.isSealed(module)).toBe(false);
|
|
29
|
+
expect(Object.isExtensible(module)).toBe(true);
|
|
30
|
+
|
|
31
|
+
// The shell reads a descriptor through the contract type, and registration
|
|
32
|
+
// order lets a host append. Nothing the helper does may prevent that.
|
|
33
|
+
module.resources?.push({ key: 'added', displayName: 'Added' });
|
|
34
|
+
expect(module.resources).toHaveLength(2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should keep a getter lazy instead of reading it', () => {
|
|
38
|
+
let reads = 0;
|
|
39
|
+
const module = defineFrontendModule({
|
|
40
|
+
id: 'lazy',
|
|
41
|
+
name: 'Lazy',
|
|
42
|
+
description: 'A descriptor with a getter-backed field',
|
|
43
|
+
icon: 'box',
|
|
44
|
+
get settingsPages() {
|
|
45
|
+
reads += 1;
|
|
46
|
+
return [];
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(reads).toBe(0);
|
|
51
|
+
expect(module.settingsPages).toHaveLength(0);
|
|
52
|
+
expect(reads).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should keep the literal type of each field', () => {
|
|
56
|
+
const module = defineFrontendModule({
|
|
57
|
+
id: 'literal',
|
|
58
|
+
name: 'Literal',
|
|
59
|
+
description: 'A descriptor whose literal types survive',
|
|
60
|
+
icon: 'box',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expectTypeOf(module.id).toEqualTypeOf<'literal'>();
|
|
64
|
+
expectTypeOf(module.name).toEqualTypeOf<'Literal'>();
|
|
65
|
+
expectTypeOf(module.icon).toEqualTypeOf<'box'>();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should narrow optional fields a descriptor declares', () => {
|
|
69
|
+
const module = defineFrontendModule({
|
|
70
|
+
id: 'narrow',
|
|
71
|
+
name: 'Narrow',
|
|
72
|
+
description: 'A descriptor declaring one optional surface',
|
|
73
|
+
icon: 'box',
|
|
74
|
+
commands: [{ id: 'narrow.open', title: 'Open Narrow' }],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// An annotated descriptor widens this to `CommandBarEntry[] | undefined`.
|
|
78
|
+
expectTypeOf(module.commands).not.toBeUndefined();
|
|
79
|
+
expectTypeOf(module).toExtend<FrontendModuleDescription>();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should type-check a descriptor against the contract', () => {
|
|
83
|
+
// @ts-expect-error `id` is required.
|
|
84
|
+
defineFrontendModule({ name: 'No id', description: '', icon: 'box' });
|
|
85
|
+
|
|
86
|
+
// @ts-expect-error `icon` must be a string.
|
|
87
|
+
defineFrontendModule({ id: 'typed', name: 'Typed', description: '', icon: 42 });
|
|
88
|
+
|
|
89
|
+
// @ts-expect-error `routes` must hold route records.
|
|
90
|
+
defineFrontendModule({ id: 'typed', name: 'Typed', description: '', icon: 'box', routes: [1] });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should keep array element types narrow under `const` inference', () => {
|
|
94
|
+
const module = defineFrontendModule({
|
|
95
|
+
id: 'narrow-elements',
|
|
96
|
+
name: 'Narrow elements',
|
|
97
|
+
description: 'A descriptor whose array elements keep their literal types',
|
|
98
|
+
icon: 'box',
|
|
99
|
+
resources: [{ key: 'thing', displayName: 'Thing' }],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expectTypeOf(module.resources[0].key).toEqualTypeOf<'thing'>();
|
|
103
|
+
expect(module.resources[0].key).toBe('thing');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should type a route guard from the contract', () => {
|
|
107
|
+
const module = defineFrontendModule({
|
|
108
|
+
id: 'guarded',
|
|
109
|
+
name: 'Guarded',
|
|
110
|
+
description: 'A descriptor whose guard parameter is typed by the contract',
|
|
111
|
+
icon: 'box',
|
|
112
|
+
routes: [
|
|
113
|
+
{
|
|
114
|
+
path: '/guarded',
|
|
115
|
+
component: async () => await Promise.resolve({}),
|
|
116
|
+
beforeEnter: (to) => {
|
|
117
|
+
expectTypeOf(to.params).not.toBeAny();
|
|
118
|
+
return true;
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(module.routes).toHaveLength(1);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FrontendModuleDescription } from './types/descriptor';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Declares a frontend module descriptor.
|
|
5
|
+
*
|
|
6
|
+
* Identity at runtime: it returns the object it was given. It does not wrap,
|
|
7
|
+
* clone, or freeze it, so a descriptor declared through it behaves exactly as a
|
|
8
|
+
* descriptor declared with a type annotation.
|
|
9
|
+
*
|
|
10
|
+
* What it adds is a seam and inference. The seam gives the SDK one place to
|
|
11
|
+
* attach validation or a dev-mode check later, instead of ten declaration sites
|
|
12
|
+
* it cannot see. The inference keeps each field's literal type — `MyModule.id`
|
|
13
|
+
* reads as `'my-feature'`, not `string` — which an annotation widens away.
|
|
14
|
+
*
|
|
15
|
+
* Use it for every descriptor. An annotated descriptor still satisfies the type,
|
|
16
|
+
* but it is not the canonical form.
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* export const MyFeatureModule = defineFrontendModule({
|
|
20
|
+
* id: 'my-feature',
|
|
21
|
+
* name: 'My Feature',
|
|
22
|
+
* description: 'What this module does',
|
|
23
|
+
* icon: 'box',
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function defineFrontendModule<const T extends FrontendModuleDescription>(module: T): T {
|
|
28
|
+
return module;
|
|
29
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export type * from './types';
|
|
2
2
|
|
|
3
|
+
export { defineFrontendModule } from './defineFrontendModule';
|
|
3
4
|
export { assertUniqueRouteNames } from './routeNames';
|
|
4
5
|
|
|
5
6
|
export * as modalRegistry from './registries/modalRegistry';
|
|
6
7
|
export * from './registries/resourceRegistry';
|
|
7
8
|
export * as pushHandlerRegistry from './registries/pushHandlerRegistry';
|
|
8
9
|
export * as commandRegistry from './registries/commandRegistry';
|
|
10
|
+
export * as componentRegistry from './registries/componentRegistry';
|
|
9
11
|
export * as parameterInputRegistry from './registries/parameterInputRegistry';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { computed, defineComponent } from 'vue';
|
|
2
|
+
|
|
3
|
+
import * as componentRegistry from './componentRegistry';
|
|
4
|
+
|
|
5
|
+
const First = defineComponent({ template: '<div>first</div>' });
|
|
6
|
+
const Second = defineComponent({ template: '<div>second</div>' });
|
|
7
|
+
|
|
8
|
+
describe('componentRegistry', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
componentRegistry.clear();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('resolves a registered slot', () => {
|
|
14
|
+
componentRegistry.register('project-filter', First);
|
|
15
|
+
|
|
16
|
+
expect(componentRegistry.get('project-filter')).toBe(First);
|
|
17
|
+
expect(componentRegistry.has('project-filter')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('resolves an unregistered slot to undefined', () => {
|
|
21
|
+
expect(componentRegistry.get('project-filter')).toBeUndefined();
|
|
22
|
+
expect(componentRegistry.has('project-filter')).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('treats re-registering the same component as a no-op', () => {
|
|
26
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
27
|
+
componentRegistry.register('project-filter', First);
|
|
28
|
+
componentRegistry.register('project-filter', First);
|
|
29
|
+
|
|
30
|
+
expect(componentRegistry.get('project-filter')).toBe(First);
|
|
31
|
+
expect(warn).not.toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('keeps the first component and warns when a second claims the slot', () => {
|
|
35
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
36
|
+
componentRegistry.register('project-filter', First);
|
|
37
|
+
componentRegistry.register('project-filter', Second);
|
|
38
|
+
|
|
39
|
+
expect(componentRegistry.get('project-filter')).toBe(First);
|
|
40
|
+
expect(warn).toHaveBeenCalledWith(
|
|
41
|
+
'Component slot "project-filter" is already registered. Skipping.',
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// The registry is `shallowReactive` so a module can resolve a slot with a plain
|
|
46
|
+
// `computed`. The shell registers slots lazily, after a module may already have
|
|
47
|
+
// read an empty slot — swapping in a plain `Map` would break that silently.
|
|
48
|
+
it('makes a computed reader see a later registration', () => {
|
|
49
|
+
const slot = computed(() => componentRegistry.get('project-filter'));
|
|
50
|
+
expect(slot.value).toBeUndefined();
|
|
51
|
+
|
|
52
|
+
componentRegistry.register('project-filter', First);
|
|
53
|
+
|
|
54
|
+
expect(slot.value).toBe(First);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('makes a computed reader see an unregister', () => {
|
|
58
|
+
componentRegistry.register('project-filter', First);
|
|
59
|
+
const slot = computed(() => componentRegistry.get('project-filter'));
|
|
60
|
+
expect(slot.value).toBe(First);
|
|
61
|
+
|
|
62
|
+
componentRegistry.unregister('project-filter');
|
|
63
|
+
|
|
64
|
+
expect(slot.value).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('frees the slot on unregister', () => {
|
|
68
|
+
componentRegistry.register('project-filter', First);
|
|
69
|
+
componentRegistry.unregister('project-filter');
|
|
70
|
+
|
|
71
|
+
expect(componentRegistry.get('project-filter')).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { shallowReactive, type Component } from 'vue';
|
|
2
|
+
|
|
3
|
+
import type { ModuleComponentSlot } from '../types/component';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shell-hosted components, keyed by slot. The shell registers; modules read.
|
|
7
|
+
*
|
|
8
|
+
* Shallow-reactive so a module can resolve a slot with a plain `computed`, and
|
|
9
|
+
* shallow specifically so a component is never turned into a reactive object.
|
|
10
|
+
*
|
|
11
|
+
* A slot with nothing registered resolves to `undefined`, and the module renders
|
|
12
|
+
* nothing for it. That is the correct behavior in a module's own test run, where
|
|
13
|
+
* no shell exists — a test that needs the slot registers a stub.
|
|
14
|
+
*/
|
|
15
|
+
const components = shallowReactive(new Map<ModuleComponentSlot, Component>());
|
|
16
|
+
|
|
17
|
+
export function register(slot: ModuleComponentSlot, component: Component): void {
|
|
18
|
+
const existing = components.get(slot);
|
|
19
|
+
if (existing && existing !== component) {
|
|
20
|
+
console.warn(`Component slot "${slot}" is already registered. Skipping.`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
components.set(slot, component);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function get(slot: ModuleComponentSlot): Component | undefined {
|
|
28
|
+
return components.get(slot);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function has(slot: ModuleComponentSlot): boolean {
|
|
32
|
+
return components.has(slot);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function unregister(slot: ModuleComponentSlot): void {
|
|
36
|
+
components.delete(slot);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Test isolation only. */
|
|
40
|
+
export function clear(): void {
|
|
41
|
+
components.clear();
|
|
42
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI a module renders but does not own.
|
|
3
|
+
*
|
|
4
|
+
* Each id names one shell-hosted component with a fixed contract. The union is
|
|
5
|
+
* closed on purpose: this is not a generic slot system, and adding an id is a
|
|
6
|
+
* reviewed change to the module contract, not a local decision.
|
|
7
|
+
*
|
|
8
|
+
* `project-filter` — a project picker. The project list, the permissions that
|
|
9
|
+
* decide between local and remote search, and the search itself all live in
|
|
10
|
+
* `features/collaboration/projects`, which sits above the module layer. A module
|
|
11
|
+
* that needs to filter by project renders this instead of importing any of it.
|
|
12
|
+
*/
|
|
13
|
+
export type ModuleComponentSlot = 'project-filter';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `v-model` value of the `project-filter` slot. `null` means "all projects".
|
|
17
|
+
*
|
|
18
|
+
* Deliberately narrow: `id` is all a consumer needs, and the host keeps the full
|
|
19
|
+
* project object. A consumer writes only `null` (to clear); every non-null value
|
|
20
|
+
* originates in the host.
|
|
21
|
+
*/
|
|
22
|
+
export type SlotProjectSelection = { id: string } | null;
|
package/src/types/index.ts
CHANGED