Skip to content

Commit 53194c8

Browse files
authored
feat: storage triggers (#38)
1 parent d8d394c commit 53194c8

23 files changed

+1771
-8
lines changed

analysis_options.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ analyzer:
99
missing_required_param: error
1010
missing_return: error
1111
todo: ignore
12+
use_null_aware_elements: ignore
1213

1314
linter:
1415
rules:

example/basic/firebase.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,15 @@
2525
"ui": {
2626
"enabled": true,
2727
"port": 4000
28+
},
29+
"storage": {
30+
"port": 9199
2831
}
2932
},
3033
"database": {
3134
"rules": "database.rules.json"
35+
},
36+
"storage": {
37+
"rules": "storage.rules"
3238
}
3339
}

example/basic/lib/main.dart

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,52 @@ void main(List<String> args) async {
420420
print(' Updated By: ${data?.updateUser.email}');
421421
});
422422

423+
// ==========================================================================
424+
// Cloud Storage trigger examples
425+
// ==========================================================================
426+
427+
// Storage onObjectFinalized - triggers when an object is created/overwritten
428+
firebase.storage.onObjectFinalized(
429+
bucket: 'demo-test.firebasestorage.app',
430+
(event) async {
431+
final data = event.data;
432+
print('Object finalized in bucket: ${event.bucket}');
433+
print(' Name: ${data?.name}');
434+
print(' Content Type: ${data?.contentType}');
435+
print(' Size: ${data?.size}');
436+
},
437+
);
438+
439+
// Storage onObjectArchived - triggers when an object is archived
440+
firebase.storage.onObjectArchived(bucket: 'demo-test.firebasestorage.app', (
441+
event,
442+
) async {
443+
final data = event.data;
444+
print('Object archived in bucket: ${event.bucket}');
445+
print(' Name: ${data?.name}');
446+
print(' Storage Class: ${data?.storageClass}');
447+
});
448+
449+
// Storage onObjectDeleted - triggers when an object is deleted
450+
firebase.storage.onObjectDeleted(bucket: 'demo-test.firebasestorage.app', (
451+
event,
452+
) async {
453+
final data = event.data;
454+
print('Object deleted in bucket: ${event.bucket}');
455+
print(' Name: ${data?.name}');
456+
});
457+
458+
// Storage onObjectMetadataUpdated - triggers when object metadata changes
459+
firebase.storage.onObjectMetadataUpdated(
460+
bucket: 'demo-test.firebasestorage.app',
461+
(event) async {
462+
final data = event.data;
463+
print('Object metadata updated in bucket: ${event.bucket}');
464+
print(' Name: ${data?.name}');
465+
print(' Metadata: ${data?.metadata}');
466+
},
467+
);
468+
423469
// ==========================================================================
424470
// Scheduler trigger examples
425471
// ==========================================================================

example/basic/storage.rules

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
rules_version = '2';
2+
service firebase.storage {
3+
match /b/{bucket}/o {
4+
match /{allPaths=**} {
5+
allow read, write: if true;
6+
}
7+
}
8+
}

example/client_app/index.html

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,26 +194,47 @@ <h2>6. Raw POST to Callable (without SDK)</h2>
194194
<div id="rawResult" class="result" style="display: none;"></div>
195195
</div>
196196

197+
<!-- Storage Upload Demo -->
198+
<div class="card">
199+
<h2>7. Storage - File Upload (triggers onObjectFinalized)</h2>
200+
<p>Uploads a file to Cloud Storage via the Firebase SDK. The emulator automatically triggers <code>onObjectFinalized</code> on the Dart function. Check the emulator logs to see the trigger fire.</p>
201+
202+
<div>
203+
<input type="file" id="storageFile">
204+
</div>
205+
<div>
206+
<input type="text" id="storagePath" placeholder="Upload path" value="uploads/" style="width: 300px;">
207+
<button onclick="uploadFile()">Upload to Storage</button>
208+
<button onclick="deleteFile()">Delete Last Upload</button>
209+
</div>
210+
211+
<div id="storageResult" class="result" style="display: none;"></div>
212+
</div>
213+
197214
<!-- Firebase SDK -->
198215
<script type="module">
199216
// Import Firebase SDK
200217
import { initializeApp } from 'https://www.gstatic.com/firebasejs/12.8.0/firebase-app.js';
201218
import { getFunctions, httpsCallable, connectFunctionsEmulator } from 'https://www.gstatic.com/firebasejs/12.8.0/firebase-functions.js';
202219
import { getAuth, connectAuthEmulator, signInWithEmailAndPassword, createUserWithEmailAndPassword, signOut as firebaseSignOut, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/12.8.0/firebase-auth.js';
220+
import { getStorage, connectStorageEmulator, ref, uploadBytes, deleteObject, getMetadata } from 'https://www.gstatic.com/firebasejs/12.8.0/firebase-storage.js';
203221

204222
// Initialize Firebase (minimal config for Functions)
205223
const firebaseConfig = {
206224
projectId: 'demo-test',
207225
apiKey: 'fake-api-key', // Not needed for emulator
226+
storageBucket: 'demo-test.firebasestorage.app',
208227
};
209228

210229
const app = initializeApp(firebaseConfig);
211230
const functions = getFunctions(app, 'us-central1');
212231
const auth = getAuth(app);
232+
const storage = getStorage(app);
213233

214234
// Connect to emulators
215235
connectFunctionsEmulator(functions, '127.0.0.1', 5001);
216236
connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true });
237+
connectStorageEmulator(storage, '127.0.0.1', 9199);
217238

218239
// Track auth state
219240
onAuthStateChanged(auth, (user) => {
@@ -234,8 +255,13 @@ <h2>6. Raw POST to Callable (without SDK)</h2>
234255
window.signInWithEmailAndPassword = signInWithEmailAndPassword;
235256
window.createUserWithEmailAndPassword = createUserWithEmailAndPassword;
236257
window.firebaseSignOut = firebaseSignOut;
258+
window.storage = storage;
259+
window.storageRef = ref;
260+
window.uploadBytes = uploadBytes;
261+
window.deleteObject = deleteObject;
262+
window.getMetadata = getMetadata;
237263

238-
console.log('Firebase initialized and connected to emulators (Functions + Auth)');
264+
console.log('Firebase initialized and connected to emulators (Functions + Auth + Storage)');
239265
</script>
240266

241267
<script>
@@ -465,6 +491,64 @@ <h2>6. Raw POST to Callable (without SDK)</h2>
465491
}
466492
}
467493
window.callRawCallable = callRawCallable;
494+
495+
// 7. Storage - File upload (triggers onObjectFinalized)
496+
let lastUploadedRef = null;
497+
498+
async function uploadFile() {
499+
const fileInput = document.getElementById('storageFile');
500+
const pathPrefix = document.getElementById('storagePath').value || 'uploads/';
501+
502+
if (!fileInput.files.length) {
503+
showResult('storageResult', 'Please select a file first.', true);
504+
return;
505+
}
506+
507+
const file = fileInput.files[0];
508+
const fullPath = pathPrefix + file.name;
509+
510+
try {
511+
const fileRef = window.storageRef(window.storage, fullPath);
512+
const snapshot = await window.uploadBytes(fileRef, file, {
513+
contentType: file.type,
514+
});
515+
516+
lastUploadedRef = fileRef;
517+
const metadata = await window.getMetadata(fileRef);
518+
519+
showResult('storageResult', {
520+
status: 'Upload complete - check emulator logs for onObjectFinalized trigger',
521+
path: fullPath,
522+
contentType: metadata.contentType,
523+
size: metadata.size,
524+
bucket: metadata.bucket,
525+
timeCreated: metadata.timeCreated,
526+
});
527+
} catch (error) {
528+
showResult('storageResult', `Upload error: ${error.message}`, true);
529+
}
530+
}
531+
window.uploadFile = uploadFile;
532+
533+
async function deleteFile() {
534+
if (!lastUploadedRef) {
535+
showResult('storageResult', 'No file uploaded yet. Upload a file first.', true);
536+
return;
537+
}
538+
539+
try {
540+
await window.deleteObject(lastUploadedRef);
541+
542+
showResult('storageResult', {
543+
status: 'File deleted - check emulator logs for onObjectDeleted trigger',
544+
path: lastUploadedRef.fullPath,
545+
});
546+
lastUploadedRef = null;
547+
} catch (error) {
548+
showResult('storageResult', `Delete error: ${error.message}`, true);
549+
}
550+
}
551+
window.deleteFile = deleteFile;
468552
</script>
469553
</body>
470554
</html>

example/nodejs_reference/index.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const { onThresholdAlertPublished } = require("firebase-functions/v2/alerts/perf
1414
const { beforeUserCreated, beforeUserSignedIn, beforeOperation } = require("firebase-functions/v2/identity");
1515
const { onSchedule } = require("firebase-functions/v2/scheduler");
1616
const { onConfigUpdated } = require("firebase-functions/v2/remoteConfig");
17+
const { onObjectFinalized, onObjectArchived, onObjectDeleted, onObjectMetadataUpdated } = require("firebase-functions/v2/storage");
1718
const { defineString, defineInt, defineBoolean } = require("firebase-functions/params");
1819

1920
// =============================================================================
@@ -355,6 +356,50 @@ exports.onConfigUpdated = onConfigUpdated(
355356
}
356357
);
357358

359+
// =============================================================================
360+
// Cloud Storage trigger examples
361+
// =============================================================================
362+
363+
// Storage onObjectFinalized - triggers when an object is created/overwritten
364+
exports.onObjectFinalized_demotestfirebasestorageapp = onObjectFinalized(
365+
{ bucket: "demo-test.firebasestorage.app" },
366+
(event) => {
367+
console.log("Object finalized in bucket:", event.data.bucket);
368+
console.log(" Name:", event.data.name);
369+
console.log(" Content Type:", event.data.contentType);
370+
console.log(" Size:", event.data.size);
371+
}
372+
);
373+
374+
// Storage onObjectArchived - triggers when an object is archived
375+
exports.onObjectArchived_demotestfirebasestorageapp = onObjectArchived(
376+
{ bucket: "demo-test.firebasestorage.app" },
377+
(event) => {
378+
console.log("Object archived in bucket:", event.data.bucket);
379+
console.log(" Name:", event.data.name);
380+
console.log(" Storage Class:", event.data.storageClass);
381+
}
382+
);
383+
384+
// Storage onObjectDeleted - triggers when an object is deleted
385+
exports.onObjectDeleted_demotestfirebasestorageapp = onObjectDeleted(
386+
{ bucket: "demo-test.firebasestorage.app" },
387+
(event) => {
388+
console.log("Object deleted in bucket:", event.data.bucket);
389+
console.log(" Name:", event.data.name);
390+
}
391+
);
392+
393+
// Storage onObjectMetadataUpdated - triggers when object metadata changes
394+
exports.onObjectMetadataUpdated_demotestfirebasestorageapp = onObjectMetadataUpdated(
395+
{ bucket: "demo-test.firebasestorage.app" },
396+
(event) => {
397+
console.log("Object metadata updated in bucket:", event.data.bucket);
398+
console.log(" Name:", event.data.name);
399+
console.log(" Metadata:", event.data.metadata);
400+
}
401+
);
402+
358403
// =============================================================================
359404
// Scheduler trigger examples
360405
// =============================================================================

lib/builder.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,16 @@ class _FirebaseFunctionsVisitor extends RecursiveAstVisitor<void> {
176176
'$_pkg/src/remote_config/remote_config_namespace.dart#RemoteConfigNamespace',
177177
['onConfigUpdated'],
178178
),
179+
_Namespace(
180+
_extractStorageFunction,
181+
'$_pkg/src/storage/storage_namespace.dart#StorageNamespace',
182+
[
183+
'onObjectArchived',
184+
'onObjectFinalized',
185+
'onObjectDeleted',
186+
'onObjectMetadataUpdated',
187+
],
188+
),
179189
_Namespace(
180190
// Adapter: _extractSchedulerFunction only takes node, not the second String arg
181191
(node, _) => _extractSchedulerFunction(node),
@@ -525,6 +535,26 @@ class _FirebaseFunctionsVisitor extends RecursiveAstVisitor<void> {
525535
);
526536
}
527537

538+
/// Extracts a Storage function declaration.
539+
void _extractStorageFunction(MethodInvocation node, String methodName) {
540+
// Extract bucket name from named argument
541+
final bucketName = node.extractLiteralForArg('bucket');
542+
if (bucketName == null) return;
543+
544+
// Generate function name from bucket (strip non-alphanumeric chars for valid function ID)
545+
final sanitizedBucket = bucketName.replaceAll(RegExp('[^a-zA-Z0-9]'), '');
546+
final functionName = '${methodName}_$sanitizedBucket';
547+
548+
endpoints[functionName] = EndpointSpec(
549+
name: functionName,
550+
type: 'storage',
551+
storageBucket: bucketName,
552+
storageEventType: methodName,
553+
options: node.findOptionsArg(),
554+
variableToParamName: _variableToParamName,
555+
);
556+
}
557+
528558
/// Helper to extract alert endpoint from a method invocation.
529559
void _extractAlertEndpoint(MethodInvocation node, String alertType) {
530560
// Extract appId from options if present

lib/firebase_functions.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,5 @@ export 'src/remote_config/remote_config.dart';
8787
export 'src/scheduler/scheduler.dart';
8888
// Core runtime
8989
export 'src/server.dart' show fireUp;
90+
// Storage triggers
91+
export 'src/storage/storage.dart';

lib/src/builder/manifest.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,15 @@ void _addTrigger(
281281
'retry': false,
282282
};
283283

284+
case 'storage'
285+
when endpoint.storageEventType != null &&
286+
endpoint.storageBucket != null:
287+
map['eventTrigger'] = <String, dynamic>{
288+
'eventType': _mapStorageEventType(endpoint.storageEventType!),
289+
'eventFilters': {'bucket': endpoint.storageBucket},
290+
'retry': false,
291+
};
292+
284293
case 'scheduler' when endpoint.schedule != null:
285294
final trigger = <String, dynamic>{'schedule': endpoint.schedule};
286295
if (endpoint.timeZone != null) {
@@ -325,3 +334,12 @@ String _mapDatabaseEventType(String methodName) => switch (methodName) {
325334
'onValueWritten' => 'google.firebase.database.ref.v1.written',
326335
_ => throw ArgumentError('Unknown Database event type: $methodName'),
327336
};
337+
338+
/// Maps Storage method name to CloudEvent event type.
339+
String _mapStorageEventType(String methodName) => switch (methodName) {
340+
'onObjectArchived' => 'google.cloud.storage.object.v1.archived',
341+
'onObjectFinalized' => 'google.cloud.storage.object.v1.finalized',
342+
'onObjectDeleted' => 'google.cloud.storage.object.v1.deleted',
343+
'onObjectMetadataUpdated' => 'google.cloud.storage.object.v1.metadataUpdated',
344+
_ => throw ArgumentError('Unknown Storage event type: $methodName'),
345+
};

lib/src/builder/spec.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,13 @@ class EndpointSpec {
5050
this.schedule,
5151
this.timeZone,
5252
this.retryConfig,
53+
this.storageBucket,
54+
this.storageEventType,
5355
this.options,
5456
this.variableToParamName = const {},
5557
});
5658
final String name;
57-
// 'https', 'callable', 'pubsub', 'firestore', 'database', 'alert', 'blocking', 'scheduler'
59+
// 'https', 'callable', 'pubsub', 'firestore', 'database', 'alert', 'blocking', 'scheduler', 'storage'
5860
final String type;
5961
final String? topic; // For Pub/Sub functions
6062
final String? firestoreEventType; // For Firestore: onDocumentCreated, etc.
@@ -73,6 +75,8 @@ class EndpointSpec {
7375
final String? schedule; // For Scheduler: cron expression
7476
final String? timeZone; // For Scheduler: timezone
7577
final Map<String, dynamic>? retryConfig; // For Scheduler: retry configuration
78+
final String? storageBucket; // For Storage: bucket name
79+
final String? storageEventType; // For Storage: onObjectFinalized, etc.
7680
final InstanceCreationExpression? options;
7781
final Map<String, String> variableToParamName;
7882

0 commit comments

Comments
 (0)