@valentinkolb/ssr 0.3.0 → 0.4.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/README.md CHANGED
@@ -279,11 +279,21 @@ src/
279
279
  createConfig({
280
280
  dev?: boolean; // Enable dev mode (default: false)
281
281
  verbose?: boolean; // Enable verbose logging (default: !dev)
282
- autoRefresh?: boolean; // Enable auto-reload in dev (default: true)
283
282
  template?: (context) => string; // HTML template function (optional, has default)
284
283
  })
285
284
  ```
286
285
 
286
+ ## Dev Tools
287
+
288
+ In dev mode, a small `[ssr]` badge appears in the corner of the page. Click it to open the dev tools panel where you can:
289
+
290
+ - Toggle auto-reload on/off
291
+ - Highlight island components (green border)
292
+ - Highlight client components (blue border)
293
+ - Move the panel to any corner
294
+
295
+ Settings are persisted in localStorage.
296
+
287
297
  ## Contributing
288
298
 
289
299
  Contributions are welcome! The codebase is intentionally minimal. Keep changes focused and avoid adding unnecessary complexity.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/ssr",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Minimal SSR framework for SolidJS and Bun",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -27,17 +27,16 @@ type Routes = Record<string, RouteHandler>;
27
27
  * ```
28
28
  */
29
29
  export const routes = (config: SsrConfig): Routes => {
30
- const { dev, autoRefresh } = config;
30
+ const { dev } = config;
31
31
  const ssrDir = getSsrDir(dev);
32
32
 
33
- const devRoutes: Routes =
34
- dev && autoRefresh
35
- ? {
36
- "/_ssr/_reload": () => createReloadResponse(),
37
- "/_ssr/_ping": () => new Response("ok"),
38
- "/_ssr/_client.js": () => createClientResponse(),
39
- }
40
- : {};
33
+ const devRoutes: Routes = dev
34
+ ? {
35
+ "/_ssr/_reload": () => createReloadResponse(),
36
+ "/_ssr/_ping": () => new Response("ok"),
37
+ "/_ssr/_client.js": () => createClientResponse(),
38
+ }
39
+ : {};
41
40
 
42
41
  return {
43
42
  ...devRoutes,
@@ -4,78 +4,250 @@
4
4
  if (!window.__ssr_reload) {
5
5
  window.__ssr_reload = true;
6
6
 
7
- // Tooltip
8
- const tooltip = document.body.appendChild(
9
- Object.assign(document.createElement("div"), {
10
- innerHTML: `
11
- auto refresh enabled
12
- <br/><br/>
13
- to disable, add to config
14
- <br/>
15
- { autoRefresh: false }
16
- `,
17
- }),
18
- );
19
- Object.assign(tooltip.style, {
20
- fontFamily: "monospace",
21
- fontSize: "12px",
22
- color: "#888",
23
- background: "#000",
24
- padding: "8px",
25
- border: "1px solid #333",
26
- position: "fixed",
27
- bottom: "28px",
28
- left: "8px",
29
- zIndex: "9999",
30
- display: "none",
31
- });
32
-
33
- // Badge
34
- const badge = document.body.appendChild(
35
- Object.assign(document.createElement("div"), {
36
- innerText: "[ssr]",
37
- onmouseenter: () => (tooltip.style.display = "block"),
38
- onmouseleave: () => (tooltip.style.display = "none"),
39
- }),
40
- );
41
- Object.assign(badge.style, {
42
- fontFamily: "monospace",
43
- fontSize: "12px",
44
- color: "#555",
45
- position: "fixed",
46
- bottom: "8px",
47
- left: "8px",
48
- zIndex: "9999",
49
- cursor: "default",
50
- });
51
-
52
- let es;
53
- try {
54
- es = new EventSource("/_ssr/_reload");
55
- } catch {
56
- return;
57
- }
58
-
59
- es.onerror = (e) => {
60
- e.preventDefault();
61
- es.close();
62
- window.__ssr_reload = false;
63
- badge.innerText = "[...]";
64
-
65
- const check = setInterval(() => {
66
- fetch("/_ssr/_ping")
67
- .then(({ ok }) => {
68
- if (!ok) return;
69
- clearInterval(check);
70
- location.reload();
71
- })
72
- .catch(() => {});
73
- }, 300);
74
- };
75
-
76
- // Clean up on page unload (for bfcache)
77
- window.addEventListener("pagehide", () => {
78
- es.close();
79
- window.__ssr_reload = false;
80
- });
7
+ (function () {
8
+ // ========================================
9
+ // Settings (persisted in localStorage)
10
+ // ========================================
11
+ const STORAGE_KEY = "_ssr";
12
+ const defaults = {
13
+ autoReload: true,
14
+ highlightIslands: false,
15
+ highlightClients: false,
16
+ position: "bl",
17
+ };
18
+
19
+ const loadSettings = () => {
20
+ try {
21
+ return {
22
+ ...defaults,
23
+ ...JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"),
24
+ };
25
+ } catch {
26
+ return defaults;
27
+ }
28
+ };
29
+
30
+ const saveSettings = (s) =>
31
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
32
+
33
+ let settings = loadSettings();
34
+
35
+ // ========================================
36
+ // Component counts from DOM
37
+ // ========================================
38
+ const islandCount = document.querySelectorAll("solid-island").length;
39
+ const clientCount = document.querySelectorAll("solid-client").length;
40
+
41
+ // ========================================
42
+ // Highlight styles
43
+ // ========================================
44
+ const style = document.head.appendChild(document.createElement("style"));
45
+
46
+ const updateStyles = () => {
47
+ style.textContent = `
48
+ ${
49
+ settings.highlightIslands
50
+ ? `
51
+ solid-island {
52
+ display: block;
53
+ box-shadow: 0 0 0 1px #22c55e !important;
54
+ position: relative;
55
+ }
56
+ solid-island::before {
57
+ content: attr(data-file);
58
+ position: absolute;
59
+ top: -17px;
60
+ left: -1px;
61
+ font-size: 10px;
62
+ font-family: monospace;
63
+ color: black;
64
+ background: #22c55e;
65
+ padding: 1px 4px;
66
+ white-space: nowrap;
67
+ }
68
+ `
69
+ : ""
70
+ }
71
+ ${
72
+ settings.highlightClients
73
+ ? `
74
+ solid-client {
75
+ display: block;
76
+ box-shadow: 0 0 0 1px #3b82f6 !important;
77
+ position: relative;
78
+ }
79
+ solid-client::before {
80
+ content: attr(data-file);
81
+ position: absolute;
82
+ top: -17px;
83
+ left: -1px;
84
+ font-size: 10px;
85
+ font-family: monospace;
86
+ color: black;
87
+ background: #3b82f6;
88
+ padding: 1px 4px;
89
+ white-space: nowrap;
90
+ }
91
+ `
92
+ : ""
93
+ }
94
+ `;
95
+ };
96
+ updateStyles();
97
+
98
+ // ========================================
99
+ // Position logic
100
+ // ========================================
101
+ const positions = {
102
+ tl: {
103
+ badge: { top: "8px", left: "8px", bottom: "", right: "" },
104
+ panel: { top: "32px", left: "8px", bottom: "", right: "" },
105
+ },
106
+ tr: {
107
+ badge: { top: "8px", right: "8px", bottom: "", left: "" },
108
+ panel: { top: "32px", right: "8px", bottom: "", left: "" },
109
+ },
110
+ bl: {
111
+ badge: { bottom: "8px", left: "8px", top: "", right: "" },
112
+ panel: { bottom: "32px", left: "8px", top: "", right: "" },
113
+ },
114
+ br: {
115
+ badge: { bottom: "8px", right: "8px", top: "", left: "" },
116
+ panel: { bottom: "32px", right: "8px", top: "", left: "" },
117
+ },
118
+ };
119
+
120
+ const applyPosition = () => {
121
+ const pos = positions[settings.position] || positions.bl;
122
+ Object.assign(badge.style, pos.badge);
123
+ Object.assign(panel.style, pos.panel);
124
+ };
125
+
126
+ // ========================================
127
+ // UI Elements
128
+ // ========================================
129
+ const panel = document.body.appendChild(document.createElement("div"));
130
+ panel.innerHTML = `
131
+ <div style="margin-bottom:8px;font-weight:bold">SSR Dev Tools</div>
132
+ <label style="display:block;margin:4px 0;cursor:pointer">
133
+ <input type="checkbox" id="_ssr_reload" ${settings.autoReload ? "checked" : ""}>
134
+ Auto reload
135
+ </label>
136
+ <label style="display:block;margin:4px 0;cursor:pointer">
137
+ <input type="checkbox" id="_ssr_islands" ${settings.highlightIslands ? "checked" : ""}>
138
+ Highlight islands (${islandCount})
139
+ </label>
140
+ <label style="display:block;margin:4px 0;cursor:pointer">
141
+ <input type="checkbox" id="_ssr_clients" ${settings.highlightClients ? "checked" : ""}>
142
+ Highlight clients (${clientCount})
143
+ </label>
144
+ <div style="margin-top:8px;border-top:1px solid #333;padding-top:8px">
145
+ <label style="color:#888">Position:
146
+ <select id="_ssr_position" style="background:#222;color:#ccc;border:1px solid #444;padding:2px;margin-left:4px">
147
+ <option value="tl" ${settings.position === "tl" ? "selected" : ""}>Top Left</option>
148
+ <option value="tr" ${settings.position === "tr" ? "selected" : ""}>Top Right</option>
149
+ <option value="bl" ${settings.position === "bl" ? "selected" : ""}>Bottom Left</option>
150
+ <option value="br" ${settings.position === "br" ? "selected" : ""}>Bottom Right</option>
151
+ </select>
152
+ </label>
153
+ </div>
154
+ `;
155
+ Object.assign(panel.style, {
156
+ fontFamily: "monospace",
157
+ fontSize: "12px",
158
+ color: "#ccc",
159
+ background: "#111",
160
+ padding: "12px",
161
+ border: "1px solid #333",
162
+ borderRadius: "4px",
163
+ position: "fixed",
164
+ zIndex: "9999",
165
+ display: "none",
166
+ });
167
+
168
+ // Badge
169
+ const badge = document.body.appendChild(document.createElement("div"));
170
+ badge.innerText = "[ssr]";
171
+ badge.onclick = () => {
172
+ panel.style.display = panel.style.display === "none" ? "block" : "none";
173
+ };
174
+ Object.assign(badge.style, {
175
+ fontFamily: "monospace",
176
+ fontSize: "12px",
177
+ color: "#555",
178
+ position: "fixed",
179
+ zIndex: "9999",
180
+ cursor: "pointer",
181
+ });
182
+
183
+ applyPosition();
184
+
185
+ // ========================================
186
+ // Event handlers
187
+ // ========================================
188
+ panel.querySelector("#_ssr_islands").onchange = (e) => {
189
+ settings.highlightIslands = e.target.checked;
190
+ saveSettings(settings);
191
+ updateStyles();
192
+ };
193
+
194
+ panel.querySelector("#_ssr_clients").onchange = (e) => {
195
+ settings.highlightClients = e.target.checked;
196
+ saveSettings(settings);
197
+ updateStyles();
198
+ };
199
+
200
+ panel.querySelector("#_ssr_position").onchange = (e) => {
201
+ settings.position = e.target.value;
202
+ saveSettings(settings);
203
+ applyPosition();
204
+ };
205
+
206
+ // ========================================
207
+ // Live reload via SSE
208
+ // ========================================
209
+ let es, checkInterval;
210
+
211
+ const startReload = () => {
212
+ if (es) return;
213
+ try {
214
+ es = new EventSource("/_ssr/_reload");
215
+ badge.innerText = "[ssr]";
216
+ } catch {
217
+ return;
218
+ }
219
+ es.onerror = (e) => {
220
+ e.preventDefault();
221
+ stopReload();
222
+ badge.innerText = "[...]";
223
+ if (!settings.autoReload) return;
224
+ checkInterval = setInterval(() => {
225
+ fetch("/_ssr/_ping")
226
+ .then(({ ok }) => ok && location.reload())
227
+ .catch(() => {});
228
+ }, 300);
229
+ };
230
+ };
231
+
232
+ const stopReload = () => {
233
+ if (es) {
234
+ es.close();
235
+ es = null;
236
+ }
237
+ if (checkInterval) {
238
+ clearInterval(checkInterval);
239
+ checkInterval = null;
240
+ }
241
+ };
242
+
243
+ if (settings.autoReload) startReload();
244
+
245
+ panel.querySelector("#_ssr_reload").onchange = (e) => {
246
+ settings.autoReload = e.target.checked;
247
+ saveSettings(settings);
248
+ settings.autoReload ? startReload() : stopReload();
249
+ };
250
+
251
+ window.addEventListener("pagehide", stopReload);
252
+ })();
81
253
  }
@@ -23,7 +23,7 @@ import {
23
23
  * ```
24
24
  */
25
25
  export const routes = (config: SsrConfig) => {
26
- const { dev, autoRefresh } = config;
26
+ const { dev } = config;
27
27
  const ssrDir = getSsrDir(dev);
28
28
 
29
29
  return new Elysia({ name: "ssr" })
@@ -34,13 +34,7 @@ export const routes = (config: SsrConfig) => {
34
34
  headers: { "Cache-Control": getCacheHeaders(dev) },
35
35
  }),
36
36
  )
37
- .get("/_ssr/_reload", () =>
38
- dev && autoRefresh ? createReloadResponse() : notFound(),
39
- )
40
- .get("/_ssr/_ping", () =>
41
- dev && autoRefresh ? new Response("ok") : notFound(),
42
- )
43
- .get("/_ssr/_client.js", () =>
44
- dev && autoRefresh ? createClientResponse() : notFound(),
45
- );
37
+ .get("/_ssr/_reload", () => (dev ? createReloadResponse() : notFound()))
38
+ .get("/_ssr/_ping", () => (dev ? new Response("ok") : notFound()))
39
+ .get("/_ssr/_client.js", () => (dev ? createClientResponse() : notFound()));
46
40
  };
@@ -21,13 +21,13 @@ import {
21
21
  * ```
22
22
  */
23
23
  export const routes = (config: SsrConfig) => {
24
- const { dev, autoRefresh } = config;
24
+ const { dev } = config;
25
25
  const ssrDir = getSsrDir(dev);
26
26
 
27
27
  const app = new Hono();
28
28
 
29
29
  // Dev mode endpoints
30
- if (dev && autoRefresh) {
30
+ if (dev) {
31
31
  app.get("/_client.js", () => createClientResponse());
32
32
  app.get("/_reload", () => createReloadResponse());
33
33
  app.get("/_ping", (c) => c.text("ok"));
package/src/index.ts CHANGED
@@ -21,8 +21,6 @@ export type SsrOptions<T extends object = object> = {
21
21
  dev?: boolean;
22
22
  /** Enable verbose logging (default: true in prod, false in dev) */
23
23
  verbose?: boolean;
24
- /** Enable auto page refresh in dev mode (default: true) */
25
- autoRefresh?: boolean;
26
24
  /** HTML template function (optional, has default) */
27
25
  template?: (
28
26
  ctx: {
@@ -35,7 +33,6 @@ export type SsrOptions<T extends object = object> = {
35
33
  export type SsrConfig = {
36
34
  dev: boolean;
37
35
  verbose?: boolean;
38
- autoRefresh: boolean;
39
36
  };
40
37
 
41
38
  type HtmlFn<T extends object> = (
@@ -85,7 +82,7 @@ export type SsrResult<T extends object> = {
85
82
  export const createConfig = <T extends object = object>(
86
83
  options: SsrOptions<T> = {},
87
84
  ): SsrResult<T> => {
88
- const { dev = false, verbose, autoRefresh = true, template } = options;
85
+ const { dev = false, verbose, template } = options;
89
86
 
90
87
  // Default template if none provided
91
88
  const htmlTemplate =
@@ -108,7 +105,6 @@ export const createConfig = <T extends object = object>(
108
105
  const config: SsrConfig = {
109
106
  dev,
110
107
  verbose,
111
- autoRefresh,
112
108
  };
113
109
 
114
110
  // HTML renderer
@@ -116,21 +112,24 @@ export const createConfig = <T extends object = object>(
116
112
  const body = renderToString(() => element);
117
113
 
118
114
  // Extract island and client component IDs from rendered HTML
119
- const islandIds = [
120
- ...new Set(
121
- [...body.matchAll(/<solid-(island|client) data-id="([^"]+)"/g)].map(
122
- (m) => m[2],
123
- ),
124
- ),
115
+ const matches = [
116
+ ...body.matchAll(/<solid-(island|client) data-id="([^"]+)"/g),
125
117
  ];
118
+ const islands = [
119
+ ...new Set(matches.filter((m) => m[1] === "island").map((m) => m[2])),
120
+ ];
121
+ const clients = [
122
+ ...new Set(matches.filter((m) => m[1] === "client").map((m) => m[2])),
123
+ ];
124
+ const islandIds = [...islands, ...clients];
126
125
 
127
126
  // Component scripts
128
127
  let scripts = islandIds
129
128
  .map((id) => `<script type="module" src="/_ssr/${id}.js"></script>`)
130
129
  .join("\n");
131
130
 
132
- // Add dev reload script in dev mode (if autoRefresh enabled)
133
- if (dev && autoRefresh) {
131
+ // Add dev tools script in dev mode
132
+ if (dev) {
134
133
  scripts += `\n<script type="module" src="/_ssr/_client.js"></script>`;
135
134
  }
136
135
 
@@ -186,7 +185,7 @@ export const createConfig = <T extends object = object>(
186
185
  // Issue: https://github.com/oven-sh/bun/issues/4689
187
186
  const contents = await import(`${path}?`, { with: { type: "text" } });
188
187
  return {
189
- contents: await transform(contents.default, path, "ssr"),
188
+ contents: await transform(contents.default, path, "ssr", dev),
190
189
  loader: "js",
191
190
  };
192
191
  });
package/src/transform.ts CHANGED
@@ -35,7 +35,7 @@ const attr = (name: string, value: any) =>
35
35
 
36
36
  type ComponentType = "island" | "client";
37
37
 
38
- const componentWrapperPlugin = (filename: string) => ({
38
+ const componentWrapperPlugin = (filename: string, dev: boolean) => ({
39
39
  visitor: {
40
40
  Program(programPath: any) {
41
41
  const componentImports = new Map<
@@ -109,17 +109,23 @@ const componentWrapperPlugin = (filename: string) => ({
109
109
  // For islands: wrap the component, for client: empty wrapper (no SSR)
110
110
  const children = component.type === "island" ? [path.node] : [];
111
111
 
112
- const wrapper = jsx(
113
- wrapperTag,
114
- [
115
- attr("data-id", id),
116
- attr(
117
- "data-props",
118
- t.callExpression(t.identifier("__seroval_serialize"), [props]),
119
- ),
120
- ],
121
- children,
122
- );
112
+ // Extract filename from path (e.g., "Counter.island.tsx")
113
+ const file = component.path.split("/").pop() || "";
114
+
115
+ const attrs = [
116
+ attr("data-id", id),
117
+ attr(
118
+ "data-props",
119
+ t.callExpression(t.identifier("__seroval_serialize"), [props]),
120
+ ),
121
+ ];
122
+
123
+ // Add file attribute in dev mode
124
+ if (dev) {
125
+ attrs.push(attr("data-file", file));
126
+ }
127
+
128
+ const wrapper = jsx(wrapperTag, attrs, children);
123
129
 
124
130
  path.replaceWith(wrapper);
125
131
  path.skip();
@@ -137,6 +143,7 @@ export const transform = async (
137
143
  source: string,
138
144
  filename: string,
139
145
  mode: "ssr" | "dom",
146
+ dev: boolean = false,
140
147
  ): Promise<string> => {
141
148
  let code = source;
142
149
 
@@ -144,7 +151,7 @@ export const transform = async (
144
151
  const result = await transformAsync(code, {
145
152
  filename,
146
153
  parserOpts: { plugins: ["jsx", "typescript"] },
147
- plugins: [() => componentWrapperPlugin(filename)],
154
+ plugins: [() => componentWrapperPlugin(filename, dev)],
148
155
  });
149
156
  code = result?.code || code;
150
157
  }