@n8n/frontend-module-sdk 0.6.2 → 0.7.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@n8n/frontend-module-sdk",
4
- "version": "0.6.2",
4
+ "version": "0.7.0",
5
5
  "main": "src/index.ts",
6
6
  "import": "src/index.ts",
7
7
  "exports": {
@@ -10,8 +10,8 @@
10
10
  "dependencies": {
11
11
  "vue": "^3.5.13",
12
12
  "vue-router": "^4.5.0",
13
- "@n8n/design-system": "2.35.2",
14
- "@n8n/api-types": "1.36.2"
13
+ "@n8n/api-types": "1.37.0",
14
+ "@n8n/design-system": "2.36.0"
15
15
  },
16
16
  "devDependencies": {
17
17
  "typescript": "6.0.2",
@@ -20,8 +20,8 @@
20
20
  "vitest": "^4.1.9",
21
21
  "vue-tsc": "^2.2.8",
22
22
  "@n8n/eslint-config": "0.0.1",
23
- "@n8n/typescript-config": "1.10.0",
24
- "@n8n/vitest-config": "1.21.0"
23
+ "@n8n/vitest-config": "1.21.0",
24
+ "@n8n/typescript-config": "1.10.0"
25
25
  },
26
26
  "license": "SEE LICENSE IN LICENSE.md",
27
27
  "homepage": "https://n8n.io",
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export type * from './types';
2
2
 
3
+ export { assertUniqueRouteNames } from './routeNames';
4
+
3
5
  export * as modalRegistry from './registries/modalRegistry';
4
6
  export * from './registries/resourceRegistry';
5
7
  export * as pushHandlerRegistry from './registries/pushHandlerRegistry';
@@ -30,16 +30,29 @@ describe('commandRegistry', () => {
30
30
  expect(commandRegistry.getAll()).toEqual([commandA, commandB]);
31
31
  });
32
32
 
33
- it('should warn and skip when an id is registered twice', () => {
33
+ it('should warn and skip when a different command claims the id', () => {
34
34
  const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
35
35
 
36
36
  commandRegistry.register(commandA);
37
- commandRegistry.register(commandA);
37
+ commandRegistry.register({ ...commandA, title: 'Impostor' });
38
38
 
39
39
  expect(consoleSpy).toHaveBeenCalledWith(
40
40
  'Command with id "cmd-a" is already registered. Skipping.',
41
41
  );
42
- expect(commandRegistry.getAll()).toHaveLength(1);
42
+ expect(commandRegistry.getAll()).toEqual([commandA]);
43
+
44
+ consoleSpy.mockRestore();
45
+ });
46
+
47
+ // A re-login replays the manifest, so the same definitions arrive twice.
48
+ it('should re-register the same command silently', () => {
49
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
50
+
51
+ commandRegistry.register(commandA);
52
+ commandRegistry.register(commandA);
53
+
54
+ expect(consoleSpy).not.toHaveBeenCalled();
55
+ expect(commandRegistry.getAll()).toEqual([commandA]);
43
56
 
44
57
  consoleSpy.mockRestore();
45
58
  });
@@ -13,8 +13,13 @@ function notifyListeners(): void {
13
13
  }
14
14
 
15
15
  export function register(command: CommandBarEntry): void {
16
- if (commands.has(command.id)) {
17
- console.warn(`Command with id "${command.id}" is already registered. Skipping.`);
16
+ const existing = commands.get(command.id);
17
+ if (existing) {
18
+ // Same definition replayed by a re-login is a no-op; a different one
19
+ // claiming a taken id is the real collision.
20
+ if (existing !== command) {
21
+ console.warn(`Command with id "${command.id}" is already registered. Skipping.`);
22
+ }
18
23
  return;
19
24
  }
20
25
  commands.set(command.id, command);
@@ -49,20 +49,45 @@ describe('modalRegistry', () => {
49
49
  expect(modalRegistry.getKeys()).toHaveLength(2);
50
50
  });
51
51
 
52
- it('should warn and skip registration if modal key already exists', () => {
52
+ it('should warn and skip registration if a different modal claims the key', () => {
53
53
  const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
54
54
 
55
55
  modalRegistry.register(mockModal1);
56
- modalRegistry.register(mockModal1);
56
+ modalRegistry.register({ ...mockModal1, component: mockComponent2 });
57
57
 
58
58
  expect(consoleSpy).toHaveBeenCalledWith(
59
59
  'Modal with key "test-modal-1" is already registered. Skipping.',
60
60
  );
61
61
  expect(modalRegistry.getKeys()).toHaveLength(1);
62
+ expect(modalRegistry.get('test-modal-1')?.component).toBe(mockComponent1);
62
63
 
63
64
  consoleSpy.mockRestore();
64
65
  });
65
66
 
67
+ // A re-login replays the manifest, so the same definitions arrive twice.
68
+ it('should re-register the same definition silently', () => {
69
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
70
+
71
+ modalRegistry.register(mockModal1);
72
+ modalRegistry.register(mockModal1);
73
+
74
+ expect(consoleSpy).not.toHaveBeenCalled();
75
+ expect(modalRegistry.get('test-modal-1')).toBe(mockModal1);
76
+ expect(modalRegistry.getKeys()).toHaveLength(1);
77
+
78
+ consoleSpy.mockRestore();
79
+ });
80
+
81
+ it('should not notify listeners when the same definition is re-registered', () => {
82
+ modalRegistry.register(mockModal1);
83
+ const listener = vi.fn();
84
+ modalRegistry.subscribe(listener);
85
+
86
+ modalRegistry.register(mockModal1);
87
+
88
+ expect(listener).not.toHaveBeenCalled();
89
+ });
90
+
66
91
  it('should notify listeners when a modal is registered', () => {
67
92
  const listener = vi.fn();
68
93
  modalRegistry.subscribe(listener);
@@ -22,8 +22,14 @@ function notifyListeners(): void {
22
22
  }
23
23
 
24
24
  export function register(modal: ModalDefinition): void {
25
- if (modals.has(modal.key)) {
26
- console.warn(`Modal with key "${modal.key}" is already registered. Skipping.`);
25
+ const existing = modals.get(modal.key);
26
+ if (existing) {
27
+ // Replaying the same definition is how a re-login re-runs registration —
28
+ // a no-op, not a collision. Only a different definition claiming a taken
29
+ // key is worth warning about.
30
+ if (existing !== modal) {
31
+ console.warn(`Modal with key "${modal.key}" is already registered. Skipping.`);
32
+ }
27
33
  return;
28
34
  }
29
35
 
@@ -50,6 +50,21 @@ describe('pushHandlerRegistry', () => {
50
50
  consoleSpy.mockRestore();
51
51
  });
52
52
 
53
+ // A re-login replays the manifest, so the same handlers arrive twice.
54
+ it('should re-register the same handler silently', () => {
55
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
56
+ const handler = vi.fn();
57
+ const handlers: ModulePushHandlers = { workflowActivated: handler };
58
+
59
+ pushHandlerRegistry.registerAll(handlers);
60
+ pushHandlerRegistry.registerAll(handlers);
61
+
62
+ expect(consoleSpy).not.toHaveBeenCalled();
63
+ expect(pushHandlerRegistry.get('workflowActivated')).toBe(handler);
64
+
65
+ consoleSpy.mockRestore();
66
+ });
67
+
53
68
  it('should register every entry in a module pushHandlers map', () => {
54
69
  const activated = vi.fn();
55
70
  const deactivated = vi.fn();
@@ -7,8 +7,13 @@ import type { ModulePushHandler, ModulePushHandlers } from '../types/push';
7
7
  const handlers = new Map<PushType, ModulePushHandler>();
8
8
 
9
9
  export function register(type: PushType, handler: ModulePushHandler): void {
10
- if (handlers.has(type)) {
11
- console.warn(`Push handler for type "${type}" is already registered. Skipping.`);
10
+ const existing = handlers.get(type);
11
+ if (existing) {
12
+ // Same handler replayed by a re-login is a no-op; a different one
13
+ // claiming a taken type is the real collision.
14
+ if (existing !== handler) {
15
+ console.warn(`Push handler for type "${type}" is already registered. Skipping.`);
16
+ }
12
17
  return;
13
18
  }
14
19
  handlers.set(type, handler);
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router';
3
+
4
+ import { assertUniqueRouteNames } from './routeNames';
5
+ import type { FrontendModuleDescription } from './types/descriptor';
6
+
7
+ const component = async () => await Promise.resolve({});
8
+
9
+ const moduleWith = (id: string, routes: RouteRecordRaw[]): FrontendModuleDescription => ({
10
+ id,
11
+ name: id,
12
+ description: '',
13
+ icon: 'box',
14
+ routes,
15
+ });
16
+
17
+ const route = (name: string, children?: RouteRecordRaw[]): RouteRecordRaw =>
18
+ ({ path: `/${name.toLowerCase()}`, name, component, children }) as RouteRecordRaw;
19
+
20
+ /** A router holding the given routes, standing in for the shell's own. */
21
+ const shellRouter = (routes: RouteRecordRaw[] = []) =>
22
+ createRouter({ history: createMemoryHistory(), routes });
23
+
24
+ describe('assertUniqueRouteNames', () => {
25
+ it('should accept modules with distinct route names', () => {
26
+ expect(() =>
27
+ assertUniqueRouteNames(
28
+ [
29
+ moduleWith('otel', [route('SettingsOpenTelemetryView')]),
30
+ moduleWith('mcp', [route('McpSettings')]),
31
+ ],
32
+ shellRouter(),
33
+ ),
34
+ ).not.toThrow();
35
+ });
36
+
37
+ it('should accept modules that declare no routes', () => {
38
+ expect(() =>
39
+ assertUniqueRouteNames([{ id: 'a', name: 'a', description: '', icon: 'box' }], shellRouter()),
40
+ ).not.toThrow();
41
+ });
42
+
43
+ it('should throw and name both modules when two claim the same route name', () => {
44
+ expect(() =>
45
+ assertUniqueRouteNames(
46
+ [moduleWith('otel', [route('Shared')]), moduleWith('insights', [route('Shared')])],
47
+ shellRouter(),
48
+ ),
49
+ ).toThrow(
50
+ 'Duplicate route name "Shared" declared by module "insights" — already taken by module "otel".',
51
+ );
52
+ });
53
+
54
+ it('should throw when one module declares the same route name twice', () => {
55
+ expect(() =>
56
+ assertUniqueRouteNames([moduleWith('otel', [route('Twice'), route('Twice')])], shellRouter()),
57
+ ).toThrow(
58
+ 'Duplicate route name "Twice" declared by module "otel" — already taken by module "otel".',
59
+ );
60
+ });
61
+
62
+ it('should detect a collision between a nested child route and a top-level route', () => {
63
+ expect(() =>
64
+ assertUniqueRouteNames(
65
+ [
66
+ moduleWith('chat', [route('ChatView', [route('Nested')])]),
67
+ moduleWith('agents', [route('Nested')]),
68
+ ],
69
+ shellRouter(),
70
+ ),
71
+ ).toThrow(
72
+ 'Duplicate route name "Nested" declared by module "agents" — already taken by module "chat".',
73
+ );
74
+ });
75
+
76
+ it('should ignore unnamed routes, which vue-router matches by path only', () => {
77
+ const unnamed = { path: '/a', component } as RouteRecordRaw;
78
+
79
+ expect(() =>
80
+ assertUniqueRouteNames(
81
+ [moduleWith('a', [unnamed]), moduleWith('b', [unnamed])],
82
+ shellRouter(),
83
+ ),
84
+ ).not.toThrow();
85
+ });
86
+
87
+ describe('against the shell', () => {
88
+ it('should throw when a module claims a name the shell already registered', () => {
89
+ expect(() =>
90
+ assertUniqueRouteNames(
91
+ [moduleWith('otel', [route('Workflows')])],
92
+ shellRouter([route('Workflows')]),
93
+ ),
94
+ ).toThrow(
95
+ 'Duplicate route name "Workflows" declared by module "otel" — already taken by the app shell.',
96
+ );
97
+ });
98
+
99
+ it('should throw when a module claims the name of a nested shell route', () => {
100
+ expect(() =>
101
+ assertUniqueRouteNames(
102
+ [moduleWith('otel', [route('ExecutionPreview')])],
103
+ shellRouter([route('WorkflowExecutions', [route('ExecutionPreview')])]),
104
+ ),
105
+ ).toThrow(
106
+ 'Duplicate route name "ExecutionPreview" declared by module "otel" — already taken by the app shell.',
107
+ );
108
+ });
109
+
110
+ it('should report the shell as the owner even when another module also wants the name', () => {
111
+ expect(() =>
112
+ assertUniqueRouteNames(
113
+ [moduleWith('otel', [route('Settings')]), moduleWith('mcp', [route('Settings')])],
114
+ shellRouter([route('Settings')]),
115
+ ),
116
+ ).toThrow(
117
+ 'Duplicate route name "Settings" declared by module "otel" — already taken by the app shell.',
118
+ );
119
+ });
120
+
121
+ it('should accept module names that no shell route uses', () => {
122
+ expect(() =>
123
+ assertUniqueRouteNames(
124
+ [moduleWith('otel', [route('SettingsOpenTelemetryView')])],
125
+ shellRouter([route('Settings'), route('Workflows')]),
126
+ ),
127
+ ).not.toThrow();
128
+ });
129
+ });
130
+ });
@@ -0,0 +1,54 @@
1
+ import type { Router, RouteRecordRaw } from 'vue-router';
2
+
3
+ import type { FrontendModuleDescription } from './types/descriptor';
4
+
5
+ const SHELL_OWNER = 'the app shell';
6
+
7
+ function* declaredRouteNames(routes: RouteRecordRaw[]): Generator<string | symbol> {
8
+ for (const route of routes) {
9
+ if (route.name !== undefined && route.name !== null) {
10
+ yield route.name;
11
+ }
12
+ if (route.children) {
13
+ yield* declaredRouteNames(route.children);
14
+ }
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Throws when a module claims a route name that is already taken, by the shell
20
+ * or by another module.
21
+ *
22
+ * Route names are global to the router, and `router.addRoute` replaces a
23
+ * duplicate without warning — the losing route simply stops resolving. A central
24
+ * `VIEWS` enum kept every name unique, shell and module alike, because they were
25
+ * all members of one enum. Module-owned name constants are the better contract,
26
+ * but they scatter that check, so it is restored here.
27
+ *
28
+ * Call this before registering any module route. The shell's names are read from
29
+ * `router`, so a module route added earlier would be counted as pre-existing
30
+ * rather than reported.
31
+ */
32
+ export function assertUniqueRouteNames(modules: FrontendModuleDescription[], router: Router): void {
33
+ const owners = new Map<string | symbol, string>();
34
+
35
+ for (const { name } of router.getRoutes()) {
36
+ if (name !== undefined && name !== null) {
37
+ owners.set(name, SHELL_OWNER);
38
+ }
39
+ }
40
+
41
+ for (const module of modules) {
42
+ if (!module.routes) continue;
43
+
44
+ for (const name of declaredRouteNames(module.routes)) {
45
+ const owner = owners.get(name);
46
+ if (owner !== undefined) {
47
+ throw new Error(
48
+ `Duplicate route name "${String(name)}" declared by module "${module.id}" — already taken by ${owner}.`,
49
+ );
50
+ }
51
+ owners.set(name, `module "${module.id}"`);
52
+ }
53
+ }
54
+ }
package/tsconfig.json DELETED
@@ -1,20 +0,0 @@
1
- {
2
- "extends": "@n8n/typescript-config/tsconfig.frontend.json",
3
- "compilerOptions": {
4
- "rootDirs": [".", "../design-system/src", "../composables/src"],
5
- "noEmit": true,
6
- "moduleResolution": "bundler",
7
- "types": [
8
- "vite/client",
9
- "vitest/globals",
10
- "unplugin-icons/types/vue",
11
- "../design-system/src/shims-modules.d.ts"
12
- ],
13
- "paths": {
14
- "@n8n/design-system*": ["../design-system/src*"],
15
- "@n8n/composables*": ["../composables/src*"],
16
- "@n8n/utils*": ["../../../@n8n/utils/src*"]
17
- }
18
- },
19
- "include": ["src/**/*.ts", "vite.config.ts"]
20
- }