From a303bea75009b64a8aa1abd9188e5f941a434bf3 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:30:00 -0700 Subject: [PATCH 1/6] refactor: build route VCL as snippets, not per-route Fastly objects configure-fastly.js created a Fastly condition, response object, and header per route. That accumulates objects on every change and spends a synthetic status code per redirect (the live service was already carrying 900-906). Render routes.json into three snippets instead: two lookup tables plus the recv/error logic, overwritten by name on every run, with one reused internal status (700) driving every 301. Redirects and section rewrites become table lookups, so the route config stops accumulating. - routes-to-vcl.js: pure routes.json to snippet-spec generator, with a test wired into npm test / test:unit - fastly-extended.js: add setSnippet (delete-then-create, since fastly-js updateSnippet sends no body) and validateVersion; drop the now-unused condition and response-object helpers - configure-fastly.js: build and write the snippets, validate the version before activating, drop the per-route machinery and unused lodash.defaults --- bin/configure-fastly.js | 94 +++++---------------------------- bin/lib/fastly-extended.js | 52 +++++++++---------- bin/lib/routes-to-vcl.js | 104 +++++++++++++++++++++++++++++++++++++ package-lock.json | 7 --- package.json | 4 +- test/routes-to-vcl.test.js | 58 +++++++++++++++++++++ 6 files changed, 203 insertions(+), 116 deletions(-) create mode 100644 bin/lib/routes-to-vcl.js create mode 100644 test/routes-to-vcl.test.js diff --git a/bin/configure-fastly.js b/bin/configure-fastly.js index e913671..97e25df 100644 --- a/bin/configure-fastly.js +++ b/bin/configure-fastly.js @@ -1,6 +1,5 @@ -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 || ''; @@ -8,23 +7,6 @@ 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,6 +22,7 @@ const getWorkingVersion = async () => { return response.number; }; +// Route origin requests to the S3 bucket. const setBucketNameHeader = version => fastly.setFastlyHeader(version, { name: BUCKET_NAME_HEADER_NAME, action: 'set', @@ -50,73 +33,22 @@ const setBucketNameHeader = version => fastly.setFastlyHeader(version, { 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([ + await Promise.all([ setBucketNameHeader(version), - setAppRouteRequestConditions(version) + setAppRouteSnippets(version) ]); - await setAppRouteHeaders(version, results[1]); + // 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; }; diff --git a/bin/lib/fastly-extended.js b/bin/lib/fastly-extended.js index 9c0e11c..830080f 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,9 +13,8 @@ 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 @@ -26,6 +24,11 @@ module.exports = (apiToken, serviceId) => { throw err; }); + const ignoreMissing = err => { + if (err && err.status === 404) return null; + throw err; + }; + const withService = (version, extra) => Object.assign( {service_id: serviceId, version_id: version}, extra @@ -55,16 +58,13 @@ module.exports = (apiToken, serviceId) => { return versionApi.cloneServiceVersion({service_id: serviceId, version_id: version}); }, - // Upsert a Fastly condition entry. - setCondition: (version, condition) => { + // 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 condition. No serviceId configured')); + return Promise.reject(new Error('Failed to validate version. No serviceId configured.')); } - const params = withService(version, condition); - return upsert( - () => conditionApi.updateCondition(Object.assign({condition_name: condition.name}, params)), - () => conditionApi.createCondition(params) - ); + return versionApi.validateServiceVersion({service_id: serviceId, version_id: version}); }, // Upsert a Fastly header entry. @@ -79,21 +79,21 @@ module.exports = (apiToken, serviceId) => { ); }, - // 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..2bbe285 --- /dev/null +++ b/bin/lib/routes-to-vcl.js @@ -0,0 +1,104 @@ +/* + * 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; + * 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; + +// Escape a string for use inside a VCL double-quoted literal. +const vclString = value => `"${String(value).replace(/\\/g, '\\\\') + .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 sectionEntries = []; + + routes.forEach(route => { + if (route.redirect) { + redirectEntries.push([redirectSourcePath(route), 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 legacy .html 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};`, + '}', + '', + '# 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: tables}, + {name: 'app-routes-recv', type: 'recv', priority: '10', content: recv}, + {name: 'app-routes-error', type: 'error', priority: '100', content: 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/test/routes-to-vcl.test.js b/test/routes-to-vcl.test.js new file mode 100644 index 0000000..876506d --- /dev/null +++ b/test/routes-to-vcl.test.js @@ -0,0 +1,58 @@ +const assert = require('assert'); +const {routesToSnippets, redirectSourcePath, REDIRECT_STATUS} = require('../bin/lib/routes-to-vcl'); + +// A small fixture covering each route shape: index, a 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: '^/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' +); + +// 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'); + +// 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'); +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'); From 929fb1af9e34fbc53cc9af116de8bd350e7a8cc3 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:58:21 -0700 Subject: [PATCH 2/6] refactor: stop managing Fastly origin host in code The origin Host header is owned by the backend's Override host setting in the Fastly service config, which cloning a version preserves. Setting it again from configure-fastly duplicated that responsibility and, with a CloudFront-fronted origin, set the wrong value (an S3 bucket name rather than a host CloudFront routes on). Drop setBucketNameHeader and the now-unused header helper; the script manages only the route snippets. --- README.md | 1 - bin/configure-fastly.js | 18 +----------------- bin/lib/fastly-extended.js | 20 -------------------- 3 files changed, 1 insertion(+), 38 deletions(-) diff --git a/README.md b/README.md index de540dc..ea15e14 100644 --- a/README.md +++ b/README.md @@ -49,4 +49,3 @@ Use `^C` to stop the node process `npm start` starts. | `FASTLY_ACTIVATE_CHANGES`| `false` | 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 97e25df..a6b1ff6 100644 --- a/bin/configure-fastly.js +++ b/bin/configure-fastly.js @@ -2,8 +2,6 @@ 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); @@ -22,17 +20,6 @@ const getWorkingVersion = async () => { return response.number; }; -// Route origin requests to the S3 bucket. -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 -}); - // 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)) @@ -40,10 +27,7 @@ const setAppRouteSnippets = version => Promise.all( const configureFastly = async () => { const version = await getWorkingVersion(); - await Promise.all([ - setBucketNameHeader(version), - setAppRouteSnippets(version) - ]); + await setAppRouteSnippets(version); // Compile-check the generated VCL before anything tries to activate it. const validation = await fastly.validateVersion(version); if (validation.status !== 'ok') { diff --git a/bin/lib/fastly-extended.js b/bin/lib/fastly-extended.js index 830080f..52d2896 100644 --- a/bin/lib/fastly-extended.js +++ b/bin/lib/fastly-extended.js @@ -13,17 +13,9 @@ module.exports = (apiToken, serviceId) => { Fastly.ApiClient.instance.authenticate(apiToken); const versionApi = new Fastly.VersionApi(); - const headerApi = new Fastly.HeaderApi(); 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(); - throw err; - }); - const ignoreMissing = err => { if (err && err.status === 404) return null; throw err; @@ -67,18 +59,6 @@ module.exports = (apiToken, serviceId) => { return versionApi.validateServiceVersion({service_id: serviceId, version_id: version}); }, - // Upsert a Fastly header entry. - setFastlyHeader: (version, header) => { - if (!serviceId) { - return Promise.reject(new Error('Failed to set header. No serviceId configured')); - } - const params = withService(version, header); - return upsert( - () => headerApi.updateHeaderObject(Object.assign({header_name: header.name}, params)), - () => headerApi.createHeaderObject(params) - ); - }, - // 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) => { From a1ce1bad46bf167ab50f97c80dcaa1316d733bbf Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:59:48 -0700 Subject: [PATCH 3/6] ci: configure Fastly during deploy Run the Fastly configuration script as its own job in the deploy workflow, scoped to the resolved GitHub Environment so staging and production each use their own Fastly service and activation setting. Now that route config renders to a few overwritten snippets instead of accumulating per-route objects, running it on every deploy no longer piles up Fastly objects. Activation is gated per environment by the FASTLY_ACTIVATE_CHANGES variable: set it in an environment to activate and purge after configuring, leave it unset to configure and validate only. --- .github/workflows/deploy.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 21bba1b..2965aaa 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -94,3 +94,25 @@ jobs: - name: Upload to S3 run: | aws s3 sync ./build s3://${{ vars.AWS_S3_BUCKET }}/junior/ + + configure-fastly: + needs: [setup, lint, build] + 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 in an Environment to have that environment's deploys + # activate the new version; leave it unset to configure + validate only. + FASTLY_ACTIVATE_CHANGES: ${{ vars.FASTLY_ACTIVATE_CHANGES }} From 9da7c585af7d60e8f4e2d52b8ac52d7ce13409f6 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:22:03 -0700 Subject: [PATCH 4/6] feat: redirect old /learn URLs to /explore The learn section was renamed to explore, but the live config still routed /learn to a now-missing learn page. Redirect /learn and its sub-paths to the matching /explore path (preserving the tail), plus an exact /learn.html redirect, so existing links keep working. Add a "prefix" redirect kind to the generator, rendered as a regex rewrite in recv (a table lookup can only match an exact path). --- bin/lib/routes-to-vcl.js | 31 +++++++++++++++++++++++++++---- src/routes.json | 15 +++++++++++++-- test/routes-to-vcl.test.js | 20 ++++++++++++++++++-- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/bin/lib/routes-to-vcl.js b/bin/lib/routes-to-vcl.js index 2bbe285..3d4603a 100644 --- a/bin/lib/routes-to-vcl.js +++ b/bin/lib/routes-to-vcl.js @@ -4,8 +4,9 @@ * 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; - * a single reused internal status code drives every 301. + * 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}. */ @@ -20,6 +21,9 @@ const REDIRECT_STATUS = 700; 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 @@ -45,11 +49,19 @@ const renderTable = (name, entries) => { const routesToSnippets = routes => { const redirectEntries = []; + const prefixRedirects = []; const sectionEntries = []; routes.forEach(route => { if (route.redirect) { - redirectEntries.push([redirectSourcePath(route), 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 @@ -62,13 +74,24 @@ const routesToSnippets = routes => { ].join('\n\n'); const recv = [ - '# Redirect legacy .html paths to their clean URL (single reused status).', + '# 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 == "/") {', 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 index 876506d..95b8f81 100644 --- a/test/routes-to-vcl.test.js +++ b/test/routes-to-vcl.test.js @@ -1,12 +1,14 @@ const assert = require('assert'); const {routesToSnippets, redirectSourcePath, REDIRECT_STATUS} = require('../bin/lib/routes-to-vcl'); -// A small fixture covering each route shape: index, a redirect, a prefix -// section, an exact section, and a section with no redirect pair. +// 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'} ]; @@ -39,6 +41,18 @@ assert.ok(recv.content.includes('table.lookup(sections, var.section'), 'recv loo 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'); @@ -49,6 +63,8 @@ assert.ok(error.content.includes('set obj.http.Location = req.http.X-Redirect-Lo // 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/, From cbfa1f6f153074da1bef2f31971cbc73909adec9 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:47:24 -0700 Subject: [PATCH 5/6] ci: order Fastly config after upload; require "true" to activate The configure-fastly job ran in parallel with the S3 upload, so with activation on it could flip Fastly to new routing before the new content was uploaded -- and this PR adds new routes. Depend on the deploy job so content lands first, and so a failed upload skips the Fastly step. FASTLY_ACTIVATE_CHANGES was a presence check, so the string "false" (or any non-empty value) still activated. Require exactly "true". --- .github/workflows/deploy.yml | 9 ++++++--- README.md | 2 +- bin/configure-fastly.js | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2965aaa..80a76ac 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -96,7 +96,9 @@ jobs: aws s3 sync ./build s3://${{ vars.AWS_S3_BUCKET }}/junior/ configure-fastly: - needs: [setup, lint, build] + # 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 }} @@ -113,6 +115,7 @@ jobs: env: FASTLY_API_KEY: ${{ secrets.FASTLY_API_KEY }} FASTLY_SERVICE_ID: ${{ vars.FASTLY_SERVICE_ID }} - # Set this var in an Environment to have that environment's deploys - # activate the new version; leave it unset to configure + validate only. + # 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 ea15e14..9ca2cfa 100644 --- a/README.md +++ b/README.md @@ -46,6 +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 | diff --git a/bin/configure-fastly.js b/bin/configure-fastly.js index a6b1ff6..ee37c1c 100644 --- a/bin/configure-fastly.js +++ b/bin/configure-fastly.js @@ -38,7 +38,7 @@ const configureFastly = async () => { 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); From ce42205198f3d5e112b7705e5e897fc5af2f1859 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:40:42 -0700 Subject: [PATCH 6/6] refactor: annotate generated snippets and use default priority Prepend a provenance header naming bin/configure-fastly.js to each generated snippet, so it is clear in the Fastly UI that they are generated and how to regenerate them. Use the default snippet priority (100) for app-routes-recv instead of 10. It is the only generated recv snippet, so it needs no special ordering, and keeping it at the default leaves lower numbers free for manually-managed snippets that must sort ahead of it. --- bin/lib/routes-to-vcl.js | 15 ++++++++++++--- test/routes-to-vcl.test.js | 8 ++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/bin/lib/routes-to-vcl.js b/bin/lib/routes-to-vcl.js index 3d4603a..d137366 100644 --- a/bin/lib/routes-to-vcl.js +++ b/bin/lib/routes-to-vcl.js @@ -17,6 +17,15 @@ // 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, '\\"')}"`; @@ -118,9 +127,9 @@ const routesToSnippets = routes => { ].join('\n'); return [ - {name: 'app-routes-tables', type: 'init', priority: '100', content: tables}, - {name: 'app-routes-recv', type: 'recv', priority: '10', content: recv}, - {name: 'app-routes-error', type: 'error', priority: '100', content: error} + {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)} ]; }; diff --git a/test/routes-to-vcl.test.js b/test/routes-to-vcl.test.js index 95b8f81..f7e726c 100644 --- a/test/routes-to-vcl.test.js +++ b/test/routes-to-vcl.test.js @@ -22,6 +22,14 @@ assert.deepStrictEqual( ['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'];