Skip to content

Commit 809fe46

Browse files
authored
Allow passing an alternative public endpoint for Firebase and handle destination rewrites (#33)
* Allow passing an alternative public endpoint for Firebase * Fix assigning publicEndpoint * Improve globs parsing by using picomatch and parse destinations rewrites * Rewrite matched destination URLs * Move jest config to file and setup environment * Add license to package.json * Add picomatch declaration file and update usage * Add tests for firebase * Add more tests * Ensure reserved URLs are not rewritten
1 parent bf8004c commit 809fe46

File tree

12 files changed

+334
-60
lines changed

12 files changed

+334
-60
lines changed

.nvmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
14.15.1

jest.config.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
module.exports = {
2+
roots: ['<rootDir>/src'],
3+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
4+
transform: {
5+
'^.+\\.tsx?$': 'ts-jest'
6+
},
7+
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
8+
// testEnvironment: 'jsdom',
9+
setupFiles: [
10+
'./jest.setup.js'
11+
]
12+
};

jest.setup.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Setup mocks for Request/Response
2+
const makeServiceWorkerEnv = require('service-worker-mock');
3+
4+
Object.assign(global, makeServiceWorkerEnv());

package.json

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
{
22
"name": "proxies-on-cloudflare",
33
"main": "./dist/index.js",
4+
"license": "Apache-2.0",
45
"version": "0.3.4",
5-
"dependencies": {},
6+
"dependencies": {
7+
"picomatch": "^2.3.0"
8+
},
69
"files": [
710
"dist/*"
811
],
912
"devDependencies": {
1013
"@types/jest": "^23.3.11",
1114
"jest": "^23.6.0",
1215
"prettier": "^1.15.3",
16+
"service-worker-mock": "^2.0.5",
1317
"ts-jest": "^23.10.5",
1418
"tslint": "^5.20.0",
1519
"tslint-config-prettier": "^1.17.0",
@@ -18,24 +22,7 @@
1822
},
1923
"scripts": {
2024
"prepare": "tsc",
21-
"test": "jest",
25+
"test": "jest --config=jest.config.js",
2226
"lint": "tslint ./src/**/*.ts"
23-
},
24-
"jest": {
25-
"roots": [
26-
"<rootDir>/src"
27-
],
28-
"transform": {
29-
"^.+\\.tsx?$": "ts-jest"
30-
},
31-
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
32-
"moduleFileExtensions": [
33-
"ts",
34-
"tsx",
35-
"js",
36-
"jsx",
37-
"json",
38-
"node"
39-
]
4027
}
4128
}

src/__tests__/firebase.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { Firebase } from '../firebase';
2+
3+
const firebase = new Firebase(
4+
'firebase-project',
5+
{
6+
rewrites: [
7+
{
8+
source: '/function-with-glob/**',
9+
function: 'glob-function'
10+
},
11+
{
12+
source: '/exact-path',
13+
function: 'exact-function'
14+
},
15+
{
16+
source: '**/!(*.js)',
17+
destination: '/index.html'
18+
}
19+
]
20+
},
21+
{
22+
headers: {
23+
'Some-Header': 'My-Firebase-Header'
24+
},
25+
publicEndpoint: new URL('https://my-endpoint.com/public')
26+
}
27+
);
28+
29+
describe('getEndpoint', () => {
30+
it('should return the function URL that match a glob with a path', async () => {
31+
const endpointURL = firebase.getEndpoint(
32+
new Request('https://myapp.com/function-with-glob/with/path')
33+
);
34+
35+
expect(endpointURL.toString()).toBe(
36+
'https://us-central1-firebase-project.cloudfunctions.net/glob-function'
37+
);
38+
});
39+
40+
it('should return the function URL that match a glob without a path', async () => {
41+
const endpointURL = firebase.getEndpoint(
42+
new Request('https://myapp.com/function-with-glob')
43+
);
44+
45+
expect(endpointURL.toString()).toBe(
46+
'https://us-central1-firebase-project.cloudfunctions.net/glob-function'
47+
);
48+
});
49+
50+
it('should return the function URL that has an exact match', async () => {
51+
const endpointURL = firebase.getEndpoint(
52+
new Request('https://myapp.com/exact-path')
53+
);
54+
55+
expect(endpointURL.toString()).toBe(
56+
'https://us-central1-firebase-project.cloudfunctions.net/exact-function'
57+
);
58+
});
59+
60+
it('should return the public endpoint URL when not an exact match', async () => {
61+
const endpointURL = firebase.getEndpoint(
62+
new Request('https://myapp.com/exact-path/with/more')
63+
);
64+
65+
expect(endpointURL.toString()).toBe('https://my-endpoint.com/public');
66+
});
67+
68+
it('should return the default Firebase hosting endpoint for reserved requests', async () => {
69+
const endpointURL = firebase.getEndpoint(
70+
new Request('https://myapp.com/__/some/firebase/request')
71+
);
72+
73+
expect(endpointURL.toString()).toBe(
74+
'https://firebase-project.firebaseapp.com/'
75+
);
76+
});
77+
78+
it('should return the public endpoint URL for any other request', async () => {
79+
const endpointURL = firebase.getEndpoint(
80+
new Request('https://myapp.com/some/file.js')
81+
);
82+
83+
expect(endpointURL.toString()).toBe('https://my-endpoint.com/public');
84+
});
85+
});
86+
87+
describe('rewriteURL', () => {
88+
it('should not rewrite an URL that matches a rewrite rule', async () => {
89+
const finalURL = firebase.rewriteURL(
90+
new URL('https://myapp.com/some/js-file.js')
91+
);
92+
93+
expect(finalURL.toString()).toBe('https://myapp.com/some/js-file.js');
94+
});
95+
96+
it('should rewrite an URL that has a rewrite rule', async () => {
97+
const finalURL = firebase.rewriteURL(
98+
new URL('https://myapp.com/some/non-js-file.html')
99+
);
100+
101+
expect(finalURL.toString()).toBe('https://myapp.com/index.html');
102+
});
103+
104+
it('should not rewrite Firebase reserved requests', async () => {
105+
const finalURL = firebase.rewriteURL(
106+
new URL('https://myapp.com/__/some/firebase/request')
107+
);
108+
109+
expect(finalURL.toString()).toBe(
110+
'https://myapp.com/__/some/firebase/request'
111+
);
112+
});
113+
114+
it('should not rewrite an URL that matches a function', async () => {
115+
const finalURL = firebase.rewriteURL(
116+
new URL('https://myapp.com/function-with-glob/and/path')
117+
);
118+
119+
expect(finalURL.toString()).toBe(
120+
'https://myapp.com/function-with-glob/and/path'
121+
);
122+
});
123+
});

src/firebase/index.ts

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ interface ExtraOptions {
1212
// Seed (string) of our cache hash
1313
// changing the seed will invalidate all previous entries
1414
seed?: string;
15+
// Custom endpoint to fetch public files from instead of Firebase hosting
16+
publicEndpoint?: URL;
1517
}
1618
interface HeaderOptions {
1719
[key: string]: string | null;
@@ -29,9 +31,10 @@ export default function firebase(
2931
return fbase.serve.bind(fbase);
3032
}
3133

32-
class Firebase {
34+
export class Firebase {
3335
public matcher: Matcher;
3436
public projectID: string;
37+
public publicEndpoint: URL;
3538
public hostingEndpoint: URL;
3639
public globalHeaders: HeaderOptions;
3740
public proxy: ServeFunction;
@@ -44,12 +47,22 @@ class Firebase {
4447
this.matcher = new Matcher(config.rewrites);
4548
// Static Hosting endpoint
4649
this.hostingEndpoint = fbhostingEndpoint(projectID);
50+
// Endpoint for public files in hosting, can be overriden in extra options
51+
this.publicEndpoint =
52+
extra && extra.publicEndpoint
53+
? extra.publicEndpoint
54+
: this.hostingEndpoint;
4755
// Custom headers
4856
this.globalHeaders = extra && extra.headers ? extra.headers : {};
4957
// Cache seed
5058
this.seed = extra && extra.seed ? extra.seed : '42';
5159
// Proxy
52-
this.proxy = cache(customProxy(req => this.getEndpoint(req!)), this.seed);
60+
this.proxy = cache(
61+
customProxy(req => this.getEndpoint(req!), {
62+
rewriteURL: url => this.rewriteURL(url)
63+
}),
64+
this.seed
65+
);
5366
}
5467

5568
public async serve(event: FetchEvent): Promise<Response> {
@@ -71,18 +84,44 @@ class Firebase {
7184
const url = new URL(request.url);
7285
const pathname = url.pathname;
7386

87+
// If reserved, pass through to the original FirebaseHosting application endpoint
88+
if (isReserved(pathname)) {
89+
return this.hostingEndpoint;
90+
}
91+
7492
// Get cloud func for path
75-
const funcname = this.matcher.match(pathname);
93+
const match = this.matcher.match(pathname);
94+
// If no func matched, we're looking for a public file in Firebase hosting, pass through
95+
if (!match || !('function' in match)) {
96+
return this.publicEndpoint;
97+
}
7698

77-
// Is this URL part of Firebase's reserved /__/* namespace
78-
const isReserved = pathname.startsWith('/__/');
99+
// Route to specific cloud function
100+
return cloudfuncEndpoint(this.projectID, match.function);
101+
}
79102

80-
// If no func matched or reserved, pass through to FirebaseHosting
81-
if (isReserved || !funcname) {
82-
return this.hostingEndpoint;
103+
/**
104+
* Rewrite URL's pathname to match configured destination
105+
*/
106+
public rewriteURL(url: URL): URL {
107+
// If reserved, we never modify the request
108+
if (isReserved(url.pathname)) {
109+
return url;
83110
}
84111

85-
// Route to specific cloud function
86-
return cloudfuncEndpoint(this.projectID, funcname);
112+
const match = this.matcher.match(url.pathname);
113+
if (!match || !('destination' in match)) {
114+
return url;
115+
}
116+
117+
url.pathname = match.destination;
118+
return url;
87119
}
88120
}
121+
122+
/**
123+
* Is this URL part of Firebase's reserved /__/* namespace
124+
*/
125+
function isReserved(pathname: string): boolean {
126+
return pathname.startsWith('/__/');
127+
}

src/firebase/picomatch.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
declare module 'picomatch' {
2+
export function isMatch(str: string, glob: string): boolean;
3+
}

src/firebase/rewrites.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
1-
import { globToRegex, isGlob } from '../globs';
1+
import * as pm from 'picomatch';
2+
import { isGlob } from '../globs';
23
import { FirebaseRewrites } from './types';
34

4-
interface GlobMatch {
5-
regex: RegExp;
5+
interface FunctionMatch {
66
function: string;
77
}
88

9+
interface DestinationMatch {
10+
destination: string;
11+
}
12+
13+
type RewriteMatch = FunctionMatch | DestinationMatch;
14+
15+
interface GlobMatch {
16+
// Matcher for a path
17+
matcher: (path: string) => boolean;
18+
match: RewriteMatch;
19+
}
20+
921
interface ExactMatches {
10-
[key: string]: string;
22+
[path: string]: RewriteMatch;
1123
}
1224

1325
export class Matcher {
@@ -19,40 +31,40 @@ export class Matcher {
1931
this.globs = rewrites
2032
.filter(r => isGlob(r.source))
2133
.map(r => ({
22-
regex: globToRegex(pathGlob(r.source)),
23-
function: r.function
34+
matcher: (path: string) => pm.isMatch(path, r.source),
35+
match:
36+
'function' in r
37+
? { function: r.function }
38+
: { destination: r.destination }
2439
}));
2540

2641
// Dict of exact matches
2742
this.exacts = rewrites
2843
.filter(r => !isGlob(r.source))
2944
.reduce<ExactMatches>((accu, r) => {
30-
accu[r.source] = r.function;
45+
accu[r.source] =
46+
'function' in r
47+
? { function: r.function }
48+
: { destination: r.destination };
3149
return accu;
3250
}, {});
3351
}
3452

3553
// Matching function, converting path to func name, null if no match
36-
public match(path: string): string | null {
54+
public match(path: string): RewriteMatch | null {
3755
// Try exact match
3856
if (path in this.exacts) {
3957
return this.exacts[path];
4058
}
4159

4260
// Globs
4361
for (const glob of this.globs) {
44-
if (glob.regex.test(path)) {
45-
return glob.function;
62+
if (glob.matcher(path)) {
63+
return glob.match;
4664
}
4765
}
4866

4967
// No function found
5068
return null;
5169
}
5270
}
53-
54-
// pathGlob ensure that all path glob expressions start with a /
55-
function pathGlob(expr: string): string {
56-
// Ensure path starts with '/'
57-
return expr.replace(/^\/?/, '/');
58-
}

src/firebase/types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,16 @@ export interface FirebaseConfig {
44

55
export interface FirebaseRewrites extends Array<FirebaseRewrite> {}
66

7-
export interface FirebaseRewrite {
7+
export interface FirebaseFunctionRewrite {
88
source: string;
99
function: string;
1010
}
11+
12+
export interface FirebaseDestinationRewrite {
13+
source: string;
14+
destination: string;
15+
}
16+
17+
export type FirebaseRewrite =
18+
| FirebaseFunctionRewrite
19+
| FirebaseDestinationRewrite;

0 commit comments

Comments
 (0)