@vhosty/vite-plugin 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zack Warren
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,104 @@
1
- # Temporary Holding Version
1
+ # @vhosty/vite-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Runs your Vite dev server through [vhosty](https://github.com/vhostyhq/vhosty), with
4
+ HTTPS and hot reload at `https://<port>.<your-domain>`, from any device on your tailnet.
5
+
6
+ ```
7
+ ➜ Local: http://127.0.0.1:5173/
8
+ ➜ vhosty: https://5173.vhosty.link/
9
+ ```
10
+
11
+ - **Zero config on vhosty machines.** It reads your domain from `~/.vhosty/config`.
12
+ - **Safe for teammates.** Without vhosty, the plugin does nothing and Vite behaves normally.
13
+ - **Runs several projects at once.** Each project gets the next free port and its matching hostname.
14
+ - **Works with Laravel.** `public/hot` gets the `https://` URL, so `@vite` loads assets through vhosty.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm i -D @vhosty/vite-plugin
20
+ ```
21
+
22
+ ```js
23
+ // vite.config.js
24
+ import { defineConfig } from 'vite';
25
+ import laravel from 'laravel-vite-plugin';
26
+ import vue from '@vitejs/plugin-vue';
27
+ import vhosty from '@vhosty/vite-plugin';
28
+
29
+ export default defineConfig({
30
+ plugins: [
31
+ vhosty(),
32
+ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true }),
33
+ vue(),
34
+ ],
35
+ });
36
+ ```
37
+
38
+ Run `npm run dev` as usual.
39
+
40
+ ## What it does
41
+
42
+ When the dev server starts (`vite` / `npm run dev`, but not `build` or `preview`) on a
43
+ machine with a vhosty domain, the plugin:
44
+
45
+ - **Picks a free port** starting at `server.port` (default 5173). It checks both `127.0.0.1`
46
+ and `::1`, so it never lands on a port another dev server holds over IPv6.
47
+ - **Points Vite at `https://<port>.<domain>`:** `server.origin`, HMR over a secure
48
+ WebSocket on port 443, and `allowedHosts` for that hostname.
49
+ - **Binds to `127.0.0.1` only.** vhosty's Caddy is the way in, so the dev server isn't
50
+ exposed on your network.
51
+ - **Allows CORS from `https://*.<domain>`,** so your app loads the assets whether it's at
52
+ `escrow.vhosty.link`, a proxied name, or another port.
53
+ - **Deletes a stale `public/fonts-manifest.dev.json`,** which laravel-vite-plugin can
54
+ leave pointing at an old hostname.
55
+
56
+ ## Options
57
+
58
+ ```js
59
+ vhosty({
60
+ domain: 'vhosty.link', // default: VHOSTY_DOMAIN, then ~/.vhosty/config
61
+ port: 5173, // first port to try (default: server.port, or 5173)
62
+ cleanFontsManifest: true, // delete a stale fonts-manifest.dev.json on start
63
+ configPath: '/Users/you/.vhosty/config', // default: ~/.vhosty/config
64
+ })
65
+ ```
66
+
67
+ The plugin looks for the domain in this order:
68
+
69
+ 1. the `domain` option
70
+ 2. `VHOSTY_DOMAIN` in the project's `.env`
71
+ 3. `VITE_HMR_DOMAIN` (the older name, still supported)
72
+ 4. `VHOSTY_DOMAIN` in `~/.vhosty/config`
73
+
74
+ If none is set, the plugin does nothing. To turn it off for one project on a vhosty
75
+ machine, set `VHOSTY=false` in that project's `.env`.
76
+
77
+ ## Laravel
78
+
79
+ Set the app's URL in `.env`, then run `php artisan config:clear`:
80
+
81
+ ```dotenv
82
+ APP_URL=https://escrow.vhosty.link
83
+ ```
84
+
85
+ When vhosty serves the app through PHP-FPM, Laravel already generates `https://` links.
86
+ See the [vhosty Laravel guide](https://github.com/vhostyhq/vhosty/blob/main/docs/laravel-vite.md)
87
+ for `artisan serve` and Octane.
88
+
89
+ ## Troubleshooting
90
+
91
+ | Symptom | Fix |
92
+ |---|---|
93
+ | No `vhosty:` line on startup | No domain found. Check `~/.vhosty/config`, or set `VHOSTY_DOMAIN`. |
94
+ | Assets load from `http://localhost:5173` | `vhosty()` must come **before** `laravel()` in `plugins`. |
95
+ | 502 on `https://<port>.<domain>` | Vite isn't running, or vhosty's Caddy isn't. Run `vhosty doctor`. |
96
+ | `EOF` in Caddy's log | Vite is serving HTTPS itself (`basicSsl()`, `detectTls`, `server.https`). Remove it; vhosty handles HTTPS. |
97
+
98
+ ## Requirements
99
+
100
+ Vite 5 or newer, Node 18 or newer, and an ESM `vite.config` (the default for Vite and Laravel).
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,24 @@
1
+ import { type Plugin } from 'vite';
2
+ export interface VhostyOptions {
3
+ /**
4
+ * The vhosty domain, e.g. "vhosty.link". Defaults to VHOSTY_DOMAIN in .env,
5
+ * then ~/.vhosty/config. Without one, the plugin does nothing.
6
+ */
7
+ domain?: string;
8
+ /** First port to try. Defaults to server.port, or 5173. */
9
+ port?: number;
10
+ /** Delete a stale public/fonts-manifest.dev.json on start. Default: true. */
11
+ cleanFontsManifest?: boolean;
12
+ /** Path to the vhosty config. Default: ~/.vhosty/config */
13
+ configPath?: string;
14
+ }
15
+ /**
16
+ * Serves the Vite dev server through vhosty at https://<port>.<domain>, which
17
+ * vhosty's Caddy proxies to localhost:<port>. Picks a free port, so several
18
+ * projects can run side by side.
19
+ *
20
+ * Runs before laravel() so the hot file picks up the https origin.
21
+ */
22
+ export default function vhosty(options?: VhostyOptions): Plugin;
23
+ export { vhosty };
24
+ export { corsOrigin, findFreePort, resolveDomain } from './utils.js';
package/dist/index.js ADDED
@@ -0,0 +1,61 @@
1
+ import { resolve } from 'node:path';
2
+ import { loadEnv } from 'vite';
3
+ import { corsOrigin, findFreePort, removeStaleFontsManifest, resolveDomain } from './utils.js';
4
+ /**
5
+ * Serves the Vite dev server through vhosty at https://<port>.<domain>, which
6
+ * vhosty's Caddy proxies to localhost:<port>. Picks a free port, so several
7
+ * projects can run side by side.
8
+ *
9
+ * Runs before laravel() so the hot file picks up the https origin.
10
+ */
11
+ export default function vhosty(options = {}) {
12
+ let url;
13
+ return {
14
+ name: 'vhosty',
15
+ enforce: 'pre',
16
+ async config(userConfig, { command, mode, isPreview }) {
17
+ if (command !== 'serve' || isPreview) {
18
+ return;
19
+ }
20
+ const root = userConfig.root ? resolve(userConfig.root) : process.cwd();
21
+ const envDir = typeof userConfig.envDir === 'string' ? resolve(root, userConfig.envDir) : root;
22
+ const env = loadEnv(mode, envDir, '');
23
+ const domain = resolveDomain(env, options.domain, options.configPath);
24
+ if (!domain) {
25
+ return;
26
+ }
27
+ const port = await findFreePort(options.port ?? userConfig.server?.port ?? 5173);
28
+ const host = `${port}.${domain}`;
29
+ url = `https://${host}`;
30
+ return {
31
+ server: {
32
+ host: '127.0.0.1',
33
+ port,
34
+ strictPort: true,
35
+ origin: url,
36
+ allowedHosts: [host],
37
+ cors: { origin: corsOrigin(domain) },
38
+ hmr: { host, protocol: 'wss', clientPort: 443 },
39
+ },
40
+ };
41
+ },
42
+ configResolved(config) {
43
+ if (url && options.cleanFontsManifest !== false) {
44
+ const publicDir = config.publicDir ? config.publicDir : 'public';
45
+ removeStaleFontsManifest(config.root, publicDir);
46
+ }
47
+ },
48
+ configureServer(server) {
49
+ if (!url) {
50
+ return;
51
+ }
52
+ const printUrls = server.printUrls.bind(server);
53
+ server.printUrls = () => {
54
+ printUrls();
55
+ server.config.logger.info(` \x1b[32m➜\x1b[0m \x1b[1mvhosty\x1b[0m: \x1b[36m${url}/\x1b[0m`);
56
+ };
57
+ },
58
+ };
59
+ }
60
+ export { vhosty };
61
+ export { corsOrigin, findFreePort, resolveDomain } from './utils.js';
@@ -0,0 +1,7 @@
1
+ export declare function isPortFree(port: number, host: string): Promise<boolean>;
2
+ export declare function findFreePort(start: number, attempts?: number): Promise<number>;
3
+ export declare function resolveDomain(env: Record<string, string>, option?: string, configPath?: string): string | undefined;
4
+ export declare function readConfigDomain(configPath: string): string | undefined;
5
+ export declare function normalizeDomain(domain: string): string;
6
+ export declare function corsOrigin(domain: string): RegExp;
7
+ export declare function removeStaleFontsManifest(root: string, publicDirectory?: string): boolean;
package/dist/utils.js ADDED
@@ -0,0 +1,81 @@
1
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
2
+ import { createServer } from 'node:net';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ /*
6
+ * True when nothing is listening on `port` at `host`. A machine without IPv6
7
+ * can't have anything listening on ::1, so those errors count as free.
8
+ */
9
+ export function isPortFree(port, host) {
10
+ return new Promise((done) => {
11
+ const probe = createServer()
12
+ .once('error', (err) => {
13
+ done(err.code === 'EADDRNOTAVAIL' || err.code === 'EAFNOSUPPORT');
14
+ })
15
+ .once('listening', () => probe.close(() => done(true)))
16
+ .listen({ port, host, exclusive: true });
17
+ });
18
+ }
19
+ /*
20
+ * First port from `start` that's free on both 127.0.0.1 and ::1. Node 17+
21
+ * often binds `localhost` to ::1 only, and Caddy dials `localhost`, so a
22
+ * port in use on either address would route to the wrong app.
23
+ */
24
+ export async function findFreePort(start, attempts = 100) {
25
+ for (let port = start; port < start + attempts; port++) {
26
+ if ((await isPortFree(port, '127.0.0.1')) && (await isPortFree(port, '::1'))) {
27
+ return port;
28
+ }
29
+ }
30
+ throw new Error(`[vhosty] No free port between ${start} and ${start + attempts - 1}`);
31
+ }
32
+ /*
33
+ * The vhosty domain, from (in order): the plugin option, VHOSTY_DOMAIN,
34
+ * the older VITE_HMR_DOMAIN, then ~/.vhosty/config. VHOSTY=false turns
35
+ * the plugin off for one project.
36
+ */
37
+ export function resolveDomain(env, option, configPath = join(homedir(), '.vhosty', 'config')) {
38
+ if (env.VHOSTY === 'false' || env.VHOSTY === '0') {
39
+ return undefined;
40
+ }
41
+ const domain = option || env.VHOSTY_DOMAIN || env.VITE_HMR_DOMAIN || readConfigDomain(configPath);
42
+ return domain ? normalizeDomain(domain) : undefined;
43
+ }
44
+ export function readConfigDomain(configPath) {
45
+ try {
46
+ const config = readFileSync(configPath, 'utf8');
47
+ return config.match(/^\s*VHOSTY_DOMAIN=["']?([^"'\s]+)["']?\s*$/m)?.[1];
48
+ }
49
+ catch {
50
+ return undefined; // not a vhosty machine
51
+ }
52
+ }
53
+ export function normalizeDomain(domain) {
54
+ return domain
55
+ .trim()
56
+ .toLowerCase()
57
+ .replace(/^https?:\/\//, '')
58
+ .replace(/^\*\./, '')
59
+ .replace(/\/.*$/, '')
60
+ .replace(/\.$/, '');
61
+ }
62
+ /*
63
+ * Matches https://<one label>.<domain>, so every app and port on the vhosty
64
+ * domain can load the dev server's assets. Only your tailnet can reach these.
65
+ */
66
+ export function corsOrigin(domain) {
67
+ const escaped = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
68
+ return new RegExp(`^https://[a-z0-9-]+\\.${escaped}$`);
69
+ }
70
+ /*
71
+ * laravel-vite-plugin caches font URLs in public/fonts-manifest.dev.json and
72
+ * doesn't always rewrite it, which leaves fonts pointing at an old host.
73
+ */
74
+ export function removeStaleFontsManifest(root, publicDirectory = 'public') {
75
+ const file = resolve(root, publicDirectory, 'fonts-manifest.dev.json');
76
+ if (!existsSync(file)) {
77
+ return false;
78
+ }
79
+ rmSync(file, { force: true });
80
+ return true;
81
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@vhosty/vite-plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Serve the Vite dev server through vhosty: HTTPS and hot reload at https://<port>.<your-domain> over your tailnet",
5
+ "keywords": [
6
+ "vite",
7
+ "vite-plugin",
8
+ "vhosty",
9
+ "tailscale",
10
+ "caddy",
11
+ "laravel",
12
+ "hmr",
13
+ "https"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Zack Warren",
17
+ "homepage": "https://github.com/vhostyhq/vite-plugin#readme",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/vhostyhq/vite-plugin.git"
21
+ },
22
+ "bugs": "https://github.com/vhostyhq/vite-plugin/issues",
23
+ "type": "module",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "sideEffects": false,
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "scripts": {
39
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
40
+ "test": "vitest run",
41
+ "typecheck": "tsc --noEmit",
42
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
43
+ },
44
+ "peerDependencies": {
45
+ "vite": ">=5"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^26.6.6",
49
+ "laravel-vite-plugin": "^3.2.0",
50
+ "typescript": "^7.0.2",
51
+ "vite": "^8.3.5",
52
+ "vitest": "^5.0.5"
53
+ }
54
+ }