@reckona/mreact-router 0.0.138 → 0.0.139
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 +1 -1
- package/dist/actions.d.ts +1 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +32 -12
- package/dist/actions.js.map +1 -1
- package/dist/adapters/static.js +10 -4
- package/dist/adapters/static.js.map +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +28 -21
- package/dist/build.js.map +1 -1
- package/dist/cache.d.ts +2 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +23 -1
- package/dist/cache.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +49 -12
- package/dist/client.js.map +1 -1
- package/dist/dev-server.d.ts +1 -0
- package/dist/dev-server.d.ts.map +1 -1
- package/dist/dev-server.js +6 -4
- package/dist/dev-server.js.map +1 -1
- package/dist/link.js +17 -3
- package/dist/link.js.map +1 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +10 -7
- package/dist/middleware.js.map +1 -1
- package/dist/multipart.d.ts +5 -0
- package/dist/multipart.d.ts.map +1 -1
- package/dist/multipart.js +24 -1
- package/dist/multipart.js.map +1 -1
- package/dist/native-route-matcher.d.ts +9 -0
- package/dist/native-route-matcher.d.ts.map +1 -1
- package/dist/native-route-matcher.js +23 -11
- package/dist/native-route-matcher.js.map +1 -1
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +8 -1
- package/dist/navigation.js.map +1 -1
- package/dist/render.d.ts +5 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +239 -95
- package/dist/render.js.map +1 -1
- package/dist/route-source.d.ts +3 -1
- package/dist/route-source.d.ts.map +1 -1
- package/dist/route-source.js +13 -11
- package/dist/route-source.js.map +1 -1
- package/dist/route-styles.d.ts +4 -0
- package/dist/route-styles.d.ts.map +1 -1
- package/dist/route-styles.js +15 -3
- package/dist/route-styles.js.map +1 -1
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +39 -9
- package/dist/serve.js.map +1 -1
- package/dist/server-action-inference.d.ts.map +1 -1
- package/dist/server-action-inference.js +57 -0
- package/dist/server-action-inference.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +27 -11
- package/dist/vite.js.map +1 -1
- package/package.json +11 -11
- package/src/actions.ts +44 -10
- package/src/adapters/static.ts +11 -4
- package/src/build.ts +28 -17
- package/src/cache.ts +33 -1
- package/src/client.ts +49 -12
- package/src/dev-server.ts +11 -4
- package/src/link.ts +24 -3
- package/src/middleware.ts +12 -7
- package/src/multipart.ts +32 -1
- package/src/native-route-matcher.ts +34 -13
- package/src/navigation.ts +11 -2
- package/src/render.ts +321 -113
- package/src/route-source.ts +14 -10
- package/src/route-styles.ts +28 -3
- package/src/serve.ts +68 -15
- package/src/server-action-inference.ts +86 -0
- package/src/vite.ts +39 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reckona/mreact-router",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.139",
|
|
4
4
|
"description": "File-system app router, SSR, actions, and deployment adapters for mreact.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jsx",
|
|
@@ -105,20 +105,20 @@
|
|
|
105
105
|
},
|
|
106
106
|
"dependencies": {
|
|
107
107
|
"typescript": "^6.0.3",
|
|
108
|
-
"@reckona/mreact": "0.0.
|
|
109
|
-
"@reckona/mreact-
|
|
110
|
-
"@reckona/mreact-
|
|
111
|
-
"@reckona/mreact-devtools": "0.0.
|
|
112
|
-
"@reckona/mreact-reactive-
|
|
113
|
-
"@reckona/mreact-
|
|
114
|
-
"@reckona/mreact-
|
|
115
|
-
"@reckona/mreact-
|
|
116
|
-
"@reckona/mreact-shared": "0.0.
|
|
108
|
+
"@reckona/mreact": "0.0.139",
|
|
109
|
+
"@reckona/mreact-compiler": "0.0.139",
|
|
110
|
+
"@reckona/mreact-query": "0.0.139",
|
|
111
|
+
"@reckona/mreact-devtools": "0.0.139",
|
|
112
|
+
"@reckona/mreact-reactive-dom": "0.0.139",
|
|
113
|
+
"@reckona/mreact-reactive-core": "0.0.139",
|
|
114
|
+
"@reckona/mreact-compat": "0.0.139",
|
|
115
|
+
"@reckona/mreact-server": "0.0.139",
|
|
116
|
+
"@reckona/mreact-shared": "0.0.139"
|
|
117
117
|
},
|
|
118
118
|
"peerDependencies": {
|
|
119
119
|
"vite": ">=8 <9"
|
|
120
120
|
},
|
|
121
121
|
"optionalDependencies": {
|
|
122
|
-
"@reckona/mreact-router-native": "0.0.
|
|
122
|
+
"@reckona/mreact-router-native": "0.0.139"
|
|
123
123
|
}
|
|
124
124
|
}
|
package/src/actions.ts
CHANGED
|
@@ -64,6 +64,7 @@ const actionTokenSecret = process.env.MREACT_SERVER_ACTION_SECRET ?? defaultActi
|
|
|
64
64
|
const DEFAULT_REPLAY_TTL_MS = 10 * 60 * 1000;
|
|
65
65
|
const DEFAULT_REPLAY_MAX_ENTRIES = 50_000;
|
|
66
66
|
const DEFAULT_ACTION_BODY_MAX_BYTES = 10 * 1024 * 1024;
|
|
67
|
+
const DEFAULT_ACTION_FORM_MAX_FIELDS = 1_000;
|
|
67
68
|
let warnedUnrestrictedServerActions = false;
|
|
68
69
|
|
|
69
70
|
export interface ServerActionContext {
|
|
@@ -103,19 +104,20 @@ class BoundedReplayStore {
|
|
|
103
104
|
|
|
104
105
|
add(value: string): void {
|
|
105
106
|
const now = Date.now();
|
|
106
|
-
// Cheap opportunistic sweep: drop the oldest expired entries first,
|
|
107
|
-
// then enforce the hard cap with FIFO eviction.
|
|
108
107
|
if (this.entries.size >= this.maxEntries) {
|
|
109
|
-
|
|
108
|
+
const oldest = this.entries.entries().next().value;
|
|
109
|
+
if (oldest !== undefined) {
|
|
110
|
+
const [key, expiresAt] = oldest;
|
|
111
|
+
this.entries.delete(key);
|
|
110
112
|
if (expiresAt < now) {
|
|
111
|
-
this.entries.
|
|
113
|
+
while (this.entries.size >= this.maxEntries) {
|
|
114
|
+
const nextOldest = this.entries.entries().next().value;
|
|
115
|
+
if (nextOldest === undefined || nextOldest[1] >= now) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
this.entries.delete(nextOldest[0]);
|
|
119
|
+
}
|
|
112
120
|
}
|
|
113
|
-
if (this.entries.size < this.maxEntries) break;
|
|
114
|
-
}
|
|
115
|
-
while (this.entries.size >= this.maxEntries) {
|
|
116
|
-
const oldest = this.entries.keys().next().value;
|
|
117
|
-
if (oldest === undefined) break;
|
|
118
|
-
this.entries.delete(oldest);
|
|
119
121
|
}
|
|
120
122
|
}
|
|
121
123
|
this.entries.set(value, now + this.ttlMs);
|
|
@@ -147,6 +149,7 @@ export interface AppRouterServerActionOptions {
|
|
|
147
149
|
allowedActions?: readonly AppRouterAllowedServerAction[] | undefined;
|
|
148
150
|
authorize?: ServerActionHandlerOptions["authorize"] | undefined;
|
|
149
151
|
maxBodyBytes?: number | undefined;
|
|
152
|
+
maxFormFields?: number | undefined;
|
|
150
153
|
replayStore?: ServerActionReplayStore | undefined;
|
|
151
154
|
}
|
|
152
155
|
|
|
@@ -370,6 +373,15 @@ async function dispatchServerActionRequestWithoutCacheContext(options: {
|
|
|
370
373
|
}
|
|
371
374
|
|
|
372
375
|
const formData = await options.request.formData();
|
|
376
|
+
const fieldCountResponse = validateServerActionFormFieldCount(
|
|
377
|
+
formData,
|
|
378
|
+
options.serverActions?.maxFormFields ?? DEFAULT_ACTION_FORM_MAX_FIELDS,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
if (fieldCountResponse !== undefined) {
|
|
382
|
+
return fieldCountResponse;
|
|
383
|
+
}
|
|
384
|
+
|
|
373
385
|
const csrfResponse = validateFormCsrf(options.request, formData);
|
|
374
386
|
|
|
375
387
|
if (csrfResponse !== undefined) {
|
|
@@ -515,6 +527,28 @@ function validateServerActionBodySize(
|
|
|
515
527
|
return undefined;
|
|
516
528
|
}
|
|
517
529
|
|
|
530
|
+
function validateServerActionFormFieldCount(
|
|
531
|
+
formData: FormData,
|
|
532
|
+
maxFormFields: number,
|
|
533
|
+
): Response | undefined {
|
|
534
|
+
if (!Number.isFinite(maxFormFields) || maxFormFields < 0) {
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
let count = 0;
|
|
539
|
+
for (const _ of formData.keys()) {
|
|
540
|
+
count += 1;
|
|
541
|
+
if (count > maxFormFields) {
|
|
542
|
+
return jsonResponse({
|
|
543
|
+
ok: false,
|
|
544
|
+
error: "Server action form field count is too large.",
|
|
545
|
+
}, 413);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
|
|
518
552
|
function redirectToFormReferer(request: Request): Response {
|
|
519
553
|
return new Response(null, {
|
|
520
554
|
status: 303,
|
package/src/adapters/static.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
2
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
3
3
|
import type { BuiltPrerenderedRoute, BuiltServerManifest } from "../build.js";
|
|
4
4
|
|
|
5
5
|
export interface StaticExportOptions {
|
|
@@ -79,14 +79,21 @@ async function writePrerenderedRoute(
|
|
|
79
79
|
throw new Error(`Cannot export route ${route} with status ${entry.status}.`);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const file =
|
|
82
|
+
const file = routeToHtmlFile(exportDir, route);
|
|
83
83
|
await mkdir(dirname(file), { recursive: true });
|
|
84
84
|
await writeFile(file, entry.html);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
function routeToHtmlFile(route: string): string {
|
|
87
|
+
function routeToHtmlFile(exportDir: string, route: string): string {
|
|
88
88
|
const pathname = route.split("?")[0] ?? "/";
|
|
89
89
|
const normalized = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
|
90
|
+
const relativeFile = normalized === "" ? "index.html" : join(normalized, "index.html");
|
|
91
|
+
const root = resolve(exportDir);
|
|
92
|
+
const file = resolve(root, relativeFile);
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
if (file !== root && !file.startsWith(`${root}${sep}`)) {
|
|
95
|
+
throw new Error(`unsafe static export route: ${route}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return file;
|
|
92
99
|
}
|
package/src/build.ts
CHANGED
|
@@ -1387,6 +1387,7 @@ async function collectBuildServerActionManifest(options: {
|
|
|
1387
1387
|
.filter((route) => route.kind === "page")
|
|
1388
1388
|
.map((route) => relative(options.projectRoot, route.file).split(sep).join("/")),
|
|
1389
1389
|
);
|
|
1390
|
+
const inferredRouteReferences: Array<{ code: string; file: string }> = [];
|
|
1390
1391
|
|
|
1391
1392
|
for (const [file, code] of Object.entries(options.files)) {
|
|
1392
1393
|
if (!isAppRelativeFile(file, relativeRoutesDir) || !isSourceModuleFile(file)) {
|
|
@@ -1402,29 +1403,39 @@ async function collectBuildServerActionManifest(options: {
|
|
|
1402
1403
|
}
|
|
1403
1404
|
|
|
1404
1405
|
if (routeSourceFiles.has(file)) {
|
|
1405
|
-
|
|
1406
|
+
inferredRouteReferences.push({ code, file });
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
const inferredRouteResults = await mapWithBuildConcurrency(
|
|
1411
|
+
inferredRouteReferences,
|
|
1412
|
+
async ({ code, file }) => ({
|
|
1413
|
+
file,
|
|
1414
|
+
inference: await collectBuildInferredServerActionReferences({
|
|
1406
1415
|
file,
|
|
1407
1416
|
files: options.files,
|
|
1408
1417
|
relativeRoutesDir,
|
|
1409
1418
|
source: code,
|
|
1410
|
-
})
|
|
1419
|
+
}),
|
|
1420
|
+
}),
|
|
1421
|
+
);
|
|
1411
1422
|
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1423
|
+
for (const { file, inference } of inferredRouteResults) {
|
|
1424
|
+
for (const diagnostic of inference.diagnostics) {
|
|
1425
|
+
console.warn(formatServerActionInferenceDiagnostic(diagnostic));
|
|
1426
|
+
}
|
|
1415
1427
|
|
|
1416
|
-
|
|
1428
|
+
routeReferences.set(file, inference.references);
|
|
1417
1429
|
|
|
1418
|
-
|
|
1419
|
-
|
|
1430
|
+
for (const reference of inference.references) {
|
|
1431
|
+
const key = `${reference.moduleId}#${reference.exportName}`;
|
|
1420
1432
|
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
}
|
|
1433
|
+
if (!entries.has(key)) {
|
|
1434
|
+
entries.set(key, {
|
|
1435
|
+
exportName: reference.exportName,
|
|
1436
|
+
inferred: reference.inferred,
|
|
1437
|
+
moduleId: reference.moduleId,
|
|
1438
|
+
});
|
|
1428
1439
|
}
|
|
1429
1440
|
}
|
|
1430
1441
|
}
|
|
@@ -4383,8 +4394,8 @@ function cloudflareWorkspaceRuntimePlugin(): RouterCompatPlugin {
|
|
|
4383
4394
|
{ filter: /^native-escape$/, namespace: "mreact-cloudflare-native-escape" },
|
|
4384
4395
|
() => ({
|
|
4385
4396
|
contents: `function escapeHtml(value) {
|
|
4386
|
-
return String(value ?? "").replace(/[&<>"
|
|
4387
|
-
char === "&" ? "&" : char === "<" ? "<" : char === ">" ? ">" :
|
|
4397
|
+
return String(value ?? "").replace(/[&<>"]/g, (char) =>
|
|
4398
|
+
char === "&" ? "&" : char === "<" ? "<" : char === ">" ? ">" : """
|
|
4388
4399
|
);
|
|
4389
4400
|
}
|
|
4390
4401
|
export function escapeHtmlBatch(values) {
|
package/src/cache.ts
CHANGED
|
@@ -233,8 +233,13 @@ export function cachedRouteResponse(options: {
|
|
|
233
233
|
cache?: AppRouterCache | undefined;
|
|
234
234
|
key: string;
|
|
235
235
|
now?: number;
|
|
236
|
+
request?: Request | undefined;
|
|
236
237
|
}): Promise<Response | undefined> {
|
|
237
238
|
return Promise.resolve().then(async () => {
|
|
239
|
+
if (requestCarriesCredentials(options.request)) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
238
243
|
const cache = options.cache ?? cacheState.memoryCache;
|
|
239
244
|
|
|
240
245
|
await consumeInvalidations(cache);
|
|
@@ -262,6 +267,7 @@ export async function cacheRouteResponse(options: {
|
|
|
262
267
|
now?: number;
|
|
263
268
|
path: string;
|
|
264
269
|
policy: RouteCachePolicy | undefined;
|
|
270
|
+
request?: Request | undefined;
|
|
265
271
|
response: Response;
|
|
266
272
|
}): Promise<Response> {
|
|
267
273
|
if (options.policy === undefined) {
|
|
@@ -276,6 +282,24 @@ export async function cacheRouteResponse(options: {
|
|
|
276
282
|
const body = await options.response.text();
|
|
277
283
|
const cacheControl = options.policy.cacheControl;
|
|
278
284
|
const status = options.response.status;
|
|
285
|
+
const contentType = options.response.headers.get("content-type") ?? "text/html; charset=utf-8";
|
|
286
|
+
|
|
287
|
+
if (
|
|
288
|
+
requestCarriesCredentials(options.request) ||
|
|
289
|
+
options.response.headers.has("set-cookie")
|
|
290
|
+
) {
|
|
291
|
+
const headers = new Headers(options.response.headers);
|
|
292
|
+
headers.set("cache-control", "private, no-store");
|
|
293
|
+
if (!headers.has("content-type")) {
|
|
294
|
+
headers.set("content-type", contentType);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return new Response(body, {
|
|
298
|
+
headers,
|
|
299
|
+
status,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
279
303
|
await (options.cache ?? cacheState.memoryCache).set(options.key, {
|
|
280
304
|
body,
|
|
281
305
|
cacheControl,
|
|
@@ -287,13 +311,21 @@ export async function cacheRouteResponse(options: {
|
|
|
287
311
|
return new Response(body, {
|
|
288
312
|
headers: {
|
|
289
313
|
"cache-control": cacheControl,
|
|
290
|
-
"content-type":
|
|
314
|
+
"content-type": contentType,
|
|
291
315
|
"x-mreact-cache": "MISS",
|
|
292
316
|
},
|
|
293
317
|
status,
|
|
294
318
|
});
|
|
295
319
|
}
|
|
296
320
|
|
|
321
|
+
function requestCarriesCredentials(request: Request | undefined): boolean {
|
|
322
|
+
if (request === undefined) {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return request.headers.has("authorization") || request.headers.has("cookie");
|
|
327
|
+
}
|
|
328
|
+
|
|
297
329
|
export function revalidatePath(path: string): void {
|
|
298
330
|
const normalizedPath = normalizeRevalidationPath(path);
|
|
299
331
|
const activeContext = activeRouteCacheContext();
|
package/src/client.ts
CHANGED
|
@@ -2240,6 +2240,7 @@ export async function buildClientRouteEntrySource(
|
|
|
2240
2240
|
},
|
|
2241
2241
|
fetchRevalidationInstalled: false,
|
|
2242
2242
|
installed: false,
|
|
2243
|
+
prefetchedUrls: new Set(),
|
|
2243
2244
|
prefetchedScripts: new Set(),
|
|
2244
2245
|
reloadNextNavigationFetch: false,
|
|
2245
2246
|
routePrefetchManifest: undefined,
|
|
@@ -2593,6 +2594,12 @@ export async function __mreactPrefetch(url) {
|
|
|
2593
2594
|
return false;
|
|
2594
2595
|
}
|
|
2595
2596
|
|
|
2597
|
+
if (__mreactNavigationState.prefetchedUrls.has(href)) {
|
|
2598
|
+
return true;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
__mreactNavigationState.prefetchedUrls.add(href);
|
|
2602
|
+
|
|
2596
2603
|
const script = __mreactRouteScriptForNavigationUrl(href);
|
|
2597
2604
|
|
|
2598
2605
|
if (script === undefined) {
|
|
@@ -2690,6 +2697,10 @@ export async function __mreactNavigate(url, options = {}) {
|
|
|
2690
2697
|
|
|
2691
2698
|
try {
|
|
2692
2699
|
const cachedHtml = __mreactCachedNavigationHtml(href);
|
|
2700
|
+
const script = __mreactRouteScriptForNavigationUrl(href);
|
|
2701
|
+
if (script !== undefined) {
|
|
2702
|
+
void __mreactPrefetchRouteScript(script);
|
|
2703
|
+
}
|
|
2693
2704
|
const html = cachedHtml ?? await __mreactFetchNavigationHtml(href);
|
|
2694
2705
|
|
|
2695
2706
|
if (typeof html !== "string") {
|
|
@@ -2842,12 +2853,14 @@ export function __mreactInvalidateNavigationCache(path) {
|
|
|
2842
2853
|
for (const href of Array.from(__mreactNavigationState.cache.keys())) {
|
|
2843
2854
|
if (__mreactNormalizeNavigationPath(href) === normalizedPath) {
|
|
2844
2855
|
__mreactNavigationState.cache.delete(href);
|
|
2856
|
+
__mreactNavigationState.prefetchedUrls.delete(href);
|
|
2845
2857
|
}
|
|
2846
2858
|
}
|
|
2847
2859
|
}
|
|
2848
2860
|
|
|
2849
2861
|
function __mreactInvalidateAllNavigationCache() {
|
|
2850
2862
|
__mreactNavigationState.cache.clear();
|
|
2863
|
+
__mreactNavigationState.prefetchedUrls.clear();
|
|
2851
2864
|
}
|
|
2852
2865
|
|
|
2853
2866
|
function __mreactFetchNavigationHtml(href) {
|
|
@@ -2903,11 +2916,19 @@ function __mreactNormalizeNavigationPath(path) {
|
|
|
2903
2916
|
}
|
|
2904
2917
|
|
|
2905
2918
|
export function __mreactRestoreHistoryState(state) {
|
|
2906
|
-
if (state === null || state === undefined || state.__mreact !== true
|
|
2919
|
+
if (state === null || state === undefined || state.__mreact !== true) {
|
|
2907
2920
|
return false;
|
|
2908
2921
|
}
|
|
2909
2922
|
|
|
2910
|
-
const
|
|
2923
|
+
const html = typeof state.html === "string"
|
|
2924
|
+
? state.html
|
|
2925
|
+
: typeof state.url === "string" ? __mreactNavigationState.cache.get(state.url) : undefined;
|
|
2926
|
+
|
|
2927
|
+
if (typeof html !== "string") {
|
|
2928
|
+
return false;
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
const applied = __mreactApplyNavigationHtml(html, state.url);
|
|
2911
2932
|
|
|
2912
2933
|
if (!applied) {
|
|
2913
2934
|
return false;
|
|
@@ -3120,15 +3141,14 @@ function __mreactSyncRouteDataScripts(root, currentRouteId, nextRouteId) {
|
|
|
3120
3141
|
return;
|
|
3121
3142
|
}
|
|
3122
3143
|
|
|
3123
|
-
for (const
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3144
|
+
for (const id of managedIds) {
|
|
3145
|
+
document.getElementById(id)?.remove();
|
|
3146
|
+
const next = typeof root.getElementById === "function"
|
|
3147
|
+
? root.getElementById(id)
|
|
3148
|
+
: root.querySelector(\`#\${id}\`);
|
|
3128
3149
|
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
document.body.appendChild(element);
|
|
3150
|
+
if (next !== null && next !== undefined) {
|
|
3151
|
+
document.body.appendChild(next);
|
|
3132
3152
|
}
|
|
3133
3153
|
}
|
|
3134
3154
|
}
|
|
@@ -3155,13 +3175,29 @@ function __mreactRouteDataScriptSelector() {
|
|
|
3155
3175
|
function __mreactCurrentHistoryState(url) {
|
|
3156
3176
|
return {
|
|
3157
3177
|
__mreact: true,
|
|
3158
|
-
html:
|
|
3178
|
+
html: __mreactCurrentDocumentRouteHtml(),
|
|
3159
3179
|
scrollX: Number(globalThis.scrollX ?? 0),
|
|
3160
3180
|
scrollY: Number(globalThis.scrollY ?? 0),
|
|
3161
3181
|
url,
|
|
3162
3182
|
};
|
|
3163
3183
|
}
|
|
3164
3184
|
|
|
3185
|
+
function __mreactCurrentDocumentRouteHtml() {
|
|
3186
|
+
if (typeof document === "undefined") {
|
|
3187
|
+
return undefined;
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
const marker = document.querySelector("[" + __mreactRouteMarkerAttribute + "]");
|
|
3191
|
+
if (marker === null) {
|
|
3192
|
+
return undefined;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
const routeDataScripts = Array.from(document.querySelectorAll(__mreactRouteDataScriptSelector()))
|
|
3196
|
+
.map((script) => script.outerHTML)
|
|
3197
|
+
.join("");
|
|
3198
|
+
return marker.outerHTML + routeDataScripts;
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3165
3201
|
function __mreactPushHistoryState(url) {
|
|
3166
3202
|
if (typeof history === "undefined" || url === undefined) {
|
|
3167
3203
|
return;
|
|
@@ -4245,7 +4281,8 @@ export function cell(initial) {
|
|
|
4245
4281
|
buildApi.onLoad({ filter: /^devtools$/, namespace: "mreact-devtools-stub" }, () => ({
|
|
4246
4282
|
contents: `export function emitReactiveDevtoolsEvent() {}
|
|
4247
4283
|
export function hasReactiveDevtoolsEmitter() { return false; }
|
|
4248
|
-
export function currentDevtoolsEmitter() { return undefined; }
|
|
4284
|
+
export function currentDevtoolsEmitter() { return undefined; }
|
|
4285
|
+
export function currentReactiveDevtools() { return undefined; }`,
|
|
4249
4286
|
loader: "ts",
|
|
4250
4287
|
}));
|
|
4251
4288
|
buildApi.onLoad({ filter: /\.(?:mreact\.)?[cm]?[jt]sx$/ }, async (args) => {
|
package/src/dev-server.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface StartDevServerOptions extends AppRouterProjectOptions {
|
|
|
28
28
|
logger?: AppRouterLogger | undefined;
|
|
29
29
|
routeCache?: AppRouterCache | undefined;
|
|
30
30
|
serverActions?: AppRouterServerActionOptions | undefined;
|
|
31
|
+
verboseErrors?: boolean | undefined;
|
|
31
32
|
viteConfig?: UserConfig | undefined;
|
|
32
33
|
onUpgrade?: HttpUpgradeHandler | undefined;
|
|
33
34
|
}
|
|
@@ -87,7 +88,7 @@ export async function startDevServer(
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
if (vite === undefined) {
|
|
90
|
-
sendDevServerError(outgoing, new Error("Vite dev server is not initialized."));
|
|
91
|
+
sendDevServerError(outgoing, new Error("Vite dev server is not initialized."), options);
|
|
91
92
|
return;
|
|
92
93
|
}
|
|
93
94
|
|
|
@@ -96,7 +97,7 @@ export async function startDevServer(
|
|
|
96
97
|
if (error instanceof Error) {
|
|
97
98
|
vite?.ssrFixStacktrace(error);
|
|
98
99
|
}
|
|
99
|
-
sendDevServerError(outgoing, error);
|
|
100
|
+
sendDevServerError(outgoing, error, options);
|
|
100
101
|
return;
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -263,8 +264,14 @@ function definedProjectOptions(options: StartDevServerOptions): AppRouterProject
|
|
|
263
264
|
};
|
|
264
265
|
}
|
|
265
266
|
|
|
266
|
-
function sendDevServerError(
|
|
267
|
+
function sendDevServerError(
|
|
268
|
+
outgoing: ServerResponse,
|
|
269
|
+
error: unknown,
|
|
270
|
+
options: Pick<StartDevServerOptions, "verboseErrors">,
|
|
271
|
+
): void {
|
|
267
272
|
outgoing.statusCode = 500;
|
|
268
273
|
outgoing.setHeader("content-type", "text/plain; charset=utf-8");
|
|
269
|
-
outgoing.end(
|
|
274
|
+
outgoing.end(options.verboseErrors === true
|
|
275
|
+
? error instanceof Error ? error.stack : String(error)
|
|
276
|
+
: "Internal Server Error");
|
|
270
277
|
}
|
package/src/link.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { escapeHtmlAttribute } from "@reckona/mreact-shared/html-escape";
|
|
1
|
+
import { escapeHtmlAttribute, escapeHtmlText } from "@reckona/mreact-shared/html-escape";
|
|
2
2
|
import type { HtmlSink } from "@reckona/mreact-shared/compiler-contract";
|
|
3
3
|
import type { ReactCompatElement, ReactCompatNode } from "@reckona/mreact-compat";
|
|
4
4
|
import { safeUrlAttributeValue } from "@reckona/mreact-shared/url-safety";
|
|
@@ -87,7 +87,14 @@ function createAnchorElement(props: Record<string, unknown>): HTMLAnchorElement
|
|
|
87
87
|
continue;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
const attrName = attributeName(name);
|
|
91
|
+
const safeValue = safeUrlAttributeValue(attrName, String(value));
|
|
92
|
+
|
|
93
|
+
if (safeValue === undefined) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
anchor.setAttribute(attrName, safeValue);
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
return anchor;
|
|
@@ -164,5 +171,19 @@ function renderChildren(child: LinkChild): string {
|
|
|
164
171
|
return child.map(renderChildren).join("");
|
|
165
172
|
}
|
|
166
173
|
|
|
167
|
-
|
|
174
|
+
if (typeof child === "string" && isPreRenderedSafeHtmlChild(child)) {
|
|
175
|
+
return child;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return escapeHtmlText(child);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isPreRenderedSafeHtmlChild(value: string): boolean {
|
|
182
|
+
if (!/[<&]/.test(value)) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return !/(?:<\s*\/?\s*(?:script|style|iframe|object|embed|link|meta|base)\b|\son[a-z]+\s*=|javascript\s*:)/iu.test(
|
|
187
|
+
value,
|
|
188
|
+
);
|
|
168
189
|
}
|
package/src/middleware.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { normalizeRoutePath } from "./route-path.js";
|
|
2
|
+
|
|
1
3
|
export interface RouteMiddlewareControl {
|
|
2
4
|
skip?: boolean | readonly string[];
|
|
3
5
|
}
|
|
@@ -133,20 +135,23 @@ export function mergeRouteMiddlewareControls(
|
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
function middlewarePatternMatches(pattern: string, pathname: string): boolean {
|
|
136
|
-
|
|
138
|
+
const normalizedPattern = normalizeRoutePath(pattern);
|
|
139
|
+
const normalizedPathname = normalizeRoutePath(pathname);
|
|
140
|
+
|
|
141
|
+
if (normalizedPattern === normalizedPathname) {
|
|
137
142
|
return true;
|
|
138
143
|
}
|
|
139
144
|
|
|
140
|
-
if (
|
|
141
|
-
const prefix =
|
|
145
|
+
if (normalizedPattern.endsWith("/:path*")) {
|
|
146
|
+
const prefix = normalizedPattern.slice(0, -"/:path*".length);
|
|
142
147
|
|
|
143
|
-
return
|
|
148
|
+
return normalizedPathname === prefix || normalizedPathname.startsWith(`${prefix}/`);
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
if (
|
|
147
|
-
const prefix =
|
|
151
|
+
if (normalizedPattern.endsWith("*")) {
|
|
152
|
+
const prefix = normalizedPattern.slice(0, -1);
|
|
148
153
|
|
|
149
|
-
return
|
|
154
|
+
return normalizedPathname.startsWith(prefix);
|
|
150
155
|
}
|
|
151
156
|
|
|
152
157
|
return false;
|
package/src/multipart.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export interface MultipartStreamParseOptions {
|
|
2
2
|
fields?: Readonly<Record<string, MultipartStreamFieldOptions>>;
|
|
3
|
+
maxParts?: number;
|
|
3
4
|
maxBytes?: number;
|
|
4
5
|
}
|
|
5
6
|
|
|
@@ -11,6 +12,7 @@ export interface MultipartStreamFieldOptions {
|
|
|
11
12
|
export interface MultipartStreamPart {
|
|
12
13
|
name: string;
|
|
13
14
|
filename?: string;
|
|
15
|
+
safeFilename?: string;
|
|
14
16
|
contentType?: string;
|
|
15
17
|
headers: Headers;
|
|
16
18
|
body: ReadableStream<Uint8Array<ArrayBufferLike>>;
|
|
@@ -34,6 +36,8 @@ interface PartWriter {
|
|
|
34
36
|
const crlf = "\r\n";
|
|
35
37
|
const headerSeparator = "\r\n\r\n";
|
|
36
38
|
const maxHeaderBytes = 64 * 1024;
|
|
39
|
+
export const defaultMultipartMaxBytes = 10 * 1024 * 1024;
|
|
40
|
+
export const defaultMultipartMaxParts = 1_000;
|
|
37
41
|
|
|
38
42
|
export function parseMultipartStream(
|
|
39
43
|
request: Request,
|
|
@@ -73,6 +77,7 @@ async function parseMultipartRequest(
|
|
|
73
77
|
const keepTailBytes = delimiter.length + 4;
|
|
74
78
|
let buffer: Uint8Array<ArrayBufferLike> = new Uint8Array();
|
|
75
79
|
let totalBytes = 0;
|
|
80
|
+
let totalParts = 0;
|
|
76
81
|
let started = false;
|
|
77
82
|
let currentPart: PartWriter | undefined;
|
|
78
83
|
|
|
@@ -87,7 +92,7 @@ async function parseMultipartRequest(
|
|
|
87
92
|
|
|
88
93
|
if (!next.done) {
|
|
89
94
|
totalBytes += next.value.byteLength;
|
|
90
|
-
enforceByteLimit("multipart request", totalBytes, options.maxBytes);
|
|
95
|
+
enforceByteLimit("multipart request", totalBytes, options.maxBytes ?? defaultMultipartMaxBytes);
|
|
91
96
|
buffer = concatBytes(buffer, next.value);
|
|
92
97
|
}
|
|
93
98
|
|
|
@@ -136,6 +141,8 @@ async function parseMultipartRequest(
|
|
|
136
141
|
break;
|
|
137
142
|
}
|
|
138
143
|
|
|
144
|
+
totalParts += 1;
|
|
145
|
+
enforcePartLimit(totalParts, options.maxParts ?? defaultMultipartMaxParts);
|
|
139
146
|
currentPart = createPartWriter(
|
|
140
147
|
decodeAscii(buffer.slice(0, headerEnd)),
|
|
141
148
|
options,
|
|
@@ -266,6 +273,7 @@ function createPart(options: {
|
|
|
266
273
|
|
|
267
274
|
if (options.filename !== undefined) {
|
|
268
275
|
part.filename = options.filename;
|
|
276
|
+
part.safeFilename = sanitizeMultipartFilename(options.filename);
|
|
269
277
|
}
|
|
270
278
|
|
|
271
279
|
const contentType = options.headers.get("content-type");
|
|
@@ -330,6 +338,29 @@ function enforceByteLimit(label: string, bytes: number, maxBytes: number | undef
|
|
|
330
338
|
}
|
|
331
339
|
}
|
|
332
340
|
|
|
341
|
+
function enforcePartLimit(parts: number, maxParts: number | undefined): void {
|
|
342
|
+
if (maxParts !== undefined && parts > maxParts) {
|
|
343
|
+
throw new Error(`multipart request exceeded ${maxParts} parts.`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function sanitizeMultipartFilename(filename: string): string {
|
|
348
|
+
const leaf = filename.split(/[\\/]/u).at(-1) ?? "";
|
|
349
|
+
let sanitized = "";
|
|
350
|
+
|
|
351
|
+
for (const char of leaf) {
|
|
352
|
+
sanitized += isControlCharacter(char) ? "_" : char;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return sanitized === "" || sanitized === "." || sanitized === ".." ? "upload.bin" : sanitized;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function isControlCharacter(char: string): boolean {
|
|
359
|
+
const code = char.charCodeAt(0);
|
|
360
|
+
|
|
361
|
+
return code < 0x20 || code === 0x7f;
|
|
362
|
+
}
|
|
363
|
+
|
|
333
364
|
function parseMultipartBoundary(contentType: string | null): string | undefined {
|
|
334
365
|
if (contentType === null) {
|
|
335
366
|
return undefined;
|