Skip to content

Commit 5da298d

Browse files
committed
fix: update cli builder with new frameworks
1 parent 9dcca3e commit 5da298d

6 files changed

Lines changed: 220 additions & 12 deletions

File tree

src/commands/deploy/node-pipeline/analyze_package.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from "fs-extra";
22
import path from "path";
33
import { log } from "../../../log";
44
import { PackageJson } from "type-fest";
5-
import * as R from "ramda";
5+
import { detect_framework } from "./frameworks";
66
interface AnalyzePackage {
77
workdir: string;
88
}
@@ -17,27 +17,27 @@ export const analyze_package = async (params: AnalyzePackage) => {
1717
// Check if build is required to run
1818
const build_script = process.env.FAABLE_NPM_BUILD_SCRIPT
1919
? process.env.FAABLE_NPM_BUILD_SCRIPT
20-
: pkg?.scripts["build"]
20+
: pkg?.scripts?.["build"]
2121
? "build"
2222
: null;
2323

2424
if (!build_script) {
2525
log.info(`No build script on package.json`);
2626
}
2727

28-
let type: string = "node";
29-
30-
// Detect nextjs deployment type
31-
const next_dep = R.lensPath(["dependencies", "next"]);
32-
const next_devdep = R.lensPath(["devDependencies", "next"]);
33-
if (R.view(next_dep, pkg) || R.view(next_devdep, pkg)) {
34-
type = "next";
35-
}
28+
const has_start = Boolean(pkg?.scripts?.["start"]);
29+
const { type, start_command, inject_serve } = detect_framework({
30+
pkg,
31+
workdir,
32+
has_start,
33+
});
3634

3735
log.info(`⚡️ Detected deployment type=${type}`);
3836

3937
return {
4038
build_script,
4139
type,
40+
start_command,
41+
inject_serve,
4242
};
4343
};

src/commands/deploy/node-pipeline/build_docker.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ const entrypoint_template = Handlebars.compile(entrypoint);
3131
interface BuildConfig {
3232
app: FaableApp;
3333
workdir: string;
34+
/** Framework-detected start command, used when faable.json doesn't set one. */
35+
start_command?: string | null;
3436
template_context: {
3537
from: string;
3638
};
@@ -40,7 +42,12 @@ export const build_docker = async (props: BuildConfig) => {
4042
const { app, workdir, template_context } = props;
4143

4244
const entrypoint_custom = entrypoint_template(template_context);
43-
const start_command = Configuration.instance().startCommand;
45+
// Precedence: explicit faable.json startCommand > framework-detected command
46+
// (e.g. serving a static SPA) > default `npm run start`.
47+
const start_command =
48+
Configuration.instance().configuredStartCommand ??
49+
props.start_command ??
50+
"npm run start";
4451
log.info(`⚙️ Start command: ${start_command}`);
4552

4653
// NOTE: use slim to build projects
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import fs from "fs-extra";
2+
import path from "path";
3+
import { PackageJson } from "type-fest";
4+
import * as R from "ramda";
5+
import { log } from "../../../log";
6+
7+
/**
8+
* A static framework produces a directory of static assets (a SPA) that needs
9+
* to be served by a static server, instead of running its own node process.
10+
*
11+
* - `outputDir` is the build output relative to the project root.
12+
* - `serveCommand` builds the container start command used when the project
13+
* does NOT define its own `start` script. It must bind to 0.0.0.0 and the
14+
* `$PORT` env var (=80, set in the Dockerfile).
15+
* - `injectServe` is true for frameworks without a bundled preview/serve tool
16+
* (CRA, Vue, Angular): we install the standalone `serve` package into the
17+
* workdir before building the image so it ships in node_modules (no runtime
18+
* download). Vite/Astro/Gatsby ship their own and don't need it.
19+
* - `resolveOutput` lets a framework compute its output dir dynamically
20+
* (Angular reads it from angular.json).
21+
*/
22+
export interface Framework {
23+
type: string;
24+
/** Package names that, if present in (dev)dependencies, identify the framework. */
25+
deps: string[];
26+
outputDir?: string;
27+
serveCommand?: (dir: string) => string;
28+
injectServe?: boolean;
29+
resolveOutput?: (workdir: string) => string;
30+
}
31+
32+
const has_dep = (pkg: PackageJson, name: string) =>
33+
Boolean(
34+
R.view(R.lensPath(["dependencies", name]), pkg) ||
35+
R.view(R.lensPath(["devDependencies", name]), pkg)
36+
);
37+
38+
/**
39+
* Read Angular's build output path from angular.json. Defaults to `dist` when
40+
* it can't be resolved. Angular ≥17 (application builder) emits into
41+
* `<outputPath>/browser`, so we append it when the project uses that builder.
42+
*/
43+
export const resolve_angular_output = (workdir: string): string => {
44+
const fallback = "dist";
45+
try {
46+
const angular_json = fs.readJSONSync(path.join(workdir, "angular.json"));
47+
const projects = angular_json?.projects ?? {};
48+
const project_name =
49+
angular_json?.defaultProject ?? Object.keys(projects)[0];
50+
const build = projects?.[project_name]?.architect?.build;
51+
const output = build?.options?.outputPath as string | undefined;
52+
if (!output) return fallback;
53+
54+
const builder: string = build?.builder ?? "";
55+
const is_application_builder =
56+
builder.includes("application") || builder.includes("browser-esbuild");
57+
return is_application_builder ? path.join(output, "browser") : output;
58+
} catch {
59+
return fallback;
60+
}
61+
};
62+
63+
/**
64+
* Framework registry, evaluated in order. Order matters: Astro/SvelteKit/CRA
65+
* pull Vite in transitively, so Vite must be the last static fallback.
66+
*/
67+
export const FRAMEWORKS: Framework[] = [
68+
// Next.js: handled by its own runtime_strategy/PVC, never static-served here.
69+
{ type: "next", deps: ["next"] },
70+
71+
{
72+
type: "astro",
73+
deps: ["astro"],
74+
outputDir: "dist",
75+
serveCommand: (dir) => `npx astro preview --host 0.0.0.0 --port $PORT`,
76+
},
77+
{
78+
type: "gatsby",
79+
deps: ["gatsby"],
80+
outputDir: "public",
81+
serveCommand: (dir) => `npx gatsby serve --host 0.0.0.0 --port $PORT`,
82+
},
83+
{
84+
type: "cra",
85+
deps: ["react-scripts"],
86+
outputDir: "build",
87+
injectServe: true,
88+
serveCommand: (dir) => `npx serve -s ${dir} -l $PORT`,
89+
},
90+
{
91+
type: "vue",
92+
deps: ["@vue/cli-service"],
93+
outputDir: "dist",
94+
injectServe: true,
95+
serveCommand: (dir) => `npx serve -s ${dir} -l $PORT`,
96+
},
97+
{
98+
type: "angular",
99+
deps: ["@angular/cli", "@angular-devkit/build-angular"],
100+
injectServe: true,
101+
resolveOutput: resolve_angular_output,
102+
serveCommand: (dir) => `npx serve -s ${dir} -l $PORT`,
103+
},
104+
{
105+
type: "vite",
106+
deps: ["vite"],
107+
outputDir: "dist",
108+
serveCommand: (dir) => `npx vite preview --host 0.0.0.0 --port $PORT`,
109+
},
110+
];
111+
112+
export interface DetectedFramework {
113+
type: string;
114+
/** Container start command to serve the static output, or null if none. */
115+
start_command: string | null;
116+
/** Whether the standalone `serve` package must be injected before build. */
117+
inject_serve: boolean;
118+
}
119+
120+
interface DetectParams {
121+
pkg: PackageJson;
122+
workdir: string;
123+
/** True when package.json defines a `start` script (e.g. custom SSR server). */
124+
has_start: boolean;
125+
}
126+
127+
/**
128+
* Detect the framework from package.json and compute how to serve it.
129+
*
130+
* When the project defines its own `start` script we never override it (the app
131+
* ships a real server — custom SSR, Nuxt, Remix, SvelteKit node-adapter, etc.),
132+
* so `start_command`/`inject_serve` stay neutral.
133+
*/
134+
export const detect_framework = (params: DetectParams): DetectedFramework => {
135+
const { pkg, workdir, has_start } = params;
136+
137+
const framework = FRAMEWORKS.find((fw) =>
138+
fw.deps.some((dep) => has_dep(pkg, dep))
139+
);
140+
141+
if (!framework) {
142+
return { type: "node", start_command: null, inject_serve: false };
143+
}
144+
145+
// Static frameworks only override the start command when the project doesn't
146+
// ship its own server.
147+
if (framework.serveCommand && !has_start) {
148+
const output_dir = framework.resolveOutput
149+
? framework.resolveOutput(workdir)
150+
: framework.outputDir ?? "dist";
151+
const start_command = framework.serveCommand(output_dir);
152+
log.info(
153+
`No start script on package.json, serving ${framework.type} output (${output_dir}) with [${start_command}]`
154+
);
155+
return {
156+
type: framework.type,
157+
start_command,
158+
inject_serve: Boolean(framework.injectServe),
159+
};
160+
}
161+
162+
return { type: framework.type, start_command: null, inject_serve: false };
163+
};

src/commands/deploy/node-pipeline/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { FaableApp, Secret } from "../../../api/FaableApi";
22
import { build_docker } from "./build_docker";
33
import { analyze_package } from "./analyze_package";
44
import { build_project } from "./build_project";
5+
import { inject_serve } from "./inject_serve";
56
import { Runtime } from "../runtime-detect/RuntimeStrategy";
67
import * as R from "ramda";
78
import { log } from "../../../log";
@@ -20,7 +21,10 @@ export const build_node = async (app: FaableApp, options: BuildNodeOptions) => {
2021
throw new Error("Runtime version not specified for node");
2122
}
2223
// Analyze package.json to check if build is needed
23-
const { build_script, type } = await analyze_package({ workdir });
24+
const { build_script, type, start_command, inject_serve: needs_serve } =
25+
await analyze_package({
26+
workdir,
27+
});
2428

2529
// Environment variables
2630

@@ -30,10 +34,17 @@ export const build_node = async (app: FaableApp, options: BuildNodeOptions) => {
3034
// Do build
3135
await build_project({ app, build_script, env });
3236

37+
// Frameworks without a bundled static server (CRA/Vue/Angular) need `serve`
38+
// installed into node_modules before packaging, so it ships in the image.
39+
if (needs_serve) {
40+
await inject_serve(workdir);
41+
}
42+
3343
// Bundle project inside a docker image
3444
await build_docker({
3545
app,
3646
workdir,
47+
start_command,
3748
template_context: {
3849
from: `node:${runtime.version}`,
3950
},
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { log } from "../../../log";
2+
import { cmd } from "../../../lib/cmd";
3+
4+
// Pinned for reproducible builds. `serve` is the standalone static server used
5+
// for frameworks without a bundled preview tool (CRA, Vue, Angular).
6+
const SERVE_VERSION = "14";
7+
8+
/**
9+
* Install `serve` into the project's node_modules so it ships inside the image
10+
* via `COPY . .` (the Dockerfile does no `npm install`). This lets `npx serve`
11+
* resolve the local copy at container start — no runtime download needed.
12+
*
13+
* `--no-save` keeps the user's package.json/lockfile untouched.
14+
*/
15+
export const inject_serve = async (workdir: string) => {
16+
log.info(`📥 Injecting static server (serve@${SERVE_VERSION}) into image`);
17+
const timeout = 5 * 60 * 1000; // 5 minute timeout
18+
await cmd(
19+
`npm install serve@${SERVE_VERSION} --no-save --no-audit --no-fund`,
20+
{ cwd: workdir, timeout, enableOutput: true }
21+
);
22+
};

src/lib/Configuration.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ export class Configuration {
4949
return this.config.startCommand || "npm run start";
5050
}
5151

52+
/** Start command explicitly set in faable.json, or undefined when relying on the default. */
53+
get configuredStartCommand() {
54+
return this.config.startCommand;
55+
}
56+
5257
get buildCommand() {
5358
return this.config.buildCommand;
5459
}

0 commit comments

Comments
 (0)