@tilewise/workbench-client 0.1.0 → 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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1 · 2026-11-06 01:07 +04:00
4
+
5
+ - feat(workbench-client): build apps in a workspace folder and answer each build's fake API (fc6dba7)
6
+ - Build each commit from the app's folder in the repository, or from the top for a commit from before the app moved there
7
+ - Install a workspace commit's own packages, so they link to that commit's and not the checkout's
8
+ - Keep a commit with no workspace file of its own from taking the checkout's
9
+ - Run the commit's own Vite, or this plugin's when the commit has none installed
10
+ - Add the api option: a fake API's fetch handler, answered at /api for Live from its source
11
+ - Bundle each commit's fake API with its build and answer it under the build, in a worker of its own with no environment, stopped after 30 minutes idle
12
+ - Never serve the bundle as a file of the build
13
+ - Describe workspaces and the fake API in README and CLAUDE.md
14
+ - feat(proto,workbench-client): show the app on the iPhone Duo's halves as it folds (e34be11)
15
+ - Answer the viewer's snapshot with a picture of the screen, handed over as a bitmap, in protocol 4
16
+ - Take the picture with modern-screenshot, loaded with the first one asked for, leaving out hidden tabs and what's out of view
17
+ - Ask for a picture before a fold and another once the app is laid out the new way, waiting up to 0.7 s
18
+ - Show the app as it was on the half that lifts, and laid out the new way on the half that lands
19
+ - Let the app lay itself out closed under the picture while the Duo shuts, so the closed phone lands lit
20
+ - Fold dark as before when a build doesn't answer
21
+ - Read protocol 4 builds as protocol 3's
22
+
3
23
  ## 0.1.0 · 2026-11-03 17:34 +04:00
4
24
 
5
25
  - Start keeping versions and this changelog
package/README.md CHANGED
@@ -153,8 +153,10 @@ Mount `<Bridge />` once at the app's root. In the viewer's frame it says
153
153
  screen and every value as they change, and does what the viewer asks. It lays
154
154
  the app out for the device the viewer draws round it: `--safe-top`,
155
155
  `--safe-bottom` and `html[data-platform]`; until a device arrives, the app's
156
- own CSS holds the first phone's. Outside a frame it only opens the state
157
- `?at=` names. [docs/protocol.md](docs/protocol.md) has the protocol.
156
+ own CSS holds the first phone's. When the viewer folds a foldable it asks
157
+ for a picture of the screen, to show on the half that swings: the bridge
158
+ takes it with [modern-screenshot](https://github.com/qq15725/modern-screenshot),
159
+ loaded only then. Outside a frame it only opens the state `?at=` names. [docs/protocol.md](docs/protocol.md) has the protocol.
158
160
 
159
161
  ## Local builds
160
162
 
@@ -175,3 +177,35 @@ the app's router takes `basepath: import.meta.env.BASE_URL`.
175
177
  Only the viewer may ask: from its origin (`WORKBENCH_URL`, or a viewer on
176
178
  localhost) with the token in the link the dev server logs. `pnpm dev` opens
177
179
  that link; without `WORKBENCH_URL` the dev server opens no viewer.
180
+
181
+ ### An app in a workspace
182
+
183
+ The app may live in a folder of a pnpm workspace, such as `apps/app/`, with
184
+ its dev server's root there. Each commit is built from that folder, or from
185
+ the top for a commit from before the app moved there. A workspace commit
186
+ installs its own packages (`pnpm install --frozen-lockfile --prefer-offline`),
187
+ so its packages link to that commit's, never the checkout's.
188
+
189
+ ### A fake API
190
+
191
+ ```ts
192
+ workbenchLocal({ base: 'develop', api: { entry: '../fake-api/src/index.ts' } });
193
+ ```
194
+
195
+ `entry` is the fake API's module, from the app's folder. Its default export
196
+ is a fetch handler: a function from `Request` to `Response`, or an object
197
+ with `fetch`, as a Hono app is. `path` is where the app asks it, `/api` unless
198
+ set. The handler is asked without that path, so `/api/bags/b1` reaches it as
199
+ `/bags/b1`.
200
+
201
+ - **Live:** the dev server answers it at `path`, from its source. Vite loads
202
+ it again when it changes.
203
+ - **Builds:** each build bundles its commit's fake API, with every package
204
+ inside it, into `.builds/<sha>/__api/`, and answers it under the build, at
205
+ `/__workbench/b/<sha>/api/…`. An old build asks the API it was built with,
206
+ whatever the checkout's has become. The app asks
207
+ `` `${import.meta.env.BASE_URL}api/…` ``, which is right in both.
208
+ - Each build's API runs in a worker of its own: loaded on its first request
209
+ and stopped after 30 minutes with nothing asked, so a visit's data lasts
210
+ until then. The worker gets no environment variables, and the bundle
211
+ itself is never served.
@@ -3,7 +3,8 @@
3
3
  * it says `hello` with the manifest, and again whenever asked; sends `at`,
4
4
  * `blocks` and `values` as they change, and `tone` and `height`; does what
5
5
  * the viewer asks (`go`, `set`, `reset`), scrolling to the marked part after
6
- * `set` or `reset`; and lays the app out for the `device`. Outside a frame it
7
- * only takes `?at=` from the address.
6
+ * `set` or `reset`; lays the app out for the `device`; and answers `snapshot`
7
+ * with a picture of the screen. Outside a frame it only takes `?at=` from the
8
+ * address.
8
9
  */
9
10
  export declare function Bridge(): null;
@@ -7,6 +7,7 @@ import { applyDevice } from './device.js';
7
7
  import { isFramed, post } from './post.js';
8
8
  import { readViewerMessage } from './readViewerMessage.js';
9
9
  import { revealControl } from './revealControl.js';
10
+ import { snapshot } from './snapshot.js';
10
11
  import { watchScreen } from './watchScreen.js';
11
12
  const nothingSent = () => ({ at: null, values: null, blocks: new Map() });
12
13
  /** Tells the viewer what changed since it was last told; `answer` sends `values` even when it didn't. */
@@ -41,8 +42,9 @@ function useApp() {
41
42
  * it says `hello` with the manifest, and again whenever asked; sends `at`,
42
43
  * `blocks` and `values` as they change, and `tone` and `height`; does what
43
44
  * the viewer asks (`go`, `set`, `reset`), scrolling to the marked part after
44
- * `set` or `reset`; and lays the app out for the `device`. Outside a frame it
45
- * only takes `?at=` from the address.
45
+ * `set` or `reset`; lays the app out for the `device`; and answers `snapshot`
46
+ * with a picture of the screen. Outside a frame it only takes `?at=` from the
47
+ * address.
46
48
  */
47
49
  export function Bridge() {
48
50
  const app = useApp();
@@ -102,6 +104,13 @@ export function Bridge() {
102
104
  case 'device':
103
105
  applyDevice(message);
104
106
  break;
107
+ case 'snapshot': {
108
+ const { id } = message;
109
+ void snapshot().then((image) => {
110
+ post({ type: 'snapshot', id, image }, image ? [image] : []);
111
+ });
112
+ break;
113
+ }
105
114
  }
106
115
  };
107
116
  window.addEventListener('message', onMessage);
@@ -1,5 +1,5 @@
1
1
  import type { AppSays } from '../protocol/types.js';
2
- /** Says something to the viewer around the frame, tagged as the app's. */
3
- export declare function post(message: AppSays): void;
2
+ /** Says something to the viewer around the frame, tagged as the app's; `transfer` is handed over, not copied. */
3
+ export declare function post(message: AppSays, transfer?: Transferable[]): void;
4
4
  /** Whether the app is in the viewer's frame, rather than a page of its own. */
5
5
  export declare const isFramed: () => boolean;
@@ -1,8 +1,8 @@
1
1
  import { FROM_APP } from '../protocol/protocol.js';
2
- /** Says something to the viewer around the frame, tagged as the app's. */
3
- export function post(message) {
2
+ /** Says something to the viewer around the frame, tagged as the app's; `transfer` is handed over, not copied. */
3
+ export function post(message, transfer = []) {
4
4
  const tagged = { source: FROM_APP, ...message };
5
- window.parent.postMessage(tagged, '*');
5
+ window.parent.postMessage(tagged, '*', transfer);
6
6
  }
7
7
  /** Whether the app is in the viewer's frame, rather than a page of its own. */
8
8
  export const isFramed = () => window.parent !== window;
@@ -25,6 +25,8 @@ export function readViewerMessage(data) {
25
25
  return typeof message['id'] === 'string' ? { type: 'reset', id: message['id'] } : null;
26
26
  case 'device':
27
27
  return readDevice(message);
28
+ case 'snapshot':
29
+ return typeof message['id'] === 'number' ? { type: 'snapshot', id: message['id'] } : null;
28
30
  default:
29
31
  return null;
30
32
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A picture of the screen as it is now, for the viewer to show on the half of
3
+ * a foldable that swings; null if it can't be taken. The library that takes
4
+ * it loads with the first one asked for, so an app that is never folded never
5
+ * loads it.
6
+ */
7
+ export declare function snapshot(): Promise<ImageBitmap | null>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What the picture needs: the elements drawn, and not wholly below or right of
3
+ * the window. A hidden tab's are most of an app's, and the slowest to copy.
4
+ */
5
+ function inView(node) {
6
+ if (!(node instanceof HTMLElement)) {
7
+ return true;
8
+ }
9
+ if (getComputedStyle(node).display === 'none') {
10
+ return false;
11
+ }
12
+ const { top, left } = node.getBoundingClientRect();
13
+ return top < window.innerHeight && left < window.innerWidth;
14
+ }
15
+ /**
16
+ * A picture of the screen as it is now, for the viewer to show on the half of
17
+ * a foldable that swings; null if it can't be taken. The library that takes
18
+ * it loads with the first one asked for, so an app that is never folded never
19
+ * loads it.
20
+ */
21
+ export async function snapshot() {
22
+ try {
23
+ const { createContext, domToCanvas } = await import('modern-screenshot');
24
+ const context = await createContext(document.documentElement, {
25
+ width: window.innerWidth,
26
+ height: window.innerHeight,
27
+ scale: Math.min(2, window.devicePixelRatio),
28
+ filter: inView,
29
+ // Images that haven't loaded, such as lazy ones out of view, aren't waited for: the
30
+ // picture fetches each one it shows itself.
31
+ timeout: 1,
32
+ autoDestruct: true,
33
+ features: {
34
+ restoreScrollPosition: true,
35
+ copyScrollbar: false,
36
+ // Safari's fix draws the picture again for every image in it, a tenth of a second apart.
37
+ fixSvgXmlDecode: false,
38
+ },
39
+ });
40
+ // Fetching those images and drawing the picture get the time they need.
41
+ context.timeout = 3000;
42
+ return await createImageBitmap(await domToCanvas(context));
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
@@ -1,5 +1,5 @@
1
1
  /** The protocol this package speaks. */
2
- export declare const PROTOCOL = 3;
2
+ export declare const PROTOCOL = 4;
3
3
  /**
4
4
  * Every message carries `source`, for every app, as builds from before the
5
5
  * package send them. Each side ignores any message without the other's tag.
@@ -1,5 +1,5 @@
1
1
  /** The protocol this package speaks. */
2
- export const PROTOCOL = 3;
2
+ export const PROTOCOL = 4;
3
3
  /**
4
4
  * Every message carries `source`, for every app, as builds from before the
5
5
  * package send them. Each side ignores any message without the other's tag.
@@ -1,6 +1,6 @@
1
1
  import type { Control, Value } from '../controls/types.js';
2
2
  /**
3
- * Protocol 3: what the app and the viewer say to each other through
3
+ * Protocol 4: what the app and the viewer say to each other through
4
4
  * `postMessage`, between the frame and the page around it. Any change to a
5
5
  * message or to the manifest bumps the protocol, so the viewer can still read
6
6
  * older builds.
@@ -67,7 +67,7 @@ export type Tone = 'light' | 'dark';
67
67
  /** What the app says. */
68
68
  export type AppSays = {
69
69
  type: 'hello';
70
- protocol: 3;
70
+ protocol: 4;
71
71
  manifest: Manifest;
72
72
  } | {
73
73
  type: 'at';
@@ -86,6 +86,12 @@ export type AppSays = {
86
86
  } | {
87
87
  type: 'height';
88
88
  height: number;
89
+ }
90
+ /** The screen as it was when asked, at up to twice its size; null when it couldn't be taken. */
91
+ | {
92
+ type: 'snapshot';
93
+ id: number;
94
+ image: ImageBitmap | null;
89
95
  };
90
96
  /** What the app says, as sent: tagged `source: 'aitems'`. */
91
97
  export type AppMessage = {
@@ -115,6 +121,11 @@ export type ViewerSays = {
115
121
  } | {
116
122
  type: 'reset';
117
123
  id?: string;
124
+ }
125
+ /** A picture of the screen as it is now, answered with the same `id`. */
126
+ | {
127
+ type: 'snapshot';
128
+ id: number;
118
129
  };
119
130
  /** What the viewer says, as sent: tagged `source: 'aitems-workbench'`. */
120
131
  export type ViewerMessage = {
@@ -0,0 +1,7 @@
1
+ /** What a fake API answers with: the standard fetch handler, as Hono and most servers give one. */
2
+ export type ApiFetch = (request: Request) => Response | Promise<Response>;
3
+ /**
4
+ * The fetch handler of a fake API's module: its default export, either the
5
+ * function itself or an object with `fetch`.
6
+ */
7
+ export declare function apiFetchOf(module: unknown): ApiFetch;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The fetch handler of a fake API's module: its default export, either the
3
+ * function itself or an object with `fetch`.
4
+ */
5
+ export function apiFetchOf(module) {
6
+ const api = module?.default;
7
+ if (typeof api === 'function') {
8
+ return api;
9
+ }
10
+ const fetch = api?.fetch;
11
+ if (typeof fetch === 'function') {
12
+ return fetch.bind(api);
13
+ }
14
+ throw new Error('The fake API has no default export with fetch');
15
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Each build's fake API, run in a worker of its own: loaded on its first
3
+ * request, kept while it is asked things, so a visit's data stays, and stopped
4
+ * after `idle` with nothing asked, which frees its memory. A worker gets no
5
+ * environment, so a bundle sees none of the dev server's settings.
6
+ */
7
+ export declare function createApiWorkers(idle?: number): {
8
+ answer: (key: string, file: string, request: Request) => Promise<Response>;
9
+ loaded: () => string[];
10
+ stopAll: () => void;
11
+ };
@@ -0,0 +1,111 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { Worker } from 'node:worker_threads';
3
+ import { apiFetchOf } from './apiFetch.js';
4
+ /** How long a build's fake API stays loaded with nothing asked of it. */
5
+ const IDLE = 30 * 60 * 1000;
6
+ // The worker's code, as CommonJS: it loads the bundle and answers each request with it.
7
+ const SOURCE = `
8
+ const { parentPort, workerData } = require('node:worker_threads');
9
+ const apiFetchOf = ${apiFetchOf.toString()};
10
+ const loaded = import(workerData.url).then(apiFetchOf);
11
+ parentPort.on('message', async ({ id, method, url, headers, body }) => {
12
+ try {
13
+ const answer = await (await loaded)(new Request(url, { method, headers, body }));
14
+ const bytes = await answer.arrayBuffer();
15
+ const plain = [...answer.headers].filter(([name]) => name !== 'set-cookie');
16
+ const cookies = answer.headers.getSetCookie().map((value) => ['set-cookie', value]);
17
+ parentPort.postMessage({ id, status: answer.status, headers: [...plain, ...cookies], body: bytes }, [bytes]);
18
+ } catch (failure) {
19
+ parentPort.postMessage({ id, error: String((failure && failure.stack) || failure) });
20
+ }
21
+ });
22
+ `;
23
+ /**
24
+ * Each build's fake API, run in a worker of its own: loaded on its first
25
+ * request, kept while it is asked things, so a visit's data stays, and stopped
26
+ * after `idle` with nothing asked, which frees its memory. A worker gets no
27
+ * environment, so a bundle sees none of the dev server's settings.
28
+ */
29
+ export function createApiWorkers(idle = IDLE) {
30
+ const running = new Map();
31
+ let next = 0;
32
+ function stop(key) {
33
+ const entry = running.get(key);
34
+ running.delete(key);
35
+ if (entry) {
36
+ if (entry.timer) {
37
+ clearTimeout(entry.timer);
38
+ }
39
+ void entry.worker.terminate();
40
+ }
41
+ }
42
+ function start(key, file) {
43
+ const worker = new Worker(SOURCE, {
44
+ eval: true,
45
+ env: {},
46
+ workerData: { url: pathToFileURL(file).href },
47
+ });
48
+ worker.unref();
49
+ const entry = { worker, pending: new Map(), timer: null };
50
+ const failAll = (error) => {
51
+ for (const [id, resolve] of entry.pending) {
52
+ resolve({ id, error });
53
+ }
54
+ entry.pending.clear();
55
+ };
56
+ worker.on('message', (reply) => {
57
+ entry.pending.get(reply.id)?.(reply);
58
+ entry.pending.delete(reply.id);
59
+ });
60
+ worker.on('error', (failure) => {
61
+ failAll(failure.stack ?? String(failure));
62
+ stop(key);
63
+ });
64
+ worker.on('exit', () => {
65
+ failAll('The fake API stopped');
66
+ if (running.get(key) === entry) {
67
+ running.delete(key);
68
+ }
69
+ });
70
+ running.set(key, entry);
71
+ return entry;
72
+ }
73
+ /** Asks the fake API in `file`, run once per `key`, and gives its answer. */
74
+ async function answer(key, file, request) {
75
+ const entry = running.get(key) ?? start(key, file);
76
+ if (entry.timer) {
77
+ clearTimeout(entry.timer);
78
+ }
79
+ entry.timer = setTimeout(() => stop(key), idle);
80
+ entry.timer.unref();
81
+ const body = request.method === 'GET' || request.method === 'HEAD' ? null : await request.arrayBuffer();
82
+ const asked = {
83
+ id: (next += 1),
84
+ method: request.method,
85
+ url: request.url,
86
+ headers: [...request.headers],
87
+ body,
88
+ };
89
+ const reply = await new Promise((resolve) => {
90
+ entry.pending.set(asked.id, resolve);
91
+ entry.worker.postMessage(asked, body ? [body] : []);
92
+ });
93
+ if ('error' in reply) {
94
+ return Response.json({ error: reply.error }, { status: 500 });
95
+ }
96
+ const empty = reply.status === 204 || reply.status === 304;
97
+ return new Response(empty ? null : reply.body, {
98
+ status: reply.status,
99
+ headers: reply.headers,
100
+ });
101
+ }
102
+ /** The keys of the fake APIs loaded now, for the tests. */
103
+ const loaded = () => [...running.keys()];
104
+ /** Stops every fake API, as the dev server closes. */
105
+ function stopAll() {
106
+ for (const key of [...running.keys()]) {
107
+ stop(key);
108
+ }
109
+ }
110
+ return { answer, loaded, stopAll };
111
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Where the app is in a commit's tree: at `path`, the app's folder in the
3
+ * repository as the checkout has it, or at the top for a commit from before
4
+ * the app moved there. A folder holds the app when it has Vite's index.html.
5
+ */
6
+ export declare function appFolder(tree: string, path: string): string;
@@ -0,0 +1,11 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * Where the app is in a commit's tree: at `path`, the app's folder in the
5
+ * repository as the checkout has it, or at the top for a commit from before
6
+ * the app moved there. A folder holds the app when it has Vite's index.html.
7
+ */
8
+ export function appFolder(tree, path) {
9
+ const moved = join(tree, path);
10
+ return path !== '' && existsSync(join(moved, 'index.html')) ? moved : tree;
11
+ }
@@ -8,6 +8,16 @@ export interface Build {
8
8
  /** The end of the log, when it failed. */
9
9
  error?: string;
10
10
  }
11
+ /** A fake API beside the app: its module, from the app's folder, and where the app asks it. */
12
+ export interface ApiOption {
13
+ entry: string;
14
+ path: string;
15
+ }
16
+ /** A build's own fake API: its bundle, and where the build asks it. */
17
+ export interface BuiltApi {
18
+ file: string;
19
+ path: string;
20
+ }
11
21
  /** A tree's lockfile and the package manager it belongs to: pnpm, or npm for a commit from before a switch. */
12
22
  export declare function lockOf(dir: string): Promise<{
13
23
  manager: string;
@@ -17,11 +27,19 @@ export declare function lockOf(dir: string): Promise<{
17
27
  * Builds any commit of the app, one at a time, in a git worktree under
18
28
  * `.builds/work/`, into `.builds/<sha>/`, served under `/__workbench/b/<sha>/`.
19
29
  * The checkout and its uncommitted changes are never touched.
30
+ *
31
+ * The app may live in a folder of the repository, such as a pnpm workspace's
32
+ * `apps/app/`: each commit is built from that folder, or from the top for a
33
+ * commit from before the app moved there. With `api`, a commit that has the
34
+ * fake API is bundled too, into `.builds/<sha>/__api/`, so its build asks its
35
+ * own API, whatever the checkout's API has become since.
20
36
  */
21
37
  export declare function createBuilds(options: {
22
38
  root: () => string;
23
39
  git: Git;
40
+ api?: () => ApiOption | null;
24
41
  }): {
42
+ apiOf: (sha: string) => Promise<BuiltApi | null>;
25
43
  buildOf: (sha: string) => Promise<Build>;
26
44
  requestBuild: (sha: string, retry?: boolean) => Promise<Build>;
27
45
  settled: () => Promise<void>;
@@ -1,8 +1,16 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { appendFile, lstat, mkdir, readdir, readFile, rename, rm, rmdir, symlink, unlink, writeFile, } from 'node:fs/promises';
4
- import { join } from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+ import { dirname, join, relative, resolve } from 'node:path';
6
+ import { appFolder } from './appFolder.js';
7
+ import { bundleApi } from './bundleApi.js';
5
8
  import { buildEnvironment } from './environment.js';
9
+ import { isWorkspace } from './isWorkspace.js';
10
+ /** The Vite this plugin runs in, for a commit that has none of its own installed. */
11
+ const ownVite = () => {
12
+ return join(dirname(createRequire(import.meta.url).resolve('vite/package.json')), 'bin', 'vite.js');
13
+ };
6
14
  /** A tree's lockfile and the package manager it belongs to: pnpm, or npm for a commit from before a switch. */
7
15
  export async function lockOf(dir) {
8
16
  const pnpm = await readFile(join(dir, 'pnpm-lock.yaml'), 'utf8').catch(() => null);
@@ -18,12 +26,30 @@ export async function lockOf(dir) {
18
26
  * Builds any commit of the app, one at a time, in a git worktree under
19
27
  * `.builds/work/`, into `.builds/<sha>/`, served under `/__workbench/b/<sha>/`.
20
28
  * The checkout and its uncommitted changes are never touched.
29
+ *
30
+ * The app may live in a folder of the repository, such as a pnpm workspace's
31
+ * `apps/app/`: each commit is built from that folder, or from the top for a
32
+ * commit from before the app moved there. With `api`, a commit that has the
33
+ * fake API is bundled too, into `.builds/<sha>/__api/`, so its build asks its
34
+ * own API, whatever the checkout's API has become since.
21
35
  */
22
36
  export function createBuilds(options) {
23
37
  const states = new Map();
24
38
  const queue = [];
25
39
  let busy = false;
26
40
  const folder = () => join(options.root(), '.builds');
41
+ /** A built commit's fake API, or null when it has none. */
42
+ async function apiOf(sha) {
43
+ try {
44
+ const meta = JSON.parse(await readFile(join(folder(), sha, 'meta.json'), 'utf8'));
45
+ return meta.ok && meta.api
46
+ ? { file: join(folder(), sha, meta.api.file), path: meta.api.path }
47
+ : null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
27
53
  async function buildOf(sha) {
28
54
  const live = states.get(sha);
29
55
  if (live) {
@@ -111,25 +137,39 @@ export function createBuilds(options) {
111
137
  await dropWork(work);
112
138
  await options.git('worktree', 'prune');
113
139
  await options.git('worktree', 'add', '--detach', work, sha);
114
- await addBasePath(work);
115
- // The same lockfile as the checkout: borrow its node_modules. Otherwise the
140
+ // The worktree is the whole repository; the app is in its folder, or at the top before a move.
141
+ const top = (await options.git('rev-parse', '--show-toplevel')).trim();
142
+ const app = appFolder(work, relative(top, root));
143
+ await addBasePath(app);
144
+ // A workspace installs, so its packages link to this commit's, not the checkout's. One
145
+ // package with the same lockfile as the checkout borrows its node_modules. Otherwise the
116
146
  // commit installs its own, with the package manager its lockfile belongs to.
117
- const ours = await lockOf(root);
147
+ const ours = await lockOf(top);
118
148
  const theirs = await lockOf(work);
119
- if (theirs.text !== null &&
149
+ if (await isWorkspace(work)) {
150
+ await step(sha, log, 'pnpm', ['install', '--frozen-lockfile', '--prefer-offline'], work, 'pnpm install');
151
+ }
152
+ else if (theirs.text !== null &&
120
153
  theirs.text === ours.text &&
121
- existsSync(join(root, 'node_modules'))) {
122
- await symlink(join(root, 'node_modules'), join(work, 'node_modules'), 'junction');
154
+ existsSync(join(top, 'node_modules'))) {
155
+ await symlink(join(top, 'node_modules'), join(work, 'node_modules'), 'junction');
123
156
  }
124
157
  else if (theirs.manager === 'pnpm') {
125
- await step(sha, log, 'pnpm', ['install', '--frozen-lockfile'], work, 'pnpm install');
158
+ // The worktree sits inside the checkout: a commit with no workspace file of its own
159
+ // must not take the checkout's for its own.
160
+ const alone = existsSync(join(work, 'pnpm-workspace.yaml')) ? [] : ['--ignore-workspace'];
161
+ await step(sha, log, 'pnpm', ['install', '--frozen-lockfile', ...alone], work, 'pnpm install');
126
162
  }
127
163
  else {
128
164
  await step(sha, log, 'npm', ['ci', '--no-audit', '--no-fund', '--loglevel=error'], work, 'npm ci');
129
165
  }
130
166
  const temporary = `${out}.tmp`;
131
167
  await rm(temporary, { recursive: true, force: true });
132
- const vite = join(work, 'node_modules', 'vite', 'bin', 'vite.js');
168
+ // The commit's own Vite: the app's, then the workspace's, then this plugin's.
169
+ const vite = [
170
+ join(app, 'node_modules', 'vite', 'bin', 'vite.js'),
171
+ join(work, 'node_modules', 'vite', 'bin', 'vite.js'),
172
+ ].find((file) => existsSync(file)) ?? ownVite();
133
173
  const args = [
134
174
  vite,
135
175
  'build',
@@ -139,9 +179,23 @@ export function createBuilds(options) {
139
179
  temporary,
140
180
  '--emptyOutDir',
141
181
  ];
142
- await step(sha, log, process.execPath, args, work, 'vite build');
182
+ await step(sha, log, process.execPath, args, app, 'vite build');
183
+ // Its own fake API, when the commit has one inside the worktree.
184
+ const api = options.api?.() ?? null;
185
+ let built = null;
186
+ const entry = api ? resolve(app, api.entry) : null;
187
+ if (api && entry && !relative(work, entry).startsWith('..')) {
188
+ const file = await bundleApi((more, cwd) => step(sha, log, process.execPath, [vite, ...more], cwd, 'API bundle'), app, entry, join(temporary, '__api'));
189
+ built = file ? { file: `__api/${file}`, path: api.path } : null;
190
+ }
143
191
  const protocol = (await bundleText(temporary)).includes('aitems-workbench') ? 1 : 0;
144
- await writeFile(join(temporary, 'meta.json'), JSON.stringify({ sha, ok: true, protocol, builtAt: new Date().toISOString() }));
192
+ await writeFile(join(temporary, 'meta.json'), JSON.stringify({
193
+ sha,
194
+ ok: true,
195
+ protocol,
196
+ builtAt: new Date().toISOString(),
197
+ ...(built && { api: built }),
198
+ }));
145
199
  await rm(out, { recursive: true, force: true });
146
200
  await rename(temporary, out);
147
201
  states.set(sha, { state: 'built', protocol });
@@ -171,14 +225,14 @@ export function createBuilds(options) {
171
225
  .git('worktree', 'remove', '--force', work)
172
226
  .catch(() => rm(work, { recursive: true, force: true }));
173
227
  }
174
- return { buildOf, requestBuild, settled, folder };
228
+ return { apiOf, buildOf, requestBuild, settled, folder };
175
229
  }
176
230
  /**
177
231
  * A router made before the app was served under a base path matches nothing
178
232
  * there; such a commit's TanStack router is given `import.meta.env.BASE_URL`.
179
233
  */
180
- async function addBasePath(work) {
181
- const main = join(work, 'src', 'main.tsx');
234
+ async function addBasePath(app) {
235
+ const main = join(app, 'src', 'main.tsx');
182
236
  if (!existsSync(main)) {
183
237
  return;
184
238
  }
@@ -0,0 +1,9 @@
1
+ /** Runs that commit's Vite with these arguments, in that folder. */
2
+ export type RunVite = (args: string[], cwd: string) => Promise<void>;
3
+ /**
4
+ * Bundles a commit's fake API, `entry`, into `out`: one module with every
5
+ * package inside it, so it still runs once the commit's worktree is gone.
6
+ * The commit's own Vite builds it, without the app's config or plugins. Gives
7
+ * the bundle's file name in `out`, or null when the commit has no fake API.
8
+ */
9
+ export declare function bundleApi(runVite: RunVite, app: string, entry: string, out: string): Promise<string | null>;
@@ -0,0 +1,32 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { basename, extname, join } from 'node:path';
4
+ /**
5
+ * Bundles a commit's fake API, `entry`, into `out`: one module with every
6
+ * package inside it, so it still runs once the commit's worktree is gone.
7
+ * The commit's own Vite builds it, without the app's config or plugins. Gives
8
+ * the bundle's file name in `out`, or null when the commit has no fake API.
9
+ */
10
+ export async function bundleApi(runVite, app, entry, out) {
11
+ if (!existsSync(entry)) {
12
+ return null;
13
+ }
14
+ const config = join(app, '.workbench-api.config.mjs');
15
+ await writeFile(config, `export default ${JSON.stringify({
16
+ logLevel: 'warn',
17
+ publicDir: false,
18
+ build: { ssr: entry, outDir: out, emptyOutDir: true, minify: false, copyPublicDir: false },
19
+ ssr: { noExternal: true, target: 'node' },
20
+ })};\n`);
21
+ try {
22
+ await runVite(['build', '--config', config], app);
23
+ }
24
+ finally {
25
+ await rm(config, { force: true });
26
+ }
27
+ // The bundle is a module wherever it sits, whatever package.json is above it.
28
+ await writeFile(join(out, 'package.json'), '{ "type": "module" }\n');
29
+ const name = basename(entry, extname(entry));
30
+ const files = await readdir(out);
31
+ return files.find((file) => file === `${name}.js` || file === `${name}.mjs`) ?? null;
32
+ }
@@ -0,0 +1,8 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ /**
3
+ * A Node request as a fetch `Request` for a fake API, at `path`: the part of
4
+ * the address after the API's mount, with its query.
5
+ */
6
+ export declare function toRequest(request: IncomingMessage, path: string): Promise<Request>;
7
+ /** Writes a fake API's fetch `Response` to a Node response. */
8
+ export declare function sendResponse(response: ServerResponse, answer: Response): Promise<void>;
@@ -0,0 +1,42 @@
1
+ /** Headers Node works out for itself, from what it sends. */
2
+ const COMPUTED = new Set(['content-length', 'transfer-encoding', 'connection']);
3
+ const bodyOf = (request) => {
4
+ return new Promise((resolve, reject) => {
5
+ const chunks = [];
6
+ request.on('data', (chunk) => chunks.push(chunk));
7
+ request.on('end', () => resolve(Buffer.concat(chunks)));
8
+ request.on('error', reject);
9
+ });
10
+ };
11
+ /**
12
+ * A Node request as a fetch `Request` for a fake API, at `path`: the part of
13
+ * the address after the API's mount, with its query.
14
+ */
15
+ export async function toRequest(request, path) {
16
+ const method = request.method ?? 'GET';
17
+ const headers = new Headers();
18
+ for (const [name, value] of Object.entries(request.headers)) {
19
+ if (value === undefined) {
20
+ continue;
21
+ }
22
+ for (const each of Array.isArray(value) ? value : [value]) {
23
+ headers.append(name, each);
24
+ }
25
+ }
26
+ const body = method === 'GET' || method === 'HEAD' ? null : Uint8Array.from(await bodyOf(request));
27
+ return new Request(new URL(path, 'http://localhost'), { method, headers, body });
28
+ }
29
+ /** Writes a fake API's fetch `Response` to a Node response. */
30
+ export async function sendResponse(response, answer) {
31
+ response.statusCode = answer.status;
32
+ answer.headers.forEach((value, name) => {
33
+ if (!COMPUTED.has(name) && name !== 'set-cookie') {
34
+ response.setHeader(name, value);
35
+ }
36
+ });
37
+ const cookies = answer.headers.getSetCookie();
38
+ if (cookies.length > 0) {
39
+ response.setHeader('set-cookie', cookies);
40
+ }
41
+ response.end(Buffer.from(await answer.arrayBuffer()));
42
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Whether a tree is a pnpm workspace of several packages. A pnpm-workspace.yaml
3
+ * without `packages` only holds settings for one package.
4
+ */
5
+ export declare function isWorkspace(tree: string): Promise<boolean>;
@@ -0,0 +1,10 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * Whether a tree is a pnpm workspace of several packages. A pnpm-workspace.yaml
5
+ * without `packages` only holds settings for one package.
6
+ */
7
+ export async function isWorkspace(tree) {
8
+ const text = await readFile(join(tree, 'pnpm-workspace.yaml'), 'utf8').catch(() => '');
9
+ return /^packages\s*:/m.test(text);
10
+ }
@@ -1,4 +1,7 @@
1
1
  import type { ServerResponse } from 'node:http';
2
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. */
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
+ */
4
7
  export declare function serveBuild(response: ServerResponse, folder: string, sha: string, rest: string): Promise<void>;
@@ -19,13 +19,19 @@ export function sendJson(response, status, body) {
19
19
  response.setHeader('Cache-Control', 'no-store');
20
20
  response.end(JSON.stringify(body));
21
21
  }
22
- /** A file of a build; a path that is no file is the app's own route, so its index. */
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
+ */
23
26
  export async function serveBuild(response, folder, sha, rest) {
24
27
  const dir = join(folder, sha);
25
28
  let file = resolve(dir, `.${decodeURIComponent(rest || '/')}`);
26
29
  if (!file.startsWith(dir + sep) && file !== dir) {
27
30
  return sendJson(response, 400, { error: 'Bad path' });
28
31
  }
32
+ if (file === join(dir, '__api') || file.startsWith(join(dir, '__api') + sep)) {
33
+ return sendJson(response, 404, { error: 'Not found' });
34
+ }
29
35
  const isFile = await stat(file).then((found) => found.isFile(), () => false);
30
36
  if (!isFile) {
31
37
  file = join(dir, 'index.html');
@@ -15,7 +15,18 @@ import type { Plugin } from 'vite';
15
15
  * `base` is the branch the others are compared with: the repository's
16
16
  * default branch unless set. Builds are served without the token, as a frame
17
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`.
18
25
  */
19
26
  export declare function workbenchLocal(options?: {
20
27
  base?: string;
28
+ api?: {
29
+ entry: string;
30
+ path?: string;
31
+ };
21
32
  }): Plugin;
@@ -1,8 +1,12 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomBytes } from 'node:crypto';
3
+ import { resolve } from 'node:path';
4
+ import { apiFetchOf } from './apiFetch.js';
5
+ import { createApiWorkers } from './apiWorkers.js';
3
6
  import { createBuilds } from './builds.js';
4
7
  import { checkout } from './checkout.js';
5
8
  import { environment } from './environment.js';
9
+ import { sendResponse, toRequest } from './fetchBridge.js';
6
10
  import { defaultBranch, gitIn } from './git.js';
7
11
  import { sendJson, serveBuild } from './serveBuild.js';
8
12
  import { commits, versions } from './versions.js';
@@ -65,12 +69,30 @@ function open(url) {
65
69
  * `base` is the branch the others are compared with: the repository's
66
70
  * default branch unless set. Builds are served without the token, as a frame
67
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`.
68
79
  */
69
80
  export function workbenchLocal(options = {}) {
70
81
  let root = process.cwd();
71
82
  let git = gitIn(root);
72
83
  let server = null;
73
- const builds = createBuilds({ root: () => root, git: (...args) => git(...args) });
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();
74
96
  const base = async () => options.base ?? (await defaultBranch(git));
75
97
  async function answer(request, response, url) {
76
98
  const path = url.pathname;
@@ -109,11 +131,45 @@ export function workbenchLocal(options = {}) {
109
131
  }
110
132
  return sendJson(response, 404, { error: 'Not found' });
111
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
+ }
112
163
  async function handle(request, response) {
113
164
  const url = new URL(request.url ?? '/', 'http://local');
114
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
+ }
115
171
  if (built && request.method === 'GET') {
116
- return serveBuild(response, builds.folder(), built[1] ?? '', built[2] ?? '/');
172
+ return serveBuild(response, builds.folder(), sha, rest);
117
173
  }
118
174
  const origin = request.headers.origin;
119
175
  if (allowed(origin)) {
@@ -160,9 +216,18 @@ export function workbenchLocal(options = {}) {
160
216
  if (ours) {
161
217
  stack.unshift(ours);
162
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
+ }
163
225
  devServer.httpServer?.once('listening', () => {
164
226
  void announce(devServer);
165
227
  });
228
+ devServer.httpServer?.once('close', () => {
229
+ workers.stopAll();
230
+ });
166
231
  },
167
232
  };
168
233
  async function announce(devServer) {
package/docs/protocol.md CHANGED
@@ -1,4 +1,4 @@
1
- # Protocol 3
1
+ # Protocol 4
2
2
 
3
3
  The app and the viewer talk through `postMessage`, between the frame and the
4
4
  page around it. The types are exported: `Manifest`, `AppMessage` and
@@ -28,11 +28,13 @@ and forms from it alone, so an old commit shows its own screens and controls.
28
28
  | `values` | App | `values` | Every control's value, now |
29
29
  | `tone` | App | `top`: light or dark | The top of the screen, so the status bar reads on it |
30
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 |
31
32
  | `hello` | Viewer | None | Say your hello again |
32
33
  | `device` | Viewer | `insets` (`top`, `bottom`), `platform`, `whole` | The device drawn round the app |
33
34
  | `go` | Viewer | `state` | Go to this state |
34
35
  | `set` | Viewer | `id`, `value` | Give this control this value |
35
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 |
36
38
 
37
39
  - The app says `hello` as it loads in a frame and whenever the viewer says
38
40
  `hello`, then sends `at`, `blocks` and `values`. After that it sends each
@@ -44,6 +46,12 @@ and forms from it alone, so an old commit shows its own screens and controls.
44
46
  marked `data-control` with its id.
45
47
  - The viewer sends `device` after each `hello` and whenever another device is
46
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.
47
55
  - Outside a frame the app sends nothing, and only takes `?at=` from the
48
56
  address.
49
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tilewise/workbench-client",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Connects a React prototype to the workbench's viewer: its screens, its controls and the protocol between them.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,6 +53,9 @@
53
53
  "vite": "^8.2.2",
54
54
  "vitest": "^4.1.11"
55
55
  },
56
+ "dependencies": {
57
+ "modern-screenshot": "^4.7.0"
58
+ },
56
59
  "scripts": {
57
60
  "build": "tilewise-build",
58
61
  "check:circular": "tilewise-madge src",