blumenjs 0.1.6 → 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.
@@ -11,9 +11,14 @@ import (
11
11
  )
12
12
 
13
13
  const (
14
- nodeSSRURL = "http://localhost:4000/render"
14
+ nodeSSRURL = "http://localhost:4000/render"
15
+ nodeDataURL = "http://localhost:4000/data"
15
16
  )
16
17
 
18
+ // Global page cache — stores rendered HTML in Go memory for near-instant responses.
19
+ // Max 500 entries with LRU eviction. A single Go server can cache the entire site.
20
+ var pageCache = NewPageCache(500)
21
+
17
22
  // SSRResponse from Node service
18
23
  type SSRResponse struct {
19
24
  HTML string `json:"html"`
@@ -28,6 +33,21 @@ type SSRRequest struct {
28
33
  Data map[string]interface{} `json:"data,omitempty"`
29
34
  }
30
35
 
36
+ // DataRequest to Node /data endpoint
37
+ type DataRequest struct {
38
+ Path string `json:"path"`
39
+ Query map[string][]string `json:"query"`
40
+ Params map[string]interface{} `json:"params"`
41
+ Headers map[string]string `json:"headers,omitempty"`
42
+ }
43
+
44
+ // DataResponse from Node /data endpoint
45
+ type DataResponse struct {
46
+ Props map[string]interface{} `json:"props"`
47
+ HasServerProps bool `json:"hasServerProps"`
48
+ Revalidate int `json:"revalidate"` // TTL in seconds
49
+ }
50
+
31
51
  var httpClient = &http.Client{
32
52
  Timeout: 10 * time.Second,
33
53
  }
@@ -35,8 +55,20 @@ var httpClient = &http.Client{
35
55
  func main() {
36
56
  mux := http.NewServeMux()
37
57
 
38
- // Static files are served directly — must be registered first
39
- mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
58
+ // Static files with immutable cache headers
59
+ staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static")))
60
+ mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
61
+ // Set aggressive cache headers for static assets
62
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
63
+ w.Header().Set("X-Blumen-Cache", "STATIC")
64
+ staticHandler.ServeHTTP(w, r)
65
+ })
66
+
67
+ // Image optimization endpoint - processes and caches images on the fly
68
+ mux.HandleFunc("/_blumen/image", ImageHandler())
69
+
70
+ // Cache management endpoint (for development/debugging)
71
+ mux.HandleFunc("/_blumen/cache", cacheStatusHandler)
40
72
 
41
73
  // Specific routes with Go loaders for data fetching
42
74
  mux.HandleFunc("/dashboard/settings", PageHandler(func(r *http.Request) (map[string]interface{}, error) {
@@ -46,16 +78,9 @@ func main() {
46
78
  }, nil
47
79
  }))
48
80
 
49
- mux.HandleFunc("/users/{id}", PageHandler(func(r *http.Request) (map[string]interface{}, error) {
50
- id := r.PathValue("id")
51
- return map[string]interface{}{
52
- "userId": id,
53
- "userProfile": "Profile data for user " + id + " fetched from Go db",
54
- }, nil
55
- }))
56
-
57
81
  // Catch-all: forward every other request to the Node SSR server.
58
- // This decouples Go from knowing about specific React page paths.
82
+ // If the page exports getServerProps, Node will fetch the data first.
83
+ // If a Go DataLoader is registered above, it takes priority.
59
84
  mux.HandleFunc("/", PageHandler(nil))
60
85
 
61
86
  startPort := 3000
@@ -73,6 +98,7 @@ func main() {
73
98
  }
74
99
 
75
100
  log.Printf("Go server starting on http://localhost:%d", startPort)
101
+ log.Printf("Page cache initialized (max %d entries)", 500)
76
102
  if err := http.Serve(listener, mux); err != nil {
77
103
  log.Fatalf("Server error: %v", err)
78
104
  }
@@ -87,33 +113,114 @@ func PageHandler(loader DataLoader) http.HandlerFunc {
87
113
  return
88
114
  }
89
115
 
90
- var props map[string]interface{}
91
- var err error
92
- if loader != nil {
93
- props, err = loader(r)
94
- if err != nil {
95
- log.Printf("Loader error: %v", err)
96
- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
116
+ // Generate cache key from the full URL (path + query string)
117
+ cacheKey := r.URL.RequestURI()
118
+
119
+ // Check the page cache first
120
+ if entry, found, stale := pageCache.Get(cacheKey); found {
121
+ // Handle ETag conditional request
122
+ if match := r.Header.Get("If-None-Match"); match == entry.ETag {
123
+ w.Header().Set("X-Blumen-Cache", "HIT")
124
+ w.WriteHeader(http.StatusNotModified)
97
125
  return
98
126
  }
99
- }
100
127
 
101
- // If this is a SPA data fetch request, return JSON props
102
- if r.Header.Get("X-Blumen-Data") == "1" || r.URL.Query().Get("_data") == "1" {
103
- w.Header().Set("Content-Type", "application/json")
104
- if props == nil {
105
- props = make(map[string]interface{})
128
+ if !stale {
129
+ // CACHE HIT: serve directly from Go memory (sub-millisecond)
130
+ serveCachedPage(w, entry, "HIT")
131
+ return
106
132
  }
107
- json.NewEncoder(w).Encode(props)
133
+
134
+ // STALE: serve stale content immediately, revalidate in background
135
+ serveCachedPage(w, entry, "STALE")
136
+
137
+ // Background revalidation (fire and forget)
138
+ go func() {
139
+ html, revalidate := renderPage(r, loader)
140
+ if html != "" {
141
+ pageCache.Set(cacheKey, html, revalidate)
142
+ }
143
+ }()
144
+ return
145
+ }
146
+
147
+ // CACHE MISS: render the page and cache the result
148
+ html, revalidate := renderPage(r, loader)
149
+ if html == "" {
150
+ // renderPage already wrote the error response
108
151
  return
109
152
  }
110
153
 
111
- handleRouteWithProps(w, r, props)
154
+ // Cache the rendered page
155
+ pageCache.Set(cacheKey, html, revalidate)
156
+
157
+ // Serve the fresh response
158
+ entry, _, _ := pageCache.Get(cacheKey)
159
+ if entry != nil {
160
+ serveCachedPage(w, entry, "MISS")
161
+ } else {
162
+ // Fallback: serve directly without cache metadata
163
+ writeHTMLResponse(w, html, "MISS", "")
164
+ }
165
+ }
166
+ }
167
+
168
+ // serveCachedPage writes a cached page response with proper headers.
169
+ func serveCachedPage(w http.ResponseWriter, entry *CacheEntry, cacheStatus string) {
170
+ writeHTMLResponse(w, entry.HTML, cacheStatus, entry.ETag)
171
+ }
172
+
173
+ // writeHTMLResponse writes an HTML response with cache and security headers.
174
+ func writeHTMLResponse(w http.ResponseWriter, html string, cacheStatus string, etag string) {
175
+ // Security headers
176
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
177
+ w.Header().Set("X-Content-Type-Options", "nosniff")
178
+ w.Header().Set("X-Frame-Options", "DENY")
179
+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
180
+
181
+ // Cache headers
182
+ w.Header().Set("X-Blumen-Cache", cacheStatus)
183
+ if etag != "" {
184
+ w.Header().Set("ETag", etag)
112
185
  }
186
+ // Tell browsers and CDNs they can cache for 10s, serve stale for 60s while revalidating
187
+ w.Header().Set("Cache-Control", "public, s-maxage=10, stale-while-revalidate=60")
188
+
189
+ w.WriteHeader(http.StatusOK)
190
+ w.Write([]byte(html))
113
191
  }
114
192
 
115
- func handleRouteWithProps(w http.ResponseWriter, r *http.Request, props map[string]interface{}) {
116
- // Prepare SSR request
193
+ // renderPage performs the full SSR pipeline: fetch data + render HTML.
194
+ // Returns the HTML string and the revalidation TTL in seconds.
195
+ func renderPage(r *http.Request, loader DataLoader) (string, int) {
196
+ var props map[string]interface{}
197
+ var err error
198
+ revalidate := 0
199
+
200
+ if loader != nil {
201
+ // Use Go DataLoader (takes priority over getServerProps)
202
+ props, err = loader(r)
203
+ if err != nil {
204
+ log.Printf("Loader error: %v", err)
205
+ return "", 0
206
+ }
207
+ } else {
208
+ // No Go DataLoader - check if the page has getServerProps
209
+ tsProps, tsRevalidate, tsErr := fetchServerProps(r)
210
+ if tsErr != nil {
211
+ log.Printf("getServerProps error: %v", tsErr)
212
+ // Non-fatal: render the page without server props
213
+ } else if tsProps != nil {
214
+ props = tsProps
215
+ revalidate = tsRevalidate
216
+ }
217
+ }
218
+
219
+ // If this is a SPA data fetch request, return JSON props (don't cache)
220
+ // This check shouldn't happen in renderPage since it's called from the handler,
221
+ // but the handler already handles this case before reaching here.
222
+
223
+ // Call Node SSR to render the HTML
117
224
  ssrReq := SSRRequest{
118
225
  Path: r.URL.Path,
119
226
  Query: r.URL.Query(),
@@ -124,24 +231,64 @@ func handleRouteWithProps(w http.ResponseWriter, r *http.Request, props map[stri
124
231
  Data: props,
125
232
  }
126
233
 
127
- // Call Node SSR service
128
234
  ssrResp, err := callNodeSSR(ssrReq)
129
235
  if err != nil {
130
236
  log.Printf("SSR error: %v", err)
131
- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
132
- return
237
+ return "", 0
133
238
  }
134
239
 
240
+ return ssrResp.HTML, revalidate
241
+ }
135
242
 
243
+ // fetchServerProps calls the Node SSR /data endpoint to run getServerProps.
244
+ // Returns nil if the page doesn't export getServerProps.
245
+ // Also returns the revalidate TTL (0 = no caching from getServerProps).
246
+ func fetchServerProps(r *http.Request) (map[string]interface{}, int, error) {
247
+ // Build a subset of headers to forward
248
+ headers := make(map[string]string)
249
+ for _, key := range []string{"Authorization", "Cookie", "Accept-Language", "User-Agent"} {
250
+ if val := r.Header.Get(key); val != "" {
251
+ headers[key] = val
252
+ }
253
+ }
136
254
 
137
- // Security headers
138
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
139
- w.Header().Set("X-Content-Type-Options", "nosniff")
140
- w.Header().Set("X-Frame-Options", "DENY")
141
- w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
255
+ dataReq := DataRequest{
256
+ Path: r.URL.Path,
257
+ Query: r.URL.Query(),
258
+ Params: map[string]interface{}{},
259
+ Headers: headers,
260
+ }
142
261
 
143
- w.WriteHeader(http.StatusOK)
144
- w.Write([]byte(ssrResp.HTML))
262
+ reqBody, err := json.Marshal(dataReq)
263
+ if err != nil {
264
+ return nil, 0, fmt.Errorf("marshal data request: %w", err)
265
+ }
266
+
267
+ resp, err := httpClient.Post(
268
+ nodeDataURL,
269
+ "application/json",
270
+ bytes.NewReader(reqBody),
271
+ )
272
+ if err != nil {
273
+ return nil, 0, fmt.Errorf("http post /data: %w", err)
274
+ }
275
+ defer resp.Body.Close()
276
+
277
+ if resp.StatusCode != http.StatusOK {
278
+ return nil, 0, fmt.Errorf("node /data returned %d", resp.StatusCode)
279
+ }
280
+
281
+ var dataResp DataResponse
282
+ if err := json.NewDecoder(resp.Body).Decode(&dataResp); err != nil {
283
+ return nil, 0, fmt.Errorf("decode data response: %w", err)
284
+ }
285
+
286
+ if !dataResp.HasServerProps {
287
+ // Page doesn't export getServerProps, skip
288
+ return nil, 0, nil
289
+ }
290
+
291
+ return dataResp.Props, dataResp.Revalidate, nil
145
292
  }
146
293
 
147
294
  func callNodeSSR(req SSRRequest) (*SSRResponse, error) {
@@ -172,4 +319,25 @@ func callNodeSSR(req SSRRequest) (*SSRResponse, error) {
172
319
  return &ssrResp, nil
173
320
  }
174
321
 
175
-
322
+ // cacheStatusHandler provides cache introspection for debugging.
323
+ // GET /_blumen/cache — returns current cache size and status
324
+ // DELETE /_blumen/cache — clears the entire cache
325
+ func cacheStatusHandler(w http.ResponseWriter, r *http.Request) {
326
+ switch r.Method {
327
+ case http.MethodGet:
328
+ w.Header().Set("Content-Type", "application/json")
329
+ json.NewEncoder(w).Encode(map[string]interface{}{
330
+ "size": pageCache.Size(),
331
+ "maxSize": 500,
332
+ "status": "active",
333
+ })
334
+ case http.MethodDelete:
335
+ pageCache.Clear()
336
+ w.Header().Set("Content-Type", "application/json")
337
+ json.NewEncoder(w).Encode(map[string]interface{}{
338
+ "message": "Cache cleared",
339
+ })
340
+ default:
341
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
342
+ }
343
+ }
@@ -3,7 +3,7 @@ import { renderToString } from "react-dom/server";
3
3
  import React from "react";
4
4
 
5
5
  // Auto-generated route map (run `npm run routes` to regenerate)
6
- import { routes, App, Document } from "./generated-routes";
6
+ import { routes, App, Document, serverPropsMap } from "./generated-routes";
7
7
  import { matchRoute } from "../app/shared/router";
8
8
  import NotFoundPage from "../app/pages/NotFound";
9
9
 
@@ -21,6 +21,19 @@ interface SSRResponse {
21
21
  props: Record<string, any>;
22
22
  }
23
23
 
24
+ interface DataRequest {
25
+ path: string;
26
+ query: Record<string, string[]>;
27
+ params: Record<string, any>;
28
+ headers?: Record<string, string>;
29
+ }
30
+
31
+ interface DataResponse {
32
+ props: Record<string, any>;
33
+ hasServerProps: boolean;
34
+ revalidate: number; // TTL in seconds; 0 = no caching
35
+ }
36
+
24
37
  const PORT = process.env.PORT || 4000;
25
38
 
26
39
  const server = http.createServer(async (req, res) => {
@@ -36,12 +49,97 @@ const server = http.createServer(async (req, res) => {
36
49
  return;
37
50
  }
38
51
 
39
- if (req.method !== "POST" || req.url !== "/render") {
52
+ if (req.method !== "POST") {
40
53
  res.writeHead(404);
41
54
  res.end(JSON.stringify({ error: "Not Found" }));
42
55
  return;
43
56
  }
44
57
 
58
+ // Route to the correct handler
59
+ if (req.url === "/data") {
60
+ return handleData(req, res);
61
+ } else if (req.url === "/render") {
62
+ return handleRender(req, res);
63
+ } else {
64
+ res.writeHead(404);
65
+ res.end(JSON.stringify({ error: "Not Found" }));
66
+ }
67
+ });
68
+
69
+ /**
70
+ * POST /data - Execute getServerProps for a matched route.
71
+ *
72
+ * Called by the Go server before /render to fetch TypeScript-defined
73
+ * server data. If the page doesn't export getServerProps, returns
74
+ * { hasServerProps: false } and Go skips the data merge.
75
+ */
76
+ async function handleData(req: http.IncomingMessage, res: http.ServerResponse) {
77
+ try {
78
+ const body = await readBody(req);
79
+ const dataReq: DataRequest = JSON.parse(body);
80
+
81
+ // Match the route to find the page
82
+ const match = matchRoute(dataReq.path, routes);
83
+
84
+ if (!match) {
85
+ res.writeHead(200);
86
+ res.end(JSON.stringify({ props: {}, hasServerProps: false }));
87
+ return;
88
+ }
89
+
90
+ // Find the route definition to look up in serverPropsMap
91
+ const routeDef = routes.find(r => r.component === match.component);
92
+ const getServerProps = routeDef ? serverPropsMap[routeDef.path] : undefined;
93
+
94
+ if (!getServerProps) {
95
+ // This page doesn't export getServerProps
96
+ res.writeHead(200);
97
+ res.end(JSON.stringify({ props: {}, hasServerProps: false }));
98
+ return;
99
+ }
100
+
101
+ // Build the BlumenContext
102
+ const ctx = {
103
+ params: { ...(dataReq.params || {}), ...match.params },
104
+ query: dataReq.query || {},
105
+ path: dataReq.path,
106
+ headers: dataReq.headers || {},
107
+ };
108
+
109
+ // Run getServerProps
110
+ const result = await getServerProps(ctx);
111
+
112
+ const dataResp: DataResponse = {
113
+ props: result?.props || {},
114
+ hasServerProps: true,
115
+ revalidate: result?.revalidate || 0,
116
+ };
117
+
118
+ res.writeHead(200);
119
+ res.end(JSON.stringify(dataResp));
120
+ } catch (error) {
121
+ console.error("getServerProps Error:", error);
122
+ res.writeHead(500);
123
+ res.end(
124
+ JSON.stringify({
125
+ error: "getServerProps failed",
126
+ message:
127
+ process.env.NODE_ENV === "development" ? String(error) : undefined,
128
+ }),
129
+ );
130
+ }
131
+ }
132
+
133
+ /**
134
+ * POST /render - Render the matched page to HTML.
135
+ *
136
+ * This is the existing SSR endpoint. It receives props (possibly
137
+ * enriched by /data) and renders the React component tree to HTML.
138
+ */
139
+ async function handleRender(
140
+ req: http.IncomingMessage,
141
+ res: http.ServerResponse,
142
+ ) {
45
143
  try {
46
144
  // Parse request body
47
145
  const body = await readBody(req);
@@ -57,7 +155,11 @@ const server = http.createServer(async (req, res) => {
57
155
  status: 404,
58
156
  serverRendered: true,
59
157
  };
60
- const element = React.createElement(NotFound, props);
158
+ const element = React.createElement(
159
+ "div",
160
+ { className: "page-transition page-transition-active" },
161
+ React.createElement(NotFound, props),
162
+ );
61
163
  const html = renderToString(element);
62
164
 
63
165
  res.writeHead(200);
@@ -76,10 +178,16 @@ const server = http.createServer(async (req, res) => {
76
178
  };
77
179
 
78
180
  // Render React to HTML
79
- const appElement = React.createElement(App, {
80
- Component: match.component,
81
- pageProps: props,
82
- });
181
+ // Wrap in the same page-transition div that the client-side
182
+ // RouterProvider renders, so hydration trees match exactly.
183
+ const appElement = React.createElement(
184
+ "div",
185
+ { className: "page-transition page-transition-active" },
186
+ React.createElement(App, {
187
+ Component: match.component,
188
+ pageProps: props,
189
+ }),
190
+ );
83
191
 
84
192
  const documentElement = React.createElement(Document, {
85
193
  initialProps: props,
@@ -106,7 +214,7 @@ const server = http.createServer(async (req, res) => {
106
214
  }),
107
215
  );
108
216
  }
109
- });
217
+ }
110
218
 
111
219
  function readBody(req: http.IncomingMessage): Promise<string> {
112
220
  return new Promise((resolve, reject) => {
@@ -123,6 +231,10 @@ function readBody(req: http.IncomingMessage): Promise<string> {
123
231
 
124
232
  server.listen(PORT, () => {
125
233
  console.log(`Node SSR server running on http://localhost:${PORT}`);
234
+ const gspCount = Object.keys(serverPropsMap).length;
235
+ if (gspCount > 0) {
236
+ console.log(` ${gspCount} page(s) with getServerProps detected`);
237
+ }
126
238
  });
127
239
 
128
240
  // Handle graceful shutdown
@@ -40,6 +40,8 @@ interface RouteEntry {
40
40
  patternStr: string;
41
41
  /** Extracted parameter keys */
42
42
  keys: string[];
43
+ /** Whether this page exports a getServerProps function */
44
+ hasGetServerProps: boolean;
43
45
  }
44
46
 
45
47
  function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
@@ -87,12 +89,17 @@ function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
87
89
  patternStr = `^${patternStr}$`;
88
90
  }
89
91
 
92
+ // Check if the page exports getServerProps
93
+ const fileContent = fs.readFileSync(fullPath, "utf-8");
94
+ const hasGetServerProps = /export\s+(async\s+)?function\s+getServerProps/.test(fileContent);
95
+
90
96
  results.push({
91
97
  route: routePath,
92
98
  componentId,
93
99
  importPath: relPath.replace(".tsx", "").replace(/\\/g, "/"),
94
100
  patternStr,
95
- keys
101
+ keys,
102
+ hasGetServerProps
96
103
  });
97
104
  }
98
105
  }
@@ -139,6 +146,18 @@ function generateRouteFile(
139
146
  `import ${r.componentId} from "${importPathPrefix}/${r.importPath}";`,
140
147
  );
141
148
 
149
+ // For SSR: import getServerProps from pages that export it
150
+ const gspImports: string[] = [];
151
+ const gspRoutes = routes.filter(r => r.hasGetServerProps);
152
+ if (isServer && gspRoutes.length > 0) {
153
+ for (const r of gspRoutes) {
154
+ const gspId = "gsp_" + r.componentId.replace("Page_", "");
155
+ gspImports.push(
156
+ `import { getServerProps as ${gspId} } from "${importPathPrefix}/${r.importPath}";`,
157
+ );
158
+ }
159
+ }
160
+
142
161
  const hasApp = fs.existsSync(path.join(PAGES_DIR, "_app.tsx"));
143
162
  const hasDoc = fs.existsSync(path.join(PAGES_DIR, "_document.tsx"));
144
163
 
@@ -163,6 +182,25 @@ function generateRouteFile(
163
182
  \t}`;
164
183
  });
165
184
 
185
+ // Generate serverPropsMap for SSR routes
186
+ let serverPropsMapStr = "";
187
+ if (isServer && gspRoutes.length > 0) {
188
+ const entries = gspRoutes.map(r => {
189
+ const gspId = "gsp_" + r.componentId.replace("Page_", "");
190
+ return `\t"${r.route}": ${gspId},`;
191
+ });
192
+ serverPropsMapStr = [
193
+ "",
194
+ "// Map of routes that export getServerProps",
195
+ "// Used by the SSR server to run data fetching before rendering",
196
+ "export const serverPropsMap: Record<string, Function> = {",
197
+ ...entries,
198
+ "};",
199
+ ].join("\n");
200
+ } else if (isServer) {
201
+ serverPropsMapStr = "\nexport const serverPropsMap: Record<string, Function> = {};";
202
+ }
203
+
166
204
  const map = [
167
205
  "",
168
206
  "export interface RouteDef {",
@@ -178,10 +216,11 @@ function generateRouteFile(
178
216
  "",
179
217
  appImport,
180
218
  docImport,
219
+ serverPropsMapStr,
181
220
  "",
182
221
  ];
183
222
 
184
- return [...header, ...imports, ...map].join("\n");
223
+ return [...header, ...imports, ...gspImports, ...map].join("\n");
185
224
  }
186
225
 
187
226
  function main() {
@@ -197,7 +236,8 @@ function main() {
197
236
 
198
237
  console.log(` Found ${routes.length} route(s):`);
199
238
  for (const r of routes) {
200
- console.log(` ${r.route.padEnd(25)} ${r.importPath}.tsx`);
239
+ const gspTag = r.hasGetServerProps ? " [getServerProps]" : "";
240
+ console.log(` ${r.route.padEnd(25)} → ${r.importPath}.tsx${gspTag}`);
201
241
  }
202
242
 
203
243
  // Generate SSR routes (node-ssr/ → ../app/pages)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blumenjs",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "The React framework powered by Go. Lightning-fast SSR with the DX you deserve.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,9 +26,9 @@
26
26
  "dev:legacy": "npm run routes && concurrently \"npm run dev:client\" \"npm run dev:ssr\" \"npm run dev:go\"",
27
27
  "dev:client": "webpack serve --mode development",
28
28
  "dev:ssr": "NODE_ENV=development tsx watch node-ssr/server.ts",
29
- "dev:go": "go run go-server/main.go",
29
+ "dev:go": "go run go-server/main.go go-server/image.go go-server/cache.go",
30
30
  "build:client": "webpack --mode production",
31
- "build:ssr": "esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --external:react --external:react-dom",
31
+ "build:ssr": "esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external",
32
32
  "clean": "rm -rf dist static/js/bundle.js"
33
33
  },
34
34
  "keywords": [
@@ -64,12 +64,16 @@
64
64
  "@types/react-dom": "^18.2.0",
65
65
  "babel-loader": "^10.1.1",
66
66
  "concurrently": "^8.2.2",
67
+ "css-loader": "^7.1.4",
67
68
  "esbuild": "^0.19.0",
69
+ "mini-css-extract-plugin": "^2.10.2",
68
70
  "react-refresh": "^0.18.0",
71
+ "style-loader": "^4.0.0",
69
72
  "ts-loader": "^9.5.1",
70
73
  "tsx": "^4.6.0",
71
74
  "typescript": "^5.3.0",
72
75
  "webpack": "^5.89.0",
76
+ "webpack-bundle-analyzer": "^5.3.0",
73
77
  "webpack-cli": "^5.1.4",
74
78
  "webpack-dev-server": "^5.2.3"
75
79
  },