@checkstack/signal-common 0.0.2
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/CHANGELOG.md +61 -0
- package/package.json +24 -0
- package/src/index.test.ts +162 -0
- package/src/index.ts +179 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# @checkstack/signal-common
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
|
|
8
|
+
- Updated dependencies [d20d274]
|
|
9
|
+
- @checkstack/common@0.0.2
|
|
10
|
+
|
|
11
|
+
## 0.1.1
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Updated dependencies [a65e002]
|
|
16
|
+
- @checkstack/common@0.2.0
|
|
17
|
+
|
|
18
|
+
## 0.1.0
|
|
19
|
+
|
|
20
|
+
### Minor Changes
|
|
21
|
+
|
|
22
|
+
- b55fae6: Added realtime Signal Service for backend-to-frontend push notifications via WebSockets.
|
|
23
|
+
|
|
24
|
+
## New Packages
|
|
25
|
+
|
|
26
|
+
- **@checkstack/signal-common**: Shared types including `Signal`, `SignalService`, `createSignal()`, and WebSocket protocol messages
|
|
27
|
+
- **@checkstack/signal-backend**: `SignalServiceImpl` with EventBus integration and Bun WebSocket handler using native pub/sub
|
|
28
|
+
- **@checkstack/signal-frontend**: React `SignalProvider` and `useSignal()` hook for consuming typed signals
|
|
29
|
+
|
|
30
|
+
## Changes
|
|
31
|
+
|
|
32
|
+
- **@checkstack/backend-api**: Added `coreServices.signalService` reference for plugins to emit signals
|
|
33
|
+
- **@checkstack/backend**: Integrated WebSocket server at `/api/signals/ws` with session-based authentication
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
Backend plugins can emit signals:
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { coreServices } from "@checkstack/backend-api";
|
|
41
|
+
import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
|
|
42
|
+
|
|
43
|
+
const signalService = context.signalService;
|
|
44
|
+
await signalService.sendToUser(NOTIFICATION_RECEIVED, userId, { ... });
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Frontend components subscribe to signals:
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
51
|
+
import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
|
|
52
|
+
|
|
53
|
+
useSignal(NOTIFICATION_RECEIVED, (payload) => {
|
|
54
|
+
// Handle realtime notification
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Patch Changes
|
|
59
|
+
|
|
60
|
+
- Updated dependencies [ffc28f6]
|
|
61
|
+
- @checkstack/common@0.1.0
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/signal-common",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"import": "./src/index.ts"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@checkstack/common": "workspace:*",
|
|
12
|
+
"zod": "^4.0.0"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"typescript": "^5.7.2",
|
|
16
|
+
"@checkstack/tsconfig": "workspace:*",
|
|
17
|
+
"@checkstack/scripts": "workspace:*"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"lint": "bun run lint:code",
|
|
22
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { createSignal, type Signal, type SignalMessage } from "../src/index";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
describe("createSignal", () => {
|
|
6
|
+
it("should create a signal with the given id and schema", () => {
|
|
7
|
+
const schema = z.object({ message: z.string() });
|
|
8
|
+
const signal = createSignal("test.signal", schema);
|
|
9
|
+
|
|
10
|
+
expect(signal.id).toBe("test.signal");
|
|
11
|
+
expect(signal.payloadSchema).toBe(schema);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("should create signals with different payload types", () => {
|
|
15
|
+
const stringSignal = createSignal("string.signal", z.string());
|
|
16
|
+
const numberSignal = createSignal("number.signal", z.number());
|
|
17
|
+
const objectSignal = createSignal(
|
|
18
|
+
"object.signal",
|
|
19
|
+
z.object({
|
|
20
|
+
id: z.string(),
|
|
21
|
+
count: z.number(),
|
|
22
|
+
active: z.boolean(),
|
|
23
|
+
})
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(stringSignal.id).toBe("string.signal");
|
|
27
|
+
expect(numberSignal.id).toBe("number.signal");
|
|
28
|
+
expect(objectSignal.id).toBe("object.signal");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should validate payload against schema", () => {
|
|
32
|
+
const signal = createSignal(
|
|
33
|
+
"notification.received",
|
|
34
|
+
z.object({
|
|
35
|
+
id: z.string(),
|
|
36
|
+
title: z.string(),
|
|
37
|
+
importance: z.enum(["info", "warning", "critical"]),
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Valid payload
|
|
42
|
+
const validPayload = {
|
|
43
|
+
id: "n-123",
|
|
44
|
+
title: "Test Notification",
|
|
45
|
+
importance: "info" as const,
|
|
46
|
+
};
|
|
47
|
+
const validResult = signal.payloadSchema.safeParse(validPayload);
|
|
48
|
+
expect(validResult.success).toBe(true);
|
|
49
|
+
|
|
50
|
+
// Invalid payload - missing required field
|
|
51
|
+
const invalidPayload = {
|
|
52
|
+
id: "n-123",
|
|
53
|
+
title: "Test",
|
|
54
|
+
};
|
|
55
|
+
const invalidResult = signal.payloadSchema.safeParse(invalidPayload);
|
|
56
|
+
expect(invalidResult.success).toBe(false);
|
|
57
|
+
|
|
58
|
+
// Invalid payload - wrong enum value
|
|
59
|
+
const wrongEnumPayload = {
|
|
60
|
+
id: "n-123",
|
|
61
|
+
title: "Test",
|
|
62
|
+
importance: "urgent",
|
|
63
|
+
};
|
|
64
|
+
const wrongEnumResult = signal.payloadSchema.safeParse(wrongEnumPayload);
|
|
65
|
+
expect(wrongEnumResult.success).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should support nested object schemas", () => {
|
|
69
|
+
const signal = createSignal(
|
|
70
|
+
"complex.signal",
|
|
71
|
+
z.object({
|
|
72
|
+
user: z.object({
|
|
73
|
+
id: z.string(),
|
|
74
|
+
name: z.string(),
|
|
75
|
+
}),
|
|
76
|
+
metadata: z.record(z.string(), z.string()),
|
|
77
|
+
tags: z.array(z.string()),
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const payload = {
|
|
82
|
+
user: { id: "u-1", name: "Test User" },
|
|
83
|
+
metadata: { source: "api" },
|
|
84
|
+
tags: ["important", "system"],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const result = signal.payloadSchema.safeParse(payload);
|
|
88
|
+
expect(result.success).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should support optional fields in schema", () => {
|
|
92
|
+
const signal = createSignal(
|
|
93
|
+
"optional.signal",
|
|
94
|
+
z.object({
|
|
95
|
+
required: z.string(),
|
|
96
|
+
optional: z.string().optional(),
|
|
97
|
+
})
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// With optional field
|
|
101
|
+
const withOptional = signal.payloadSchema.safeParse({
|
|
102
|
+
required: "value",
|
|
103
|
+
optional: "optional value",
|
|
104
|
+
});
|
|
105
|
+
expect(withOptional.success).toBe(true);
|
|
106
|
+
|
|
107
|
+
// Without optional field
|
|
108
|
+
const withoutOptional = signal.payloadSchema.safeParse({
|
|
109
|
+
required: "value",
|
|
110
|
+
});
|
|
111
|
+
expect(withoutOptional.success).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("Signal type inference", () => {
|
|
116
|
+
it("should correctly infer payload type from schema", () => {
|
|
117
|
+
const signal = createSignal(
|
|
118
|
+
"typed.signal",
|
|
119
|
+
z.object({
|
|
120
|
+
count: z.number(),
|
|
121
|
+
name: z.string(),
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// TypeScript should infer that payload is { count: number, name: string }
|
|
126
|
+
type InferredPayload = z.infer<typeof signal.payloadSchema>;
|
|
127
|
+
|
|
128
|
+
// This test ensures the types compile correctly
|
|
129
|
+
const payload: InferredPayload = { count: 42, name: "test" };
|
|
130
|
+
expect(payload.count).toBe(42);
|
|
131
|
+
expect(payload.name).toBe("test");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("SignalMessage structure", () => {
|
|
136
|
+
it("should have correct message envelope structure", () => {
|
|
137
|
+
const message: SignalMessage<{ text: string }> = {
|
|
138
|
+
signalId: "test.message",
|
|
139
|
+
payload: { text: "Hello" },
|
|
140
|
+
timestamp: new Date().toISOString(),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
expect(message.signalId).toBe("test.message");
|
|
144
|
+
expect(message.payload.text).toBe("Hello");
|
|
145
|
+
expect(typeof message.timestamp).toBe("string");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("Signal ID conventions", () => {
|
|
150
|
+
it("should follow dot-notation naming convention", () => {
|
|
151
|
+
const signals = [
|
|
152
|
+
createSignal("notification.received", z.string()),
|
|
153
|
+
createSignal("notification.read", z.string()),
|
|
154
|
+
createSignal("system.maintenance.scheduled", z.string()),
|
|
155
|
+
createSignal("healthcheck.status.changed", z.string()),
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
for (const signal of signals) {
|
|
159
|
+
expect(signal.id).toMatch(/^[a-z]+(\.[a-z]+)+$/);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Permission, PluginMetadata } from "@checkstack/common";
|
|
3
|
+
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// SIGNAL DEFINITION
|
|
6
|
+
// =============================================================================
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A Signal is a typed event that can be broadcast from backend to frontend.
|
|
10
|
+
* Similar to the Hook pattern but for realtime WebSocket communication.
|
|
11
|
+
*/
|
|
12
|
+
export interface Signal<T = unknown> {
|
|
13
|
+
id: string;
|
|
14
|
+
payloadSchema: z.ZodType<T>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Factory function for creating type-safe signals.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* const NOTIFICATION_RECEIVED = createSignal(
|
|
23
|
+
* "notification.received",
|
|
24
|
+
* z.object({ id: z.string(), title: z.string() })
|
|
25
|
+
* );
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function createSignal<T>(
|
|
29
|
+
id: string,
|
|
30
|
+
payloadSchema: z.ZodType<T>
|
|
31
|
+
): Signal<T> {
|
|
32
|
+
return { id, payloadSchema };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// SIGNAL MESSAGE ENVELOPE
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The message envelope sent over WebSocket containing a signal payload.
|
|
41
|
+
*/
|
|
42
|
+
export interface SignalMessage<T = unknown> {
|
|
43
|
+
signalId: string;
|
|
44
|
+
payload: T;
|
|
45
|
+
timestamp: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// CHANNEL TYPES
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Signal channels determine who receives the signal.
|
|
54
|
+
*/
|
|
55
|
+
export type SignalChannel =
|
|
56
|
+
| { type: "broadcast" }
|
|
57
|
+
| { type: "user"; userId: string };
|
|
58
|
+
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// WEBSOCKET PROTOCOL MESSAGES
|
|
61
|
+
// =============================================================================
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Messages sent from client to server over WebSocket.
|
|
65
|
+
*/
|
|
66
|
+
export type ClientToServerMessage = { type: "ping" };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Messages sent from server to client over WebSocket.
|
|
70
|
+
*/
|
|
71
|
+
export type ServerToClientMessage =
|
|
72
|
+
| { type: "pong" }
|
|
73
|
+
| { type: "connected"; userId: string }
|
|
74
|
+
| { type: "signal"; signalId: string; payload: unknown; timestamp: string }
|
|
75
|
+
| { type: "error"; message: string };
|
|
76
|
+
|
|
77
|
+
// =============================================================================
|
|
78
|
+
// SIGNAL SERVICE INTERFACE
|
|
79
|
+
// =============================================================================
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* SignalService provides methods to emit signals to connected clients.
|
|
83
|
+
*
|
|
84
|
+
* Signals are pushed via WebSocket to connected frontends in realtime.
|
|
85
|
+
* The service coordinates across backend instances via the EventBus.
|
|
86
|
+
*/
|
|
87
|
+
export interface SignalService {
|
|
88
|
+
/**
|
|
89
|
+
* Emit a broadcast signal to all connected clients.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```typescript
|
|
93
|
+
* await signalService.broadcast(SYSTEM_STATUS_CHANGED, { status: "maintenance" });
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
broadcast<T>(signal: Signal<T>, payload: T): Promise<void>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Emit a signal to a specific user.
|
|
100
|
+
* Only WebSocket connections authenticated as this user will receive it.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```typescript
|
|
104
|
+
* await signalService.sendToUser(NOTIFICATION_RECEIVED, userId, { title: "New message" });
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
sendToUser<T>(signal: Signal<T>, userId: string, payload: T): Promise<void>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Emit a signal to multiple users.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```typescript
|
|
114
|
+
* await signalService.sendToUsers(NOTIFICATION_RECEIVED, [user1, user2], { title: "Alert" });
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
sendToUsers<T>(
|
|
118
|
+
signal: Signal<T>,
|
|
119
|
+
userIds: string[],
|
|
120
|
+
payload: T
|
|
121
|
+
): Promise<void>;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Emit a signal only to users from the provided list who have the required permission.
|
|
125
|
+
* Uses S2S RPC to filter users via AuthApi before sending.
|
|
126
|
+
*
|
|
127
|
+
* @param signal - The signal to emit
|
|
128
|
+
* @param userIds - List of user IDs to potentially send to
|
|
129
|
+
* @param payload - Signal payload
|
|
130
|
+
* @param pluginMetadata - The plugin metadata (for constructing fully-qualified permission ID)
|
|
131
|
+
* @param permission - Permission object with native ID (will be prefixed with pluginId)
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```typescript
|
|
135
|
+
* import { pluginMetadata, permissions } from "@checkstack/healthcheck-common";
|
|
136
|
+
*
|
|
137
|
+
* await signalService.sendToAuthorizedUsers(
|
|
138
|
+
* HEALTH_STATE_CHANGED,
|
|
139
|
+
* subscriberUserIds,
|
|
140
|
+
* { systemId, newState: "degraded" },
|
|
141
|
+
* pluginMetadata,
|
|
142
|
+
* permissions.healthcheckStatusRead
|
|
143
|
+
* );
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
sendToAuthorizedUsers<T>(
|
|
147
|
+
signal: Signal<T>,
|
|
148
|
+
userIds: string[],
|
|
149
|
+
payload: T,
|
|
150
|
+
pluginMetadata: PluginMetadata,
|
|
151
|
+
permission: Permission
|
|
152
|
+
): Promise<void>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// =============================================================================
|
|
156
|
+
// CORE PLUGIN LIFECYCLE SIGNALS
|
|
157
|
+
// =============================================================================
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Broadcast to all frontends when a plugin has been fully installed on the backend.
|
|
161
|
+
* Frontends should dynamically load the plugin's UI assets.
|
|
162
|
+
*/
|
|
163
|
+
export const PLUGIN_INSTALLED = createSignal(
|
|
164
|
+
"core.plugin.installed",
|
|
165
|
+
z.object({
|
|
166
|
+
pluginId: z.string(),
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Broadcast to all frontends when a plugin has been deregistered from the backend.
|
|
172
|
+
* Frontends should remove the plugin's extensions and routes.
|
|
173
|
+
*/
|
|
174
|
+
export const PLUGIN_DEREGISTERED = createSignal(
|
|
175
|
+
"core.plugin.deregistered",
|
|
176
|
+
z.object({
|
|
177
|
+
pluginId: z.string(),
|
|
178
|
+
})
|
|
179
|
+
);
|