Skip to content

Commit

Permalink
Merge c1bd41e into b9accc3
Browse files Browse the repository at this point in the history
  • Loading branch information
Alex Memering committed Apr 10, 2019
2 parents b9accc3 + c1bd41e commit 1dec052
Show file tree
Hide file tree
Showing 7 changed files with 484 additions and 90 deletions.
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
fixed - Fixes a bug in Cloud Firestore emulator where rulesets with no-op writes would modify a document's updated_at timestamp.
fixed - Reduced lock contention in Cloud Firestore emulator during concurrent writes to a document.
feature - Serving Hosting locally can now proxy to the live Cloud Run service.
1 change: 1 addition & 0 deletions src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ var api = {
"FIREBASE_HOSTING_API_URL",
"https://firebasehosting.googleapis.com"
),
cloudRunApiOrigin: utils.envOverride("CLOUD_RUN_API_URL", "https://run.googleapis.com"),

setRefreshToken: function(token) {
refreshToken = token;
Expand Down
75 changes: 75 additions & 0 deletions src/hosting/cloudRunProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { RequestHandler } from "express";
import { get } from "lodash";

import { errorRequestHandler, proxyRequestHandler } from "./proxy";
import * as getProjectId from "../getProjectId";
import * as logger from "../logger";
import { cloudRunApiOrigin, request as apiRequest } from "../api";

export interface CloudRunProxyOptions {
project?: string;
}

export interface CloudRunProxyRewrite {
run: {
serviceId: string;
region?: string;
};
}

const cloudRunCache: { [s: string]: string } = {};

function getCloudRunUrl(rewrite: CloudRunProxyRewrite, projectId: string): Promise<string> {
const alreadyFetched = cloudRunCache[`${rewrite.run.region}/${rewrite.run.serviceId}`];
if (alreadyFetched) {
return Promise.resolve(alreadyFetched);
}

const path = `/v1alpha1/projects/${projectId}/locations/${rewrite.run.region ||
"us-central1"}/services/${rewrite.run.serviceId}`;
logger.info(`[hosting] Looking up Cloud Run service "${path}" for its URL`);
return apiRequest("GET", path, { origin: cloudRunApiOrigin, auth: true })
.then((res) => {
const url = get(res, "body.status.address.hostname");
if (!url) {
return Promise.reject("Cloud Run URL doesn't exist in response.");
}

cloudRunCache[`${rewrite.run.region}/${rewrite.run.serviceId}`] = url;
return url;
})
.catch((err) => {
const errInfo = `error looking up URL for Cloud Run service: ${err}`;
return Promise.reject(errInfo);
});
}

/**
* Returns a function which, given a CloudRunProxyRewrite, returns a Promise
* that resolves with a middleware-like function that proxies the request to
* the live Cloud Run service running within the given project.
*/
export default function(
options: CloudRunProxyOptions
): (r: CloudRunProxyRewrite) => Promise<RequestHandler> {
return async (rewrite: CloudRunProxyRewrite) => {
if (!rewrite.run) {
// SuperStatic wouldn't send it here, but we should check
return errorRequestHandler('Cloud Run rewrites must have a valid "run" field.');
}
if (!rewrite.run.serviceId) {
return errorRequestHandler("Cloud Run rewrites must supply a service ID.");
}
if (!rewrite.run.region) {
rewrite.run.region = "us-central1"; // Default region
}
logger.info(`[hosting] Cloud Run rewrite ${JSON.stringify(rewrite)} triggered`);

const textIdentifier = `Cloud Run service "${rewrite.run.serviceId}" for region "${
rewrite.run.region
}"`;
return getCloudRunUrl(rewrite, getProjectId(options, false))
.then((url) => proxyRequestHandler(url, textIdentifier))
.catch(errorRequestHandler);
};
}
94 changes: 4 additions & 90 deletions src/hosting/functionsProxy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { capitalize, includes } from "lodash";
import { Request, RequestHandler, Response } from "express";
import * as request from "request";
import { includes } from "lodash";
import { RequestHandler } from "express";

import { proxyRequestHandler } from "./proxy";
import * as getProjectId from "../getProjectId";
import * as logger from "../logger";

export interface FunctionsProxyOptions {
port: number;
Expand All @@ -15,29 +14,6 @@ export interface FunctionProxyRewrite {
function: string;
}

const REQUIRED_VARY_VALUES = ["Accept-Encoding", "Authorization", "Cookie"];

function makeVary(vary?: string): string {
if (!vary) {
return "Accept-Encoding, Authorization, Cookie";
}

const varies = vary.split(/, ?/).map((v) => {
return v
.split("-")
.map((part) => capitalize(part))
.join("-");
});

REQUIRED_VARY_VALUES.forEach((requiredVary) => {
if (!includes(varies, requiredVary)) {
varies.push(requiredVary);
}
});

return varies.join(", ");
}

/**
* Returns a function which, given a FunctionProxyRewrite, returns a Promise
* that resolves with a middleware-like function that proxies the request to a
Expand All @@ -57,69 +33,7 @@ export default function(
rewrite.function
}`;
}
return await ((req: Request, res: Response, next: () => void): any => {
logger.info(`[hosting] Rewriting ${req.url} to ${destLabel} function ${rewrite.function}`);
// Extract the __session cookie from headers to forward it to the functions
// cookie is not a string[].
const cookie = (req.headers.cookie as string) || "";
const sessionCookie = cookie.split(/; ?/).find((c: string) => {
return c.trim().indexOf("__session=") === 0;
});

const proxied = request({
method: req.method,
qs: req.query,
url: url + req.url,
headers: {
"X-Forwarded-Host": req.headers.host,
"X-Original-Url": req.url,
Pragma: "no-cache",
"Cache-Control": "no-cache, no-store",
// forward the parsed __session cookie if any
Cookie: sessionCookie,
},
followRedirect: false,
timeout: 60000,
});

req.pipe(proxied);

// err here is `any` in order to check `.code`
proxied.on("error", (err: any) => {
if (err.code === "ETIMEDOUT" || err.code === "ESOCKETTIMEDOUT") {
res.statusCode = 504;
return res.end("Timed out waiting for function to respond.");
}

res.statusCode = 500;
res.end(
`An internal error occurred while connecting to Cloud Function "${rewrite.function}"`
);
});

proxied.on("response", (response) => {
if (response.statusCode === 404) {
// x-cascade is not a string[].
const cascade = response.headers["x-cascade"] as string;
if (cascade && cascade.toUpperCase() === "PASS") {
return next();
}
}

// default to private cache
if (!response.headers["cache-control"]) {
response.headers["cache-control"] = "private";
}

// don't allow cookies to be set on non-private cached responses
if (response.headers["cache-control"].indexOf("private") < 0) {
delete response.headers["set-cookie"];
}

response.headers.vary = makeVary(response.headers.vary);

proxied.pipe(res);
});
});
return await proxyRequestHandler(url, `${destLabel} Function ${rewrite.function}`);
};
}
113 changes: 113 additions & 0 deletions src/hosting/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { capitalize, includes } from "lodash";
import { Request, RequestHandler, Response } from "express";
import * as request from "request";

import * as logger from "../logger";

const REQUIRED_VARY_VALUES = ["Accept-Encoding", "Authorization", "Cookie"];

function makeVary(vary?: string): string {
if (!vary) {
return "Accept-Encoding, Authorization, Cookie";
}

const varies = vary.split(/, ?/).map((v) => {
return v
.split("-")
.map((part) => capitalize(part))
.join("-");
});

REQUIRED_VARY_VALUES.forEach((requiredVary) => {
if (!includes(varies, requiredVary)) {
varies.push(requiredVary);
}
});

return varies.join(", ");
}

/**
* Returns an Express RequestHandler that will proxy the given request to a new
* URL. Provide a rewriteIdentifier to help identify what triggered the proxy
* when writing out logs or errors. This makes some minor changes to headers,
* cookies, and caching similar to the behavior of the production version of
* the Firebase Hosting origin.
*/
export function proxyRequestHandler(url: string, rewriteIdentifier: string): RequestHandler {
return (req: Request, res: Response, next: () => void): any => {
logger.info(`[hosting] Rewriting ${req.url} to ${url} for ${rewriteIdentifier}`);
// Extract the __session cookie from headers to forward it to the
// functions cookie is not a string[].
const cookie = (req.headers.cookie as string) || "";
const sessionCookie = cookie.split(/; ?/).find((c: string) => {
return c.trim().indexOf("__session=") === 0;
});

const proxied = request({
method: req.method,
qs: req.query,
url: url + req.url,
headers: {
"X-Forwarded-Host": req.headers.host,
"X-Original-Url": req.url,
Pragma: "no-cache",
"Cache-Control": "no-cache, no-store",
// forward the parsed __session cookie if any
Cookie: sessionCookie,
},
followRedirect: false,
timeout: 60000,
});

req.pipe(proxied);

// err here is `any` in order to check `.code`
proxied.on("error", (err: any) => {
if (err.code === "ETIMEDOUT" || err.code === "ESOCKETTIMEDOUT") {
res.statusCode = 504;
return res.end("Timed out waiting for function to respond.");
}

res.statusCode = 500;
res.end(`An internal error occurred while proxying for ${rewriteIdentifier}`);
});

proxied.on("response", (response) => {
if (response.statusCode === 404) {
// x-cascade is not a string[].
const cascade = response.headers["x-cascade"] as string;
if (cascade && cascade.toUpperCase() === "PASS") {
return next();
}
}

// default to private cache
if (!response.headers["cache-control"]) {
response.headers["cache-control"] = "private";
}

// don't allow cookies to be set on non-private cached responses
if (response.headers["cache-control"].indexOf("private") < 0) {
delete response.headers["set-cookie"];
}

response.headers.vary = makeVary(response.headers.vary);

proxied.pipe(res);
});
};
}

/**
* Returns an Express RequestHandler that will both log out the error and
* return an internal HTTP error response.
*/
export function errorRequestHandler(error: string): RequestHandler {
return (req: Request, res: Response, next: () => void): any => {
res.statusCode = 500;
const out = `A problem occurred while trying to handle a proxied rewrite: ${error}`;
logger.error(out);
res.end(out);
};
}
2 changes: 2 additions & 0 deletions src/serve/hosting.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ var detectProjectRoot = require("../detectProjectRoot");
var implicitInit = require("../hosting/implicitInit");
var initMiddleware = require("../hosting/initMiddleware");
var functionsProxy = require("../hosting/functionsProxy").default;
var cloudRunProxy = require("../hosting/cloudRunProxy").default;
var normalizedHostingConfigs = require("../hosting/normalizedHostingConfigs");

var MAX_PORT_ATTEMPTS = 10;
Expand All @@ -28,6 +29,7 @@ function _startServer(options, config, port, init) {
},
rewriters: {
function: functionsProxy(options),
run: cloudRunProxy(options),
},
}).listen(function() {
var siteName = config.target || config.site;
Expand Down

0 comments on commit 1dec052

Please sign in to comment.