@tilewise/workbench-client 0.0.0-stage → 0.1.1
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.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +210 -2
- package/dist/blocks/ScreenScope.d.ts +11 -0
- package/dist/blocks/ScreenScope.js +11 -0
- package/dist/blocks/drawnBlocks.d.ts +11 -0
- package/dist/blocks/drawnBlocks.js +47 -0
- package/dist/blocks/screenContext.d.ts +2 -0
- package/dist/blocks/screenContext.js +3 -0
- package/dist/bridge/Bridge.d.ts +10 -0
- package/dist/bridge/Bridge.js +129 -0
- package/dist/bridge/device.d.ts +6 -0
- package/dist/bridge/device.js +28 -0
- package/dist/bridge/post.d.ts +5 -0
- package/dist/bridge/post.js +8 -0
- package/dist/bridge/readViewerMessage.d.ts +3 -0
- package/dist/bridge/readViewerMessage.js +46 -0
- package/dist/bridge/revealControl.d.ts +10 -0
- package/dist/bridge/revealControl.js +62 -0
- package/dist/bridge/snapshot.d.ts +7 -0
- package/dist/bridge/snapshot.js +47 -0
- package/dist/bridge/toneAtTop.d.ts +3 -0
- package/dist/bridge/toneAtTop.js +23 -0
- package/dist/bridge/watchScreen.d.ts +10 -0
- package/dist/bridge/watchScreen.js +50 -0
- package/dist/bridge/wholeHeight.d.ts +8 -0
- package/dist/bridge/wholeHeight.js +16 -0
- package/dist/controls/paint.d.ts +8 -0
- package/dist/controls/paint.js +16 -0
- package/dist/controls/parseValue.d.ts +7 -0
- package/dist/controls/parseValue.js +34 -0
- package/dist/controls/resetControl.d.ts +3 -0
- package/dist/controls/resetControl.js +6 -0
- package/dist/controls/setControl.d.ts +3 -0
- package/dist/controls/setControl.js +6 -0
- package/dist/controls/store.d.ts +12 -0
- package/dist/controls/store.js +125 -0
- package/dist/controls/types.d.ts +111 -0
- package/dist/controls/types.js +6 -0
- package/dist/controls/useControl.d.ts +7 -0
- package/dist/controls/useControl.js +16 -0
- package/dist/define/buildManifest.d.ts +8 -0
- package/dist/define/buildManifest.js +51 -0
- package/dist/define/checkDefaults.d.ts +3 -0
- package/dist/define/checkDefaults.js +29 -0
- package/dist/define/defineApp.d.ts +24 -0
- package/dist/define/defineApp.js +92 -0
- package/dist/define/defineBlock.d.ts +12 -0
- package/dist/define/defineBlock.js +10 -0
- package/dist/define/defineScreen.d.ts +11 -0
- package/dist/define/defineScreen.js +10 -0
- package/dist/define/part.d.ts +23 -0
- package/dist/define/part.js +22 -0
- package/dist/define/registeredApp.d.ts +12 -0
- package/dist/define/registeredApp.js +5 -0
- package/dist/draw/boolean.d.ts +6 -0
- package/dist/draw/boolean.js +5 -0
- package/dist/draw/choose.d.ts +4 -0
- package/dist/draw/choose.js +4 -0
- package/dist/draw/color.d.ts +2 -0
- package/dist/draw/color.js +4 -0
- package/dist/draw/number.d.ts +5 -0
- package/dist/draw/number.js +5 -0
- package/dist/draw/text.d.ts +5 -0
- package/dist/draw/text.js +5 -0
- package/dist/draw/variant.d.ts +17 -0
- package/dist/draw/variant.js +4 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +14 -0
- package/dist/protocol/protocol.d.ts +8 -0
- package/dist/protocol/protocol.js +8 -0
- package/dist/protocol/types.d.ts +144 -0
- package/dist/protocol/types.js +1 -0
- package/dist/vite/apiFetch.d.ts +7 -0
- package/dist/vite/apiFetch.js +15 -0
- package/dist/vite/apiWorkers.d.ts +11 -0
- package/dist/vite/apiWorkers.js +111 -0
- package/dist/vite/appFolder.d.ts +6 -0
- package/dist/vite/appFolder.js +11 -0
- package/dist/vite/builds.d.ts +47 -0
- package/dist/vite/builds.js +250 -0
- package/dist/vite/bundleApi.d.ts +9 -0
- package/dist/vite/bundleApi.js +32 -0
- package/dist/vite/checkout.d.ts +11 -0
- package/dist/vite/checkout.js +43 -0
- package/dist/vite/environment.d.ts +9 -0
- package/dist/vite/environment.js +10 -0
- package/dist/vite/fetchBridge.d.ts +8 -0
- package/dist/vite/fetchBridge.js +42 -0
- package/dist/vite/git.d.ts +7 -0
- package/dist/vite/git.js +18 -0
- package/dist/vite/index.d.ts +1 -0
- package/dist/vite/index.js +1 -0
- package/dist/vite/isWorkspace.d.ts +5 -0
- package/dist/vite/isWorkspace.js +10 -0
- package/dist/vite/serveBuild.d.ts +7 -0
- package/dist/vite/serveBuild.js +45 -0
- package/dist/vite/versions.d.ts +44 -0
- package/dist/vite/versions.js +122 -0
- package/dist/vite/workbenchLocal.d.ts +32 -0
- package/dist/vite/workbenchLocal.js +251 -0
- package/docs/protocol.md +66 -0
- package/package.json +63 -3
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { Worker } from 'node:worker_threads';
|
|
3
|
+
import { apiFetchOf } from './apiFetch.js';
|
|
4
|
+
/** How long a build's fake API stays loaded with nothing asked of it. */
|
|
5
|
+
const IDLE = 30 * 60 * 1000;
|
|
6
|
+
// The worker's code, as CommonJS: it loads the bundle and answers each request with it.
|
|
7
|
+
const SOURCE = `
|
|
8
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
9
|
+
const apiFetchOf = ${apiFetchOf.toString()};
|
|
10
|
+
const loaded = import(workerData.url).then(apiFetchOf);
|
|
11
|
+
parentPort.on('message', async ({ id, method, url, headers, body }) => {
|
|
12
|
+
try {
|
|
13
|
+
const answer = await (await loaded)(new Request(url, { method, headers, body }));
|
|
14
|
+
const bytes = await answer.arrayBuffer();
|
|
15
|
+
const plain = [...answer.headers].filter(([name]) => name !== 'set-cookie');
|
|
16
|
+
const cookies = answer.headers.getSetCookie().map((value) => ['set-cookie', value]);
|
|
17
|
+
parentPort.postMessage({ id, status: answer.status, headers: [...plain, ...cookies], body: bytes }, [bytes]);
|
|
18
|
+
} catch (failure) {
|
|
19
|
+
parentPort.postMessage({ id, error: String((failure && failure.stack) || failure) });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
`;
|
|
23
|
+
/**
|
|
24
|
+
* Each build's fake API, run in a worker of its own: loaded on its first
|
|
25
|
+
* request, kept while it is asked things, so a visit's data stays, and stopped
|
|
26
|
+
* after `idle` with nothing asked, which frees its memory. A worker gets no
|
|
27
|
+
* environment, so a bundle sees none of the dev server's settings.
|
|
28
|
+
*/
|
|
29
|
+
export function createApiWorkers(idle = IDLE) {
|
|
30
|
+
const running = new Map();
|
|
31
|
+
let next = 0;
|
|
32
|
+
function stop(key) {
|
|
33
|
+
const entry = running.get(key);
|
|
34
|
+
running.delete(key);
|
|
35
|
+
if (entry) {
|
|
36
|
+
if (entry.timer) {
|
|
37
|
+
clearTimeout(entry.timer);
|
|
38
|
+
}
|
|
39
|
+
void entry.worker.terminate();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function start(key, file) {
|
|
43
|
+
const worker = new Worker(SOURCE, {
|
|
44
|
+
eval: true,
|
|
45
|
+
env: {},
|
|
46
|
+
workerData: { url: pathToFileURL(file).href },
|
|
47
|
+
});
|
|
48
|
+
worker.unref();
|
|
49
|
+
const entry = { worker, pending: new Map(), timer: null };
|
|
50
|
+
const failAll = (error) => {
|
|
51
|
+
for (const [id, resolve] of entry.pending) {
|
|
52
|
+
resolve({ id, error });
|
|
53
|
+
}
|
|
54
|
+
entry.pending.clear();
|
|
55
|
+
};
|
|
56
|
+
worker.on('message', (reply) => {
|
|
57
|
+
entry.pending.get(reply.id)?.(reply);
|
|
58
|
+
entry.pending.delete(reply.id);
|
|
59
|
+
});
|
|
60
|
+
worker.on('error', (failure) => {
|
|
61
|
+
failAll(failure.stack ?? String(failure));
|
|
62
|
+
stop(key);
|
|
63
|
+
});
|
|
64
|
+
worker.on('exit', () => {
|
|
65
|
+
failAll('The fake API stopped');
|
|
66
|
+
if (running.get(key) === entry) {
|
|
67
|
+
running.delete(key);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
running.set(key, entry);
|
|
71
|
+
return entry;
|
|
72
|
+
}
|
|
73
|
+
/** Asks the fake API in `file`, run once per `key`, and gives its answer. */
|
|
74
|
+
async function answer(key, file, request) {
|
|
75
|
+
const entry = running.get(key) ?? start(key, file);
|
|
76
|
+
if (entry.timer) {
|
|
77
|
+
clearTimeout(entry.timer);
|
|
78
|
+
}
|
|
79
|
+
entry.timer = setTimeout(() => stop(key), idle);
|
|
80
|
+
entry.timer.unref();
|
|
81
|
+
const body = request.method === 'GET' || request.method === 'HEAD' ? null : await request.arrayBuffer();
|
|
82
|
+
const asked = {
|
|
83
|
+
id: (next += 1),
|
|
84
|
+
method: request.method,
|
|
85
|
+
url: request.url,
|
|
86
|
+
headers: [...request.headers],
|
|
87
|
+
body,
|
|
88
|
+
};
|
|
89
|
+
const reply = await new Promise((resolve) => {
|
|
90
|
+
entry.pending.set(asked.id, resolve);
|
|
91
|
+
entry.worker.postMessage(asked, body ? [body] : []);
|
|
92
|
+
});
|
|
93
|
+
if ('error' in reply) {
|
|
94
|
+
return Response.json({ error: reply.error }, { status: 500 });
|
|
95
|
+
}
|
|
96
|
+
const empty = reply.status === 204 || reply.status === 304;
|
|
97
|
+
return new Response(empty ? null : reply.body, {
|
|
98
|
+
status: reply.status,
|
|
99
|
+
headers: reply.headers,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** The keys of the fake APIs loaded now, for the tests. */
|
|
103
|
+
const loaded = () => [...running.keys()];
|
|
104
|
+
/** Stops every fake API, as the dev server closes. */
|
|
105
|
+
function stopAll() {
|
|
106
|
+
for (const key of [...running.keys()]) {
|
|
107
|
+
stop(key);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { answer, loaded, stopAll };
|
|
111
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the app is in a commit's tree: at `path`, the app's folder in the
|
|
3
|
+
* repository as the checkout has it, or at the top for a commit from before
|
|
4
|
+
* the app moved there. A folder holds the app when it has Vite's index.html.
|
|
5
|
+
*/
|
|
6
|
+
export declare function appFolder(tree: string, path: string): string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Where the app is in a commit's tree: at `path`, the app's folder in the
|
|
5
|
+
* repository as the checkout has it, or at the top for a commit from before
|
|
6
|
+
* the app moved there. A folder holds the app when it has Vite's index.html.
|
|
7
|
+
*/
|
|
8
|
+
export function appFolder(tree, path) {
|
|
9
|
+
const moved = join(tree, path);
|
|
10
|
+
return path !== '' && existsSync(join(moved, 'index.html')) ? moved : tree;
|
|
11
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Git } from './git.js';
|
|
2
|
+
export interface Build {
|
|
3
|
+
state: 'none' | 'queued' | 'building' | 'built' | 'failed';
|
|
4
|
+
/** What it is doing now, while building. */
|
|
5
|
+
step?: string;
|
|
6
|
+
/** 1 for a commit whose app talks to the viewer; 0 for one from before. Its hello says which protocol. */
|
|
7
|
+
protocol?: number;
|
|
8
|
+
/** The end of the log, when it failed. */
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
/** A fake API beside the app: its module, from the app's folder, and where the app asks it. */
|
|
12
|
+
export interface ApiOption {
|
|
13
|
+
entry: string;
|
|
14
|
+
path: string;
|
|
15
|
+
}
|
|
16
|
+
/** A build's own fake API: its bundle, and where the build asks it. */
|
|
17
|
+
export interface BuiltApi {
|
|
18
|
+
file: string;
|
|
19
|
+
path: string;
|
|
20
|
+
}
|
|
21
|
+
/** A tree's lockfile and the package manager it belongs to: pnpm, or npm for a commit from before a switch. */
|
|
22
|
+
export declare function lockOf(dir: string): Promise<{
|
|
23
|
+
manager: string;
|
|
24
|
+
text: string | null;
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* Builds any commit of the app, one at a time, in a git worktree under
|
|
28
|
+
* `.builds/work/`, into `.builds/<sha>/`, served under `/__workbench/b/<sha>/`.
|
|
29
|
+
* The checkout and its uncommitted changes are never touched.
|
|
30
|
+
*
|
|
31
|
+
* The app may live in a folder of the repository, such as a pnpm workspace's
|
|
32
|
+
* `apps/app/`: each commit is built from that folder, or from the top for a
|
|
33
|
+
* commit from before the app moved there. With `api`, a commit that has the
|
|
34
|
+
* fake API is bundled too, into `.builds/<sha>/__api/`, so its build asks its
|
|
35
|
+
* own API, whatever the checkout's API has become since.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createBuilds(options: {
|
|
38
|
+
root: () => string;
|
|
39
|
+
git: Git;
|
|
40
|
+
api?: () => ApiOption | null;
|
|
41
|
+
}): {
|
|
42
|
+
apiOf: (sha: string) => Promise<BuiltApi | null>;
|
|
43
|
+
buildOf: (sha: string) => Promise<Build>;
|
|
44
|
+
requestBuild: (sha: string, retry?: boolean) => Promise<Build>;
|
|
45
|
+
settled: () => Promise<void>;
|
|
46
|
+
folder: () => string;
|
|
47
|
+
};
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { appendFile, lstat, mkdir, readdir, readFile, rename, rm, rmdir, symlink, unlink, writeFile, } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { appFolder } from './appFolder.js';
|
|
7
|
+
import { bundleApi } from './bundleApi.js';
|
|
8
|
+
import { buildEnvironment } from './environment.js';
|
|
9
|
+
import { isWorkspace } from './isWorkspace.js';
|
|
10
|
+
/** The Vite this plugin runs in, for a commit that has none of its own installed. */
|
|
11
|
+
const ownVite = () => {
|
|
12
|
+
return join(dirname(createRequire(import.meta.url).resolve('vite/package.json')), 'bin', 'vite.js');
|
|
13
|
+
};
|
|
14
|
+
/** A tree's lockfile and the package manager it belongs to: pnpm, or npm for a commit from before a switch. */
|
|
15
|
+
export async function lockOf(dir) {
|
|
16
|
+
const pnpm = await readFile(join(dir, 'pnpm-lock.yaml'), 'utf8').catch(() => null);
|
|
17
|
+
if (pnpm !== null) {
|
|
18
|
+
return { manager: 'pnpm', text: pnpm };
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
manager: 'npm',
|
|
22
|
+
text: await readFile(join(dir, 'package-lock.json'), 'utf8').catch(() => null),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Builds any commit of the app, one at a time, in a git worktree under
|
|
27
|
+
* `.builds/work/`, into `.builds/<sha>/`, served under `/__workbench/b/<sha>/`.
|
|
28
|
+
* The checkout and its uncommitted changes are never touched.
|
|
29
|
+
*
|
|
30
|
+
* The app may live in a folder of the repository, such as a pnpm workspace's
|
|
31
|
+
* `apps/app/`: each commit is built from that folder, or from the top for a
|
|
32
|
+
* commit from before the app moved there. With `api`, a commit that has the
|
|
33
|
+
* fake API is bundled too, into `.builds/<sha>/__api/`, so its build asks its
|
|
34
|
+
* own API, whatever the checkout's API has become since.
|
|
35
|
+
*/
|
|
36
|
+
export function createBuilds(options) {
|
|
37
|
+
const states = new Map();
|
|
38
|
+
const queue = [];
|
|
39
|
+
let busy = false;
|
|
40
|
+
const folder = () => join(options.root(), '.builds');
|
|
41
|
+
/** A built commit's fake API, or null when it has none. */
|
|
42
|
+
async function apiOf(sha) {
|
|
43
|
+
try {
|
|
44
|
+
const meta = JSON.parse(await readFile(join(folder(), sha, 'meta.json'), 'utf8'));
|
|
45
|
+
return meta.ok && meta.api
|
|
46
|
+
? { file: join(folder(), sha, meta.api.file), path: meta.api.path }
|
|
47
|
+
: null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function buildOf(sha) {
|
|
54
|
+
const live = states.get(sha);
|
|
55
|
+
if (live) {
|
|
56
|
+
return live;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const meta = JSON.parse(await readFile(join(folder(), sha, 'meta.json'), 'utf8'));
|
|
60
|
+
const build = meta.ok
|
|
61
|
+
? { state: 'built', protocol: meta.protocol ?? 0 }
|
|
62
|
+
: { state: 'failed', ...(meta.error !== undefined && { error: meta.error }) };
|
|
63
|
+
states.set(sha, build);
|
|
64
|
+
return build;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { state: 'none' };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function requestBuild(sha, retry = false) {
|
|
71
|
+
const build = await buildOf(sha);
|
|
72
|
+
if (build.state === 'failed' && retry) {
|
|
73
|
+
states.delete(sha);
|
|
74
|
+
await rm(join(folder(), sha), { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
else if (build.state !== 'none') {
|
|
77
|
+
return build;
|
|
78
|
+
}
|
|
79
|
+
const queued = { state: 'queued' };
|
|
80
|
+
states.set(sha, queued);
|
|
81
|
+
queue.push(sha);
|
|
82
|
+
void drain();
|
|
83
|
+
return queued;
|
|
84
|
+
}
|
|
85
|
+
/** Waits for the queue to empty, for the tests. */
|
|
86
|
+
async function settled() {
|
|
87
|
+
while (busy || queue.length > 0) {
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function drain() {
|
|
92
|
+
if (busy) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
busy = true;
|
|
96
|
+
try {
|
|
97
|
+
for (let sha = queue.shift(); sha; sha = queue.shift()) {
|
|
98
|
+
await build(sha);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
busy = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Runs a command into the build's log, failing on a non-zero exit. */
|
|
106
|
+
const step = (sha, log, command, args, cwd, name) => {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
// pnpm and npm are .cmd files on Windows, which only a shell starts; the arguments are ours.
|
|
109
|
+
const shell = process.platform === 'win32' && (command === 'pnpm' || command === 'npm');
|
|
110
|
+
const child = spawn(command, args, { cwd, shell, env: buildEnvironment() });
|
|
111
|
+
const write = (chunk) => {
|
|
112
|
+
void appendFile(log, chunk);
|
|
113
|
+
};
|
|
114
|
+
child.stdout.on('data', write);
|
|
115
|
+
child.stderr.on('data', write);
|
|
116
|
+
child.on('error', reject);
|
|
117
|
+
child.on('close', (code) => {
|
|
118
|
+
if (code === 0) {
|
|
119
|
+
resolve();
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
reject(new Error(`${name} exited with ${code}`));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
states.set(sha, { state: 'building', step: name });
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
async function build(sha) {
|
|
129
|
+
const root = options.root();
|
|
130
|
+
const work = join(folder(), 'work', sha);
|
|
131
|
+
const out = join(folder(), sha);
|
|
132
|
+
const log = join(folder(), `${sha}.log`);
|
|
133
|
+
await mkdir(join(folder(), 'work'), { recursive: true });
|
|
134
|
+
await writeFile(log, `Building ${sha}\n`);
|
|
135
|
+
states.set(sha, { state: 'building', step: 'checking out' });
|
|
136
|
+
try {
|
|
137
|
+
await dropWork(work);
|
|
138
|
+
await options.git('worktree', 'prune');
|
|
139
|
+
await options.git('worktree', 'add', '--detach', work, sha);
|
|
140
|
+
// The worktree is the whole repository; the app is in its folder, or at the top before a move.
|
|
141
|
+
const top = (await options.git('rev-parse', '--show-toplevel')).trim();
|
|
142
|
+
const app = appFolder(work, relative(top, root));
|
|
143
|
+
await addBasePath(app);
|
|
144
|
+
// A workspace installs, so its packages link to this commit's, not the checkout's. One
|
|
145
|
+
// package with the same lockfile as the checkout borrows its node_modules. Otherwise the
|
|
146
|
+
// commit installs its own, with the package manager its lockfile belongs to.
|
|
147
|
+
const ours = await lockOf(top);
|
|
148
|
+
const theirs = await lockOf(work);
|
|
149
|
+
if (await isWorkspace(work)) {
|
|
150
|
+
await step(sha, log, 'pnpm', ['install', '--frozen-lockfile', '--prefer-offline'], work, 'pnpm install');
|
|
151
|
+
}
|
|
152
|
+
else if (theirs.text !== null &&
|
|
153
|
+
theirs.text === ours.text &&
|
|
154
|
+
existsSync(join(top, 'node_modules'))) {
|
|
155
|
+
await symlink(join(top, 'node_modules'), join(work, 'node_modules'), 'junction');
|
|
156
|
+
}
|
|
157
|
+
else if (theirs.manager === 'pnpm') {
|
|
158
|
+
// The worktree sits inside the checkout: a commit with no workspace file of its own
|
|
159
|
+
// must not take the checkout's for its own.
|
|
160
|
+
const alone = existsSync(join(work, 'pnpm-workspace.yaml')) ? [] : ['--ignore-workspace'];
|
|
161
|
+
await step(sha, log, 'pnpm', ['install', '--frozen-lockfile', ...alone], work, 'pnpm install');
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
await step(sha, log, 'npm', ['ci', '--no-audit', '--no-fund', '--loglevel=error'], work, 'npm ci');
|
|
165
|
+
}
|
|
166
|
+
const temporary = `${out}.tmp`;
|
|
167
|
+
await rm(temporary, { recursive: true, force: true });
|
|
168
|
+
// The commit's own Vite: the app's, then the workspace's, then this plugin's.
|
|
169
|
+
const vite = [
|
|
170
|
+
join(app, 'node_modules', 'vite', 'bin', 'vite.js'),
|
|
171
|
+
join(work, 'node_modules', 'vite', 'bin', 'vite.js'),
|
|
172
|
+
].find((file) => existsSync(file)) ?? ownVite();
|
|
173
|
+
const args = [
|
|
174
|
+
vite,
|
|
175
|
+
'build',
|
|
176
|
+
'--base',
|
|
177
|
+
`/__workbench/b/${sha}/`,
|
|
178
|
+
'--outDir',
|
|
179
|
+
temporary,
|
|
180
|
+
'--emptyOutDir',
|
|
181
|
+
];
|
|
182
|
+
await step(sha, log, process.execPath, args, app, 'vite build');
|
|
183
|
+
// Its own fake API, when the commit has one inside the worktree.
|
|
184
|
+
const api = options.api?.() ?? null;
|
|
185
|
+
let built = null;
|
|
186
|
+
const entry = api ? resolve(app, api.entry) : null;
|
|
187
|
+
if (api && entry && !relative(work, entry).startsWith('..')) {
|
|
188
|
+
const file = await bundleApi((more, cwd) => step(sha, log, process.execPath, [vite, ...more], cwd, 'API bundle'), app, entry, join(temporary, '__api'));
|
|
189
|
+
built = file ? { file: `__api/${file}`, path: api.path } : null;
|
|
190
|
+
}
|
|
191
|
+
const protocol = (await bundleText(temporary)).includes('aitems-workbench') ? 1 : 0;
|
|
192
|
+
await writeFile(join(temporary, 'meta.json'), JSON.stringify({
|
|
193
|
+
sha,
|
|
194
|
+
ok: true,
|
|
195
|
+
protocol,
|
|
196
|
+
builtAt: new Date().toISOString(),
|
|
197
|
+
...(built && { api: built }),
|
|
198
|
+
}));
|
|
199
|
+
await rm(out, { recursive: true, force: true });
|
|
200
|
+
await rename(temporary, out);
|
|
201
|
+
states.set(sha, { state: 'built', protocol });
|
|
202
|
+
}
|
|
203
|
+
catch (failure) {
|
|
204
|
+
const tail = (await readFile(log, 'utf8').catch(() => '')).split('\n').slice(-40).join('\n');
|
|
205
|
+
const error = `${failure.message}\n${tail}`.trim();
|
|
206
|
+
await mkdir(out, { recursive: true });
|
|
207
|
+
await writeFile(join(out, 'meta.json'), JSON.stringify({ sha, ok: false, error, builtAt: new Date().toISOString() }));
|
|
208
|
+
states.set(sha, { state: 'failed', error });
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
await dropWork(work);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Removes a build's worktree. Its node_modules may be a link to the
|
|
216
|
+
* checkout's: that link goes first, on its own, because removing the
|
|
217
|
+
* worktree with it in place follows it and empties the checkout's packages.
|
|
218
|
+
*/
|
|
219
|
+
async function dropWork(work) {
|
|
220
|
+
const modules = join(work, 'node_modules');
|
|
221
|
+
if ((await lstat(modules).catch(() => null))?.isSymbolicLink()) {
|
|
222
|
+
await unlink(modules).catch(() => rmdir(modules));
|
|
223
|
+
}
|
|
224
|
+
await options
|
|
225
|
+
.git('worktree', 'remove', '--force', work)
|
|
226
|
+
.catch(() => rm(work, { recursive: true, force: true }));
|
|
227
|
+
}
|
|
228
|
+
return { apiOf, buildOf, requestBuild, settled, folder };
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* A router made before the app was served under a base path matches nothing
|
|
232
|
+
* there; such a commit's TanStack router is given `import.meta.env.BASE_URL`.
|
|
233
|
+
*/
|
|
234
|
+
async function addBasePath(app) {
|
|
235
|
+
const main = join(app, 'src', 'main.tsx');
|
|
236
|
+
if (!existsSync(main)) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const source = await readFile(main, 'utf8');
|
|
240
|
+
if (source.includes('createRouter({ routeTree })')) {
|
|
241
|
+
await writeFile(main, source.replace('createRouter({ routeTree })', 'createRouter({ routeTree, basepath: import.meta.env.BASE_URL })'));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async function bundleText(dir) {
|
|
245
|
+
const assets = join(dir, 'assets');
|
|
246
|
+
const files = existsSync(assets)
|
|
247
|
+
? (await readdir(assets)).filter((file) => file.endsWith('.js'))
|
|
248
|
+
: [];
|
|
249
|
+
return (await Promise.all(files.map((file) => readFile(join(assets, file), 'utf8')))).join('\n');
|
|
250
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Runs that commit's Vite with these arguments, in that folder. */
|
|
2
|
+
export type RunVite = (args: string[], cwd: string) => Promise<void>;
|
|
3
|
+
/**
|
|
4
|
+
* Bundles a commit's fake API, `entry`, into `out`: one module with every
|
|
5
|
+
* package inside it, so it still runs once the commit's worktree is gone.
|
|
6
|
+
* The commit's own Vite builds it, without the app's config or plugins. Gives
|
|
7
|
+
* the bundle's file name in `out`, or null when the commit has no fake API.
|
|
8
|
+
*/
|
|
9
|
+
export declare function bundleApi(runVite: RunVite, app: string, entry: string, out: string): Promise<string | null>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, extname, join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Bundles a commit's fake API, `entry`, into `out`: one module with every
|
|
6
|
+
* package inside it, so it still runs once the commit's worktree is gone.
|
|
7
|
+
* The commit's own Vite builds it, without the app's config or plugins. Gives
|
|
8
|
+
* the bundle's file name in `out`, or null when the commit has no fake API.
|
|
9
|
+
*/
|
|
10
|
+
export async function bundleApi(runVite, app, entry, out) {
|
|
11
|
+
if (!existsSync(entry)) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const config = join(app, '.workbench-api.config.mjs');
|
|
15
|
+
await writeFile(config, `export default ${JSON.stringify({
|
|
16
|
+
logLevel: 'warn',
|
|
17
|
+
publicDir: false,
|
|
18
|
+
build: { ssr: entry, outDir: out, emptyOutDir: true, minify: false, copyPublicDir: false },
|
|
19
|
+
ssr: { noExternal: true, target: 'node' },
|
|
20
|
+
})};\n`);
|
|
21
|
+
try {
|
|
22
|
+
await runVite(['build', '--config', config], app);
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
await rm(config, { force: true });
|
|
26
|
+
}
|
|
27
|
+
// The bundle is a module wherever it sits, whatever package.json is above it.
|
|
28
|
+
await writeFile(join(out, 'package.json'), '{ "type": "module" }\n');
|
|
29
|
+
const name = basename(entry, extname(entry));
|
|
30
|
+
const files = await readdir(out);
|
|
31
|
+
return files.find((file) => file === `${name}.js` || file === `${name}.mjs`) ?? null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Git } from './git.js';
|
|
2
|
+
interface Answer {
|
|
3
|
+
status: number;
|
|
4
|
+
body: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Checks a branch out, refused while anything is uncommitted. When the
|
|
8
|
+
* lockfile changes, installs and restarts the dev server.
|
|
9
|
+
*/
|
|
10
|
+
export declare function checkout(git: Git, root: string, branch: string, restart: () => void): Promise<Answer>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { lockOf } from './builds.js';
|
|
3
|
+
/**
|
|
4
|
+
* Checks a branch out, refused while anything is uncommitted. When the
|
|
5
|
+
* lockfile changes, installs and restarts the dev server.
|
|
6
|
+
*/
|
|
7
|
+
export async function checkout(git, root, branch, restart) {
|
|
8
|
+
const changed = (await git('status', '--porcelain')).split('\n').filter(Boolean).length;
|
|
9
|
+
if (changed) {
|
|
10
|
+
return {
|
|
11
|
+
status: 409,
|
|
12
|
+
body: { error: `You have ${changed} changed files. Commit or stash them first.`, changed },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const known = (await git('for-each-ref', '--format=%(refname:short)', `refs/heads/${branch}`, `refs/remotes/origin/${branch}`)).trim();
|
|
16
|
+
if (!known) {
|
|
17
|
+
return { status: 404, body: { error: `No branch ${branch}` } };
|
|
18
|
+
}
|
|
19
|
+
const before = (await lockOf(root)).text;
|
|
20
|
+
await git('switch', branch);
|
|
21
|
+
const after = await lockOf(root);
|
|
22
|
+
const installed = before !== after.text;
|
|
23
|
+
if (installed) {
|
|
24
|
+
await new Promise((resolve, reject) => {
|
|
25
|
+
const args = after.manager === 'pnpm' ? ['install'] : ['install', '--no-audit', '--no-fund'];
|
|
26
|
+
const child = spawn(after.manager, args, {
|
|
27
|
+
cwd: root,
|
|
28
|
+
shell: process.platform === 'win32',
|
|
29
|
+
stdio: 'inherit',
|
|
30
|
+
});
|
|
31
|
+
child.on('close', (code) => {
|
|
32
|
+
if (code === 0) {
|
|
33
|
+
resolve();
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
reject(new Error(`${after.manager} install exited with ${code}`));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
setTimeout(restart, 50);
|
|
41
|
+
}
|
|
42
|
+
return { status: 200, body: { current: branch, installed } };
|
|
43
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Every setting the local builds service reads from the environment. */
|
|
2
|
+
export declare const environment: {
|
|
3
|
+
/** The viewer's address. Without it, the dev server opens no viewer. */
|
|
4
|
+
viewerUrl: string | null;
|
|
5
|
+
/** The package script that started the dev server: only `dev` opens the viewer. */
|
|
6
|
+
script: string | null;
|
|
7
|
+
};
|
|
8
|
+
/** What a build's install and bundle run with: this environment, as CI. */
|
|
9
|
+
export declare const buildEnvironment: () => NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const viewer = process.env['WORKBENCH_URL']?.replace(/\/$/, '');
|
|
2
|
+
/** Every setting the local builds service reads from the environment. */
|
|
3
|
+
export const environment = {
|
|
4
|
+
/** The viewer's address. Without it, the dev server opens no viewer. */
|
|
5
|
+
viewerUrl: viewer === undefined || viewer === '' ? null : viewer,
|
|
6
|
+
/** The package script that started the dev server: only `dev` opens the viewer. */
|
|
7
|
+
script: process.env['npm_lifecycle_event'] ?? null,
|
|
8
|
+
};
|
|
9
|
+
/** What a build's install and bundle run with: this environment, as CI. */
|
|
10
|
+
export const buildEnvironment = () => ({ ...process.env, CI: '1' });
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* A Node request as a fetch `Request` for a fake API, at `path`: the part of
|
|
4
|
+
* the address after the API's mount, with its query.
|
|
5
|
+
*/
|
|
6
|
+
export declare function toRequest(request: IncomingMessage, path: string): Promise<Request>;
|
|
7
|
+
/** Writes a fake API's fetch `Response` to a Node response. */
|
|
8
|
+
export declare function sendResponse(response: ServerResponse, answer: Response): Promise<void>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Headers Node works out for itself, from what it sends. */
|
|
2
|
+
const COMPUTED = new Set(['content-length', 'transfer-encoding', 'connection']);
|
|
3
|
+
const bodyOf = (request) => {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const chunks = [];
|
|
6
|
+
request.on('data', (chunk) => chunks.push(chunk));
|
|
7
|
+
request.on('end', () => resolve(Buffer.concat(chunks)));
|
|
8
|
+
request.on('error', reject);
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A Node request as a fetch `Request` for a fake API, at `path`: the part of
|
|
13
|
+
* the address after the API's mount, with its query.
|
|
14
|
+
*/
|
|
15
|
+
export async function toRequest(request, path) {
|
|
16
|
+
const method = request.method ?? 'GET';
|
|
17
|
+
const headers = new Headers();
|
|
18
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
19
|
+
if (value === undefined) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
for (const each of Array.isArray(value) ? value : [value]) {
|
|
23
|
+
headers.append(name, each);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const body = method === 'GET' || method === 'HEAD' ? null : Uint8Array.from(await bodyOf(request));
|
|
27
|
+
return new Request(new URL(path, 'http://localhost'), { method, headers, body });
|
|
28
|
+
}
|
|
29
|
+
/** Writes a fake API's fetch `Response` to a Node response. */
|
|
30
|
+
export async function sendResponse(response, answer) {
|
|
31
|
+
response.statusCode = answer.status;
|
|
32
|
+
answer.headers.forEach((value, name) => {
|
|
33
|
+
if (!COMPUTED.has(name) && name !== 'set-cookie') {
|
|
34
|
+
response.setHeader(name, value);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
const cookies = answer.headers.getSetCookie();
|
|
38
|
+
if (cookies.length > 0) {
|
|
39
|
+
response.setHeader('set-cookie', cookies);
|
|
40
|
+
}
|
|
41
|
+
response.end(Buffer.from(await answer.arrayBuffer()));
|
|
42
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type Git = (...args: string[]) => Promise<string>;
|
|
2
|
+
/** Runs git in a repository, and gives its output. */
|
|
3
|
+
export declare function gitIn(root: string): Git;
|
|
4
|
+
/** Whether a ref names a commit. */
|
|
5
|
+
export declare const refExists: (git: Git, ref: string) => Promise<boolean>;
|
|
6
|
+
/** The repository's default branch, as its remote has it, or `main`. */
|
|
7
|
+
export declare function defaultBranch(git: Git): Promise<string>;
|
package/dist/vite/git.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
/** Runs git in a repository, and gives its output. */
|
|
5
|
+
export function gitIn(root) {
|
|
6
|
+
return async (...args) => {
|
|
7
|
+
return (await run('git', args, { cwd: root, maxBuffer: 16 * 1024 * 1024 })).stdout;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/** Whether a ref names a commit. */
|
|
11
|
+
export const refExists = (git, ref) => {
|
|
12
|
+
return git('rev-parse', '--verify', '--quiet', ref).then(() => true, () => false);
|
|
13
|
+
};
|
|
14
|
+
/** The repository's default branch, as its remote has it, or `main`. */
|
|
15
|
+
export async function defaultBranch(git) {
|
|
16
|
+
const head = await git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD').catch(() => '');
|
|
17
|
+
return head.trim().replace(/^origin\//, '') || 'main';
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { workbenchLocal } from './workbenchLocal.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { workbenchLocal } from './workbenchLocal.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Whether a tree is a pnpm workspace of several packages. A pnpm-workspace.yaml
|
|
5
|
+
* without `packages` only holds settings for one package.
|
|
6
|
+
*/
|
|
7
|
+
export async function isWorkspace(tree) {
|
|
8
|
+
const text = await readFile(join(tree, 'pnpm-workspace.yaml'), 'utf8').catch(() => '');
|
|
9
|
+
return /^packages\s*:/m.test(text);
|
|
10
|
+
}
|