nukejs 0.0.13 → 0.0.15

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
@@ -17,6 +17,7 @@ npm create nuke@latest
17
17
  - [Pages & Routing](#pages--routing)
18
18
  - [Layouts](#layouts)
19
19
  - [Client Components](#client-components)
20
+ - [State Management](#state-management)
20
21
  - [API Routes](#api-routes)
21
22
  - [Middleware](#middleware)
22
23
  - [Static Files](#static-files)
@@ -292,7 +293,142 @@ Children and other React elements can be passed as props — NukeJS serializes t
292
293
 
293
294
  ---
294
295
 
295
- ## API Routes
296
+ ## State Management
297
+
298
+ NukeJS ships a lightweight built-in store for sharing state across client components. Because each `"use client"` component is hydrated into its own independent React root, React Context cannot cross component boundaries — the store solves this.
299
+
300
+ All store state lives in `window.__nukeStores`, so it is shared across every bundle on the page regardless of how many times a store module is evaluated.
301
+
302
+ ### createStore
303
+
304
+ ```ts
305
+ import { createStore } from 'nukejs';
306
+
307
+ const counterStore = createStore('counter', { count: 0 });
308
+ ```
309
+
310
+ The first argument is a unique name used to key the store in the global registry. The second is the initial state. If two bundles call `createStore` with the same name, the first one wins and subsequent calls reuse the existing entry.
311
+
312
+ ### useStore
313
+
314
+ ```tsx
315
+ "use client"
316
+ import { useStore } from 'nukejs';
317
+ import { counterStore } from '../stores/counter';
318
+
319
+ export default function Counter() {
320
+ const { count } = useStore(counterStore);
321
+ return (
322
+ <div>
323
+ <p>{count}</p>
324
+ <button onClick={() => counterStore.setState(s => ({ count: s.count + 1 }))}>
325
+ Increment
326
+ </button>
327
+ </div>
328
+ );
329
+ }
330
+ ```
331
+
332
+ Pass an optional selector to avoid re-renders when unrelated parts of state change:
333
+
334
+ ```tsx
335
+ // Only re-renders when `count` changes, not on any other state update
336
+ const count = useStore(counterStore, s => s.count);
337
+ ```
338
+
339
+ ### Sharing state across components
340
+
341
+ Stores shine when two completely separate client components need to stay in sync. Define the store in its own file (no `"use client"` needed) and import it from both:
342
+
343
+ ```ts
344
+ // app/stores/cart.ts
345
+ import { createStore } from 'nukejs';
346
+
347
+ export type CartItem = { id: string; name: string; price: number };
348
+
349
+ export const cartStore = createStore('cart', {
350
+ items: [] as CartItem[],
351
+ total: 0,
352
+ });
353
+ ```
354
+
355
+ ```tsx
356
+ // app/components/AddToCartButton.tsx
357
+ "use client"
358
+ import { cartStore, type CartItem } from '../stores/cart';
359
+
360
+ export default function AddToCartButton({ item }: { item: CartItem }) {
361
+ return (
362
+ <button onClick={() =>
363
+ cartStore.setState(s => ({
364
+ items: [...s.items, item],
365
+ total: s.total + item.price,
366
+ }))
367
+ }>
368
+ Add to cart
369
+ </button>
370
+ );
371
+ }
372
+ ```
373
+
374
+ ```tsx
375
+ // app/components/CartIcon.tsx
376
+ "use client"
377
+ import { useStore } from 'nukejs';
378
+ import { cartStore } from '../stores/cart';
379
+
380
+ export default function CartIcon() {
381
+ const { items, total } = useStore(cartStore);
382
+ return (
383
+ <span>
384
+ 🛒 {items.length} items — ${total.toFixed(2)}
385
+ </span>
386
+ );
387
+ }
388
+ ```
389
+
390
+ ```tsx
391
+ // app/pages/shop.tsx — server component, no JS cost
392
+ import AddToCartButton from '../components/AddToCartButton';
393
+ import CartIcon from '../components/CartIcon';
394
+
395
+ export default function ShopPage() {
396
+ const item = { id: '1', name: 'Widget', price: 9.99 };
397
+ return (
398
+ <div>
399
+ <CartIcon />
400
+ <AddToCartButton item={item} />
401
+ </div>
402
+ );
403
+ }
404
+ ```
405
+
406
+ `CartIcon` updates instantly when the button is clicked — they are separate React roots with separate bundles, but they write and read through the same `'cart'` entry in `window.__nukeStores`.
407
+
408
+ ### setState
409
+
410
+ Accepts a full replacement value or an updater function:
411
+
412
+ ```ts
413
+ // Replace
414
+ cartStore.setState({ items: [], total: 0 });
415
+
416
+ // Updater — receives current state, returns next state
417
+ cartStore.setState(s => ({ ...s, total: s.total + 5 }));
418
+ ```
419
+
420
+ ### API reference
421
+
422
+ | | Description |
423
+ |---|---|
424
+ | `createStore(name, initialState)` | Creates or retrieves a named store |
425
+ | `useStore(store)` | Subscribes to the full state |
426
+ | `useStore(store, selector)` | Subscribes to a derived slice (re-renders only when slice changes) |
427
+ | `store.getState()` | Returns the current state snapshot |
428
+ | `store.setState(updater)` | Updates state and notifies all subscribers |
429
+ | `store.subscribe(listener)` | Registers a listener; returns an unsubscribe function |
430
+
431
+
296
432
 
297
433
  Export named HTTP method handlers from `.ts` files in your `server/` directory.
298
434
 
@@ -149,7 +149,7 @@ function collectServerPages(pagesDir) {
149
149
  return walkFiles(pagesDir).filter((relPath) => {
150
150
  const stem = path.basename(relPath, path.extname(relPath));
151
151
  if (stem === "layout" || stem === "_404" || stem === "_500") return false;
152
- return isServerComponent(path.join(pagesDir, relPath));
152
+ return true;
153
153
  }).map((relPath) => ({
154
154
  ...analyzeFile(relPath, "page"),
155
155
  absPath: path.join(pagesDir, relPath)
@@ -426,7 +426,8 @@ function buildWrapperAttrString(attrs: Record<string, any>): string {
426
426
  .map(([key, value]) => {
427
427
  if (key === 'className') key = 'class';
428
428
  if (key === 'style' && typeof value === 'object') {
429
- const css = Object.entries(value as Record<string, any>)
429
+ // Always prepend display:contents so the wrapper span is invisible to layout.
430
+ const css = 'display:contents;' + Object.entries(value as Record<string, any>)
430
431
  .map(([p, val]) => \`\${p.replace(/[A-Z]/g, m => \`-\${m.toLowerCase()}\`)}:\${escapeHtml(String(val))}\`)
431
432
  .join(';');
432
433
  return \`style="\${css}"\`;
@@ -436,6 +437,8 @@ function buildWrapperAttrString(attrs: Record<string, any>): string {
436
437
  return \`\${key}="\${escapeHtml(String(value))}"\`;
437
438
  })
438
439
  .filter(Boolean);
440
+ // When no style prop was passed, still emit display:contents.
441
+ if (!('style' in attrs)) parts.push('style="display:contents"');
439
442
  return parts.length ? ' ' + parts.join(' ') : '';
440
443
  }
441
444
 
package/dist/builder.js CHANGED
@@ -49,6 +49,7 @@ const PUBLIC_STEMS = /* @__PURE__ */ new Set([
49
49
  "request-store",
50
50
  "Link",
51
51
  "bundle",
52
+ "store",
52
53
  "utils",
53
54
  "logger"
54
55
  ]);
package/dist/bundle.js CHANGED
@@ -10,7 +10,7 @@ function setupLocationChangeMonitor() {
10
10
  originalReplaceState(...args);
11
11
  dispatch(args[2]);
12
12
  };
13
- window.addEventListener("popstate", () => dispatch(window.location.pathname));
13
+ window.addEventListener("popstate", () => dispatch(window.location.pathname + window.location.search));
14
14
  }
15
15
  function makeLogger(level) {
16
16
  return {
@@ -248,7 +248,15 @@ function syncAttrs(live, next) {
248
248
  if (!next.hasAttribute(name)) live.removeAttribute(name);
249
249
  }
250
250
  function setupNavigation(log) {
251
+ let hmrNavPending = false;
251
252
  window.addEventListener("locationchange", async ({ detail: { href, hmr } }) => {
253
+ if (hmr) {
254
+ if (hmrNavPending) {
255
+ log.info("[HMR] Navigation already in flight \u2014 skipping duplicate for", href);
256
+ return;
257
+ }
258
+ hmrNavPending = true;
259
+ }
252
260
  try {
253
261
  const fetchUrl = hmr ? href + (href.includes("?") ? "&" : "?") + "__hmr=1" : href;
254
262
  const response = await fetch(fetchUrl, { headers: { Accept: "text/html" } });
@@ -279,7 +287,7 @@ function setupNavigation(log) {
279
287
  activeRoots.splice(0).forEach((r) => r.unmount());
280
288
  const navData = JSON.parse(currDataEl?.textContent ?? "{}");
281
289
  log.info("\u{1F504} Route \u2192", href, "\u2014 mounting", navData.hydrateIds?.length ?? 0, "component(s)");
282
- const mods = await loadModules(navData.allIds ?? [], log, String(Date.now()));
290
+ const mods = await loadModules(navData.allIds ?? [], log, hmr ? String(Date.now()) : "");
283
291
  await mountNodes(mods, log);
284
292
  window.scrollTo(0, 0);
285
293
  log.info("\u{1F389} Navigation complete:", href);
@@ -287,6 +295,7 @@ function setupNavigation(log) {
287
295
  log.error("Navigation error, falling back to full reload:", err);
288
296
  window.location.href = href;
289
297
  } finally {
298
+ if (hmr) hmrNavPending = false;
290
299
  clientErrorPending = false;
291
300
  }
292
301
  });
@@ -1,13 +1,25 @@
1
1
  import { log } from "./logger.js";
2
2
  function hmr() {
3
3
  const es = new EventSource("/__hmr");
4
+ let reconnecting = false;
4
5
  es.onopen = () => {
6
+ reconnecting = false;
5
7
  log.info("[HMR] Connected");
6
8
  };
7
9
  es.onerror = () => {
10
+ if (reconnecting) return;
11
+ reconnecting = true;
8
12
  es.close();
9
13
  waitForReconnect();
10
14
  };
15
+ document.addEventListener("visibilitychange", () => {
16
+ if (document.visibilityState !== "visible") return;
17
+ if (es.readyState === EventSource.OPEN) return;
18
+ if (reconnecting) return;
19
+ reconnecting = true;
20
+ es.close();
21
+ waitForReconnect(500, 20);
22
+ });
11
23
  es.onmessage = async (event) => {
12
24
  try {
13
25
  const msg = JSON.parse(event.data);
@@ -48,8 +60,18 @@ function hmr() {
48
60
  }
49
61
  };
50
62
  }
63
+ let _navTimer = null;
64
+ let _navHref = null;
51
65
  function navigate(href) {
52
- window.dispatchEvent(new CustomEvent("locationchange", { detail: { href, hmr: true } }));
66
+ _navHref = href;
67
+ if (_navTimer) clearTimeout(_navTimer);
68
+ _navTimer = setTimeout(() => {
69
+ _navTimer = null;
70
+ if (_navHref !== null) {
71
+ window.dispatchEvent(new CustomEvent("locationchange", { detail: { href: _navHref, hmr: true } }));
72
+ _navHref = null;
73
+ }
74
+ }, 50);
53
75
  }
54
76
  function patternMatchesPathname(pattern, pathname) {
55
77
  const normPattern = pattern.length > 1 ? pattern.replace(/\/+$/, "") : pattern;
package/dist/index.d.ts CHANGED
@@ -1,10 +1,12 @@
1
+ export { createStore, useStore } from './store';
2
+ export type { Store } from './store';
1
3
  export { useHtml } from './use-html';
2
4
  export type { HtmlOptions } from './use-html';
3
5
  export type { TitleValue, HtmlAttrs, BodyAttrs, MetaTag, LinkTag, ScriptTag, StyleTag, } from './html-store';
4
6
  export { default as useRouter } from './use-router';
5
7
  export { useRequest } from './use-request';
6
8
  export type { RequestContext } from './use-request';
7
- export { normaliseHeaders, sanitiseHeaders } from './request-store';
9
+ export { normaliseHeaders, sanitiseHeaders, getRequestStore } from './request-store';
8
10
  export { default as Link } from './Link';
9
11
  export { setupLocationChangeMonitor, initRuntime } from './bundle';
10
12
  export type { RuntimeData } from './bundle';
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { createStore, useStore } from "./store.js";
1
2
  import { useHtml } from "./use-html.js";
2
3
  import { default as default2 } from "./use-router.js";
3
4
  import { useRequest } from "./use-request.js";
4
- import { normaliseHeaders, sanitiseHeaders } from "./request-store.js";
5
+ import { normaliseHeaders, sanitiseHeaders, getRequestStore } from "./request-store.js";
5
6
  import { default as default3 } from "./Link.js";
6
7
  import { setupLocationChangeMonitor, initRuntime } from "./bundle.js";
7
8
  import { escapeHtml } from "./utils.js";
@@ -10,8 +11,10 @@ export {
10
11
  default3 as Link,
11
12
  ansi,
12
13
  c,
14
+ createStore,
13
15
  escapeHtml,
14
16
  getDebugLevel,
17
+ getRequestStore,
15
18
  initRuntime,
16
19
  log,
17
20
  normaliseHeaders,
@@ -20,5 +23,6 @@ export {
20
23
  setupLocationChangeMonitor,
21
24
  useHtml,
22
25
  useRequest,
23
- default2 as useRouter
26
+ default2 as useRouter,
27
+ useStore
24
28
  };
package/dist/renderer.js CHANGED
@@ -20,7 +20,7 @@ function buildWrapperAttrString(attrs) {
20
20
  const parts = Object.entries(attrs).map(([key, value]) => {
21
21
  if (key === "className") key = "class";
22
22
  if (key === "style" && typeof value === "object") {
23
- const css = Object.entries(value).map(([k, v]) => {
23
+ const css = "display:contents;" + Object.entries(value).map(([k, v]) => {
24
24
  const prop = k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
25
25
  const safeVal = String(v).replace(/[<>"'`\\]/g, "");
26
26
  return `${prop}:${safeVal}`;
@@ -31,6 +31,7 @@ function buildWrapperAttrString(attrs) {
31
31
  if (value == null) return "";
32
32
  return `${key}="${escapeHtml(String(value))}"`;
33
33
  }).filter(Boolean);
34
+ if (!("style" in attrs)) parts.push('style="display:contents"');
34
35
  return parts.length ? " " + parts.join(" ") : "";
35
36
  }
36
37
  async function renderElementToHtml(element, ctx) {
@@ -67,13 +67,9 @@ export declare function normaliseHeaders(raw: Record<string, string | string[] |
67
67
  export declare function sanitiseHeaders(raw: Record<string, string | string[] | undefined>): Record<string, string>;
68
68
  /**
69
69
  * Runs `fn` inside the context of the given request, then clears the store.
70
- *
71
- * Usage in the SSR pipeline:
72
- * ```ts
73
- * const store = await runWithRequestStore(ctx, async () => {
74
- * appHtml = await renderElementToHtml(element, renderCtx);
75
- * });
76
- * ```
70
+ * The store is set synchronously before `fn` is called, so any code that
71
+ * reads getRequestStore() during the synchronous phase of a server component
72
+ * (before its first `await`) will always see the correct context.
77
73
  */
78
74
  export declare function runWithRequestStore<T>(ctx: RequestContext, fn: () => Promise<T>): Promise<T>;
79
75
  /**
@@ -0,0 +1,104 @@
1
+ /**
2
+ * store.ts — NukeJS Client State Management
3
+ *
4
+ * Provides a lightweight, cross-boundary state system for "use client"
5
+ * components. The core problem it solves: NukeJS hydrates every client
6
+ * component into its own independent React root (via hydrateRoot / createRoot),
7
+ * so React Context cannot carry state across component boundaries. Each
8
+ * component's esbuild bundle is also a separate module instance, so a plain
9
+ * module-level variable would not be shared between bundles.
10
+ *
11
+ * Solution: all store state lives in `window.__nukeStores`, a Map that persists
12
+ * for the lifetime of the page regardless of how many times the store module
13
+ * is evaluated. Every bundle that imports `createStore('counter', …)` gets
14
+ * a thin proxy object that reads from and writes to the same backing entry —
15
+ * state is automatically shared across roots and bundles.
16
+ *
17
+ * API:
18
+ *
19
+ * const cartStore = createStore('cart', { items: [], total: 0 });
20
+ *
21
+ * // Inside any "use client" component on the same page:
22
+ * const items = useStore(cartStore, s => s.items);
23
+ * cartStore.setState(s => ({ ...s, items: [...s.items, newItem] }));
24
+ *
25
+ * The store is safe to import in server components — it detects the absence of
26
+ * `window` and returns a lightweight no-op stub so SSR never throws.
27
+ */
28
+ type Listener = () => void;
29
+ type Unsubscribe = () => void;
30
+ type Updater<T> = T | ((prev: T) => T);
31
+ /**
32
+ * A NukeJS store handle. Create it once at module scope; pass it into
33
+ * `useStore()` inside any client component to subscribe.
34
+ */
35
+ export interface Store<T extends object> {
36
+ /** Returns the current state snapshot. */
37
+ getState(): T;
38
+ /**
39
+ * Updates the state and notifies every subscriber.
40
+ *
41
+ * Accepts a full replacement value or an updater function:
42
+ * store.setState({ count: 0 })
43
+ * store.setState(s => ({ ...s, count: s.count + 1 }))
44
+ */
45
+ setState(updater: Updater<T>): void;
46
+ /**
47
+ * Registers a change listener. Returns an unsubscribe function.
48
+ * Compatible with `useSyncExternalStore`.
49
+ */
50
+ subscribe(listener: Listener): Unsubscribe;
51
+ /** The name this store is registered under in the global registry. */
52
+ readonly name: string;
53
+ /**
54
+ * The value passed to `createStore` as its second argument.
55
+ * Used internally as the server snapshot so `useSyncExternalStore` always
56
+ * reconciles against a value that matches the server-rendered HTML —
57
+ * the server never has mutations, so initial state is always what it renders.
58
+ */
59
+ readonly initialState: T;
60
+ }
61
+ interface StoreEntry<T> {
62
+ state: T;
63
+ listeners: Set<Listener>;
64
+ }
65
+ declare global {
66
+ interface Window {
67
+ __nukeStores?: Map<string, StoreEntry<any>>;
68
+ }
69
+ }
70
+ /**
71
+ * Creates (or retrieves) a named store backed by the page-global registry.
72
+ *
73
+ * If a store with the given `name` already exists in the registry (e.g.
74
+ * because another bundle called `createStore` first), the existing entry is
75
+ * reused and `initialState` is ignored. This means the first bundle to run
76
+ * wins the initial value — define your stores in a single shared file, or
77
+ * treat `initialState` as a consistent default across all bundles.
78
+ *
79
+ * @param name A unique string key for this store.
80
+ * @param initialState Default state used when the store is first created.
81
+ */
82
+ export declare function createStore<T extends object>(name: string, initialState: T): Store<T>;
83
+ /**
84
+ * React hook that subscribes a component to a store.
85
+ *
86
+ * An optional `selector` lets you derive a slice of state. The component
87
+ * only re-renders when the selected value changes (by reference equality),
88
+ * not on every store mutation.
89
+ *
90
+ * Works across independent React roots — any component on the page that calls
91
+ * `useStore` with the same store will re-render when that store changes,
92
+ * regardless of which component boundary each lives in.
93
+ *
94
+ * @example
95
+ * // Full state
96
+ * const state = useStore(cartStore);
97
+ *
98
+ * @example
99
+ * // Selected slice — re-renders only when `items` changes
100
+ * const items = useStore(cartStore, s => s.items);
101
+ */
102
+ export declare function useStore<T extends object>(store: Store<T>): T;
103
+ export declare function useStore<T extends object, U>(store: Store<T>, selector: (state: T) => U): U;
104
+ export {};
package/dist/store.js ADDED
@@ -0,0 +1,45 @@
1
+ import { useSyncExternalStore } from "react";
2
+ function getRegistry() {
3
+ if (typeof window === "undefined") {
4
+ return /* @__PURE__ */ new Map();
5
+ }
6
+ if (!window.__nukeStores) {
7
+ window.__nukeStores = /* @__PURE__ */ new Map();
8
+ }
9
+ return window.__nukeStores;
10
+ }
11
+ function createStore(name, initialState) {
12
+ const registry = getRegistry();
13
+ if (!registry.has(name)) {
14
+ registry.set(name, {
15
+ state: initialState,
16
+ listeners: /* @__PURE__ */ new Set()
17
+ });
18
+ }
19
+ const entry = registry.get(name);
20
+ const subscribe = (listener) => {
21
+ entry.listeners.add(listener);
22
+ return () => {
23
+ entry.listeners.delete(listener);
24
+ };
25
+ };
26
+ const getState = () => entry.state;
27
+ const setState = (updater) => {
28
+ entry.state = typeof updater === "function" ? updater(entry.state) : updater;
29
+ for (const l of Array.from(entry.listeners)) l();
30
+ };
31
+ return { name, initialState, getState, setState, subscribe };
32
+ }
33
+ function useStore(store, selector) {
34
+ const getSnapshot = selector ? () => selector(store.getState()) : () => store.getState();
35
+ const getServerSnapshot = selector ? () => selector(store.initialState) : () => store.initialState;
36
+ return useSyncExternalStore(
37
+ store.subscribe,
38
+ getSnapshot,
39
+ getServerSnapshot
40
+ );
41
+ }
42
+ export {
43
+ createStore,
44
+ useStore
45
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukejs",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "A minimal, opinionated full-stack React framework on Node.js that server-renders everything and hydrates only interactive parts.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",