@gsxhq/vite-plugin-gsx 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 gsxhq
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 ADDED
@@ -0,0 +1,197 @@
1
+ # @gsxhq/vite-plugin-gsx
2
+
3
+ Vite dev plugin for [gsx](https://github.com/gsxhq/gsx) — watches `.gsx` files, runs `gsx generate`, shows compile errors in the Vite overlay, and triggers a full browser reload after the Go server reboots.
4
+
5
+ Because gsx renders HTML **server-side**, there is no JavaScript module graph to hot-replace. The plugin does not use Vite HMR; instead it re-generates the `.x.go` files, waits for the Go binary to restart, and then issues a **full-reload** driven by a POST from the Go server — not by the file-change event itself. This keeps the browser tab pointing at a live server, never at stale generated code.
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm i -D @gsxhq/vite-plugin-gsx
13
+ ```
14
+
15
+ ### Prerequisite: `go tool gsx`
16
+
17
+ The default command is `go tool gsx generate`. Register the tool in your Go module once:
18
+
19
+ ```bash
20
+ go get -tool github.com/gsxhq/gsx/cmd/gsx
21
+ ```
22
+
23
+ After this, `go tool gsx generate` works without a separate install step and is pinned to your `go.mod` version. If you maintain a custom `cmd/gsx` in your own repo, override the command option instead (see [Options](#options)).
24
+
25
+ ---
26
+
27
+ ## Quick start
28
+
29
+ ```ts
30
+ // vite.config.ts
31
+ import { defineConfig } from "vite";
32
+ import { gsx } from "@gsxhq/vite-plugin-gsx";
33
+
34
+ // Example dev config: Vite is the front door and proxies non-Vite routes to the
35
+ // Go server, so the injected @vite/client socket survives Go rebuilds.
36
+ export default defineConfig({
37
+ plugins: [
38
+ gsx({
39
+ // Default is ["go","tool","gsx","generate"]; override for a custom cmd/gsx:
40
+ // command: ["go", "run", "./cmd/gsx", "generate"],
41
+ }),
42
+ ],
43
+ server: {
44
+ proxy: {
45
+ "^(?!/@vite|/@id|/node_modules).*": {
46
+ target: "http://localhost:8080",
47
+ changeOrigin: true,
48
+ ws: true,
49
+ },
50
+ },
51
+ },
52
+ });
53
+ ```
54
+
55
+ This config is also present at [`examples/vite.config.ts`](examples/vite.config.ts) and is type-checked on every CI run (`npm run typecheck:example`).
56
+
57
+ ---
58
+
59
+ ## How the loop works
60
+
61
+ ```
62
+ save .gsx file
63
+
64
+
65
+ vite-plugin-gsx: watcher fires
66
+ │ debounce 50 ms
67
+
68
+ go tool gsx generate ← regenerates .x.go files
69
+ │ ok → nothing broadcast (wait for Go server)
70
+ │ err → error overlay shown in browser
71
+
72
+ wgo / air: detects .go change, rebuilds and restarts Go binary
73
+
74
+
75
+ Go binary boots → calls NotifyViteReload(viteDevURL)
76
+ │ POST /__reload
77
+
78
+ vite-plugin-gsx: receives POST → server.ws.send({ type: "full-reload" })
79
+
80
+
81
+ browser tab reloads, fetches fresh HTML from the new Go server
82
+ ```
83
+
84
+ **Key invariant:** the browser reload is triggered by the Go POST, not by the `.gsx` file change. The plugin does not broadcast a reload immediately after `gsx generate` succeeds — it waits for the Go server to be up and ready before reloading the browser. This prevents the browser from loading a page from a server that is still mid-restart.
85
+
86
+ ---
87
+
88
+ ## Project-side glue (three pieces)
89
+
90
+ The plugin handles the Vite side. Your Go project needs three corresponding pieces.
91
+
92
+ ### 1. Proxy
93
+
94
+ Add the proxy block to your `vite.config.ts` (shown in the quick start above). This makes Vite the front door: asset and HMR WebSocket routes stay with Vite; everything else — your actual HTML pages — is proxied to the Go server on port 8080. The `@vite/client` WebSocket connection is maintained across Go rebuilds because it connects to Vite, not to Go.
95
+
96
+ ```ts
97
+ server: {
98
+ proxy: {
99
+ "^(?!/@vite|/@id|/node_modules).*": {
100
+ target: "http://localhost:8080",
101
+ changeOrigin: true,
102
+ ws: true,
103
+ },
104
+ },
105
+ },
106
+ ```
107
+
108
+ ### 2. `@vite/client` script in the layout
109
+
110
+ The browser needs the `@vite/client` script to receive the full-reload signal. Inject it in your root layout, gated on a boolean your app controls so the script is never emitted in production:
111
+
112
+ ```gsx
113
+ component Layout(title string) {
114
+ <head>
115
+ <title>{title}</title>
116
+ if dev { <script type="module" src="/@vite/client"></script> }
117
+ </head>
118
+ }
119
+ ```
120
+
121
+ `dev` is a boolean **you** supply — the plugin does not set or pass any dev flag. Gate it on the same signal as `NotifyViteReload` so the two stay consistent. The natural choice is whether `VITE_DEV_URL` is set (the `NotifyViteReload` snippet already no-ops when it is empty):
122
+
123
+ ```go
124
+ var dev = os.Getenv("VITE_DEV_URL") != ""
125
+ ```
126
+
127
+ Pass `dev` into the layout from your handler. When `VITE_DEV_URL` is unset (production), `dev` is `false`, the `if dev` branch is omitted, and the script tag is never emitted.
128
+
129
+ ### 3. `NotifyViteReload` — Go boot hook
130
+
131
+ After your Go server starts (or restarts after a wgo rebuild), call `NotifyViteReload` with the Vite dev server URL. This fires a POST to `/__reload`, which the plugin receives and forwards to the browser as a full-reload.
132
+
133
+ ```go
134
+ // NotifyViteReload pokes the Vite dev server after this binary boots so any
135
+ // browser tab holding an @vite/client socket reloads. Dev-only: no-ops when
136
+ // VITE_DEV_URL is unset.
137
+ func NotifyViteReload(viteDevURL string) {
138
+ if viteDevURL == "" {
139
+ return
140
+ }
141
+ go func() {
142
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
143
+ defer cancel()
144
+ for range 10 {
145
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, viteDevURL+"/__reload", nil)
146
+ if err != nil {
147
+ return
148
+ }
149
+ if resp, err := http.DefaultClient.Do(req); err == nil {
150
+ resp.Body.Close()
151
+ return
152
+ }
153
+ select {
154
+ case <-ctx.Done():
155
+ return
156
+ case <-time.After(150 * time.Millisecond):
157
+ }
158
+ }
159
+ }()
160
+ }
161
+ ```
162
+
163
+ Pass `os.Getenv("VITE_DEV_URL")` (e.g. `http://localhost:5173`) when starting the server in dev mode. In production, leave `VITE_DEV_URL` unset and the function is a no-op.
164
+
165
+ ### `wgo` for Go rebuilds
166
+
167
+ The plugin only regenerates `.x.go` files; it does not rebuild or restart the Go binary. Use `wgo` (or `air`) to watch `.go` files and rebuild:
168
+
169
+ ```bash
170
+ go tool wgo -file=.go go build -o tmp/app ./cmd/app :: tmp/app
171
+ ```
172
+
173
+ `wgo` rebuilds and restarts `tmp/app` on any `.go` change, including the `.x.go` files that `gsx generate` just wrote. After restart, the new binary calls `NotifyViteReload` and the browser reloads.
174
+
175
+ ---
176
+
177
+ ## Options
178
+
179
+ All options are optional. Omit them entirely to use the defaults.
180
+
181
+ | Option | Type | Default | Description |
182
+ |---|---|---|---|
183
+ | `command` | `string[]` | `["go","tool","gsx","generate"]` | Command and leading args to invoke gsx. Override with `["go","run","./cmd/gsx","generate"]` for a local binary. |
184
+ | `paths` | `string[]` | `["."]` | Path args passed to `generate`. Narrows which packages are regenerated. |
185
+ | `watch` | `string \| string[]` | `"**/*.gsx"` | Glob(s) whose changes trigger regeneration. Relative to the Vite config root. |
186
+ | `cwd` | `string` | Vite config root | Working directory for the generate command. |
187
+ | `reloadEndpoint` | `string` | `"/__reload"` | HTTP endpoint the Go server POSTs to trigger a full browser reload. |
188
+ | `debounce` | `number` | `50` | Debounce window in milliseconds for rapid saves before running generate. |
189
+ | `generateOnStart` | `boolean` | `true` | Run an initial `gsx generate` when the Vite dev server starts, so `.x.go` files exist from the first boot. |
190
+
191
+ ---
192
+
193
+ ## Notes
194
+
195
+ - **Dev-only.** The plugin sets `apply: "serve"` and is excluded from production builds. It has no effect on `vite build`.
196
+ - **Production generation.** Run `gsx generate` in CI or via a `//go:generate` directive. The plugin is not involved.
197
+ - **Full-reload only.** gsx renders HTML server-side, so there is no JavaScript module graph to partially update. Every change results in a full page reload, not a component-level HMR update.
@@ -0,0 +1,22 @@
1
+ import { Plugin } from 'vite';
2
+
3
+ interface GsxOptions {
4
+ /** Command + leading args to invoke gsx. Default: ["go","tool","gsx","generate"]. */
5
+ command?: string[];
6
+ /** Path args passed to generate. Default: ["."]. */
7
+ paths?: string[];
8
+ /** Globs whose changes trigger regeneration. Default: all .gsx files. */
9
+ watch?: string | string[];
10
+ /** Working directory for the command. Default: Vite config root. */
11
+ cwd?: string;
12
+ /** Endpoint the Go server POSTs to trigger reload. Default: "/__reload". */
13
+ reloadEndpoint?: string;
14
+ /** Debounce window for rapid saves, ms. Default: 50. */
15
+ debounce?: number;
16
+ /** Run an initial generate when the dev server starts. Default: true. */
17
+ generateOnStart?: boolean;
18
+ }
19
+
20
+ declare function gsx(options?: GsxOptions): Plugin;
21
+
22
+ export { type GsxOptions, gsx as default, gsx };
package/dist/index.js ADDED
@@ -0,0 +1,186 @@
1
+ // src/index.ts
2
+ import { readFileSync } from "fs";
3
+ import { relative } from "path";
4
+ import picomatch from "picomatch";
5
+
6
+ // src/options.ts
7
+ var DEFAULT_GSX_GLOB = "**/*.gsx";
8
+ function resolveOptions(user, root) {
9
+ const watch = user.watch === void 0 ? [DEFAULT_GSX_GLOB] : Array.isArray(user.watch) ? user.watch : [user.watch];
10
+ return {
11
+ command: user.command ?? ["go", "tool", "gsx", "generate"],
12
+ paths: user.paths ?? ["."],
13
+ watch,
14
+ cwd: user.cwd ?? root,
15
+ reloadEndpoint: user.reloadEndpoint ?? "/__reload",
16
+ debounce: user.debounce ?? 50,
17
+ generateOnStart: user.generateOnStart ?? true
18
+ };
19
+ }
20
+
21
+ // src/generate.ts
22
+ import { spawn } from "child_process";
23
+ function runGenerate(opts) {
24
+ const [bin, ...leading] = opts.command;
25
+ if (!bin) {
26
+ return Promise.resolve({
27
+ ok: false,
28
+ raw: "",
29
+ diagnostics: [synthetic("vite-plugin-gsx: empty `command` option")]
30
+ });
31
+ }
32
+ const args = [...leading, "--json", ...opts.paths];
33
+ return new Promise((resolve) => {
34
+ let stdout = "";
35
+ let stderr = "";
36
+ const child = spawn(bin, args, { cwd: opts.cwd });
37
+ child.stdout.on("data", (d) => stdout += d.toString());
38
+ child.stderr.on("data", (d) => stderr += d.toString());
39
+ child.on("error", (e) => {
40
+ resolve({
41
+ ok: false,
42
+ raw: String(e),
43
+ diagnostics: [
44
+ synthetic(
45
+ `vite-plugin-gsx: could not run gsx (\`${opts.command.join(" ")}\`): ${e.code ?? e.message}. Is the gsx \`tool\` directive in go.mod, and is Go installed?`
46
+ )
47
+ ]
48
+ });
49
+ });
50
+ child.on("close", (code) => {
51
+ if (code === 0) {
52
+ resolve({ ok: true, diagnostics: [], raw: stdout });
53
+ return;
54
+ }
55
+ const parsed = parseDiagnostics(stdout);
56
+ if (parsed) {
57
+ resolve({ ok: false, diagnostics: parsed, raw: stdout });
58
+ return;
59
+ }
60
+ const detail = (stderr || stdout || `exit ${code}`).trim();
61
+ resolve({
62
+ ok: false,
63
+ raw: stdout,
64
+ diagnostics: [synthetic(`gsx generate failed: ${detail}`)]
65
+ });
66
+ });
67
+ });
68
+ }
69
+ function parseDiagnostics(stdout) {
70
+ const text = stdout.trim();
71
+ if (!text.startsWith("[")) return null;
72
+ try {
73
+ const arr = JSON.parse(text);
74
+ if (!Array.isArray(arr)) return null;
75
+ return arr;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+ function synthetic(message) {
81
+ return {
82
+ file: "",
83
+ range: { start: { line: 1, col: 1 }, end: { line: 1, col: 1 } },
84
+ severity: "error",
85
+ message
86
+ };
87
+ }
88
+
89
+ // src/diagnostics.ts
90
+ function toViteError(diags, readSource2) {
91
+ const err = diags.find((d) => d.severity === "error");
92
+ if (!err) return null;
93
+ const head = err.code ? `${err.code}: ${err.message}` : err.message;
94
+ const message = err.help ? `${head}
95
+
96
+ ${err.help}` : head;
97
+ return {
98
+ message,
99
+ stack: "",
100
+ id: err.file,
101
+ frame: buildFrame(err, readSource2),
102
+ plugin: "vite-plugin-gsx",
103
+ loc: {
104
+ file: err.file,
105
+ line: err.range.start.line,
106
+ column: err.range.start.col
107
+ }
108
+ };
109
+ }
110
+ function buildFrame(diag, readSource2) {
111
+ const src = readSource2(diag.file);
112
+ if (src === null) return "";
113
+ const lines = src.split("\n");
114
+ const lineNo = diag.range.start.line;
115
+ const srcLine = lines[lineNo - 1];
116
+ if (srcLine === void 0) return "";
117
+ const gutter = `${lineNo} | `;
118
+ const caretPad = " ".repeat(gutter.length + Math.max(0, diag.range.start.col - 1));
119
+ return `${gutter}${srcLine}
120
+ ${caretPad}^`;
121
+ }
122
+
123
+ // src/index.ts
124
+ function gsx(options = {}) {
125
+ return {
126
+ name: "vite-plugin-gsx",
127
+ apply: "serve",
128
+ configureServer(server) {
129
+ const opts = resolveOptions(options, server.config.root);
130
+ const isMatch = picomatch(opts.watch);
131
+ const logger = server.config.logger;
132
+ server.middlewares.use(opts.reloadEndpoint, (req, res) => {
133
+ if (req.method !== "POST") {
134
+ res.statusCode = 405;
135
+ res.end();
136
+ return;
137
+ }
138
+ server.ws.send({ type: "full-reload", path: "*" });
139
+ res.statusCode = 204;
140
+ res.end();
141
+ });
142
+ async function generate() {
143
+ const result = await runGenerate({
144
+ command: opts.command,
145
+ paths: opts.paths,
146
+ cwd: opts.cwd
147
+ });
148
+ if (result.ok) return;
149
+ const err = toViteError(result.diagnostics, readSource);
150
+ if (err) {
151
+ for (const d of result.diagnostics) {
152
+ logger.error(`[vite-plugin-gsx] ${d.file}: ${d.message}`, {
153
+ timestamp: true
154
+ });
155
+ }
156
+ server.ws.send({ type: "error", err });
157
+ }
158
+ }
159
+ let timer;
160
+ function schedule() {
161
+ if (timer) clearTimeout(timer);
162
+ timer = setTimeout(() => void generate(), opts.debounce);
163
+ }
164
+ function onChange(file) {
165
+ if (isMatch(relative(opts.cwd, file))) schedule();
166
+ }
167
+ server.watcher.on("change", onChange);
168
+ server.watcher.on("add", onChange);
169
+ server.watcher.on("unlink", onChange);
170
+ if (opts.generateOnStart) void generate();
171
+ }
172
+ };
173
+ }
174
+ function readSource(file) {
175
+ if (!file) return null;
176
+ try {
177
+ return readFileSync(file, "utf8");
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+ var index_default = gsx;
183
+ export {
184
+ index_default as default,
185
+ gsx
186
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@gsxhq/vite-plugin-gsx",
3
+ "version": "0.1.0",
4
+ "description": "Vite dev plugin for gsx: watch .gsx, regenerate, error overlay, browser reload.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": { "node": ">=18" },
8
+ "repository": { "type": "git", "url": "git+https://github.com/gsxhq/vite-plugin-gsx.git" },
9
+ "homepage": "https://github.com/gsxhq/vite-plugin-gsx#readme",
10
+ "publishConfig": { "access": "public" },
11
+ "files": ["dist"],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
17
+ },
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "scripts": {
21
+ "build": "tsup",
22
+ "prepublishOnly": "npm run build",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
25
+ "typecheck": "tsc --noEmit",
26
+ "typecheck:example": "tsc --noEmit -p examples/tsconfig.json"
27
+ },
28
+ "peerDependencies": {
29
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
30
+ },
31
+ "dependencies": {
32
+ "picomatch": "^4.0.2"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^20.14.0",
36
+ "@types/picomatch": "^3.0.1",
37
+ "tsup": "^8.3.0",
38
+ "typescript": "^5.6.0",
39
+ "vite": "^6.0.0",
40
+ "vitest": "^2.1.0"
41
+ }
42
+ }