@tilewise/workbench-client 0.0.0-stage → 0.1.0

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +176 -2
  4. package/dist/blocks/ScreenScope.d.ts +11 -0
  5. package/dist/blocks/ScreenScope.js +11 -0
  6. package/dist/blocks/drawnBlocks.d.ts +11 -0
  7. package/dist/blocks/drawnBlocks.js +47 -0
  8. package/dist/blocks/screenContext.d.ts +2 -0
  9. package/dist/blocks/screenContext.js +3 -0
  10. package/dist/bridge/Bridge.d.ts +9 -0
  11. package/dist/bridge/Bridge.js +120 -0
  12. package/dist/bridge/device.d.ts +6 -0
  13. package/dist/bridge/device.js +28 -0
  14. package/dist/bridge/post.d.ts +5 -0
  15. package/dist/bridge/post.js +8 -0
  16. package/dist/bridge/readViewerMessage.d.ts +3 -0
  17. package/dist/bridge/readViewerMessage.js +44 -0
  18. package/dist/bridge/revealControl.d.ts +10 -0
  19. package/dist/bridge/revealControl.js +62 -0
  20. package/dist/bridge/toneAtTop.d.ts +3 -0
  21. package/dist/bridge/toneAtTop.js +23 -0
  22. package/dist/bridge/watchScreen.d.ts +10 -0
  23. package/dist/bridge/watchScreen.js +50 -0
  24. package/dist/bridge/wholeHeight.d.ts +8 -0
  25. package/dist/bridge/wholeHeight.js +16 -0
  26. package/dist/controls/paint.d.ts +8 -0
  27. package/dist/controls/paint.js +16 -0
  28. package/dist/controls/parseValue.d.ts +7 -0
  29. package/dist/controls/parseValue.js +34 -0
  30. package/dist/controls/resetControl.d.ts +3 -0
  31. package/dist/controls/resetControl.js +6 -0
  32. package/dist/controls/setControl.d.ts +3 -0
  33. package/dist/controls/setControl.js +6 -0
  34. package/dist/controls/store.d.ts +12 -0
  35. package/dist/controls/store.js +125 -0
  36. package/dist/controls/types.d.ts +111 -0
  37. package/dist/controls/types.js +6 -0
  38. package/dist/controls/useControl.d.ts +7 -0
  39. package/dist/controls/useControl.js +16 -0
  40. package/dist/define/buildManifest.d.ts +8 -0
  41. package/dist/define/buildManifest.js +51 -0
  42. package/dist/define/checkDefaults.d.ts +3 -0
  43. package/dist/define/checkDefaults.js +29 -0
  44. package/dist/define/defineApp.d.ts +24 -0
  45. package/dist/define/defineApp.js +92 -0
  46. package/dist/define/defineBlock.d.ts +12 -0
  47. package/dist/define/defineBlock.js +10 -0
  48. package/dist/define/defineScreen.d.ts +11 -0
  49. package/dist/define/defineScreen.js +10 -0
  50. package/dist/define/part.d.ts +23 -0
  51. package/dist/define/part.js +22 -0
  52. package/dist/define/registeredApp.d.ts +12 -0
  53. package/dist/define/registeredApp.js +5 -0
  54. package/dist/draw/boolean.d.ts +6 -0
  55. package/dist/draw/boolean.js +5 -0
  56. package/dist/draw/choose.d.ts +4 -0
  57. package/dist/draw/choose.js +4 -0
  58. package/dist/draw/color.d.ts +2 -0
  59. package/dist/draw/color.js +4 -0
  60. package/dist/draw/number.d.ts +5 -0
  61. package/dist/draw/number.js +5 -0
  62. package/dist/draw/text.d.ts +5 -0
  63. package/dist/draw/text.js +5 -0
  64. package/dist/draw/variant.d.ts +17 -0
  65. package/dist/draw/variant.js +4 -0
  66. package/dist/index.d.ts +18 -0
  67. package/dist/index.js +14 -0
  68. package/dist/protocol/protocol.d.ts +8 -0
  69. package/dist/protocol/protocol.js +8 -0
  70. package/dist/protocol/types.d.ts +133 -0
  71. package/dist/protocol/types.js +1 -0
  72. package/dist/vite/builds.d.ts +29 -0
  73. package/dist/vite/builds.js +196 -0
  74. package/dist/vite/checkout.d.ts +11 -0
  75. package/dist/vite/checkout.js +43 -0
  76. package/dist/vite/environment.d.ts +9 -0
  77. package/dist/vite/environment.js +10 -0
  78. package/dist/vite/git.d.ts +7 -0
  79. package/dist/vite/git.js +18 -0
  80. package/dist/vite/index.d.ts +1 -0
  81. package/dist/vite/index.js +1 -0
  82. package/dist/vite/serveBuild.d.ts +4 -0
  83. package/dist/vite/serveBuild.js +39 -0
  84. package/dist/vite/versions.d.ts +44 -0
  85. package/dist/vite/versions.js +122 -0
  86. package/dist/vite/workbenchLocal.d.ts +21 -0
  87. package/dist/vite/workbenchLocal.js +186 -0
  88. package/docs/protocol.md +58 -0
  89. package/package.json +60 -3
@@ -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,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>;
@@ -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,4 @@
1
+ import type { ServerResponse } from 'node:http';
2
+ export declare function sendJson(response: ServerResponse, status: number, body: unknown): void;
3
+ /** A file of a build; a path that is no file is the app's own route, so its index. */
4
+ export declare function serveBuild(response: ServerResponse, folder: string, sha: string, rest: string): Promise<void>;
@@ -0,0 +1,39 @@
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
+ /** A file of a build; a path that is no file is the app's own route, so its index. */
23
+ export async function serveBuild(response, folder, sha, rest) {
24
+ const dir = join(folder, sha);
25
+ let file = resolve(dir, `.${decodeURIComponent(rest || '/')}`);
26
+ if (!file.startsWith(dir + sep) && file !== dir) {
27
+ return sendJson(response, 400, { error: 'Bad path' });
28
+ }
29
+ const isFile = await stat(file).then((found) => found.isFile(), () => false);
30
+ if (!isFile) {
31
+ file = join(dir, 'index.html');
32
+ }
33
+ if (!existsSync(file)) {
34
+ return sendJson(response, 404, { error: 'Not built' });
35
+ }
36
+ response.setHeader('Content-Type', TYPES[extname(file)] ?? 'application/octet-stream');
37
+ response.setHeader('Cache-Control', file.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable');
38
+ createReadStream(file).pipe(response);
39
+ }
@@ -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,21 @@
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
+ export declare function workbenchLocal(options?: {
20
+ base?: string;
21
+ }): Plugin;
@@ -0,0 +1,186 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { createBuilds } from './builds.js';
4
+ import { checkout } from './checkout.js';
5
+ import { environment } from './environment.js';
6
+ import { defaultBranch, gitIn } from './git.js';
7
+ import { sendJson, serveBuild } from './serveBuild.js';
8
+ import { commits, versions } from './versions.js';
9
+ const PREFIX = '/__workbench';
10
+ const SHA = /^[0-9a-f]{7,40}$/;
11
+ const REF = /^[\w][\w./-]*$/;
12
+ // One token for the life of the process: Vite restarting on a config change
13
+ // keeps the viewer that was opened with it connected.
14
+ const scope = globalThis;
15
+ const token = (scope.workbenchToken ??= randomBytes(16).toString('hex'));
16
+ /** Only the viewer may ask: from its address, or a viewer on this machine. */
17
+ const allowed = (origin) => {
18
+ return (!!origin &&
19
+ (origin === environment.viewerUrl || /^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin)));
20
+ };
21
+ const bodyOf = (request) => {
22
+ return new Promise((resolve) => {
23
+ let text = '';
24
+ request.setEncoding('utf8');
25
+ request.on('data', (chunk) => {
26
+ text += chunk;
27
+ if (text.length > 10_000) {
28
+ request.destroy();
29
+ }
30
+ });
31
+ request.on('end', () => {
32
+ try {
33
+ resolve(JSON.parse(text || '{}'));
34
+ }
35
+ catch {
36
+ resolve(null);
37
+ }
38
+ });
39
+ request.on('error', () => resolve(null));
40
+ });
41
+ };
42
+ function open(url) {
43
+ const [command, args] = process.platform === 'win32'
44
+ ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
45
+ : process.platform === 'darwin'
46
+ ? ['open', [url]]
47
+ : ['xdg-open', [url]];
48
+ spawn(command, args, { detached: true, stdio: 'ignore' })
49
+ .on('error', () => undefined)
50
+ .unref();
51
+ }
52
+ /**
53
+ * The local builds service, as a Vite plugin: with it, the app's dev server
54
+ * also answers the deployed viewer. It lists this machine's branches and
55
+ * commits, builds any commit, and checks a branch out.
56
+ *
57
+ * GET /__workbench/versions branches, tags, what is checked out, changed files
58
+ * GET /__workbench/commits?ref=<ref> that ref's history, newest first, each with its build
59
+ * POST /__workbench/builds/<sha> build it; asked again, says how it stands (?retry after a failure)
60
+ * GET /__workbench/builds/<sha> how its build stands
61
+ * GET /__workbench/b/<sha>/… the build, for the viewer's frame
62
+ * POST /__workbench/checkout { branch }: refused while anything is uncommitted
63
+ * POST /__workbench/fetch git fetch, so push status is current
64
+ *
65
+ * `base` is the branch the others are compared with: the repository's
66
+ * default branch unless set. Builds are served without the token, as a frame
67
+ * can't send a header. Without WORKBENCH_URL, the dev server opens no viewer.
68
+ */
69
+ export function workbenchLocal(options = {}) {
70
+ let root = process.cwd();
71
+ let git = gitIn(root);
72
+ let server = null;
73
+ const builds = createBuilds({ root: () => root, git: (...args) => git(...args) });
74
+ const base = async () => options.base ?? (await defaultBranch(git));
75
+ async function answer(request, response, url) {
76
+ const path = url.pathname;
77
+ if (request.method === 'GET' && path === '/versions') {
78
+ return sendJson(response, 200, await versions(git, await base(), builds.buildOf));
79
+ }
80
+ if (request.method === 'GET' && path === '/commits') {
81
+ const ref = url.searchParams.get('ref') ?? 'HEAD';
82
+ if (!REF.test(ref)) {
83
+ return sendJson(response, 400, { error: 'Bad ref' });
84
+ }
85
+ return sendJson(response, 200, { commits: await commits(git, ref, builds.buildOf) });
86
+ }
87
+ const build = /^\/builds\/([0-9a-f]{7,40})$/.exec(path);
88
+ if (build) {
89
+ const sha = (await git('rev-parse', '--verify', '--quiet', `${build[1]}^{commit}`).catch(() => '')).trim();
90
+ if (!SHA.test(sha)) {
91
+ return sendJson(response, 404, { error: 'No such commit' });
92
+ }
93
+ const state = request.method === 'POST'
94
+ ? await builds.requestBuild(sha, url.searchParams.has('retry'))
95
+ : await builds.buildOf(sha);
96
+ return sendJson(response, 200, { sha, ...state });
97
+ }
98
+ if (request.method === 'POST' && path === '/checkout') {
99
+ const branch = (await bodyOf(request))?.branch;
100
+ if (typeof branch !== 'string' || !REF.test(branch)) {
101
+ return sendJson(response, 400, { error: 'Bad branch' });
102
+ }
103
+ const result = await checkout(git, root, branch, () => void server?.restart());
104
+ return sendJson(response, result.status, result.body);
105
+ }
106
+ if (request.method === 'POST' && path === '/fetch') {
107
+ await git('fetch', '--prune', 'origin');
108
+ return sendJson(response, 200, { ok: true });
109
+ }
110
+ return sendJson(response, 404, { error: 'Not found' });
111
+ }
112
+ async function handle(request, response) {
113
+ const url = new URL(request.url ?? '/', 'http://local');
114
+ const built = /^\/b\/([0-9a-f]{7,40})(\/.*)?$/.exec(url.pathname);
115
+ if (built && request.method === 'GET') {
116
+ return serveBuild(response, builds.folder(), built[1] ?? '', built[2] ?? '/');
117
+ }
118
+ const origin = request.headers.origin;
119
+ if (allowed(origin)) {
120
+ response.setHeader('Access-Control-Allow-Origin', origin);
121
+ response.setHeader('Vary', 'Origin');
122
+ response.setHeader('Access-Control-Allow-Headers', 'content-type, x-workbench-token');
123
+ response.setHeader('Access-Control-Allow-Methods', 'GET, POST');
124
+ response.setHeader('Access-Control-Allow-Private-Network', 'true');
125
+ }
126
+ if (request.method === 'OPTIONS') {
127
+ response.statusCode = 204;
128
+ return response.end();
129
+ }
130
+ if (!allowed(origin) || request.headers['x-workbench-token'] !== token) {
131
+ return sendJson(response, 403, {
132
+ error: 'Only the viewer, with the token from the dev server',
133
+ });
134
+ }
135
+ try {
136
+ await answer(request, response, url);
137
+ }
138
+ catch (failure) {
139
+ sendJson(response, 500, { error: failure.message });
140
+ }
141
+ }
142
+ return {
143
+ name: 'workbench-local',
144
+ apply: 'serve',
145
+ // Builds and their worktrees are no part of the app: never watch or reload for them.
146
+ config: () => ({ server: { watch: { ignored: ['**/.builds/**'] } } }),
147
+ configResolved(config) {
148
+ root = config.root;
149
+ git = gitIn(root);
150
+ },
151
+ configureServer(devServer) {
152
+ server = devServer;
153
+ devServer.middlewares.use(PREFIX, (request, response) => {
154
+ void handle(request, response);
155
+ });
156
+ // First in line: Vite's own CORS answers every preflight before any plugin, without the
157
+ // viewer's origin or Chrome's private-network header, so the deployed page could not ask.
158
+ const stack = devServer.middlewares.stack;
159
+ const ours = stack.pop();
160
+ if (ours) {
161
+ stack.unshift(ours);
162
+ }
163
+ devServer.httpServer?.once('listening', () => {
164
+ void announce(devServer);
165
+ });
166
+ },
167
+ };
168
+ async function announce(devServer) {
169
+ if (!environment.viewerUrl) {
170
+ devServer.config.logger.info('\n Viewer: set WORKBENCH_URL to its address to open it\n');
171
+ return;
172
+ }
173
+ const address = devServer.httpServer?.address();
174
+ const port = typeof address === 'object' && address ? address.port : 5173;
175
+ // The repository, as owner/name: the viewer opens its project from it.
176
+ const remote = await git('remote', 'get-url', 'origin').catch(() => '');
177
+ const repository = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\s*$/.exec(remote)?.[1];
178
+ const local = encodeURIComponent(`http://localhost:${port}`);
179
+ const url = `${environment.viewerUrl}/?src=local&v=live#local=${local}&token=${token}${repository ? `&repo=${encodeURIComponent(repository)}` : ''}`;
180
+ devServer.config.logger.info(`\n Viewer: ${url}\n`);
181
+ // `pnpm dev` opens it; any other script is the app alone.
182
+ if (environment.script === 'dev') {
183
+ open(url);
184
+ }
185
+ }
186
+ }
@@ -0,0 +1,58 @@
1
+ # Protocol 3
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
+ | `hello` | Viewer | None | Say your hello again |
32
+ | `device` | Viewer | `insets` (`top`, `bottom`), `platform`, `whole` | The device drawn round the app |
33
+ | `go` | Viewer | `state` | Go to this state |
34
+ | `set` | Viewer | `id`, `value` | Give this control this value |
35
+ | `reset` | Viewer | `id`, or none for all | Put this control back to its default |
36
+
37
+ - The app says `hello` as it loads in a frame and whenever the viewer says
38
+ `hello`, then sends `at`, `blocks` and `values`. After that it sends each
39
+ only when it changes, and `tone` and `height` whenever they change;
40
+ `height` only on Full page.
41
+ - The app has the last word on values: `set` asks, the app checks, and the
42
+ `values` it answers with says what it kept. `reset` is answered the same
43
+ way. After `set`, or `reset` of one control, the app scrolls to the part
44
+ marked `data-control` with its id.
45
+ - The viewer sends `device` after each `hello` and whenever another device is
46
+ picked.
47
+ - Outside a frame the app sends nothing, and only takes `?at=` from the
48
+ address.
49
+
50
+ Every message carries `source`: `'aitems'` from the app and
51
+ `'aitems-workbench'` from the viewer, for every app, as builds from before
52
+ this package send them. Each side ignores any message without the other's
53
+ tag, or from a window other than the one it expects.
54
+
55
+ ## Changing it
56
+
57
+ Any change to a message or to the manifest bumps `PROTOCOL`, so the viewer
58
+ can still read older builds.
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@tilewise/workbench-client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
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
+ "scripts": {
57
+ "build": "tilewise-build",
58
+ "check:circular": "tilewise-madge src",
59
+ "lint": "tilewise-lint src/",
60
+ "test": "vitest run",
61
+ "typecheck": "tsc --noEmit"
62
+ }
6
63
  }