diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 21bba1b..80a76ac 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -94,3 +94,28 @@ jobs: - name: Upload to S3 run: | aws s3 sync ./build s3://${{ vars.AWS_S3_BUCKET }}/junior/ + + configure-fastly: + # Runs after deploy so new origin content is uploaded before new routing + # can go live; also skips if the S3 upload failed. + needs: [setup, lint, build, deploy] + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + environment: ${{ needs.setup.outputs.environment }} + steps: + - uses: actions/checkout@v4 + - name: Use Node 22x + uses: actions/setup-node@v4 + with: + node-version: '22.x' + - name: Install Dependencies + run: npm ci --legacy-peer-deps + - name: Configure Fastly + run: npm run sync-fastly + env: + FASTLY_API_KEY: ${{ secrets.FASTLY_API_KEY }} + FASTLY_SERVICE_ID: ${{ vars.FASTLY_SERVICE_ID }} + # Set this var to "true" in an Environment to have that environment's + # deploys activate the new version; any other value configures and + # validates only. + FASTLY_ACTIVATE_CHANGES: ${{ vars.FASTLY_ACTIVATE_CHANGES }} diff --git a/README.md b/README.md index de540dc..9ca2cfa 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,6 @@ Use `^C` to stop the node process `npm start` starts. | ------------------------ | ------- | ------------------------------------------------ | | `FASTLY_SERVICE_ID` | `''` | Fastly service ID for `bin/configure-fastly.js` | | `FASTLY_API_KEY` | `''` | Fastly API key for `bin/configure-fastly.js` | -| `FASTLY_ACTIVATE_CHANGES`| `false` | Activate changes and purge all after configuring | +| `FASTLY_ACTIVATE_CHANGES`| `false` | Set to `true` to activate changes and purge all after configuring | | `AWS_ACCESS_KEY_ID` | `''` | AWS access key id for S3 | | `AWS_SECRET_ACCESS_KEY` | `''` | AWS secret access key for S3 | -| `S3_BUCKET_NAME` | `''` | S3 bucket name to deploy into | diff --git a/bin/configure-fastly.js b/bin/configure-fastly.js index e913671..ee37c1c 100644 --- a/bin/configure-fastly.js +++ b/bin/configure-fastly.js @@ -1,30 +1,10 @@ -const defaults = require('lodash.defaults'); - const routeJson = require('../src/routes.json'); +const {routesToSnippets} = require('./lib/routes-to-vcl'); const FASTLY_SERVICE_ID = process.env.FASTLY_SERVICE_ID || ''; -const S3_BUCKET_NAME = process.env.S3_BUCKET_NAME || ''; -const BUCKET_NAME_HEADER_NAME = 'Bucket name'; const fastly = require('./lib/fastly-extended')(process.env.FASTLY_API_KEY, FASTLY_SERVICE_ID); -/* - * Translate an express-style pattern e.g. /path/:arg/ to a regex - * all :arguments become .+? - */ -const expressPatternToRegex = pattern => pattern.replace(/(:[^/]+)/gi, '.+?'); - -const getConditionNameForRoute = (route, type) => `routes/${route.pattern} (${type})`; - -const getHeaderNameForRoute = route => { - if (route.name) return `rewrites/${route.name}`; - if (route.redirect) return `redirects/${route.pattern}`; -}; - -const getResponseNameForRoute = route => `redirects/${route.pattern}`; - -const routes = routeJson.map(route => defaults({}, {pattern: expressPatternToRegex(route.pattern)}, route)); - // Get the latest version, cloning it first if it is already active or locked. const getWorkingVersion = async () => { const response = await fastly.getLatestVersion(); @@ -40,89 +20,25 @@ const getWorkingVersion = async () => { return response.number; }; -const setBucketNameHeader = version => fastly.setFastlyHeader(version, { - name: BUCKET_NAME_HEADER_NAME, - action: 'set', - ignore_if_set: 0, - type: 'REQUEST', - dst: 'http.host', - src: `"${S3_BUCKET_NAME}"`, - priority: 1 -}); - -// Create a request condition per route and return them keyed by route index. -const setAppRouteRequestConditions = async version => { - const conditions = []; - await Promise.all(routes.map(async (route, id) => { - conditions[id] = await fastly.setCondition(version, { - name: getConditionNameForRoute(route, 'request'), - statement: `req.url ~ "${route.pattern}"`, - type: 'REQUEST', - // Priority needs to be > 1 to not interact with http->https redirect - priority: 10 + id - }); - })); - return conditions; -}; - -const setRedirectRouteHeader = async (version, route, id) => { - const responseCondition = await fastly.setCondition(version, { - name: getConditionNameForRoute(route, 'response'), - statement: `req.url ~ "${route.pattern}"`, - type: 'RESPONSE', - priority: id - }); - await fastly.setResponseObject(version, { - name: getResponseNameForRoute(route), - status: 301, - response: 'Moved Permanently', - request_condition: getConditionNameForRoute(route, 'request') - }); - return fastly.setFastlyHeader(version, { - name: getHeaderNameForRoute(route), - action: 'set', - ignore_if_set: 0, - type: 'RESPONSE', - dst: 'http.Location', - src: `"${route.redirect}"`, - response_condition: responseCondition.name - }); -}; - -const setRewriteRouteHeader = (version, route, requestCondition) => fastly.setFastlyHeader(version, { - name: getHeaderNameForRoute(route, 'request'), - action: 'set', - ignore_if_set: 0, - type: 'REQUEST', - dst: 'url', - src: `"/${route.name}.html"`, - request_condition: requestCondition.name, - priority: 10 -}); - -// Create the response/request header for every route (redirects vs. rewrites). -const setAppRouteHeaders = (version, requestConditions) => Promise.all(routes.map((route, id) => { - if (route.redirect) { - return setRedirectRouteHeader(version, route, id); - } - return setRewriteRouteHeader(version, route, requestConditions[id]); -})); +// Render routes.json into VCL snippets and write them to the version. +const setAppRouteSnippets = version => Promise.all( + routesToSnippets(routeJson).map(snippet => fastly.setSnippet(version, snippet)) +); const configureFastly = async () => { const version = await getWorkingVersion(); - // The bucket header and the request conditions depend only on the version, - // so run them together; the route headers depend on the request conditions. - const results = await Promise.all([ - setBucketNameHeader(version), - setAppRouteRequestConditions(version) - ]); - await setAppRouteHeaders(version, results[1]); + await setAppRouteSnippets(version); + // Compile-check the generated VCL before anything tries to activate it. + const validation = await fastly.validateVersion(version); + if (validation.status !== 'ok') { + throw new Error(`Version ${version} failed validation: ${validation.msg}`); + } return version; }; configureFastly() .then(async version => { - if (!process.env.FASTLY_ACTIVATE_CHANGES) return; + if (process.env.FASTLY_ACTIVATE_CHANGES !== 'true') return; const response = await fastly.activateVersion(version); process.stdout.write(`Successfully configured and activated version ${response.number}\n`); await fastly.purgeAll(FASTLY_SERVICE_ID); diff --git a/bin/lib/fastly-extended.js b/bin/lib/fastly-extended.js index 9c0e11c..52d2896 100644 --- a/bin/lib/fastly-extended.js +++ b/bin/lib/fastly-extended.js @@ -3,9 +3,8 @@ const Fastly = require('fastly'); /* * Fastly configuration helpers built on the official fastly-js client. * - * Wraps the per-resource API classes and exposes Promise-returning, upsert-by- - * name helpers with stable signatures so callers don't deal with the client's - * create-vs-update split. Authenticates the shared ApiClient on construction. + * Wraps the per-resource API classes and exposes Promise-returning helpers with + * stable signatures. Authenticates the shared ApiClient on construction. * * @param {string} apiToken Fastly API token * @param {string} serviceId Fastly service id @@ -14,17 +13,13 @@ module.exports = (apiToken, serviceId) => { Fastly.ApiClient.instance.authenticate(apiToken); const versionApi = new Fastly.VersionApi(); - const conditionApi = new Fastly.ConditionApi(); - const headerApi = new Fastly.HeaderApi(); - const responseObjectApi = new Fastly.ResponseObjectApi(); + const snippetApi = new Fastly.SnippetApi(); const purgeApi = new Fastly.PurgeApi(); - // Upsert-by-name: fastly-js has no upsert, so update (PUT by name) and fall - // back to create (POST) when the resource does not yet exist (404). - const upsert = (update, create) => update().catch(err => { - if (err && err.status === 404) return create(); + const ignoreMissing = err => { + if (err && err.status === 404) return null; throw err; - }); + }; const withService = (version, extra) => Object.assign( {service_id: serviceId, version_id: version}, @@ -55,45 +50,30 @@ module.exports = (apiToken, serviceId) => { return versionApi.cloneServiceVersion({service_id: serviceId, version_id: version}); }, - // Upsert a Fastly condition entry. - setCondition: (version, condition) => { - if (!serviceId) { - return Promise.reject(new Error('Failed to set condition. No serviceId configured')); - } - const params = withService(version, condition); - return upsert( - () => conditionApi.updateCondition(Object.assign({condition_name: condition.name}, params)), - () => conditionApi.createCondition(params) - ); - }, - - // Upsert a Fastly header entry. - setFastlyHeader: (version, header) => { + // Compile-check a version's generated VCL without activating it. Resolves + // with {status, msg}; status is 'ok' when the version is valid. + validateVersion: version => { if (!serviceId) { - return Promise.reject(new Error('Failed to set header. No serviceId configured')); + return Promise.reject(new Error('Failed to validate version. No serviceId configured.')); } - const params = withService(version, header); - return upsert( - () => headerApi.updateHeaderObject(Object.assign({header_name: header.name}, params)), - () => headerApi.createHeaderObject(params) - ); + return versionApi.validateServiceVersion({service_id: serviceId, version_id: version}); }, - // Upsert a Fastly response object. The client takes the body wrapped in - // create_response_object_request for both create and update. - setResponseObject: (version, responseObject) => { + // Replace a versioned VCL snippet. fastly-js updateSnippet sends no body, + // so delete any existing snippet of this name (ignoring 404) then create. + setSnippet: (version, snippet) => { if (!serviceId) { - return Promise.reject(new Error('Failed to set response object. No serviceId configured')); + return Promise.reject(new Error('Failed to set snippet. No serviceId configured')); } - return upsert( - () => responseObjectApi.updateResponseObject(withService(version, { - response_object_name: responseObject.name, - create_response_object_request: responseObject - })), - () => responseObjectApi.createResponseObject(withService(version, { - create_response_object_request: responseObject - })) - ); + return snippetApi.deleteSnippet({service_id: serviceId, version_id: version, name: snippet.name}) + .catch(ignoreMissing) + .then(() => snippetApi.createSnippet(withService(version, { + name: snippet.name, + type: snippet.type, + content: snippet.content, + priority: snippet.priority, + dynamic: '0' + }))); }, // Activate a version. diff --git a/bin/lib/routes-to-vcl.js b/bin/lib/routes-to-vcl.js new file mode 100644 index 0000000..d137366 --- /dev/null +++ b/bin/lib/routes-to-vcl.js @@ -0,0 +1,136 @@ +/* + * Turn routes.json into Fastly VCL snippets. + * + * Instead of creating one Fastly condition + response object + header per route + * (which accumulates objects and burns a synthetic status code per redirect), + * the whole route table is rendered into a few snippets that are overwritten by + * name on every run. Exact redirects and section rewrites become table lookups, + * while a "prefix" redirect becomes a regex rewrite that moves a whole renamed + * path tree; a single reused internal status code drives every 301. + * + * Returns an array of snippet specs: {name, type, priority, content}. + */ + +// Internal-only status used to signal "issue a redirect": caught in the error +// snippet and turned into a 301, never returned to a client. Any value >= 600 +// works as long as it does not collide with another error code in the service's +// VCL; the value is arbitrary. Verify against the live generated VCL if unsure. +const REDIRECT_STATUS = 700; + +// Provenance header prepended to every generated snippet so it is obvious in the +// Fastly UI that the snippet is generated and where it comes from. +const GENERATED_HEADER = [ + '# Generated by bin/configure-fastly.js from src/routes.json.', + '# Do not edit in Fastly; this snippet is overwritten on every deploy.' +].join('\n'); + +const withHeader = content => `${GENERATED_HEADER}\n\n${content}`; + +// Escape a string for use inside a VCL double-quoted literal. +const vclString = value => `"${String(value).replace(/\\/g, '\\\\') + .replace(/"/g, '\\"')}"`; + +// Escape a literal path for use inside a VCL regular expression. +const vclRegex = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +// Derive the literal source path a redirect route matches, from its pattern. +// Redirect patterns are anchored literal paths (e.g. "^/about\\.html"); strip +// the anchors and unescape. Throws if the pattern isn't a literal path, so a +// regex redirect can never be silently dropped into an exact-match table. +const redirectSourcePath = route => { + const body = route.pattern.replace(/^\^/, '').replace(/\$$/, ''); + const unescaped = body.replace(/\\(.)/g, '$1'); + // After unescaping, a literal path re-escaped must match the original body. + const reEscaped = unescaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (reEscaped !== body) { + throw new Error(`Redirect pattern is not a literal path, cannot map to a lookup table: ${route.pattern}`); + } + return unescaped; +}; + +// The first path segment a section route keys on (its name is that segment). +const sectionKey = route => route.name; + +const renderTable = (name, entries) => { + const lines = entries.map(([key, value]) => ` ${vclString(key)}: ${vclString(value)},`); + return `table ${name} {\n${lines.join('\n')}\n}`; +}; + +const routesToSnippets = routes => { + const redirectEntries = []; + const prefixRedirects = []; + const sectionEntries = []; + + routes.forEach(route => { + if (route.redirect) { + const source = redirectSourcePath(route); + // A prefix redirect moves a whole renamed path tree to the new path + // (preserving the sub-path); a plain redirect maps one exact path. + if (route.prefix) { + prefixRedirects.push([source, route.redirect]); + } else { + redirectEntries.push([source, route.redirect]); + } + return; + } + if (route.name === 'index') return; // handled explicitly in recv + sectionEntries.push([sectionKey(route), `/${route.name}.html`]); + }); + + const tables = [ + renderTable('redirects', redirectEntries), + renderTable('sections', sectionEntries) + ].join('\n\n'); + + const recv = [ + '# Redirect exact legacy paths to their clean URL (single reused status).', + 'declare local var.redirect STRING;', + 'set var.redirect = table.lookup(redirects, req.url.path, "");', + 'if (var.redirect != "") {', + ' set req.http.X-Redirect-Location = var.redirect;', + ` error ${REDIRECT_STATUS};`, + '}', + ...prefixRedirects.flatMap(([source, target]) => { + const src = vclRegex(source); + return [ + '', + `# Redirect the renamed ${source} tree to ${target}, preserving the sub-path.`, + `if (req.url.path ~ "^${src}(/.*)?$") {`, + ` set req.http.X-Redirect-Location = regsub(req.url.path, "^${src}", "${target}");`, + ` error ${REDIRECT_STATUS};`, + '}' + ]; + }), + '', + '# Serve section pages by rewriting to their static html file.', + 'if (req.url.path == "/") {', + ' set req.url = "/index.html";', + '} else {', + ' declare local var.section STRING;', + ' set var.section = regsub(req.url.path, "^/([^/?]+).*$", "\\1");', + ' declare local var.view STRING;', + ' set var.view = table.lookup(sections, var.section, "");', + ' if (var.view != "") {', + ' set req.url = var.view;', + ' }', + '}' + ].join('\n'); + + const error = [ + `if (obj.status == ${REDIRECT_STATUS}) {`, + ' set obj.status = 301;', + ' set obj.response = "Moved Permanently";', + ' set obj.http.Location = req.http.X-Redirect-Location;', + ' synthetic {""};', + ' return(deliver);', + '}' + ].join('\n'); + + return [ + {name: 'app-routes-tables', type: 'init', priority: '100', content: withHeader(tables)}, + {name: 'app-routes-recv', type: 'recv', priority: '100', content: withHeader(recv)}, + {name: 'app-routes-error', type: 'error', priority: '100', content: withHeader(error)} + ]; +}; + +module.exports = {routesToSnippets, redirectSourcePath, REDIRECT_STATUS}; diff --git a/package-lock.json b/package-lock.json index e73b2ab..93e60b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "html-loader": "5.1.0", "html-webpack-plugin": "5.6.4", "lodash.debounce": "4.0.8", - "lodash.defaults": "4.2.0", "postcss": "8.5.6", "postcss-loader": "8.2.0", "postcss-simple-vars": "7.0.1", @@ -7488,12 +7487,6 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "dev": true - }, "node_modules/lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", diff --git a/package.json b/package.json index cb08cc0..e4ac39c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "lint": "eslint . --ext .js,.jsx && sass-lint -v ./src/**/*.scss", "start": "node ./dev-server/index.js", "sync-fastly": "node ./bin/configure-fastly.js", - "test": "npm run lint && npm run build" + "test:unit": "node ./test/routes-to-vcl.test.js", + "test": "npm run lint && npm run test:unit && npm run build" }, "repository": { "type": "git", @@ -39,7 +40,6 @@ "html-loader": "5.1.0", "html-webpack-plugin": "5.6.4", "lodash.debounce": "4.0.8", - "lodash.defaults": "4.2.0", "postcss": "8.5.6", "postcss-loader": "8.2.0", "postcss-simple-vars": "7.0.1", diff --git a/src/routes.json b/src/routes.json index fe6d4af..f1238a8 100644 --- a/src/routes.json +++ b/src/routes.json @@ -19,11 +19,22 @@ "name": "explore-redirect", "redirect": "/explore" }, + { + "pattern": "^/learn\\.html", + "name": "learn-redirect", + "redirect": "/explore" + }, + { + "pattern": "^/learn", + "name": "learn-path-redirect", + "redirect": "/explore", + "prefix": true + }, { "pattern": "^/explore(/.+)*/?", "name": "explore", "title": "Explore" - }, + }, { "pattern": "^/teach\\.html", "name": "teach-redirect", @@ -33,7 +44,7 @@ "pattern": "^/teach(/.+)*/?", "name": "teach", "title": "Teach" - }, + }, { "pattern": "^/outreach\\.html", "name": "outreach-redirect", diff --git a/test/routes-to-vcl.test.js b/test/routes-to-vcl.test.js new file mode 100644 index 0000000..f7e726c --- /dev/null +++ b/test/routes-to-vcl.test.js @@ -0,0 +1,82 @@ +const assert = require('assert'); +const {routesToSnippets, redirectSourcePath, REDIRECT_STATUS} = require('../bin/lib/routes-to-vcl'); + +// A small fixture covering each route shape: index, an exact redirect, a prefix +// (renamed-section) redirect, a prefix section, an exact section, and a section +// with no redirect pair. +const routes = [ + {pattern: '^/$', name: 'index', title: 'Home'}, + {pattern: '^/about\\.html', name: 'about-redirect', redirect: '/about'}, + {pattern: '^/about(/.+)*/?', name: 'about', title: 'About'}, + {pattern: '^/learn', name: 'learn-path-redirect', redirect: '/explore', prefix: true}, + {pattern: '^/research/?$', name: 'research', title: 'Research'}, + {pattern: '^/hoc/?$', name: 'hoc', title: 'Hour of Code'} +]; + +const snippets = routesToSnippets(routes); +const byName = Object.fromEntries(snippets.map(s => [s.name, s])); + +assert.strictEqual(snippets.length, 3, 'produces exactly three snippets'); +assert.deepStrictEqual( + snippets.map(s => s.type).sort(), + ['error', 'init', 'recv'], + 'snippet types are init, recv, error' +); +assert.ok( + snippets.every(s => s.priority === '100'), + 'generated snippets use the default snippet priority' +); +assert.ok( + snippets.every(s => s.content.startsWith('# Generated by bin/configure-fastly.js')), + 'every generated snippet carries a provenance header' +); + +// Tables (init snippet). +const tables = byName['app-routes-tables']; +assert.strictEqual(tables.type, 'init'); +assert.ok(tables.content.includes('"/about.html": "/about"'), 'redirects table maps source to clean url'); +assert.ok(tables.content.includes('"about": "/about.html"'), 'sections table maps segment to view'); +assert.ok(tables.content.includes('"research": "/research.html"'), 'exact section is in sections table'); +assert.ok(tables.content.includes('"hoc": "/hoc.html"'), 'section without a redirect is still in sections'); +assert.ok(!tables.content.includes('index'), 'index is not put in a table'); +assert.ok(!tables.content.includes('about-redirect'), 'redirect route name is not leaked into a table'); + +// Recv snippet. +const recv = byName['app-routes-recv']; +assert.strictEqual(recv.type, 'recv'); +assert.ok(recv.content.includes('table.lookup(redirects, req.url.path'), 'recv looks up the redirects table'); +assert.ok(recv.content.includes('table.lookup(sections, var.section'), 'recv looks up the sections table'); +assert.ok(recv.content.includes(`error ${REDIRECT_STATUS}`), 'recv raises the redirect sentinel'); +assert.ok(recv.content.includes('set req.url = "/index.html"'), 'recv handles the root path'); + +// A prefix (renamed-section) redirect renders as a regsub in recv, not an exact +// table row, so the old path and all its sub-paths move to the new path. +assert.ok(!tables.content.includes('"/learn"'), 'prefix redirect source is not an exact table row'); +assert.ok( + recv.content.includes('req.url.path ~ "^/learn(/.*)?$"'), + 'recv matches the renamed prefix and its sub-paths' +); +assert.ok( + recv.content.includes('regsub(req.url.path, "^/learn", "/explore")'), + 'recv rewrites the old prefix to the new path, preserving the rest' +); + +// Error snippet. +const error = byName['app-routes-error']; +assert.strictEqual(error.type, 'error'); +assert.ok(error.content.includes(`obj.status == ${REDIRECT_STATUS}`), 'error catches the redirect sentinel'); +assert.ok(error.content.includes('set obj.status = 301'), 'error converts to a 301'); +assert.ok(error.content.includes('set obj.http.Location = req.http.X-Redirect-Location'), 'error sets Location'); + +// redirectSourcePath derives literal paths and rejects real regexes. +assert.strictEqual(redirectSourcePath({pattern: '^/about\\.html'}), '/about.html'); +assert.strictEqual(redirectSourcePath({pattern: '^/eula\\.html$'}), '/eula.html'); +// A prefix redirect uses a bare path (no .html) as its source. +assert.strictEqual(redirectSourcePath({pattern: '^/learn'}), '/learn'); +assert.throws( + () => redirectSourcePath({pattern: '^/projects/(\\d+)'}), + /not a literal path/, + 'a regex redirect pattern is rejected rather than silently mishandled' +); + +process.stdout.write('routes-to-vcl: all assertions passed\n');