Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ jobs:

release:
name: release
needs: [check, e2e]
needs: [check, site, e2e]
# Off until launch: the repository variable RELEASE_ENABLED is set to true once v0.0.0 is tagged and staging exists.
# Without the tag, semantic-release counts every commit and picks 1.0.0.
if: github.event_name == 'push' && vars.RELEASE_ENABLED == 'true'
Expand Down Expand Up @@ -177,3 +177,20 @@ jobs:
git checkout -B staging origin/staging
git merge --no-ff origin/main -m "chore(release): merge main into staging [skip ci]"
git push origin staging

deploy-site:
name: deploy site
needs: release
# Only main is production; Vercel no longer deploys it on push (site/vercel.json), so this is the one production deploy,
# made after the release job has pushed its version commit.
if: github.ref_name == 'main'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# The hook builds main's tip, so an older commit can never replace a newer one in production.
- name: Trigger the production deploy
env:
HOOK: ${{ secrets.VERCEL_DEPLOY_HOOK_MAIN }}
run: |
if [ -z "$HOOK" ]; then echo "::error::VERCEL_DEPLOY_HOOK_MAIN is not set"; exit 1; fi
curl --fail-with-body -sS -X POST "$HOOK"
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,7 @@ workflow's `release` job runs them after the checks pass.
the bot commits `chore(release): x.y.z [skip ci]` to `main`, and then merges `main` back into `staging`.
Release candidates only change the published package, so the repo keeps the last release's version between
releases. Never edit these pins by hand.
- **The website** deploys to production from CI, not from Vercel's push hook: after the `release` job, the
`deploy-site` job calls a Vercel Deploy Hook (the `VERCEL_DEPLOY_HOOK_MAIN` secret), which builds `main`'s tip.
That way the release commit, not the merge before it, is what goes live. Pull requests and `staging` still get
Vercel previews on push.
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"prepack": "bun run build && node scripts/prepack.mjs"
},
"dependencies": {
"elkjs": "^0.12.0"
"elkjs": "0.12.0"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
Expand Down
17 changes: 13 additions & 4 deletions packages/cli/src/open.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { spawn } from 'node:child_process';

/** The command that opens a file in the default browser on `platform`. */
export function browserCommand(file: string, platform: NodeJS.Platform = process.platform): { cmd: string; args: string[] } {
/** The command that opens a file in the default browser on `platform`, or why it can't be done safely. */
export function browserCommand(
file: string,
platform: NodeJS.Platform = process.platform,
): { cmd: string; args: string[] } | { refused: string } {
if (platform === 'darwin') return { cmd: 'open', args: [file] };
// `start` is a cmd builtin, and its first quoted argument is the window title.
if (platform === 'win32') return { cmd: 'cmd', args: ['/c', 'start', '""', file] };
// Quoted, cmd takes & | < > ^ ( ) literally; it still expands %VAR% inside quotes, and " would end the quoting.
if (platform === 'win32')
return /["%]/.test(file)
? { refused: 'the path has a " or % that cmd would interpret' }
: { cmd: 'cmd', args: ['/c', 'start', '""', `"${file}"`] };
return { cmd: 'xdg-open', args: [file] };
}

Expand All @@ -13,7 +20,9 @@ export function browserCommand(file: string, platform: NodeJS.Platform = process
* opener installed, a headless machine). An opener that is still running after a moment is taken as success.
*/
export function openInBrowser(file: string): Promise<string | null> {
const { cmd, args } = browserCommand(file);
const command = browserCommand(file);
if ('refused' in command) return Promise.resolve(command.refused);
const { cmd, args } = command;
return new Promise((resolve) => {
const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsVerbatimArguments: cmd === 'cmd' });
const timer = setTimeout(() => resolve(null), 3000);
Expand Down
19 changes: 17 additions & 2 deletions packages/cli/test/open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,24 @@ import { describe, expect, it } from 'vitest';
import { browserCommand } from '../src/open';

describe('browserCommand', () => {
it("uses each platform's own opener", () => {
it('uses the native opener on darwin and linux', () => {
expect(browserCommand('/tmp/a b.html', 'darwin')).toEqual({ cmd: 'open', args: ['/tmp/a b.html'] });
expect(browserCommand('C:\\d\\a.html', 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '""', 'C:\\d\\a.html'] });
expect(browserCommand('/tmp/a.html', 'linux')).toEqual({ cmd: 'xdg-open', args: ['/tmp/a.html'] });
});

it('quotes the path for cmd on win32', () => {
expect(browserCommand('C:\\d\\a.html', 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '""', '"C:\\d\\a.html"'] });
});

it('keeps spaces and cmd metacharacters inside the quotes', () => {
const file = 'C:\\Users\\First Last\\a&b (1).html';
expect(browserCommand(file, 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '""', `"${file}"`] });
});

it('refuses win32 paths with a % or " that cmd would interpret', () => {
for (const file of ['C:\\d\\100%.html', 'C:\\d\\a"b.html']) {
const result = browserCommand(file, 'win32');
expect('refused' in result && typeof result.refused).toBe('string');
}
});
});
7 changes: 7 additions & 0 deletions packages/cli/test/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ describe('skill ↔ CLI version', () => {
});

describe('package.json', () => {
it('pins elkjs exactly, and to the version the layout package uses, so a CLI version always gives the same layout', () => {
const dep = (p: string) => (JSON.parse(readFileSync(new URL(p, import.meta.url), 'utf8')) as { dependencies: Record<string, string> }).dependencies.elkjs;
const cli = dep('../package.json');
expect(cli).toMatch(/^\d+\.\d+\.\d+$/);
expect(dep('../../layout/package.json')).toBe(cli);
});

it('names every diagram kind in its npm keywords, so a search for any of them finds the package', () => {
const { keywords } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { keywords: string[] };
for (const kind of DIAGRAM_KINDS) expect(keywords, kind).toContain(kind);
Expand Down
2 changes: 1 addition & 1 deletion packages/layout/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@
},
"dependencies": {
"@stackmap/core": "workspace:*",
"elkjs": "^0.12.0"
"elkjs": "0.12.0"
}
}
75 changes: 75 additions & 0 deletions packages/layout/src/elk-io.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import type { EventEmitter } from 'node:events';

/** What threadElk needs from a worker thread: enough to fake in a test. */
export interface ThreadLike extends Pick<EventEmitter, 'on'> {
postMessage(msg: unknown): void;
terminate(): unknown;
}

/** elkjs's worker port over a thread; a thread that dies or exits fails what it was asked instead of hanging. */
export function threadPort(thread: ThreadLike) {
const pending = new Set<number>();
const port = {
onmessage: null as ((answer: { data: unknown }) => void) | null,
postMessage(msg: { id: number }) {
pending.add(msg.id);
thread.postMessage(msg);
},
terminate: () => void thread.terminate(),
};
thread.on('message', (data: { id: number; error?: { message: string } }) => {
pending.delete(data.id);
port.onmessage?.({ data: data.error ? { ...data, error: new Error(data.error.message) } : data });
});
// A thread that dies (out of memory, say) answers nothing: fail what it was asked instead of hanging.
thread.on('error', (error) => {
for (const id of pending) port.onmessage?.({ data: { id, error } });
pending.clear();
});
// An exit (terminate, process.exit, some resource limits) can come without an 'error': fail what's still waiting.
thread.on('exit', (code: number) => {
const error = new Error(`ELK thread stopped (exit code ${code})`);
for (const id of pending) port.onmessage?.({ data: { id, error } });
pending.clear();
});
return port;
}

/** Sends `input` to a child ELK and resolves its JSON answer; every way the child can fail rejects, once. */
export function runChild<T>(child: ChildProcessWithoutNullStreams, input: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
let out = '';
let err = '';
let settled = false;
const succeed = (value: T) => {
if (!settled) {
settled = true;
resolve(value);
}
};
const fail = (error: Error) => {
if (!settled) {
settled = true;
reject(error);
}
};
child.stdout.on('data', (c: Buffer) => (out += c));
child.stderr.on('data', (c: Buffer) => (err += c));
child.on('error', (e) => fail(e));
// EPIPE when the child dies before reading its input: 'close' reports why.
child.stdin.on('error', () => {});
child.on('close', (code, signal) => {
if (code === 0) {
try {
succeed(JSON.parse(out) as T);
} catch (e) {
fail(new Error(`ELK answered with invalid JSON: ${(e as Error).message}`));
}
} else {
fail(new Error(err.trim() || (signal ? `ELK was killed by ${signal}` : `ELK exited with code ${code}`)));
}
});
child.stdin.end(input);
});
}
49 changes: 15 additions & 34 deletions packages/layout/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { spawn } from 'node:child_process';
import { spawn, type ChildProcess } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { Worker as Thread } from 'node:worker_threads';
import ElkApi from 'elkjs/lib/elk-api.js';
import ElkBundled from 'elkjs/lib/elk.bundled.js';
import type { ElkExtendedEdge, ElkNode } from 'elkjs/lib/elk-api';
import { cardSize, isLaneKind, usesCompactCards, type DiagramDraft, type Direction, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core';
import { runChild, threadPort } from './elk-io';
import { placeLabels } from './labels';
import { layoutLanes } from './lanes';
import { layoutSequence } from './sequence';
Expand Down Expand Up @@ -53,31 +54,14 @@ parentPort.on('message', (msg) => elk.postMessage(msg));

function threadElk(workerUrl: string): { elk: InstanceType<typeof ElkBundled>; dispose: () => void } {
const thread = new Thread(NODE_THREAD, { eval: true, workerData: workerUrl, resourceLimits: { stackSizeMb: ELK_STACK_MB } });
const pending = new Set<number>();
const port = {
onmessage: null as ((answer: { data: unknown }) => void) | null,
postMessage(msg: { id: number }) {
pending.add(msg.id);
thread.postMessage(msg);
},
terminate: () => void thread.terminate(),
};
thread.on('message', (data: { id: number; error?: { message: string } }) => {
pending.delete(data.id);
port.onmessage?.({ data: data.error ? { ...data, error: new Error(data.error.message) } : data });
});
// A thread that dies (out of memory, say) answers nothing: fail what it was asked instead of hanging.
thread.on('error', (error) => {
for (const id of pending) port.onmessage?.({ data: { id, error } });
pending.clear();
});
const port = threadPort(thread);
const elk = new ElkApi({ workerFactory: () => port as never });
return { elk, dispose: () => elk.terminateWorker() };
}

/**
* ELK in-process where a global `self` exists: its worker script, run with `self` hidden, exports the in-process worker
* instead of taking over `self.onmessage`. Bun's fallback for a large graph where a child process can't be started.
* instead of taking over `self.onmessage`. Bun's large-graph host on Windows, where the child process (a POSIX shell with ulimit) isn't available.
*/
function mainThreadElk(workerUrl: string): InstanceType<typeof ElkBundled> {
const mod: { exports: { Worker?: new () => unknown } } = { exports: {} };
Expand Down Expand Up @@ -106,22 +90,19 @@ process.stdin.on('data', (c) => (input += c)).on('end', () =>
`;

/** For Bun's large graphs: ELK in a child Bun started with room for its recursion (the densest need ~100 MB). */
function childElk(workerUrl: string): InstanceType<typeof ElkBundled> {
function childElk(workerUrl: string): { elk: InstanceType<typeof ElkBundled>; dispose: () => void } {
const apiUrl = createRequire(import.meta.url).resolve('elkjs/lib/elk-api.js');
// The option only when the limit rose: telling JavaScriptCore the stack is bigger than it is crashes instead of throwing.
const script = `if ulimit -s ${BUN_STACK_KB} 2>/dev/null; then export BUN_JSC_maxPerThreadStackUsage=${BUN_STACK_KB * 1024}; fi; exec "$0" -e "$1" "$2" "$3"`;
const layout = (graph: ElkNode) =>
new Promise<ElkNode>((resolve, reject) => {
const child = spawn('/bin/sh', ['-c', script, process.execPath, BUN_CHILD, workerUrl, apiUrl]);
let out = '';
let err = '';
child.stdout.on('data', (c: Buffer) => (out += c));
child.stderr.on('data', (c: Buffer) => (err += c));
child.on('error', reject);
child.on('close', (code) => (code === 0 ? resolve(JSON.parse(out) as ElkNode) : reject(new Error(err.trim() || `ELK exited with code ${code}`))));
child.stdin.end(JSON.stringify(graph));
});
return { layout } as never;
const live = new Set<ChildProcess>();
const layout = (graph: ElkNode) => {
const child = spawn('/bin/sh', ['-c', script, process.execPath, BUN_CHILD, workerUrl, apiUrl]);
live.add(child);
child.once('close', () => live.delete(child));
return runChild<ElkNode>(child, JSON.stringify(graph));
};
// A layout that threw elsewhere leaves its child running: kill it with the rest.
return { elk: { layout } as never, dispose: () => live.forEach((c) => c.kill()) };
}

// elkjs's bundled build treats any runtime with a global `self` and no `document` as a web worker
Expand All @@ -134,7 +115,7 @@ function createElk(large: boolean): { elk: InstanceType<typeof ElkBundled>; disp
if (node ? !large : g.document !== undefined || g.Worker === undefined) return { elk: new ElkBundled(), dispose: () => {} };
const workerUrl = createRequire(import.meta.url).resolve('elkjs/lib/elk-worker.min.js');
if (node) return threadElk(workerUrl);
if (large) return { elk: process.platform === 'win32' ? mainThreadElk(workerUrl) : childElk(workerUrl), dispose: () => {} };
if (large) return process.platform === 'win32' ? { elk: mainThreadElk(workerUrl), dispose: () => {} } : childElk(workerUrl);
const elk = new ElkApi({ workerUrl });
// A real Worker keeps the process alive until terminated; the in-process one has no terminate().
return { elk, dispose: () => elk.terminateWorker() };
Expand Down
Loading
Loading