@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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +253 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
- package/service/Dockerfile +23 -0
- package/service/package-lock.json +3231 -0
- package/service/package.json +26 -0
- package/service/src/index.ts +77 -0
- package/service/src/routes/auth.ts +51 -0
- package/service/src/routes/events.ts +148 -0
- package/service/src/routes/files.ts +230 -0
- package/service/src/routes/health.ts +22 -0
- package/service/src/services/firebase.ts +67 -0
- package/service/src/services/pubsub.ts +373 -0
- package/service/src/services/storage.ts +204 -0
- package/service/tsconfig.json +16 -0
|
@@ -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
|
+
}
|