mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-28 13:13:26 +08:00
133 lines
4.8 KiB
JavaScript
133 lines
4.8 KiB
JavaScript
// The phase vocabulary of a preview, shared by all three ends.
|
|
//
|
|
// `preview.mjs` runs on a laptop or a CI runner, `preview-serve.mjs` runs inside
|
|
// the box, and `.github/scripts/codespace-preview.mjs` renders the PR comment from
|
|
// what the other two report. All three import this file, so the checklist and the
|
|
// emitters cannot drift.
|
|
//
|
|
// A phase crosses the gap as one line of printable ASCII. The in-box markers travel
|
|
// through `gh codespace ssh`, and one is echoed from that single-quoted command, so
|
|
// a marker must never carry a quote or a newline.
|
|
export const PHASE_MARKER = '##preview-phase##';
|
|
|
|
// Ordered: the renderer ticks off everything before the current phase.
|
|
//
|
|
// `slowAfterMs` is only set where a phase has a long tail worth warning about. The
|
|
// phases capped by a short wait — checkout, start (2 min health + 3 min readiness)
|
|
// and share (2 min) — get none: a hint would fire in the seconds before the wait
|
|
// hard-fails and says nothing the failure will not.
|
|
export const PREVIEW_PHASES = /** @type { const } */ ([
|
|
{ key: 'resolve', label: 'Resolve the PR head' },
|
|
{ key: 'box', label: 'Prepare the box', slowAfterMs: 480_000 },
|
|
{ key: 'ssh', label: 'Wait for SSH', slowAfterMs: 180_000 },
|
|
{ key: 'checkout', label: 'Check out the commit' },
|
|
{ key: 'install', label: 'Install dependencies', slowAfterMs: 180_000 },
|
|
{ key: 'build', label: 'Build the monorepo', slowAfterMs: 600_000 },
|
|
{ key: 'start', label: 'Start n8n' },
|
|
{ key: 'share', label: 'Share the port with the org' },
|
|
]);
|
|
|
|
/**
|
|
* Position of a phase in the checklist, or -1 for anything we do not know.
|
|
*
|
|
* @param {string | undefined} key
|
|
*/
|
|
export function phaseIndex(key) {
|
|
return PREVIEW_PHASES.findIndex((phase) => phase.key === key);
|
|
}
|
|
|
|
/**
|
|
* A detail is free text written by us, but it still ends up in a shell string and
|
|
* in markdown. Keep it to one short run of printable ASCII with no quote.
|
|
*
|
|
* @param {string | undefined} detail
|
|
*/
|
|
function sanitizeDetail(detail) {
|
|
if (!detail) return '';
|
|
// Collapse whitespace before dropping characters, so a newline leaves a word
|
|
// break behind rather than joining the two words it separated.
|
|
return [...detail.replace(/\s+/g, ' ')]
|
|
.filter((char) => char >= ' ' && char <= '~' && char !== "'" && char !== '`')
|
|
.join('')
|
|
.trim()
|
|
.slice(0, 120);
|
|
}
|
|
|
|
/**
|
|
* The line an emitter prints to announce a phase. Throws on a key that is not in
|
|
* the table: a typo would otherwise emit a marker the renderer silently drops.
|
|
*
|
|
* @param {string} key
|
|
* @param {string} [detail] What this run is doing in that phase.
|
|
*/
|
|
export function phaseMarkerLine(key, detail) {
|
|
if (phaseIndex(key) === -1) throw new Error(`No such preview phase: ${key}`);
|
|
const suffix = sanitizeDetail(detail);
|
|
return `${PHASE_MARKER} ${key}${suffix ? ` ${suffix}` : ''}`;
|
|
}
|
|
|
|
/**
|
|
* The shell command that announces a phase from inside the `gh codespace ssh`
|
|
* chain. The quoting lives here, next to the format it has to survive.
|
|
*
|
|
* @param {string} key
|
|
* @param {string} [detail]
|
|
*/
|
|
export function shellPhaseEcho(key, detail) {
|
|
return `echo '${phaseMarkerLine(key, detail)}'`;
|
|
}
|
|
|
|
/**
|
|
* Read a marker back. An unknown key is not a phase: the in-box stream is ordinary
|
|
* build output, and a stray or forged line must not move the checklist.
|
|
*
|
|
* @param {string} line
|
|
* @returns {{key: string, detail?: string} | undefined}
|
|
*/
|
|
export function parsePhaseMarker(line) {
|
|
const trimmed = (line ?? '').trim();
|
|
if (!trimmed.startsWith(`${PHASE_MARKER} `)) return undefined;
|
|
|
|
const [key, ...rest] = trimmed.slice(PHASE_MARKER.length).trim().split(' ');
|
|
if (phaseIndex(key) === -1) return undefined;
|
|
|
|
const detail = rest.join(' ').trim();
|
|
return detail ? { key, detail } : { key };
|
|
}
|
|
|
|
// A spinner or a binary blob must not grow the buffer without bound.
|
|
const MAX_LINE = 8192;
|
|
|
|
/**
|
|
* Complete lines out of a stream that arrives in chunks. Both hops need this — the
|
|
* runner relaying the box's output, and the CI wrapper reading the runner's stdout —
|
|
* so the fiddly part of the feature is written and tested once.
|
|
*
|
|
* @returns {(chunk: string | Buffer) => string[]}
|
|
*/
|
|
export function createLineSplitter() {
|
|
let partial = '';
|
|
|
|
return (chunk) => {
|
|
partial += String(chunk);
|
|
// Split on a bare \r too: `pnpm install` and turbo write \r-terminated
|
|
// progress, which would otherwise park a line behind one that never ends.
|
|
const lines = partial.split(/\r\n|\n|\r/);
|
|
partial = lines.pop() ?? '';
|
|
if (partial.length > MAX_LINE) partial = '';
|
|
|
|
return lines;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The markers in a chunked stream, in the order they arrive.
|
|
*
|
|
* @returns {(chunk: string | Buffer) => Array<{key: string, detail?: string}>}
|
|
*/
|
|
export function createPhaseScanner() {
|
|
const split = createLineSplitter();
|
|
|
|
return (chunk) => split(chunk).map(parsePhaseMarker).filter((marker) => marker !== undefined);
|
|
}
|