@faable/faable 1.16.0 → 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)
@@ -52,7 +53,16 @@ const node_buildpack = {
52
53
  const build_command = plan.build_script
53
54
  ? `npm run ${plan.build_script}`
54
55
  : ctx.config.buildCommand;
55
- 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
+ }
56
66
  // Frameworks without a bundled static server (CRA/Vue/Angular) need
57
67
  // `serve` installed into node_modules before packaging.
58
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",