Repository: firebase/firebase-tools (ts)

Issue:
Redirect all requests to a Function or Cloud Run service without any public directory
<!-- DO NOT DELETE
validate_template=false
template_path=.github/ISSUE_TEMPLATE/feature_request.md
-->
I originally [asked this question on Stackoverflow](https://stackoverflow.com/questions/67451582/how-to-redirect-all-requests-to-a-function-or-cloud-run-service-without-any-publ) but I was advised by @mbleigh to open a Feature Request here.

I am using Firebase Hosting for many websites. Some websites have static files that I want to serve from Firebase Hosting static file serving, however, some others only have dynamic content for which I want to leverage Firebase's custom domain and caching.

It seems that the `public` attribute is required in  `firebase.json` . This requires me to deploy a public folder with a dummy file in it.

It would be great to allow deploying to Firebase Hosting without any `public` directory,but with at least one `function` or `run` redirect.

The following gives me this error: `Error: Must supply a public directory using "public" in each "hosting" config.`

```
{
    "hosting": [
        {
            "target": "index",
            "public": "static",
            "rewrites": [
                {
                    "source": "**",
                    "destination": "/index.html"
                }
            ]
        },
        {
            "target": "api",
            "rewrites": [
                {
                    "source": "**",
                    "run": {
                        "serviceId": "api",
                        "region": "us-central1"
                    }
                }
            ]
        }
    ]
}
```



Pull request description:
Allow Hosting deploys with no `public` dir in some circumstances.
### Description

Allows no `public` directory:

- When dynamic rewrites are present (and no static rewrites).
- When redirects are present.

Also refactors and TypeScript-ifies some of the Hosting deploy code.

### Scenarios Tested

- Deployed a standard site with a `public` directory.
- Deployed a site with no `public` dir but with a `function` rewrite.
- Deployed a site with no `public` dir but with a `run` rewrite.
- Deployed a site with no `public` dir but with a redirect.
- Deployed a multi-site (just to make sure it was still working)

Fixes #3349

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45d0d2741..5233ef283 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,4 @@
 - Adds Cloud Storage for Firebase emulator
+- Allows Hosting deploys with no `public` directory when dynamic rewrites or redirects are present. (#3349)
 - Fixes error where Hosting gets in an undeployable state due to the hash cache having incorrect entries.
 - Improves error handling for the throttler queue, to better diagnose Hosting deploys that fail during the file upload step.
diff --git a/src/deploy/hosting/deploy.js b/src/deploy/hosting/deploy.js
deleted file mode 100644
index 7b6a2cf82..000000000
--- a/src/deploy/hosting/deploy.js
+++ /dev/null
@@ -1,90 +0,0 @@
-"use strict";
-
-const { Uploader } = require("./uploader");
-const { detectProjectRoot } = require("../../detectProjectRoot");
-var { listFiles } = require("../../listFiles");
-const { logger } = require("../../logger");
-var track = require("../../track");
-var utils = require("../../utils");
-
-var clc = require("cli-color");
-var SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
-
-// Feature from cli-color 2.0.0 that we want to use:
-// See: https://github.com/medikoo/cli-color/blob/master/erase.js
-var _ERASE_LINE = "\x1b[2K";
-
-module.exports = function (context, options) {
-  if (!context.hosting || !context.hosting.deploys) {
-    return Promise.resolve();
-  }
-
-  var spins = 0;
-  function _updateSpinner(newMessage) {
-    // don't try to rewrite lines if debugging since it's likely to get interrupted
-    if (debugging) {
-      utils.logLabeledBullet("hosting", newMessage);
-    } else {
-      process.stdout.write(_ERASE_LINE + clc.move(-9999, 0));
-      process.stdout.write(
-        clc.bold.cyan(SPINNER[spins % SPINNER.length] + "  hosting: ") + newMessage
-      );
-    }
-    spins++;
-  }
-
-  function _runDeploys(deploys, debugging) {
-    const deploy = deploys.shift();
-
-    utils.logLabeledBullet("hosting[" + deploy.site + "]", "beginning deploy...");
-    var t0 = Date.now();
-
-    const publicDir = options.config.path(deploy.config.public);
-    const files = listFiles(publicDir, deploy.config.ignore);
-
-    utils.logLabeledBullet(
-      "hosting[" + deploy.site + "]",
-      "found " + files.length + " files in " + clc.bold(deploy.config.public)
-    );
-    const uploader = new Uploader({
-      version: deploy.version,
-      files: files,
-      public: options.config.path(deploy.config.public),
-      cwd: options.cwd,
-      projectRoot: detectProjectRoot(options),
-    });
-
-    var progressInterval = setInterval(
-      function () {
-        _updateSpinner(uploader.statusMessage());
-      },
-      debugging ? 2000 : 200
-    );
-
-    return uploader
-      .start()
-      .then(function () {
-        clearInterval(progressInterval);
-        if (!debugging) {
-          process.stdout.write(_ERASE_LINE + clc.move(-9999, 0));
-        }
-        utils.logLabeledSuccess("hosting[" + deploy.site + "]", "file upload complete");
-        var dt = Date.now() - t0;
-        logger.debug("[hosting] deploy completed after " + dt + "ms");
-        return track("Hosting Deploy", "success", dt);
-      })
-      .catch(function (err) {
-        clearInterval(progressInterval);
-        return Promise.reject(err);
-      })
-      .then(function () {
-        if (deploys.length) {
-          return _runDeploys(deploys);
-        }
-      });
-  }
-
-  var debugging = options.debug || options.nonInteractive;
-  const deploys = [].concat(context.hosting.deploys);
-  return _runDeploys(deploys, debugging);
-};
diff --git a/src/deploy/hosting/deploy.ts b/src/deploy/hosting/deploy.ts
new file mode 100644
index 000000000..e90541347
--- /dev/null
+++ b/src/deploy/hosting/deploy.ts
@@ -0,0 +1,107 @@
+import { Uploader } from "./uploader";
+import { detectProjectRoot } from "../../detectProjectRoot";
+import { listFiles } from "../../listFiles";
+import { logger } from "../../logger";
+import * as track from "../../track";
+import { logLabeledBullet, logLabeledSuccess } from "../../utils";
+import { HostingDeploy } from "./hostingDeploy";
+
+import * as clc from "cli-color";
+const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
+
+// Feature from cli-color 2.0.0 that we want to use:
+// See: https://github.com/medikoo/cli-color/blob/master/erase.js
+const _ERASE_LINE = "\x1b[2K";
+
+export async function deploy(
+  context: { hosting?: { deploys?: HostingDeploy[] } },
+  options: {
+    cwd?: string;
+    configPath?: string;
+    debug?: boolean;
+    nonInteractive?: boolean;
+    config: { path: (path: string) => string };
+  }
+): Promise<void> {
+  if (!context.hosting?.deploys) {
+    return;
+  }
+
+  let spins = 0;
+  function updateSpinner(newMessage: string, debugging: boolean): void {
+    // don't try to rewrite lines if debugging since it's likely to get interrupted
+    if (debugging) {
+      logLabeledBullet("hosting", newMessage);
+    } else {
+      process.stdout.write(_ERASE_LINE + clc.move(-9999, 0));
+      process.stdout.write(
+        clc.bold.cyan(SPINNER[spins % SPINNER.length] + "  hosting: ") + newMessage
+      );
+    }
+    spins++;
+  }
+
+  async function runDeploys(deploys: HostingDeploy[], debugging: boolean): Promise<void> {
+    const deploy = deploys.shift();
+    if (!deploy) {
+      return;
+    }
+
+    // No need to run Uploader for no-file deploys
+    if (!deploy.config?.public) {
+      logLabeledBullet(
+        `hosting[${deploy.site}]`,
+        'no "public" directory to upload, continuing with release'
+      );
+      return runDeploys(deploys, debugging);
+    }
+
+    logLabeledBullet("hosting[" + deploy.site + "]", "beginning deploy...");
+    const t0 = Date.now();
+
+    const publicDir = options.config.path(deploy.config.public);
+    const files = listFiles(publicDir, deploy.config.ignore);
+
+    logLabeledBullet(
+      `hosting[${deploy.site}]`,
+      `found ${files.length} files in ${clc.bold(deploy.config.public)}`
+    );
+
+    const uploader = new Uploader({
+      version: deploy.version,
+      files: files,
+      public: publicDir,
+      cwd: options.cwd,
+      projectRoot: detectProjectRoot(options),
+    });
+
+    const progressInterval = setInterval(
+      () => updateSpinner(uploader.statusMessage(), debugging),
+      debugging ? 2000 : 200
+    );
+
+    try {
+      await uploader.start();
+    } catch (err) {
+      track("Hosting Deploy", "failure");
+      throw err;
+    } finally {
+      clearInterval(progressInterval);
+    }
+
+    if (!debugging) {
+      process.stdout.write(_ERASE_LINE + clc.move(-9999, 0));
+    }
+
+    logLabeledSuccess("hosting[" + deploy.site + "]", "file upload complete");
+    const dt = Date.now() - t0;
+    logger.debug("[hosting] deploy completed after " + dt + "ms");
+
+    track("Hosting Deploy", "success", dt);
+    return runDeploys(deploys, debugging);
+  }
+
+  const debugging = !!(options.debug || options.nonInteractive);
+  const deploys = [...(context.hosting?.deploys || [])];
+  return runDeploys(deploys, debugging);
+}
diff --git a/src/deploy/hosting/hostingDeploy.ts b/src/deploy/hosting/hostingDeploy.ts
new file mode 100644
index 000000000..21aefb410
--- /dev/null
+++ b/src/deploy/hosting/hostingDeploy.ts
@@ -0,0 +1,21 @@
+// NOTE: This type is incomplete and contains only config necessary for validation.
+export interface HostingConfig {
+  ignore?: string[];
+  public?: string;
+  rewrites?: {
+    source: string;
+    destination?: string;
+    function?: string;
+    run?: { serviceId: string; location?: string };
+  }[];
+  redirects?: {
+    source: string;
+  }[];
+  i18n?: { root: string };
+}
+
+export interface HostingDeploy {
+  site: string;
+  version?: string;
+  config: HostingConfig;
+}
diff --git a/src/deploy/hosting/index.js b/src/deploy/hosting/index.js
deleted file mode 100644
index 8368af69d..000000000
--- a/src/deploy/hosting/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-"use strict";
-
-module.exports = {
-  prepare: require("./prepare"),
-  deploy: require("./deploy"),
-  release: require("./release"),
-};
diff --git a/src/deploy/hosting/index.ts b/src/deploy/hosting/index.ts
new file mode 100644
index 000000000..7fc7e8a33
--- /dev/null
+++ b/src/deploy/hosting/index.ts
@@ -0,0 +1,5 @@
+import * as prepare from "./prepare";
+import { deploy } from "./deploy";
+import * as release from "./release";
+
+export { prepare, deploy, release };
diff --git a/src/deploy/hosting/prepare.js b/src/deploy/hosting/prepare.js
index 0ea82aeec..35f6c8798 100644
--- a/src/deploy/hosting/prepare.js
+++ b/src/deploy/hosting/prepare.js
@@ -1,17 +1,13 @@
 "use strict";
 
 const _ = require("lodash");
-const clc = require("cli-color");
-const path = require("path");
 
 const api = require("../../api");
 const convertConfig = require("./convertConfig");
 const deploymentTool = require("../../deploymentTool");
 const { FirebaseError } = require("../../error");
-const utils = require("../../utils");
-const fsutils = require("../../fsutils");
 const { normalizedHostingConfigs } = require("../../hosting/normalizedHostingConfigs");
-const { resolveProjectPath } = require("../../projectPath");
+const { validateDeploy } = require("./validate");
 
 module.exports = function (context, options) {
   // Allow the public directory to be overridden by the --public flag
@@ -39,35 +35,7 @@ module.exports = function (context, options) {
   _.each(context.hosting.deploys, function (deploy) {
     const cfg = deploy.config;
 
-    if (!cfg.public) {
-      throw new FirebaseError(
-        'Must supply a public directory using "public" in each "hosting" config.'
-      );
-    }
-
-    if (!fsutils.dirExistsSync(resolveProjectPath(options, cfg.public))) {
-      throw new FirebaseError(
-        `Specified public directory '${cfg.public}' does not exist, ` +
-          `can't deploy hosting to site ${deploy.site}`,
-        { exit: 1 }
-      );
-    }
-
-    if (cfg.i18n) {
-      if (!cfg.i18n.root) {
-        throw new FirebaseError("The root in the i18n config can't be empty.");
-      } else {
-        const i18nPath = path.join(cfg.public, cfg.i18n.root);
-        if (!fsutils.dirExistsSync(resolveProjectPath(options, i18nPath))) {
-          utils.logLabeledWarning(
-            "hosting",
-            `Couldn't find specified i18n root directory ${clc.bold(
-              cfg.i18n.root
-            )} in public directory ${clc.bold(cfg.public)}.`
-          );
-        }
-      }
-    }
+    validateDeploy(deploy, options);
 
     const data = {
       config: convertConfig(cfg),
diff --git a/src/deploy/hosting/validate.ts b/src/deploy/hosting/validate.ts
new file mode 100644
index 000000000..3773a4d11
--- /dev/null
+++ b/src/deploy/hosting/validate.ts
@@ -0,0 +1,53 @@
+import * as path from "path";
+import * as clc from "cli-color";
+
+import { FirebaseError } from "../../error";
+import { resolveProjectPath } from "../../projectPath";
+import { dirExistsSync } from "../../fsutils";
+import { logLabeledWarning } from "../../utils";
+import { HostingDeploy } from "./hostingDeploy";
+
+export function validateDeploy(deploy: HostingDeploy, options: any) {
+  const cfg = deploy.config;
+
+  const hasPublicDir = !!cfg.public;
+  const hasAnyStaticRewrites = !!(cfg.rewrites || []).filter((rw) => rw.destination)?.length;
+  const hasAnyDynamicRewrites = !!(cfg.rewrites || []).filter((rw) => !rw.destination)?.length;
+  const hasAnyRedirects = !!cfg.redirects?.length;
+
+  if (!hasPublicDir && hasAnyStaticRewrites) {
+    throw new FirebaseError('Must supply a "public" directory when using "destination" rewrites.');
+  }
+
+  if (!hasPublicDir && !hasAnyDynamicRewrites && !hasAnyRedirects) {
+    throw new FirebaseError(
+      'Must supply a "public" directory or at least one rewrite or redirect in each "hosting" config.'
+    );
+  }
+
+  if (hasPublicDir && !dirExistsSync(resolveProjectPath(options, cfg.public!))) {
+    throw new FirebaseError(
+      `Specified "public" directory "${cfg.public}" does not exist, can't deploy hosting to site "${deploy.site}"`
+    );
+  }
+
+  if (cfg.i18n) {
+    if (!hasPublicDir) {
+      throw new FirebaseError('Must supply a "public" directory when using "i18n" configuration.');
+    }
+
+    if (!cfg.i18n.root) {
+      throw new FirebaseError('Must supply a "root" in "i18n" config.');
+    } else {
+      const i18nPath = path.join(cfg.public!, cfg.i18n.root);
+      if (!dirExistsSync(resolveProjectPath(options, i18nPath))) {
+        logLabeledWarning(
+          "hosting",
+          `Couldn't find specified i18n root directory ${clc.bold(
+            cfg.i18n.root
+          )} in public directory ${clc.bold(cfg.public)}.`
+        );
+      }
+    }
+  }
+}


Test patch:
diff --git a/src/test/deploy/hosting/validate.spec.ts b/src/test/deploy/hosting/validate.spec.ts
new file mode 100644
index 000000000..eb6bf4811
--- /dev/null
+++ b/src/test/deploy/hosting/validate.spec.ts
@@ -0,0 +1,74 @@
+import { expect } from "chai";
+import { validateDeploy } from "../../../deploy/hosting/validate";
+
+const PUBLIC_DIR_ERROR_PREFIX = 'Must supply a "public" directory';
+
+describe("validateDeploy()", () => {
+  function testDeploy(merge: any) {
+    return () => {
+      validateDeploy(
+        {
+          site: "test-site",
+          version: "abc123",
+          config: { ...merge },
+        },
+        {
+          cwd: __dirname + "/../../fixtures/simplehosting",
+          configPath: __dirname + "/../../fixtures/simplehosting/firebase.json",
+        }
+      );
+    };
+  }
+
+  it("should error out if there is no public directory but a 'destination' rewrite", () => {
+    expect(
+      testDeploy({
+        rewrites: [
+          { source: "/foo", destination: "/bar.html" },
+          { source: "/baz", function: "app" },
+        ],
+      })
+    ).to.throw(PUBLIC_DIR_ERROR_PREFIX);
+  });
+
+  it("should error out if there is no public directory and no rewrites or redirects", () => {
+    expect(testDeploy({})).to.throw(PUBLIC_DIR_ERROR_PREFIX);
+  });
+
+  it("should error out if there is no public directory and an i18n with root", () => {
+    expect(
+      testDeploy({
+        i18n: { root: "/foo" },
+        rewrites: [{ source: "/foo", function: "pass" }],
+      })
+    ).to.throw(PUBLIC_DIR_ERROR_PREFIX);
+  });
+
+  it("should error out if there is a public directory and an i18n with no root", () => {
+    expect(
+      testDeploy({
+        public: "public",
+        i18n: {},
+        rewrites: [{ source: "/foo", function: "pass" }],
+      })
+    ).to.throw('Must supply a "root"');
+  });
+
+  it("should pass with public and nothing else", () => {
+    expect(testDeploy({ public: "public" })).not.to.throw();
+  });
+
+  it("should pass with no public but a function rewrite", () => {
+    expect(testDeploy({ rewrites: [{ source: "/", function: "app" }] })).not.to.throw();
+  });
+
+  it("should pass with no public but a run rewrite", () => {
+    expect(testDeploy({ rewrites: [{ source: "/", run: { serviceId: "app" } }] })).not.to.throw();
+  });
+
+  it("should pass with no public but a redirect", () => {
+    expect(
+      testDeploy({ redirects: [{ source: "/", destination: "https://google.com/", type: 302 }] })
+    ).not.to.throw();
+  });
+});
diff --git a/src/test/fixtures/simplehosting/firebase.json b/src/test/fixtures/simplehosting/firebase.json
new file mode 100644
index 000000000..9c1c0c967
--- /dev/null
+++ b/src/test/fixtures/simplehosting/firebase.json
@@ -0,0 +1,5 @@
+{
+  "hosting": {
+    "public": "public"
+  }
+}
diff --git a/src/test/fixtures/simplehosting/public/index.html b/src/test/fixtures/simplehosting/public/index.html
new file mode 100644
index 000000000..ce0136250
--- /dev/null
+++ b/src/test/fixtures/simplehosting/public/index.html
@@ -0,0 +1,1 @@
+hello