hadars 0.1.40 → 0.2.1

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.
Files changed (42) hide show
  1. package/README.md +85 -70
  2. package/cli-lib.ts +89 -12
  3. package/dist/chunk-HWOLYLPF.js +332 -0
  4. package/dist/{chunk-2ENP7IAW.js → chunk-LY5MTHFV.js} +360 -203
  5. package/dist/cli.js +506 -274
  6. package/dist/cloudflare.cjs +1394 -0
  7. package/dist/cloudflare.d.cts +64 -0
  8. package/dist/cloudflare.d.ts +64 -0
  9. package/dist/cloudflare.js +68 -0
  10. package/dist/{hadars-Bh-V5YXg.d.cts → hadars-DEBSYAQl.d.cts} +1 -36
  11. package/dist/{hadars-Bh-V5YXg.d.ts → hadars-DEBSYAQl.d.ts} +1 -36
  12. package/dist/index.cjs +129 -156
  13. package/dist/index.d.cts +5 -11
  14. package/dist/index.d.ts +5 -11
  15. package/dist/index.js +129 -155
  16. package/dist/lambda.cjs +391 -229
  17. package/dist/lambda.d.cts +1 -2
  18. package/dist/lambda.d.ts +1 -2
  19. package/dist/lambda.js +18 -307
  20. package/dist/slim-react/index.cjs +361 -203
  21. package/dist/slim-react/index.d.cts +24 -8
  22. package/dist/slim-react/index.d.ts +24 -8
  23. package/dist/slim-react/index.js +3 -1
  24. package/dist/ssr-render-worker.js +352 -221
  25. package/dist/utils/Head.tsx +132 -187
  26. package/package.json +7 -2
  27. package/src/build.ts +7 -6
  28. package/src/cloudflare.ts +139 -0
  29. package/src/index.tsx +0 -3
  30. package/src/lambda.ts +6 -2
  31. package/src/slim-react/context.ts +2 -1
  32. package/src/slim-react/index.ts +21 -18
  33. package/src/slim-react/render.ts +379 -240
  34. package/src/slim-react/renderContext.ts +105 -45
  35. package/src/ssr-render-worker.ts +14 -44
  36. package/src/types/hadars.ts +0 -1
  37. package/src/utils/Head.tsx +132 -187
  38. package/src/utils/cookies.ts +1 -1
  39. package/src/utils/response.tsx +68 -33
  40. package/src/utils/serve.ts +29 -27
  41. package/src/utils/ssrHandler.ts +54 -25
  42. package/src/utils/staticFile.ts +2 -7
@@ -36,6 +36,23 @@ export function captureMap(): Map<object, unknown> | null {
36
36
  return _g[MAP_KEY];
37
37
  }
38
38
 
39
+ const UNSUSPEND_KEY = "__hadarsUnsuspend";
40
+
41
+ /**
42
+ * Capture the current __hadarsUnsuspend slot alongside captureMap() before
43
+ * an async boundary. Because useServerData reads this global, concurrent
44
+ * renders would corrupt each other's cache if it weren't restored after every
45
+ * await continuation — exactly like the context map itself.
46
+ */
47
+ export function captureUnsuspend(): unknown {
48
+ return _g[UNSUSPEND_KEY];
49
+ }
50
+
51
+ /** Restore a previously captured __hadarsUnsuspend slot after an await. */
52
+ export function restoreUnsuspend(u: unknown): void {
53
+ _g[UNSUSPEND_KEY] = u;
54
+ }
55
+
39
56
  /** Read the current value for a context within the active render. */
40
57
  export function getContextValue<T>(context: object): T {
41
58
  const map: Map<object, unknown> | null = _g[MAP_KEY];
@@ -49,12 +66,18 @@ export function getContextValue<T>(context: object): T {
49
66
  * Returns the previous value so the caller can restore it on exit.
50
67
  */
51
68
  export function pushContextValue(context: object, value: unknown): unknown {
52
- const map: Map<object, unknown> | null = _g[MAP_KEY];
69
+ let map: Map<object, unknown> | null = _g[MAP_KEY];
70
+ // Lazily create the Map on the first Provider encountered — renders without
71
+ // any Context.Provider never allocate a Map at all.
72
+ if (map === null) {
73
+ map = new Map();
74
+ _g[MAP_KEY] = map;
75
+ }
53
76
  const c = context as any;
54
- const prev = map && map.has(context)
77
+ const prev = map.has(context)
55
78
  ? map.get(context)
56
79
  : ("_defaultValue" in c ? c._defaultValue : c._currentValue);
57
- map?.set(context, value);
80
+ map.set(context, value);
58
81
  return prev;
59
82
  }
60
83
 
@@ -84,19 +107,46 @@ const GLOBAL_KEY = "__slimReactRenderState";
84
107
  // React 19's initial context is { id: 1, overflow: "" } — sentinel bit only.
85
108
  const EMPTY: TreeContext = { id: 1, overflow: "" };
86
109
 
110
+ /**
111
+ * Module-level cache for the shared RenderState singleton.
112
+ * Avoids a `globalThis[key]` property lookup on every push/pop/reset call
113
+ * (which happens multiple times per component during rendering).
114
+ * Both slim-react instances (direct import + SSR bundle copy) initialise the
115
+ * same `globalThis[GLOBAL_KEY]` object and then each cache that same reference,
116
+ * so correctness is preserved.
117
+ */
118
+ let _stateCache: RenderState | null = null;
119
+
87
120
  function s(): RenderState {
88
- const g = globalThis as any;
89
- if (!g[GLOBAL_KEY]) {
90
- g[GLOBAL_KEY] = { currentTreeContext: { ...EMPTY }, localIdCounter: 0, idPrefix: "" };
121
+ if (_stateCache !== null) return _stateCache;
122
+ if (!_g[GLOBAL_KEY]) {
123
+ _g[GLOBAL_KEY] = { currentTreeContext: { ...EMPTY }, localIdCounter: 0, idPrefix: "" };
91
124
  }
92
- return g[GLOBAL_KEY] as RenderState;
125
+ _stateCache = _g[GLOBAL_KEY] as RenderState;
126
+ return _stateCache;
93
127
  }
94
128
 
129
+ /**
130
+ * Flat primitive stacks for pushTreeContext / popTreeContext.
131
+ *
132
+ * Instead of allocating a new `{ id, overflow }` object on every array child
133
+ * (which is the hot path — called once per child in every array render), we
134
+ * save the two scalar fields into parallel pre-allocated arrays and return a
135
+ * numeric depth index. No heap objects are allocated in the push. Both arrays
136
+ * grow lazily and are never shrunk, so after a few renders they stop growing.
137
+ */
138
+ const _treeIdStack: number[] = [];
139
+ const _treeOvStack: string[] = [];
140
+ let _treeDepth = 0;
141
+
95
142
  export function resetRenderState(idPrefix = "") {
96
143
  const st = s();
97
- st.currentTreeContext = { ...EMPTY };
144
+ // Mutate in place — avoids allocating a new TreeContext object each render.
145
+ st.currentTreeContext.id = EMPTY.id;
146
+ st.currentTreeContext.overflow = EMPTY.overflow;
98
147
  st.localIdCounter = 0;
99
- st.idPrefix = idPrefix;
148
+ st.idPrefix = idPrefix;
149
+ _treeDepth = 0;
100
150
  }
101
151
 
102
152
  export function setIdPrefix(prefix: string) {
@@ -110,44 +160,48 @@ export function setIdPrefix(prefix: string) {
110
160
  * - on overflow, the LOWEST bits of the old data move to the overflow string
111
161
  * (rounded to a multiple of 5 so base-32 digits align on byte boundaries)
112
162
  */
113
- export function pushTreeContext(totalChildren: number, index: number): TreeContext {
114
- const st = s();
115
- const saved: TreeContext = { ...st.currentTreeContext };
163
+ /**
164
+ * Push a new tree-context level. Returns a numeric depth token (not an
165
+ * object) so the caller can pop with zero heap allocation in the common case.
166
+ */
167
+ export function pushTreeContext(totalChildren: number, index: number): number {
168
+ const st = s();
169
+ const ctx = st.currentTreeContext;
170
+ const depth = _treeDepth++;
171
+
172
+ // Save current scalars into the flat stacks — no object allocation.
173
+ _treeIdStack[depth] = ctx.id;
174
+ _treeOvStack[depth] = ctx.overflow;
116
175
 
117
- const baseIdWithLeadingBit = st.currentTreeContext.id;
118
- const baseOverflow = st.currentTreeContext.overflow;
119
- // Number of data bits currently stored (excludes the sentinel bit).
176
+ const baseIdWithLeadingBit = ctx.id;
177
+ const baseOverflow = ctx.overflow;
120
178
  const baseLength = 31 - Math.clz32(baseIdWithLeadingBit);
121
- // Strip the sentinel to get the pure data portion.
122
- let baseId = baseIdWithLeadingBit & ~(1 << baseLength);
179
+ let baseId = baseIdWithLeadingBit & ~(1 << baseLength);
123
180
 
124
- const slot = index + 1; // 1-indexed
125
- const newBits = 32 - Math.clz32(totalChildren); // bits required for the new slot
126
- const length = newBits + baseLength; // total data bits after push
181
+ const slot = index + 1;
182
+ const newBits = 32 - Math.clz32(totalChildren);
183
+ const length = newBits + baseLength;
127
184
 
185
+ // Mutate currentTreeContext in place — avoids allocating a new object.
128
186
  if (30 < length) {
129
- // Overflow: flush the lowest bits of the old data to the overflow string.
130
- // Round down to a multiple of 5 so each base-32 character covers exactly
131
- // 5 bits (no fractional digits that would corrupt adjacent chars).
132
- const numberOfOverflowBits = baseLength - (baseLength % 5);
133
- const overflowStr = (baseId & ((1 << numberOfOverflowBits) - 1)).toString(32);
134
- baseId >>= numberOfOverflowBits;
135
- const newBaseLength = baseLength - numberOfOverflowBits;
136
- st.currentTreeContext = {
137
- id: (1 << (newBits + newBaseLength)) | (slot << newBaseLength) | baseId,
138
- overflow: overflowStr + baseOverflow,
139
- };
187
+ const overflowBits = baseLength - (baseLength % 5);
188
+ const overflowStr = (baseId & ((1 << overflowBits) - 1)).toString(32);
189
+ baseId >>= overflowBits;
190
+ const newBaseLength = baseLength - overflowBits;
191
+ ctx.id = (1 << (newBits + newBaseLength)) | (slot << newBaseLength) | baseId;
192
+ ctx.overflow = overflowStr + baseOverflow;
140
193
  } else {
141
- st.currentTreeContext = {
142
- id: (1 << length) | (slot << baseLength) | baseId,
143
- overflow: baseOverflow,
144
- };
194
+ ctx.id = (1 << length) | (slot << baseLength) | baseId;
195
+ ctx.overflow = baseOverflow;
145
196
  }
146
- return saved;
197
+ return depth;
147
198
  }
148
199
 
149
- export function popTreeContext(saved: TreeContext) {
150
- s().currentTreeContext = saved;
200
+ export function popTreeContext(depth: number): void {
201
+ const ctx = s().currentTreeContext;
202
+ ctx.id = _treeIdStack[depth]!;
203
+ ctx.overflow = _treeOvStack[depth]!;
204
+ _treeDepth = depth;
151
205
  }
152
206
 
153
207
  export function pushComponentScope(): number {
@@ -166,15 +220,21 @@ export function componentCalledUseId(): boolean {
166
220
  return s().localIdCounter > 0;
167
221
  }
168
222
 
169
- export function snapshotContext(): { tree: TreeContext; localId: number } {
170
- const st = s();
171
- return { tree: { ...st.currentTreeContext }, localId: st.localIdCounter };
223
+ export function snapshotContext(): { tree: TreeContext; localId: number; treeDepth: number } {
224
+ const st = s();
225
+ const ctx = st.currentTreeContext;
226
+ // Copy scalars directly — avoids a spread allocation.
227
+ return { tree: { id: ctx.id, overflow: ctx.overflow }, localId: st.localIdCounter, treeDepth: _treeDepth };
172
228
  }
173
229
 
174
- export function restoreContext(snap: { tree: TreeContext; localId: number }) {
175
- const st = s();
176
- st.currentTreeContext = { ...snap.tree };
177
- st.localIdCounter = snap.localId;
230
+ export function restoreContext(snap: { tree: TreeContext; localId: number; treeDepth: number }): void {
231
+ const st = s();
232
+ const ctx = st.currentTreeContext;
233
+ // Mutate in place — avoids allocating a new TreeContext object.
234
+ ctx.id = snap.tree.id;
235
+ ctx.overflow = snap.tree.overflow;
236
+ st.localIdCounter = snap.localId;
237
+ _treeDepth = snap.treeDepth;
178
238
  }
179
239
 
180
240
  /**
@@ -13,6 +13,7 @@
13
13
  import { workerData, parentPort } from 'node:worker_threads';
14
14
  import { pathToFileURL } from 'node:url';
15
15
  import { renderToString, createElement } from './slim-react/index';
16
+ import { buildHeadHtml } from './utils/response';
16
17
 
17
18
  const { ssrBundlePath } = workerData as { ssrBundlePath: string };
18
19
 
@@ -42,43 +43,6 @@ function deserializeRequest(s: SerializableRequest): any {
42
43
  return req;
43
44
  }
44
45
 
45
- // ── Head HTML serialisation ────────────────────────────────────────────────
46
-
47
- const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
48
- const escAttr = (s: string) => s.replace(/[&<>"]/g, c => ESC[c] ?? c);
49
- const escText = (s: string) => s.replace(/[&<>]/g, c => ESC[c] ?? c);
50
-
51
- const HEAD_ATTR: Record<string, string> = {
52
- className: 'class', htmlFor: 'for', httpEquiv: 'http-equiv',
53
- charSet: 'charset', crossOrigin: 'crossorigin',
54
- };
55
-
56
- function renderHeadTag(tag: string, id: string, opts: Record<string, unknown>, selfClose = false): string {
57
- let a = ` id="${escAttr(id)}"`;
58
- let inner = '';
59
- for (const [k, v] of Object.entries(opts)) {
60
- if (k === 'key' || k === 'children') continue;
61
- if (k === 'dangerouslySetInnerHTML') { inner = (v as any).__html ?? ''; continue; }
62
- const attr = HEAD_ATTR[k] ?? k;
63
- if (v === true) a += ` ${attr}`;
64
- else if (v !== false && v != null) a += ` ${attr}="${escAttr(String(v))}"`;
65
- }
66
- return selfClose ? `<${tag}${a}>` : `<${tag}${a}>${inner}</${tag}>`;
67
- }
68
-
69
- function buildHeadHtml(head: any): string {
70
- let html = `<title>${escText(head.title ?? '')}</title>`;
71
- for (const [id, opts] of Object.entries(head.meta ?? {}))
72
- html += renderHeadTag('meta', id, opts as Record<string, unknown>, true);
73
- for (const [id, opts] of Object.entries(head.link ?? {}))
74
- html += renderHeadTag('link', id, opts as Record<string, unknown>, true);
75
- for (const [id, opts] of Object.entries(head.style ?? {}))
76
- html += renderHeadTag('style', id, opts as Record<string, unknown>);
77
- for (const [id, opts] of Object.entries(head.script ?? {}))
78
- html += renderHeadTag('script', id, opts as Record<string, unknown>);
79
- return html;
80
- }
81
-
82
46
  // ── Full lifecycle ─────────────────────────────────────────────────────────
83
47
 
84
48
  async function runFullLifecycle(serialReq: SerializableRequest) {
@@ -94,39 +58,45 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
94
58
  let props: any = {
95
59
  ...(getInitProps ? await getInitProps(parsedReq) : {}),
96
60
  location: serialReq.location,
97
- context,
98
61
  };
99
62
 
100
63
  // Create per-request cache for useServerData, active for all renders.
101
64
  const unsuspend = { cache: new Map<string, any>() };
102
65
  (globalThis as any).__hadarsUnsuspend = unsuspend;
66
+ // Expose the head context so HadarsHead can write into it without needing
67
+ // the user to manually wrap their App with HadarsContext.
68
+ (globalThis as any).__hadarsContext = context;
103
69
 
104
- // renderToString internally retries until all Suspense/useServerData promises
105
- // resolve, so its return value is already the final correct HTML — no second
106
- // render pass needed.
70
+ // Single pass component-level self-retry resolves all useServerData inline.
71
+ // context.head is fully populated by the time renderToString returns.
107
72
  let appHtml: string;
108
73
  try {
109
74
  appHtml = await renderToString(createElement(Component, props));
110
75
  } finally {
111
76
  (globalThis as any).__hadarsUnsuspend = null;
77
+ (globalThis as any).__hadarsContext = null;
112
78
  }
79
+ // Head is captured after the render — all components have run.
80
+ const headHtml = buildHeadHtml(context.head);
81
+ const status = context.head.status ?? 200;
113
82
  const { context: _ctx, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
114
83
 
115
84
  // Collect fulfilled useServerData values for client-side hydration.
116
85
  const serverData: Record<string, unknown> = {};
86
+ let hasServerData = false;
117
87
  for (const [key, entry] of unsuspend.cache) {
118
- if (entry.status === 'fulfilled') serverData[key] = entry.value;
88
+ if (entry.status === 'fulfilled') { serverData[key] = entry.value; hasServerData = true; }
119
89
  }
120
90
  const clientProps = {
121
91
  ...restProps,
122
92
  location: serialReq.location,
123
- ...(Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}),
93
+ ...(hasServerData ? { __serverData: serverData } : {}),
124
94
  };
125
95
 
126
96
  const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c');
127
97
  const html = `<div id="app">${appHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>`;
128
98
 
129
- return { html, headHtml: buildHeadHtml(context.head), status: context.head.status ?? 200 };
99
+ return { html, headHtml, status };
130
100
  }
131
101
 
132
102
  // ── Message handler ────────────────────────────────────────────────────────
@@ -40,7 +40,6 @@ export interface AppContext {
40
40
 
41
41
  export type HadarsEntryBase = {
42
42
  location: string;
43
- context: AppContext;
44
43
  }
45
44
 
46
45
  export type HadarsProps<T extends {}> = T & HadarsEntryBase;