@faable/faable 1.15.4 → 1.17.0

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.
@@ -7,6 +7,7 @@ import { build_project } from './build_project.js';
7
7
  import { ensure_dependencies } from './ensure_dependencies.js';
8
8
  import { inject_serve } from './inject_serve.js';
9
9
  import { resolve_node_version } from './node_version.js';
10
+ import { wrap_next_config } from './wrap_next_config.js';
10
11
 
11
12
  const BANNER = `NODE_VERSION=$(node --version)
12
13
  NPM_VERSION=$(npm --version)
@@ -35,7 +36,15 @@ const node_buildpack = {
35
36
  };
36
37
  },
37
38
  async build(ctx, plan) {
38
- const env = R.fromPairs(ctx.env_vars.map((e) => [e.name, e.value]));
39
+ const env = {
40
+ ...R.fromPairs(ctx.env_vars.map((e) => [e.name, e.value])),
41
+ // Platform-injected build-time identity, mirroring the runtime env the
42
+ // controller sets on the pod. FAABLE_DEPLOY_ID (build id ≡ runtime id)
43
+ // is the deterministic Next.js buildId source — user secrets can't
44
+ // override either. See arch/deploy/version-skew-coexistence.md.
45
+ FAABLE_APP_ID: ctx.app.id,
46
+ FAABLE_DEPLOY_ID: ctx.deployment.id,
47
+ };
39
48
  log.info(`Building with env variables ${Object.keys(env).join(",")}`);
40
49
  // The workflow no longer runs `npm ci` — install here when needed, before
41
50
  // the build and before `COPY . .` packages node_modules into the image.
@@ -44,7 +53,16 @@ const node_buildpack = {
44
53
  const build_command = plan.build_script
45
54
  ? `npm run ${plan.build_script}`
46
55
  : ctx.config.buildCommand;
47
- await build_project({ command: build_command, env, cwd: ctx.workdir });
56
+ // Next: wrap next.config for the duration of the build so the buildId is
57
+ // the deployment id (transparent version-skew protection). Restored
58
+ // BEFORE the docker build — `COPY . .` must package the original config.
59
+ const wrapped = plan.type === "next" ? await wrap_next_config(ctx.workdir, env) : null;
60
+ try {
61
+ await build_project({ command: build_command, env, cwd: ctx.workdir });
62
+ }
63
+ finally {
64
+ await wrapped?.restore();
65
+ }
48
66
  // Frameworks without a bundled static server (CRA/Vue/Angular) need
49
67
  // `serve` installed into node_modules before packaging.
50
68
  if (plan.inject_serve) {
@@ -0,0 +1,138 @@
1
+ import { existsSync, rmSync, renameSync } from 'fs';
2
+ import { rename, writeFile, rm, readFile } from 'fs/promises';
3
+ import path__default from 'path';
4
+ import { log } from '../../../../log.js';
5
+
6
+ /**
7
+ * Transparent deterministic-buildId for Next.js apps
8
+ * (arch/deploy/version-skew-coexistence.md, Fase 1).
9
+ *
10
+ * Next reads no env var for its buildId — the only deterministic source is
11
+ * `generateBuildId` in the user's next.config. Rather than asking users to
12
+ * edit their config, the buildpack wraps it for the duration of `next build`:
13
+ * the original file is renamed aside and replaced by a generated wrapper that
14
+ * resolves it (object | function | promise | async) and adds a
15
+ * `generateBuildId` reading FAABLE_DEPLOY_ID — ONLY when the user doesn't
16
+ * define their own.
17
+ *
18
+ * The wrapper is self-contained (no imports beyond the original config): the
19
+ * user's node_modules has no faable packages.
20
+ *
21
+ * Restoration is guaranteed: `restore()` runs in the caller's finally (before
22
+ * the docker build, so `COPY . .` packages the original config — `next start`
23
+ * loads next.config at runtime too), and a signal handler covers Ctrl-C
24
+ * mid-build. `next.config.ts` is skipped with a warning (it needs Next's own
25
+ * TS loader; not wrappable from a JS wrapper).
26
+ */
27
+ const CONFIG_FILES = ["next.config.js", "next.config.cjs", "next.config.mjs"];
28
+ const ORIGINAL_PREFIX = "next.config.faable-original";
29
+ const wrapperBody = (originalFile, flavor) => {
30
+ const resolver = `
31
+ const resolveConfig = async (original, phase, args) => {
32
+ let config = original && original.__esModule ? original.default : original;
33
+ if (typeof config === "function") config = config(phase, args);
34
+ config = await config;
35
+ config = config || {};
36
+ if (config.generateBuildId) return config; // user-defined wins, never override
37
+ return {
38
+ ...config,
39
+ // Deterministic buildId = platform deployment id (build id ≡ runtime id).
40
+ generateBuildId: () => process.env.FAABLE_DEPLOY_ID ?? null,
41
+ };
42
+ };`;
43
+ if (flavor === "cjs") {
44
+ return `// Generated by faable deploy for this build only — auto-restored afterwards.
45
+ // Adds a deterministic buildId (FAABLE_DEPLOY_ID) unless the config defines
46
+ // its own generateBuildId. See faable.com docs on version skew.
47
+ ${originalFile ? `const original = require("./${originalFile}");` : `const original = {};`}
48
+ ${resolver}
49
+ module.exports = (phase, args) => resolveConfig(original, phase, args);
50
+ `;
51
+ }
52
+ return `// Generated by faable deploy for this build only — auto-restored afterwards.
53
+ // Adds a deterministic buildId (FAABLE_DEPLOY_ID) unless the config defines
54
+ // its own generateBuildId. See faable.com docs on version skew.
55
+ ${originalFile ? `import original from "./${originalFile}";` : `const original = {};`}
56
+ ${resolver}
57
+ export default (phase, args) => resolveConfig(original, phase, args);
58
+ `;
59
+ };
60
+ const packageIsESM = async (workdir) => {
61
+ try {
62
+ const pkg = JSON.parse(await readFile(path__default.join(workdir, "package.json"), "utf8"));
63
+ return pkg.type === "module";
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ };
69
+ /**
70
+ * Wraps the project's next.config for the build. Returns null (no-op) when
71
+ * there is nothing to do: FAABLE_DEPLOY_ID absent from `env`, a `.ts` config,
72
+ * or the user already handles the buildId. Callers MUST `await
73
+ * wrapped?.restore()` in a finally.
74
+ */
75
+ const wrap_next_config = async (workdir, env) => {
76
+ if (!env.FAABLE_DEPLOY_ID)
77
+ return null;
78
+ if (existsSync(path__default.join(workdir, "next.config.ts"))) {
79
+ log.warn(`next.config.ts detected — skipping the deterministic buildId wrap (define generateBuildId with FAABLE_DEPLOY_ID yourself to opt in).`);
80
+ return null;
81
+ }
82
+ const configFile = CONFIG_FILES.find((f) => existsSync(path__default.join(workdir, f)));
83
+ // Wrapper flavor/extension: mirror the original; with no config at all,
84
+ // .mjs is unambiguous regardless of package.json type.
85
+ let wrapperFile;
86
+ let originalFile = null;
87
+ let flavor;
88
+ if (!configFile) {
89
+ wrapperFile = "next.config.mjs";
90
+ flavor = "esm";
91
+ }
92
+ else {
93
+ const ext = path__default.extname(configFile); // .js | .cjs | .mjs
94
+ originalFile = `${ORIGINAL_PREFIX}${ext}`;
95
+ wrapperFile = configFile;
96
+ flavor =
97
+ ext === ".mjs" || (ext === ".js" && (await packageIsESM(workdir)))
98
+ ? "esm"
99
+ : "cjs";
100
+ }
101
+ const wrapperPath = path__default.join(workdir, wrapperFile);
102
+ const originalPath = originalFile ? path__default.join(workdir, originalFile) : null;
103
+ if (configFile && originalPath) {
104
+ await rename(path__default.join(workdir, configFile), originalPath);
105
+ }
106
+ await writeFile(wrapperPath, wrapperBody(originalFile, flavor), "utf8");
107
+ log.info(`🧬 Deterministic buildId: wrapped ${configFile ?? "(no next.config)"} → generateBuildId from FAABLE_DEPLOY_ID`);
108
+ // Ctrl-C / SIGTERM mid-build must not leave the user's workdir mutated.
109
+ const restoreSync = () => {
110
+ try {
111
+ rmSync(wrapperPath, { force: true });
112
+ if (configFile && originalPath) {
113
+ renameSync(originalPath, path__default.join(workdir, configFile));
114
+ }
115
+ }
116
+ catch {
117
+ // Best effort on the signal path
118
+ }
119
+ };
120
+ process.once("SIGINT", restoreSync);
121
+ process.once("SIGTERM", restoreSync);
122
+ let restored = false;
123
+ return {
124
+ restore: async () => {
125
+ if (restored)
126
+ return;
127
+ restored = true;
128
+ process.removeListener("SIGINT", restoreSync);
129
+ process.removeListener("SIGTERM", restoreSync);
130
+ await rm(wrapperPath, { force: true });
131
+ if (configFile && originalPath) {
132
+ await rename(originalPath, path__default.join(workdir, configFile));
133
+ }
134
+ },
135
+ };
136
+ };
137
+
138
+ export { wrap_next_config };
@@ -104,7 +104,7 @@ const deploy = {
104
104
  .catch((error) => log.warn(`Could not mark deployment BUILDING: ${error.message}`));
105
105
  const stop_log_sync = start_log_sync(api, deployment.id);
106
106
  try {
107
- await buildpack.build({ workdir, config, app, env_vars }, plan);
107
+ await buildpack.build({ workdir, config, app, env_vars, deployment }, plan);
108
108
  // Upload to Faable registry, tagged by version and pinned to digest
109
109
  const { image_ref } = await upload_tag({
110
110
  app,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.15.4",
3
+ "version": "1.17.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",