@tilewise/workbench-client 0.1.2 → 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,24 @@
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
+
3
22
  ## 0.1.2 · 2026-11-07 06:11 +04:00
4
23
 
5
24
  - feat(workbench-client): read the fake API from workbench.json (25e1ad9)
package/README.md CHANGED
@@ -181,8 +181,35 @@ repository declares its setup: the fake API comes from it (below), and
181
181
  read it from GitHub.
182
182
 
183
183
  Only the viewer may ask: from its origin (`WORKBENCH_URL`, or a viewer on
184
- localhost) with the token in the link the dev server logs. `pnpm dev` opens
185
- 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.
186
213
 
187
214
  ### An app in a workspace
188
215
 
@@ -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
+ }
@@ -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/connect { token, repo }: the token, for an allowed origin, which needs none
7
8
  * GET /__workbench/setup the checkout's workbench.json: { name, viewers }; 404 none, 422 no setup
8
9
  * GET /__workbench/versions branches, tags, what is checked out, changed files
9
10
  * GET /__workbench/commits?ref=<ref> that ref's history, newest first, each with its build
@@ -17,6 +18,11 @@ import type { Plugin } from 'vite';
17
18
  * default branch unless set. Builds are served without the token, as a frame
18
19
  * can't send a header. Without WORKBENCH_URL, the dev server opens no viewer.
19
20
  *
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
+ *
20
26
  * A fake API beside the app is declared in `workbench.json` at the
21
27
  * repository's top, as Proto's `api`: its module from Proto's `folder`, whose
22
28
  * default export is a fetch handler, asked at `/api`. The dev server answers
@@ -28,6 +34,13 @@ import type { Plugin } from 'vite';
28
34
  *
29
35
  * The `api` option overrides the file: `entry`, the module from the app's
30
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.
31
44
  */
32
45
  export declare function workbenchLocal(options?: {
33
46
  base?: string;
@@ -35,4 +48,5 @@ export declare function workbenchLocal(options?: {
35
48
  entry: string;
36
49
  path?: string;
37
50
  };
51
+ https?: boolean;
38
52
  }): Plugin;
@@ -7,7 +7,8 @@ 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';
12
13
  import { readSetup, setupApi } from './setup.js';
13
14
  import { commits, versions } from './versions.js';
@@ -59,6 +60,7 @@ function open(url) {
59
60
  * also answers the deployed viewer. It lists this machine's branches and
60
61
  * commits, builds any commit, and checks a branch out.
61
62
  *
63
+ * GET /__workbench/connect { token, repo }: the token, for an allowed origin, which needs none
62
64
  * GET /__workbench/setup the checkout's workbench.json: { name, viewers }; 404 none, 422 no setup
63
65
  * GET /__workbench/versions branches, tags, what is checked out, changed files
64
66
  * GET /__workbench/commits?ref=<ref> that ref's history, newest first, each with its build
@@ -72,6 +74,11 @@ function open(url) {
72
74
  * default branch unless set. Builds are served without the token, as a frame
73
75
  * can't send a header. Without WORKBENCH_URL, the dev server opens no viewer.
74
76
  *
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
+ *
75
82
  * A fake API beside the app is declared in `workbench.json` at the
76
83
  * repository's top, as Proto's `api`: its module from Proto's `folder`, whose
77
84
  * default export is a fetch handler, asked at `/api`. The dev server answers
@@ -83,12 +90,21 @@ function open(url) {
83
90
  *
84
91
  * The `api` option overrides the file: `entry`, the module from the app's
85
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.
86
100
  */
87
101
  export function workbenchLocal(options = {}) {
88
102
  let root = process.cwd();
89
103
  let git = gitIn(root);
90
104
  let top = null;
91
105
  let server = null;
106
+ let certificate = null;
107
+ const https = environment.https ?? options.https ?? !environment.vitest;
92
108
  const given = options.api;
93
109
  const option = given
94
110
  ? {
@@ -218,6 +234,9 @@ export function workbenchLocal(options = {}) {
218
234
  response.statusCode = 204;
219
235
  return response.end();
220
236
  }
237
+ if (request.method === 'GET' && url.pathname === '/connect' && allowed(origin)) {
238
+ return sendJson(response, 200, { token, repo: await repositoryOf(git) });
239
+ }
221
240
  if (!allowed(origin) || request.headers['x-workbench-token'] !== token) {
222
241
  return sendJson(response, 403, {
223
242
  error: 'Only the viewer, with the token from the dev server',
@@ -233,8 +252,15 @@ export function workbenchLocal(options = {}) {
233
252
  return {
234
253
  name: 'workbench-local',
235
254
  apply: 'serve',
236
- // Builds and their worktrees are no part of the app: never watch or reload for them.
237
- 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
+ },
238
264
  configResolved(config) {
239
265
  root = config.root;
240
266
  git = gitIn(root);
@@ -266,16 +292,20 @@ export function workbenchLocal(options = {}) {
266
292
  },
267
293
  };
268
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
+ }
269
301
  if (!environment.viewerUrl) {
270
302
  devServer.config.logger.info('\n Viewer: set WORKBENCH_URL to its address to open it\n');
271
303
  return;
272
304
  }
273
- const address = devServer.httpServer?.address();
274
- const port = typeof address === 'object' && address ? address.port : 5173;
275
305
  // The repository, as owner/name: the viewer opens its project from it.
276
- const remote = await git('remote', 'get-url', 'origin').catch(() => '');
277
- const repository = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\s*$/.exec(remote)?.[1];
278
- 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}`);
279
309
  const url = `${environment.viewerUrl}/?src=local&v=live#local=${local}&token=${token}${repository ? `&repo=${encodeURIComponent(repository)}` : ''}`;
280
310
  devServer.config.logger.info(`\n Viewer: ${url}\n`);
281
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.2",
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": {