@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,7 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http';
|
|
2
|
+
export declare function sendJson(response: ServerResponse, status: number, body: unknown): void;
|
|
3
|
+
/**
|
|
4
|
+
* A file of a build; a path that is no file is the app's own route, so its
|
|
5
|
+
* index. The build's fake API bundle is run, never served.
|
|
6
|
+
*/
|
|
7
|
+
export declare function serveBuild(response: ServerResponse, folder: string, sha: string, rest: string): Promise<void>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createReadStream, existsSync } from 'node:fs';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { extname, join, resolve, sep } from 'node:path';
|
|
4
|
+
const TYPES = {
|
|
5
|
+
'.html': 'text/html; charset=utf-8',
|
|
6
|
+
'.js': 'text/javascript',
|
|
7
|
+
'.css': 'text/css',
|
|
8
|
+
'.json': 'application/json',
|
|
9
|
+
'.svg': 'image/svg+xml',
|
|
10
|
+
'.png': 'image/png',
|
|
11
|
+
'.jpg': 'image/jpeg',
|
|
12
|
+
'.webp': 'image/webp',
|
|
13
|
+
'.woff2': 'font/woff2',
|
|
14
|
+
'.ico': 'image/x-icon',
|
|
15
|
+
};
|
|
16
|
+
export function sendJson(response, status, body) {
|
|
17
|
+
response.statusCode = status;
|
|
18
|
+
response.setHeader('Content-Type', 'application/json');
|
|
19
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
20
|
+
response.end(JSON.stringify(body));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A file of a build; a path that is no file is the app's own route, so its
|
|
24
|
+
* index. The build's fake API bundle is run, never served.
|
|
25
|
+
*/
|
|
26
|
+
export async function serveBuild(response, folder, sha, rest) {
|
|
27
|
+
const dir = join(folder, sha);
|
|
28
|
+
let file = resolve(dir, `.${decodeURIComponent(rest || '/')}`);
|
|
29
|
+
if (!file.startsWith(dir + sep) && file !== dir) {
|
|
30
|
+
return sendJson(response, 400, { error: 'Bad path' });
|
|
31
|
+
}
|
|
32
|
+
if (file === join(dir, '__api') || file.startsWith(join(dir, '__api') + sep)) {
|
|
33
|
+
return sendJson(response, 404, { error: 'Not found' });
|
|
34
|
+
}
|
|
35
|
+
const isFile = await stat(file).then((found) => found.isFile(), () => false);
|
|
36
|
+
if (!isFile) {
|
|
37
|
+
file = join(dir, 'index.html');
|
|
38
|
+
}
|
|
39
|
+
if (!existsSync(file)) {
|
|
40
|
+
return sendJson(response, 404, { error: 'Not built' });
|
|
41
|
+
}
|
|
42
|
+
response.setHeader('Content-Type', TYPES[extname(file)] ?? 'application/octet-stream');
|
|
43
|
+
response.setHeader('Cache-Control', file.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable');
|
|
44
|
+
createReadStream(file).pipe(response);
|
|
45
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Build } from './builds.js';
|
|
2
|
+
import { type Git } from './git.js';
|
|
3
|
+
type Sync = 'pushed' | 'ahead' | 'behind' | 'diverged' | 'local' | 'remote';
|
|
4
|
+
/**
|
|
5
|
+
* This machine's branches, with push status, author, how each stands against
|
|
6
|
+
* the base and its build; its tags; what is checked out; and how many files
|
|
7
|
+
* are changed.
|
|
8
|
+
*/
|
|
9
|
+
export declare function versions(git: Git, base: string, buildOf: (sha: string) => Promise<Build>): Promise<{
|
|
10
|
+
source: string;
|
|
11
|
+
base: string;
|
|
12
|
+
current: string;
|
|
13
|
+
changed: number;
|
|
14
|
+
branches: {
|
|
15
|
+
vsBase: {
|
|
16
|
+
ahead: number;
|
|
17
|
+
behind: number;
|
|
18
|
+
} | undefined;
|
|
19
|
+
build: Build;
|
|
20
|
+
name: string;
|
|
21
|
+
sha: string;
|
|
22
|
+
date: string;
|
|
23
|
+
author: string;
|
|
24
|
+
sync: Sync;
|
|
25
|
+
ahead: number;
|
|
26
|
+
behind: number;
|
|
27
|
+
current: boolean;
|
|
28
|
+
changed: number;
|
|
29
|
+
}[];
|
|
30
|
+
tags: {
|
|
31
|
+
name: string | undefined;
|
|
32
|
+
sha: string | undefined;
|
|
33
|
+
date: string | undefined;
|
|
34
|
+
}[];
|
|
35
|
+
}>;
|
|
36
|
+
/** A ref's history, newest first, each commit with its build. A branch only on the remote is origin's. */
|
|
37
|
+
export declare function commits(git: Git, ref: string, buildOf: (sha: string) => Promise<Build>): Promise<{
|
|
38
|
+
sha: string;
|
|
39
|
+
author: string | undefined;
|
|
40
|
+
date: string | undefined;
|
|
41
|
+
message: string;
|
|
42
|
+
build: Build;
|
|
43
|
+
}[]>;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { refExists } from './git.js';
|
|
2
|
+
const rows = (output) => {
|
|
3
|
+
return output
|
|
4
|
+
.split('\n')
|
|
5
|
+
.filter(Boolean)
|
|
6
|
+
.map((line) => line.split('\t'));
|
|
7
|
+
};
|
|
8
|
+
const BRANCH = '%(refname:short)%09%(objectname)%09%(committerdate:iso-strict)%09%(authorname)';
|
|
9
|
+
/**
|
|
10
|
+
* This machine's branches, with push status, author, how each stands against
|
|
11
|
+
* the base and its build; its tags; what is checked out; and how many files
|
|
12
|
+
* are changed.
|
|
13
|
+
*/
|
|
14
|
+
export async function versions(git, base, buildOf) {
|
|
15
|
+
const [head, status, heads, remotes, tags, baseRef] = await Promise.all([
|
|
16
|
+
git('rev-parse', '--abbrev-ref', 'HEAD'),
|
|
17
|
+
git('status', '--porcelain'),
|
|
18
|
+
git('for-each-ref', `--format=${BRANCH}%09%(upstream:short)%09%(upstream:track,nobracket)`, 'refs/heads'),
|
|
19
|
+
git('for-each-ref', `--format=${BRANCH}`, 'refs/remotes/origin'),
|
|
20
|
+
// An annotated tag names its commit through *objectname.
|
|
21
|
+
git('for-each-ref', '--sort=-creatordate', '--format=%(refname:short)%09%(if)%(*objectname)%(then)%(*objectname)%(else)%(objectname)%(end)%09%(creatordate:iso-strict)', 'refs/tags'),
|
|
22
|
+
baseRefOf(git, base),
|
|
23
|
+
]);
|
|
24
|
+
const current = head.trim();
|
|
25
|
+
const changed = status.split('\n').filter(Boolean).length;
|
|
26
|
+
const branches = rows(heads).map(([name = '', sha = '', date = '', author = '', upstream, track]) => {
|
|
27
|
+
const ahead = Number(/ahead (\d+)/.exec(track ?? '')?.[1] ?? 0);
|
|
28
|
+
const behind = Number(/behind (\d+)/.exec(track ?? '')?.[1] ?? 0);
|
|
29
|
+
return {
|
|
30
|
+
name,
|
|
31
|
+
ref: `refs/heads/${name}`,
|
|
32
|
+
sha,
|
|
33
|
+
date,
|
|
34
|
+
author,
|
|
35
|
+
sync: syncOf(upstream, track, ahead, behind),
|
|
36
|
+
ahead,
|
|
37
|
+
behind,
|
|
38
|
+
current: name === current,
|
|
39
|
+
changed: name === current ? changed : 0,
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
const local = new Set(branches.map((branch) => branch.name));
|
|
43
|
+
for (const [ref = '', sha = '', date = '', author = ''] of rows(remotes)) {
|
|
44
|
+
const name = ref.replace(/^origin\//, '');
|
|
45
|
+
if (name === 'HEAD' || name === 'origin' || local.has(name)) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
branches.push({
|
|
49
|
+
name,
|
|
50
|
+
ref: `refs/remotes/${ref}`,
|
|
51
|
+
sha,
|
|
52
|
+
date,
|
|
53
|
+
author,
|
|
54
|
+
sync: 'remote',
|
|
55
|
+
ahead: 0,
|
|
56
|
+
behind: 0,
|
|
57
|
+
current: false,
|
|
58
|
+
changed: 0,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
branches.sort((one, other) => {
|
|
62
|
+
return (Number(other.name === base) - Number(one.name === base) ||
|
|
63
|
+
Number(other.current) - Number(one.current) ||
|
|
64
|
+
other.date.localeCompare(one.date));
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
source: 'local',
|
|
68
|
+
base,
|
|
69
|
+
current,
|
|
70
|
+
changed,
|
|
71
|
+
branches: await Promise.all(branches.map(async ({ ref, ...branch }) => {
|
|
72
|
+
return {
|
|
73
|
+
...branch,
|
|
74
|
+
vsBase: baseRef ? await against(git, baseRef, ref) : undefined,
|
|
75
|
+
build: await buildOf(branch.sha),
|
|
76
|
+
};
|
|
77
|
+
})),
|
|
78
|
+
tags: rows(tags).map(([name, sha, date]) => ({ name, sha, date })),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function syncOf(upstream, track, ahead, behind) {
|
|
82
|
+
if (!upstream || track === 'gone') {
|
|
83
|
+
return 'local';
|
|
84
|
+
}
|
|
85
|
+
if (ahead && behind) {
|
|
86
|
+
return 'diverged';
|
|
87
|
+
}
|
|
88
|
+
if (ahead) {
|
|
89
|
+
return 'ahead';
|
|
90
|
+
}
|
|
91
|
+
return behind ? 'behind' : 'pushed';
|
|
92
|
+
}
|
|
93
|
+
/** The base as GitHub has it, when this machine has fetched it. */
|
|
94
|
+
async function baseRefOf(git, base) {
|
|
95
|
+
if (await refExists(git, `refs/remotes/origin/${base}`)) {
|
|
96
|
+
return `origin/${base}`;
|
|
97
|
+
}
|
|
98
|
+
return (await refExists(git, `refs/heads/${base}`)) ? base : null;
|
|
99
|
+
}
|
|
100
|
+
/** A branch against the base: its commits not there yet, and the base's not in it. */
|
|
101
|
+
async function against(git, baseRef, ref) {
|
|
102
|
+
const counts = (await git('rev-list', '--left-right', '--count', `${baseRef}...${ref}`))
|
|
103
|
+
.trim()
|
|
104
|
+
.split(/\s+/)
|
|
105
|
+
.map(Number);
|
|
106
|
+
return { ahead: counts[1] ?? 0, behind: counts[0] ?? 0 };
|
|
107
|
+
}
|
|
108
|
+
/** A ref's history, newest first, each commit with its build. A branch only on the remote is origin's. */
|
|
109
|
+
export async function commits(git, ref, buildOf) {
|
|
110
|
+
const remoteOnly = !(await refExists(git, `refs/heads/${ref}`)) &&
|
|
111
|
+
(await refExists(git, `refs/remotes/origin/${ref}`));
|
|
112
|
+
const output = await git('log', '--format=%H%x09%an%x09%aI%x09%s', '-n', '300', remoteOnly ? `origin/${ref}` : ref, '--');
|
|
113
|
+
return Promise.all(rows(output).map(async ([sha = '', author, date, ...message]) => {
|
|
114
|
+
return {
|
|
115
|
+
sha,
|
|
116
|
+
author,
|
|
117
|
+
date,
|
|
118
|
+
message: message.join('\t'),
|
|
119
|
+
build: await buildOf(sha),
|
|
120
|
+
};
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
/**
|
|
3
|
+
* The local builds service, as a Vite plugin: with it, the app's dev server
|
|
4
|
+
* also answers the deployed viewer. It lists this machine's branches and
|
|
5
|
+
* commits, builds any commit, and checks a branch out.
|
|
6
|
+
*
|
|
7
|
+
* GET /__workbench/versions branches, tags, what is checked out, changed files
|
|
8
|
+
* GET /__workbench/commits?ref=<ref> that ref's history, newest first, each with its build
|
|
9
|
+
* POST /__workbench/builds/<sha> build it; asked again, says how it stands (?retry after a failure)
|
|
10
|
+
* GET /__workbench/builds/<sha> how its build stands
|
|
11
|
+
* GET /__workbench/b/<sha>/… the build, for the viewer's frame
|
|
12
|
+
* POST /__workbench/checkout { branch }: refused while anything is uncommitted
|
|
13
|
+
* POST /__workbench/fetch git fetch, so push status is current
|
|
14
|
+
*
|
|
15
|
+
* `base` is the branch the others are compared with: the repository's
|
|
16
|
+
* default branch unless set. Builds are served without the token, as a frame
|
|
17
|
+
* can't send a header. Without WORKBENCH_URL, the dev server opens no viewer.
|
|
18
|
+
*
|
|
19
|
+
* `api` is a fake API beside the app: `entry`, its module from the app's
|
|
20
|
+
* folder, whose default export is a fetch handler, and `path`, where the app
|
|
21
|
+
* asks it (`/api` unless set). The dev server answers it there for Live, from
|
|
22
|
+
* its source; each build bundles that commit's and answers it under the
|
|
23
|
+
* build, at `/__workbench/b/<sha>/api/…`. Either way the handler is asked
|
|
24
|
+
* without the path, so `/api/bags/b1` reaches it as `/bags/b1`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function workbenchLocal(options?: {
|
|
27
|
+
base?: string;
|
|
28
|
+
api?: {
|
|
29
|
+
entry: string;
|
|
30
|
+
path?: string;
|
|
31
|
+
};
|
|
32
|
+
}): Plugin;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { apiFetchOf } from './apiFetch.js';
|
|
5
|
+
import { createApiWorkers } from './apiWorkers.js';
|
|
6
|
+
import { createBuilds } from './builds.js';
|
|
7
|
+
import { checkout } from './checkout.js';
|
|
8
|
+
import { environment } from './environment.js';
|
|
9
|
+
import { sendResponse, toRequest } from './fetchBridge.js';
|
|
10
|
+
import { defaultBranch, gitIn } from './git.js';
|
|
11
|
+
import { sendJson, serveBuild } from './serveBuild.js';
|
|
12
|
+
import { commits, versions } from './versions.js';
|
|
13
|
+
const PREFIX = '/__workbench';
|
|
14
|
+
const SHA = /^[0-9a-f]{7,40}$/;
|
|
15
|
+
const REF = /^[\w][\w./-]*$/;
|
|
16
|
+
// One token for the life of the process: Vite restarting on a config change
|
|
17
|
+
// keeps the viewer that was opened with it connected.
|
|
18
|
+
const scope = globalThis;
|
|
19
|
+
const token = (scope.workbenchToken ??= randomBytes(16).toString('hex'));
|
|
20
|
+
/** Only the viewer may ask: from its address, or a viewer on this machine. */
|
|
21
|
+
const allowed = (origin) => {
|
|
22
|
+
return (!!origin &&
|
|
23
|
+
(origin === environment.viewerUrl || /^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin)));
|
|
24
|
+
};
|
|
25
|
+
const bodyOf = (request) => {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
let text = '';
|
|
28
|
+
request.setEncoding('utf8');
|
|
29
|
+
request.on('data', (chunk) => {
|
|
30
|
+
text += chunk;
|
|
31
|
+
if (text.length > 10_000) {
|
|
32
|
+
request.destroy();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
request.on('end', () => {
|
|
36
|
+
try {
|
|
37
|
+
resolve(JSON.parse(text || '{}'));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
resolve(null);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
request.on('error', () => resolve(null));
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
function open(url) {
|
|
47
|
+
const [command, args] = process.platform === 'win32'
|
|
48
|
+
? ['rundll32', ['url.dll,FileProtocolHandler', url]]
|
|
49
|
+
: process.platform === 'darwin'
|
|
50
|
+
? ['open', [url]]
|
|
51
|
+
: ['xdg-open', [url]];
|
|
52
|
+
spawn(command, args, { detached: true, stdio: 'ignore' })
|
|
53
|
+
.on('error', () => undefined)
|
|
54
|
+
.unref();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The local builds service, as a Vite plugin: with it, the app's dev server
|
|
58
|
+
* also answers the deployed viewer. It lists this machine's branches and
|
|
59
|
+
* commits, builds any commit, and checks a branch out.
|
|
60
|
+
*
|
|
61
|
+
* GET /__workbench/versions branches, tags, what is checked out, changed files
|
|
62
|
+
* GET /__workbench/commits?ref=<ref> that ref's history, newest first, each with its build
|
|
63
|
+
* POST /__workbench/builds/<sha> build it; asked again, says how it stands (?retry after a failure)
|
|
64
|
+
* GET /__workbench/builds/<sha> how its build stands
|
|
65
|
+
* GET /__workbench/b/<sha>/… the build, for the viewer's frame
|
|
66
|
+
* POST /__workbench/checkout { branch }: refused while anything is uncommitted
|
|
67
|
+
* POST /__workbench/fetch git fetch, so push status is current
|
|
68
|
+
*
|
|
69
|
+
* `base` is the branch the others are compared with: the repository's
|
|
70
|
+
* default branch unless set. Builds are served without the token, as a frame
|
|
71
|
+
* can't send a header. Without WORKBENCH_URL, the dev server opens no viewer.
|
|
72
|
+
*
|
|
73
|
+
* `api` is a fake API beside the app: `entry`, its module from the app's
|
|
74
|
+
* folder, whose default export is a fetch handler, and `path`, where the app
|
|
75
|
+
* asks it (`/api` unless set). The dev server answers it there for Live, from
|
|
76
|
+
* its source; each build bundles that commit's and answers it under the
|
|
77
|
+
* build, at `/__workbench/b/<sha>/api/…`. Either way the handler is asked
|
|
78
|
+
* without the path, so `/api/bags/b1` reaches it as `/bags/b1`.
|
|
79
|
+
*/
|
|
80
|
+
export function workbenchLocal(options = {}) {
|
|
81
|
+
let root = process.cwd();
|
|
82
|
+
let git = gitIn(root);
|
|
83
|
+
let server = null;
|
|
84
|
+
const api = options.api
|
|
85
|
+
? {
|
|
86
|
+
entry: options.api.entry,
|
|
87
|
+
path: `/${(options.api.path ?? '/api').replace(/^\/+|\/+$/g, '')}`,
|
|
88
|
+
}
|
|
89
|
+
: null;
|
|
90
|
+
const builds = createBuilds({
|
|
91
|
+
root: () => root,
|
|
92
|
+
git: (...args) => git(...args),
|
|
93
|
+
api: () => api,
|
|
94
|
+
});
|
|
95
|
+
const workers = createApiWorkers();
|
|
96
|
+
const base = async () => options.base ?? (await defaultBranch(git));
|
|
97
|
+
async function answer(request, response, url) {
|
|
98
|
+
const path = url.pathname;
|
|
99
|
+
if (request.method === 'GET' && path === '/versions') {
|
|
100
|
+
return sendJson(response, 200, await versions(git, await base(), builds.buildOf));
|
|
101
|
+
}
|
|
102
|
+
if (request.method === 'GET' && path === '/commits') {
|
|
103
|
+
const ref = url.searchParams.get('ref') ?? 'HEAD';
|
|
104
|
+
if (!REF.test(ref)) {
|
|
105
|
+
return sendJson(response, 400, { error: 'Bad ref' });
|
|
106
|
+
}
|
|
107
|
+
return sendJson(response, 200, { commits: await commits(git, ref, builds.buildOf) });
|
|
108
|
+
}
|
|
109
|
+
const build = /^\/builds\/([0-9a-f]{7,40})$/.exec(path);
|
|
110
|
+
if (build) {
|
|
111
|
+
const sha = (await git('rev-parse', '--verify', '--quiet', `${build[1]}^{commit}`).catch(() => '')).trim();
|
|
112
|
+
if (!SHA.test(sha)) {
|
|
113
|
+
return sendJson(response, 404, { error: 'No such commit' });
|
|
114
|
+
}
|
|
115
|
+
const state = request.method === 'POST'
|
|
116
|
+
? await builds.requestBuild(sha, url.searchParams.has('retry'))
|
|
117
|
+
: await builds.buildOf(sha);
|
|
118
|
+
return sendJson(response, 200, { sha, ...state });
|
|
119
|
+
}
|
|
120
|
+
if (request.method === 'POST' && path === '/checkout') {
|
|
121
|
+
const branch = (await bodyOf(request))?.branch;
|
|
122
|
+
if (typeof branch !== 'string' || !REF.test(branch)) {
|
|
123
|
+
return sendJson(response, 400, { error: 'Bad branch' });
|
|
124
|
+
}
|
|
125
|
+
const result = await checkout(git, root, branch, () => void server?.restart());
|
|
126
|
+
return sendJson(response, result.status, result.body);
|
|
127
|
+
}
|
|
128
|
+
if (request.method === 'POST' && path === '/fetch') {
|
|
129
|
+
await git('fetch', '--prune', 'origin');
|
|
130
|
+
return sendJson(response, 200, { ok: true });
|
|
131
|
+
}
|
|
132
|
+
return sendJson(response, 404, { error: 'Not found' });
|
|
133
|
+
}
|
|
134
|
+
/** A build's own fake API, asked at `rest`, the path under the build; false when it has none there. */
|
|
135
|
+
async function answerBuildApi(request, response, sha, rest, search) {
|
|
136
|
+
const own = await builds.apiOf(sha);
|
|
137
|
+
if (!own || (rest !== own.path && !rest.startsWith(`${own.path}/`))) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const asked = await toRequest(request, `${rest.slice(own.path.length) || '/'}${search}`);
|
|
142
|
+
await sendResponse(response, await workers.answer(sha, own.file, asked));
|
|
143
|
+
}
|
|
144
|
+
catch (failure) {
|
|
145
|
+
sendJson(response, 500, { error: failure.message });
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
/** The fake API for Live, from its source: Vite loads it again when it changes. */
|
|
150
|
+
async function answerLiveApi(request, response) {
|
|
151
|
+
try {
|
|
152
|
+
if (!api || !server) {
|
|
153
|
+
return sendJson(response, 404, { error: 'No fake API' });
|
|
154
|
+
}
|
|
155
|
+
const module = await server.ssrLoadModule(resolve(root, api.entry));
|
|
156
|
+
const asked = await toRequest(request, request.url ?? '/');
|
|
157
|
+
await sendResponse(response, await apiFetchOf(module)(asked));
|
|
158
|
+
}
|
|
159
|
+
catch (failure) {
|
|
160
|
+
sendJson(response, 500, { error: failure.message });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function handle(request, response) {
|
|
164
|
+
const url = new URL(request.url ?? '/', 'http://local');
|
|
165
|
+
const built = /^\/b\/([0-9a-f]{7,40})(\/.*)?$/.exec(url.pathname);
|
|
166
|
+
const sha = built?.[1] ?? '';
|
|
167
|
+
const rest = built?.[2] ?? '/';
|
|
168
|
+
if (built && (await answerBuildApi(request, response, sha, rest, url.search))) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (built && request.method === 'GET') {
|
|
172
|
+
return serveBuild(response, builds.folder(), sha, rest);
|
|
173
|
+
}
|
|
174
|
+
const origin = request.headers.origin;
|
|
175
|
+
if (allowed(origin)) {
|
|
176
|
+
response.setHeader('Access-Control-Allow-Origin', origin);
|
|
177
|
+
response.setHeader('Vary', 'Origin');
|
|
178
|
+
response.setHeader('Access-Control-Allow-Headers', 'content-type, x-workbench-token');
|
|
179
|
+
response.setHeader('Access-Control-Allow-Methods', 'GET, POST');
|
|
180
|
+
response.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
181
|
+
}
|
|
182
|
+
if (request.method === 'OPTIONS') {
|
|
183
|
+
response.statusCode = 204;
|
|
184
|
+
return response.end();
|
|
185
|
+
}
|
|
186
|
+
if (!allowed(origin) || request.headers['x-workbench-token'] !== token) {
|
|
187
|
+
return sendJson(response, 403, {
|
|
188
|
+
error: 'Only the viewer, with the token from the dev server',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
await answer(request, response, url);
|
|
193
|
+
}
|
|
194
|
+
catch (failure) {
|
|
195
|
+
sendJson(response, 500, { error: failure.message });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
name: 'workbench-local',
|
|
200
|
+
apply: 'serve',
|
|
201
|
+
// Builds and their worktrees are no part of the app: never watch or reload for them.
|
|
202
|
+
config: () => ({ server: { watch: { ignored: ['**/.builds/**'] } } }),
|
|
203
|
+
configResolved(config) {
|
|
204
|
+
root = config.root;
|
|
205
|
+
git = gitIn(root);
|
|
206
|
+
},
|
|
207
|
+
configureServer(devServer) {
|
|
208
|
+
server = devServer;
|
|
209
|
+
devServer.middlewares.use(PREFIX, (request, response) => {
|
|
210
|
+
void handle(request, response);
|
|
211
|
+
});
|
|
212
|
+
// First in line: Vite's own CORS answers every preflight before any plugin, without the
|
|
213
|
+
// viewer's origin or Chrome's private-network header, so the deployed page could not ask.
|
|
214
|
+
const stack = devServer.middlewares.stack;
|
|
215
|
+
const ours = stack.pop();
|
|
216
|
+
if (ours) {
|
|
217
|
+
stack.unshift(ours);
|
|
218
|
+
}
|
|
219
|
+
// Before Vite's own middleware, which would answer the path with the app's page.
|
|
220
|
+
if (api) {
|
|
221
|
+
devServer.middlewares.use(api.path, (request, response) => {
|
|
222
|
+
void answerLiveApi(request, response);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
devServer.httpServer?.once('listening', () => {
|
|
226
|
+
void announce(devServer);
|
|
227
|
+
});
|
|
228
|
+
devServer.httpServer?.once('close', () => {
|
|
229
|
+
workers.stopAll();
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
async function announce(devServer) {
|
|
234
|
+
if (!environment.viewerUrl) {
|
|
235
|
+
devServer.config.logger.info('\n Viewer: set WORKBENCH_URL to its address to open it\n');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const address = devServer.httpServer?.address();
|
|
239
|
+
const port = typeof address === 'object' && address ? address.port : 5173;
|
|
240
|
+
// The repository, as owner/name: the viewer opens its project from it.
|
|
241
|
+
const remote = await git('remote', 'get-url', 'origin').catch(() => '');
|
|
242
|
+
const repository = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\s*$/.exec(remote)?.[1];
|
|
243
|
+
const local = encodeURIComponent(`http://localhost:${port}`);
|
|
244
|
+
const url = `${environment.viewerUrl}/?src=local&v=live#local=${local}&token=${token}${repository ? `&repo=${encodeURIComponent(repository)}` : ''}`;
|
|
245
|
+
devServer.config.logger.info(`\n Viewer: ${url}\n`);
|
|
246
|
+
// `pnpm dev` opens it; any other script is the app alone.
|
|
247
|
+
if (environment.script === 'dev') {
|
|
248
|
+
open(url);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
package/docs/protocol.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Protocol 4
|
|
2
|
+
|
|
3
|
+
The app and the viewer talk through `postMessage`, between the frame and the
|
|
4
|
+
page around it. The types are exported: `Manifest`, `AppMessage` and
|
|
5
|
+
`ViewerMessage`.
|
|
6
|
+
|
|
7
|
+
## Manifest
|
|
8
|
+
|
|
9
|
+
The app sends it with `hello`. The viewer draws its screen list, route tree
|
|
10
|
+
and forms from it alone, so an old commit shows its own screens and controls.
|
|
11
|
+
|
|
12
|
+
- `app`: whose build this is, such as `aitems`.
|
|
13
|
+
- `screens`: every screen, with `id`, `label`, `short`, and where it sits
|
|
14
|
+
(`parent`, `tab`, `group`, `page`, `where`, `statesBy`, `files`), each with
|
|
15
|
+
its `states`. A state goes without its jump: the viewer names the state, and
|
|
16
|
+
the app makes the jump.
|
|
17
|
+
- `controls`: every control as declared, with its `level`, and its `screen`
|
|
18
|
+
or `block`.
|
|
19
|
+
- `blocks`: each block's `id`, `label`, and its controls' ids, in order.
|
|
20
|
+
|
|
21
|
+
## Messages
|
|
22
|
+
|
|
23
|
+
| Message | Said by | Fields | Means |
|
|
24
|
+
| --- | --- | --- | --- |
|
|
25
|
+
| `hello` | App | `protocol`, `manifest` | Here is what this build has |
|
|
26
|
+
| `at` | App | `screen`, `state` or null | This is where the app is now |
|
|
27
|
+
| `blocks` | App | `screen`, `blocks` (ids) | These blocks are drawn on this screen now |
|
|
28
|
+
| `values` | App | `values` | Every control's value, now |
|
|
29
|
+
| `tone` | App | `top`: light or dark | The top of the screen, so the status bar reads on it |
|
|
30
|
+
| `height` | App | `height` | On Full page, how tall the open screen is laid out whole |
|
|
31
|
+
| `snapshot` | App | `id`, `image` (an `ImageBitmap`) or null | The screen as it was when asked |
|
|
32
|
+
| `hello` | Viewer | None | Say your hello again |
|
|
33
|
+
| `device` | Viewer | `insets` (`top`, `bottom`), `platform`, `whole` | The device drawn round the app |
|
|
34
|
+
| `go` | Viewer | `state` | Go to this state |
|
|
35
|
+
| `set` | Viewer | `id`, `value` | Give this control this value |
|
|
36
|
+
| `reset` | Viewer | `id`, or none for all | Put this control back to its default |
|
|
37
|
+
| `snapshot` | Viewer | `id`, a number | Send a picture of the screen as it is now |
|
|
38
|
+
|
|
39
|
+
- The app says `hello` as it loads in a frame and whenever the viewer says
|
|
40
|
+
`hello`, then sends `at`, `blocks` and `values`. After that it sends each
|
|
41
|
+
only when it changes, and `tone` and `height` whenever they change;
|
|
42
|
+
`height` only on Full page.
|
|
43
|
+
- The app has the last word on values: `set` asks, the app checks, and the
|
|
44
|
+
`values` it answers with says what it kept. `reset` is answered the same
|
|
45
|
+
way. After `set`, or `reset` of one control, the app scrolls to the part
|
|
46
|
+
marked `data-control` with its id.
|
|
47
|
+
- The viewer sends `device` after each `hello` and whenever another device is
|
|
48
|
+
picked.
|
|
49
|
+
- The viewer asks for a `snapshot` just before a foldable folds, and again as
|
|
50
|
+
it opens, once the app has laid itself out open, and shows them on the
|
|
51
|
+
halves that swing, as the app's frame can't be in two places. The app
|
|
52
|
+
answers with the same `id` and hands the picture over, at up to twice the
|
|
53
|
+
screen's size; `image` is null when it can't be taken. A build before
|
|
54
|
+
protocol 4 doesn't answer, and the viewer folds it dark.
|
|
55
|
+
- Outside a frame the app sends nothing, and only takes `?at=` from the
|
|
56
|
+
address.
|
|
57
|
+
|
|
58
|
+
Every message carries `source`: `'aitems'` from the app and
|
|
59
|
+
`'aitems-workbench'` from the viewer, for every app, as builds from before
|
|
60
|
+
this package send them. Each side ignores any message without the other's
|
|
61
|
+
tag, or from a window other than the one it expects.
|
|
62
|
+
|
|
63
|
+
## Changing it
|
|
64
|
+
|
|
65
|
+
Any change to a message or to the manifest bumps `PROTOCOL`, so the viewer
|
|
66
|
+
can still read older builds.
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tilewise/workbench-client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Connects a React prototype to the workbench's viewer: its screens, its controls and the protocol between them.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/tilewise/workbench.git",
|
|
9
|
+
"directory": "packages/workbench-client"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./vite": {
|
|
19
|
+
"types": "./dist/vite/index.d.ts",
|
|
20
|
+
"default": "./dist/vite/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"docs",
|
|
29
|
+
"README.md",
|
|
30
|
+
"CHANGELOG.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"react": "^19.0.0",
|
|
35
|
+
"vite": ">=7.0.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"vite": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@testing-library/dom": "^10.4.1",
|
|
44
|
+
"@testing-library/react": "^16.3.3",
|
|
45
|
+
"@tilewise/config": "^0.2.0",
|
|
46
|
+
"@types/node": "^24.13.3",
|
|
47
|
+
"@types/react": "^19.2.18",
|
|
48
|
+
"@types/react-dom": "^19.2.4",
|
|
49
|
+
"jsdom": "^30.1.0",
|
|
50
|
+
"react": "^19.2.8",
|
|
51
|
+
"react-dom": "^19.2.8",
|
|
52
|
+
"typescript": "~6.0.2",
|
|
53
|
+
"vite": "^8.2.2",
|
|
54
|
+
"vitest": "^4.1.11"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"modern-screenshot": "^4.7.0"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tilewise-build",
|
|
61
|
+
"check:circular": "tilewise-madge src",
|
|
62
|
+
"lint": "tilewise-lint src/",
|
|
63
|
+
"test": "vitest run",
|
|
64
|
+
"typecheck": "tsc --noEmit"
|
|
65
|
+
}
|
|
6
66
|
}
|