@pyreon/vite-plugin 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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Pyreon HMR Runtime — preserves signal state across hot module reloads.
3
+ *
4
+ * Served as a virtual module `\0pyreon/hmr-runtime` and injected into every
5
+ * .tsx/.jsx module during development.
6
+ *
7
+ * ## How it works
8
+ *
9
+ * 1. The Vite plugin rewrites top-level `signal()` calls:
10
+ * `const count = signal(0)` → `const count = __hmr_signal("src/App.tsx", "count", signal, 0)`
11
+ *
12
+ * 2. `__hmr_signal` checks a global registry for a saved value under the
13
+ * composite key `moduleId + ":" + name`. If found, it creates the signal
14
+ * with the preserved value instead of the initial one.
15
+ *
16
+ * 3. When `import.meta.hot.accept()` fires, a `dispose` callback saves every
17
+ * registered signal's current value into the registry before the old module
18
+ * is discarded.
19
+ *
20
+ * The registry lives on `globalThis` so it survives module re-execution.
21
+ */
22
+
23
+ interface SignalLike {
24
+ peek(): unknown
25
+ set(value: unknown): void
26
+ }
27
+
28
+ interface ModuleSignals {
29
+ entries: Map<string, SignalLike>
30
+ }
31
+
32
+ const REGISTRY_KEY = "__pyreon_hmr_registry__"
33
+
34
+ type Registry = Map<string, Map<string, unknown>>
35
+
36
+ function getRegistry(): Registry {
37
+ const g = globalThis as Record<string, unknown>
38
+ if (!g[REGISTRY_KEY]) {
39
+ g[REGISTRY_KEY] = new Map()
40
+ }
41
+ return g[REGISTRY_KEY] as Registry
42
+ }
43
+
44
+ const moduleSignals = new Map<string, ModuleSignals>()
45
+
46
+ /**
47
+ * Called in place of `signal(initialValue)` for module-scope signals.
48
+ * Restores the previous value if the module is being hot-reloaded.
49
+ */
50
+ export function __hmr_signal<T>(
51
+ moduleId: string,
52
+ name: string,
53
+ signalFn: (value: T) => SignalLike,
54
+ initialValue: T,
55
+ ): ReturnType<typeof signalFn> {
56
+ const registry = getRegistry()
57
+ const saved = registry.get(moduleId)
58
+
59
+ // Use saved value if available (hot reload), otherwise use initial
60
+ const value = saved?.has(name) ? (saved.get(name) as T) : initialValue
61
+
62
+ const s = signalFn(value)
63
+
64
+ // Track this signal for future disposal
65
+ let mod = moduleSignals.get(moduleId)
66
+ if (!mod) {
67
+ mod = { entries: new Map() }
68
+ moduleSignals.set(moduleId, mod)
69
+ }
70
+ mod.entries.set(name, s)
71
+
72
+ return s
73
+ }
74
+
75
+ /**
76
+ * Called in the `import.meta.hot.dispose` callback.
77
+ * Saves all registered signal values for the module before it is discarded.
78
+ */
79
+ export function __hmr_dispose(moduleId: string): void {
80
+ const mod = moduleSignals.get(moduleId)
81
+ if (!mod) return
82
+
83
+ const registry = getRegistry()
84
+ const saved = new Map<string, unknown>()
85
+ for (const [name, s] of mod.entries) {
86
+ saved.set(name, s.peek())
87
+ }
88
+ registry.set(moduleId, saved)
89
+
90
+ // Clear entries so the new module can re-register
91
+ moduleSignals.delete(moduleId)
92
+ }
package/src/index.ts ADDED
@@ -0,0 +1,376 @@
1
+ /**
2
+ * @pyreon/vite-plugin — Vite integration for Pyreon framework.
3
+ *
4
+ * Applies Pyreon's JSX reactive transform to .tsx, .jsx, and .pyreon files,
5
+ * and configures Vite to use Pyreon's JSX runtime.
6
+ *
7
+ * ## Basic usage (SPA)
8
+ *
9
+ * import pyreon from "@pyreon/vite-plugin"
10
+ * export default { plugins: [pyreon()] }
11
+ *
12
+ * ## SSR mode
13
+ *
14
+ * import pyreon from "@pyreon/vite-plugin"
15
+ * export default { plugins: [pyreon({ ssr: { entry: "./src/entry-server.ts" } })] }
16
+ *
17
+ * In SSR mode, the plugin adds dev server middleware that:
18
+ * 1. Loads your server entry via Vite's `ssrLoadModule`
19
+ * 2. Calls the exported `handler` or default export (Request → Response)
20
+ * 3. Returns the SSR'd HTML for every non-asset request
21
+ *
22
+ * For production, build separately:
23
+ * vite build # client bundle
24
+ * vite build --ssr src/entry-server.ts --outDir dist/server # server bundle
25
+ */
26
+
27
+ import { transformJSX } from "@pyreon/compiler"
28
+ import type { Plugin, ViteDevServer } from "vite"
29
+
30
+ // Virtual module ID for the HMR runtime
31
+ const HMR_RUNTIME_ID = "\0pyreon/hmr-runtime"
32
+ const HMR_RUNTIME_IMPORT = "virtual:pyreon/hmr-runtime"
33
+
34
+ export interface PyreonPluginOptions {
35
+ /**
36
+ * Enable SSR dev middleware.
37
+ *
38
+ * Pass an object with `entry` pointing to your server entry file.
39
+ * The entry must export a `handler` function: `(req: Request) => Promise<Response>`
40
+ * or a default export of the same type.
41
+ *
42
+ * @example
43
+ * pyreonPlugin({ ssr: { entry: "./src/entry-server.ts" } })
44
+ */
45
+ ssr?: {
46
+ /** Server entry file path (e.g. "./src/entry-server.ts") */
47
+ entry: string
48
+ }
49
+ }
50
+
51
+ export default function pyreonPlugin(options?: PyreonPluginOptions): Plugin {
52
+ const ssrConfig = options?.ssr
53
+ let isBuild = false
54
+
55
+ return {
56
+ name: "pyreon",
57
+ enforce: "pre",
58
+
59
+ config(_, env) {
60
+ isBuild = env.command === "build"
61
+
62
+ return {
63
+ resolve: {
64
+ conditions: ["bun"],
65
+ },
66
+ esbuild: {
67
+ jsx: "automatic",
68
+ jsxImportSource: "@pyreon/core",
69
+ },
70
+ // In SSR build mode, configure the entry
71
+ ...(env.isSsrBuild && ssrConfig
72
+ ? {
73
+ build: {
74
+ ssr: true,
75
+ rollupOptions: {
76
+ input: ssrConfig.entry,
77
+ },
78
+ },
79
+ }
80
+ : {}),
81
+ }
82
+ },
83
+
84
+ // ── Virtual module: HMR runtime ─────────────────────────────────────────
85
+ resolveId(id) {
86
+ if (id === HMR_RUNTIME_IMPORT) return HMR_RUNTIME_ID
87
+ },
88
+
89
+ load(id) {
90
+ if (id === HMR_RUNTIME_ID) {
91
+ return HMR_RUNTIME_SOURCE
92
+ }
93
+ },
94
+
95
+ transform(code, id) {
96
+ const ext = getExt(id)
97
+ if (ext !== ".tsx" && ext !== ".jsx" && ext !== ".pyreon") return
98
+ const result = transformJSX(code, id)
99
+ // Surface compiler warnings in the terminal
100
+ for (const w of result.warnings) {
101
+ this.warn(`${w.message} (${id}:${w.line}:${w.column})`)
102
+ }
103
+
104
+ let output = result.code
105
+
106
+ // ── HMR injection (dev only) ────────────────────────────────────────
107
+ if (!isBuild) {
108
+ output = injectHmr(output, id)
109
+ }
110
+
111
+ return { code: output, map: null }
112
+ },
113
+
114
+ // ── SSR dev middleware ───────────────────────────────────────────────────
115
+ configureServer(server: ViteDevServer) {
116
+ if (!ssrConfig) return
117
+
118
+ // Return a function so the middleware runs AFTER Vite's built-in middleware
119
+ // (static files, HMR, etc.) — only handle requests that Vite doesn't serve.
120
+ return () => {
121
+ server.middlewares.use(async (req, res, next) => {
122
+ if (req.method !== "GET") return next()
123
+ const url = req.url ?? "/"
124
+ if (isAssetRequest(url)) return next()
125
+
126
+ try {
127
+ await handleSsrRequest(server, ssrConfig.entry, url, req, res, next)
128
+ } catch (err) {
129
+ server.ssrFixStacktrace(err as Error)
130
+ next(err)
131
+ }
132
+ })
133
+ }
134
+ },
135
+ }
136
+ }
137
+
138
+ async function handleSsrRequest(
139
+ server: ViteDevServer,
140
+ entry: string,
141
+ url: string,
142
+ req: import("node:http").IncomingMessage,
143
+ res: import("node:http").ServerResponse,
144
+ next: (err?: unknown) => void,
145
+ ): Promise<void> {
146
+ const mod = await server.ssrLoadModule(entry)
147
+ const handler = mod.handler ?? mod.default
148
+
149
+ if (typeof handler !== "function") {
150
+ next()
151
+ return
152
+ }
153
+
154
+ const origin = `http://${req.headers.host ?? "localhost"}`
155
+ const fullUrl = new URL(url, origin)
156
+ const request = new Request(fullUrl.href, {
157
+ method: req.method ?? "GET",
158
+ headers: Object.entries(req.headers).reduce((h, [k, v]) => {
159
+ if (v) h.set(k, Array.isArray(v) ? v.join(", ") : v)
160
+ return h
161
+ }, new Headers()),
162
+ })
163
+
164
+ const response: Response = await handler(request)
165
+ let html = await response.text()
166
+
167
+ html = await server.transformIndexHtml(url, html)
168
+
169
+ res.statusCode = response.status
170
+ response.headers.forEach((v, k) => {
171
+ res.setHeader(k, v)
172
+ })
173
+ res.end(html)
174
+ }
175
+
176
+ // ── HMR injection ─────────────────────────────────────────────────────────────
177
+
178
+ /**
179
+ * Regex that detects signal declarations (prefix + variable name).
180
+ * The arguments are extracted via balanced-paren matching in `injectHmr`.
181
+ * A brace-depth check filters out matches inside functions/blocks — only
182
+ * module-scope (depth 0) signals are rewritten for HMR state preservation.
183
+ */
184
+ const SIGNAL_PREFIX_RE = /^((?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*)signal\(/gm
185
+
186
+ /**
187
+ * Detect whether the module exports any component-like functions
188
+ * (uppercase first letter — standard convention for JSX components).
189
+ */
190
+ const EXPORT_COMPONENT_RE =
191
+ /export\s+(?:default\s+)?(?:function\s+([A-Z]\w*)|const\s+([A-Z]\w*)\s*[=:])/
192
+
193
+ function skipStringLiteral(code: string, start: number, quote: string): number {
194
+ let j = start + 1
195
+ while (j < code.length) {
196
+ if (code[j] === "\\") {
197
+ j += 2
198
+ continue
199
+ }
200
+ if (code[j] === quote) break
201
+ j++
202
+ }
203
+ return j
204
+ }
205
+
206
+ function extractBalancedArgs(code: string, start: number): string | null {
207
+ let depth = 1
208
+ for (let i = start; i < code.length; i++) {
209
+ const ch = code[i]
210
+ if (ch === "(") depth++
211
+ else if (ch === ")") {
212
+ depth--
213
+ if (depth === 0) return code.slice(start, i)
214
+ } else if (ch === '"' || ch === "'" || ch === "`") {
215
+ i = skipStringLiteral(code, i, ch)
216
+ }
217
+ }
218
+ return null
219
+ }
220
+
221
+ /**
222
+ * Compute brace depth at position `pos` — returns 0 for module scope.
223
+ * Skips string literals to avoid counting braces inside strings.
224
+ */
225
+ function braceDepthAt(code: string, pos: number): number {
226
+ let depth = 0
227
+ for (let i = 0; i < pos; i++) {
228
+ const ch = code[i]
229
+ if (ch === "{") depth++
230
+ else if (ch === "}") depth--
231
+ else if (ch === '"' || ch === "'" || ch === "`") {
232
+ i = skipStringLiteral(code, i, ch)
233
+ }
234
+ }
235
+ return depth
236
+ }
237
+
238
+ function injectHmr(code: string, moduleId: string): string {
239
+ const hasSignals = SIGNAL_PREFIX_RE.test(code)
240
+ SIGNAL_PREFIX_RE.lastIndex = 0
241
+
242
+ const hasComponentExport = EXPORT_COMPONENT_RE.test(code)
243
+
244
+ // Only inject HMR if the module exports components or has module-scope signals
245
+ if (!hasComponentExport && !hasSignals) return code
246
+
247
+ let output = code
248
+
249
+ // Rewrite top-level signal() calls to use __hmr_signal for state preservation
250
+ if (hasSignals) {
251
+ const escapedId = JSON.stringify(moduleId)
252
+ // Process matches in reverse order so indices stay valid after replacement
253
+ const matches: {
254
+ start: number
255
+ end: number
256
+ prefix: string
257
+ name: string
258
+ args: string
259
+ }[] = []
260
+ let m: RegExpExecArray | null = SIGNAL_PREFIX_RE.exec(code)
261
+ while (m !== null) {
262
+ const argsStart = m.index + m[0].length
263
+ const args = extractBalancedArgs(code, argsStart)
264
+ if (args === null) {
265
+ m = SIGNAL_PREFIX_RE.exec(code)
266
+ continue // unbalanced — skip
267
+ }
268
+ // Only rewrite module-scope signals (brace depth 0).
269
+ // esbuild may strip indentation, so we can't rely on column position.
270
+ if (braceDepthAt(code, m.index) === 0) {
271
+ matches.push({
272
+ start: m.index,
273
+ end: argsStart + args.length + 1, // +1 for closing paren
274
+ prefix: m[1] ?? "",
275
+ name: m[2] ?? "",
276
+ args,
277
+ })
278
+ }
279
+ m = SIGNAL_PREFIX_RE.exec(code)
280
+ }
281
+ SIGNAL_PREFIX_RE.lastIndex = 0
282
+
283
+ // Replace in reverse to preserve offsets
284
+ for (let i = matches.length - 1; i >= 0; i--) {
285
+ const { start, end, prefix, name, args } = matches[i] as (typeof matches)[number]
286
+ const replacement = `${prefix}__hmr_signal(${escapedId}, ${JSON.stringify(name)}, signal, ${args})`
287
+ output = output.slice(0, start) + replacement + output.slice(end)
288
+ }
289
+ }
290
+
291
+ // Build the HMR footer
292
+ const escapedId = JSON.stringify(moduleId)
293
+ const lines: string[] = []
294
+
295
+ if (hasSignals) {
296
+ lines.push(`import { __hmr_signal, __hmr_dispose } from "${HMR_RUNTIME_IMPORT}";`)
297
+ }
298
+
299
+ lines.push(`if (import.meta.hot) {`)
300
+
301
+ if (hasSignals) {
302
+ lines.push(` import.meta.hot.dispose(() => __hmr_dispose(${escapedId}));`)
303
+ }
304
+
305
+ lines.push(` import.meta.hot.accept();`)
306
+ lines.push(`}`)
307
+
308
+ output = `${output}\n\n${lines.join("\n")}\n`
309
+
310
+ return output
311
+ }
312
+
313
+ // ── Helpers ───────────────────────────────────────────────────────────────────
314
+
315
+ function getExt(id: string): string {
316
+ const clean = id.split("?")[0] ?? id
317
+ const dot = clean.lastIndexOf(".")
318
+ return dot >= 0 ? clean.slice(dot) : ""
319
+ }
320
+
321
+ /** Skip Vite-handled asset requests (CSS, images, HMR, etc.) */
322
+ function isAssetRequest(url: string): boolean {
323
+ return (
324
+ url.startsWith("/@") || // @vite/client, @id, @fs, etc.
325
+ url.startsWith("/__") || // __open-in-editor, etc.
326
+ url.includes("/node_modules/") ||
327
+ /\.(css|js|ts|tsx|jsx|json|ico|png|jpg|jpeg|gif|svg|woff2?|ttf|eot|map)(\?|$)/.test(url)
328
+ )
329
+ }
330
+
331
+ // ── HMR runtime source (served as virtual module) ─────────────────────────────
332
+ //
333
+ // Inlined here so it's available without a filesystem read. This is the
334
+ // compiled-to-JS version of hmr-runtime.ts — kept in sync manually.
335
+
336
+ const HMR_RUNTIME_SOURCE = `
337
+ const REGISTRY_KEY = "__pyreon_hmr_registry__";
338
+
339
+ function getRegistry() {
340
+ if (!globalThis[REGISTRY_KEY]) {
341
+ globalThis[REGISTRY_KEY] = new Map();
342
+ }
343
+ return globalThis[REGISTRY_KEY];
344
+ }
345
+
346
+ const moduleSignals = new Map();
347
+
348
+ export function __hmr_signal(moduleId, name, signalFn, initialValue) {
349
+ const registry = getRegistry();
350
+ const saved = registry.get(moduleId);
351
+ const value = saved?.has(name) ? saved.get(name) : initialValue;
352
+ const s = signalFn(value);
353
+
354
+ let mod = moduleSignals.get(moduleId);
355
+ if (!mod) {
356
+ mod = { entries: new Map() };
357
+ moduleSignals.set(moduleId, mod);
358
+ }
359
+ mod.entries.set(name, s);
360
+
361
+ return s;
362
+ }
363
+
364
+ export function __hmr_dispose(moduleId) {
365
+ const mod = moduleSignals.get(moduleId);
366
+ if (!mod) return;
367
+
368
+ const registry = getRegistry();
369
+ const saved = new Map();
370
+ for (const [name, s] of mod.entries) {
371
+ saved.set(name, s.peek());
372
+ }
373
+ registry.set(moduleId, saved);
374
+ moduleSignals.delete(moduleId);
375
+ }
376
+ `