@stacksolo/plugin-gcp-kernel 0.1.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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Cloud Pub/Sub Service
3
+ *
4
+ * Handles event publishing and subscription management.
5
+ * Replaces NATS/JetStream for GCP deployments.
6
+ *
7
+ * Architecture:
8
+ * - Single pull subscription consumes all events from the topic
9
+ * - Kernel filters events by pattern and pushes to matching HTTP endpoints
10
+ * - Same pattern matching as NATS kernel (* = single segment, > = multi-segment)
11
+ */
12
+
13
+ import { PubSub, Message } from '@google-cloud/pubsub';
14
+
15
+ const pubsub = new PubSub();
16
+
17
+ // In-memory subscription registry
18
+ interface RegisteredSubscription {
19
+ id: string;
20
+ pattern: string;
21
+ endpoint: string;
22
+ serviceName?: string;
23
+ maxRetries: number;
24
+ retryDelayMs: number;
25
+ createdAt: Date;
26
+ deliveredCount: number;
27
+ failedCount: number;
28
+ }
29
+
30
+ const subscriptions = new Map<string, RegisteredSubscription>();
31
+ let subscriptionCounter = 0;
32
+ let consumerRunning = false;
33
+
34
+ function generateSubscriptionId(): string {
35
+ return `sub_${Date.now()}_${++subscriptionCounter}`;
36
+ }
37
+
38
+ function getEventsTopic() {
39
+ const topicName = process.env.PUBSUB_EVENTS_TOPIC;
40
+ if (!topicName) {
41
+ throw new Error('PUBSUB_EVENTS_TOPIC environment variable not set');
42
+ }
43
+ return pubsub.topic(topicName);
44
+ }
45
+
46
+ // =============================================================================
47
+ // Pattern Matching (same as NATS kernel)
48
+ // =============================================================================
49
+
50
+ /**
51
+ * Check if an event type matches a subscription pattern
52
+ * Supports wildcards:
53
+ * '*' - matches single segment (e.g., 'user.*' matches 'user.created')
54
+ * '>' - matches multiple segments (e.g., 'order.>' matches 'order.item.added')
55
+ */
56
+ function matchesPattern(eventType: string, pattern: string): boolean {
57
+ // Exact match
58
+ if (eventType === pattern) return true;
59
+
60
+ // Convert pattern to regex
61
+ const regexPattern = pattern
62
+ .replace(/\./g, '\\.')
63
+ .replace(/\*/g, '[^.]+')
64
+ .replace(/>/g, '.+');
65
+
66
+ const regex = new RegExp(`^${regexPattern}$`);
67
+ return regex.test(eventType);
68
+ }
69
+
70
+ /**
71
+ * Get all subscriptions that match an event type
72
+ */
73
+ function getMatchingSubscriptions(eventType: string): RegisteredSubscription[] {
74
+ const matches: RegisteredSubscription[] = [];
75
+ for (const sub of subscriptions.values()) {
76
+ if (matchesPattern(eventType, sub.pattern)) {
77
+ matches.push(sub);
78
+ }
79
+ }
80
+ return matches;
81
+ }
82
+
83
+ // =============================================================================
84
+ // HTTP Push Delivery (same as NATS kernel)
85
+ // =============================================================================
86
+
87
+ interface EventPayload {
88
+ type: string;
89
+ data: unknown;
90
+ metadata?: Record<string, string>;
91
+ timestamp: string;
92
+ messageId?: string;
93
+ }
94
+
95
+ /**
96
+ * Deliver an event to an HTTP endpoint with retries
97
+ */
98
+ async function deliverEvent(
99
+ subscription: RegisteredSubscription,
100
+ event: EventPayload
101
+ ): Promise<boolean> {
102
+ let lastError: Error | null = null;
103
+
104
+ for (let attempt = 0; attempt <= subscription.maxRetries; attempt++) {
105
+ try {
106
+ const response = await fetch(subscription.endpoint, {
107
+ method: 'POST',
108
+ headers: {
109
+ 'Content-Type': 'application/json',
110
+ 'X-Event-Type': event.type,
111
+ 'X-Event-Timestamp': event.timestamp,
112
+ 'X-Subscription-Id': subscription.id,
113
+ 'X-Delivery-Attempt': String(attempt + 1),
114
+ },
115
+ body: JSON.stringify(event),
116
+ signal: AbortSignal.timeout(30000), // 30s timeout
117
+ });
118
+
119
+ if (response.ok) {
120
+ subscription.deliveredCount++;
121
+ return true;
122
+ }
123
+
124
+ // Non-retryable status codes
125
+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {
126
+ console.error(
127
+ `Event delivery to ${subscription.endpoint} failed with ${response.status}, not retrying`
128
+ );
129
+ subscription.failedCount++;
130
+ return false;
131
+ }
132
+
133
+ lastError = new Error(`HTTP ${response.status}: ${response.statusText}`);
134
+ } catch (error) {
135
+ lastError = error instanceof Error ? error : new Error(String(error));
136
+ }
137
+
138
+ // Wait before retry (exponential backoff)
139
+ if (attempt < subscription.maxRetries) {
140
+ const delay = subscription.retryDelayMs * Math.pow(2, attempt);
141
+ await new Promise((resolve) => setTimeout(resolve, delay));
142
+ }
143
+ }
144
+
145
+ console.error(
146
+ `Event delivery to ${subscription.endpoint} failed after ${subscription.maxRetries + 1} attempts:`,
147
+ lastError
148
+ );
149
+ subscription.failedCount++;
150
+ return false;
151
+ }
152
+
153
+ // =============================================================================
154
+ // Pub/Sub Consumer
155
+ // =============================================================================
156
+
157
+ /**
158
+ * Start the Pub/Sub consumer that delivers events to HTTP endpoints
159
+ */
160
+ export async function startEventConsumer(): Promise<void> {
161
+ if (consumerRunning) return;
162
+
163
+ const topicName = process.env.PUBSUB_EVENTS_TOPIC;
164
+ if (!topicName) {
165
+ console.warn('PUBSUB_EVENTS_TOPIC not set, event consumer not started');
166
+ return;
167
+ }
168
+
169
+ // Use a single pull subscription for the kernel
170
+ const subscriptionName = `${topicName}-kernel-consumer`;
171
+
172
+ // Get or create the subscription
173
+ let subscription;
174
+ try {
175
+ subscription = pubsub.subscription(subscriptionName);
176
+ const [exists] = await subscription.exists();
177
+ if (!exists) {
178
+ const topic = getEventsTopic();
179
+ [subscription] = await topic.createSubscription(subscriptionName, {
180
+ ackDeadlineSeconds: 60,
181
+ retryPolicy: {
182
+ minimumBackoff: { seconds: 10 },
183
+ maximumBackoff: { seconds: 600 },
184
+ },
185
+ });
186
+ console.log(`Created Pub/Sub subscription: ${subscriptionName}`);
187
+ }
188
+ } catch (error) {
189
+ console.error('Failed to setup Pub/Sub subscription:', error);
190
+ return;
191
+ }
192
+
193
+ consumerRunning = true;
194
+ console.log('Started Pub/Sub event consumer for HTTP push delivery');
195
+
196
+ // Process messages
197
+ subscription.on('message', async (message: Message) => {
198
+ try {
199
+ const payload = JSON.parse(message.data.toString());
200
+ const eventType = payload.type || message.attributes?.eventType || 'unknown';
201
+
202
+ const event: EventPayload = {
203
+ type: eventType,
204
+ data: payload.data,
205
+ metadata: payload.metadata,
206
+ timestamp: payload.timestamp || new Date().toISOString(),
207
+ messageId: message.id,
208
+ };
209
+
210
+ // Find matching subscriptions
211
+ const matchingSubs = getMatchingSubscriptions(eventType);
212
+
213
+ if (matchingSubs.length === 0) {
214
+ // No subscribers, ack and continue
215
+ message.ack();
216
+ return;
217
+ }
218
+
219
+ // Deliver to all matching subscriptions in parallel
220
+ const deliveryResults = await Promise.all(
221
+ matchingSubs.map((sub) => deliverEvent(sub, event))
222
+ );
223
+
224
+ // Ack the message (we don't retry at Pub/Sub level, we handle retries ourselves)
225
+ message.ack();
226
+
227
+ const successCount = deliveryResults.filter(Boolean).length;
228
+ if (successCount < matchingSubs.length) {
229
+ console.warn(
230
+ `Event ${eventType} delivered to ${successCount}/${matchingSubs.length} subscribers`
231
+ );
232
+ }
233
+ } catch (error) {
234
+ console.error('Error processing event:', error);
235
+ // Ack anyway to avoid blocking
236
+ message.ack();
237
+ }
238
+ });
239
+
240
+ subscription.on('error', (error) => {
241
+ console.error('Pub/Sub subscription error:', error);
242
+ });
243
+ }
244
+
245
+ /**
246
+ * Stop the event consumer
247
+ */
248
+ export function stopEventConsumer(): void {
249
+ consumerRunning = false;
250
+ }
251
+
252
+ // =============================================================================
253
+ // Public API
254
+ // =============================================================================
255
+
256
+ /**
257
+ * Publish an event to Pub/Sub
258
+ */
259
+ export async function publishEvent(
260
+ eventType: string,
261
+ data: unknown,
262
+ metadata?: Record<string, string>
263
+ ): Promise<{ messageId: string; eventType: string; timestamp: string }> {
264
+ const topic = getEventsTopic();
265
+
266
+ const timestamp = new Date().toISOString();
267
+ const payload = {
268
+ type: eventType,
269
+ data,
270
+ metadata,
271
+ timestamp,
272
+ };
273
+
274
+ const messageId = await topic.publishMessage({
275
+ data: Buffer.from(JSON.stringify(payload)),
276
+ attributes: {
277
+ eventType,
278
+ timestamp,
279
+ ...metadata,
280
+ },
281
+ });
282
+
283
+ return {
284
+ messageId,
285
+ eventType,
286
+ timestamp,
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Create a subscription for event delivery
292
+ * Events matching the pattern will be pushed to the endpoint
293
+ */
294
+ export async function createSubscription(
295
+ pattern: string,
296
+ endpoint: string,
297
+ serviceName?: string
298
+ ): Promise<{
299
+ subscriptionId: string;
300
+ pattern: string;
301
+ endpoint: string;
302
+ }> {
303
+ const subscriptionId = generateSubscriptionId();
304
+
305
+ const registered: RegisteredSubscription = {
306
+ id: subscriptionId,
307
+ pattern,
308
+ endpoint,
309
+ serviceName,
310
+ maxRetries: 3,
311
+ retryDelayMs: 1000,
312
+ createdAt: new Date(),
313
+ deliveredCount: 0,
314
+ failedCount: 0,
315
+ };
316
+
317
+ subscriptions.set(subscriptionId, registered);
318
+
319
+ console.log(`Registered subscription ${subscriptionId}: ${pattern} -> ${endpoint}`);
320
+
321
+ return {
322
+ subscriptionId,
323
+ pattern,
324
+ endpoint,
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Delete a subscription
330
+ */
331
+ export async function deleteSubscription(subscriptionId: string): Promise<void> {
332
+ const registered = subscriptions.get(subscriptionId);
333
+ if (!registered) {
334
+ throw new Error('NOT_FOUND');
335
+ }
336
+
337
+ subscriptions.delete(subscriptionId);
338
+ console.log(`Deleted subscription ${subscriptionId}`);
339
+ }
340
+
341
+ /**
342
+ * List all subscriptions
343
+ */
344
+ export function listSubscriptions(pattern?: string): Array<{
345
+ subscriptionId: string;
346
+ pattern: string;
347
+ endpoint: string;
348
+ serviceName?: string;
349
+ createdAt: string;
350
+ deliveredCount: number;
351
+ failedCount: number;
352
+ }> {
353
+ let subs = Array.from(subscriptions.values());
354
+
355
+ if (pattern) {
356
+ subs = subs.filter((s) => s.pattern === pattern);
357
+ }
358
+
359
+ return subs.map((s) => ({
360
+ subscriptionId: s.id,
361
+ pattern: s.pattern,
362
+ endpoint: s.endpoint,
363
+ serviceName: s.serviceName,
364
+ createdAt: s.createdAt.toISOString(),
365
+ deliveredCount: s.deliveredCount,
366
+ failedCount: s.failedCount,
367
+ }));
368
+ }
369
+
370
+ /**
371
+ * Check if an event type matches a pattern (exported for testing)
372
+ */
373
+ export { matchesPattern };
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Cloud Storage Service
3
+ *
4
+ * Handles file operations via signed URLs.
5
+ */
6
+
7
+ import { Storage } from '@google-cloud/storage';
8
+
9
+ const storage = new Storage();
10
+ const SIGNED_URL_EXPIRATION = parseInt(process.env.SIGNED_URL_EXPIRATION || '3600', 10);
11
+
12
+ function getBucket() {
13
+ const bucketName = process.env.GCS_BUCKET;
14
+ if (!bucketName) {
15
+ throw new Error('GCS_BUCKET environment variable not set');
16
+ }
17
+ return storage.bucket(bucketName);
18
+ }
19
+
20
+ /**
21
+ * Validate file path - prevent path traversal attacks
22
+ */
23
+ export function validatePath(path: string): { valid: boolean; error?: string } {
24
+ if (!path) {
25
+ return { valid: false, error: 'Path is required' };
26
+ }
27
+
28
+ if (path.startsWith('/')) {
29
+ return { valid: false, error: 'Path must not start with /' };
30
+ }
31
+
32
+ if (path.includes('..')) {
33
+ return { valid: false, error: 'Path must not contain ..' };
34
+ }
35
+
36
+ if (path.includes('//')) {
37
+ return { valid: false, error: 'Path must not contain //' };
38
+ }
39
+
40
+ return { valid: true };
41
+ }
42
+
43
+ /**
44
+ * Generate a signed upload URL
45
+ */
46
+ export async function getUploadUrl(
47
+ path: string,
48
+ contentType: string
49
+ ): Promise<{ uploadUrl: string; path: string; expiresAt: string }> {
50
+ const bucket = getBucket();
51
+ const file = bucket.file(path);
52
+
53
+ const expiresAt = new Date(Date.now() + SIGNED_URL_EXPIRATION * 1000);
54
+
55
+ const [uploadUrl] = await file.getSignedUrl({
56
+ version: 'v4',
57
+ action: 'write',
58
+ expires: expiresAt,
59
+ contentType,
60
+ });
61
+
62
+ return {
63
+ uploadUrl,
64
+ path,
65
+ expiresAt: expiresAt.toISOString(),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Generate a signed download URL
71
+ */
72
+ export async function getDownloadUrl(
73
+ path: string
74
+ ): Promise<{ downloadUrl: string; path: string; expiresAt: string }> {
75
+ const bucket = getBucket();
76
+ const file = bucket.file(path);
77
+
78
+ // Check if file exists
79
+ const [exists] = await file.exists();
80
+ if (!exists) {
81
+ throw new Error('NOT_FOUND');
82
+ }
83
+
84
+ const expiresAt = new Date(Date.now() + SIGNED_URL_EXPIRATION * 1000);
85
+
86
+ const [downloadUrl] = await file.getSignedUrl({
87
+ version: 'v4',
88
+ action: 'read',
89
+ expires: expiresAt,
90
+ });
91
+
92
+ return {
93
+ downloadUrl,
94
+ path,
95
+ expiresAt: expiresAt.toISOString(),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * List files with a prefix
101
+ */
102
+ export async function listFiles(
103
+ prefix?: string,
104
+ maxResults?: number,
105
+ pageToken?: string
106
+ ): Promise<{
107
+ files: Array<{
108
+ path: string;
109
+ size: number;
110
+ contentType: string;
111
+ created: string;
112
+ updated: string;
113
+ }>;
114
+ nextPageToken?: string;
115
+ }> {
116
+ const bucket = getBucket();
117
+
118
+ const [files, nextQuery] = await bucket.getFiles({
119
+ prefix: prefix || '',
120
+ maxResults: maxResults || 100,
121
+ pageToken,
122
+ autoPaginate: false,
123
+ });
124
+
125
+ return {
126
+ files: files.map((file) => ({
127
+ path: file.name,
128
+ size: parseInt(file.metadata.size as string, 10) || 0,
129
+ contentType: (file.metadata.contentType as string) || 'application/octet-stream',
130
+ created: file.metadata.timeCreated as string,
131
+ updated: file.metadata.updated as string,
132
+ })),
133
+ nextPageToken: nextQuery?.pageToken,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Delete a file
139
+ */
140
+ export async function deleteFile(path: string): Promise<void> {
141
+ const bucket = getBucket();
142
+ const file = bucket.file(path);
143
+
144
+ // Check if file exists
145
+ const [exists] = await file.exists();
146
+ if (!exists) {
147
+ throw new Error('NOT_FOUND');
148
+ }
149
+
150
+ await file.delete();
151
+ }
152
+
153
+ /**
154
+ * Move/rename a file
155
+ */
156
+ export async function moveFile(
157
+ sourcePath: string,
158
+ destinationPath: string
159
+ ): Promise<void> {
160
+ const bucket = getBucket();
161
+ const sourceFile = bucket.file(sourcePath);
162
+
163
+ // Check if source file exists
164
+ const [exists] = await sourceFile.exists();
165
+ if (!exists) {
166
+ throw new Error('NOT_FOUND');
167
+ }
168
+
169
+ // Copy to destination then delete source
170
+ await sourceFile.copy(destinationPath);
171
+ await sourceFile.delete();
172
+ }
173
+
174
+ /**
175
+ * Get file metadata
176
+ */
177
+ export async function getFileMetadata(path: string): Promise<{
178
+ path: string;
179
+ size: number;
180
+ contentType: string;
181
+ created: string;
182
+ updated: string;
183
+ metadata?: Record<string, string>;
184
+ }> {
185
+ const bucket = getBucket();
186
+ const file = bucket.file(path);
187
+
188
+ // Check if file exists
189
+ const [exists] = await file.exists();
190
+ if (!exists) {
191
+ throw new Error('NOT_FOUND');
192
+ }
193
+
194
+ const [metadata] = await file.getMetadata();
195
+
196
+ return {
197
+ path,
198
+ size: parseInt(metadata.size as string, 10) || 0,
199
+ contentType: (metadata.contentType as string) || 'application/octet-stream',
200
+ created: metadata.timeCreated as string,
201
+ updated: metadata.updated as string,
202
+ metadata: metadata.metadata as Record<string, string> | undefined,
203
+ };
204
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true,
12
+ "resolveJsonModule": true
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }