@flexireact/core 2.3.0 → 2.4.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.
- package/core/edge/cache.ts +344 -0
- package/core/edge/fetch-polyfill.ts +247 -0
- package/core/edge/handler.ts +248 -0
- package/core/edge/index.ts +81 -0
- package/core/edge/ppr.ts +264 -0
- package/core/edge/runtime.ts +161 -0
- package/core/index.ts +49 -1
- package/package.json +1 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlexiReact Universal Cache System
|
|
3
|
+
*
|
|
4
|
+
* Smart caching that works on:
|
|
5
|
+
* - Cloudflare Workers (Cache API + KV)
|
|
6
|
+
* - Vercel Edge (Edge Config + KV)
|
|
7
|
+
* - Deno (Deno KV)
|
|
8
|
+
* - Node.js/Bun (In-memory + File cache)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { detectRuntime } from './runtime.js';
|
|
12
|
+
|
|
13
|
+
// Cache entry
|
|
14
|
+
export interface CacheEntry<T = any> {
|
|
15
|
+
value: T;
|
|
16
|
+
expires: number; // timestamp
|
|
17
|
+
stale?: number; // stale-while-revalidate timestamp
|
|
18
|
+
tags?: string[];
|
|
19
|
+
etag?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Cache options
|
|
23
|
+
export interface CacheOptions {
|
|
24
|
+
ttl?: number; // seconds
|
|
25
|
+
staleWhileRevalidate?: number; // seconds
|
|
26
|
+
tags?: string[];
|
|
27
|
+
key?: string;
|
|
28
|
+
revalidate?: number | false; // ISR-style revalidation
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Cache storage interface
|
|
32
|
+
export interface CacheStorage {
|
|
33
|
+
get<T>(key: string): Promise<CacheEntry<T> | null>;
|
|
34
|
+
set<T>(key: string, entry: CacheEntry<T>): Promise<void>;
|
|
35
|
+
delete(key: string): Promise<void>;
|
|
36
|
+
deleteByTag(tag: string): Promise<void>;
|
|
37
|
+
clear(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// In-memory cache (fallback)
|
|
41
|
+
class MemoryCache implements CacheStorage {
|
|
42
|
+
private store = new Map<string, CacheEntry>();
|
|
43
|
+
private tagIndex = new Map<string, Set<string>>();
|
|
44
|
+
|
|
45
|
+
async get<T>(key: string): Promise<CacheEntry<T> | null> {
|
|
46
|
+
const entry = this.store.get(key);
|
|
47
|
+
if (!entry) return null;
|
|
48
|
+
|
|
49
|
+
// Check expiration
|
|
50
|
+
if (entry.expires && entry.expires < Date.now()) {
|
|
51
|
+
// Check stale-while-revalidate
|
|
52
|
+
if (entry.stale && entry.stale > Date.now()) {
|
|
53
|
+
return { ...entry, value: entry.value as T };
|
|
54
|
+
}
|
|
55
|
+
this.store.delete(key);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { ...entry, value: entry.value as T };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async set<T>(key: string, entry: CacheEntry<T>): Promise<void> {
|
|
63
|
+
this.store.set(key, entry);
|
|
64
|
+
|
|
65
|
+
// Index by tags
|
|
66
|
+
if (entry.tags) {
|
|
67
|
+
entry.tags.forEach(tag => {
|
|
68
|
+
if (!this.tagIndex.has(tag)) {
|
|
69
|
+
this.tagIndex.set(tag, new Set());
|
|
70
|
+
}
|
|
71
|
+
this.tagIndex.get(tag)!.add(key);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async delete(key: string): Promise<void> {
|
|
77
|
+
this.store.delete(key);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async deleteByTag(tag: string): Promise<void> {
|
|
81
|
+
const keys = this.tagIndex.get(tag);
|
|
82
|
+
if (keys) {
|
|
83
|
+
keys.forEach(key => this.store.delete(key));
|
|
84
|
+
this.tagIndex.delete(tag);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async clear(): Promise<void> {
|
|
89
|
+
this.store.clear();
|
|
90
|
+
this.tagIndex.clear();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Cloudflare KV cache
|
|
95
|
+
class CloudflareCache implements CacheStorage {
|
|
96
|
+
private kv: any;
|
|
97
|
+
|
|
98
|
+
constructor(kv: any) {
|
|
99
|
+
this.kv = kv;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async get<T>(key: string): Promise<CacheEntry<T> | null> {
|
|
103
|
+
try {
|
|
104
|
+
const data = await this.kv.get(key, 'json');
|
|
105
|
+
if (!data) return null;
|
|
106
|
+
|
|
107
|
+
const entry = data as CacheEntry<T>;
|
|
108
|
+
if (entry.expires && entry.expires < Date.now()) {
|
|
109
|
+
if (entry.stale && entry.stale > Date.now()) {
|
|
110
|
+
return entry;
|
|
111
|
+
}
|
|
112
|
+
await this.kv.delete(key);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return entry;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async set<T>(key: string, entry: CacheEntry<T>): Promise<void> {
|
|
123
|
+
const ttl = entry.expires ? Math.ceil((entry.expires - Date.now()) / 1000) : undefined;
|
|
124
|
+
await this.kv.put(key, JSON.stringify(entry), { expirationTtl: ttl });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async delete(key: string): Promise<void> {
|
|
128
|
+
await this.kv.delete(key);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async deleteByTag(tag: string): Promise<void> {
|
|
132
|
+
// KV doesn't support tag-based deletion natively
|
|
133
|
+
// Would need to maintain a tag index
|
|
134
|
+
console.warn('Tag-based deletion not fully supported in Cloudflare KV');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async clear(): Promise<void> {
|
|
138
|
+
// KV doesn't support clear
|
|
139
|
+
console.warn('Clear not supported in Cloudflare KV');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Deno KV cache
|
|
144
|
+
class DenoKVCache implements CacheStorage {
|
|
145
|
+
private kv: any;
|
|
146
|
+
|
|
147
|
+
constructor() {
|
|
148
|
+
// @ts-ignore - Deno global
|
|
149
|
+
this.kv = Deno.openKv();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async get<T>(key: string): Promise<CacheEntry<T> | null> {
|
|
153
|
+
try {
|
|
154
|
+
const kv = await this.kv;
|
|
155
|
+
const result = await kv.get(['cache', key]);
|
|
156
|
+
if (!result.value) return null;
|
|
157
|
+
|
|
158
|
+
const entry = result.value as CacheEntry<T>;
|
|
159
|
+
if (entry.expires && entry.expires < Date.now()) {
|
|
160
|
+
if (entry.stale && entry.stale > Date.now()) {
|
|
161
|
+
return entry;
|
|
162
|
+
}
|
|
163
|
+
await kv.delete(['cache', key]);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return entry;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async set<T>(key: string, entry: CacheEntry<T>): Promise<void> {
|
|
174
|
+
const kv = await this.kv;
|
|
175
|
+
await kv.set(['cache', key], entry);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async delete(key: string): Promise<void> {
|
|
179
|
+
const kv = await this.kv;
|
|
180
|
+
await kv.delete(['cache', key]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async deleteByTag(tag: string): Promise<void> {
|
|
184
|
+
// Would need tag index
|
|
185
|
+
console.warn('Tag-based deletion requires tag index in Deno KV');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async clear(): Promise<void> {
|
|
189
|
+
// Would need to iterate all keys
|
|
190
|
+
console.warn('Clear requires iteration in Deno KV');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Create cache based on runtime
|
|
195
|
+
function createCacheStorage(options?: { kv?: any }): CacheStorage {
|
|
196
|
+
const runtime = detectRuntime();
|
|
197
|
+
|
|
198
|
+
switch (runtime) {
|
|
199
|
+
case 'cloudflare':
|
|
200
|
+
if (options?.kv) {
|
|
201
|
+
return new CloudflareCache(options.kv);
|
|
202
|
+
}
|
|
203
|
+
return new MemoryCache();
|
|
204
|
+
|
|
205
|
+
case 'deno':
|
|
206
|
+
return new DenoKVCache();
|
|
207
|
+
|
|
208
|
+
case 'node':
|
|
209
|
+
case 'bun':
|
|
210
|
+
default:
|
|
211
|
+
return new MemoryCache();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Main cache instance
|
|
216
|
+
let cacheStorage: CacheStorage = new MemoryCache();
|
|
217
|
+
|
|
218
|
+
// Initialize cache with platform-specific storage
|
|
219
|
+
export function initCache(options?: { kv?: any }): void {
|
|
220
|
+
cacheStorage = createCacheStorage(options);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Cache function wrapper (like React cache)
|
|
224
|
+
export function cacheFunction<T extends (...args: any[]) => Promise<any>>(
|
|
225
|
+
fn: T,
|
|
226
|
+
options: CacheOptions = {}
|
|
227
|
+
): T {
|
|
228
|
+
const { ttl = 60, staleWhileRevalidate = 0, tags = [] } = options;
|
|
229
|
+
|
|
230
|
+
return (async (...args: any[]) => {
|
|
231
|
+
const key = options.key || `fn:${fn.name}:${JSON.stringify(args)}`;
|
|
232
|
+
|
|
233
|
+
// Try cache first
|
|
234
|
+
const cached = await cacheStorage.get(key);
|
|
235
|
+
if (cached) {
|
|
236
|
+
// Check if stale and needs revalidation
|
|
237
|
+
if (cached.expires < Date.now() && cached.stale && cached.stale > Date.now()) {
|
|
238
|
+
// Return stale data, revalidate in background
|
|
239
|
+
queueMicrotask(async () => {
|
|
240
|
+
try {
|
|
241
|
+
const fresh = await fn(...args);
|
|
242
|
+
await cacheStorage.set(key, {
|
|
243
|
+
value: fresh,
|
|
244
|
+
expires: Date.now() + ttl * 1000,
|
|
245
|
+
stale: Date.now() + (ttl + staleWhileRevalidate) * 1000,
|
|
246
|
+
tags
|
|
247
|
+
});
|
|
248
|
+
} catch (e) {
|
|
249
|
+
console.error('Background revalidation failed:', e);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return cached.value;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Execute function
|
|
257
|
+
const result = await fn(...args);
|
|
258
|
+
|
|
259
|
+
// Cache result
|
|
260
|
+
await cacheStorage.set(key, {
|
|
261
|
+
value: result,
|
|
262
|
+
expires: Date.now() + ttl * 1000,
|
|
263
|
+
stale: staleWhileRevalidate ? Date.now() + (ttl + staleWhileRevalidate) * 1000 : undefined,
|
|
264
|
+
tags
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return result;
|
|
268
|
+
}) as T;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Unstable cache (Next.js compatible API)
|
|
272
|
+
export function unstable_cache<T extends (...args: any[]) => Promise<any>>(
|
|
273
|
+
fn: T,
|
|
274
|
+
keyParts?: string[],
|
|
275
|
+
options?: { revalidate?: number | false; tags?: string[] }
|
|
276
|
+
): T {
|
|
277
|
+
return cacheFunction(fn, {
|
|
278
|
+
key: keyParts?.join(':'),
|
|
279
|
+
ttl: typeof options?.revalidate === 'number' ? options.revalidate : 3600,
|
|
280
|
+
tags: options?.tags
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Revalidate by tag
|
|
285
|
+
export async function revalidateTag(tag: string): Promise<void> {
|
|
286
|
+
await cacheStorage.deleteByTag(tag);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Revalidate by path
|
|
290
|
+
export async function revalidatePath(path: string): Promise<void> {
|
|
291
|
+
await cacheStorage.delete(`page:${path}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Cache object for direct access
|
|
295
|
+
export const cache = {
|
|
296
|
+
get: <T>(key: string) => cacheStorage.get<T>(key),
|
|
297
|
+
set: <T>(key: string, value: T, options: CacheOptions = {}) => {
|
|
298
|
+
const { ttl = 60, staleWhileRevalidate = 0, tags = [] } = options;
|
|
299
|
+
return cacheStorage.set(key, {
|
|
300
|
+
value,
|
|
301
|
+
expires: Date.now() + ttl * 1000,
|
|
302
|
+
stale: staleWhileRevalidate ? Date.now() + (ttl + staleWhileRevalidate) * 1000 : undefined,
|
|
303
|
+
tags
|
|
304
|
+
});
|
|
305
|
+
},
|
|
306
|
+
delete: (key: string) => cacheStorage.delete(key),
|
|
307
|
+
deleteByTag: (tag: string) => cacheStorage.deleteByTag(tag),
|
|
308
|
+
clear: () => cacheStorage.clear(),
|
|
309
|
+
|
|
310
|
+
// Wrap function with caching
|
|
311
|
+
wrap: cacheFunction,
|
|
312
|
+
|
|
313
|
+
// Next.js compatible
|
|
314
|
+
unstable_cache,
|
|
315
|
+
revalidateTag,
|
|
316
|
+
revalidatePath
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Request-level cache (per-request deduplication)
|
|
320
|
+
const requestCache = new WeakMap<Request, Map<string, any>>();
|
|
321
|
+
|
|
322
|
+
export function getRequestCache(request: Request): Map<string, any> {
|
|
323
|
+
if (!requestCache.has(request)) {
|
|
324
|
+
requestCache.set(request, new Map());
|
|
325
|
+
}
|
|
326
|
+
return requestCache.get(request)!;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// React-style cache for request deduplication
|
|
330
|
+
export function reactCache<T extends (...args: any[]) => any>(fn: T): T {
|
|
331
|
+
const cache = new Map<string, any>();
|
|
332
|
+
|
|
333
|
+
return ((...args: any[]) => {
|
|
334
|
+
const key = JSON.stringify(args);
|
|
335
|
+
if (cache.has(key)) {
|
|
336
|
+
return cache.get(key);
|
|
337
|
+
}
|
|
338
|
+
const result = fn(...args);
|
|
339
|
+
cache.set(key, result);
|
|
340
|
+
return result;
|
|
341
|
+
}) as T;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export default cache;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch API Polyfill for Universal Compatibility
|
|
3
|
+
*
|
|
4
|
+
* Ensures Request, Response, Headers work everywhere
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Use native Web APIs if available, otherwise polyfill
|
|
8
|
+
const globalFetch = globalThis.fetch;
|
|
9
|
+
const GlobalRequest = globalThis.Request;
|
|
10
|
+
const GlobalResponse = globalThis.Response;
|
|
11
|
+
const GlobalHeaders = globalThis.Headers;
|
|
12
|
+
|
|
13
|
+
// Extended Request with FlexiReact helpers
|
|
14
|
+
export class FlexiRequest extends GlobalRequest {
|
|
15
|
+
private _parsedUrl?: URL;
|
|
16
|
+
private _cookies?: Map<string, string>;
|
|
17
|
+
|
|
18
|
+
constructor(input: RequestInfo | URL, init?: RequestInit) {
|
|
19
|
+
super(input, init);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get pathname(): string {
|
|
23
|
+
if (!this._parsedUrl) {
|
|
24
|
+
this._parsedUrl = new URL(this.url);
|
|
25
|
+
}
|
|
26
|
+
return this._parsedUrl.pathname;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get searchParams(): URLSearchParams {
|
|
30
|
+
if (!this._parsedUrl) {
|
|
31
|
+
this._parsedUrl = new URL(this.url);
|
|
32
|
+
}
|
|
33
|
+
return this._parsedUrl.searchParams;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get cookies(): Map<string, string> {
|
|
37
|
+
if (!this._cookies) {
|
|
38
|
+
this._cookies = new Map();
|
|
39
|
+
const cookieHeader = this.headers.get('cookie');
|
|
40
|
+
if (cookieHeader) {
|
|
41
|
+
cookieHeader.split(';').forEach(cookie => {
|
|
42
|
+
const [name, ...rest] = cookie.trim().split('=');
|
|
43
|
+
if (name) {
|
|
44
|
+
this._cookies!.set(name, rest.join('='));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return this._cookies;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
cookie(name: string): string | undefined {
|
|
53
|
+
return this.cookies.get(name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Parse JSON body
|
|
57
|
+
async jsonBody<T = any>(): Promise<T> {
|
|
58
|
+
return this.json();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Parse form data
|
|
62
|
+
async formBody(): Promise<FormData> {
|
|
63
|
+
return this.formData();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Get query param
|
|
67
|
+
query(name: string): string | null {
|
|
68
|
+
return this.searchParams.get(name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Get all query params
|
|
72
|
+
queryAll(name: string): string[] {
|
|
73
|
+
return this.searchParams.getAll(name);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Extended Response with FlexiReact helpers
|
|
78
|
+
export class FlexiResponse extends GlobalResponse {
|
|
79
|
+
// Static helpers for common responses
|
|
80
|
+
|
|
81
|
+
static json(data: any, init?: ResponseInit): FlexiResponse {
|
|
82
|
+
const headers = new Headers(init?.headers);
|
|
83
|
+
headers.set('Content-Type', 'application/json');
|
|
84
|
+
|
|
85
|
+
return new FlexiResponse(JSON.stringify(data), {
|
|
86
|
+
...init,
|
|
87
|
+
headers
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static html(html: string, init?: ResponseInit): FlexiResponse {
|
|
92
|
+
const headers = new Headers(init?.headers);
|
|
93
|
+
headers.set('Content-Type', 'text/html; charset=utf-8');
|
|
94
|
+
|
|
95
|
+
return new FlexiResponse(html, {
|
|
96
|
+
...init,
|
|
97
|
+
headers
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static text(text: string, init?: ResponseInit): FlexiResponse {
|
|
102
|
+
const headers = new Headers(init?.headers);
|
|
103
|
+
headers.set('Content-Type', 'text/plain; charset=utf-8');
|
|
104
|
+
|
|
105
|
+
return new FlexiResponse(text, {
|
|
106
|
+
...init,
|
|
107
|
+
headers
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static redirect(url: string, status: 301 | 302 | 303 | 307 | 308 = 307): FlexiResponse {
|
|
112
|
+
return new FlexiResponse(null, {
|
|
113
|
+
status,
|
|
114
|
+
headers: { Location: url }
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
static notFound(message: string = 'Not Found'): FlexiResponse {
|
|
119
|
+
return new FlexiResponse(message, {
|
|
120
|
+
status: 404,
|
|
121
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static error(message: string = 'Internal Server Error', status: number = 500): FlexiResponse {
|
|
126
|
+
return new FlexiResponse(message, {
|
|
127
|
+
status,
|
|
128
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Stream response
|
|
133
|
+
static stream(
|
|
134
|
+
stream: ReadableStream,
|
|
135
|
+
init?: ResponseInit
|
|
136
|
+
): FlexiResponse {
|
|
137
|
+
return new FlexiResponse(stream, init);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Set cookie helper
|
|
141
|
+
withCookie(
|
|
142
|
+
name: string,
|
|
143
|
+
value: string,
|
|
144
|
+
options: {
|
|
145
|
+
maxAge?: number;
|
|
146
|
+
expires?: Date;
|
|
147
|
+
path?: string;
|
|
148
|
+
domain?: string;
|
|
149
|
+
secure?: boolean;
|
|
150
|
+
httpOnly?: boolean;
|
|
151
|
+
sameSite?: 'Strict' | 'Lax' | 'None';
|
|
152
|
+
} = {}
|
|
153
|
+
): FlexiResponse {
|
|
154
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
155
|
+
|
|
156
|
+
if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`);
|
|
157
|
+
if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
|
|
158
|
+
if (options.path) parts.push(`Path=${options.path}`);
|
|
159
|
+
if (options.domain) parts.push(`Domain=${options.domain}`);
|
|
160
|
+
if (options.secure) parts.push('Secure');
|
|
161
|
+
if (options.httpOnly) parts.push('HttpOnly');
|
|
162
|
+
if (options.sameSite) parts.push(`SameSite=${options.sameSite}`);
|
|
163
|
+
|
|
164
|
+
const headers = new Headers(this.headers);
|
|
165
|
+
headers.append('Set-Cookie', parts.join('; '));
|
|
166
|
+
|
|
167
|
+
return new FlexiResponse(this.body, {
|
|
168
|
+
status: this.status,
|
|
169
|
+
statusText: this.statusText,
|
|
170
|
+
headers
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Add headers helper
|
|
175
|
+
withHeaders(newHeaders: Record<string, string>): FlexiResponse {
|
|
176
|
+
const headers = new Headers(this.headers);
|
|
177
|
+
Object.entries(newHeaders).forEach(([key, value]) => {
|
|
178
|
+
headers.set(key, value);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return new FlexiResponse(this.body, {
|
|
182
|
+
status: this.status,
|
|
183
|
+
statusText: this.statusText,
|
|
184
|
+
headers
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Extended Headers
|
|
190
|
+
export class FlexiHeaders extends GlobalHeaders {
|
|
191
|
+
// Get bearer token
|
|
192
|
+
getBearerToken(): string | null {
|
|
193
|
+
const auth = this.get('Authorization');
|
|
194
|
+
if (auth?.startsWith('Bearer ')) {
|
|
195
|
+
return auth.slice(7);
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Get basic auth credentials
|
|
201
|
+
getBasicAuth(): { username: string; password: string } | null {
|
|
202
|
+
const auth = this.get('Authorization');
|
|
203
|
+
if (auth?.startsWith('Basic ')) {
|
|
204
|
+
try {
|
|
205
|
+
const decoded = atob(auth.slice(6));
|
|
206
|
+
const [username, password] = decoded.split(':');
|
|
207
|
+
return { username, password };
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check content type
|
|
216
|
+
isJson(): boolean {
|
|
217
|
+
return this.get('Content-Type')?.includes('application/json') ?? false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
isFormData(): boolean {
|
|
221
|
+
const ct = this.get('Content-Type') ?? '';
|
|
222
|
+
return ct.includes('multipart/form-data') || ct.includes('application/x-www-form-urlencoded');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
isHtml(): boolean {
|
|
226
|
+
return this.get('Accept')?.includes('text/html') ?? false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Export both native and extended versions
|
|
231
|
+
export {
|
|
232
|
+
FlexiRequest as Request,
|
|
233
|
+
FlexiResponse as Response,
|
|
234
|
+
FlexiHeaders as Headers
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// Also export native versions for compatibility
|
|
238
|
+
export const NativeRequest = GlobalRequest;
|
|
239
|
+
export const NativeResponse = GlobalResponse;
|
|
240
|
+
export const NativeHeaders = GlobalHeaders;
|
|
241
|
+
|
|
242
|
+
export default {
|
|
243
|
+
Request: FlexiRequest,
|
|
244
|
+
Response: FlexiResponse,
|
|
245
|
+
Headers: FlexiHeaders,
|
|
246
|
+
fetch: globalFetch
|
|
247
|
+
};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlexiReact Universal Edge Handler
|
|
3
|
+
*
|
|
4
|
+
* Single handler that works on all platforms:
|
|
5
|
+
* - Node.js (http.createServer)
|
|
6
|
+
* - Bun (Bun.serve)
|
|
7
|
+
* - Deno (Deno.serve)
|
|
8
|
+
* - Cloudflare Workers (fetch handler)
|
|
9
|
+
* - Vercel Edge (edge function)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { FlexiRequest, FlexiResponse } from './fetch-polyfill.js';
|
|
13
|
+
import runtime, { detectRuntime } from './runtime.js';
|
|
14
|
+
import { cache, CacheOptions } from './cache.js';
|
|
15
|
+
|
|
16
|
+
// Handler context
|
|
17
|
+
export interface EdgeContext {
|
|
18
|
+
runtime: typeof runtime;
|
|
19
|
+
cache: typeof cache;
|
|
20
|
+
env: Record<string, string | undefined>;
|
|
21
|
+
waitUntil: (promise: Promise<any>) => void;
|
|
22
|
+
passThroughOnException?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Route handler type
|
|
26
|
+
export type EdgeHandler = (
|
|
27
|
+
request: FlexiRequest,
|
|
28
|
+
context: EdgeContext
|
|
29
|
+
) => Promise<FlexiResponse> | FlexiResponse;
|
|
30
|
+
|
|
31
|
+
// Middleware type
|
|
32
|
+
export type EdgeMiddleware = (
|
|
33
|
+
request: FlexiRequest,
|
|
34
|
+
context: EdgeContext,
|
|
35
|
+
next: () => Promise<FlexiResponse>
|
|
36
|
+
) => Promise<FlexiResponse> | FlexiResponse;
|
|
37
|
+
|
|
38
|
+
// App configuration
|
|
39
|
+
export interface EdgeAppConfig {
|
|
40
|
+
routes?: Map<string, EdgeHandler>;
|
|
41
|
+
middleware?: EdgeMiddleware[];
|
|
42
|
+
notFound?: EdgeHandler;
|
|
43
|
+
onError?: (error: Error, request: FlexiRequest) => FlexiResponse;
|
|
44
|
+
basePath?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Create universal edge app
|
|
48
|
+
export function createEdgeApp(config: EdgeAppConfig = {}) {
|
|
49
|
+
const {
|
|
50
|
+
routes = new Map(),
|
|
51
|
+
middleware = [],
|
|
52
|
+
notFound = () => FlexiResponse.notFound(),
|
|
53
|
+
onError = (error) => FlexiResponse.error(error.message),
|
|
54
|
+
basePath = ''
|
|
55
|
+
} = config;
|
|
56
|
+
|
|
57
|
+
// Main fetch handler (Web Standard)
|
|
58
|
+
async function handleRequest(
|
|
59
|
+
request: Request,
|
|
60
|
+
env: Record<string, any> = {},
|
|
61
|
+
executionContext?: { waitUntil: (p: Promise<any>) => void; passThroughOnException?: () => void }
|
|
62
|
+
): Promise<Response> {
|
|
63
|
+
const flexiRequest = new FlexiRequest(request.url, {
|
|
64
|
+
method: request.method,
|
|
65
|
+
headers: request.headers,
|
|
66
|
+
body: request.body,
|
|
67
|
+
// @ts-ignore - duplex is needed for streaming
|
|
68
|
+
duplex: 'half'
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const context: EdgeContext = {
|
|
72
|
+
runtime,
|
|
73
|
+
cache,
|
|
74
|
+
env: env as Record<string, string | undefined>,
|
|
75
|
+
waitUntil: executionContext?.waitUntil || (() => {}),
|
|
76
|
+
passThroughOnException: executionContext?.passThroughOnException
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// Run middleware chain
|
|
81
|
+
const response = await runMiddleware(flexiRequest, context, middleware, async () => {
|
|
82
|
+
// Match route
|
|
83
|
+
const pathname = flexiRequest.pathname.replace(basePath, '') || '/';
|
|
84
|
+
|
|
85
|
+
// Try exact match first
|
|
86
|
+
let handler = routes.get(pathname);
|
|
87
|
+
|
|
88
|
+
// Try pattern matching
|
|
89
|
+
if (!handler) {
|
|
90
|
+
for (const [pattern, h] of routes) {
|
|
91
|
+
if (matchRoute(pathname, pattern)) {
|
|
92
|
+
handler = h;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (handler) {
|
|
99
|
+
return await handler(flexiRequest, context);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return await notFound(flexiRequest, context);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return response;
|
|
106
|
+
} catch (error: any) {
|
|
107
|
+
console.error('Edge handler error:', error);
|
|
108
|
+
return onError(error, flexiRequest);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Run middleware chain
|
|
113
|
+
async function runMiddleware(
|
|
114
|
+
request: FlexiRequest,
|
|
115
|
+
context: EdgeContext,
|
|
116
|
+
middlewares: EdgeMiddleware[],
|
|
117
|
+
finalHandler: () => Promise<FlexiResponse>
|
|
118
|
+
): Promise<FlexiResponse> {
|
|
119
|
+
let index = 0;
|
|
120
|
+
|
|
121
|
+
async function next(): Promise<FlexiResponse> {
|
|
122
|
+
if (index >= middlewares.length) {
|
|
123
|
+
return finalHandler();
|
|
124
|
+
}
|
|
125
|
+
const mw = middlewares[index++];
|
|
126
|
+
return mw(request, context, next);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return next();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Simple route matching
|
|
133
|
+
function matchRoute(pathname: string, pattern: string): boolean {
|
|
134
|
+
// Exact match
|
|
135
|
+
if (pathname === pattern) return true;
|
|
136
|
+
|
|
137
|
+
// Convert pattern to regex
|
|
138
|
+
const regexPattern = pattern
|
|
139
|
+
.replace(/\[\.\.\.(\w+)\]/g, '(?<$1>.+)') // [...slug] -> catch-all
|
|
140
|
+
.replace(/\[(\w+)\]/g, '(?<$1>[^/]+)'); // [id] -> dynamic segment
|
|
141
|
+
|
|
142
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
143
|
+
return regex.test(pathname);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Return platform-specific exports
|
|
147
|
+
return {
|
|
148
|
+
// Web Standard fetch handler (Cloudflare, Vercel Edge, Deno)
|
|
149
|
+
fetch: handleRequest,
|
|
150
|
+
|
|
151
|
+
// Cloudflare Workers
|
|
152
|
+
async scheduled(event: any, env: any, ctx: any) {
|
|
153
|
+
// Handle scheduled events
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
// Add route
|
|
157
|
+
route(path: string, handler: EdgeHandler) {
|
|
158
|
+
routes.set(path, handler);
|
|
159
|
+
return this;
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
// Add middleware
|
|
163
|
+
use(mw: EdgeMiddleware) {
|
|
164
|
+
middleware.push(mw);
|
|
165
|
+
return this;
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// Node.js adapter
|
|
169
|
+
toNodeHandler() {
|
|
170
|
+
return async (req: any, res: any) => {
|
|
171
|
+
const url = `http://${req.headers.host}${req.url}`;
|
|
172
|
+
const headers = new Headers();
|
|
173
|
+
Object.entries(req.headers).forEach(([key, value]) => {
|
|
174
|
+
if (typeof value === 'string') headers.set(key, value);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const body = ['GET', 'HEAD'].includes(req.method) ? undefined : req;
|
|
178
|
+
|
|
179
|
+
const request = new Request(url, {
|
|
180
|
+
method: req.method,
|
|
181
|
+
headers,
|
|
182
|
+
body,
|
|
183
|
+
// @ts-ignore
|
|
184
|
+
duplex: 'half'
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const response = await handleRequest(request, process.env);
|
|
188
|
+
|
|
189
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
190
|
+
|
|
191
|
+
if (response.body) {
|
|
192
|
+
const reader = response.body.getReader();
|
|
193
|
+
while (true) {
|
|
194
|
+
const { done, value } = await reader.read();
|
|
195
|
+
if (done) break;
|
|
196
|
+
res.write(value);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
res.end();
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
// Bun adapter
|
|
204
|
+
toBunHandler() {
|
|
205
|
+
return {
|
|
206
|
+
fetch: handleRequest,
|
|
207
|
+
port: parseInt(process.env.PORT || '3000', 10)
|
|
208
|
+
};
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
// Deno adapter
|
|
212
|
+
toDenoHandler() {
|
|
213
|
+
return handleRequest;
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
// Start server based on runtime
|
|
217
|
+
async listen(port: number = 3000) {
|
|
218
|
+
const rt = detectRuntime();
|
|
219
|
+
|
|
220
|
+
switch (rt) {
|
|
221
|
+
case 'bun':
|
|
222
|
+
console.log(`🚀 FlexiReact Edge running on Bun at http://localhost:${port}`);
|
|
223
|
+
// @ts-ignore - Bun global
|
|
224
|
+
return Bun.serve({
|
|
225
|
+
port,
|
|
226
|
+
fetch: handleRequest
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
case 'deno':
|
|
230
|
+
console.log(`🚀 FlexiReact Edge running on Deno at http://localhost:${port}`);
|
|
231
|
+
// @ts-ignore - Deno global
|
|
232
|
+
return Deno.serve({ port }, handleRequest);
|
|
233
|
+
|
|
234
|
+
case 'node':
|
|
235
|
+
default:
|
|
236
|
+
const http = await import('http');
|
|
237
|
+
const server = http.createServer(this.toNodeHandler());
|
|
238
|
+
server.listen(port, () => {
|
|
239
|
+
console.log(`🚀 FlexiReact Edge running on Node.js at http://localhost:${port}`);
|
|
240
|
+
});
|
|
241
|
+
return server;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Export default app creator
|
|
248
|
+
export default createEdgeApp;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlexiReact Edge Runtime
|
|
3
|
+
*
|
|
4
|
+
* Universal edge runtime that works everywhere:
|
|
5
|
+
* - Node.js
|
|
6
|
+
* - Bun
|
|
7
|
+
* - Deno
|
|
8
|
+
* - Cloudflare Workers
|
|
9
|
+
* - Vercel Edge
|
|
10
|
+
* - Netlify Edge
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Runtime detection and capabilities
|
|
14
|
+
export {
|
|
15
|
+
detectRuntime,
|
|
16
|
+
getRuntimeCapabilities,
|
|
17
|
+
runtime as edgeRuntimeInfo,
|
|
18
|
+
type RuntimeEnvironment,
|
|
19
|
+
type RuntimeCapabilities
|
|
20
|
+
} from './runtime.js';
|
|
21
|
+
|
|
22
|
+
// Fetch polyfill with helpers
|
|
23
|
+
export {
|
|
24
|
+
FlexiRequest,
|
|
25
|
+
FlexiResponse,
|
|
26
|
+
FlexiHeaders,
|
|
27
|
+
Request,
|
|
28
|
+
Response,
|
|
29
|
+
Headers,
|
|
30
|
+
NativeRequest,
|
|
31
|
+
NativeResponse,
|
|
32
|
+
NativeHeaders
|
|
33
|
+
} from './fetch-polyfill.js';
|
|
34
|
+
|
|
35
|
+
// Universal handler
|
|
36
|
+
export {
|
|
37
|
+
createEdgeApp,
|
|
38
|
+
type EdgeContext,
|
|
39
|
+
type EdgeHandler,
|
|
40
|
+
type EdgeMiddleware,
|
|
41
|
+
type EdgeAppConfig
|
|
42
|
+
} from './handler.js';
|
|
43
|
+
|
|
44
|
+
// Smart caching
|
|
45
|
+
export {
|
|
46
|
+
cache as smartCache,
|
|
47
|
+
initCache,
|
|
48
|
+
cacheFunction,
|
|
49
|
+
unstable_cache,
|
|
50
|
+
revalidateTag,
|
|
51
|
+
revalidatePath,
|
|
52
|
+
getRequestCache,
|
|
53
|
+
reactCache,
|
|
54
|
+
type CacheEntry,
|
|
55
|
+
type CacheOptions,
|
|
56
|
+
type CacheStorage
|
|
57
|
+
} from './cache.js';
|
|
58
|
+
|
|
59
|
+
// Partial Prerendering
|
|
60
|
+
export {
|
|
61
|
+
dynamic,
|
|
62
|
+
staticComponent,
|
|
63
|
+
PPRBoundary,
|
|
64
|
+
PPRShell,
|
|
65
|
+
prerenderWithPPR,
|
|
66
|
+
streamPPR,
|
|
67
|
+
pprFetch,
|
|
68
|
+
PPRLoading,
|
|
69
|
+
getPPRStyles,
|
|
70
|
+
experimental_ppr,
|
|
71
|
+
type PPRConfig,
|
|
72
|
+
type PPRRenderResult,
|
|
73
|
+
type PPRPageConfig,
|
|
74
|
+
type GenerateStaticParams
|
|
75
|
+
} from './ppr.js';
|
|
76
|
+
|
|
77
|
+
// Default export
|
|
78
|
+
export { default as edgeRuntime } from './runtime.js';
|
|
79
|
+
export { default as edgeCache } from './cache.js';
|
|
80
|
+
export { default as edgePPR } from './ppr.js';
|
|
81
|
+
export { default as createApp } from './handler.js';
|
package/core/edge/ppr.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlexiReact Partial Prerendering (PPR)
|
|
3
|
+
*
|
|
4
|
+
* Combines static shell with dynamic content:
|
|
5
|
+
* - Static parts are prerendered at build time
|
|
6
|
+
* - Dynamic parts stream in at request time
|
|
7
|
+
* - Best of both SSG and SSR
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { renderToString } from 'react-dom/server';
|
|
12
|
+
import { cache } from './cache.js';
|
|
13
|
+
|
|
14
|
+
// PPR configuration
|
|
15
|
+
export interface PPRConfig {
|
|
16
|
+
// Static shell cache duration
|
|
17
|
+
shellCacheTTL?: number;
|
|
18
|
+
// Dynamic content timeout
|
|
19
|
+
dynamicTimeout?: number;
|
|
20
|
+
// Fallback for dynamic parts
|
|
21
|
+
fallback?: React.ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Mark component as dynamic (not prerendered)
|
|
25
|
+
export function dynamic<T extends React.ComponentType<any>>(
|
|
26
|
+
Component: T,
|
|
27
|
+
options?: { fallback?: React.ReactNode }
|
|
28
|
+
): T {
|
|
29
|
+
(Component as any).__flexi_dynamic = true;
|
|
30
|
+
(Component as any).__flexi_fallback = options?.fallback;
|
|
31
|
+
return Component;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Mark component as static (prerendered)
|
|
35
|
+
export function staticComponent<T extends React.ComponentType<any>>(Component: T): T {
|
|
36
|
+
(Component as any).__flexi_static = true;
|
|
37
|
+
return Component;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Suspense boundary for PPR
|
|
41
|
+
export interface SuspenseBoundaryProps {
|
|
42
|
+
children: React.ReactNode;
|
|
43
|
+
fallback?: React.ReactNode;
|
|
44
|
+
id?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function PPRBoundary({ children, fallback, id }: SuspenseBoundaryProps): React.ReactElement {
|
|
48
|
+
return React.createElement(
|
|
49
|
+
React.Suspense,
|
|
50
|
+
{
|
|
51
|
+
fallback: fallback || React.createElement('div', {
|
|
52
|
+
'data-ppr-placeholder': id || 'loading',
|
|
53
|
+
className: 'ppr-loading'
|
|
54
|
+
}, '⏳')
|
|
55
|
+
},
|
|
56
|
+
children
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// PPR Shell - static wrapper
|
|
61
|
+
export interface PPRShellProps {
|
|
62
|
+
children: React.ReactNode;
|
|
63
|
+
fallback?: React.ReactNode;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function PPRShell({ children, fallback }: PPRShellProps): React.ReactElement {
|
|
67
|
+
return React.createElement(
|
|
68
|
+
'div',
|
|
69
|
+
{ 'data-ppr-shell': 'true' },
|
|
70
|
+
React.createElement(
|
|
71
|
+
React.Suspense,
|
|
72
|
+
{ fallback: fallback || null },
|
|
73
|
+
children
|
|
74
|
+
)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Prerender a page with PPR
|
|
79
|
+
export interface PPRRenderResult {
|
|
80
|
+
staticShell: string;
|
|
81
|
+
dynamicParts: Map<string, () => Promise<string>>;
|
|
82
|
+
fullHtml: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function prerenderWithPPR(
|
|
86
|
+
Component: React.ComponentType<any>,
|
|
87
|
+
props: any,
|
|
88
|
+
config: PPRConfig = {}
|
|
89
|
+
): Promise<PPRRenderResult> {
|
|
90
|
+
const { shellCacheTTL = 3600 } = config;
|
|
91
|
+
|
|
92
|
+
// Track dynamic parts
|
|
93
|
+
const dynamicParts = new Map<string, () => Promise<string>>();
|
|
94
|
+
let dynamicCounter = 0;
|
|
95
|
+
|
|
96
|
+
// Create element
|
|
97
|
+
const element = React.createElement(Component, props);
|
|
98
|
+
|
|
99
|
+
// Render static shell (with placeholders for dynamic parts)
|
|
100
|
+
const staticShell = renderToString(element);
|
|
101
|
+
|
|
102
|
+
// Cache the static shell
|
|
103
|
+
const cacheKey = `ppr:${Component.name || 'page'}:${JSON.stringify(props)}`;
|
|
104
|
+
await cache.set(cacheKey, staticShell, { ttl: shellCacheTTL, tags: ['ppr'] });
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
staticShell,
|
|
108
|
+
dynamicParts,
|
|
109
|
+
fullHtml: staticShell
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Stream PPR response
|
|
114
|
+
export async function streamPPR(
|
|
115
|
+
staticShell: string,
|
|
116
|
+
dynamicParts: Map<string, () => Promise<string>>,
|
|
117
|
+
options?: { onError?: (error: Error) => string }
|
|
118
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
119
|
+
const encoder = new TextEncoder();
|
|
120
|
+
|
|
121
|
+
return new ReadableStream({
|
|
122
|
+
async start(controller) {
|
|
123
|
+
// Send static shell immediately
|
|
124
|
+
controller.enqueue(encoder.encode(staticShell));
|
|
125
|
+
|
|
126
|
+
// Stream dynamic parts as they resolve
|
|
127
|
+
const promises = Array.from(dynamicParts.entries()).map(async ([id, render]) => {
|
|
128
|
+
try {
|
|
129
|
+
const html = await render();
|
|
130
|
+
// Send script to replace placeholder
|
|
131
|
+
const script = `<script>
|
|
132
|
+
(function() {
|
|
133
|
+
var placeholder = document.querySelector('[data-ppr-placeholder="${id}"]');
|
|
134
|
+
if (placeholder) {
|
|
135
|
+
var temp = document.createElement('div');
|
|
136
|
+
temp.innerHTML = ${JSON.stringify(html)};
|
|
137
|
+
placeholder.replaceWith(...temp.childNodes);
|
|
138
|
+
}
|
|
139
|
+
})();
|
|
140
|
+
</script>`;
|
|
141
|
+
controller.enqueue(encoder.encode(script));
|
|
142
|
+
} catch (error: any) {
|
|
143
|
+
const errorHtml = options?.onError?.(error) || `<div class="ppr-error">Error loading content</div>`;
|
|
144
|
+
const script = `<script>
|
|
145
|
+
(function() {
|
|
146
|
+
var placeholder = document.querySelector('[data-ppr-placeholder="${id}"]');
|
|
147
|
+
if (placeholder) {
|
|
148
|
+
placeholder.innerHTML = ${JSON.stringify(errorHtml)};
|
|
149
|
+
}
|
|
150
|
+
})();
|
|
151
|
+
</script>`;
|
|
152
|
+
controller.enqueue(encoder.encode(script));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await Promise.all(promises);
|
|
157
|
+
controller.close();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// PPR-aware fetch wrapper
|
|
163
|
+
export function pprFetch(
|
|
164
|
+
input: RequestInfo | URL,
|
|
165
|
+
init?: RequestInit & {
|
|
166
|
+
cache?: 'force-cache' | 'no-store' | 'no-cache';
|
|
167
|
+
next?: { revalidate?: number; tags?: string[] };
|
|
168
|
+
}
|
|
169
|
+
): Promise<Response> {
|
|
170
|
+
const cacheMode = init?.cache || 'force-cache';
|
|
171
|
+
const revalidate = init?.next?.revalidate;
|
|
172
|
+
const tags = init?.next?.tags || [];
|
|
173
|
+
|
|
174
|
+
// If no-store, always fetch fresh
|
|
175
|
+
if (cacheMode === 'no-store') {
|
|
176
|
+
return fetch(input, init);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Create cache key
|
|
180
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
181
|
+
const cacheKey = `fetch:${url}:${JSON.stringify(init?.body || '')}`;
|
|
182
|
+
|
|
183
|
+
// Try cache first
|
|
184
|
+
return cache.wrap(
|
|
185
|
+
async () => {
|
|
186
|
+
const response = await fetch(input, init);
|
|
187
|
+
return response;
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
key: cacheKey,
|
|
191
|
+
ttl: revalidate || 3600,
|
|
192
|
+
tags
|
|
193
|
+
}
|
|
194
|
+
)();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Export directive markers
|
|
198
|
+
export const experimental_ppr = true;
|
|
199
|
+
|
|
200
|
+
// Page config for PPR
|
|
201
|
+
export interface PPRPageConfig {
|
|
202
|
+
experimental_ppr?: boolean;
|
|
203
|
+
revalidate?: number | false;
|
|
204
|
+
dynamic?: 'auto' | 'force-dynamic' | 'force-static' | 'error';
|
|
205
|
+
dynamicParams?: boolean;
|
|
206
|
+
fetchCache?: 'auto' | 'default-cache' | 'only-cache' | 'force-cache' | 'force-no-store' | 'default-no-store' | 'only-no-store';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Generate static params (for SSG with PPR)
|
|
210
|
+
export type GenerateStaticParams<T = any> = () => Promise<T[]> | T[];
|
|
211
|
+
|
|
212
|
+
// Default PPR loading component
|
|
213
|
+
export function PPRLoading(): React.ReactElement {
|
|
214
|
+
return React.createElement('div', {
|
|
215
|
+
className: 'ppr-loading animate-pulse',
|
|
216
|
+
style: {
|
|
217
|
+
background: 'linear-gradient(90deg, #1a1a1a 25%, #2a2a2a 50%, #1a1a1a 75%)',
|
|
218
|
+
backgroundSize: '200% 100%',
|
|
219
|
+
animation: 'shimmer 1.5s infinite',
|
|
220
|
+
borderRadius: '4px',
|
|
221
|
+
height: '1em',
|
|
222
|
+
width: '100%'
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Inject PPR styles
|
|
228
|
+
export function getPPRStyles(): string {
|
|
229
|
+
return `
|
|
230
|
+
@keyframes shimmer {
|
|
231
|
+
0% { background-position: 200% 0; }
|
|
232
|
+
100% { background-position: -200% 0; }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.ppr-loading {
|
|
236
|
+
background: linear-gradient(90deg, #1a1a1a 25%, #2a2a2a 50%, #1a1a1a 75%);
|
|
237
|
+
background-size: 200% 100%;
|
|
238
|
+
animation: shimmer 1.5s infinite;
|
|
239
|
+
border-radius: 4px;
|
|
240
|
+
min-height: 1em;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.ppr-error {
|
|
244
|
+
color: #ef4444;
|
|
245
|
+
padding: 1rem;
|
|
246
|
+
border: 1px solid #ef4444;
|
|
247
|
+
border-radius: 4px;
|
|
248
|
+
background: rgba(239, 68, 68, 0.1);
|
|
249
|
+
}
|
|
250
|
+
`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export default {
|
|
254
|
+
dynamic,
|
|
255
|
+
staticComponent,
|
|
256
|
+
PPRBoundary,
|
|
257
|
+
PPRShell,
|
|
258
|
+
prerenderWithPPR,
|
|
259
|
+
streamPPR,
|
|
260
|
+
pprFetch,
|
|
261
|
+
PPRLoading,
|
|
262
|
+
getPPRStyles,
|
|
263
|
+
experimental_ppr
|
|
264
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlexiReact Universal Edge Runtime
|
|
3
|
+
*
|
|
4
|
+
* Works on:
|
|
5
|
+
* - Node.js
|
|
6
|
+
* - Bun
|
|
7
|
+
* - Deno
|
|
8
|
+
* - Cloudflare Workers
|
|
9
|
+
* - Vercel Edge
|
|
10
|
+
* - Any Web-standard runtime
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Detect runtime environment
|
|
14
|
+
export type RuntimeEnvironment =
|
|
15
|
+
| 'node'
|
|
16
|
+
| 'bun'
|
|
17
|
+
| 'deno'
|
|
18
|
+
| 'cloudflare'
|
|
19
|
+
| 'vercel-edge'
|
|
20
|
+
| 'netlify-edge'
|
|
21
|
+
| 'fastly'
|
|
22
|
+
| 'unknown';
|
|
23
|
+
|
|
24
|
+
export function detectRuntime(): RuntimeEnvironment {
|
|
25
|
+
// Bun
|
|
26
|
+
if (typeof globalThis.Bun !== 'undefined') {
|
|
27
|
+
return 'bun';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Deno
|
|
31
|
+
if (typeof globalThis.Deno !== 'undefined') {
|
|
32
|
+
return 'deno';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Cloudflare Workers
|
|
36
|
+
if (typeof globalThis.caches !== 'undefined' && typeof (globalThis as any).WebSocketPair !== 'undefined') {
|
|
37
|
+
return 'cloudflare';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Vercel Edge
|
|
41
|
+
if (typeof process !== 'undefined' && process.env?.VERCEL_EDGE === '1') {
|
|
42
|
+
return 'vercel-edge';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Netlify Edge
|
|
46
|
+
if (typeof globalThis.Netlify !== 'undefined') {
|
|
47
|
+
return 'netlify-edge';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Node.js
|
|
51
|
+
if (typeof process !== 'undefined' && process.versions?.node) {
|
|
52
|
+
return 'node';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return 'unknown';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Runtime capabilities
|
|
59
|
+
export interface RuntimeCapabilities {
|
|
60
|
+
hasFileSystem: boolean;
|
|
61
|
+
hasWebCrypto: boolean;
|
|
62
|
+
hasWebStreams: boolean;
|
|
63
|
+
hasFetch: boolean;
|
|
64
|
+
hasWebSocket: boolean;
|
|
65
|
+
hasKV: boolean;
|
|
66
|
+
hasCache: boolean;
|
|
67
|
+
maxExecutionTime: number; // ms, 0 = unlimited
|
|
68
|
+
maxMemory: number; // bytes, 0 = unlimited
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getRuntimeCapabilities(): RuntimeCapabilities {
|
|
72
|
+
const runtime = detectRuntime();
|
|
73
|
+
|
|
74
|
+
switch (runtime) {
|
|
75
|
+
case 'cloudflare':
|
|
76
|
+
return {
|
|
77
|
+
hasFileSystem: false,
|
|
78
|
+
hasWebCrypto: true,
|
|
79
|
+
hasWebStreams: true,
|
|
80
|
+
hasFetch: true,
|
|
81
|
+
hasWebSocket: true,
|
|
82
|
+
hasKV: true,
|
|
83
|
+
hasCache: true,
|
|
84
|
+
maxExecutionTime: 30000, // 30s for paid, 10ms for free
|
|
85
|
+
maxMemory: 128 * 1024 * 1024 // 128MB
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
case 'vercel-edge':
|
|
89
|
+
return {
|
|
90
|
+
hasFileSystem: false,
|
|
91
|
+
hasWebCrypto: true,
|
|
92
|
+
hasWebStreams: true,
|
|
93
|
+
hasFetch: true,
|
|
94
|
+
hasWebSocket: false,
|
|
95
|
+
hasKV: true, // Vercel KV
|
|
96
|
+
hasCache: true,
|
|
97
|
+
maxExecutionTime: 30000,
|
|
98
|
+
maxMemory: 128 * 1024 * 1024
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
case 'deno':
|
|
102
|
+
return {
|
|
103
|
+
hasFileSystem: true,
|
|
104
|
+
hasWebCrypto: true,
|
|
105
|
+
hasWebStreams: true,
|
|
106
|
+
hasFetch: true,
|
|
107
|
+
hasWebSocket: true,
|
|
108
|
+
hasKV: true, // Deno KV
|
|
109
|
+
hasCache: true,
|
|
110
|
+
maxExecutionTime: 0,
|
|
111
|
+
maxMemory: 0
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
case 'bun':
|
|
115
|
+
return {
|
|
116
|
+
hasFileSystem: true,
|
|
117
|
+
hasWebCrypto: true,
|
|
118
|
+
hasWebStreams: true,
|
|
119
|
+
hasFetch: true,
|
|
120
|
+
hasWebSocket: true,
|
|
121
|
+
hasKV: false,
|
|
122
|
+
hasCache: false,
|
|
123
|
+
maxExecutionTime: 0,
|
|
124
|
+
maxMemory: 0
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
case 'node':
|
|
128
|
+
default:
|
|
129
|
+
return {
|
|
130
|
+
hasFileSystem: true,
|
|
131
|
+
hasWebCrypto: true,
|
|
132
|
+
hasWebStreams: true,
|
|
133
|
+
hasFetch: true,
|
|
134
|
+
hasWebSocket: true,
|
|
135
|
+
hasKV: false,
|
|
136
|
+
hasCache: false,
|
|
137
|
+
maxExecutionTime: 0,
|
|
138
|
+
maxMemory: 0
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Runtime info
|
|
144
|
+
export const runtime = {
|
|
145
|
+
name: detectRuntime(),
|
|
146
|
+
capabilities: getRuntimeCapabilities(),
|
|
147
|
+
|
|
148
|
+
get isEdge(): boolean {
|
|
149
|
+
return ['cloudflare', 'vercel-edge', 'netlify-edge', 'fastly'].includes(this.name);
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
get isServer(): boolean {
|
|
153
|
+
return ['node', 'bun', 'deno'].includes(this.name);
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
get supportsStreaming(): boolean {
|
|
157
|
+
return this.capabilities.hasWebStreams;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export default runtime;
|
package/core/index.ts
CHANGED
|
@@ -70,6 +70,54 @@ export {
|
|
|
70
70
|
builtinPlugins
|
|
71
71
|
} from './plugins/index.js';
|
|
72
72
|
|
|
73
|
+
// Edge Runtime
|
|
74
|
+
export {
|
|
75
|
+
// Runtime
|
|
76
|
+
detectRuntime,
|
|
77
|
+
getRuntimeCapabilities,
|
|
78
|
+
edgeRuntimeInfo,
|
|
79
|
+
// Fetch
|
|
80
|
+
FlexiRequest,
|
|
81
|
+
FlexiResponse,
|
|
82
|
+
FlexiHeaders,
|
|
83
|
+
// Handler
|
|
84
|
+
createEdgeApp,
|
|
85
|
+
// Cache
|
|
86
|
+
smartCache,
|
|
87
|
+
initCache,
|
|
88
|
+
cacheFunction,
|
|
89
|
+
unstable_cache,
|
|
90
|
+
revalidateTag,
|
|
91
|
+
revalidatePath,
|
|
92
|
+
reactCache,
|
|
93
|
+
// PPR
|
|
94
|
+
dynamic,
|
|
95
|
+
staticComponent,
|
|
96
|
+
PPRBoundary,
|
|
97
|
+
PPRShell,
|
|
98
|
+
prerenderWithPPR,
|
|
99
|
+
streamPPR,
|
|
100
|
+
pprFetch,
|
|
101
|
+
PPRLoading,
|
|
102
|
+
experimental_ppr,
|
|
103
|
+
// Default exports
|
|
104
|
+
createApp
|
|
105
|
+
} from './edge/index.js';
|
|
106
|
+
export type {
|
|
107
|
+
RuntimeEnvironment,
|
|
108
|
+
RuntimeCapabilities,
|
|
109
|
+
EdgeContext,
|
|
110
|
+
EdgeHandler,
|
|
111
|
+
EdgeMiddleware,
|
|
112
|
+
EdgeAppConfig,
|
|
113
|
+
CacheEntry,
|
|
114
|
+
CacheOptions,
|
|
115
|
+
PPRConfig,
|
|
116
|
+
PPRRenderResult,
|
|
117
|
+
PPRPageConfig,
|
|
118
|
+
GenerateStaticParams
|
|
119
|
+
} from './edge/index.js';
|
|
120
|
+
|
|
73
121
|
// Font Optimization
|
|
74
122
|
export {
|
|
75
123
|
createFont,
|
|
@@ -153,7 +201,7 @@ export {
|
|
|
153
201
|
export type { CookieOptions } from './helpers.js';
|
|
154
202
|
|
|
155
203
|
// Version
|
|
156
|
-
export const VERSION = '2.
|
|
204
|
+
export const VERSION = '2.4.0';
|
|
157
205
|
|
|
158
206
|
// Default export
|
|
159
207
|
export default {
|