@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.
Files changed (103) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +210 -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 +10 -0
  11. package/dist/bridge/Bridge.js +129 -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 +46 -0
  18. package/dist/bridge/revealControl.d.ts +10 -0
  19. package/dist/bridge/revealControl.js +62 -0
  20. package/dist/bridge/snapshot.d.ts +7 -0
  21. package/dist/bridge/snapshot.js +47 -0
  22. package/dist/bridge/toneAtTop.d.ts +3 -0
  23. package/dist/bridge/toneAtTop.js +23 -0
  24. package/dist/bridge/watchScreen.d.ts +10 -0
  25. package/dist/bridge/watchScreen.js +50 -0
  26. package/dist/bridge/wholeHeight.d.ts +8 -0
  27. package/dist/bridge/wholeHeight.js +16 -0
  28. package/dist/controls/paint.d.ts +8 -0
  29. package/dist/controls/paint.js +16 -0
  30. package/dist/controls/parseValue.d.ts +7 -0
  31. package/dist/controls/parseValue.js +34 -0
  32. package/dist/controls/resetControl.d.ts +3 -0
  33. package/dist/controls/resetControl.js +6 -0
  34. package/dist/controls/setControl.d.ts +3 -0
  35. package/dist/controls/setControl.js +6 -0
  36. package/dist/controls/store.d.ts +12 -0
  37. package/dist/controls/store.js +125 -0
  38. package/dist/controls/types.d.ts +111 -0
  39. package/dist/controls/types.js +6 -0
  40. package/dist/controls/useControl.d.ts +7 -0
  41. package/dist/controls/useControl.js +16 -0
  42. package/dist/define/buildManifest.d.ts +8 -0
  43. package/dist/define/buildManifest.js +51 -0
  44. package/dist/define/checkDefaults.d.ts +3 -0
  45. package/dist/define/checkDefaults.js +29 -0
  46. package/dist/define/defineApp.d.ts +24 -0
  47. package/dist/define/defineApp.js +92 -0
  48. package/dist/define/defineBlock.d.ts +12 -0
  49. package/dist/define/defineBlock.js +10 -0
  50. package/dist/define/defineScreen.d.ts +11 -0
  51. package/dist/define/defineScreen.js +10 -0
  52. package/dist/define/part.d.ts +23 -0
  53. package/dist/define/part.js +22 -0
  54. package/dist/define/registeredApp.d.ts +12 -0
  55. package/dist/define/registeredApp.js +5 -0
  56. package/dist/draw/boolean.d.ts +6 -0
  57. package/dist/draw/boolean.js +5 -0
  58. package/dist/draw/choose.d.ts +4 -0
  59. package/dist/draw/choose.js +4 -0
  60. package/dist/draw/color.d.ts +2 -0
  61. package/dist/draw/color.js +4 -0
  62. package/dist/draw/number.d.ts +5 -0
  63. package/dist/draw/number.js +5 -0
  64. package/dist/draw/text.d.ts +5 -0
  65. package/dist/draw/text.js +5 -0
  66. package/dist/draw/variant.d.ts +17 -0
  67. package/dist/draw/variant.js +4 -0
  68. package/dist/index.d.ts +18 -0
  69. package/dist/index.js +14 -0
  70. package/dist/protocol/protocol.d.ts +8 -0
  71. package/dist/protocol/protocol.js +8 -0
  72. package/dist/protocol/types.d.ts +144 -0
  73. package/dist/protocol/types.js +1 -0
  74. package/dist/vite/apiFetch.d.ts +7 -0
  75. package/dist/vite/apiFetch.js +15 -0
  76. package/dist/vite/apiWorkers.d.ts +11 -0
  77. package/dist/vite/apiWorkers.js +111 -0
  78. package/dist/vite/appFolder.d.ts +6 -0
  79. package/dist/vite/appFolder.js +11 -0
  80. package/dist/vite/builds.d.ts +47 -0
  81. package/dist/vite/builds.js +250 -0
  82. package/dist/vite/bundleApi.d.ts +9 -0
  83. package/dist/vite/bundleApi.js +32 -0
  84. package/dist/vite/checkout.d.ts +11 -0
  85. package/dist/vite/checkout.js +43 -0
  86. package/dist/vite/environment.d.ts +9 -0
  87. package/dist/vite/environment.js +10 -0
  88. package/dist/vite/fetchBridge.d.ts +8 -0
  89. package/dist/vite/fetchBridge.js +42 -0
  90. package/dist/vite/git.d.ts +7 -0
  91. package/dist/vite/git.js +18 -0
  92. package/dist/vite/index.d.ts +1 -0
  93. package/dist/vite/index.js +1 -0
  94. package/dist/vite/isWorkspace.d.ts +5 -0
  95. package/dist/vite/isWorkspace.js +10 -0
  96. package/dist/vite/serveBuild.d.ts +7 -0
  97. package/dist/vite/serveBuild.js +45 -0
  98. package/dist/vite/versions.d.ts +44 -0
  99. package/dist/vite/versions.js +122 -0
  100. package/dist/vite/workbenchLocal.d.ts +32 -0
  101. package/dist/vite/workbenchLocal.js +251 -0
  102. package/docs/protocol.md +66 -0
  103. 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
+ }
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
  }