hadars 0.1.35 → 0.1.37
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 +77 -0
- package/cli-lib.ts +90 -5
- package/dist/chunk-F7IIMM3J.js +1060 -0
- package/dist/chunk-UNQSQIOO.js +60 -0
- package/dist/cli.js +285 -261
- package/dist/hadars-B3b_6sj2.d.cts +188 -0
- package/dist/hadars-B3b_6sj2.d.ts +188 -0
- package/dist/index.cjs +11 -19
- package/dist/index.d.cts +105 -0
- package/dist/index.d.ts +4 -175
- package/dist/index.js +14 -42
- package/dist/{jsx-runtime-97ca74a5.d.ts → jsx-runtime-BOYrELJb.d.cts} +1 -1
- package/dist/jsx-runtime-BOYrELJb.d.ts +18 -0
- package/dist/lambda.cjs +1405 -0
- package/dist/lambda.d.cts +93 -0
- package/dist/lambda.d.ts +93 -0
- package/dist/lambda.js +499 -0
- package/dist/loader.cjs +22 -44
- package/dist/slim-react/index.cjs +29 -58
- package/dist/slim-react/index.d.cts +190 -0
- package/dist/slim-react/index.d.ts +3 -3
- package/dist/slim-react/index.js +34 -1043
- package/dist/slim-react/jsx-runtime.d.cts +1 -0
- package/dist/slim-react/jsx-runtime.d.ts +1 -1
- package/dist/ssr-render-worker.js +44 -78
- package/dist/ssr-watch.js +24 -7
- package/dist/utils/Head.tsx +2 -2
- package/package.json +11 -6
- package/src/build.ts +8 -166
- package/src/lambda.ts +287 -0
- package/src/types/hadars.ts +10 -0
- package/src/utils/Head.tsx +2 -2
- package/src/utils/rspack.ts +27 -0
- package/src/utils/ssrHandler.ts +185 -0
package/src/lambda.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS Lambda adapter for hadars.
|
|
3
|
+
*
|
|
4
|
+
* After running `hadars build`, import this in your Lambda entry file:
|
|
5
|
+
*
|
|
6
|
+
* // lambda.ts
|
|
7
|
+
* import { createLambdaHandler } from 'hadars/lambda';
|
|
8
|
+
* import config from './hadars.config';
|
|
9
|
+
* export const handler = createLambdaHandler(config);
|
|
10
|
+
*
|
|
11
|
+
* The returned handler speaks the API Gateway HTTP API (v2) payload format.
|
|
12
|
+
* API Gateway REST API (v1) events are also accepted.
|
|
13
|
+
*
|
|
14
|
+
* Static assets (JS, CSS, fonts) are served directly from the bundled
|
|
15
|
+
* `.hadars/static/` directory. For production use, front the Lambda with
|
|
16
|
+
* CloudFront and route static paths to an S3 origin instead.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import React from 'react';
|
|
20
|
+
import pathMod from 'node:path';
|
|
21
|
+
import { pathToFileURL } from 'node:url';
|
|
22
|
+
import fs from 'node:fs/promises';
|
|
23
|
+
import { createProxyHandler } from './utils/proxyHandler';
|
|
24
|
+
import { parseRequest } from './utils/request';
|
|
25
|
+
import { tryServeFile } from './utils/staticFile';
|
|
26
|
+
import { getReactResponse } from './utils/response';
|
|
27
|
+
import { buildSsrResponse, buildSsrHtml, makePrecontentHtmlGetter, createRenderCache } from './utils/ssrHandler';
|
|
28
|
+
import type { HadarsOptions, HadarsEntryModule, HadarsProps } from './types/hadars';
|
|
29
|
+
|
|
30
|
+
// ── Lambda event / response types ────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** API Gateway HTTP API (v2) event. */
|
|
33
|
+
export interface APIGatewayV2Event {
|
|
34
|
+
version: '2.0';
|
|
35
|
+
routeKey: string;
|
|
36
|
+
rawPath: string;
|
|
37
|
+
rawQueryString: string;
|
|
38
|
+
cookies?: string[];
|
|
39
|
+
headers: Record<string, string>;
|
|
40
|
+
requestContext: {
|
|
41
|
+
http: { method: string };
|
|
42
|
+
domainName: string;
|
|
43
|
+
};
|
|
44
|
+
body?: string;
|
|
45
|
+
isBase64Encoded: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** API Gateway REST API (v1) event. */
|
|
49
|
+
export interface APIGatewayV1Event {
|
|
50
|
+
httpMethod: string;
|
|
51
|
+
path: string;
|
|
52
|
+
queryStringParameters?: Record<string, string> | null;
|
|
53
|
+
headers?: Record<string, string> | null;
|
|
54
|
+
body?: string | null;
|
|
55
|
+
isBase64Encoded: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type APIGatewayEvent = APIGatewayV2Event | APIGatewayV1Event;
|
|
59
|
+
|
|
60
|
+
export interface LambdaResponse {
|
|
61
|
+
statusCode: number;
|
|
62
|
+
headers: Record<string, string>;
|
|
63
|
+
body: string;
|
|
64
|
+
isBase64Encoded: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type LambdaHandler = (event: APIGatewayEvent) => Promise<LambdaResponse>;
|
|
68
|
+
|
|
69
|
+
// ── Event → Request ───────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
function eventToRequest(event: APIGatewayEvent): Request {
|
|
72
|
+
let method: string;
|
|
73
|
+
let path: string;
|
|
74
|
+
let queryString: string;
|
|
75
|
+
let headers: Record<string, string>;
|
|
76
|
+
let rawBody: string | undefined | null;
|
|
77
|
+
let isBase64: boolean;
|
|
78
|
+
let host: string;
|
|
79
|
+
|
|
80
|
+
if ('version' in event && event.version === '2.0') {
|
|
81
|
+
method = event.requestContext.http.method;
|
|
82
|
+
path = event.rawPath;
|
|
83
|
+
queryString = event.rawQueryString;
|
|
84
|
+
headers = { ...event.headers };
|
|
85
|
+
// v2 puts cookies in a separate array; merge them back into the header
|
|
86
|
+
if (event.cookies?.length) headers['cookie'] = event.cookies.join('; ');
|
|
87
|
+
rawBody = event.body;
|
|
88
|
+
isBase64 = event.isBase64Encoded;
|
|
89
|
+
host = event.requestContext.domainName;
|
|
90
|
+
} else {
|
|
91
|
+
const e = event as APIGatewayV1Event;
|
|
92
|
+
method = e.httpMethod;
|
|
93
|
+
path = e.path;
|
|
94
|
+
const qs = e.queryStringParameters;
|
|
95
|
+
queryString = qs ? new URLSearchParams(qs as Record<string, string>).toString() : '';
|
|
96
|
+
headers = (e.headers as Record<string, string>) ?? {};
|
|
97
|
+
rawBody = e.body;
|
|
98
|
+
isBase64 = e.isBase64Encoded;
|
|
99
|
+
host = headers['host'] ?? 'lambda';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const url = `https://${host}${path}${queryString ? '?' + queryString : ''}`;
|
|
103
|
+
let body: BodyInit | undefined;
|
|
104
|
+
if (rawBody) body = isBase64 ? Buffer.from(rawBody, 'base64') : rawBody;
|
|
105
|
+
|
|
106
|
+
return new Request(url, {
|
|
107
|
+
method,
|
|
108
|
+
headers,
|
|
109
|
+
body: ['GET', 'HEAD'].includes(method) ? undefined : body,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Response → Lambda ─────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
const TEXT_RE = /\b(?:text\/|application\/(?:json|javascript|xml)|image\/svg\+xml)/;
|
|
116
|
+
|
|
117
|
+
async function responseToLambda(response: Response): Promise<LambdaResponse> {
|
|
118
|
+
const headers: Record<string, string> = {};
|
|
119
|
+
response.headers.forEach((v, k) => { headers[k] = v; });
|
|
120
|
+
|
|
121
|
+
const contentType = response.headers.get('Content-Type') ?? '';
|
|
122
|
+
if (TEXT_RE.test(contentType)) {
|
|
123
|
+
return {
|
|
124
|
+
statusCode: response.status,
|
|
125
|
+
headers,
|
|
126
|
+
body: await response.text(),
|
|
127
|
+
isBase64Encoded: false,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Binary response (images, fonts, pre-compressed assets, etc.)
|
|
132
|
+
return {
|
|
133
|
+
statusCode: response.status,
|
|
134
|
+
headers,
|
|
135
|
+
body: Buffer.from(await response.arrayBuffer()).toString('base64'),
|
|
136
|
+
isBase64Encoded: true,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
const HadarsFolder = './.hadars';
|
|
143
|
+
const StaticPath = `${HadarsFolder}/static`;
|
|
144
|
+
const SSR_FILENAME = 'index.ssr.js';
|
|
145
|
+
const noopCtx = { upgrade: () => false };
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Pre-loaded module and HTML for single-file Lambda bundles created by
|
|
149
|
+
* `hadars export lambda`. Passing this bypasses all runtime file I/O so
|
|
150
|
+
* the handler works without a `.hadars/` directory on disk.
|
|
151
|
+
*/
|
|
152
|
+
export interface LambdaBundled {
|
|
153
|
+
/** The compiled SSR module — import it statically in your entry shim. */
|
|
154
|
+
ssrModule: HadarsEntryModule<any>;
|
|
155
|
+
/**
|
|
156
|
+
* The contents of `.hadars/static/out.html` — load it as a text string
|
|
157
|
+
* at build time (esbuild's `--loader:.html=text` does this automatically
|
|
158
|
+
* when `hadars export lambda` runs).
|
|
159
|
+
*/
|
|
160
|
+
outHtml: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Creates an AWS Lambda handler from a hadars config.
|
|
165
|
+
*
|
|
166
|
+
* Must be called after `hadars build` has produced the `.hadars/` output
|
|
167
|
+
* directory. The handler is stateless — Lambda can reuse the same instance
|
|
168
|
+
* across invocations; the SSR module and HTML template are cached in memory
|
|
169
|
+
* after the first warm invocation.
|
|
170
|
+
*
|
|
171
|
+
* Pass a {@link LambdaBundled} object as the second argument when using
|
|
172
|
+
* `hadars export lambda` to produce a single-file bundle — in that mode all
|
|
173
|
+
* I/O is eliminated and the handler is fully self-contained.
|
|
174
|
+
*
|
|
175
|
+
* @example — standard (file-based) deployment
|
|
176
|
+
* export const handler = createLambdaHandler(config);
|
|
177
|
+
*
|
|
178
|
+
* @example — single-file bundle (generated entry shim)
|
|
179
|
+
* import * as ssrModule from './.hadars/index.ssr.js';
|
|
180
|
+
* import outHtml from './.hadars/static/out.html';
|
|
181
|
+
* export const handler = createLambdaHandler(config, { ssrModule, outHtml });
|
|
182
|
+
*/
|
|
183
|
+
export function createLambdaHandler(options: HadarsOptions, bundled?: LambdaBundled): LambdaHandler {
|
|
184
|
+
const cwd = process.cwd();
|
|
185
|
+
const fetchHandler = options.fetch;
|
|
186
|
+
const handleProxy = createProxyHandler(options);
|
|
187
|
+
|
|
188
|
+
const getPrecontentHtml = bundled
|
|
189
|
+
? makePrecontentHtmlGetter(Promise.resolve(bundled.outHtml))
|
|
190
|
+
: makePrecontentHtmlGetter(fs.readFile(pathMod.join(cwd, StaticPath, 'out.html'), 'utf-8'));
|
|
191
|
+
|
|
192
|
+
// Hoist the SSR module reference so it is resolved once, not on every
|
|
193
|
+
// request. In bundled mode the module is already in-memory; in file-based
|
|
194
|
+
// mode we lazily import it and cache the promise so Node's module cache is
|
|
195
|
+
// only consulted once.
|
|
196
|
+
let ssrModulePromise: Promise<HadarsEntryModule<any>> | null = null;
|
|
197
|
+
const getSsrModule = (): Promise<HadarsEntryModule<any>> => {
|
|
198
|
+
if (bundled) return Promise.resolve(bundled.ssrModule);
|
|
199
|
+
if (!ssrModulePromise) {
|
|
200
|
+
ssrModulePromise = import(
|
|
201
|
+
pathToFileURL(pathMod.resolve(cwd, HadarsFolder, SSR_FILENAME)).href
|
|
202
|
+
) as Promise<HadarsEntryModule<any>>;
|
|
203
|
+
}
|
|
204
|
+
return ssrModulePromise;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const runHandler = async (req: Request): Promise<Response> => {
|
|
208
|
+
const request = parseRequest(req);
|
|
209
|
+
|
|
210
|
+
if (fetchHandler) {
|
|
211
|
+
const res = await fetchHandler(request);
|
|
212
|
+
if (res) return res;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const proxied = await handleProxy(request);
|
|
216
|
+
if (proxied) return proxied;
|
|
217
|
+
|
|
218
|
+
const urlPath = new URL(request.url).pathname;
|
|
219
|
+
|
|
220
|
+
if (!bundled) {
|
|
221
|
+
// File-based deployment: serve static assets from disk.
|
|
222
|
+
const staticRes = await tryServeFile(pathMod.join(cwd, StaticPath, urlPath));
|
|
223
|
+
if (staticRes) return staticRes;
|
|
224
|
+
|
|
225
|
+
const projectStaticPath = pathMod.resolve(cwd, 'static');
|
|
226
|
+
const projectRes = await tryServeFile(pathMod.join(projectStaticPath, urlPath));
|
|
227
|
+
if (projectRes) return projectRes;
|
|
228
|
+
|
|
229
|
+
const routeClean = urlPath.replace(/(^\/|\/$)/g, '');
|
|
230
|
+
if (routeClean) {
|
|
231
|
+
const routeRes = await tryServeFile(
|
|
232
|
+
pathMod.join(cwd, StaticPath, routeClean, 'index.html'),
|
|
233
|
+
);
|
|
234
|
+
if (routeRes) return routeRes;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// bundled mode: static assets are not served from Lambda — route them to S3/CDN.
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const {
|
|
241
|
+
default: Component,
|
|
242
|
+
getInitProps,
|
|
243
|
+
getAfterRenderProps,
|
|
244
|
+
getFinalProps,
|
|
245
|
+
} = await getSsrModule();
|
|
246
|
+
|
|
247
|
+
const { bodyHtml, clientProps, status, headHtml } = await getReactResponse(request, {
|
|
248
|
+
document: {
|
|
249
|
+
body: Component as React.FC<HadarsProps<object>>,
|
|
250
|
+
lang: 'en',
|
|
251
|
+
getInitProps,
|
|
252
|
+
getAfterRenderProps,
|
|
253
|
+
getFinalProps,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
if (request.headers.get('Accept') === 'application/json') {
|
|
258
|
+
const serverData = (clientProps as any).__serverData ?? {};
|
|
259
|
+
return new Response(JSON.stringify({ serverData }), {
|
|
260
|
+
status,
|
|
261
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Build the HTML string directly — avoids creating a ReadableStream
|
|
266
|
+
// that would immediately be drained by the Lambda response serialiser.
|
|
267
|
+
const html = await buildSsrHtml(bodyHtml, clientProps, headHtml, getPrecontentHtml);
|
|
268
|
+
return new Response(html, {
|
|
269
|
+
status,
|
|
270
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
271
|
+
});
|
|
272
|
+
} catch (err: any) {
|
|
273
|
+
console.error('[hadars] SSR render error:', err);
|
|
274
|
+
return new Response('Internal Server Error', { status: 500 });
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const finalHandler: (req: Request, ctx: any) => Promise<Response | undefined> = options.cache
|
|
279
|
+
? createRenderCache(options.cache, (req) => runHandler(req))
|
|
280
|
+
: (req: Request) => runHandler(req);
|
|
281
|
+
|
|
282
|
+
return async (event: APIGatewayEvent): Promise<LambdaResponse> => {
|
|
283
|
+
const req = eventToRequest(event);
|
|
284
|
+
const response = (await finalHandler(req, noopCtx)) ?? new Response('Not Found', { status: 404 });
|
|
285
|
+
return responseToLambda(response);
|
|
286
|
+
};
|
|
287
|
+
}
|
package/src/types/hadars.ts
CHANGED
|
@@ -142,6 +142,16 @@ export interface HadarsOptions {
|
|
|
142
142
|
* ]
|
|
143
143
|
*/
|
|
144
144
|
moduleRules?: Record<string, any>[];
|
|
145
|
+
/**
|
|
146
|
+
* Additional rspack/webpack-compatible plugins applied to both the client
|
|
147
|
+
* and SSR bundles. Any object that implements the `apply(compiler)` method
|
|
148
|
+
* (the standard webpack/rspack plugin interface) is accepted.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* import { SubresourceIntegrityPlugin } from 'webpack-subresource-integrity';
|
|
152
|
+
* plugins: [new SubresourceIntegrityPlugin()]
|
|
153
|
+
*/
|
|
154
|
+
plugins?: Array<{ apply(compiler: any): void }>;
|
|
145
155
|
/**
|
|
146
156
|
* SSR response cache for `run()` mode. Has no effect in `dev()` mode.
|
|
147
157
|
*
|
package/src/utils/Head.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import type { AppContext, AppUnsuspend, LinkProps, MetaProps, ScriptProps, StyleProps } from '../types/hadars'
|
|
2
|
+
import type { AppContext as HadarsAppContext, AppUnsuspend, LinkProps, MetaProps, ScriptProps, StyleProps } from '../types/hadars'
|
|
3
3
|
|
|
4
4
|
interface InnerContext {
|
|
5
5
|
setTitle: (title: string) => void;
|
|
@@ -31,7 +31,7 @@ const AppContext = React.createContext<InnerContext>({
|
|
|
31
31
|
|
|
32
32
|
export const AppProviderSSR: React.FC<{
|
|
33
33
|
children: React.ReactNode,
|
|
34
|
-
context:
|
|
34
|
+
context: HadarsAppContext,
|
|
35
35
|
}> = React.memo( ({ children, context }) => {
|
|
36
36
|
|
|
37
37
|
const { head } = context;
|
package/src/utils/rspack.ts
CHANGED
|
@@ -136,6 +136,8 @@ interface EntryOptions {
|
|
|
136
136
|
optimization?: Record<string, unknown>;
|
|
137
137
|
// additional module rules appended after the built-in rules
|
|
138
138
|
moduleRules?: Record<string, any>[];
|
|
139
|
+
// additional rspack/webpack-compatible plugins (applied after built-in plugins)
|
|
140
|
+
plugins?: Array<{ apply(compiler: any): void }>;
|
|
139
141
|
// force React runtime mode independently of build mode (client only)
|
|
140
142
|
reactMode?: 'development' | 'production';
|
|
141
143
|
}
|
|
@@ -264,6 +266,30 @@ const buildCompilerConfig = (
|
|
|
264
266
|
}
|
|
265
267
|
|
|
266
268
|
const extraPlugins: any[] = [];
|
|
269
|
+
|
|
270
|
+
// Built-in plugin: force classic chunk loading for web worker sub-compilations.
|
|
271
|
+
// Without this, worker child compilers inherit the parent's outputModule:true context
|
|
272
|
+
// and may emit ES module chunks that cannot be loaded inside classic workers
|
|
273
|
+
// via importScripts. Applied to client builds only — SSR doesn't spawn workers.
|
|
274
|
+
if (!isServerBuild) {
|
|
275
|
+
extraPlugins.push({
|
|
276
|
+
apply(compiler: any) {
|
|
277
|
+
compiler.hooks.compilation.tap('HadarsWorkerChunkLoading', (compilation: any) => {
|
|
278
|
+
compilation.hooks.childCompiler.tap(
|
|
279
|
+
'HadarsWorkerChunkLoading',
|
|
280
|
+
(childCompiler: any) => {
|
|
281
|
+
if (childCompiler.options?.output) {
|
|
282
|
+
childCompiler.options.output.chunkLoading = 'import-scripts';
|
|
283
|
+
}
|
|
284
|
+
if (childCompiler.options?.experiments) {
|
|
285
|
+
childCompiler.options.experiments.outputModule = false;
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
}
|
|
267
293
|
const defineValues: Record<string, string> = { ...(opts.define ?? {}) };
|
|
268
294
|
// When reactMode overrides the React runtime we must also set process.env.NODE_ENV
|
|
269
295
|
// so React picks its dev/prod bundle, independently of the rspack build mode.
|
|
@@ -352,6 +378,7 @@ const buildCompilerConfig = (
|
|
|
352
378
|
isDev && !isServerBuild && new ReactRefreshPlugin(),
|
|
353
379
|
includeHotPlugin && isDev && !isServerBuild && new rspack.HotModuleReplacementPlugin(),
|
|
354
380
|
...extraPlugins,
|
|
381
|
+
...(opts.plugins ?? []),
|
|
355
382
|
],
|
|
356
383
|
...localConfig,
|
|
357
384
|
// Merge base resolve (modules, tsConfig, extensions) with per-build resolve
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { parseRequest } from './request';
|
|
2
|
+
import type { HadarsOptions } from '../types/hadars';
|
|
3
|
+
|
|
4
|
+
export const HEAD_MARKER = '<meta name="HADARS_HEAD">';
|
|
5
|
+
export const BODY_MARKER = '<meta name="HADARS_BODY">';
|
|
6
|
+
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
|
|
9
|
+
// ── HTML response assembly ────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export async function buildSsrResponse(
|
|
12
|
+
bodyHtml: string,
|
|
13
|
+
clientProps: Record<string, unknown>,
|
|
14
|
+
headHtml: string,
|
|
15
|
+
status: number,
|
|
16
|
+
getPrecontentHtml: (headHtml: string) => Promise<[string, string]>,
|
|
17
|
+
): Promise<Response> {
|
|
18
|
+
const responseStream = new ReadableStream({
|
|
19
|
+
async start(controller) {
|
|
20
|
+
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
21
|
+
// Flush the shell (precontentHtml) immediately so the browser can
|
|
22
|
+
// start loading CSS/fonts before the body is assembled.
|
|
23
|
+
controller.enqueue(encoder.encode(precontentHtml));
|
|
24
|
+
|
|
25
|
+
const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c');
|
|
26
|
+
controller.enqueue(encoder.encode(
|
|
27
|
+
`<div id="app">${bodyHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>` + postContent,
|
|
28
|
+
));
|
|
29
|
+
controller.close();
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return new Response(responseStream, {
|
|
34
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
35
|
+
status,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Like {@link buildSsrResponse} but returns the complete HTML string directly
|
|
41
|
+
* instead of wrapping it in a streaming Response. Use this in environments
|
|
42
|
+
* where streaming is not beneficial (e.g. AWS Lambda) to avoid the
|
|
43
|
+
* ReadableStream allocation and the subsequent `.text()` drain overhead.
|
|
44
|
+
*/
|
|
45
|
+
export async function buildSsrHtml(
|
|
46
|
+
bodyHtml: string,
|
|
47
|
+
clientProps: Record<string, unknown>,
|
|
48
|
+
headHtml: string,
|
|
49
|
+
getPrecontentHtml: (headHtml: string) => Promise<[string, string]>,
|
|
50
|
+
): Promise<string> {
|
|
51
|
+
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
52
|
+
const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c');
|
|
53
|
+
return (
|
|
54
|
+
precontentHtml +
|
|
55
|
+
`<div id="app">${bodyHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>` +
|
|
56
|
+
postContent
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns a function that parses `out.html` into pre-head, post-head, and
|
|
62
|
+
* post-content segments and caches the result. Call the returned function with
|
|
63
|
+
* the per-request headHtml to produce the full HTML prefix and suffix.
|
|
64
|
+
*/
|
|
65
|
+
export const makePrecontentHtmlGetter = (htmlFilePromise: Promise<string>) => {
|
|
66
|
+
let preHead: string | null = null;
|
|
67
|
+
let postHead: string | null = null;
|
|
68
|
+
let postContent: string | null = null;
|
|
69
|
+
return async (headHtml: string): Promise<[string, string]> => {
|
|
70
|
+
if (preHead === null || postHead === null || postContent === null) {
|
|
71
|
+
const html = await htmlFilePromise;
|
|
72
|
+
const headEnd = html.indexOf(HEAD_MARKER);
|
|
73
|
+
const contentStart = html.indexOf(BODY_MARKER);
|
|
74
|
+
preHead = html.slice(0, headEnd);
|
|
75
|
+
postHead = html.slice(headEnd + HEAD_MARKER.length, contentStart);
|
|
76
|
+
postContent = html.slice(contentStart + BODY_MARKER.length);
|
|
77
|
+
}
|
|
78
|
+
return [preHead! + headHtml + postHead!, postContent!];
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// ── SSR response cache ────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export interface CacheEntry {
|
|
85
|
+
/** Gzip-compressed response body. */
|
|
86
|
+
body: Uint8Array;
|
|
87
|
+
status: number;
|
|
88
|
+
/** Headers with Content-Encoding: gzip already set. */
|
|
89
|
+
headers: [string, string][];
|
|
90
|
+
expiresAt: number | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type CacheFetchHandler = (req: Request, ctx: any) => Promise<Response | undefined>;
|
|
94
|
+
|
|
95
|
+
async function transformStream(
|
|
96
|
+
data: Uint8Array,
|
|
97
|
+
stream: { writable: WritableStream; readable: ReadableStream<Uint8Array> },
|
|
98
|
+
): Promise<Uint8Array> {
|
|
99
|
+
const writer = stream.writable.getWriter();
|
|
100
|
+
writer.write(data);
|
|
101
|
+
writer.close();
|
|
102
|
+
const chunks: Uint8Array[] = [];
|
|
103
|
+
const reader = stream.readable.getReader();
|
|
104
|
+
while (true) {
|
|
105
|
+
const { done, value } = await reader.read();
|
|
106
|
+
if (done) break;
|
|
107
|
+
chunks.push(value);
|
|
108
|
+
}
|
|
109
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
110
|
+
const out = new Uint8Array(total);
|
|
111
|
+
let offset = 0;
|
|
112
|
+
for (const c of chunks) { out.set(c, offset); offset += c.length; }
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const gzipCompress = (d: Uint8Array) => transformStream(d, new (globalThis as any).CompressionStream('gzip'));
|
|
117
|
+
export const gzipDecompress = (d: Uint8Array) => transformStream(d, new (globalThis as any).DecompressionStream('gzip'));
|
|
118
|
+
|
|
119
|
+
export async function buildCacheEntry(res: Response, ttl: number | undefined): Promise<CacheEntry> {
|
|
120
|
+
const buf = await res.arrayBuffer();
|
|
121
|
+
const body = await gzipCompress(new Uint8Array(buf));
|
|
122
|
+
const headers: [string, string][] = [];
|
|
123
|
+
res.headers.forEach((v, k) => {
|
|
124
|
+
if (k.toLowerCase() !== 'content-encoding' && k.toLowerCase() !== 'content-length') {
|
|
125
|
+
headers.push([k, v]);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
headers.push(['content-encoding', 'gzip']);
|
|
129
|
+
return { body, status: res.status, headers, expiresAt: ttl != null ? Date.now() + ttl : null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function serveFromEntry(entry: CacheEntry, req: Request): Promise<Response> {
|
|
133
|
+
const accept = req.headers.get('Accept-Encoding') ?? '';
|
|
134
|
+
if (accept.includes('gzip')) {
|
|
135
|
+
return new Response(entry.body.buffer as ArrayBuffer, { status: entry.status, headers: entry.headers });
|
|
136
|
+
}
|
|
137
|
+
const plain = await gzipDecompress(entry.body);
|
|
138
|
+
const headers = entry.headers.filter(([k]) => k.toLowerCase() !== 'content-encoding');
|
|
139
|
+
return new Response(plain.buffer as ArrayBuffer, { status: entry.status, headers });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createRenderCache(
|
|
143
|
+
opts: NonNullable<HadarsOptions['cache']>,
|
|
144
|
+
handler: CacheFetchHandler,
|
|
145
|
+
): CacheFetchHandler {
|
|
146
|
+
const store = new Map<string, CacheEntry>();
|
|
147
|
+
const inFlight = new Map<string, Promise<CacheEntry | null>>();
|
|
148
|
+
|
|
149
|
+
return async (req, ctx) => {
|
|
150
|
+
const hadarsReq = parseRequest(req);
|
|
151
|
+
const cacheOpts = await opts(hadarsReq);
|
|
152
|
+
const key = cacheOpts?.key ?? null;
|
|
153
|
+
|
|
154
|
+
if (key != null) {
|
|
155
|
+
const entry = store.get(key);
|
|
156
|
+
if (entry) {
|
|
157
|
+
const expired = entry.expiresAt != null && Date.now() >= entry.expiresAt;
|
|
158
|
+
if (!expired) return serveFromEntry(entry, req);
|
|
159
|
+
store.delete(key);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let flight = inFlight.get(key);
|
|
163
|
+
if (!flight) {
|
|
164
|
+
const ttl = cacheOpts?.ttl;
|
|
165
|
+
flight = handler(new Request(req), ctx)
|
|
166
|
+
.then(async (res) => {
|
|
167
|
+
if (!res || res.status < 200 || res.status >= 300 || res.headers.has('set-cookie')) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const newEntry = await buildCacheEntry(res, ttl);
|
|
171
|
+
store.set(key, newEntry);
|
|
172
|
+
return newEntry;
|
|
173
|
+
})
|
|
174
|
+
.catch(() => null)
|
|
175
|
+
.finally(() => inFlight.delete(key));
|
|
176
|
+
inFlight.set(key, flight);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const newEntry = await flight;
|
|
180
|
+
if (newEntry) return serveFromEntry(newEntry, req);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return handler(req, ctx);
|
|
184
|
+
};
|
|
185
|
+
}
|