@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 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
+ );
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/common.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }