@tilewise/workbench-client 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 · 2026-11-10 17:08 +04:00
4
+
5
+ - feat(workbench-client)!: serve the dev server over https by default, for Safari (4219956)
6
+ - Serve https with a certificate for localhost from Vite's own @vitejs/plugin-basic-ssl, one for every project on the computer, kept in
7
+ - Add the https option and WORKBENCH_HTTPS (1 or 0) to choose, keeping an app's own server.https, and http under Vitest
8
+ - Log how to trust the certificate on a Mac and on Windows at each start
9
+ - Link the viewer to the dev server's https address
10
+ - Describe https in the README
11
+ - BREAKING CHANGE: the dev server answers on https://localhost:<port> instead of http://localhost:<port>; set https: false or WORKBENCH_HTTPS=0 to keep http
12
+ - fix(workbench-client): leave out HTTP/2 pseudo-headers when asking a fake API (a326e03)
13
+ - Skip :method, :path and the other pseudo-headers a request over HTTP/2 carries, which fetch refuses, so a dev server on https answers /api
14
+ - Leave out keep-alive, upgrade and proxy-connection from a fake API's answer, which HTTP/2 refuses
15
+ - Test a request as HTTP/2 hands it over
16
+ - feat(workbench-client): hand the dev server's token to the viewer's origin at /connect (0ab508c)
17
+ - Add GET /__workbench/connect, which answers { token, repo } to a request from an allowed origin (WORKBENCH_URL or a viewer on localhost) and nothing
18
+ - Read the repository's owner/name in one helper, for the link and /connect
19
+ - Test that only the viewer's origin gets the token
20
+ - Describe connecting in the README
21
+
22
+ ## 0.1.2 · 2026-11-07 06:11 +04:00
23
+
24
+ - feat(workbench-client): read the fake API from workbench.json (25e1ad9)
25
+ - 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
26
+ - Read `workbench.json` on each request, so an edit to it counts without restarting Vite
27
+ - Build each other commit with the fake API its own `workbench.json` declares, else the checkout's
28
+ - Serve the checkout's setup at `GET /__workbench/setup`, for a workbench without the GitHub App
29
+ - Describe `workbench.json` in the README's local builds, workspace and fake API sections
30
+
3
31
  ## 0.1.1 · 2026-11-06 01:07 +04:00
4
32
 
5
33
  - feat(workbench-client): build apps in a workspace folder and answer each build's fake API (fc6dba7)
package/README.md CHANGED
@@ -174,9 +174,42 @@ git-ignored `.builds/<sha>/`, and checks a branch out, refused while
174
174
  anything is uncommitted. Builds are served under `/__workbench/b/<sha>/`, so
175
175
  the app's router takes `basepath: import.meta.env.BASE_URL`.
176
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
+
177
183
  Only the viewer may ask: from its origin (`WORKBENCH_URL`, or a viewer on
178
- localhost) with the token in the link the dev server logs. `pnpm dev` opens
179
- that link; without `WORKBENCH_URL` the dev server opens no viewer.
184
+ localhost) with the dev server's token. The viewer has it from the link the
185
+ dev server logs, which `pnpm dev` opens, or by connecting to the dev server's
186
+ port: `/__workbench/connect` hands the token, and the repository it serves,
187
+ to a page of those origins alone, as the browser names a page's origin and
188
+ no site can claim another's. Without `WORKBENCH_URL` the dev server opens no
189
+ viewer, and only a viewer on localhost can connect.
190
+
191
+ ### https
192
+
193
+ The dev server serves https, so Safari can use Local too. Safari blocks a
194
+ secure page, as the viewer is, from reaching `http://localhost`, which
195
+ Chrome and Firefox allow. The certificate comes from Vite's own
196
+ [`@vitejs/plugin-basic-ssl`](https://github.com/vitejs/vite-plugin-basic-ssl).
197
+ It is made for localhost, one for every project on the computer, and kept in
198
+ `~/.workbench/https` for 825 days, Apple's limit.
199
+
200
+ Each computer trusts it once, as the dev server's log says at each start:
201
+
202
+ - **Mac** (Safari, and Chrome with it): open `https://localhost:5173` in
203
+ Safari, choose *Show Details* › *visit this website*, and enter your
204
+ password.
205
+ - **Windows** (Chrome and Edge):
206
+ `certutil -user -addstore TrustedPeople "%USERPROFILE%\.workbench\https\localhost.crt"`.
207
+ TrustedPeople trusts this certificate alone, never one it signs.
208
+
209
+ `https: false` in the options, or `WORKBENCH_HTTPS=0`, serves http.
210
+ `WORKBENCH_HTTPS=1` serves https whatever the options say. An app that sets
211
+ its own `server.https` keeps it. Under Vitest it is http, unless `https` is
212
+ set.
180
213
 
181
214
  ### An app in a workspace
182
215
 
@@ -184,28 +217,51 @@ The app may live in a folder of a pnpm workspace, such as `apps/app/`, with
184
217
  its dev server's root there. Each commit is built from that folder, or from
185
218
  the top for a commit from before the app moved there. A workspace commit
186
219
  installs its own packages (`pnpm install --frozen-lockfile --prefer-offline`),
187
- so its packages link to that commit's, never the checkout's.
220
+ so its packages link to that commit's, never the checkout's. The folder is
221
+ the dev server's root, whatever `workbench.json` says, and a local build
222
+ installs and builds as above, without Proto's `install` and `build`.
188
223
 
189
224
  ### A fake API
190
225
 
191
- ```ts
192
- workbenchLocal({ base: 'develop', api: { entry: '../fake-api/src/index.ts' } });
226
+ Declare it in `workbench.json`, at the repository's top, as Proto's `api`:
227
+
228
+ ```json
229
+ {
230
+ "name": "Aitems",
231
+ "viewers": {
232
+ "proto": { "folder": "apps/app", "api": "../fake-api/src/index.ts" }
233
+ }
234
+ }
193
235
  ```
194
236
 
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.
237
+ `api` is the fake API's module, from Proto's `folder`: the app's folder from
238
+ the top, or the top when it is `''` or left out. Its default export is a
239
+ fetch handler: a function from `Request` to `Response`, or an object with
240
+ `fetch`, as a Hono app is. The app asks it at `/api`, and the handler is
241
+ asked without that path, so `/api/bags/b1` reaches it as `/bags/b1`. With no
242
+ file, a file that isn't a setup, or no `api` in it, there is no fake API and
243
+ `/api` is the app's own.
244
+
245
+ - **Live:** the dev server answers it at `/api`, from its source. Vite loads
246
+ it again when it changes, and the file is read on each request, so
247
+ declaring the API or moving it needs no restart.
248
+ - **Builds:** each build bundles the fake API its commit's own
249
+ `workbench.json` declares (the checkout's, for a commit with no file of its
250
+ own), with every package inside it, into `.builds/<sha>/__api/`, and
251
+ answers it under the build, at `/__workbench/b/<sha>/api/…`. An old build
252
+ asks the API it was built with, whatever the checkout's has become. The app
253
+ asks `` `${import.meta.env.BASE_URL}api/…` ``, which is right in both.
208
254
  - Each build's API runs in a worker of its own: loaded on its first request
209
255
  and stopped after 30 minutes with nothing asked, so a visit's data lasts
210
256
  until then. The worker gets no environment variables, and the bundle
211
257
  itself is never served.
258
+
259
+ The `api` option overrides the file, for Live and for a commit with no file
260
+ of its own:
261
+
262
+ ```ts
263
+ workbenchLocal({ base: 'develop', api: { entry: '../fake-api/src/index.ts', path: '/api' } });
264
+ ```
265
+
266
+ `entry` is the module from the app's folder, the dev server's root; `path` is
267
+ where the app asks it, `/api` unless set.
@@ -8,9 +8,10 @@ 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. */
11
+ /** A fake API beside the app: where its module is, and where the app asks it. */
12
12
  export interface ApiOption {
13
- entry: string;
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;
14
15
  path: string;
15
16
  }
16
17
  /** A build's own fake API: its bundle, and where the build asks it. */
@@ -30,14 +31,15 @@ export declare function lockOf(dir: string): Promise<{
30
31
  *
31
32
  * The app may live in a folder of the repository, such as a pnpm workspace's
32
33
  * `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.
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.
36
38
  */
37
39
  export declare function createBuilds(options: {
38
40
  root: () => string;
39
41
  git: Git;
40
- api?: () => ApiOption | null;
42
+ api?: () => Promise<ApiOption | null>;
41
43
  }): {
42
44
  apiOf: (sha: string) => Promise<BuiltApi | null>;
43
45
  buildOf: (sha: string) => Promise<Build>;
@@ -2,11 +2,12 @@ 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
4
  import { createRequire } from 'node:module';
5
- import { dirname, join, relative, resolve } from 'node:path';
5
+ import { dirname, join, relative } from 'node:path';
6
6
  import { appFolder } from './appFolder.js';
7
7
  import { bundleApi } from './bundleApi.js';
8
8
  import { buildEnvironment } from './environment.js';
9
9
  import { isWorkspace } from './isWorkspace.js';
10
+ import { setupApi } from './setup.js';
10
11
  /** The Vite this plugin runs in, for a commit that has none of its own installed. */
11
12
  const ownVite = () => {
12
13
  return join(dirname(createRequire(import.meta.url).resolve('vite/package.json')), 'bin', 'vite.js');
@@ -29,9 +30,10 @@ export async function lockOf(dir) {
29
30
  *
30
31
  * The app may live in a folder of the repository, such as a pnpm workspace's
31
32
  * `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.
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.
35
37
  */
36
38
  export function createBuilds(options) {
37
39
  const states = new Map();
@@ -180,10 +182,11 @@ export function createBuilds(options) {
180
182
  '--emptyOutDir',
181
183
  ];
182
184
  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
+ // 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;
185
188
  let built = null;
186
- const entry = api ? resolve(app, api.entry) : null;
189
+ const entry = api ? api.entry(work, app) : null;
187
190
  if (api && entry && !relative(work, entry).startsWith('..')) {
188
191
  const file = await bundleApi((more, cwd) => step(sha, log, process.execPath, [vite, ...more], cwd, 'API bundle'), app, entry, join(temporary, '__api'));
189
192
  built = file ? { file: `__api/${file}`, path: api.path } : null;
@@ -4,6 +4,10 @@ export declare const environment: {
4
4
  viewerUrl: string | null;
5
5
  /** The package script that started the dev server: only `dev` opens the viewer. */
6
6
  script: string | null;
7
+ /** WORKBENCH_HTTPS: `1` serves https, `0` http; unset leaves it to the option. */
8
+ https: boolean | null;
9
+ /** Run by Vitest, whose servers are never opened in a browser. */
10
+ vitest: boolean;
7
11
  };
8
12
  /** What a build's install and bundle run with: this environment, as CI. */
9
13
  export declare const buildEnvironment: () => NodeJS.ProcessEnv;
@@ -1,10 +1,15 @@
1
1
  const viewer = process.env['WORKBENCH_URL']?.replace(/\/$/, '');
2
+ const https = process.env['WORKBENCH_HTTPS'];
2
3
  /** Every setting the local builds service reads from the environment. */
3
4
  export const environment = {
4
5
  /** The viewer's address. Without it, the dev server opens no viewer. */
5
6
  viewerUrl: viewer === undefined || viewer === '' ? null : viewer,
6
7
  /** The package script that started the dev server: only `dev` opens the viewer. */
7
8
  script: process.env['npm_lifecycle_event'] ?? null,
9
+ /** WORKBENCH_HTTPS: `1` serves https, `0` http; unset leaves it to the option. */
10
+ https: https === '1' ? true : https === '0' ? false : null,
11
+ /** Run by Vitest, whose servers are never opened in a browser. */
12
+ vitest: !!process.env['VITEST'],
8
13
  };
9
14
  /** What a build's install and bundle run with: this environment, as CI. */
10
15
  export const buildEnvironment = () => ({ ...process.env, CI: '1' });
@@ -1,7 +1,9 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  /**
3
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.
4
+ * the address after the API's mount, with its query. HTTP/2's pseudo-headers
5
+ * (`:method`, `:path`…), which the https dev server's requests carry, are
6
+ * no headers to fetch, so they are left out.
5
7
  */
6
8
  export declare function toRequest(request: IncomingMessage, path: string): Promise<Request>;
7
9
  /** Writes a fake API's fetch `Response` to a Node response. */
@@ -1,5 +1,12 @@
1
- /** Headers Node works out for itself, from what it sends. */
2
- const COMPUTED = new Set(['content-length', 'transfer-encoding', 'connection']);
1
+ /** Headers Node works out for itself, from what it sends, and that HTTP/2 refuses. */
2
+ const COMPUTED = new Set([
3
+ 'content-length',
4
+ 'transfer-encoding',
5
+ 'connection',
6
+ 'keep-alive',
7
+ 'upgrade',
8
+ 'proxy-connection',
9
+ ]);
3
10
  const bodyOf = (request) => {
4
11
  return new Promise((resolve, reject) => {
5
12
  const chunks = [];
@@ -10,13 +17,15 @@ const bodyOf = (request) => {
10
17
  };
11
18
  /**
12
19
  * 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.
20
+ * the address after the API's mount, with its query. HTTP/2's pseudo-headers
21
+ * (`:method`, `:path`…), which the https dev server's requests carry, are
22
+ * no headers to fetch, so they are left out.
14
23
  */
15
24
  export async function toRequest(request, path) {
16
25
  const method = request.method ?? 'GET';
17
26
  const headers = new Headers();
18
27
  for (const [name, value] of Object.entries(request.headers)) {
19
- if (value === undefined) {
28
+ if (value === undefined || name.startsWith(':')) {
20
29
  continue;
21
30
  }
22
31
  for (const each of Array.isArray(value) ? value : [value]) {
@@ -5,3 +5,5 @@ export declare function gitIn(root: string): Git;
5
5
  export declare const refExists: (git: Git, ref: string) => Promise<boolean>;
6
6
  /** The repository's default branch, as its remote has it, or `main`. */
7
7
  export declare function defaultBranch(git: Git): Promise<string>;
8
+ /** The repository on GitHub, as `owner/name`, from its `origin` remote; null for any other. */
9
+ export declare function repositoryOf(git: Git): Promise<string | null>;
package/dist/vite/git.js CHANGED
@@ -16,3 +16,8 @@ export async function defaultBranch(git) {
16
16
  const head = await git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD').catch(() => '');
17
17
  return head.trim().replace(/^origin\//, '') || 'main';
18
18
  }
19
+ /** The repository on GitHub, as `owner/name`, from its `origin` remote; null for any other. */
20
+ export async function repositoryOf(git) {
21
+ const remote = await git('remote', 'get-url', 'origin').catch(() => '');
22
+ return /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\s*$/.exec(remote)?.[1] ?? null;
23
+ }
@@ -0,0 +1,17 @@
1
+ export interface Certificate {
2
+ /** The key and the certificate, as Vite's `server.https` takes them. */
3
+ pem: string;
4
+ /** The certificate alone, the file a browser is told to trust. */
5
+ file: string;
6
+ }
7
+ /**
8
+ * The dev server's certificate for localhost, made by Vite's own basic-ssl.
9
+ * One for every project on this computer, kept in `~/.workbench/https` for
10
+ * 825 days, so a browser trusts it once. `folder` is for tests.
11
+ */
12
+ export declare function localhostCertificate(folder?: string): Promise<Certificate>;
13
+ /**
14
+ * How to trust the certificate, once per computer, for the dev server's log
15
+ * at each start: whether this computer trusts it can't be told from here.
16
+ */
17
+ export declare function trustSteps(file: string, port: number): string[];
@@ -0,0 +1,34 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { getCertificate } from '@vitejs/plugin-basic-ssl';
5
+ /** Apple's longest lifetime for a certificate its browsers trust. */
6
+ const DAYS = 825;
7
+ const CERTIFICATE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/;
8
+ /**
9
+ * The dev server's certificate for localhost, made by Vite's own basic-ssl.
10
+ * One for every project on this computer, kept in `~/.workbench/https` for
11
+ * 825 days, so a browser trusts it once. `folder` is for tests.
12
+ */
13
+ export async function localhostCertificate(folder = join(homedir(), '.workbench', 'https')) {
14
+ const pem = await getCertificate(folder, 'localhost', [], DAYS);
15
+ const file = join(folder, 'localhost.crt');
16
+ const certificate = CERTIFICATE.exec(pem)?.[0] ?? '';
17
+ const kept = await readFile(file, 'utf8').catch(() => '');
18
+ if (kept.trim() !== certificate) {
19
+ await mkdir(folder, { recursive: true });
20
+ await writeFile(file, `${certificate}\n`);
21
+ }
22
+ return { pem, file };
23
+ }
24
+ /**
25
+ * How to trust the certificate, once per computer, for the dev server's log
26
+ * at each start: whether this computer trusts it can't be told from here.
27
+ */
28
+ export function trustSteps(file, port) {
29
+ return [
30
+ 'HTTPS: trust its certificate once on this computer, if you haven’t yet.',
31
+ ` Mac: open https://localhost:${port} in Safari, choose Show Details › visit this website, and enter your password`,
32
+ ` Windows: certutil -user -addstore TrustedPeople "${file}"`,
33
+ ];
34
+ }
@@ -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,8 @@ 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/connect { token, repo }: the token, for an allowed origin, which needs none
8
+ * GET /__workbench/setup the checkout's workbench.json: { name, viewers }; 404 none, 422 no setup
7
9
  * GET /__workbench/versions branches, tags, what is checked out, changed files
8
10
  * GET /__workbench/commits?ref=<ref> that ref's history, newest first, each with its build
9
11
  * POST /__workbench/builds/<sha> build it; asked again, says how it stands (?retry after a failure)
@@ -16,12 +18,29 @@ import type { Plugin } from 'vite';
16
18
  * default branch unless set. Builds are served without the token, as a frame
17
19
  * can't send a header. Without WORKBENCH_URL, the dev server opens no viewer.
18
20
  *
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`.
21
+ * The viewer has the token from the link the dev server logs, or asks
22
+ * `/connect` for it from its own page. Only a page of an allowed origin gets
23
+ * it: the browser names the page's origin, and no site can claim another's. A
24
+ * request that names none, as a page's request to its own origin, gets none.
25
+ *
26
+ * A fake API beside the app is declared in `workbench.json` at the
27
+ * repository's top, as Proto's `api`: its module from Proto's `folder`, whose
28
+ * default export is a fetch handler, asked at `/api`. The dev server answers
29
+ * it there for Live, from its source, reading the file on each request; each
30
+ * build bundles the one its commit declares (the checkout's, for a commit with
31
+ * no file of its own) and answers it under the build, at
32
+ * `/__workbench/b/<sha>/api/…`. Either way the handler is asked without the
33
+ * path, so `/api/bags/b1` reaches it as `/bags/b1`.
34
+ *
35
+ * The `api` option overrides the file: `entry`, the module from the app's
36
+ * folder, and `path`, where the app asks it (`/api` unless set).
37
+ *
38
+ * The dev server serves https, so Safari, which blocks a secure page from
39
+ * reaching `http://localhost`, can use it too. Its certificate is Vite's own
40
+ * basic-ssl's, one for every project on this computer, which a browser
41
+ * trusts once (https.ts). `https: false`, or WORKBENCH_HTTPS=0, serves http;
42
+ * WORKBENCH_HTTPS=1 serves https whatever the option says. Under Vitest it is
43
+ * http unless `https` is set, and an app's own `server.https` is left as is.
25
44
  */
26
45
  export declare function workbenchLocal(options?: {
27
46
  base?: string;
@@ -29,4 +48,5 @@ export declare function workbenchLocal(options?: {
29
48
  entry: string;
30
49
  path?: string;
31
50
  };
51
+ https?: boolean;
32
52
  }): Plugin;
@@ -7,8 +7,10 @@ import { createBuilds } from './builds.js';
7
7
  import { checkout } from './checkout.js';
8
8
  import { environment } from './environment.js';
9
9
  import { sendResponse, toRequest } from './fetchBridge.js';
10
- import { defaultBranch, gitIn } from './git.js';
10
+ import { defaultBranch, gitIn, repositoryOf } from './git.js';
11
+ import { localhostCertificate, trustSteps } from './https.js';
11
12
  import { sendJson, serveBuild } from './serveBuild.js';
13
+ import { readSetup, setupApi } from './setup.js';
12
14
  import { commits, versions } from './versions.js';
13
15
  const PREFIX = '/__workbench';
14
16
  const SHA = /^[0-9a-f]{7,40}$/;
@@ -58,6 +60,8 @@ function open(url) {
58
60
  * also answers the deployed viewer. It lists this machine's branches and
59
61
  * commits, builds any commit, and checks a branch out.
60
62
  *
63
+ * GET /__workbench/connect { token, repo }: the token, for an allowed origin, which needs none
64
+ * GET /__workbench/setup the checkout's workbench.json: { name, viewers }; 404 none, 422 no setup
61
65
  * GET /__workbench/versions branches, tags, what is checked out, changed files
62
66
  * GET /__workbench/commits?ref=<ref> that ref's history, newest first, each with its build
63
67
  * POST /__workbench/builds/<sha> build it; asked again, says how it stands (?retry after a failure)
@@ -70,27 +74,60 @@ function open(url) {
70
74
  * default branch unless set. Builds are served without the token, as a frame
71
75
  * can't send a header. Without WORKBENCH_URL, the dev server opens no viewer.
72
76
  *
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`.
77
+ * The viewer has the token from the link the dev server logs, or asks
78
+ * `/connect` for it from its own page. Only a page of an allowed origin gets
79
+ * it: the browser names the page's origin, and no site can claim another's. A
80
+ * request that names none, as a page's request to its own origin, gets none.
81
+ *
82
+ * A fake API beside the app is declared in `workbench.json` at the
83
+ * repository's top, as Proto's `api`: its module from Proto's `folder`, whose
84
+ * default export is a fetch handler, asked at `/api`. The dev server answers
85
+ * it there for Live, from its source, reading the file on each request; each
86
+ * build bundles the one its commit declares (the checkout's, for a commit with
87
+ * no file of its own) and answers it under the build, at
88
+ * `/__workbench/b/<sha>/api/…`. Either way the handler is asked without the
89
+ * path, so `/api/bags/b1` reaches it as `/bags/b1`.
90
+ *
91
+ * The `api` option overrides the file: `entry`, the module from the app's
92
+ * folder, and `path`, where the app asks it (`/api` unless set).
93
+ *
94
+ * The dev server serves https, so Safari, which blocks a secure page from
95
+ * reaching `http://localhost`, can use it too. Its certificate is Vite's own
96
+ * basic-ssl's, one for every project on this computer, which a browser
97
+ * trusts once (https.ts). `https: false`, or WORKBENCH_HTTPS=0, serves http;
98
+ * WORKBENCH_HTTPS=1 serves https whatever the option says. Under Vitest it is
99
+ * http unless `https` is set, and an app's own `server.https` is left as is.
79
100
  */
80
101
  export function workbenchLocal(options = {}) {
81
102
  let root = process.cwd();
82
103
  let git = gitIn(root);
104
+ let top = null;
83
105
  let server = null;
84
- const api = options.api
106
+ let certificate = null;
107
+ const https = environment.https ?? options.https ?? !environment.vitest;
108
+ const given = options.api;
109
+ const option = given
85
110
  ? {
86
- entry: options.api.entry,
87
- path: `/${(options.api.path ?? '/api').replace(/^\/+|\/+$/g, '')}`,
111
+ entry: (_top, app) => resolve(app, given.entry),
112
+ path: `/${(given.path ?? '/api').replace(/^\/+|\/+$/g, '')}`,
88
113
  }
89
114
  : null;
115
+ /** The repository's top, where workbench.json is; null outside a repository. */
116
+ const topOf = () => {
117
+ return (top ??= git('rev-parse', '--show-toplevel').then((output) => output.trim() || null, () => null));
118
+ };
119
+ /** The checkout's fake API: the option, else the one its workbench.json declares now. */
120
+ const currentApi = async () => {
121
+ if (option) {
122
+ return option;
123
+ }
124
+ const at = await topOf();
125
+ return at === null ? null : setupApi(at);
126
+ };
90
127
  const builds = createBuilds({
91
128
  root: () => root,
92
129
  git: (...args) => git(...args),
93
- api: () => api,
130
+ api: currentApi,
94
131
  });
95
132
  const workers = createApiWorkers();
96
133
  const base = async () => options.base ?? (await defaultBranch(git));
@@ -99,6 +136,16 @@ export function workbenchLocal(options = {}) {
99
136
  if (request.method === 'GET' && path === '/versions') {
100
137
  return sendJson(response, 200, await versions(git, await base(), builds.buildOf));
101
138
  }
139
+ if (request.method === 'GET' && path === '/setup') {
140
+ const at = await topOf();
141
+ const read = at === null ? { state: 'missing' } : await readSetup(at);
142
+ if (read.state === 'read') {
143
+ return sendJson(response, 200, read.setup);
144
+ }
145
+ return read.state === 'missing'
146
+ ? sendJson(response, 404, { error: 'No workbench.json' })
147
+ : sendJson(response, 422, { error: "workbench.json isn't a setup" });
148
+ }
102
149
  if (request.method === 'GET' && path === '/commits') {
103
150
  const ref = url.searchParams.get('ref') ?? 'HEAD';
104
151
  if (!REF.test(ref)) {
@@ -146,13 +193,17 @@ export function workbenchLocal(options = {}) {
146
193
  }
147
194
  return true;
148
195
  }
149
- /** The fake API for Live, from its source: Vite loads it again when it changes. */
150
- async function answerLiveApi(request, response) {
196
+ /**
197
+ * The fake API for Live, from its source: Vite loads it again when it
198
+ * changes. Without one, the path is the app's, as if this were not here.
199
+ */
200
+ async function answerLiveApi(request, response, next) {
151
201
  try {
202
+ const api = await currentApi();
152
203
  if (!api || !server) {
153
- return sendJson(response, 404, { error: 'No fake API' });
204
+ return next();
154
205
  }
155
- const module = await server.ssrLoadModule(resolve(root, api.entry));
206
+ const module = await server.ssrLoadModule(api.entry((await topOf()) ?? root, root));
156
207
  const asked = await toRequest(request, request.url ?? '/');
157
208
  await sendResponse(response, await apiFetchOf(module)(asked));
158
209
  }
@@ -183,6 +234,9 @@ export function workbenchLocal(options = {}) {
183
234
  response.statusCode = 204;
184
235
  return response.end();
185
236
  }
237
+ if (request.method === 'GET' && url.pathname === '/connect' && allowed(origin)) {
238
+ return sendJson(response, 200, { token, repo: await repositoryOf(git) });
239
+ }
186
240
  if (!allowed(origin) || request.headers['x-workbench-token'] !== token) {
187
241
  return sendJson(response, 403, {
188
242
  error: 'Only the viewer, with the token from the dev server',
@@ -198,11 +252,19 @@ export function workbenchLocal(options = {}) {
198
252
  return {
199
253
  name: 'workbench-local',
200
254
  apply: 'serve',
201
- // Builds and their worktrees are no part of the app: never watch or reload for them.
202
- config: () => ({ server: { watch: { ignored: ['**/.builds/**'] } } }),
255
+ async config(config) {
256
+ // Builds and their worktrees are no part of the app: never watch or reload for them.
257
+ const ignored = { watch: { ignored: ['**/.builds/**'] } };
258
+ if (!https || config.server?.https) {
259
+ return { server: ignored };
260
+ }
261
+ certificate = await localhostCertificate();
262
+ return { server: { ...ignored, https: { key: certificate.pem, cert: certificate.pem } } };
263
+ },
203
264
  configResolved(config) {
204
265
  root = config.root;
205
266
  git = gitIn(root);
267
+ top = null;
206
268
  },
207
269
  configureServer(devServer) {
208
270
  server = devServer;
@@ -216,12 +278,11 @@ export function workbenchLocal(options = {}) {
216
278
  if (ours) {
217
279
  stack.unshift(ours);
218
280
  }
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
- }
281
+ // Before Vite's own middleware, which would answer the path with the app's page. Always
282
+ // there without the option, so declaring the API in workbench.json needs no restart.
283
+ devServer.middlewares.use(option?.path ?? '/api', (request, response, next) => {
284
+ void answerLiveApi(request, response, next);
285
+ });
225
286
  devServer.httpServer?.once('listening', () => {
226
287
  void announce(devServer);
227
288
  });
@@ -231,16 +292,20 @@ export function workbenchLocal(options = {}) {
231
292
  },
232
293
  };
233
294
  async function announce(devServer) {
295
+ const address = devServer.httpServer?.address();
296
+ const port = typeof address === 'object' && address ? address.port : 5173;
297
+ // How to trust the certificate, once, before the viewer is opened on it.
298
+ if (certificate) {
299
+ devServer.config.logger.info(`\n ${trustSteps(certificate.file, port).join('\n ')}`);
300
+ }
234
301
  if (!environment.viewerUrl) {
235
302
  devServer.config.logger.info('\n Viewer: set WORKBENCH_URL to its address to open it\n');
236
303
  return;
237
304
  }
238
- const address = devServer.httpServer?.address();
239
- const port = typeof address === 'object' && address ? address.port : 5173;
240
305
  // The repository, as owner/name: the viewer opens its project from it.
241
- const remote = await git('remote', 'get-url', 'origin').catch(() => '');
242
- const repository = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\s*$/.exec(remote)?.[1];
243
- const local = encodeURIComponent(`http://localhost:${port}`);
306
+ const repository = await repositoryOf(git);
307
+ const scheme = devServer.config.server.https ? 'https' : 'http';
308
+ const local = encodeURIComponent(`${scheme}://localhost:${port}`);
244
309
  const url = `${environment.viewerUrl}/?src=local&v=live#local=${local}&token=${token}${repository ? `&repo=${encodeURIComponent(repository)}` : ''}`;
245
310
  devServer.config.logger.info(`\n Viewer: ${url}\n`);
246
311
  // `pnpm dev` opens it; any other script is the app alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tilewise/workbench-client",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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": {
@@ -54,6 +54,7 @@
54
54
  "vitest": "^4.1.11"
55
55
  },
56
56
  "dependencies": {
57
+ "@vitejs/plugin-basic-ssl": "^2.3.0",
57
58
  "modern-screenshot": "^4.7.0"
58
59
  },
59
60
  "scripts": {