Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ yarn-error.log

# Firebase
.firebase/
.firebaserc
firebase-debug.log
firestore-debug.log
ui-debug.log
Expand Down
38 changes: 38 additions & 0 deletions example/hosting/bin/server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'package:firebase_functions/firebase_functions.dart';

void main() async {
await runFunctions((firebase) {
// An onRequest function used as a backend for Firebase Hosting rewrites.
// All requests to the hosted site are forwarded to this function, which
// handles routing based on the request path.
//
// firebase.json configures the rewrite:
// { "source": "**", "run": { "serviceId": "app", "region": "us-central1" } }
firebase.https.onRequest(
name: 'app',
options: const HttpsOptions(invoker: Invoker.public()),
(request) async {
final path = request.requestedUri.path;
return switch (path) {
'/' => Response.ok('Home page'),
'/about' => Response.ok('About page'),
_ => Response.notFound('Not found: $path'),
};
},
);
});
}
33 changes: 33 additions & 0 deletions example/hosting/firebase.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"functions": [
{
"source": ".",
"codebase": "default",
"runtime": "dart3"
}
],
"hosting": {
"public": "public",
"rewrites": [
{
"source": "**",
"run": {
"serviceId": "app",
"region": "us-central1"
}
}
]
},
"emulators": {
"functions": {
"port": 5001
},
"hosting": {
"port": 5002
},
"ui": {
"enabled": true
},
"singleProjectMode": true
}
}
Empty file added example/hosting/public/.gitkeep
Empty file.
29 changes: 29 additions & 0 deletions example/hosting/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

name: hosting_example
description: Firebase Hosting rewrites example for Firebase Functions for Dart
publish_to: none

resolution: workspace

environment:
sdk: ^3.7.0

dependencies:
firebase_functions: ^0.7.0-wip

dev_dependencies:
build_runner: ^2.10.5
lints: ^6.0.0
170 changes: 83 additions & 87 deletions lib/src/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ Future<void> runFunctions(FunctionsRunner runner) async {
});
}

/// Creates a shelf [Handler] for [firebase] without starting an HTTP server.
///
/// Use in tests to exercise the full routing pipeline without binding a port.
@visibleForTesting
Handler createTestHandler(Firebase firebase) =>
(request) => _routeRequest(request, firebase, firebase.$env);

/// CORS middleware for emulator mode.
Handler _corsMiddleware(Handler innerHandler) => (request) {
// Handle preflight OPTIONS requests
Expand Down Expand Up @@ -237,7 +244,6 @@ FutureOr<Response> _routeToTargetFunction(
return response;
}

/// Routes request by path matching (development/shared process mode).
FutureOr<Response> _routeByPath(
Request request,
List<FirebaseFunctionDeclaration> functions,
Expand All @@ -261,47 +267,80 @@ FutureOr<Response> _routeByPath(
currentRequest = reconstructedRequest;
}

// Not a CloudEvent, try path-based routing for HTTPS functions
// Extract the function name from the path (/{functionName})
// The firebase-tools emulator sets X-Firebase-Function header for Dart runtimes
var functionName = _extractFunctionName(requestPath);

// Fallback: Check for X-Firebase-Function header set by firebase-tools
if (functionName.isEmpty) {
functionName = currentRequest.headers['x-firebase-function'] ?? '';
// Not a CloudEvent — route to a registered HTTPS function.
//
// The functions emulator always forwards to the Dart process with the path
// stripped to /{functionName}[/{rest}], so parts[0] is the function name.
// For direct calls that bypass the emulator, the format is
// /{project}/{region}/{functionName}[/{rest}], so parts[2] is the function
// name. We resolve the ambiguity by checking each registered function name
// against the path segments rather than guessing from segment count.
var normalPath = requestPath;
if (normalPath.startsWith('/')) normalPath = normalPath.substring(1);
if (normalPath.endsWith('/')) {
normalPath = normalPath.substring(0, normalPath.length - 1);
}
final parts = normalPath.isEmpty ? <String>[] : normalPath.split('/');

// X-Firebase-Function header is set by firebase-tools for hosting rewrites.
final xFirebaseFunction = currentRequest.headers['x-firebase-function'];

// Try to find a matching function by name
for (final function in functions) {
if (functionName == function.name) {
if (currentRequest.method.toUpperCase() == 'OPTIONS' &&
function.allowedOrigins != null) {
return _buildOptionsCorsResponse(
currentRequest,
function.allowedOrigins!,
);
String? originalPath;

if (xFirebaseFunction != null) {
// Header explicitly identifies the function; use it.
if (function.name != xFirebaseFunction) continue;
if (parts.isNotEmpty && parts[0] == function.name) {
final rest = parts.sublist(1).join('/');
originalPath = rest.isEmpty ? '/' : '/$rest';
} else {
originalPath = '/';
}
} else if (parts.isNotEmpty && parts[0] == function.name) {
// /{functionName}[/{rest}] — emulator routing
final rest = parts.sublist(1).join('/');
originalPath = rest.isEmpty ? '/' : '/$rest';
} else if (parts.length >= 3 && parts[2] == function.name) {
// /{project}/{region}/{functionName}[/{rest}] — direct call
final rest = parts.length > 3 ? parts.sublist(3).join('/') : '';
originalPath = rest.isEmpty ? '/' : '/$rest';
} else {
continue;
}

if (!function.external && currentRequest.method.toUpperCase() != 'POST') {
continue;
}
if (currentRequest.method.toUpperCase() == 'OPTIONS' &&
function.allowedOrigins != null) {
return _buildOptionsCorsResponse(
currentRequest,
function.allowedOrigins!,
);
}

final wrappedHandler = withInit(function.handler);
final response = await wrappedHandler(currentRequest);
if (function.allowedOrigins != null) {
return _applyCorsHeaders(
currentRequest,
response,
function.allowedOrigins!,
);
}
return response;
if (!function.external && currentRequest.method.toUpperCase() != 'POST') {
continue;
}

// Reconstruct the request with the original path so handlers see the same
// path they would in production Cloud Run.
final handlerRequest = _withOriginalPath(currentRequest, originalPath);

final wrappedHandler = withInit(function.handler);
final response = await wrappedHandler(handlerRequest);
if (function.allowedOrigins != null) {
return _applyCorsHeaders(
handlerRequest,
response,
function.allowedOrigins!,
);
}
return response;
}

// No matching function found
// No matching function found.
final notFoundName = xFirebaseFunction ?? (parts.isNotEmpty ? parts[0] : '');
return Response.notFound(
'Function not found: $functionName\n'
'Function not found: $notFoundName\n'
'Available functions: ${functions.map((f) => f.name).join(", ")}',
);
}
Expand Down Expand Up @@ -540,61 +579,18 @@ Future<(Request, FirebaseFunctionDeclaration?)> _tryMatchCloudEventFunction(
}
}

/// Extracts the function name from a request path.
///
/// Handles different path formats:
/// - Event triggers: /functions/projects/{project}/triggers/{triggerId} -> {entryPoint}
/// - HTTPS functions: /{functionName} -> {functionName}
/// - HTTPS with project/region: /{project}/{region}/{functionName} -> {functionName}
///
/// For event triggers, the triggerId may include region prefix like "us-central1-functionName"
/// We need to extract just the function name part.
String _extractFunctionName(String requestPath) {
// Remove leading and trailing slashes
var path = requestPath;
if (path.startsWith('/')) {
path = path.substring(1);
}
if (path.endsWith('/')) {
path = path.substring(0, path.length - 1);
}

// Event trigger path: functions/projects/{project}/triggers/{triggerId}
if (path.startsWith('functions/projects/')) {
final parts = path.split('/');
if (parts.length >= 5 && parts[3] == 'triggers') {
// Extract trigger ID from: functions/projects/{project}/triggers/{triggerId}
var triggerId = parts[4];

// Firebase-tools prefixes trigger IDs with region (e.g., "us-central1-functionName")
// and may add suffixes (e.g., "us-central1-functionName-0")
// We need to strip these to get the actual function entry point name.

// Remove region prefix (e.g., "us-central1-", "europe-west1-")
triggerId = triggerId.replaceFirst(RegExp(r'^[a-z]+-[a-z]+\d+-'), '');

// Remove numeric suffix (e.g., "-0", "-1")
triggerId = triggerId.replaceFirst(RegExp(r'-\d+$'), '');

return triggerId;
}
}

// HTTPS path: {project}/{region}/{functionName} or just {functionName}
final parts = path.split('/');

// If path has 3 parts, assume {project}/{region}/{functionName}
if (parts.length == 3) {
return parts[2];
}

// If path has 1 part, it's just {functionName}
if (parts.length == 1) {
return parts[0];
}

// Return the last part as function name
return parts.isNotEmpty ? parts.last : path;
/// Creates a copy of [request] with [originalPath] set on its `requestedUri`
/// so that handlers see the original client path rather than the routing prefix
/// added by the emulator. Returns [request] unchanged if the path already matches.
Request _withOriginalPath(Request request, String originalPath) {
if (request.requestedUri.path == originalPath) return request;
return Request(
request.method,
request.requestedUri.replace(path: originalPath),
headers: request.headers,
body: request.read(),
context: request.context,
);
}

/// Handles the /__/quitquitquit graceful shutdown endpoint.
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ environment:
sdk: ^3.9.0

workspace:
- example/hosting
- example/https
- example/firestore
- example/pubsub
Expand Down
8 changes: 8 additions & 0 deletions test/e2e/e2e_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ import 'helpers/auth_client.dart';
import 'helpers/database_client.dart';
import 'helpers/emulator.dart';
import 'helpers/firestore_client.dart';
import 'helpers/hosting_client.dart';
import 'helpers/http_client.dart';
import 'helpers/pubsub_client.dart';
import 'helpers/storage_client.dart';
import 'helpers/test_client_base.dart';
import 'tests/database_tests.dart';
import 'tests/firestore_tests.dart';
import 'tests/hosting_tests.dart';
import 'tests/https_oncall_tests.dart';
import 'tests/https_onrequest_tests.dart';
import 'tests/identity_tests.dart';
Expand All @@ -42,6 +44,7 @@ import 'tests/storage_tests.dart';
void main() {
EmulatorHelper? emulator;
FunctionsHttpClient? client;
HostingHttpClient? hostingClient;
PubSubClient? pubsubClient;
FirestoreClient? firestoreClient;
DatabaseClient? databaseClient;
Expand Down Expand Up @@ -95,6 +98,9 @@ void main() {
// Create HTTP client
client = FunctionsHttpClient(emulator!.functionsUrl);

// Create Hosting client
hostingClient = HostingHttpClient(emulator!.hostingUrl);

// Create Pub/Sub client
pubsubClient = PubSubClient(emulator!.pubsubUrl, 'demo-test');

Expand Down Expand Up @@ -127,6 +133,7 @@ void main() {
try {
for (final client in <TestClientBase>[
?client,
?hostingClient,
?pubsubClient,
?firestoreClient,
?databaseClient,
Expand All @@ -146,6 +153,7 @@ void main() {

// Run all test groups (pass closures to defer value access)
runHttpsOnRequestTests(() => client!, () => emulator!);
runHostingTests(() => hostingClient!);
runHttpsOnCallTests(() => client!);
runIntegrationTests(() => examplePath);
runPubSubTests(() => examplePath, () => pubsubClient!, () => emulator!);
Expand Down
Loading
Loading