@faable/faable 1.15.0 → 1.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -92,6 +92,12 @@ class FaableApi {
92
92
  image,
93
93
  }));
94
94
  }
95
+ // Fetch a deployment with its runtime status, so the deploy command can
96
+ // watch for a terminal failure (ERROR/BUILD_ERROR + reason) while polling
97
+ // for promotion — and fail the run fast instead of timing out green.
98
+ async getDeployment(deployment_id) {
99
+ return data(this.client.get(`/deployment/${deployment_id}`));
100
+ }
95
101
  // Attach the captured build/deploy output to a deployment. The base client
96
102
  // timeout (10s) is too short for a multi-MB body on a slow uplink.
97
103
  async uploadDeploymentLogs(deployment_id, body) {
@@ -1,8 +1,8 @@
1
- import fs from 'fs-extra';
2
1
  import path__default from 'path';
3
2
  import { cmd } from '../../../../lib/cmd.js';
4
3
  import { log } from '../../../../log.js';
5
4
  import { has_any_of_files } from '../shared/has_any_of_files.js';
5
+ import { read_json_file } from '../shared/read_text_file.js';
6
6
 
7
7
  // A package.json with a next dependency beside the Dockerfile means this is a
8
8
  // Next.js app shipped with a custom image: emit type "next" so the backend
@@ -10,7 +10,7 @@ import { has_any_of_files } from '../shared/has_any_of_files.js';
10
10
  // --buildpack override, since the node buildpack claims package.json first.
11
11
  const detect_next = (workdir) => {
12
12
  try {
13
- const pkg = fs.readJSONSync(path__default.join(workdir, "package.json"));
13
+ const pkg = read_json_file(path__default.join(workdir, "package.json"));
14
14
  return Boolean(pkg?.dependencies?.next || pkg?.devDependencies?.next);
15
15
  }
16
16
  catch {
@@ -1,13 +1,13 @@
1
- import fs from 'fs-extra';
2
1
  import path__default from 'path';
3
2
  import { log } from '../../../../log.js';
3
+ import { read_json_file } from '../shared/read_text_file.js';
4
4
  import { detect_framework } from './frameworks.js';
5
5
 
6
6
  const analyze_package = async (params) => {
7
7
  const workdir = params.workdir;
8
8
  const package_file = path__default.join(path__default.resolve(workdir), "package.json");
9
9
  log.info(`Loading config from package.json`);
10
- const pkg = await fs.readJSON(package_file);
10
+ const pkg = read_json_file(package_file);
11
11
  // Check if build is required to run
12
12
  const build_script = process.env.FAABLE_NPM_BUILD_SCRIPT
13
13
  ? process.env.FAABLE_NPM_BUILD_SCRIPT
@@ -1,7 +1,7 @@
1
- import fs from 'fs-extra';
2
1
  import path__default from 'path';
3
2
  import * as R from 'ramda';
4
3
  import { log } from '../../../../log.js';
4
+ import { read_json_file } from '../shared/read_text_file.js';
5
5
 
6
6
  const has_dep = (pkg, name) => Boolean(R.view(R.lensPath(["dependencies", name]), pkg) ||
7
7
  R.view(R.lensPath(["devDependencies", name]), pkg));
@@ -13,7 +13,7 @@ const has_dep = (pkg, name) => Boolean(R.view(R.lensPath(["dependencies", name])
13
13
  const resolve_angular_output = (workdir) => {
14
14
  const fallback = "dist";
15
15
  try {
16
- const angular_json = fs.readJSONSync(path__default.join(workdir, "angular.json"));
16
+ const angular_json = read_json_file(path__default.join(workdir, "angular.json"));
17
17
  const projects = angular_json?.projects ?? {};
18
18
  const project_name = angular_json?.defaultProject ?? Object.keys(projects)[0];
19
19
  const build = projects?.[project_name]?.architect?.build;
@@ -1,7 +1,7 @@
1
- import fs from 'fs-extra';
2
1
  import path__default from 'path';
3
2
  import { cmd } from '../../../../lib/cmd.js';
4
3
  import { log } from '../../../../log.js';
4
+ import { read_json_file } from '../shared/read_text_file.js';
5
5
 
6
6
  const getCurrentNodeVersion = async () => {
7
7
  const out = await cmd(`node --version`);
@@ -16,7 +16,7 @@ const getCurrentNodeVersion = async () => {
16
16
  */
17
17
  const resolve_node_version = async (workdir) => {
18
18
  const packageJSONFile = path__default.join(workdir, "package.json");
19
- const { name, engines } = fs.readJSONSync(packageJSONFile);
19
+ const { name, engines } = read_json_file(packageJSONFile);
20
20
  if (!name) {
21
21
  throw new Error("Missing name in package.json");
22
22
  }
@@ -3,6 +3,7 @@ import path__default from 'path';
3
3
  import { log } from '../../../../log.js';
4
4
  import { render_dockerfile, build_image } from '../shared/docker_image.js';
5
5
  import { has_any_of_files } from '../shared/has_any_of_files.js';
6
+ import { read_text_file } from '../shared/read_text_file.js';
6
7
  import { cerebrium_provider } from './providers/cerebrium.js';
7
8
  import { pipfile_provider } from './providers/pipfile.js';
8
9
  import { pyproject_provider } from './providers/pyproject.js';
@@ -89,7 +90,7 @@ const python_buildpack = {
89
90
  // Framework detection blob = the entry files themselves: an
90
91
  // `from fastapi import FastAPI` line carries the token detection needs.
91
92
  const deps = entries
92
- .map((f) => fs.readFileSync(path__default.join(ctx.workdir, f)).toString())
93
+ .map((f) => read_text_file(path__default.join(ctx.workdir, f)))
93
94
  .join("\n")
94
95
  .toLowerCase();
95
96
  const { start_command, server } = resolve_start(ctx.workdir, deps, ctx.config);
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs-extra';
2
2
  import path__default from 'path';
3
+ import { read_text_file } from '../shared/read_text_file.js';
3
4
 
4
5
  /**
5
6
  * Read the `web:` process command from a Procfile, if present. Returns null when
@@ -10,7 +11,7 @@ const parse_procfile = (workdir) => {
10
11
  const procfile = path__default.join(workdir, "Procfile");
11
12
  if (!fs.existsSync(procfile))
12
13
  return null;
13
- const content = fs.readFileSync(procfile).toString();
14
+ const content = read_text_file(procfile);
14
15
  for (const line of content.split("\n")) {
15
16
  const match = line.match(/^\s*web\s*:\s*(.+?)\s*$/);
16
17
  if (match?.[1])
@@ -1,5 +1,5 @@
1
- import fs from 'fs-extra';
2
1
  import path__default from 'path';
2
+ import { read_text_file } from '../../shared/read_text_file.js';
3
3
  import { parse_cerebrium_toml } from './parse_cerebrium_toml.js';
4
4
 
5
5
  /**
@@ -12,7 +12,7 @@ const cerebrium_provider = {
12
12
  name: "cerebrium",
13
13
  files: ["cerebrium.toml"],
14
14
  resolve(workdir) {
15
- const manifest = parse_cerebrium_toml(fs.readFileSync(path__default.join(workdir, "cerebrium.toml")).toString());
15
+ const manifest = parse_cerebrium_toml(read_text_file(path__default.join(workdir, "cerebrium.toml")));
16
16
  let install_command;
17
17
  let install_files;
18
18
  if (manifest.pip_requirements_file) {
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs-extra';
2
2
  import path__default from 'path';
3
+ import { read_text_file } from '../shared/read_text_file.js';
3
4
 
4
5
  const DEFAULT_VERSION = "3.11.3";
5
6
  /**
@@ -13,7 +14,7 @@ const DEFAULT_VERSION = "3.11.3";
13
14
  const resolve_python_version = (workdir, provider_hint) => {
14
15
  const runtime_config = path__default.join(workdir, "runtime.txt");
15
16
  if (fs.existsSync(runtime_config)) {
16
- const runtime_data = fs.readFileSync(runtime_config).toString().trim();
17
+ const runtime_data = read_text_file(runtime_config).trim();
17
18
  if (!runtime_data.startsWith("python-")) {
18
19
  throw new Error("runtime.txt must have runtime format with python-<version>");
19
20
  }
@@ -21,7 +22,7 @@ const resolve_python_version = (workdir, provider_hint) => {
21
22
  }
22
23
  const python_version_file = path__default.join(workdir, ".python-version");
23
24
  if (fs.existsSync(python_version_file)) {
24
- const version = fs.readFileSync(python_version_file).toString().trim();
25
+ const version = read_text_file(python_version_file).trim();
25
26
  if (version)
26
27
  return version;
27
28
  }
@@ -29,7 +30,7 @@ const resolve_python_version = (workdir, provider_hint) => {
29
30
  return provider_hint;
30
31
  const pyproject = path__default.join(workdir, "pyproject.toml");
31
32
  if (fs.existsSync(pyproject)) {
32
- const toml = fs.readFileSync(pyproject).toString();
33
+ const toml = read_text_file(pyproject);
33
34
  const match = toml.match(/requires-python\s*=\s*["'][^0-9]*([0-9]+\.[0-9]+(?:\.[0-9]+)?)/);
34
35
  if (match?.[1])
35
36
  return match[1];
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs-extra';
2
2
  import path__default from 'path';
3
3
  import { log } from '../../../../log.js';
4
+ import { read_text_file } from '../shared/read_text_file.js';
4
5
  import { parse_procfile } from './parse_procfile.js';
5
6
 
6
7
  /** Combine all classic dependency manifests into one lowercased blob. */
@@ -9,7 +10,7 @@ const read_dependencies_text = (workdir) => {
9
10
  return files
10
11
  .map((f) => path__default.join(workdir, f))
11
12
  .filter((p) => fs.existsSync(p))
12
- .map((p) => fs.readFileSync(p).toString())
13
+ .map((p) => read_text_file(p))
13
14
  .join("\n")
14
15
  .toLowerCase();
15
16
  };
@@ -59,7 +60,7 @@ const find_app_module = (workdir, pattern) => {
59
60
  const module = rel.replace(/\.py$/, "").split(path__default.sep).join(".");
60
61
  if (!first_existing)
61
62
  first_existing = module;
62
- if (pattern.test(fs.readFileSync(abs).toString()))
63
+ if (pattern.test(read_text_file(abs)))
63
64
  return module;
64
65
  }
65
66
  return first_existing;
@@ -0,0 +1,31 @@
1
+ import fs from 'fs-extra';
2
+
3
+ /**
4
+ * Decode a manifest/source buffer to a string, honoring a leading BOM.
5
+ *
6
+ * Editors and shells on Windows (PowerShell `>` redirection, Notepad "Save As")
7
+ * frequently write files as UTF‑16 with a BOM. Reading those as UTF‑8 yields
8
+ * mojibake with interleaved NUL bytes (`f\x00a\x00s\x00t…`), which silently
9
+ * breaks token detection — e.g. `\bfastapi\b` never matches a UTF‑16
10
+ * `requirements.txt`, so the Python buildpack skips framework detection and
11
+ * fails with "Could not detect how to start this Python app". Detect the BOM and
12
+ * decode accordingly, stripping the BOM from the result.
13
+ */
14
+ const decode_buffer = (buf) => {
15
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
16
+ return new TextDecoder("utf-16le").decode(buf.subarray(2));
17
+ }
18
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
19
+ return new TextDecoder("utf-16be").decode(buf.subarray(2));
20
+ }
21
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
22
+ return buf.subarray(3).toString("utf8");
23
+ }
24
+ return buf.toString("utf8");
25
+ };
26
+ /** Read a file to a string, decoding UTF‑16/UTF‑8 BOMs (see {@link decode_buffer}). */
27
+ const read_text_file = (file) => decode_buffer(fs.readFileSync(file));
28
+ /** Read and parse a JSON file, tolerating a UTF‑16/UTF‑8 BOM. */
29
+ const read_json_file = (file) => JSON.parse(read_text_file(file));
30
+
31
+ export { decode_buffer, read_json_file, read_text_file };
@@ -112,6 +112,7 @@ const deploy = {
112
112
  const start = Date.now();
113
113
  log.info(`⏳ Waiting for deployment to be promoted...`);
114
114
  let promoted = false;
115
+ let failure = null;
115
116
  while (Date.now() - start < timeoutMs) {
116
117
  await wait(intervalMs);
117
118
  try {
@@ -120,6 +121,16 @@ const deploy = {
120
121
  promoted = true;
121
122
  break;
122
123
  }
124
+ // Watch for a terminal runtime failure so we fail fast (and red)
125
+ // instead of timing out green. The controller marks a crash-looping
126
+ // deployment ERROR — with a reason (e.g. a missing module) — within
127
+ // ~1min, long before this 5min promotion timeout.
128
+ const dep = await api.getDeployment(deployment.id);
129
+ const phase = dep.status?.phase;
130
+ if (phase === "ERROR" || phase === "BUILD_ERROR") {
131
+ failure = { phase, reason: dep.status?.reason };
132
+ break;
133
+ }
123
134
  }
124
135
  catch (_error) {
125
136
  // Ignore transient errors while polling and keep waiting
@@ -129,6 +140,15 @@ const deploy = {
129
140
  if (promoted) {
130
141
  log.info(`🌍 Deployment promoted and live, visit: https://${app.url}`);
131
142
  }
143
+ else if (failure) {
144
+ // Fail the command (non-zero exit → red GitHub Action) and surface the
145
+ // reason the controller captured so the user sees WHY without digging.
146
+ log.error(`❌ Deployment failed (${failure.phase}).`);
147
+ if (failure.reason)
148
+ log.error(failure.reason);
149
+ log.error(`Check the logs in the dashboard -> ${dashboard_url}`);
150
+ throw new Error(`Deployment ${deployment.id} failed with phase ${failure.phase}`);
151
+ }
132
152
  else {
133
153
  log.warn(`⌛ Timed out after 5min waiting for promotion. The deployment is still rolling out, check the dashboard -> ${dashboard_url}`);
134
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.15.0",
3
+ "version": "1.15.2",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",