From f4049fde1cd123673daf3d790882be3c68a1af5e Mon Sep 17 00:00:00 2001 From: jabrailkhalil Date: Sun, 4 Oct 2026 02:04:18 +0300 Subject: [PATCH 1/2] feat: allow disabling automatic config discovery --- README.md | 3 +- src/loaders/config-file.js | 9 +-- src/options.ts | 2 + src/superstatic.js | 2 +- test/integration/config-loading.spec.ts | 88 +++++++++++++++++++++++++ test/unit/loaders/config-file.spec.js | 63 ++++++++++++++++++ 6 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 test/integration/config-loading.spec.ts diff --git a/README.md b/README.md index e9b2ed3a..76cda8c8 100755 --- a/README.md +++ b/README.md @@ -203,7 +203,8 @@ Instantiates middleware. See an [example](https://github.com/firebase/superstati * `options` - Optional configuration: * `fallthrough` - When `false`, render a 404 page from within Superstatic rather than calling through to the next middleware. Defaults to `true`. - * `config` - A file path to your application's configuration file (see [Configuration](#configuration)) or an object containing your application's configuration. If an object is provided, it will be merged into existing config in a `superstatic.json`. + * `config` - A file path to your application's configuration file (see [Configuration](#configuration)) or an object containing your application's configuration. If an object is provided, it will be merged into existing config in `superstatic.json` or `firebase.json`. + * `autoConfig` - When `false`, skip discovery of `superstatic.json` and `firebase.json`. Use this with a `config` object to avoid inheriting headers, rewrites, or other settings from those files. An explicit file path in `config` is still loaded. Defaults to `true`. * `protect` - Adds HTTP basic auth. Example: `username:password` * `env`- A file path your application's environment variables file or an object containing values that are made available at the urls `/__/env.json` and `/__/env.js`. See the documentation detail on [environment variables](http://docs.firebase.com/guides/environment-variables). * `cwd` - The current working directory to set as the root. Your application's `public` configuration option will be used relative to this. diff --git a/src/loaders/config-file.js b/src/loaders/config-file.js index 37853113..eaaa482b 100644 --- a/src/loaders/config-file.js +++ b/src/loaders/config-file.js @@ -26,12 +26,13 @@ const { isPlainObject } = require("../utils/objectutils"); const CONFIG_FILE = ["superstatic.json", "firebase.json"]; -module.exports = function (filename) { +module.exports = function (filename, autoConfig = true) { + const defaultFiles = autoConfig ? CONFIG_FILE : []; if (typeof filename === "function") { return filename; } - filename = filename ?? CONFIG_FILE; + filename = filename ?? defaultFiles; let configObject = {}; let config = {}; @@ -42,7 +43,7 @@ module.exports = function (filename) { } catch { if (isPlainObject(filename)) { configObject = filename; - filename = CONFIG_FILE; + filename = defaultFiles; } } @@ -55,7 +56,7 @@ module.exports = function (filename) { // Set back to default config file if stringified object is // given as config. With this, we override values in the config file if (isPlainObject(filename)) { - filename = CONFIG_FILE; + filename = defaultFiles; } // A file name or array of file names diff --git a/src/options.ts b/src/options.ts index 83383143..e261c68f 100644 --- a/src/options.ts +++ b/src/options.ts @@ -26,6 +26,8 @@ import { Configuration } from "./config"; export interface MiddlewareOptions { fallthrough?: boolean; config?: string | Configuration; + /** Whether to discover default config files. Defaults to true. */ + autoConfig?: boolean; protect?: string; env?: string | Record; cwd?: string; diff --git a/src/superstatic.js b/src/superstatic.js index cab46844..880a688f 100644 --- a/src/superstatic.js +++ b/src/superstatic.js @@ -51,7 +51,7 @@ const superstatic = function (spec = {}) { // Load data /** @type {import("./config").Configuration} */ - const config = (spec.config = loadConfigFile(spec.config)); + const config = (spec.config = loadConfigFile(spec.config, spec.autoConfig)); config.errorPage = config.errorPage ?? "/404.html"; // Set up provider diff --git a/test/integration/config-loading.spec.ts b/test/integration/config-loading.spec.ts new file mode 100644 index 00000000..7222b976 --- /dev/null +++ b/test/integration/config-loading.spec.ts @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2026 Google LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import * as fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect } from "chai"; +import connect from "connect"; +import request from "supertest"; + +import superstatic from "../../src/"; + +describe("explicit middleware configuration", () => { + let originalCwd: string; + let directory: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + directory = await fs.mkdtemp(join(tmpdir(), "superstatic-config-")); + await fs.mkdir(join(directory, "public")); + await fs.writeFile( + join(directory, "public", "index.html"), + "explicit config", + ); + process.chdir(directory); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.rm(directory, { recursive: true, force: true }); + }); + + for (const filename of ["superstatic.json", "firebase.json"]) { + it(`does not inherit headers or rewrites from ${filename} with autoConfig disabled`, async () => { + const fileConfig = { + headers: [ + { source: "**", headers: [{ key: "X-Autoloaded", value: "yes" }] }, + ], + rewrites: [{ source: "**", destination: "/index.html" }], + }; + await fs.writeFile( + filename, + JSON.stringify( + filename === "firebase.json" ? { hosting: fileConfig } : fileConfig, + ), + ); + const options = { + autoConfig: false, + fallthrough: false, + cwd: directory, + config: { + public: "public", + redirects: [{ source: "/to-index", destination: "/index.html" }], + }, + }; + const app = connect().use(superstatic(options)); + + const response = await request(app) + .get("/index.html") + .expect(200) + .expect("explicit config"); + expect(response.headers).not.to.have.property("x-autoloaded"); + await request(app) + .get("/to-index") + .expect(301) + .expect("Location", "/index.html"); + await request(app).get("/missing").expect(404); + }); + } +}); diff --git a/test/unit/loaders/config-file.spec.js b/test/unit/loaders/config-file.spec.js index dbba00f9..db278040 100644 --- a/test/unit/loaders/config-file.spec.js +++ b/test/unit/loaders/config-file.spec.js @@ -120,4 +120,67 @@ describe("loading config files", () => { await fs.rm("firebase.json"); }); }); + describe("without automatic config discovery", () => { + let originalCwd; + + beforeEach(() => { + originalCwd = process.cwd(); + process.chdir(".tmp"); + }); + + afterEach(() => { + process.chdir(originalCwd); + }); + + for (const filename of ["superstatic.json", "firebase.json"]) { + it(`does not merge ${filename} into an explicit object`, async () => { + const fileConfig = { public: "default", headers: [{ source: "**" }] }; + await fs.writeFile( + filename, + JSON.stringify( + filename === "firebase.json" ? { hosting: fileConfig } : fileConfig, + ), + ); + + expect(loadConfigFile({ public: "app" }, false)).to.eql({ + public: "app", + }); + expect(loadConfigFile({}, false)).to.eql({}); + expect(loadConfigFile(undefined, false)).to.eql({}); + }); + + for (const autoConfig of [undefined, true]) { + it(`still merges ${filename} when autoConfig is ${autoConfig}`, async () => { + const fileConfig = { public: "default", cleanUrls: true }; + await fs.writeFile( + filename, + JSON.stringify( + filename === "firebase.json" + ? { hosting: fileConfig } + : fileConfig, + ), + ); + + expect(loadConfigFile({ public: "app" }, autoConfig)).to.eql({ + public: "app", + cleanUrls: true, + }); + }); + } + } + + it("still loads an explicit config filename", async () => { + await fs.writeFile( + "custom.json", + JSON.stringify({ hosting: { public: "app" } }), + ); + expect(loadConfigFile("custom.json", false)).to.eql({ public: "app" }); + }); + + it("still loads a stringified config object", () => { + expect(loadConfigFile(JSON.stringify({ public: "app" }), false)).to.eql({ + public: "app", + }); + }); + }); }); From 5edcfd096053cc4d8b5ed350c056ce907aae15dc Mon Sep 17 00:00:00 2001 From: jabrailkhalil Date: Sun, 4 Oct 2026 02:56:31 +0300 Subject: [PATCH 2/2] fix: merge stringified config with discovered defaults --- src/loaders/config-file.js | 9 +++------ test/unit/loaders/config-file.spec.js | 26 +++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/loaders/config-file.js b/src/loaders/config-file.js index eaaa482b..7860a1b4 100644 --- a/src/loaders/config-file.js +++ b/src/loaders/config-file.js @@ -40,6 +40,9 @@ module.exports = function (filename, autoConfig = true) { // From custom config data passed in try { configObject = JSON.parse(filename); + if (isPlainObject(configObject)) { + filename = defaultFiles; + } } catch { if (isPlainObject(filename)) { configObject = filename; @@ -53,12 +56,6 @@ module.exports = function (filename, autoConfig = true) { }); } - // Set back to default config file if stringified object is - // given as config. With this, we override values in the config file - if (isPlainObject(filename)) { - filename = defaultFiles; - } - // A file name or array of file names if (typeof filename === "string" && filename.endsWith("json")) { try { diff --git a/test/unit/loaders/config-file.spec.js b/test/unit/loaders/config-file.spec.js index db278040..f38efb6d 100644 --- a/test/unit/loaders/config-file.spec.js +++ b/test/unit/loaders/config-file.spec.js @@ -120,7 +120,7 @@ describe("loading config files", () => { await fs.rm("firebase.json"); }); }); - describe("without automatic config discovery", () => { + describe("automatic config discovery", () => { let originalCwd; beforeEach(() => { @@ -145,6 +145,11 @@ describe("loading config files", () => { expect(loadConfigFile({ public: "app" }, false)).to.eql({ public: "app", }); + expect(loadConfigFile(JSON.stringify({ public: "app" }), false)).to.eql( + { + public: "app", + }, + ); expect(loadConfigFile({}, false)).to.eql({}); expect(loadConfigFile(undefined, false)).to.eql({}); }); @@ -166,6 +171,25 @@ describe("loading config files", () => { cleanUrls: true, }); }); + + it(`merges a stringified object with ${filename} when autoConfig is ${autoConfig}`, async () => { + const fileConfig = { public: "default", cleanUrls: true }; + await fs.writeFile( + filename, + JSON.stringify( + filename === "firebase.json" + ? { hosting: fileConfig } + : fileConfig, + ), + ); + + expect( + loadConfigFile(JSON.stringify({ public: "app" }), autoConfig), + ).to.eql({ + public: "app", + cleanUrls: true, + }); + }); } }