2026-03-03 10:20:49 +07:00
|
|
|
|
import axios from 'axios';
|
2026-05-21 09:50:19 +07:00
|
|
|
|
import { getApiBase } from '../lib/apiBase';
|
2026-05-18 12:39:06 +07:00
|
|
|
|
import type { ESP32BoardOptions, SpiffsFile } from '../types/boardOptions';
|
2026-03-03 10:20:49 +07:00
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
|
export interface SketchFile {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
content: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 12:39:06 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Per-board build options forwarded to the backend. Only meaningful for
|
|
|
|
|
|
* ESP32 targets — `board_options` is structurally translated into sdkconfig
|
|
|
|
|
|
* knobs and a generated partitions.csv by the ESP-IDF compiler. `spiffs_files`
|
|
|
|
|
|
* (if non-empty) are baked into a SPIFFS partition image via `mkspiffs`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface CompileExtras {
|
|
|
|
|
|
boardOptions?: ESP32BoardOptions;
|
|
|
|
|
|
spiffsFiles?: SpiffsFile[];
|
2026-06-07 05:10:15 +07:00
|
|
|
|
// P2.3 — declared library manifest (the loaded example/project's libraries).
|
|
|
|
|
|
// Sent as the ESP-IDF resolution SCOPE; null/omitted = legacy scan-all.
|
|
|
|
|
|
// Ignored by the backend for non-ESP32 (arduino-cli) boards.
|
|
|
|
|
|
libraries?: string[] | null;
|
feat(esp32): pure ESP-IDF language mode for the ESP32 family (#139)
Adds a third entry to the board language selector next to Arduino C++
and MicroPython: ESP-IDF. In this mode the user writes a plain ESP-IDF
project — app_main() entry point, FreeRTOS + driver APIs — and the
backend compiles it through the same ESP-IDF toolchain it already uses
for ESP32 Arduino sketches, just without the arduino-esp32 component.
Backend:
- CompileRequest.language ('espidf') threaded through the sync + async
compile paths and folded into the dedup job key (language='arduino'
and omitted hash identically so old clients keep dedupping).
- espidf_compiler: pure_idf flag. User files are written into main/
as-is (no Arduino.h wrap, no velxio_compat.h, Arduino library
resolution skipped), ARDUINO_ESP32_PATH is dropped from the build env
and VELXIO_PURE_SKETCH raised so the template CMake compiles the
user's own sources via a glob branch. Pure builds get their own
persistent build-dir variant through the eff_hash fold.
- QEMU WiFi compat for IDF-style code: esp_wifi.h/esp_wifi_init
detection sets has_wifi, and literal #define SSID/PASS plus
wifi_config_t designated initializers are normalized to the QEMU AP.
- CONFIG_ARDUINO_* lines are stripped from sdkconfig.defaults in pure
mode (the symbols don't exist without the arduino component).
Frontend:
- LanguageMode gains 'espidf'; BOARD_SUPPORTS_ESPIDF covers the ESP32
family (Xtensa, S3, C3). Toolbar shows the option only for those.
- Switching modes seeds a main.c blink skeleton (app_main + gpio
driver), mirroring the MicroPython main.py flow.
- compileCode sends language='espidf'; run/stop paths are unchanged
(the QEMU worker consumes the same merged flash image).
- New gallery example: esp32-idf-blink (LED + resistor on GPIO 2).
Tests: unit coverage for the build-env switch, IDF wifi normalization,
job-key variance, file-group seeding and the new example; verified
end-to-end in a container from the prod image (pure build produces a
bootable flash image; Arduino-mode build unchanged, same variant hash).
2026-07-24 11:37:01 +07:00
|
|
|
|
// Pure ESP-IDF mode (issue #139): 'espidf' compiles the files as a pure
|
|
|
|
|
|
// ESP-IDF project (user app_main, no arduino-esp32 component). Omitted /
|
|
|
|
|
|
// undefined = classic Arduino sketch compile. ESP32 boards only.
|
|
|
|
|
|
language?: 'espidf';
|
2026-08-01 01:22:29 +07:00
|
|
|
|
// Who triggered the compile — 'agent' when the AI assistant's tool did.
|
|
|
|
|
|
// Threads through to backend metrics; omitted = manual user action.
|
|
|
|
|
|
initiatedBy?: 'agent';
|
2026-05-18 12:39:06 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 10:20:49 +07:00
|
|
|
|
export interface CompileResult {
|
|
|
|
|
|
success: boolean;
|
|
|
|
|
|
hex_content?: string;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
binary_content?: string; // base64-encoded .bin for RP2040
|
2026-03-05 05:28:33 +07:00
|
|
|
|
binary_type?: 'bin' | 'uf2';
|
2026-04-22 02:45:45 +07:00
|
|
|
|
has_wifi?: boolean; // True when sketch uses WiFi (ESP32 only)
|
2026-03-03 10:20:49 +07:00
|
|
|
|
stdout: string;
|
|
|
|
|
|
stderr: string;
|
|
|
|
|
|
error?: string;
|
2026-03-06 20:14:50 +07:00
|
|
|
|
core_install_log?: string;
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:22:09 +07:00
|
|
|
|
interface CompileStartResponse {
|
|
|
|
|
|
job_id: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CompileStatusResponse {
|
|
|
|
|
|
state: 'pending' | 'running' | 'done' | 'error';
|
|
|
|
|
|
started_at: number;
|
|
|
|
|
|
finished_at: number | null;
|
2026-05-10 04:36:58 +07:00
|
|
|
|
stdout: string;
|
2026-05-09 10:22:09 +07:00
|
|
|
|
result: CompileResult | null;
|
|
|
|
|
|
error: string | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 04:36:58 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Live progress callback — called on every poll while state ∈ {pending,
|
|
|
|
|
|
* running}. `stdout` is the full live cmake + ninja output captured so far
|
|
|
|
|
|
* (cap of ~256 KB on the server side, tail kept). Caller can compute a
|
|
|
|
|
|
* delta against the previous call if it wants to append-only render.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export type CompileProgress = (info: {
|
|
|
|
|
|
state: 'pending' | 'running';
|
|
|
|
|
|
stdout: string;
|
|
|
|
|
|
elapsedSeconds: number;
|
|
|
|
|
|
}) => void;
|
|
|
|
|
|
|
2026-05-09 10:22:09 +07:00
|
|
|
|
const POLL_INTERVAL_MS = 2000;
|
|
|
|
|
|
const MAX_POLL_DURATION_MS = 15 * 60 * 1000; // 15 minutes — covers cold ESP-IDF builds
|
|
|
|
|
|
|
|
|
|
|
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Compile a sketch via the async job pipeline.
|
|
|
|
|
|
*
|
|
|
|
|
|
* POST /compile/start → { job_id }
|
2026-05-10 04:36:58 +07:00
|
|
|
|
* GET /compile/status/<job_id> (×N) → { state, stdout, result?, error? }
|
2026-05-09 10:22:09 +07:00
|
|
|
|
*
|
|
|
|
|
|
* Each individual request returns in milliseconds, so Cloudflare's 100s edge
|
|
|
|
|
|
* timeout never kicks in — even when the underlying ESP-IDF cold build runs
|
|
|
|
|
|
* for 5-7 minutes. Falls back to throwing an Error after MAX_POLL_DURATION_MS.
|
2026-05-10 04:36:58 +07:00
|
|
|
|
*
|
|
|
|
|
|
* `onProgress` (optional): called every poll with the live cmake + ninja
|
|
|
|
|
|
* output so the editor can stream the compilation console instead of
|
|
|
|
|
|
* waiting for everything at the end.
|
2026-05-09 10:22:09 +07:00
|
|
|
|
*/
|
2026-03-03 10:20:49 +07:00
|
|
|
|
export async function compileCode(
|
2026-03-06 20:14:50 +07:00
|
|
|
|
files: SketchFile[],
|
2026-04-22 02:45:45 +07:00
|
|
|
|
board: string = 'arduino:avr:uno',
|
feat(metrics): add usage analytics dashboard with country tracking
Track per-user, per-project, and per-board usage to inform pricing tier
decisions. Adds an admin dashboard with KPIs (DAU/WAU/MAU, totals,
success rate), time-series charts for compiles/runs, board family +
FQBN breakdowns, "board diversity" pie chart (key freemium signal),
top users/projects, and per-country breakdown via Cloudflare's
CF-IPCountry header. Admin can now also view private projects.
Backend:
- New UsageEvent table (append-only event log with user_id, project_id,
event_type, board_fqbn/family, country, error_kind, duration_ms)
- Aggregate counters on User (total_compiles/runs/errors, last_active,
signup_country, last_country) and Project (compile/run/update counts,
last_compiled/run timestamps) kept in sync by MetricsService for O(1)
dashboard reads
- 10 admin endpoints under /api/admin/metrics/{overview, timeseries,
boards, board-diversity, top-users, top-projects, countries,
users/{id}, projects/{id}}
- POST /api/metrics/run for client-side run telemetry
- Country detection via cf-ipcountry header (no DB / no API calls)
- Auto-migrations in lifespan for legacy DBs
Frontend:
- recharts-powered Dashboard tab with KPI cards and 4 charts
- New Boards tab with per-family + per-FQBN breakdown
- Country column with flag emoji on Users tab
- Top countries card on Dashboard
- compileCode now forwards project_id; Run button reports via WS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:46:52 +07:00
|
|
|
|
projectId?: string | null,
|
2026-05-10 04:36:58 +07:00
|
|
|
|
onProgress?: CompileProgress,
|
2026-05-18 12:39:06 +07:00
|
|
|
|
extras?: CompileExtras,
|
2026-03-03 10:20:49 +07:00
|
|
|
|
): Promise<CompileResult> {
|
2026-05-21 09:50:19 +07:00
|
|
|
|
console.log('Sending compilation request to:', `${getApiBase()}/compile/start`);
|
2026-05-09 10:22:09 +07:00
|
|
|
|
console.log('Board:', board);
|
|
|
|
|
|
console.log(
|
|
|
|
|
|
'Files:',
|
|
|
|
|
|
files.map((f) => f.name),
|
|
|
|
|
|
);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
|
2026-05-18 12:39:06 +07:00
|
|
|
|
// Translate camelCase frontend keys to snake_case backend keys. Backend
|
|
|
|
|
|
// only inspects these fields for esp32:* FQBNs — other boards pass them
|
|
|
|
|
|
// through unread.
|
|
|
|
|
|
const board_options = extras?.boardOptions ? { ...extras.boardOptions } : null;
|
|
|
|
|
|
const spiffs_files = extras?.spiffsFiles?.length
|
|
|
|
|
|
? extras.spiffsFiles.map((f) => ({ name: f.name, content_b64: f.contentB64 }))
|
|
|
|
|
|
: null;
|
2026-06-07 05:10:15 +07:00
|
|
|
|
// P2.3 — library manifest (resolution scope). null = legacy scan-all.
|
|
|
|
|
|
const libraries = extras?.libraries && extras.libraries.length ? extras.libraries : null;
|
2026-05-18 12:39:06 +07:00
|
|
|
|
|
2026-05-09 10:22:09 +07:00
|
|
|
|
let jobId: string;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const startResp = await axios.post<CompileStartResponse>(
|
2026-05-21 09:50:19 +07:00
|
|
|
|
`${getApiBase()}/compile/start`,
|
2026-05-18 12:39:06 +07:00
|
|
|
|
{
|
|
|
|
|
|
files,
|
|
|
|
|
|
board_fqbn: board,
|
|
|
|
|
|
project_id: projectId ?? null,
|
|
|
|
|
|
board_options,
|
|
|
|
|
|
spiffs_files,
|
2026-06-07 05:10:15 +07:00
|
|
|
|
libraries,
|
feat(esp32): pure ESP-IDF language mode for the ESP32 family (#139)
Adds a third entry to the board language selector next to Arduino C++
and MicroPython: ESP-IDF. In this mode the user writes a plain ESP-IDF
project — app_main() entry point, FreeRTOS + driver APIs — and the
backend compiles it through the same ESP-IDF toolchain it already uses
for ESP32 Arduino sketches, just without the arduino-esp32 component.
Backend:
- CompileRequest.language ('espidf') threaded through the sync + async
compile paths and folded into the dedup job key (language='arduino'
and omitted hash identically so old clients keep dedupping).
- espidf_compiler: pure_idf flag. User files are written into main/
as-is (no Arduino.h wrap, no velxio_compat.h, Arduino library
resolution skipped), ARDUINO_ESP32_PATH is dropped from the build env
and VELXIO_PURE_SKETCH raised so the template CMake compiles the
user's own sources via a glob branch. Pure builds get their own
persistent build-dir variant through the eff_hash fold.
- QEMU WiFi compat for IDF-style code: esp_wifi.h/esp_wifi_init
detection sets has_wifi, and literal #define SSID/PASS plus
wifi_config_t designated initializers are normalized to the QEMU AP.
- CONFIG_ARDUINO_* lines are stripped from sdkconfig.defaults in pure
mode (the symbols don't exist without the arduino component).
Frontend:
- LanguageMode gains 'espidf'; BOARD_SUPPORTS_ESPIDF covers the ESP32
family (Xtensa, S3, C3). Toolbar shows the option only for those.
- Switching modes seeds a main.c blink skeleton (app_main + gpio
driver), mirroring the MicroPython main.py flow.
- compileCode sends language='espidf'; run/stop paths are unchanged
(the QEMU worker consumes the same merged flash image).
- New gallery example: esp32-idf-blink (LED + resistor on GPIO 2).
Tests: unit coverage for the build-env switch, IDF wifi normalization,
job-key variance, file-group seeding and the new example; verified
end-to-end in a container from the prod image (pure build produces a
bootable flash image; Arduino-mode build unchanged, same variant hash).
2026-07-24 11:37:01 +07:00
|
|
|
|
language: extras?.language ?? null,
|
2026-08-01 01:22:29 +07:00
|
|
|
|
initiated_by: extras?.initiatedBy ?? null,
|
2026-05-18 12:39:06 +07:00
|
|
|
|
},
|
2026-05-09 10:22:09 +07:00
|
|
|
|
{ withCredentials: true, timeout: 30000 },
|
2026-03-06 20:14:50 +07:00
|
|
|
|
);
|
2026-05-09 10:22:09 +07:00
|
|
|
|
jobId = startResp.data.job_id;
|
|
|
|
|
|
console.log('[compile] queued job', jobId);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Compilation request failed:', error);
|
2026-05-09 10:22:09 +07:00
|
|
|
|
if (axios.isAxiosError(error) && error.response) {
|
|
|
|
|
|
// Server returned a structured error (422, 500, etc.) — surface as a
|
|
|
|
|
|
// failed CompileResult so the editor can show stderr/error.
|
|
|
|
|
|
return error.response.data as CompileResult;
|
|
|
|
|
|
}
|
|
|
|
|
|
throw error instanceof Error
|
|
|
|
|
|
? error
|
|
|
|
|
|
: new Error('No response from server. Is the backend running?');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const startedAt = Date.now();
|
|
|
|
|
|
// Initial small delay so we don't hit /status before the background task
|
|
|
|
|
|
// has even moved past 'pending'.
|
|
|
|
|
|
await sleep(500);
|
|
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
|
if (Date.now() - startedAt > MAX_POLL_DURATION_MS) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Compile timed out client-side after ${Math.round(MAX_POLL_DURATION_MS / 1000)}s`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-03 10:20:49 +07:00
|
|
|
|
|
2026-05-09 10:22:09 +07:00
|
|
|
|
let status: CompileStatusResponse;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const resp = await axios.get<CompileStatusResponse>(
|
2026-05-21 09:50:19 +07:00
|
|
|
|
`${getApiBase()}/compile/status/${jobId}`,
|
2026-05-09 10:22:09 +07:00
|
|
|
|
{ withCredentials: true, timeout: 30000 },
|
|
|
|
|
|
);
|
|
|
|
|
|
status = resp.data;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// Transient poll error — log, wait, retry. Only abort on 404 (job
|
|
|
|
|
|
// expired or never existed).
|
|
|
|
|
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
|
|
|
|
|
throw new Error(`Compile job ${jobId} not found (server may have restarted)`);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}
|
2026-05-09 10:22:09 +07:00
|
|
|
|
console.warn('[compile] status poll error, retrying:', error);
|
|
|
|
|
|
await sleep(POLL_INTERVAL_MS);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (status.state === 'done' && status.result) {
|
|
|
|
|
|
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
|
|
|
|
|
console.log(`[compile] job ${jobId} done in ${elapsed}s`);
|
|
|
|
|
|
return status.result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (status.state === 'error') {
|
|
|
|
|
|
console.error(`[compile] job ${jobId} errored:`, status.error);
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: false,
|
2026-05-10 04:36:58 +07:00
|
|
|
|
stdout: status.stdout || '',
|
2026-05-09 10:22:09 +07:00
|
|
|
|
stderr: '',
|
|
|
|
|
|
error: status.error || 'Compile failed',
|
|
|
|
|
|
};
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 04:36:58 +07:00
|
|
|
|
// state ∈ {pending, running} — surface live build output if requested
|
|
|
|
|
|
if (onProgress) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
onProgress({
|
|
|
|
|
|
state: status.state,
|
|
|
|
|
|
stdout: status.stdout || '',
|
|
|
|
|
|
elapsedSeconds: Math.round((Date.now() - startedAt) / 1000),
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// A faulty UI hook must never break the polling loop.
|
|
|
|
|
|
console.warn('[compile] onProgress threw:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 10:22:09 +07:00
|
|
|
|
await sleep(POLL_INTERVAL_MS);
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|