From 0d1a901998d199dbb176ac7948696e435d1e35bb Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sun, 14 Feb 2021 10:26:03 -0800 Subject: [PATCH 1/3] Add initial redirect support based on Oak --- example/fileset.yaml | 9 ++ src/commands/upload.ts | 19 ++-- src/manifest.ts | 27 +++++- src/redirects.test.ts | 38 ++++++++ src/redirects.ts | 215 +++++++++++++++++++++++++++++++++++++++++ src/server.test.ts | 22 ++--- src/server.ts | 34 ++++++- src/upload.ts | 12 ++- 8 files changed, 345 insertions(+), 31 deletions(-) create mode 100644 src/redirects.test.ts create mode 100644 src/redirects.ts diff --git a/example/fileset.yaml b/example/fileset.yaml index d811de7..8637be7 100644 --- a/example/fileset.yaml +++ b/example/fileset.yaml @@ -2,3 +2,12 @@ google_cloud_project: site: schedule: default: master + +redirects: +- from: /foo + to: /bar + permanent: True +- from: /intl/:locale/ + to: /$locale/ +- from: /intl/:locale/*wildcard + to: /$locale/$wildcard \ No newline at end of file diff --git a/src/commands/upload.ts b/src/commands/upload.ts index 3689f28..3ba206c 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -1,9 +1,9 @@ import * as fs from 'fs'; import * as fsPath from 'path'; +import * as manifest from '../manifest'; import * as upload from '../upload'; import * as yaml from 'js-yaml'; -import {Manifest} from '../manifest'; import {getGitData} from '../gitdata'; interface UploadOptions { @@ -29,7 +29,7 @@ function findConfig(path: string) { // TODO: Validate config schema. const config = yaml.safeLoad(fs.readFileSync(configPath, 'utf8')) as Record< string, - string + unknown >; return config; } @@ -63,20 +63,23 @@ export class UploadCommand { throw new Error('Unable to determine the Google Cloud project.'); } - const manifest = new Manifest( - site, + const manifestObj = new manifest.Manifest( + site as string, this.options.ref || gitData.ref, this.options.branch || gitData.branch || '' ); - manifest.createFromDirectory(path); - if (!manifest.files.length) { + manifestObj.createFromDirectory(path); + if (config.redirects) { + manifestObj.setRedirects(config.redirects as manifest.Redirect[]); + } + if (!manifestObj.files.length) { console.log(`No files found in -> ${path}`); return; } upload.uploadManifest( - googleCloudProject, + googleCloudProject as string, bucket, - manifest, + manifestObj, this.options.force, ttl ); diff --git a/src/manifest.ts b/src/manifest.ts index 224a863..c11e87e 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -23,6 +23,12 @@ export interface ManifestFile { cleanPath: string; } +export interface Redirect { + from: string; + to: string; + permanent?: boolean; +} + export interface PathToHash { path?: string; } @@ -32,10 +38,12 @@ export class Manifest { ref: string; branch?: string; files: ManifestFile[]; + redirects: Redirect[]; shortSha: string; constructor(site: string, ref: string, branch?: string) { this.files = []; + this.redirects = []; this.site = site; this.ref = ref; this.shortSha = ref.slice(0, 7); @@ -49,6 +57,10 @@ export class Manifest { }); } + setRedirects(redirects: Redirect[]) { + this.redirects = redirects; + } + createHash(path: string) { const contents = fs.readFileSync(path); const hash = crypto.createHash('sha1'); @@ -60,7 +72,9 @@ export class Manifest { async addFile(path: string, dir: string) { const hash = this.createHash(path); - const cleanPath = path.replace(dir.replace(/^\\+|\\+$/g, ''), '/').replace('//', '/'); + const cleanPath = path + .replace(dir.replace(/^\\+|\\+$/g, ''), '/') + .replace('//', '/'); const manifestFile: ManifestFile = { cleanPath: cleanPath, hash: hash, @@ -70,7 +84,16 @@ export class Manifest { this.files.push(manifestFile); } - toJSON() { + async addRedirect(from: string, to: string, permanent: boolean) { + const redirect: Redirect = { + from: from, + to: to, + permanent: permanent, + }; + this.redirects.push(redirect); + } + + pathsToJSON() { const pathsToHashes: any = {}; this.files.forEach(file => { pathsToHashes[file.cleanPath] = file.hash; diff --git a/src/redirects.test.ts b/src/redirects.test.ts new file mode 100644 index 0000000..2157694 --- /dev/null +++ b/src/redirects.test.ts @@ -0,0 +1,38 @@ +import * as manifest from './manifest'; +import * as redirects from './redirects'; + +import {ExecutionContext} from 'ava'; +import test from 'ava'; + +test('Test redirects', (t: ExecutionContext) => { + const config: manifest.Redirect[] = [ + { + from: '/foo', + to: '/bar', + permanent: true, + }, + { + from: '/intl/:locale/', + to: '/$locale/', + }, + { + from: '/intl/:locale/*wildcard', + to: '/$locale/$wildcard', + }, + ]; + const routeTrie = new redirects.RouteTrie(); + config.forEach(redirect => { + const code = redirect.permanent ? 301 : 302; + const route = new redirects.RedirectRoute(code, redirect.to); + routeTrie.add(redirect.from, route); + }); + + const [route, params] = routeTrie.get('/foo'); + if (route) { + const [code, destination] = (route as redirects.RedirectRoute).getRedirect( + params + ); + t.is(code, 301); + t.is(destination, '/bar'); + } +}); diff --git a/src/redirects.ts b/src/redirects.ts new file mode 100644 index 0000000..0dc2f9d --- /dev/null +++ b/src/redirects.ts @@ -0,0 +1,215 @@ +/** + * Replaces a parameterized string, using `$var` as variable names. + * + * For example: + * + * replaceParams('/$foo/bar/$baz/', {foo: 'a', baz: 'b'}) + * => + * '/a/bar/baz/' + */ +function replaceParams(s: string, params: Record) { + const keys = Object.keys(params).sort().reverse(); + keys.forEach(key => { + const value = params[key]; + const repl = '$' + key; + s = s.replace(repl, value); + }); + + if (s.indexOf('$') !== -1) { + throw new Error('failed to replace one or more placeholders: ' + s); + } + return s; +} + +/** + * Base class for route objects. + */ +export class Route { + public locale = ''; + + setLocale(locale: string) { + this.locale = locale; + } +} + +/** + * A route that contains a redirect. + */ +export class RedirectRoute extends Route { + private readonly redirectUrlWithParams: string; + private readonly code: number; + + constructor(code: number, redirectUrlWithParams: string) { + super(); + this.redirectUrlWithParams = redirectUrlWithParams; + this.code = code; + } + + getRedirect(params: Record): [number, string] { + return [this.code, replaceParams(this.redirectUrlWithParams, params)]; + } +} + +/** + * A trie data structure that stores routes. The trie supports `:param` and + * `*wildcard` values. + */ +export class RouteTrie { + private children: Record = {}; + private paramChild?: ParamChild; + private wildcardChild?: WildcardChild; + private route?: Route; + + /** + * Adds a route to the trie. + */ + add(path: string, route: Route) { + path = this.normalizePath(path); + + // If the end was reached, save the value to the node. + if (path === '') { + this.route = route; + return; + } + + const [head, tail] = this.splitPath(path); + if (head[0] === '*') { + const paramName = head.slice(1); + this.wildcardChild = new WildcardChild(paramName, route); + return; + } + + let nextNode: RouteTrie; + if (head[0] === ':') { + if (!this.paramChild) { + const paramName = head.slice(1); + this.paramChild = new ParamChild(paramName); + } + nextNode = this.paramChild.trie; + } else { + nextNode = this.children[head]; + if (!nextNode) { + nextNode = new RouteTrie(); + this.children[head] = nextNode; + } + } + nextNode.add(tail, route); + } + + /** + * Returns a route mapped to the given path and any parameter values from the + * URL. + */ + get(path: string): [Route | undefined, Record] { + const params = {}; + const route = this.getRoute(path, params); + return [route, params]; + } + + /** + * Walks the route trie and calls a callback function for each route. + */ + walk(cb: (path: string, route: Route) => void) { + if (this.route) { + cb('/', this.route); + } + if (this.paramChild) { + const param = ':' + this.paramChild.name; + this.paramChild.trie.walk((childPath: string, route: Route) => { + const path = `/${param}${childPath}`; + cb(path, route); + }); + } + if (this.wildcardChild) { + const path = `/*${this.wildcardChild.name}`; + cb(path, this.wildcardChild.route); + } + for (const subpath of Object.keys(this.children)) { + const childTrie = this.children[subpath]; + childTrie.walk((childPath: string, childRoute: Route) => { + cb(`/${subpath}${childPath}`, childRoute); + }); + } + } + + private getRoute( + path: string, + params: Record + ): Route | undefined { + path = this.normalizePath(path); + if (path === '') { + return this.route; + } + + const [head, tail] = this.splitPath(path); + + const child = this.children[head]; + if (child) { + const route = child.getRoute(tail, params); + if (route) { + return route; + } + } + + if (this.paramChild) { + const route = this.paramChild.trie.getRoute(tail, params); + if (route) { + params[this.paramChild.name] = head; + return route; + } + } + + if (this.wildcardChild) { + params[this.wildcardChild.name] = path; + return this.wildcardChild.route; + } + + return undefined; + } + + /** + * Normalizes a path for inclusion into the route trie. + */ + private normalizePath(path: string) { + // Remove leading slashes. + return path.replace(/^\/+/g, ''); + } + + /** + * Splits the parent directory from its children, e.g.: + * + * splitPath("foo/bar/baz") -> ["foo", "bar/baz"] + */ + private splitPath(path: string): [string, string] { + const i = path.indexOf('/'); + if (i === -1) { + return [path, '']; + } + return [path.slice(0, i), path.slice(i + 1)]; + } +} + +/** + * A node in the RouteTrie for a :param child. + */ +class ParamChild { + readonly name: string; + readonly trie: RouteTrie = new RouteTrie(); + + constructor(name: string) { + this.name = name; + } +} + +/** + * A node in the RouteTrie for a *wildcard child. + */ +class WildcardChild { + readonly name: string; + readonly route: Route; + + constructor(name: string, route: Route) { + this.name = name; + this.route = route; + } +} diff --git a/src/server.test.ts b/src/server.test.ts index 2be2aab..a5c4ca0 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -13,13 +13,10 @@ test('Test parseHostname', (t: ExecutionContext) => { } ); // Custom live domain. - t.deepEqual( - server.parseHostname('example.com', 'example', 'example.com'), - { - siteId: 'example', - branchOrRef: 'master', - } - ); + t.deepEqual(server.parseHostname('example.com', 'example', 'example.com'), { + siteId: 'example', + branchOrRef: 'master', + }); // Multiple live domains. t.deepEqual( server.parseHostname('example.com', 'example', 'example.com,foo.com'), @@ -29,11 +26,8 @@ test('Test parseHostname', (t: ExecutionContext) => { } ); // Some other domain. - t.deepEqual( - server.parseHostname('something.com', 'example', 'example.com'), - { - siteId: 'example', - branchOrRef: '', - } - ); + t.deepEqual(server.parseHostname('something.com', 'example', 'example.com'), { + siteId: 'example', + branchOrRef: '', + }); }); diff --git a/src/server.ts b/src/server.ts index d6d7dd7..5937010 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,6 @@ import * as fsPath from 'path'; +import * as manifest from './manifest'; +import * as redirects from './redirects'; import {Datastore} from '@google-cloud/datastore'; import {GoogleAuth} from 'google-auth-library'; @@ -51,7 +53,11 @@ const getManifest = async (siteId: string, branchOrRef: string) => { // } }; -export function parseHostname(hostname: string, defaultSiteId?: string, defaultLiveDomain?: string) { +export function parseHostname( + hostname: string, + defaultSiteId?: string, + defaultLiveDomain?: string +) { let siteId = defaultSiteId || 'default'; let branchOrRef = ''; if (defaultLiveDomain && defaultLiveDomain.split(',').includes(hostname)) { @@ -77,7 +83,11 @@ export function createApp(siteId: string, branchOrRef: string) { const app = express(); app.disable('x-powered-by'); app.all('/*', async (req: express.Request, res: express.Response) => { - const envFromHostname = parseHostname(req.hostname, process.env.FILESET_SITE, process.env.FILESET_LIVE_DOMAIN); + const envFromHostname = parseHostname( + req.hostname, + process.env.FILESET_SITE, + process.env.FILESET_LIVE_DOMAIN + ); const requestSiteId = envFromHostname.siteId || siteId; const requestBranchOrRef = envFromHostname.branchOrRef || branchOrRef; @@ -103,6 +113,26 @@ export function createApp(siteId: string, branchOrRef: string) { return; } + // Handle redirects. + if (manifest.redirects) { + const routeTrie = new redirects.RouteTrie(); + manifest.redirects.forEach((redirect: manifest.Redirect) => { + const code = redirect.permanent ? 301 : 302; + const route = new redirects.RedirectRoute(code, redirect.to); + routeTrie.add(redirect.from, route); + }); + const [route, params] = routeTrie.get(req.path); + if (route) { + const [ + code, + destination, + ] = (route as redirects.RedirectRoute).getRedirect(params); + res.redirect(code, destination); + return; + } + } + + // Handle static content. const manifestPaths = manifest.paths; const blobKey = manifestPaths[blobPath]; const updatedUrl = `/${BUCKET}/fileset/sites/${requestSiteId}/blobs/${blobKey}`; diff --git a/src/upload.ts b/src/upload.ts index 40aa4ce..448549f 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -155,7 +155,7 @@ async function finalize( projectId: googleCloudProject, // keyFilename: '/path/to/keyfile.json', }); - const manifestPaths = manifest.toJSON(); + const manifestPaths = manifest.pathsToJSON(); const now = new Date(); // Create shortSha mapping, so a shortSha can be used to lookup filesets. @@ -164,10 +164,11 @@ async function finalize( `${manifest.site}:ref:${manifest.shortSha}`, ]); await saveManifestEntity(datastore, key, { - site: manifest.site, - ref: manifest.ref, branch: manifest.branch, paths: manifestPaths, + redirects: manifest.redirects, + ref: manifest.ref, + site: manifest.site, }); // Create branch mapping, so a branch name can be used to lookup filesets. @@ -178,10 +179,11 @@ async function finalize( `${manifest.site}:branch:${manifest.branch}`, ]); await saveManifestEntity(datastore, branchKey, { - site: manifest.site, - ref: manifest.ref, branch: manifest.branch, paths: manifestPaths, + redirects: manifest.redirects, + ref: manifest.ref, + site: manifest.site, }); } From a938cccec32c120a75309b1fa791e0084f65efa4 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sun, 14 Feb 2021 15:57:33 -0800 Subject: [PATCH 2/3] Add tests --- src/redirects.test.ts | 29 ++++++++++++++++++++++++----- src/server.ts | 7 ++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/redirects.test.ts b/src/redirects.test.ts index 2157694..d75477f 100644 --- a/src/redirects.test.ts +++ b/src/redirects.test.ts @@ -5,6 +5,7 @@ import {ExecutionContext} from 'ava'; import test from 'ava'; test('Test redirects', (t: ExecutionContext) => { + let numTestsRun = 0; const config: manifest.Redirect[] = [ { from: '/foo', @@ -20,6 +21,7 @@ test('Test redirects', (t: ExecutionContext) => { to: '/$locale/$wildcard', }, ]; + const routeTrie = new redirects.RouteTrie(); config.forEach(redirect => { const code = redirect.permanent ? 301 : 302; @@ -27,12 +29,29 @@ test('Test redirects', (t: ExecutionContext) => { routeTrie.add(redirect.from, route); }); - const [route, params] = routeTrie.get('/foo'); - if (route) { - const [code, destination] = (route as redirects.RedirectRoute).getRedirect( - params - ); + let [route, params] = routeTrie.get('/foo'); + if (route instanceof redirects.RedirectRoute) { + const [code, destination] = route.getRedirect(params); t.is(code, 301); t.is(destination, '/bar'); + numTestsRun++; + } + + [route, params] = routeTrie.get('/intl/de'); + if (route instanceof redirects.RedirectRoute) { + const [code, destination] = route.getRedirect(params); + t.is(code, 302); + t.is(destination, '/de/'); + numTestsRun++; } + + [route, params] = routeTrie.get('/intl/ja/foo'); + if (route instanceof redirects.RedirectRoute) { + const [code, destination] = route.getRedirect(params); + t.is(code, 302); + t.is(destination, '/ja/foo'); + numTestsRun++; + } + + t.is(numTestsRun, 3); }); diff --git a/src/server.ts b/src/server.ts index 5937010..dd3f38f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -122,11 +122,8 @@ export function createApp(siteId: string, branchOrRef: string) { routeTrie.add(redirect.from, route); }); const [route, params] = routeTrie.get(req.path); - if (route) { - const [ - code, - destination, - ] = (route as redirects.RedirectRoute).getRedirect(params); + if (route instanceof redirects.RedirectRoute) { + const [code, destination] = route.getRedirect(params); res.redirect(code, destination); return; } From a5abcf621a780dc33efbbf9e10c66ef7439be4e8 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sun, 14 Feb 2021 16:01:26 -0800 Subject: [PATCH 3/3] Change default from master to main --- README.md | 16 ++++++++++++---- example/fileset.yaml | 3 +-- src/server.test.ts | 4 ++-- src/server.ts | 6 +++--- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 339bcaf..48a0d3a 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,17 @@ google_cloud_project: site: # Specify a launch schedule. The schedule maps timestamps to branches or commit -# shas. If blank, `master` is used for the default deployment. +# shas. If blank, `main` is used for the default deployment. schedule: - default: master + default: main + +redirects: +- from: /foo + to: /bar +- from: /intl/:locale/ + to: /$locale/ +- from: /intl/:locale/*wildcard + to: /$locale/$wildcard ``` 2. Generate your files. @@ -209,12 +217,12 @@ Git branch is determined by inspecting the local Git environment when the The best way to understand how this works is by following the examples below: ```bash -# master branch +# main branch # ✓ public # ✓ production URL # ✓ also available from staging URL (restricted) -(master) $ fileset upload build +(main) $ fileset upload build ... Public URL: https://appid.appspot.com Staging URL: https://default-f3a9abb-dot-fileset-dot-appid.appspot.com diff --git a/example/fileset.yaml b/example/fileset.yaml index 8637be7..1baa770 100644 --- a/example/fileset.yaml +++ b/example/fileset.yaml @@ -1,8 +1,7 @@ google_cloud_project: site: schedule: - default: master - + default: main redirects: - from: /foo to: /bar diff --git a/src/server.test.ts b/src/server.test.ts index a5c4ca0..dadb363 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -15,14 +15,14 @@ test('Test parseHostname', (t: ExecutionContext) => { // Custom live domain. t.deepEqual(server.parseHostname('example.com', 'example', 'example.com'), { siteId: 'example', - branchOrRef: 'master', + branchOrRef: 'main', }); // Multiple live domains. t.deepEqual( server.parseHostname('example.com', 'example', 'example.com,foo.com'), { siteId: 'example', - branchOrRef: 'master', + branchOrRef: 'main', } ); // Some other domain. diff --git a/src/server.ts b/src/server.ts index dd3f38f..96daa03 100644 --- a/src/server.ts +++ b/src/server.ts @@ -61,14 +61,14 @@ export function parseHostname( let siteId = defaultSiteId || 'default'; let branchOrRef = ''; if (defaultLiveDomain && defaultLiveDomain.split(',').includes(hostname)) { - // Hostname is the "live" or "prod" domain. Use the master branch. - branchOrRef = 'master'; + // Hostname is the "live" or "prod" domain. Use the main branch. + branchOrRef = 'main'; } else if (hostname.includes('-dot-')) { // Use "-dot-" as a sentinel for App Engine wildcard domains. const prefix = hostname.split('-dot-')[0]; const parts = prefix.split('-'); // Either - or . siteId = parts[0]; - branchOrRef = parts.length > 1 ? parts[1].slice(0, 7) : 'master'; + branchOrRef = parts.length > 1 ? parts[1].slice(0, 7) : 'main'; } // TODO: Implement defaultStagingDomain (custom staging domain) support. return {