@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 +20 -0
- package/README.md +36 -2
- package/dist/bridge/Bridge.d.ts +3 -2
- package/dist/bridge/Bridge.js +11 -2
- package/dist/bridge/post.d.ts +2 -2
- package/dist/bridge/post.js +3 -3
- package/dist/bridge/readViewerMessage.js +2 -0
- package/dist/bridge/snapshot.d.ts +7 -0
- package/dist/bridge/snapshot.js +47 -0
- package/dist/protocol/protocol.d.ts +1 -1
- package/dist/protocol/protocol.js +1 -1
- package/dist/protocol/types.d.ts +13 -2
- package/dist/vite/apiFetch.d.ts +7 -0
- package/dist/vite/apiFetch.js +15 -0
- package/dist/vite/apiWorkers.d.ts +11 -0
- package/dist/vite/apiWorkers.js +111 -0
- package/dist/vite/appFolder.d.ts +6 -0
- package/dist/vite/appFolder.js +11 -0
- package/dist/vite/builds.d.ts +18 -0
- package/dist/vite/builds.js +68 -14
- package/dist/vite/bundleApi.d.ts +9 -0
- package/dist/vite/bundleApi.js +32 -0
- package/dist/vite/fetchBridge.d.ts +8 -0
- package/dist/vite/fetchBridge.js +42 -0
- package/dist/vite/isWorkspace.d.ts +5 -0
- package/dist/vite/isWorkspace.js +10 -0
- package/dist/vite/serveBuild.d.ts +4 -1
- package/dist/vite/serveBuild.js +7 -1
- package/dist/vite/workbenchLocal.d.ts +11 -0
- package/dist/vite/workbenchLocal.js +67 -2
- package/docs/protocol.md +9 -1
- package/package.json +4 -1
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.
|
|
157
|
-
|
|
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.
|
package/dist/bridge/Bridge.d.ts
CHANGED
|
@@ -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`;
|
|
7
|
-
* only takes `?at=` from the
|
|
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;
|
package/dist/bridge/Bridge.js
CHANGED
|
@@ -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`;
|
|
45
|
-
* only takes `?at=` from the
|
|
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);
|
package/dist/bridge/post.d.ts
CHANGED
|
@@ -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;
|
package/dist/bridge/post.js
CHANGED
|
@@ -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 =
|
|
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.
|
package/dist/protocol/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Control, Value } from '../controls/types.js';
|
|
2
2
|
/**
|
|
3
|
-
* Protocol
|
|
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:
|
|
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
|
+
}
|
package/dist/vite/builds.d.ts
CHANGED
|
@@ -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>;
|
package/dist/vite/builds.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
115
|
-
|
|
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(
|
|
147
|
+
const ours = await lockOf(top);
|
|
118
148
|
const theirs = await lockOf(work);
|
|
119
|
-
if (
|
|
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(
|
|
122
|
-
await symlink(join(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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({
|
|
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(
|
|
181
|
-
const main = join(
|
|
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,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
|
-
/**
|
|
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>;
|
package/dist/vite/serveBuild.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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(),
|
|
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
|
|
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.
|
|
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",
|