@tilewise/workbench-client 0.1.0 → 0.1.2
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 +29 -0
- package/README.md +65 -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 +20 -0
- package/dist/vite/builds.js +71 -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/setup.d.ts +38 -0
- package/dist/vite/setup.js +65 -0
- package/dist/vite/workbenchLocal.d.ts +17 -0
- package/dist/vite/workbenchLocal.js +102 -2
- package/docs/protocol.md +9 -1
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.2 · 2026-11-07 06:11 +04:00
|
|
4
|
+
|
|
5
|
+
- feat(workbench-client): read the fake API from workbench.json (25e1ad9)
|
|
6
|
+
- Answer `/api` from the fake API that `viewers.proto.api` in the repository's `workbench.json` declares, from `viewers.proto.folder`, when `workbenchLocal()` is given no `api` option; the option still wins
|
|
7
|
+
- Read `workbench.json` on each request, so an edit to it counts without restarting Vite
|
|
8
|
+
- Build each other commit with the fake API its own `workbench.json` declares, else the checkout's
|
|
9
|
+
- Serve the checkout's setup at `GET /__workbench/setup`, for a workbench without the GitHub App
|
|
10
|
+
- Describe `workbench.json` in the README's local builds, workspace and fake API sections
|
|
11
|
+
|
|
12
|
+
## 0.1.1 · 2026-11-06 01:07 +04:00
|
|
13
|
+
|
|
14
|
+
- feat(workbench-client): build apps in a workspace folder and answer each build's fake API (fc6dba7)
|
|
15
|
+
- Build each commit from the app's folder in the repository, or from the top for a commit from before the app moved there
|
|
16
|
+
- Install a workspace commit's own packages, so they link to that commit's and not the checkout's
|
|
17
|
+
- Keep a commit with no workspace file of its own from taking the checkout's
|
|
18
|
+
- Run the commit's own Vite, or this plugin's when the commit has none installed
|
|
19
|
+
- Add the api option: a fake API's fetch handler, answered at /api for Live from its source
|
|
20
|
+
- 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
|
|
21
|
+
- Never serve the bundle as a file of the build
|
|
22
|
+
- Describe workspaces and the fake API in README and CLAUDE.md
|
|
23
|
+
- feat(proto,workbench-client): show the app on the iPhone Duo's halves as it folds (e34be11)
|
|
24
|
+
- Answer the viewer's snapshot with a picture of the screen, handed over as a bitmap, in protocol 4
|
|
25
|
+
- Take the picture with modern-screenshot, loaded with the first one asked for, leaving out hidden tabs and what's out of view
|
|
26
|
+
- Ask for a picture before a fold and another once the app is laid out the new way, waiting up to 0.7 s
|
|
27
|
+
- Show the app as it was on the half that lifts, and laid out the new way on the half that lands
|
|
28
|
+
- Let the app lay itself out closed under the picture while the Duo shuts, so the closed phone lands lit
|
|
29
|
+
- Fold dark as before when a build doesn't answer
|
|
30
|
+
- Read protocol 4 builds as protocol 3's
|
|
31
|
+
|
|
3
32
|
## 0.1.0 · 2026-11-03 17:34 +04:00
|
|
4
33
|
|
|
5
34
|
- 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
|
|
|
@@ -172,6 +174,67 @@ git-ignored `.builds/<sha>/`, and checks a branch out, refused while
|
|
|
172
174
|
anything is uncommitted. Builds are served under `/__workbench/b/<sha>/`, so
|
|
173
175
|
the app's router takes `basepath: import.meta.env.BASE_URL`.
|
|
174
176
|
|
|
177
|
+
It also reads `workbench.json`, at the repository's top, where the
|
|
178
|
+
repository declares its setup: the fake API comes from it (below), and
|
|
179
|
+
`/__workbench/setup` hands the checkout's to the viewer, as `{ name, viewers }`
|
|
180
|
+
(404 without the file, 422 when it isn't a setup), for a workbench that can't
|
|
181
|
+
read it from GitHub.
|
|
182
|
+
|
|
175
183
|
Only the viewer may ask: from its origin (`WORKBENCH_URL`, or a viewer on
|
|
176
184
|
localhost) with the token in the link the dev server logs. `pnpm dev` opens
|
|
177
185
|
that link; without `WORKBENCH_URL` the dev server opens no viewer.
|
|
186
|
+
|
|
187
|
+
### An app in a workspace
|
|
188
|
+
|
|
189
|
+
The app may live in a folder of a pnpm workspace, such as `apps/app/`, with
|
|
190
|
+
its dev server's root there. Each commit is built from that folder, or from
|
|
191
|
+
the top for a commit from before the app moved there. A workspace commit
|
|
192
|
+
installs its own packages (`pnpm install --frozen-lockfile --prefer-offline`),
|
|
193
|
+
so its packages link to that commit's, never the checkout's. The folder is
|
|
194
|
+
the dev server's root, whatever `workbench.json` says, and a local build
|
|
195
|
+
installs and builds as above, without Proto's `install` and `build`.
|
|
196
|
+
|
|
197
|
+
### A fake API
|
|
198
|
+
|
|
199
|
+
Declare it in `workbench.json`, at the repository's top, as Proto's `api`:
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"name": "Aitems",
|
|
204
|
+
"viewers": {
|
|
205
|
+
"proto": { "folder": "apps/app", "api": "../fake-api/src/index.ts" }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`api` is the fake API's module, from Proto's `folder`: the app's folder from
|
|
211
|
+
the top, or the top when it is `''` or left out. Its default export is a
|
|
212
|
+
fetch handler: a function from `Request` to `Response`, or an object with
|
|
213
|
+
`fetch`, as a Hono app is. The app asks it at `/api`, and the handler is
|
|
214
|
+
asked without that path, so `/api/bags/b1` reaches it as `/bags/b1`. With no
|
|
215
|
+
file, a file that isn't a setup, or no `api` in it, there is no fake API and
|
|
216
|
+
`/api` is the app's own.
|
|
217
|
+
|
|
218
|
+
- **Live:** the dev server answers it at `/api`, from its source. Vite loads
|
|
219
|
+
it again when it changes, and the file is read on each request, so
|
|
220
|
+
declaring the API or moving it needs no restart.
|
|
221
|
+
- **Builds:** each build bundles the fake API its commit's own
|
|
222
|
+
`workbench.json` declares (the checkout's, for a commit with no file of its
|
|
223
|
+
own), with every package inside it, into `.builds/<sha>/__api/`, and
|
|
224
|
+
answers it under the build, at `/__workbench/b/<sha>/api/…`. An old build
|
|
225
|
+
asks the API it was built with, whatever the checkout's has become. The app
|
|
226
|
+
asks `` `${import.meta.env.BASE_URL}api/…` ``, which is right in both.
|
|
227
|
+
- Each build's API runs in a worker of its own: loaded on its first request
|
|
228
|
+
and stopped after 30 minutes with nothing asked, so a visit's data lasts
|
|
229
|
+
until then. The worker gets no environment variables, and the bundle
|
|
230
|
+
itself is never served.
|
|
231
|
+
|
|
232
|
+
The `api` option overrides the file, for Live and for a commit with no file
|
|
233
|
+
of its own:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
workbenchLocal({ base: 'develop', api: { entry: '../fake-api/src/index.ts', path: '/api' } });
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`entry` is the module from the app's folder, the dev server's root; `path` is
|
|
240
|
+
where the app asks it, `/api` unless set.
|
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,17 @@ 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: where its module is, and where the app asks it. */
|
|
12
|
+
export interface ApiOption {
|
|
13
|
+
/** Its module in a tree of the repository: `top` is the tree's top, `app` the app's folder in it. */
|
|
14
|
+
entry: (top: string, app: string) => string;
|
|
15
|
+
path: string;
|
|
16
|
+
}
|
|
17
|
+
/** A build's own fake API: its bundle, and where the build asks it. */
|
|
18
|
+
export interface BuiltApi {
|
|
19
|
+
file: string;
|
|
20
|
+
path: string;
|
|
21
|
+
}
|
|
11
22
|
/** A tree's lockfile and the package manager it belongs to: pnpm, or npm for a commit from before a switch. */
|
|
12
23
|
export declare function lockOf(dir: string): Promise<{
|
|
13
24
|
manager: string;
|
|
@@ -17,11 +28,20 @@ export declare function lockOf(dir: string): Promise<{
|
|
|
17
28
|
* Builds any commit of the app, one at a time, in a git worktree under
|
|
18
29
|
* `.builds/work/`, into `.builds/<sha>/`, served under `/__workbench/b/<sha>/`.
|
|
19
30
|
* The checkout and its uncommitted changes are never touched.
|
|
31
|
+
*
|
|
32
|
+
* The app may live in a folder of the repository, such as a pnpm workspace's
|
|
33
|
+
* `apps/app/`: each commit is built from that folder, or from the top for a
|
|
34
|
+
* commit from before the app moved there. A commit's fake API is bundled too,
|
|
35
|
+
* into `.builds/<sha>/__api/`, so its build asks its own API, whatever the
|
|
36
|
+
* checkout's API has become since: the one the commit's own `workbench.json`
|
|
37
|
+
* declares, else `api`, the checkout's, when the commit has it.
|
|
20
38
|
*/
|
|
21
39
|
export declare function createBuilds(options: {
|
|
22
40
|
root: () => string;
|
|
23
41
|
git: Git;
|
|
42
|
+
api?: () => Promise<ApiOption | null>;
|
|
24
43
|
}): {
|
|
44
|
+
apiOf: (sha: string) => Promise<BuiltApi | null>;
|
|
25
45
|
buildOf: (sha: string) => Promise<Build>;
|
|
26
46
|
requestBuild: (sha: string, retry?: boolean) => Promise<Build>;
|
|
27
47
|
settled: () => Promise<void>;
|
package/dist/vite/builds.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
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 } 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
|
+
import { setupApi } from './setup.js';
|
|
11
|
+
/** The Vite this plugin runs in, for a commit that has none of its own installed. */
|
|
12
|
+
const ownVite = () => {
|
|
13
|
+
return join(dirname(createRequire(import.meta.url).resolve('vite/package.json')), 'bin', 'vite.js');
|
|
14
|
+
};
|
|
6
15
|
/** A tree's lockfile and the package manager it belongs to: pnpm, or npm for a commit from before a switch. */
|
|
7
16
|
export async function lockOf(dir) {
|
|
8
17
|
const pnpm = await readFile(join(dir, 'pnpm-lock.yaml'), 'utf8').catch(() => null);
|
|
@@ -18,12 +27,31 @@ export async function lockOf(dir) {
|
|
|
18
27
|
* Builds any commit of the app, one at a time, in a git worktree under
|
|
19
28
|
* `.builds/work/`, into `.builds/<sha>/`, served under `/__workbench/b/<sha>/`.
|
|
20
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. A commit's fake API is bundled too,
|
|
34
|
+
* into `.builds/<sha>/__api/`, so its build asks its own API, whatever the
|
|
35
|
+
* checkout's API has become since: the one the commit's own `workbench.json`
|
|
36
|
+
* declares, else `api`, the checkout's, when the commit has it.
|
|
21
37
|
*/
|
|
22
38
|
export function createBuilds(options) {
|
|
23
39
|
const states = new Map();
|
|
24
40
|
const queue = [];
|
|
25
41
|
let busy = false;
|
|
26
42
|
const folder = () => join(options.root(), '.builds');
|
|
43
|
+
/** A built commit's fake API, or null when it has none. */
|
|
44
|
+
async function apiOf(sha) {
|
|
45
|
+
try {
|
|
46
|
+
const meta = JSON.parse(await readFile(join(folder(), sha, 'meta.json'), 'utf8'));
|
|
47
|
+
return meta.ok && meta.api
|
|
48
|
+
? { file: join(folder(), sha, meta.api.file), path: meta.api.path }
|
|
49
|
+
: null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
27
55
|
async function buildOf(sha) {
|
|
28
56
|
const live = states.get(sha);
|
|
29
57
|
if (live) {
|
|
@@ -111,25 +139,39 @@ export function createBuilds(options) {
|
|
|
111
139
|
await dropWork(work);
|
|
112
140
|
await options.git('worktree', 'prune');
|
|
113
141
|
await options.git('worktree', 'add', '--detach', work, sha);
|
|
114
|
-
|
|
115
|
-
|
|
142
|
+
// The worktree is the whole repository; the app is in its folder, or at the top before a move.
|
|
143
|
+
const top = (await options.git('rev-parse', '--show-toplevel')).trim();
|
|
144
|
+
const app = appFolder(work, relative(top, root));
|
|
145
|
+
await addBasePath(app);
|
|
146
|
+
// A workspace installs, so its packages link to this commit's, not the checkout's. One
|
|
147
|
+
// package with the same lockfile as the checkout borrows its node_modules. Otherwise the
|
|
116
148
|
// commit installs its own, with the package manager its lockfile belongs to.
|
|
117
|
-
const ours = await lockOf(
|
|
149
|
+
const ours = await lockOf(top);
|
|
118
150
|
const theirs = await lockOf(work);
|
|
119
|
-
if (
|
|
151
|
+
if (await isWorkspace(work)) {
|
|
152
|
+
await step(sha, log, 'pnpm', ['install', '--frozen-lockfile', '--prefer-offline'], work, 'pnpm install');
|
|
153
|
+
}
|
|
154
|
+
else if (theirs.text !== null &&
|
|
120
155
|
theirs.text === ours.text &&
|
|
121
|
-
existsSync(join(
|
|
122
|
-
await symlink(join(
|
|
156
|
+
existsSync(join(top, 'node_modules'))) {
|
|
157
|
+
await symlink(join(top, 'node_modules'), join(work, 'node_modules'), 'junction');
|
|
123
158
|
}
|
|
124
159
|
else if (theirs.manager === 'pnpm') {
|
|
125
|
-
|
|
160
|
+
// The worktree sits inside the checkout: a commit with no workspace file of its own
|
|
161
|
+
// must not take the checkout's for its own.
|
|
162
|
+
const alone = existsSync(join(work, 'pnpm-workspace.yaml')) ? [] : ['--ignore-workspace'];
|
|
163
|
+
await step(sha, log, 'pnpm', ['install', '--frozen-lockfile', ...alone], work, 'pnpm install');
|
|
126
164
|
}
|
|
127
165
|
else {
|
|
128
166
|
await step(sha, log, 'npm', ['ci', '--no-audit', '--no-fund', '--loglevel=error'], work, 'npm ci');
|
|
129
167
|
}
|
|
130
168
|
const temporary = `${out}.tmp`;
|
|
131
169
|
await rm(temporary, { recursive: true, force: true });
|
|
132
|
-
|
|
170
|
+
// The commit's own Vite: the app's, then the workspace's, then this plugin's.
|
|
171
|
+
const vite = [
|
|
172
|
+
join(app, 'node_modules', 'vite', 'bin', 'vite.js'),
|
|
173
|
+
join(work, 'node_modules', 'vite', 'bin', 'vite.js'),
|
|
174
|
+
].find((file) => existsSync(file)) ?? ownVite();
|
|
133
175
|
const args = [
|
|
134
176
|
vite,
|
|
135
177
|
'build',
|
|
@@ -139,9 +181,24 @@ export function createBuilds(options) {
|
|
|
139
181
|
temporary,
|
|
140
182
|
'--emptyOutDir',
|
|
141
183
|
];
|
|
142
|
-
await step(sha, log, process.execPath, args,
|
|
184
|
+
await step(sha, log, process.execPath, args, app, 'vite build');
|
|
185
|
+
// Its own fake API, when the commit has one inside the worktree: the one its own
|
|
186
|
+
// workbench.json declares, else the checkout's.
|
|
187
|
+
const api = (await setupApi(work)) ?? (await options.api?.()) ?? null;
|
|
188
|
+
let built = null;
|
|
189
|
+
const entry = api ? api.entry(work, app) : null;
|
|
190
|
+
if (api && entry && !relative(work, entry).startsWith('..')) {
|
|
191
|
+
const file = await bundleApi((more, cwd) => step(sha, log, process.execPath, [vite, ...more], cwd, 'API bundle'), app, entry, join(temporary, '__api'));
|
|
192
|
+
built = file ? { file: `__api/${file}`, path: api.path } : null;
|
|
193
|
+
}
|
|
143
194
|
const protocol = (await bundleText(temporary)).includes('aitems-workbench') ? 1 : 0;
|
|
144
|
-
await writeFile(join(temporary, 'meta.json'), JSON.stringify({
|
|
195
|
+
await writeFile(join(temporary, 'meta.json'), JSON.stringify({
|
|
196
|
+
sha,
|
|
197
|
+
ok: true,
|
|
198
|
+
protocol,
|
|
199
|
+
builtAt: new Date().toISOString(),
|
|
200
|
+
...(built && { api: built }),
|
|
201
|
+
}));
|
|
145
202
|
await rm(out, { recursive: true, force: true });
|
|
146
203
|
await rename(temporary, out);
|
|
147
204
|
states.set(sha, { state: 'built', protocol });
|
|
@@ -171,14 +228,14 @@ export function createBuilds(options) {
|
|
|
171
228
|
.git('worktree', 'remove', '--force', work)
|
|
172
229
|
.catch(() => rm(work, { recursive: true, force: true }));
|
|
173
230
|
}
|
|
174
|
-
return { buildOf, requestBuild, settled, folder };
|
|
231
|
+
return { apiOf, buildOf, requestBuild, settled, folder };
|
|
175
232
|
}
|
|
176
233
|
/**
|
|
177
234
|
* A router made before the app was served under a base path matches nothing
|
|
178
235
|
* there; such a commit's TanStack router is given `import.meta.env.BASE_URL`.
|
|
179
236
|
*/
|
|
180
|
-
async function addBasePath(
|
|
181
|
-
const main = join(
|
|
237
|
+
async function addBasePath(app) {
|
|
238
|
+
const main = join(app, 'src', 'main.tsx');
|
|
182
239
|
if (!existsSync(main)) {
|
|
183
240
|
return;
|
|
184
241
|
}
|
|
@@ -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');
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** What a repository says about itself in `workbench.json` at its top. */
|
|
2
|
+
export interface Setup {
|
|
3
|
+
/** '' when the file gives none. */
|
|
4
|
+
name: string;
|
|
5
|
+
/** In the file's order, each with its string settings. */
|
|
6
|
+
viewers: {
|
|
7
|
+
name: string;
|
|
8
|
+
options: Record<string, string>;
|
|
9
|
+
}[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The `workbench.json` at `top`, read as the workbench reads it: an object,
|
|
13
|
+
* whose `viewers` is an object of viewers, each named in lower case and with
|
|
14
|
+
* an object of settings, of which only strings are kept, cut to 500
|
|
15
|
+
* characters. `missing` without the file, `invalid` when it isn't JSON or
|
|
16
|
+
* isn't a setup. Read each time, so an edit to it counts at once.
|
|
17
|
+
*
|
|
18
|
+
* ```json
|
|
19
|
+
* { "name": "Aitems", "viewers": { "proto": { "folder": "apps/app", "api": "../fake-api/src/index.ts" } } }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function readSetup(top: string): Promise<{
|
|
23
|
+
state: 'read';
|
|
24
|
+
setup: Setup;
|
|
25
|
+
} | {
|
|
26
|
+
state: 'missing' | 'invalid';
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* The fake API the `workbench.json` at `top` declares: Proto's `api`, its
|
|
30
|
+
* module from Proto's `folder` ('' or none for the top), answered at `/api`.
|
|
31
|
+
* `entry` places that module in any tree of the repository, this one or a
|
|
32
|
+
* build's worktree. Null when the file is missing or no setup, or declares no
|
|
33
|
+
* fake API.
|
|
34
|
+
*/
|
|
35
|
+
export declare function setupApi(top: string): Promise<{
|
|
36
|
+
entry: (tree: string) => string;
|
|
37
|
+
path: string;
|
|
38
|
+
} | null>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
/** An object, as JSON has it: not null, not an array. */
|
|
4
|
+
const isObject = (value) => {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* The `workbench.json` at `top`, read as the workbench reads it: an object,
|
|
9
|
+
* whose `viewers` is an object of viewers, each named in lower case and with
|
|
10
|
+
* an object of settings, of which only strings are kept, cut to 500
|
|
11
|
+
* characters. `missing` without the file, `invalid` when it isn't JSON or
|
|
12
|
+
* isn't a setup. Read each time, so an edit to it counts at once.
|
|
13
|
+
*
|
|
14
|
+
* ```json
|
|
15
|
+
* { "name": "Aitems", "viewers": { "proto": { "folder": "apps/app", "api": "../fake-api/src/index.ts" } } }
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export async function readSetup(top) {
|
|
19
|
+
let text;
|
|
20
|
+
try {
|
|
21
|
+
text = await readFile(join(top, 'workbench.json'), 'utf8');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return { state: 'missing' };
|
|
25
|
+
}
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = JSON.parse(text);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { state: 'invalid' };
|
|
32
|
+
}
|
|
33
|
+
if (!isObject(raw) || (raw['viewers'] !== undefined && !isObject(raw['viewers']))) {
|
|
34
|
+
return { state: 'invalid' };
|
|
35
|
+
}
|
|
36
|
+
const name = typeof raw['name'] === 'string' ? raw['name'].trim().slice(0, 80) : '';
|
|
37
|
+
const viewers = [];
|
|
38
|
+
for (const [viewer, settings] of Object.entries(isObject(raw['viewers']) ? raw['viewers'] : {})) {
|
|
39
|
+
if (!/^[a-z][a-z0-9-]*$/.test(viewer) || !isObject(settings)) {
|
|
40
|
+
return { state: 'invalid' };
|
|
41
|
+
}
|
|
42
|
+
const options = {};
|
|
43
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
44
|
+
if (typeof value === 'string') {
|
|
45
|
+
options[key] = value.slice(0, 500);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
viewers.push({ name: viewer, options });
|
|
49
|
+
}
|
|
50
|
+
return { state: 'read', setup: { name, viewers } };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The fake API the `workbench.json` at `top` declares: Proto's `api`, its
|
|
54
|
+
* module from Proto's `folder` ('' or none for the top), answered at `/api`.
|
|
55
|
+
* `entry` places that module in any tree of the repository, this one or a
|
|
56
|
+
* build's worktree. Null when the file is missing or no setup, or declares no
|
|
57
|
+
* fake API.
|
|
58
|
+
*/
|
|
59
|
+
export async function setupApi(top) {
|
|
60
|
+
const read = await readSetup(top);
|
|
61
|
+
const proto = read.state === 'read' ? read.setup.viewers.find((viewer) => viewer.name === 'proto') : null;
|
|
62
|
+
const api = proto?.options['api']?.trim() ?? '';
|
|
63
|
+
const folder = proto?.options['folder']?.trim() ?? '';
|
|
64
|
+
return api === '' ? null : { entry: (tree) => resolve(tree, folder, api), path: '/api' };
|
|
65
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { Plugin } from 'vite';
|
|
|
4
4
|
* also answers the deployed viewer. It lists this machine's branches and
|
|
5
5
|
* commits, builds any commit, and checks a branch out.
|
|
6
6
|
*
|
|
7
|
+
* GET /__workbench/setup the checkout's workbench.json: { name, viewers }; 404 none, 422 no setup
|
|
7
8
|
* GET /__workbench/versions branches, tags, what is checked out, changed files
|
|
8
9
|
* GET /__workbench/commits?ref=<ref> that ref's history, newest first, each with its build
|
|
9
10
|
* POST /__workbench/builds/<sha> build it; asked again, says how it stands (?retry after a failure)
|
|
@@ -15,7 +16,23 @@ import type { Plugin } from 'vite';
|
|
|
15
16
|
* `base` is the branch the others are compared with: the repository's
|
|
16
17
|
* default branch unless set. Builds are served without the token, as a frame
|
|
17
18
|
* can't send a header. Without WORKBENCH_URL, the dev server opens no viewer.
|
|
19
|
+
*
|
|
20
|
+
* A fake API beside the app is declared in `workbench.json` at the
|
|
21
|
+
* repository's top, as Proto's `api`: its module from Proto's `folder`, whose
|
|
22
|
+
* default export is a fetch handler, asked at `/api`. The dev server answers
|
|
23
|
+
* it there for Live, from its source, reading the file on each request; each
|
|
24
|
+
* build bundles the one its commit declares (the checkout's, for a commit with
|
|
25
|
+
* no file of its own) and answers it under the build, at
|
|
26
|
+
* `/__workbench/b/<sha>/api/…`. Either way the handler is asked without the
|
|
27
|
+
* path, so `/api/bags/b1` reaches it as `/bags/b1`.
|
|
28
|
+
*
|
|
29
|
+
* The `api` option overrides the file: `entry`, the module from the app's
|
|
30
|
+
* folder, and `path`, where the app asks it (`/api` unless set).
|
|
18
31
|
*/
|
|
19
32
|
export declare function workbenchLocal(options?: {
|
|
20
33
|
base?: string;
|
|
34
|
+
api?: {
|
|
35
|
+
entry: string;
|
|
36
|
+
path?: string;
|
|
37
|
+
};
|
|
21
38
|
}): Plugin;
|
|
@@ -1,10 +1,15 @@
|
|
|
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';
|
|
12
|
+
import { readSetup, setupApi } from './setup.js';
|
|
8
13
|
import { commits, versions } from './versions.js';
|
|
9
14
|
const PREFIX = '/__workbench';
|
|
10
15
|
const SHA = /^[0-9a-f]{7,40}$/;
|
|
@@ -54,6 +59,7 @@ function open(url) {
|
|
|
54
59
|
* also answers the deployed viewer. It lists this machine's branches and
|
|
55
60
|
* commits, builds any commit, and checks a branch out.
|
|
56
61
|
*
|
|
62
|
+
* GET /__workbench/setup the checkout's workbench.json: { name, viewers }; 404 none, 422 no setup
|
|
57
63
|
* GET /__workbench/versions branches, tags, what is checked out, changed files
|
|
58
64
|
* GET /__workbench/commits?ref=<ref> that ref's history, newest first, each with its build
|
|
59
65
|
* POST /__workbench/builds/<sha> build it; asked again, says how it stands (?retry after a failure)
|
|
@@ -65,18 +71,65 @@ function open(url) {
|
|
|
65
71
|
* `base` is the branch the others are compared with: the repository's
|
|
66
72
|
* default branch unless set. Builds are served without the token, as a frame
|
|
67
73
|
* can't send a header. Without WORKBENCH_URL, the dev server opens no viewer.
|
|
74
|
+
*
|
|
75
|
+
* A fake API beside the app is declared in `workbench.json` at the
|
|
76
|
+
* repository's top, as Proto's `api`: its module from Proto's `folder`, whose
|
|
77
|
+
* default export is a fetch handler, asked at `/api`. The dev server answers
|
|
78
|
+
* it there for Live, from its source, reading the file on each request; each
|
|
79
|
+
* build bundles the one its commit declares (the checkout's, for a commit with
|
|
80
|
+
* no file of its own) and answers it under the build, at
|
|
81
|
+
* `/__workbench/b/<sha>/api/…`. Either way the handler is asked without the
|
|
82
|
+
* path, so `/api/bags/b1` reaches it as `/bags/b1`.
|
|
83
|
+
*
|
|
84
|
+
* The `api` option overrides the file: `entry`, the module from the app's
|
|
85
|
+
* folder, and `path`, where the app asks it (`/api` unless set).
|
|
68
86
|
*/
|
|
69
87
|
export function workbenchLocal(options = {}) {
|
|
70
88
|
let root = process.cwd();
|
|
71
89
|
let git = gitIn(root);
|
|
90
|
+
let top = null;
|
|
72
91
|
let server = null;
|
|
73
|
-
const
|
|
92
|
+
const given = options.api;
|
|
93
|
+
const option = given
|
|
94
|
+
? {
|
|
95
|
+
entry: (_top, app) => resolve(app, given.entry),
|
|
96
|
+
path: `/${(given.path ?? '/api').replace(/^\/+|\/+$/g, '')}`,
|
|
97
|
+
}
|
|
98
|
+
: null;
|
|
99
|
+
/** The repository's top, where workbench.json is; null outside a repository. */
|
|
100
|
+
const topOf = () => {
|
|
101
|
+
return (top ??= git('rev-parse', '--show-toplevel').then((output) => output.trim() || null, () => null));
|
|
102
|
+
};
|
|
103
|
+
/** The checkout's fake API: the option, else the one its workbench.json declares now. */
|
|
104
|
+
const currentApi = async () => {
|
|
105
|
+
if (option) {
|
|
106
|
+
return option;
|
|
107
|
+
}
|
|
108
|
+
const at = await topOf();
|
|
109
|
+
return at === null ? null : setupApi(at);
|
|
110
|
+
};
|
|
111
|
+
const builds = createBuilds({
|
|
112
|
+
root: () => root,
|
|
113
|
+
git: (...args) => git(...args),
|
|
114
|
+
api: currentApi,
|
|
115
|
+
});
|
|
116
|
+
const workers = createApiWorkers();
|
|
74
117
|
const base = async () => options.base ?? (await defaultBranch(git));
|
|
75
118
|
async function answer(request, response, url) {
|
|
76
119
|
const path = url.pathname;
|
|
77
120
|
if (request.method === 'GET' && path === '/versions') {
|
|
78
121
|
return sendJson(response, 200, await versions(git, await base(), builds.buildOf));
|
|
79
122
|
}
|
|
123
|
+
if (request.method === 'GET' && path === '/setup') {
|
|
124
|
+
const at = await topOf();
|
|
125
|
+
const read = at === null ? { state: 'missing' } : await readSetup(at);
|
|
126
|
+
if (read.state === 'read') {
|
|
127
|
+
return sendJson(response, 200, read.setup);
|
|
128
|
+
}
|
|
129
|
+
return read.state === 'missing'
|
|
130
|
+
? sendJson(response, 404, { error: 'No workbench.json' })
|
|
131
|
+
: sendJson(response, 422, { error: "workbench.json isn't a setup" });
|
|
132
|
+
}
|
|
80
133
|
if (request.method === 'GET' && path === '/commits') {
|
|
81
134
|
const ref = url.searchParams.get('ref') ?? 'HEAD';
|
|
82
135
|
if (!REF.test(ref)) {
|
|
@@ -109,11 +162,49 @@ export function workbenchLocal(options = {}) {
|
|
|
109
162
|
}
|
|
110
163
|
return sendJson(response, 404, { error: 'Not found' });
|
|
111
164
|
}
|
|
165
|
+
/** A build's own fake API, asked at `rest`, the path under the build; false when it has none there. */
|
|
166
|
+
async function answerBuildApi(request, response, sha, rest, search) {
|
|
167
|
+
const own = await builds.apiOf(sha);
|
|
168
|
+
if (!own || (rest !== own.path && !rest.startsWith(`${own.path}/`))) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const asked = await toRequest(request, `${rest.slice(own.path.length) || '/'}${search}`);
|
|
173
|
+
await sendResponse(response, await workers.answer(sha, own.file, asked));
|
|
174
|
+
}
|
|
175
|
+
catch (failure) {
|
|
176
|
+
sendJson(response, 500, { error: failure.message });
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The fake API for Live, from its source: Vite loads it again when it
|
|
182
|
+
* changes. Without one, the path is the app's, as if this were not here.
|
|
183
|
+
*/
|
|
184
|
+
async function answerLiveApi(request, response, next) {
|
|
185
|
+
try {
|
|
186
|
+
const api = await currentApi();
|
|
187
|
+
if (!api || !server) {
|
|
188
|
+
return next();
|
|
189
|
+
}
|
|
190
|
+
const module = await server.ssrLoadModule(api.entry((await topOf()) ?? root, root));
|
|
191
|
+
const asked = await toRequest(request, request.url ?? '/');
|
|
192
|
+
await sendResponse(response, await apiFetchOf(module)(asked));
|
|
193
|
+
}
|
|
194
|
+
catch (failure) {
|
|
195
|
+
sendJson(response, 500, { error: failure.message });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
112
198
|
async function handle(request, response) {
|
|
113
199
|
const url = new URL(request.url ?? '/', 'http://local');
|
|
114
200
|
const built = /^\/b\/([0-9a-f]{7,40})(\/.*)?$/.exec(url.pathname);
|
|
201
|
+
const sha = built?.[1] ?? '';
|
|
202
|
+
const rest = built?.[2] ?? '/';
|
|
203
|
+
if (built && (await answerBuildApi(request, response, sha, rest, url.search))) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
115
206
|
if (built && request.method === 'GET') {
|
|
116
|
-
return serveBuild(response, builds.folder(),
|
|
207
|
+
return serveBuild(response, builds.folder(), sha, rest);
|
|
117
208
|
}
|
|
118
209
|
const origin = request.headers.origin;
|
|
119
210
|
if (allowed(origin)) {
|
|
@@ -147,6 +238,7 @@ export function workbenchLocal(options = {}) {
|
|
|
147
238
|
configResolved(config) {
|
|
148
239
|
root = config.root;
|
|
149
240
|
git = gitIn(root);
|
|
241
|
+
top = null;
|
|
150
242
|
},
|
|
151
243
|
configureServer(devServer) {
|
|
152
244
|
server = devServer;
|
|
@@ -160,9 +252,17 @@ export function workbenchLocal(options = {}) {
|
|
|
160
252
|
if (ours) {
|
|
161
253
|
stack.unshift(ours);
|
|
162
254
|
}
|
|
255
|
+
// Before Vite's own middleware, which would answer the path with the app's page. Always
|
|
256
|
+
// there without the option, so declaring the API in workbench.json needs no restart.
|
|
257
|
+
devServer.middlewares.use(option?.path ?? '/api', (request, response, next) => {
|
|
258
|
+
void answerLiveApi(request, response, next);
|
|
259
|
+
});
|
|
163
260
|
devServer.httpServer?.once('listening', () => {
|
|
164
261
|
void announce(devServer);
|
|
165
262
|
});
|
|
263
|
+
devServer.httpServer?.once('close', () => {
|
|
264
|
+
workers.stopAll();
|
|
265
|
+
});
|
|
166
266
|
},
|
|
167
267
|
};
|
|
168
268
|
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.2",
|
|
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",
|