hadars 0.1.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.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/cli-bun.ts +13 -0
- package/cli-lib.ts +203 -0
- package/cli.ts +13 -0
- package/dist/cli.js +1441 -0
- package/dist/index.cjs +303 -0
- package/dist/index.d.ts +160 -0
- package/dist/index.js +263 -0
- package/dist/loader.cjs +34 -0
- package/dist/ssr-render-worker.js +92 -0
- package/dist/ssr-watch.js +345 -0
- package/dist/template.html +11 -0
- package/dist/utils/clientScript.tsx +58 -0
- package/index.ts +15 -0
- package/package.json +99 -0
- package/src/build.ts +716 -0
- package/src/index.tsx +41 -0
- package/src/ssr-render-worker.ts +138 -0
- package/src/ssr-watch.ts +56 -0
- package/src/types/global.d.ts +5 -0
- package/src/types/ninety.ts +116 -0
- package/src/utils/Head.tsx +357 -0
- package/src/utils/clientScript.tsx +58 -0
- package/src/utils/cookies.ts +16 -0
- package/src/utils/loadModule.ts +4 -0
- package/src/utils/loader.ts +41 -0
- package/src/utils/proxyHandler.tsx +101 -0
- package/src/utils/request.tsx +9 -0
- package/src/utils/response.tsx +198 -0
- package/src/utils/rspack.ts +359 -0
- package/src/utils/runtime.ts +19 -0
- package/src/utils/serve.ts +140 -0
- package/src/utils/staticFile.ts +48 -0
- package/src/utils/template.html +11 -0
- package/src/utils/upgradeRequest.tsx +19 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { AppContext, AppUnsuspend, LinkProps, MetaProps, ScriptProps, StyleProps } from '../types/ninety'
|
|
3
|
+
|
|
4
|
+
interface InnerContext {
|
|
5
|
+
setTitle: (title: string) => void;
|
|
6
|
+
addMeta: (id: string, props: MetaProps) => void;
|
|
7
|
+
addLink: (id: string, props: LinkProps) => void;
|
|
8
|
+
addStyle: (id: string, props: StyleProps) => void;
|
|
9
|
+
addScript: (id: string, props: ScriptProps) => void;
|
|
10
|
+
setStatus: (status: number) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const AppContext = React.createContext<InnerContext>({
|
|
14
|
+
setTitle: () => {
|
|
15
|
+
console.warn('AppContext: setTitle called outside of provider');
|
|
16
|
+
},
|
|
17
|
+
addMeta: () => {
|
|
18
|
+
console.warn('AppContext: addMeta called outside of provider');
|
|
19
|
+
},
|
|
20
|
+
addLink: () => {
|
|
21
|
+
console.warn('AppContext: addLink called outside of provider');
|
|
22
|
+
},
|
|
23
|
+
addStyle: () => {
|
|
24
|
+
console.warn('AppContext: addStyle called outside of provider');
|
|
25
|
+
},
|
|
26
|
+
addScript: () => {
|
|
27
|
+
console.warn('AppContext: addScript called outside of provider');
|
|
28
|
+
},
|
|
29
|
+
setStatus: () => { },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const AppProviderSSR: React.FC<{
|
|
33
|
+
children: React.ReactNode,
|
|
34
|
+
context: AppContext,
|
|
35
|
+
}> = React.memo( ({ children, context }) => {
|
|
36
|
+
|
|
37
|
+
const { head } = context;
|
|
38
|
+
|
|
39
|
+
// mutate seoData
|
|
40
|
+
const setTitle = React.useCallback((title: string) => {
|
|
41
|
+
head.title = title;
|
|
42
|
+
}, [head]);
|
|
43
|
+
const addMeta = React.useCallback((id: string, props: MetaProps) => {
|
|
44
|
+
head.meta[id] = props;
|
|
45
|
+
}, [head]);
|
|
46
|
+
const addLink = React.useCallback((id: string, props: LinkProps) => {
|
|
47
|
+
head.link[id] = props;
|
|
48
|
+
}, [head]);
|
|
49
|
+
const addStyle = React.useCallback((id: string, props: StyleProps) => {
|
|
50
|
+
head.style[id] = props;
|
|
51
|
+
}, [head]);
|
|
52
|
+
const addScript = React.useCallback((id: string, props: ScriptProps) => {
|
|
53
|
+
head.script[id] = props;
|
|
54
|
+
}, [head]);
|
|
55
|
+
|
|
56
|
+
const setStatus = React.useCallback((status: number) => {
|
|
57
|
+
head.status = status;
|
|
58
|
+
}, [head]);
|
|
59
|
+
|
|
60
|
+
const contextValue: InnerContext = React.useMemo(() => ({
|
|
61
|
+
setTitle,
|
|
62
|
+
addMeta,
|
|
63
|
+
addLink,
|
|
64
|
+
addStyle,
|
|
65
|
+
addScript,
|
|
66
|
+
setStatus,
|
|
67
|
+
}), [ setTitle, addMeta, addLink, addStyle, addScript, setStatus]);
|
|
68
|
+
return (
|
|
69
|
+
<AppContext.Provider value={contextValue}>
|
|
70
|
+
{children}
|
|
71
|
+
</AppContext.Provider>
|
|
72
|
+
);
|
|
73
|
+
} );
|
|
74
|
+
|
|
75
|
+
export const AppProviderCSR: React.FC<{
|
|
76
|
+
children: React.ReactNode
|
|
77
|
+
}> = React.memo( ({ children }) => {
|
|
78
|
+
|
|
79
|
+
const setTitle = React.useCallback((title: string) => {
|
|
80
|
+
document.title = title;
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const addMeta = React.useCallback((id: string, props: MetaProps) => {
|
|
84
|
+
let meta = document.querySelector(`#${id}`) as HTMLMetaElement | null;
|
|
85
|
+
if (!meta) {
|
|
86
|
+
meta = document.createElement('meta');
|
|
87
|
+
meta.setAttribute('id', id);
|
|
88
|
+
document.head.appendChild(meta);
|
|
89
|
+
}
|
|
90
|
+
Object.keys(props).forEach(key => {
|
|
91
|
+
const value = (props)[key as keyof MetaProps];
|
|
92
|
+
if (value) {
|
|
93
|
+
meta!.setAttribute(key, value);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const addLink = React.useCallback((id: string, props: LinkProps) => {
|
|
99
|
+
let link = document.querySelector(`#${id}`) as HTMLLinkElement | null;
|
|
100
|
+
if (!link) {
|
|
101
|
+
link = document.createElement('link');
|
|
102
|
+
link.setAttribute('id', id);
|
|
103
|
+
document.head.appendChild(link);
|
|
104
|
+
}
|
|
105
|
+
Object.keys(props).forEach(key => {
|
|
106
|
+
const value = (props)[key as keyof LinkProps];
|
|
107
|
+
if (value) {
|
|
108
|
+
link!.setAttribute(key, value);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
const addStyle = React.useCallback((id: string, props: StyleProps) => {
|
|
114
|
+
let style = document.getElementById(id) as HTMLStyleElement | null;
|
|
115
|
+
if (!style) {
|
|
116
|
+
style = document.createElement('style');
|
|
117
|
+
style.setAttribute('id', id);
|
|
118
|
+
document.head.appendChild(style);
|
|
119
|
+
}
|
|
120
|
+
Object.keys(props).forEach(key => {
|
|
121
|
+
// handle dangerouslySetInnerHTML
|
|
122
|
+
if (key === 'dangerouslySetInnerHTML' && (props as any)[key] && (props as any)[key].__html) {
|
|
123
|
+
style!.innerHTML = (props as any)[key].__html;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const value = (props)[key as keyof StyleProps];
|
|
127
|
+
if (value) {
|
|
128
|
+
(style as any)[key] = value;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
const addScript = React.useCallback((id: string, props: ScriptProps) => {
|
|
134
|
+
let script = document.getElementById(id) as HTMLScriptElement | null;
|
|
135
|
+
if (!script) {
|
|
136
|
+
script = document.createElement('script');
|
|
137
|
+
script.setAttribute('id', id);
|
|
138
|
+
document.body.appendChild(script);
|
|
139
|
+
}
|
|
140
|
+
Object.keys(props).forEach(key => {
|
|
141
|
+
// handle dangerouslySetInnerHTML
|
|
142
|
+
if (key === 'dangerouslySetInnerHTML' && (props as any)[key] && (props as any)[key].__html) {
|
|
143
|
+
script!.innerHTML = (props as any)[key].__html;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const value = (props)[key as keyof ScriptProps];
|
|
147
|
+
if (value) {
|
|
148
|
+
(script as any)[key] = value;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
const contextValue: InnerContext = React.useMemo(() => ({
|
|
154
|
+
setTitle,
|
|
155
|
+
addMeta,
|
|
156
|
+
addLink,
|
|
157
|
+
addStyle,
|
|
158
|
+
addScript,
|
|
159
|
+
setStatus: () => { },
|
|
160
|
+
}), [setTitle, addMeta, addLink, addStyle, addScript]);
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<AppContext.Provider value={contextValue}>
|
|
164
|
+
{children}
|
|
165
|
+
</AppContext.Provider>
|
|
166
|
+
);
|
|
167
|
+
} );
|
|
168
|
+
|
|
169
|
+
export const useApp = () => React.useContext(AppContext);
|
|
170
|
+
|
|
171
|
+
// ── useServerData ─────────────────────────────────────────────────────────────
|
|
172
|
+
//
|
|
173
|
+
// Client-side cache pre-populated from the server's resolved data before
|
|
174
|
+
// hydration. Keyed by the same React useId() values that the server used.
|
|
175
|
+
const clientServerDataCache = new Map<string, unknown>();
|
|
176
|
+
|
|
177
|
+
/** Call this before hydrating to seed the client cache from the server's data.
|
|
178
|
+
* Invoked automatically by the hadars client bootstrap. */
|
|
179
|
+
export function initServerDataCache(data: Record<string, unknown>) {
|
|
180
|
+
for (const [k, v] of Object.entries(data)) {
|
|
181
|
+
clientServerDataCache.set(k, v);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Fetch async data on the server during SSR. Returns `undefined` on the first
|
|
187
|
+
* render pass(es) while the promise is in flight; returns the resolved value
|
|
188
|
+
* once the framework's render loop has awaited it.
|
|
189
|
+
*
|
|
190
|
+
* On the client the pre-resolved value is read from the hydration cache
|
|
191
|
+
* serialised into the page by the server, so no fetch is issued in the browser.
|
|
192
|
+
*
|
|
193
|
+
* The `key` (string or array of strings) uniquely identifies the cached value
|
|
194
|
+
* across all SSR render passes and client hydration — it must be stable and
|
|
195
|
+
* unique within the page.
|
|
196
|
+
*
|
|
197
|
+
* `fn` may return a `Promise<T>` (normal async usage), return `T` synchronously,
|
|
198
|
+
* or throw a thenable (React Suspense protocol). All three cases are handled:
|
|
199
|
+
* - Async `Promise<T>`: awaited across render iterations, result cached.
|
|
200
|
+
* - Synchronous return: value stored immediately, returned on the same pass.
|
|
201
|
+
* - Thrown thenable (e.g. `useQuery({ suspense: true })`): the thrown promise is
|
|
202
|
+
* awaited, the cache entry is then cleared so that the next render re-calls
|
|
203
|
+
* `fn()` — at that point the Suspense hook returns synchronously.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* const user = useServerData('current_user', () => db.getUser(id));
|
|
207
|
+
* const post = useServerData(['post', postId], () => db.getPost(postId));
|
|
208
|
+
* if (!user) return null; // undefined while pending on the first SSR pass
|
|
209
|
+
*/
|
|
210
|
+
export function useServerData<T>(key: string | string[], fn: () => Promise<T> | T): T | undefined {
|
|
211
|
+
const cacheKey = Array.isArray(key) ? key.join('\x00') : key;
|
|
212
|
+
|
|
213
|
+
if (typeof window !== 'undefined') {
|
|
214
|
+
// Client: if the server serialised a value for this key, return it directly
|
|
215
|
+
// (normal async case — no re-fetch in the browser).
|
|
216
|
+
if (clientServerDataCache.has(cacheKey)) {
|
|
217
|
+
return clientServerDataCache.get(cacheKey) as T | undefined;
|
|
218
|
+
}
|
|
219
|
+
// Key not serialised → Suspense hook case (e.g. useSuspenseQuery).
|
|
220
|
+
// Call fn() directly so the hook runs with its own hydrated cache.
|
|
221
|
+
return fn() as T | undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Server: communicate via globalThis.__hadarsUnsuspend which is set by the
|
|
225
|
+
// framework around every renderToStaticMarkup / renderToString call. Using
|
|
226
|
+
// globalThis bypasses React context identity issues that arise when the user's
|
|
227
|
+
// SSR bundle and the hadars source each have their own React context instance.
|
|
228
|
+
const unsuspend: AppUnsuspend | undefined = (globalThis as any).__hadarsUnsuspend;
|
|
229
|
+
if (!unsuspend) return undefined;
|
|
230
|
+
|
|
231
|
+
const existing = unsuspend.cache.get(cacheKey);
|
|
232
|
+
|
|
233
|
+
// Suspense promise has resolved — re-call fn() so the hook returns its value
|
|
234
|
+
// synchronously from its own internal cache. The result is returned directly
|
|
235
|
+
// without being stored as 'fulfilled', so it is never included in the
|
|
236
|
+
// serialised serverData sent to the client (the library owns its own hydration).
|
|
237
|
+
if (existing?.status === 'suspense-resolved') {
|
|
238
|
+
try {
|
|
239
|
+
return fn() as T | undefined;
|
|
240
|
+
} catch {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!existing) {
|
|
246
|
+
// First encounter — call fn(), which may:
|
|
247
|
+
// (a) return a Promise<T> — normal async usage (serialised for the client)
|
|
248
|
+
// (b) return T synchronously — e.g. a sync data source
|
|
249
|
+
// (c) throw a thenable — Suspense protocol (e.g. useSuspenseQuery)
|
|
250
|
+
let result: Promise<T> | T;
|
|
251
|
+
try {
|
|
252
|
+
result = fn();
|
|
253
|
+
} catch (thrown) {
|
|
254
|
+
// (c) Suspense protocol: fn() threw a thenable. Await it, then mark the
|
|
255
|
+
// entry as 'suspense-resolved' so the next render re-calls fn() to get
|
|
256
|
+
// the synchronously available value. Not stored as 'fulfilled' → not
|
|
257
|
+
// serialised to the client (the Suspense library handles its own hydration).
|
|
258
|
+
if (thrown !== null && typeof thrown === 'object' && typeof (thrown as any).then === 'function') {
|
|
259
|
+
const suspensePromise = Promise.resolve(thrown as Promise<unknown>).then(
|
|
260
|
+
() => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
|
|
261
|
+
() => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
|
|
262
|
+
);
|
|
263
|
+
unsuspend.cache.set(cacheKey, { status: 'pending', promise: suspensePromise });
|
|
264
|
+
unsuspend.hasPending = true;
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
throw thrown;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const isThenable = result !== null && typeof result === 'object' && typeof (result as any).then === 'function';
|
|
271
|
+
|
|
272
|
+
// (b) Synchronous return — store as fulfilled and return immediately.
|
|
273
|
+
if (!isThenable) {
|
|
274
|
+
const value = result as T;
|
|
275
|
+
unsuspend.cache.set(cacheKey, { status: 'fulfilled', value });
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// (a) Async Promise — standard useServerData usage.
|
|
280
|
+
const promise = (result as Promise<T>).then(
|
|
281
|
+
value => { unsuspend.cache.set(cacheKey, { status: 'fulfilled', value }); },
|
|
282
|
+
reason => { unsuspend.cache.set(cacheKey, { status: 'rejected', reason }); },
|
|
283
|
+
);
|
|
284
|
+
unsuspend.cache.set(cacheKey, { status: 'pending', promise });
|
|
285
|
+
unsuspend.hasPending = true;
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
if (existing.status === 'pending') {
|
|
289
|
+
unsuspend.hasPending = true;
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
if (existing.status === 'rejected') throw existing.reason;
|
|
293
|
+
return existing.value as T;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
const genRandomId = () => {
|
|
298
|
+
return 'head-' + Math.random().toString(36).substr(2, 9);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export const Head: React.FC<{
|
|
302
|
+
children?: React.ReactNode;
|
|
303
|
+
status?: number;
|
|
304
|
+
}> = React.memo( ({ children, status }) => {
|
|
305
|
+
|
|
306
|
+
const {
|
|
307
|
+
setStatus,
|
|
308
|
+
setTitle,
|
|
309
|
+
addMeta,
|
|
310
|
+
addLink,
|
|
311
|
+
addStyle,
|
|
312
|
+
addScript,
|
|
313
|
+
} = useApp();
|
|
314
|
+
|
|
315
|
+
if ( status ) {
|
|
316
|
+
setStatus(status);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
React.Children.forEach(children, ( child ) => {
|
|
320
|
+
if ( !React.isValidElement(child) ) return;
|
|
321
|
+
|
|
322
|
+
const childType = child.type;
|
|
323
|
+
// React 19 types element.props as unknown; cast here since we
|
|
324
|
+
// inspect props dynamically based on the element type below.
|
|
325
|
+
const childProps = child.props as Record<string, any>;
|
|
326
|
+
const id = childProps['id'] || genRandomId();
|
|
327
|
+
|
|
328
|
+
switch ( childType ) {
|
|
329
|
+
case 'title': {
|
|
330
|
+
setTitle(childProps['children'] as string);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
case 'meta': {
|
|
334
|
+
addMeta(id.toString(), childProps as MetaProps);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
case 'link': {
|
|
338
|
+
addLink(id.toString(), childProps as LinkProps);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
case 'script': {
|
|
342
|
+
addScript(id.toString(), childProps as ScriptProps);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
case 'style': {
|
|
346
|
+
addStyle(id.toString(), childProps as StyleProps);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
default: {
|
|
350
|
+
console.warn(`HadarsHead: Unsupported child type: ${childType}`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return null;
|
|
357
|
+
} );
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
3
|
+
import type { HadarsEntryModule } from '../types/ninety';
|
|
4
|
+
import { initServerDataCache } from '$_HEAD_PATH$';
|
|
5
|
+
import * as _appMod from '$_MOD_PATH$';
|
|
6
|
+
|
|
7
|
+
const appMod = _appMod as HadarsEntryModule<{}>;
|
|
8
|
+
|
|
9
|
+
const getProps = () => {
|
|
10
|
+
const script = document.getElementById('hadars');
|
|
11
|
+
if (script) {
|
|
12
|
+
try {
|
|
13
|
+
const data = JSON.parse(script.textContent || '{}');
|
|
14
|
+
return data.hadars?.props || {};
|
|
15
|
+
} catch (e) {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const main = async () => {
|
|
23
|
+
let props = getProps();
|
|
24
|
+
|
|
25
|
+
// Seed the useServerData client cache from server-resolved values before
|
|
26
|
+
// hydration so that hooks return the same data on the first render.
|
|
27
|
+
if (props.__serverData && typeof props.__serverData === 'object') {
|
|
28
|
+
initServerDataCache(props.__serverData as Record<string, unknown>);
|
|
29
|
+
const { __serverData: _, ...rest } = props;
|
|
30
|
+
props = rest;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { location } = props;
|
|
34
|
+
|
|
35
|
+
if ( appMod.getClientProps ) {
|
|
36
|
+
props = await appMod.getClientProps(props);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
props = {
|
|
40
|
+
...props,
|
|
41
|
+
location,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const Component = appMod.default;
|
|
45
|
+
|
|
46
|
+
const appEl = document.getElementById("app");
|
|
47
|
+
if (appEl) {
|
|
48
|
+
// In HMR mode the client component may have already changed since SSR,
|
|
49
|
+
// so skip hydration to avoid mismatch warnings and do a fresh render.
|
|
50
|
+
if ((module as any).hot) {
|
|
51
|
+
createRoot(appEl).render(<Component {...props} />);
|
|
52
|
+
} else {
|
|
53
|
+
hydrateRoot(appEl, <Component {...props} />);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
main();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const parseCookies = (cookieString: string): Record<string, string> => {
|
|
2
|
+
const cookies: Record<string, string> = {};
|
|
3
|
+
if (!cookieString) {
|
|
4
|
+
return cookies;
|
|
5
|
+
}
|
|
6
|
+
const pairs = cookieString.split(';');
|
|
7
|
+
for (const pair of pairs) {
|
|
8
|
+
const index = pair.indexOf('=');
|
|
9
|
+
if (index > -1) {
|
|
10
|
+
const key = pair.slice(0, index).trim();
|
|
11
|
+
const value = pair.slice(index + 1).trim();
|
|
12
|
+
cookies[key] = decodeURIComponent(value);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return cookies;
|
|
16
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rspack/webpack loader that transforms `loadModule('path')` calls based on
|
|
3
|
+
* the compilation target:
|
|
4
|
+
*
|
|
5
|
+
* - web (browser): replaced with `import('./path')` — rspack treats this as
|
|
6
|
+
* a true dynamic import and splits the module into a separate chunk.
|
|
7
|
+
*
|
|
8
|
+
* - node (SSR): replaced with `Promise.resolve(require('./path'))` —
|
|
9
|
+
* rspack bundles the module statically so it is always available
|
|
10
|
+
* synchronously on the server, wrapped in Promise.resolve to keep the
|
|
11
|
+
* API shape identical to the client side.
|
|
12
|
+
*
|
|
13
|
+
* Example usage:
|
|
14
|
+
*
|
|
15
|
+
* import { loadModule } from 'hadars';
|
|
16
|
+
*
|
|
17
|
+
* // Code-split React component (wrap with React.lazy + Suspense):
|
|
18
|
+
* const MyComp = React.lazy(() => loadModule('./MyComp'));
|
|
19
|
+
*
|
|
20
|
+
* // Dynamic module load:
|
|
21
|
+
* const { default: fn } = await loadModule('./heavyUtil');
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Matches: loadModule('./path')
|
|
25
|
+
// loadModule<SomeType>('./path') (TypeScript generic, any complexity)
|
|
26
|
+
// Captures group 1 = quote char, group 2 = the module path.
|
|
27
|
+
// The `s` flag lets `.` span newlines so multi-line generics are handled.
|
|
28
|
+
const LOAD_MODULE_RE =
|
|
29
|
+
/\bloadModule\s*(?:<.*?>\s*)?\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/gs;
|
|
30
|
+
|
|
31
|
+
export default function loader(this: any, source: string): string {
|
|
32
|
+
const isServer = this.target === 'node' || this.target === 'async-node';
|
|
33
|
+
|
|
34
|
+
return source.replace(LOAD_MODULE_RE, (_match, quote, modulePath) => {
|
|
35
|
+
if (isServer) {
|
|
36
|
+
return `Promise.resolve(require(${quote}${modulePath}${quote}))`;
|
|
37
|
+
} else {
|
|
38
|
+
return `import(${quote}${modulePath}${quote})`;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { HadarsOptions, HadarsRequest } from "../types/ninety";
|
|
2
|
+
|
|
3
|
+
type ProxyHandler = (req: HadarsRequest) => ( Promise<Response | undefined> | undefined );
|
|
4
|
+
|
|
5
|
+
const cloneHeaders = (headers: Headers) => {
|
|
6
|
+
return new Headers(headers);
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const getCORSHeaders = (req: HadarsRequest) => {
|
|
10
|
+
const origin = req.headers.get('Origin') || '*';
|
|
11
|
+
return {
|
|
12
|
+
'Access-Control-Allow-Origin': origin,
|
|
13
|
+
'Access-Control-Allow-Methods': 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
|
14
|
+
'Access-Control-Allow-Headers': req.headers.get('Access-Control-Request-Headers') || '*',
|
|
15
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const createProxyHandler = (options: HadarsOptions): ProxyHandler => {
|
|
20
|
+
|
|
21
|
+
const { proxy, proxyCORS } = options;
|
|
22
|
+
|
|
23
|
+
if (!proxy) {
|
|
24
|
+
return () => undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (typeof proxy === 'function') {
|
|
28
|
+
return async (req: HadarsRequest) => {
|
|
29
|
+
if (req.method === 'OPTIONS' && options.proxyCORS) {
|
|
30
|
+
return new Response(null, {
|
|
31
|
+
status: 204,
|
|
32
|
+
headers: getCORSHeaders(req),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const res = await proxy(req);
|
|
36
|
+
if (res && proxyCORS) {
|
|
37
|
+
// Clone the response to modify headers
|
|
38
|
+
const modifiedHeaders = new Headers(res.headers);
|
|
39
|
+
Object.entries(getCORSHeaders(req)).forEach(([key, value]) => {
|
|
40
|
+
modifiedHeaders.set(key, value);
|
|
41
|
+
});
|
|
42
|
+
return new Response(res.body, {
|
|
43
|
+
status: res.status,
|
|
44
|
+
statusText: res.statusText,
|
|
45
|
+
headers: modifiedHeaders,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return res || undefined;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// sort proxy rules by length of path (longest first)
|
|
53
|
+
const proxyRules = Object.entries(proxy).sort((a, b) => b[0].length - a[0].length);
|
|
54
|
+
|
|
55
|
+
return async (req: HadarsRequest) => {
|
|
56
|
+
if (req.method === 'OPTIONS' && options.proxyCORS) {
|
|
57
|
+
return new Response(null, {
|
|
58
|
+
status: 204,
|
|
59
|
+
headers: getCORSHeaders(req),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
for (const [path, target] of proxyRules) {
|
|
63
|
+
if (req.pathname.startsWith(path)) {
|
|
64
|
+
const targetURL = new URL(target);
|
|
65
|
+
targetURL.pathname = targetURL.pathname.replace(/\/$/, '') + req.pathname.slice(path.length);
|
|
66
|
+
targetURL.search = req.search;
|
|
67
|
+
|
|
68
|
+
const sendHeaders = cloneHeaders(req.headers);
|
|
69
|
+
// Overwrite the Host header to match the target
|
|
70
|
+
sendHeaders.set('Host', targetURL.host);
|
|
71
|
+
|
|
72
|
+
const proxyReq = new Request(targetURL.toString(), {
|
|
73
|
+
method: req.method,
|
|
74
|
+
headers: sendHeaders,
|
|
75
|
+
body: ['GET', 'HEAD'].includes(req.method) ? undefined : req.body,
|
|
76
|
+
redirect: 'follow',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const res = await fetch(proxyReq);
|
|
80
|
+
if (proxyCORS) {
|
|
81
|
+
Object.entries(getCORSHeaders(req)).forEach(([key, value]) => {
|
|
82
|
+
res.headers.set(key, value);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
// Read the response body
|
|
86
|
+
const body = await res.arrayBuffer();
|
|
87
|
+
// remove content-length and content-encoding headers to avoid issues with modified body
|
|
88
|
+
const clonedRes = new Headers(res.headers);
|
|
89
|
+
clonedRes.delete('content-length');
|
|
90
|
+
clonedRes.delete('content-encoding');
|
|
91
|
+
// return a new Response with the modified headers and original body
|
|
92
|
+
return new Response(body, {
|
|
93
|
+
status: res.status,
|
|
94
|
+
statusText: res.statusText,
|
|
95
|
+
headers: clonedRes,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { HadarsRequest } from "../types/ninety";
|
|
2
|
+
import { parseCookies } from "./cookies";
|
|
3
|
+
|
|
4
|
+
export const parseRequest = (request: Request): HadarsRequest => {
|
|
5
|
+
const url = new URL(request.url);
|
|
6
|
+
const cookies = request.headers.get('Cookie') || '';
|
|
7
|
+
const cookieRecord: Record<string, string> = parseCookies(cookies);
|
|
8
|
+
return Object.assign(request, { pathname: url.pathname, search: url.search, location: url.pathname + url.search, cookies: cookieRecord });
|
|
9
|
+
};
|