@n8n/frontend-module-sdk 0.10.1 → 0.11.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 +3 -2
- package/package.json +4 -4
- package/src/capabilities/index.ts +2 -0
- package/src/capabilities/modalOpeners.ts +8 -0
- package/src/capabilities/workflowMcpAccessSync.ts +12 -0
- package/src/declareCapability.test.ts +17 -0
- package/src/declareCapability.ts +26 -0
- package/src/index.ts +5 -0
- package/src/registries/capabilityRegistry.test.ts +102 -0
- package/src/registries/capabilityRegistry.ts +66 -0
- package/src/types/capability.ts +22 -0
- package/src/types/index.ts +1 -0
package/README.md
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
The frontend module contract and registries for n8n editor modules.
|
|
4
4
|
|
|
5
5
|
This package owns the `FrontendModuleDescription` descriptor type, the
|
|
6
|
-
`defineFrontendModule()` helper that declares one, plus the modal and
|
|
7
|
-
registries that features register against. The
|
|
6
|
+
`defineFrontendModule()` helper that declares one, plus the modal, resource and
|
|
7
|
+
capability registries that features register against. The "Capabilities" section
|
|
8
|
+
of the frontend module guide covers when to declare a capability. The editor shell keeps the wiring
|
|
8
9
|
(`moduleInitializer`) that drives the two-phase lifecycle; this package only
|
|
9
10
|
defines the contract and the registry state.
|
|
10
11
|
|
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@n8n/frontend-module-sdk",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.11.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.
|
|
11
|
+
"@n8n/api-types": "1.41.0",
|
|
12
|
+
"@n8n/design-system": "2.40.0",
|
|
13
13
|
"@n8n/utils": "1.48.0",
|
|
14
|
-
"n8n-workflow": "2.
|
|
14
|
+
"n8n-workflow": "2.41.0",
|
|
15
15
|
"vue": "^3.5.13",
|
|
16
16
|
"vue-router": "^4.5.0"
|
|
17
17
|
},
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { declareCapability } from '../declareCapability';
|
|
2
|
+
import type { ModalOpeners } from '../types/capability';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Opens a shell-owned modal. Modals live in the shell's `ui.store`, which a
|
|
6
|
+
* module cannot reach.
|
|
7
|
+
*/
|
|
8
|
+
export const modalOpeners = declareCapability<ModalOpeners>('modal-openers');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { declareCapability } from '../declareCapability';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Writes an MCP access toggle back into the shell's workflow stores, so the list
|
|
5
|
+
* and the open document agree with the server without a refetch.
|
|
6
|
+
*
|
|
7
|
+
* Declared ahead of its provider: the shell provides it when the MCP feature
|
|
8
|
+
* moves into its own module package.
|
|
9
|
+
*/
|
|
10
|
+
export const workflowMcpAccessSync = declareCapability<(ids: string[], enabled: boolean) => void>(
|
|
11
|
+
'workflow-mcp-access-sync',
|
|
12
|
+
);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { declareCapability } from './declareCapability';
|
|
2
|
+
|
|
3
|
+
describe('declareCapability', () => {
|
|
4
|
+
it('returns a token carrying the key and the fallback', () => {
|
|
5
|
+
const noop = () => {};
|
|
6
|
+
const token = declareCapability<() => void>('test-key', { fallback: noop });
|
|
7
|
+
|
|
8
|
+
expect(token.key).toBe('test-key');
|
|
9
|
+
expect(token.fallback).toBe(noop);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('leaves the fallback undefined when it is omitted', () => {
|
|
13
|
+
const token = declareCapability<() => void>('test-key');
|
|
14
|
+
|
|
15
|
+
expect(token.fallback).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CapabilityToken } from './types/capability';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Declares a capability: an action the shell owns and a module calls, but cannot
|
|
5
|
+
* import.
|
|
6
|
+
*
|
|
7
|
+
* Reach for one only when all three hold:
|
|
8
|
+
*
|
|
9
|
+
* - the module needs a runtime action or a reactive read, not a component;
|
|
10
|
+
* - the target is shell-core state with no path down to an L2 package;
|
|
11
|
+
* - no existing contribution surface fits (modals, components, commands,
|
|
12
|
+
* resources, push handlers, parameter inputs).
|
|
13
|
+
*
|
|
14
|
+
* If any of the three fails, use the surface that fits instead.
|
|
15
|
+
*
|
|
16
|
+
* Give `T` explicitly. It is the contract both sides are checked against, and it
|
|
17
|
+
* cannot be inferred from the key.
|
|
18
|
+
*/
|
|
19
|
+
export function declareCapability<T>(
|
|
20
|
+
key: string,
|
|
21
|
+
options: { fallback?: T } = {},
|
|
22
|
+
): CapabilityToken<T> {
|
|
23
|
+
// Only carry the property when the caller declared one: `use()` reads presence,
|
|
24
|
+
// so an always-present `fallback: undefined` would suppress its throw.
|
|
25
|
+
return 'fallback' in options ? { key, fallback: options.fallback } : { key };
|
|
26
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export type * from './types';
|
|
|
2
2
|
|
|
3
3
|
export { defineFrontendModule } from './defineFrontendModule';
|
|
4
4
|
export { assertUniqueRouteNames } from './routeNames';
|
|
5
|
+
export { declareCapability } from './declareCapability';
|
|
5
6
|
|
|
6
7
|
export * as modalRegistry from './registries/modalRegistry';
|
|
7
8
|
export * from './registries/resourceRegistry';
|
|
@@ -9,3 +10,7 @@ export * as pushHandlerRegistry from './registries/pushHandlerRegistry';
|
|
|
9
10
|
export * as commandRegistry from './registries/commandRegistry';
|
|
10
11
|
export * as componentRegistry from './registries/componentRegistry';
|
|
11
12
|
export * as parameterInputRegistry from './registries/parameterInputRegistry';
|
|
13
|
+
export * as capabilityRegistry from './registries/capabilityRegistry';
|
|
14
|
+
|
|
15
|
+
// Namespaced so a token does not clash with a same-named local const in the shell.
|
|
16
|
+
export * as capabilities from './capabilities';
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as capabilityRegistry from './capabilityRegistry';
|
|
2
|
+
import { declareCapability } from '../declareCapability';
|
|
3
|
+
|
|
4
|
+
type Greeter = (name: string) => string;
|
|
5
|
+
|
|
6
|
+
// Local tokens on purpose: the registry contract must not depend on the tokens
|
|
7
|
+
// the shell happens to ship in `../capabilities`.
|
|
8
|
+
const greeter = declareCapability<Greeter>('test-greeter');
|
|
9
|
+
const greeterWithFallback = declareCapability<Greeter>('test-greeter-with-fallback', {
|
|
10
|
+
fallback: () => 'fallback',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const hello: Greeter = (name) => `hello ${name}`;
|
|
14
|
+
const goodbye: Greeter = (name) => `goodbye ${name}`;
|
|
15
|
+
|
|
16
|
+
describe('capabilityRegistry', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
capabilityRegistry.clear();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns the provided implementation from use', () => {
|
|
22
|
+
capabilityRegistry.provide(greeter, hello);
|
|
23
|
+
|
|
24
|
+
expect(capabilityRegistry.use(greeter)).toBe(hello);
|
|
25
|
+
expect(capabilityRegistry.has(greeter)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('throws from use when nothing is provided and no fallback was declared', () => {
|
|
29
|
+
expect(() => capabilityRegistry.use(greeter)).toThrowError(
|
|
30
|
+
'Capability "test-greeter" has no provider. Call capabilityRegistry.provide() at app bootstrap.',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns the fallback from use when nothing is provided', () => {
|
|
35
|
+
expect(capabilityRegistry.use(greeterWithFallback)('ada')).toBe('fallback');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('prefers a provided nullish implementation over the fallback', () => {
|
|
39
|
+
const nullableGreeter = declareCapability<Greeter | null>('test-nullable-greeter', {
|
|
40
|
+
fallback: goodbye,
|
|
41
|
+
});
|
|
42
|
+
capabilityRegistry.provide(nullableGreeter, null);
|
|
43
|
+
|
|
44
|
+
expect(capabilityRegistry.use(nullableGreeter)).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('prefers a provided implementation over the fallback', () => {
|
|
48
|
+
capabilityRegistry.provide(greeterWithFallback, hello);
|
|
49
|
+
|
|
50
|
+
expect(capabilityRegistry.use(greeterWithFallback)).toBe(hello);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns undefined from tryUse when nothing is provided, even with a fallback', () => {
|
|
54
|
+
expect(capabilityRegistry.tryUse(greeter)).toBeUndefined();
|
|
55
|
+
expect(capabilityRegistry.tryUse(greeterWithFallback)).toBeUndefined();
|
|
56
|
+
expect(capabilityRegistry.has(greeterWithFallback)).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('treats re-providing the same implementation as a no-op', () => {
|
|
60
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
61
|
+
capabilityRegistry.provide(greeter, hello);
|
|
62
|
+
capabilityRegistry.provide(greeter, hello);
|
|
63
|
+
|
|
64
|
+
expect(capabilityRegistry.use(greeter)).toBe(hello);
|
|
65
|
+
expect(warn).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps the first implementation and warns when a different one is provided', () => {
|
|
69
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
70
|
+
capabilityRegistry.provide(greeter, hello);
|
|
71
|
+
capabilityRegistry.provide(greeter, goodbye);
|
|
72
|
+
|
|
73
|
+
expect(capabilityRegistry.use(greeter)).toBe(hello);
|
|
74
|
+
expect(warn).toHaveBeenCalledWith('Capability "test-greeter" is already provided. Skipping.');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('gives two tokens that share a key string their own slot', () => {
|
|
78
|
+
const sameKey = declareCapability<() => number>('test-greeter');
|
|
79
|
+
capabilityRegistry.provide(greeter, hello);
|
|
80
|
+
|
|
81
|
+
expect(capabilityRegistry.tryUse(sameKey)).toBeUndefined();
|
|
82
|
+
expect(capabilityRegistry.use(greeter)).toBe(hello);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('frees the slot on unprovide', () => {
|
|
86
|
+
capabilityRegistry.provide(greeter, hello);
|
|
87
|
+
capabilityRegistry.unprovide(greeter);
|
|
88
|
+
|
|
89
|
+
expect(capabilityRegistry.tryUse(greeter)).toBeUndefined();
|
|
90
|
+
expect(capabilityRegistry.has(greeter)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('removes every provider on clear', () => {
|
|
94
|
+
capabilityRegistry.provide(greeter, hello);
|
|
95
|
+
capabilityRegistry.provide(greeterWithFallback, goodbye);
|
|
96
|
+
|
|
97
|
+
capabilityRegistry.clear();
|
|
98
|
+
|
|
99
|
+
expect(capabilityRegistry.has(greeter)).toBe(false);
|
|
100
|
+
expect(capabilityRegistry.has(greeterWithFallback)).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { CapabilityToken } from '../types/capability';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shell-provided actions, keyed by capability token. The shell provides;
|
|
5
|
+
* modules read.
|
|
6
|
+
*
|
|
7
|
+
* Not reactive, unlike `componentRegistry`: a capability is an action a module
|
|
8
|
+
* calls from a handler or a guard, so nothing renders off it. A capability whose
|
|
9
|
+
* value changes should carry a `Ref` and stay one provider.
|
|
10
|
+
*
|
|
11
|
+
* Keyed by the token itself, not by `token.key`: two tokens that pick the same
|
|
12
|
+
* string then get their own slot instead of one reading the other's value.
|
|
13
|
+
*/
|
|
14
|
+
const providers = new Map<CapabilityToken<unknown>, unknown>();
|
|
15
|
+
|
|
16
|
+
export function provide<T>(token: CapabilityToken<T>, implementation: T): void {
|
|
17
|
+
if (providers.has(token) && providers.get(token) !== implementation) {
|
|
18
|
+
console.warn(`Capability "${token.key}" is already provided. Skipping.`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
providers.set(token, implementation);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The provided implementation, or `undefined` when there is none. Ignores the
|
|
27
|
+
* token's `fallback` on purpose: this is the presence check.
|
|
28
|
+
*/
|
|
29
|
+
export function tryUse<T>(token: CapabilityToken<T>): T | undefined {
|
|
30
|
+
// The one cast here. The token is the slot and `provide()` is the only writer,
|
|
31
|
+
// so whatever is stored under it came from a `provide()` call with the same `T`.
|
|
32
|
+
return providers.get(token) as T | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The provided implementation, or the token's fallback. Throws when there is
|
|
37
|
+
* neither — call it from a handler or a guard, never at module scope.
|
|
38
|
+
*/
|
|
39
|
+
export function use<T>(token: CapabilityToken<T>): T {
|
|
40
|
+
// Presence decides, not the value: a provider is free to supply `null` or
|
|
41
|
+
// `undefined` when `T` allows it, and must still win over the fallback.
|
|
42
|
+
if (has(token)) {
|
|
43
|
+
return providers.get(token) as T;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if ('fallback' in token) {
|
|
47
|
+
return token.fallback as T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Capability "${token.key}" has no provider. Call capabilityRegistry.provide() at app bootstrap.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function has(token: CapabilityToken<unknown>): boolean {
|
|
56
|
+
return providers.has(token);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function unprovide(token: CapabilityToken<unknown>): void {
|
|
60
|
+
providers.delete(token);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Test isolation only. */
|
|
64
|
+
export function clear(): void {
|
|
65
|
+
providers.clear();
|
|
66
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A typed key for a capability. `declareCapability()` is the only way to make
|
|
3
|
+
* one, and the token is the only way to read or write its slot in the registry.
|
|
4
|
+
*/
|
|
5
|
+
export interface CapabilityToken<T> {
|
|
6
|
+
/** Labels the capability in registry warnings and errors. */
|
|
7
|
+
readonly key: string;
|
|
8
|
+
/** Returned by `use()` when nothing is provided. Absent means `use()` throws. */
|
|
9
|
+
readonly fallback?: T;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Structurally identical to `ModalOpeners` in `@n8n/stores`; declared here so a
|
|
14
|
+
* module needs no `@n8n/stores` import.
|
|
15
|
+
*
|
|
16
|
+
* Method syntax keeps the parameters bivariant, so the shell can provide openers
|
|
17
|
+
* typed with the narrower `ModalKey` union without a cast.
|
|
18
|
+
*/
|
|
19
|
+
export interface ModalOpeners {
|
|
20
|
+
openModal(name: string): void;
|
|
21
|
+
openModalWithData(payload: { name: string; data: Record<string, unknown> }): void;
|
|
22
|
+
}
|
package/src/types/index.ts
CHANGED