@mandujs/core 0.54.1 β 0.54.3
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/package.json +4 -2
- package/scripts/postinstall-lock.ts +153 -0
- package/src/a11y/run-audit.ts +15 -15
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__tests__/cold-start.test.ts +35 -7
- package/src/bundler/analyzer.ts +15 -7
- package/src/bundler/build.test.ts +13 -6
- package/src/bundler/build.ts +429 -182
- package/src/bundler/manifest-schema.ts +21 -14
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -9
- package/src/bundler/plugins/block-generated-imports.ts +13 -12
- package/src/bundler/types.ts +31 -14
- package/src/client/island.ts +79 -29
- package/src/config/validate.ts +1 -1
- package/src/deploy/inference/context.ts +82 -15
- package/src/filling/context.ts +17 -4
- package/src/guard/check.ts +9 -9
- package/src/guard/config-guard.ts +13 -7
- package/src/guard/fs-routes-policy.ts +51 -0
- package/src/guard/index.ts +11 -6
- package/src/kitchen/api/file-api.ts +11 -8
- package/src/resource/__tests__/schema.test.ts +14 -9
- package/src/resource/generators/slot.ts +72 -71
- package/src/resource/schema.ts +21 -13
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -0
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -0
- package/src/runtime/__tests__/page-render-response.test.ts +103 -0
- package/src/runtime/__tests__/request-middleware.test.ts +70 -0
- package/src/runtime/devtools-adapter.ts +68 -0
- package/src/runtime/escape.ts +34 -6
- package/src/runtime/observability-lifecycle.ts +290 -0
- package/src/runtime/page-render-response.ts +106 -0
- package/src/runtime/request-middleware.ts +31 -0
- package/src/runtime/server.ts +228 -944
- package/src/runtime/ssr.ts +59 -37
- package/src/runtime/static-files.ts +289 -0
- package/src/runtime/streaming-ssr.ts +22 -13
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
*
|
|
29
29
|
* URL safety model (applies to `shared.runtime`, `shared.vendor`,
|
|
30
30
|
* `shared.router`, `shared.fastRefresh.glue`, `shared.fastRefresh.runtime`,
|
|
31
|
-
* `bundles[].js`, `bundles[].css`, `islands[].js`, `
|
|
31
|
+
* `bundles[].js`, `bundles[].css`, `islands[].js`, `partials[].js`,
|
|
32
|
+
* `importMap.imports[*]`):
|
|
32
33
|
*
|
|
33
34
|
* ALLOW: absolute paths rooted at `/.mandu/client/` ending in `.js` or `.css`.
|
|
34
35
|
* The bundler itself only ever emits this shape.
|
|
@@ -161,15 +162,20 @@ const BundleEntrySchema = z.object({
|
|
|
161
162
|
priority: PrioritySchema,
|
|
162
163
|
});
|
|
163
164
|
|
|
164
|
-
const IslandEntrySchema = z.object({
|
|
165
|
-
js: safeManduUrl("islands[].js"),
|
|
166
|
-
route: z.string().min(1),
|
|
167
|
-
priority: PrioritySchema,
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
165
|
+
const IslandEntrySchema = z.object({
|
|
166
|
+
js: safeManduUrl("islands[].js"),
|
|
167
|
+
route: z.string().min(1),
|
|
168
|
+
priority: PrioritySchema,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const PartialEntrySchema = z.object({
|
|
172
|
+
js: safeManduUrl("partials[].js"),
|
|
173
|
+
priority: PrioritySchema,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const FastRefreshSchema = z.object({
|
|
177
|
+
runtime: safeManduUrl("shared.fastRefresh.runtime"),
|
|
178
|
+
glue: safeManduUrl("shared.fastRefresh.glue"),
|
|
173
179
|
});
|
|
174
180
|
|
|
175
181
|
const SharedSchema = z.object({
|
|
@@ -219,10 +225,11 @@ export const BundleManifestSchema = z
|
|
|
219
225
|
.object({
|
|
220
226
|
version: z.number().int().min(1),
|
|
221
227
|
buildTime: z.string().min(1),
|
|
222
|
-
env: z.enum(["development", "production"]),
|
|
223
|
-
bundles: z.record(z.string(), BundleEntrySchema),
|
|
224
|
-
islands: z.record(z.string(), IslandEntrySchema).optional(),
|
|
225
|
-
|
|
228
|
+
env: z.enum(["development", "production"]),
|
|
229
|
+
bundles: z.record(z.string(), BundleEntrySchema),
|
|
230
|
+
islands: z.record(z.string(), IslandEntrySchema).optional(),
|
|
231
|
+
partials: z.record(z.string(), PartialEntrySchema).optional(),
|
|
232
|
+
shared: SharedSchema,
|
|
226
233
|
importMap: ImportMapSchema.optional(),
|
|
227
234
|
})
|
|
228
235
|
.strict();
|
|
@@ -80,15 +80,19 @@ describe("defaultAllowImporter", () => {
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
describe("DEFAULT_BLOCK_FILTER", () => {
|
|
83
|
-
test("matches __generated__ specifiers", () => {
|
|
84
|
-
expect("./__generated__/foo").toMatch(DEFAULT_BLOCK_FILTER);
|
|
85
|
-
expect("../../src/__generated__/routes").toMatch(DEFAULT_BLOCK_FILTER);
|
|
86
|
-
});
|
|
87
|
-
test("
|
|
88
|
-
expect("
|
|
89
|
-
expect("
|
|
90
|
-
});
|
|
91
|
-
|
|
83
|
+
test("matches __generated__ specifiers", () => {
|
|
84
|
+
expect("./__generated__/foo").toMatch(DEFAULT_BLOCK_FILTER);
|
|
85
|
+
expect("../../src/__generated__/routes").toMatch(DEFAULT_BLOCK_FILTER);
|
|
86
|
+
});
|
|
87
|
+
test("matches direct .mandu/generated relative specifiers", () => {
|
|
88
|
+
expect("../.mandu/generated/routes").toMatch(DEFAULT_BLOCK_FILTER);
|
|
89
|
+
expect("../../../.mandu/generated/server/repos/party.repo").toMatch(DEFAULT_BLOCK_FILTER);
|
|
90
|
+
});
|
|
91
|
+
test("does NOT match look-alikes without double underscores", () => {
|
|
92
|
+
expect("./generated/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
|
|
93
|
+
expect("./src/generate/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
92
96
|
|
|
93
97
|
describe("defaultBundlerPlugins", () => {
|
|
94
98
|
test("installs block-generated-imports by default", () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bun bundler plugin β hard-fail on direct
|
|
2
|
+
* Bun bundler plugin β hard-fail on direct generated-artifact imports.
|
|
3
3
|
*
|
|
4
4
|
* Background
|
|
5
5
|
* ββββββββββ
|
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
* but it only runs when the user (or CI) invokes `mandu guard check`.
|
|
9
9
|
* Autonomous coding agents routinely bypass that step. This plugin closes
|
|
10
10
|
* the gap at the bundler level: every `mandu dev` / `mandu build` pass
|
|
11
|
-
* installs it by default, and any import whose specifier
|
|
12
|
-
* `__generated__` fails the build with a structured,
|
|
11
|
+
* installs it by default, and any import whose specifier targets
|
|
12
|
+
* `__generated__` or `.mandu/generated` fails the build with a structured,
|
|
13
|
+
* actionable error.
|
|
13
14
|
*
|
|
14
15
|
* Design
|
|
15
16
|
* ββββββ
|
|
16
|
-
* - `onResolve({ filter:
|
|
17
|
-
* whose *specifier* matches the regex, along with the importer's
|
|
18
|
-
* (`args.importer`). We never return a result; we always throw.
|
|
17
|
+
* - `onResolve({ filter: DEFAULT_BLOCK_FILTER })` β Bun hands us every
|
|
18
|
+
* import whose *specifier* matches the regex, along with the importer's
|
|
19
|
+
* path (`args.importer`). We never return a result; we always throw.
|
|
19
20
|
* - The error is `ForbiddenGeneratedImportError`, a named subclass of
|
|
20
21
|
* `Error`. Tests can `instanceof`-check; Bun surfaces `error.message` in
|
|
21
22
|
* its `result.logs` output for CLI display.
|
|
@@ -90,8 +91,8 @@ export interface BlockGeneratedImportsOptions {
|
|
|
90
91
|
allowImporter?: (importerPath: string) => boolean;
|
|
91
92
|
/**
|
|
92
93
|
* Custom filter regex applied to the import specifier. Defaults to
|
|
93
|
-
*
|
|
94
|
-
* test harnesses that want to narrow or broaden the filter.
|
|
94
|
+
* generated-artifact paths. Mandu ships a single default β exposing this
|
|
95
|
+
* for test harnesses that want to narrow or broaden the filter.
|
|
95
96
|
*/
|
|
96
97
|
filter?: RegExp;
|
|
97
98
|
}
|
|
@@ -114,7 +115,7 @@ export function defaultAllowImporter(importerPath: string): boolean {
|
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
/**
|
|
117
|
-
* Build a `BunPlugin` that blocks direct
|
|
118
|
+
* Build a `BunPlugin` that blocks direct generated-artifact imports.
|
|
118
119
|
*
|
|
119
120
|
* Usage β call from `defaultBundlerPlugins(config)` (see `./index.ts`).
|
|
120
121
|
* Every `safeBuild` / `Bun.build` invocation in Mandu funnels through
|
|
@@ -123,7 +124,7 @@ export function defaultAllowImporter(importerPath: string): boolean {
|
|
|
123
124
|
export function blockGeneratedImports(
|
|
124
125
|
options: BlockGeneratedImportsOptions = {},
|
|
125
126
|
): BunPlugin {
|
|
126
|
-
const filter = options.filter ??
|
|
127
|
+
const filter = options.filter ?? DEFAULT_BLOCK_FILTER;
|
|
127
128
|
const allowImporter = options.allowImporter ?? defaultAllowImporter;
|
|
128
129
|
|
|
129
130
|
return {
|
|
@@ -151,5 +152,5 @@ export function blockGeneratedImports(
|
|
|
151
152
|
};
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
/** Exported for unit-test convenience β keep the filter text assertable. */
|
|
155
|
-
export const DEFAULT_BLOCK_FILTER = /__generated__/;
|
|
155
|
+
/** Exported for unit-test convenience β keep the filter text assertable. */
|
|
156
|
+
export const DEFAULT_BLOCK_FILTER = /__generated__|(?:^|[\/\\])\.mandu[\/\\]generated(?:[\/\\]|$)/;
|
package/src/bundler/types.ts
CHANGED
|
@@ -55,18 +55,28 @@ export interface BundleManifest {
|
|
|
55
55
|
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
56
56
|
}
|
|
57
57
|
>;
|
|
58
|
-
/** Per-island bundles (code splitting: each island file gets its own JS bundle) */
|
|
59
|
-
islands?: Record<
|
|
60
|
-
string,
|
|
61
|
-
{
|
|
62
|
-
/** JavaScript bundle path */
|
|
58
|
+
/** Per-island bundles (code splitting: each island file gets its own JS bundle) */
|
|
59
|
+
islands?: Record<
|
|
60
|
+
string,
|
|
61
|
+
{
|
|
62
|
+
/** JavaScript bundle path */
|
|
63
63
|
js: string;
|
|
64
64
|
/** Route that owns this island */
|
|
65
65
|
route: string;
|
|
66
66
|
/** Hydration priority */
|
|
67
|
-
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
68
|
-
}
|
|
69
|
-
>;
|
|
67
|
+
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
68
|
+
}
|
|
69
|
+
>;
|
|
70
|
+
/** Inline partial bundles (from *.partial.tsx / *.partial.ts files) */
|
|
71
|
+
partials?: Record<
|
|
72
|
+
string,
|
|
73
|
+
{
|
|
74
|
+
/** JavaScript bundle path */
|
|
75
|
+
js: string;
|
|
76
|
+
/** Hydration priority */
|
|
77
|
+
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
78
|
+
}
|
|
79
|
+
>;
|
|
70
80
|
/** 곡μ μ²ν¬ */
|
|
71
81
|
shared: {
|
|
72
82
|
/** Hydration λ°νμ */
|
|
@@ -113,12 +123,19 @@ export interface BundleStats {
|
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
/** Per-island code splitting entry (used by scanIslandFiles) */
|
|
116
|
-
export interface IslandFileEntry {
|
|
117
|
-
name: string;
|
|
118
|
-
filePath: string;
|
|
119
|
-
routeId: string;
|
|
120
|
-
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
121
|
-
}
|
|
126
|
+
export interface IslandFileEntry {
|
|
127
|
+
name: string;
|
|
128
|
+
filePath: string;
|
|
129
|
+
routeId: string;
|
|
130
|
+
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Inline partial bundle entry (used by scanPartialFiles) */
|
|
134
|
+
export interface PartialFileEntry {
|
|
135
|
+
name: string;
|
|
136
|
+
filePath: string;
|
|
137
|
+
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
138
|
+
}
|
|
122
139
|
|
|
123
140
|
/**
|
|
124
141
|
* λ²λ€λ¬ μ΅μ
|
package/src/client/island.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* Hydrationμ μν ν΄λΌμ΄μΈνΈ μ¬μ΄λ μ»΄ν¬λνΈ μ μ
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { ReactNode } from "react";
|
|
7
|
-
import {
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { serializeProps } from "./serialize";
|
|
8
|
+
import { getServerData as getGlobalServerData } from "./window-state";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Island μ μ νμ
|
|
@@ -335,26 +336,45 @@ export interface PartialConfig {
|
|
|
335
336
|
/**
|
|
336
337
|
* Partial Island μ μ νμ
|
|
337
338
|
*/
|
|
338
|
-
export interface PartialDefinition<TProps> {
|
|
339
|
-
/** Partial
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
|
|
345
|
-
|
|
339
|
+
export interface PartialDefinition<TProps> {
|
|
340
|
+
/** Partial κ³ μ ID. κΈ°λ³Έκ°μ component displayName/name μ
λλ€. */
|
|
341
|
+
id?: string;
|
|
342
|
+
/** Partial μ»΄ν¬λνΈ */
|
|
343
|
+
component: React.ComponentType<TProps>;
|
|
344
|
+
/** μ΄κΈ° props (SSRμμ μ λ¬) */
|
|
345
|
+
initialProps?: TProps;
|
|
346
|
+
/** νμ΄λλ μ΄μ
μ°μ μμ */
|
|
347
|
+
priority?: "immediate" | "visible" | "idle" | "interaction";
|
|
348
|
+
/** λͺ
μμ λ²λ€ URL. κΈ°λ³Έκ°μ `/.mandu/client/{id}.partial.js` μ
λλ€. */
|
|
349
|
+
src?: string;
|
|
350
|
+
/** μλ¬ μ νμν UI */
|
|
351
|
+
errorBoundary?: (error: Error, reset: () => void) => ReactNode;
|
|
352
|
+
/** λ‘λ© μ€ νμν UI */
|
|
353
|
+
loading?: () => ReactNode;
|
|
354
|
+
}
|
|
346
355
|
|
|
347
356
|
/**
|
|
348
357
|
* μ»΄νμΌλ Partial
|
|
349
358
|
*/
|
|
350
|
-
export interface CompiledPartial<TProps> {
|
|
359
|
+
export interface CompiledPartial<TProps> {
|
|
351
360
|
/** Partial μ μ */
|
|
352
361
|
definition: PartialDefinition<TProps>;
|
|
353
362
|
/** Mandu Partial λ§μ»€ */
|
|
354
363
|
__mandu_partial: true;
|
|
355
364
|
/** Partial ID */
|
|
356
|
-
__mandu_partial_id?: string;
|
|
357
|
-
}
|
|
365
|
+
__mandu_partial_id?: string;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function normalizePartialId(id: string): string {
|
|
369
|
+
return id
|
|
370
|
+
.trim()
|
|
371
|
+
.replace(/[^A-Za-z0-9_-]/g, "-")
|
|
372
|
+
.replace(/^-+|-+$/g, "") || "partial";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function priorityToHydrate(priority: NonNullable<PartialDefinition<unknown>["priority"]>): string {
|
|
376
|
+
return priority === "immediate" ? "load" : priority;
|
|
377
|
+
}
|
|
358
378
|
|
|
359
379
|
/**
|
|
360
380
|
* Partial Island μμ±
|
|
@@ -379,26 +399,56 @@ export interface CompiledPartial<TProps> {
|
|
|
379
399
|
* }
|
|
380
400
|
* ```
|
|
381
401
|
*/
|
|
382
|
-
export function partial<TProps extends Record<string, unknown>>(
|
|
383
|
-
definition: PartialDefinition<TProps>
|
|
384
|
-
): CompiledPartial<TProps> & {
|
|
385
|
-
Render: React.ComponentType<TProps>;
|
|
386
|
-
} {
|
|
402
|
+
export function partial<TProps extends Record<string, unknown>>(
|
|
403
|
+
definition: PartialDefinition<TProps>
|
|
404
|
+
): CompiledPartial<TProps> & {
|
|
405
|
+
Render: React.ComponentType<TProps>;
|
|
406
|
+
} {
|
|
387
407
|
if (!definition.component) {
|
|
388
408
|
throw new Error("[Mandu Partial] component is required");
|
|
389
409
|
}
|
|
390
410
|
|
|
391
|
-
const
|
|
392
|
-
definition
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
411
|
+
const partialId = normalizePartialId(
|
|
412
|
+
definition.id ||
|
|
413
|
+
definition.component.displayName ||
|
|
414
|
+
definition.component.name ||
|
|
415
|
+
"partial",
|
|
416
|
+
);
|
|
417
|
+
const normalizedDefinition: PartialDefinition<TProps> = {
|
|
418
|
+
...definition,
|
|
419
|
+
id: partialId,
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const compiled: CompiledPartial<TProps> = {
|
|
423
|
+
definition: normalizedDefinition,
|
|
424
|
+
__mandu_partial: true,
|
|
425
|
+
__mandu_partial_id: partialId,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// Render μ»΄ν¬λνΈ μμ±
|
|
429
|
+
const React = require("react");
|
|
430
|
+
|
|
431
|
+
const RenderComponent: React.FC<TProps> = (props) => {
|
|
432
|
+
const renderProps = Object.keys(props).length > 0
|
|
433
|
+
? props
|
|
434
|
+
: (normalizedDefinition.initialProps ?? props);
|
|
435
|
+
const priority = normalizedDefinition.priority ?? "visible";
|
|
436
|
+
const bundleSrc = normalizedDefinition.src ?? `/.mandu/client/${partialId}.partial.js`;
|
|
437
|
+
|
|
438
|
+
return React.createElement(
|
|
439
|
+
"div",
|
|
440
|
+
{
|
|
441
|
+
"data-mandu-island": partialId,
|
|
442
|
+
"data-mandu-partial": partialId,
|
|
443
|
+
"data-mandu-src": bundleSrc,
|
|
444
|
+
"data-mandu-priority": priority,
|
|
445
|
+
"data-hydrate": priorityToHydrate(priority),
|
|
446
|
+
"data-props": serializeProps(renderProps),
|
|
447
|
+
style: { display: "contents" },
|
|
448
|
+
},
|
|
449
|
+
React.createElement(normalizedDefinition.component, renderProps),
|
|
450
|
+
);
|
|
451
|
+
};
|
|
402
452
|
|
|
403
453
|
return Object.assign(compiled, { Render: RenderComponent });
|
|
404
454
|
}
|
package/src/config/validate.ts
CHANGED
|
@@ -43,7 +43,7 @@ function _strictWithWarnings<T extends z.ZodRawShape>(
|
|
|
43
43
|
*/
|
|
44
44
|
const ServerConfigSchema = z
|
|
45
45
|
.object({
|
|
46
|
-
port: z.number().min(1).max(65535).default(
|
|
46
|
+
port: z.number().min(1).max(65535).default(3333),
|
|
47
47
|
// Default `"::"` (IPv6 wildcard, dual-stack): accepts both IPv4 and
|
|
48
48
|
// IPv6 clients on one socket. Fixes Windows Node 17+ fetch failing
|
|
49
49
|
// with `ECONNREFUSED ::1:PORT` because `localhost` resolves to `::1`
|
|
@@ -78,8 +78,8 @@ export async function buildDeployInferenceContext(
|
|
|
78
78
|
// still gets the manifest metadata and falls back to defaults.
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
const imports = extractImports(source);
|
|
82
|
-
const dependencyClasses =
|
|
81
|
+
const imports = extractImports(source);
|
|
82
|
+
const dependencyClasses = classifySourceDependencies(source, imports);
|
|
83
83
|
// Mandu's manifest patterns use `:param` / `*` (path-pattern style),
|
|
84
84
|
// not bracket-form. Detect both shapes so the heuristic doesn't
|
|
85
85
|
// misclassify dynamic routes as prerenderable. Examples:
|
|
@@ -139,18 +139,82 @@ export function extractImports(source: string): string[] {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/** Map import specifiers to coarse dependency classes. */
|
|
142
|
-
export function classifyImports(imports: string[]): ReadonlySet<DependencyClass> {
|
|
143
|
-
const classes = new Set<DependencyClass>();
|
|
144
|
-
for (const spec of imports) {
|
|
142
|
+
export function classifyImports(imports: string[]): ReadonlySet<DependencyClass> {
|
|
143
|
+
const classes = new Set<DependencyClass>();
|
|
144
|
+
for (const spec of imports) {
|
|
145
145
|
const cls = classifyOne(spec);
|
|
146
146
|
if (cls) classes.add(cls);
|
|
147
147
|
}
|
|
148
148
|
if (classes.size === 0) classes.add("fetch-only");
|
|
149
|
-
return classes;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
149
|
+
return classes;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Classify dependency signals that do not always appear as bare imports.
|
|
154
|
+
*
|
|
155
|
+
* Dogfooding surfaced Mandu API routes importing project-local
|
|
156
|
+
* `src/server/infra/db` helpers and using `db` tagged templates. Those
|
|
157
|
+
* are server-only even when the bare imports look edge-safe.
|
|
158
|
+
*/
|
|
159
|
+
export function classifySourceDependencies(
|
|
160
|
+
source: string,
|
|
161
|
+
imports: string[] = extractImports(source),
|
|
162
|
+
): ReadonlySet<DependencyClass> {
|
|
163
|
+
const classes = new Set<DependencyClass>(classifyImports(imports));
|
|
164
|
+
if (classes.size === 1 && classes.has("fetch-only")) {
|
|
165
|
+
classes.delete("fetch-only");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const allImports = extractAllImportSpecifiers(source);
|
|
169
|
+
if (allImports.some(isServerInfraImport)) {
|
|
170
|
+
classes.add("db");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (/\bBun\.SQL\b|\bBun\.sqlite\b/i.test(source)) {
|
|
174
|
+
classes.add("bun-native");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (
|
|
178
|
+
/(?:^|[^\w$])db\s*`/.test(source) ||
|
|
179
|
+
/\bctx\.deps\.db\b/.test(source) ||
|
|
180
|
+
/\bdb\.(?:query|execute|select|insert|update|delete)\b/.test(source)
|
|
181
|
+
) {
|
|
182
|
+
classes.add("db");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (classes.size === 0) {
|
|
186
|
+
classes.add("fetch-only");
|
|
187
|
+
}
|
|
188
|
+
return classes;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function extractAllImportSpecifiers(source: string): string[] {
|
|
192
|
+
const out = new Set<string>();
|
|
193
|
+
const staticImport = /^\s*import\b[^"']*?["']([^"']+)["']/gm;
|
|
194
|
+
const dynamicImport = /\bimport\(\s*["']([^"']+)["']\s*\)/g;
|
|
195
|
+
for (const re of [staticImport, dynamicImport]) {
|
|
196
|
+
let m: RegExpExecArray | null;
|
|
197
|
+
while ((m = re.exec(source)) !== null) {
|
|
198
|
+
out.add(m[1]!);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return [...out].sort();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isServerInfraImport(specifier: string): boolean {
|
|
205
|
+
const s = specifier.replace(/\\/g, "/").toLowerCase();
|
|
206
|
+
return (
|
|
207
|
+
s === "@/server/infra" ||
|
|
208
|
+
s.startsWith("@/server/infra/") ||
|
|
209
|
+
s === "src/server/infra" ||
|
|
210
|
+
s.startsWith("src/server/infra/") ||
|
|
211
|
+
s.endsWith("/server/infra") ||
|
|
212
|
+
s.includes("/server/infra/")
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function classifyOne(spec: string): DependencyClass | null {
|
|
217
|
+
const s = spec.toLowerCase();
|
|
154
218
|
if (
|
|
155
219
|
s === "bun:sqlite" ||
|
|
156
220
|
s === "bun:ffi" ||
|
|
@@ -167,11 +231,14 @@ function classifyOne(spec: string): DependencyClass | null {
|
|
|
167
231
|
if (s === "node:child_process" || s === "child_process" || s === "node:worker_threads" || s === "worker_threads") {
|
|
168
232
|
return "node-child";
|
|
169
233
|
}
|
|
170
|
-
if (
|
|
171
|
-
/^(postgres|pg|mysql2?|drizzle-orm(\/.*)?|@prisma\/client|prisma|mongodb|mongoose|@neondatabase\/.+|kysely|sqlite3|better-sqlite3|@planetscale\/.+)$/.test(s)
|
|
172
|
-
) {
|
|
173
|
-
return "db";
|
|
174
|
-
}
|
|
234
|
+
if (
|
|
235
|
+
/^(postgres|pg|mysql2?|drizzle-orm(\/.*)?|@prisma\/client|prisma|mongodb|mongoose|@neondatabase\/.+|kysely|sqlite3|better-sqlite3|@planetscale\/.+)$/.test(s)
|
|
236
|
+
) {
|
|
237
|
+
return "db";
|
|
238
|
+
}
|
|
239
|
+
if (s === "@mandujs/core/db" || s.startsWith("@mandujs/core/db/")) {
|
|
240
|
+
return "db";
|
|
241
|
+
}
|
|
175
242
|
if (/^(@anthropic-ai\/sdk|openai|ai|@ai-sdk\/.+|@google\/generative-ai|cohere-ai)$/.test(s)) {
|
|
176
243
|
return "ai-sdk";
|
|
177
244
|
}
|
package/src/filling/context.ts
CHANGED
|
@@ -628,10 +628,23 @@ export class ManduContext {
|
|
|
628
628
|
return this.withCookies(new Response(null, { status: 204 }));
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
-
/** 400 Bad Request */
|
|
632
|
-
error(message: string, details?: unknown): Response
|
|
633
|
-
|
|
634
|
-
|
|
631
|
+
/** 400 Bad Request, or custom 4xx/5xx error with ctx.error(status, message). */
|
|
632
|
+
error(message: string, details?: unknown): Response;
|
|
633
|
+
error(status: number, message: string, details?: unknown): Response;
|
|
634
|
+
error(
|
|
635
|
+
statusOrMessage: number | string,
|
|
636
|
+
messageOrDetails?: string | unknown,
|
|
637
|
+
maybeDetails?: unknown
|
|
638
|
+
): Response {
|
|
639
|
+
if (typeof statusOrMessage === "number") {
|
|
640
|
+
const status = Number.isInteger(statusOrMessage) && statusOrMessage >= 400 && statusOrMessage <= 599
|
|
641
|
+
? statusOrMessage
|
|
642
|
+
: 400;
|
|
643
|
+
const message = typeof messageOrDetails === "string" ? messageOrDetails : "Error";
|
|
644
|
+
return this.json({ status: "error", message, details: maybeDetails }, status);
|
|
645
|
+
}
|
|
646
|
+
return this.json({ status: "error", message: statusOrMessage, details: messageOrDetails }, 400);
|
|
647
|
+
}
|
|
635
648
|
|
|
636
649
|
/** 401 Unauthorized */
|
|
637
650
|
unauthorized(message: string = "Unauthorized"): Response {
|
package/src/guard/check.ts
CHANGED
|
@@ -30,21 +30,21 @@ export const GENERATED_IMPORT_DOCS_URL =
|
|
|
30
30
|
"https://mandujs.com/docs/architect/generated-access";
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Build the user-facing message for a detected direct
|
|
34
|
-
* import. `specifier` is the literal import string that tripped the
|
|
35
|
-
*
|
|
33
|
+
* Build the user-facing message for a detected direct generated-artifact
|
|
34
|
+
* import. `specifier` is the literal import string that tripped the guard
|
|
35
|
+
* (not the resolved path).
|
|
36
36
|
*
|
|
37
37
|
* This helper is the single source of truth for the message text β both
|
|
38
38
|
* the static Guard pass (`checkInvalidGeneratedImport`) and the bundler
|
|
39
39
|
* plugin (`blockGeneratedImports`) call through it so the two paths
|
|
40
40
|
* cannot drift.
|
|
41
41
|
*/
|
|
42
|
-
export function buildForbiddenGeneratedImportMessage(specifier: string): string {
|
|
43
|
-
return (
|
|
44
|
-
`Direct
|
|
45
|
-
`Use the runtime registry: see ${GENERATED_IMPORT_DOCS_URL}`
|
|
46
|
-
);
|
|
47
|
-
}
|
|
42
|
+
export function buildForbiddenGeneratedImportMessage(specifier: string): string {
|
|
43
|
+
return (
|
|
44
|
+
`Direct generated artifact imports are forbidden: ${specifier}. ` +
|
|
45
|
+
`Use the runtime registry: see ${GENERATED_IMPORT_DOCS_URL}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Shared remediation hint. Points at `getGenerated()` from
|
|
@@ -187,13 +187,19 @@ export function formatConfigGuardResult(result: ConfigGuardResult): string {
|
|
|
187
187
|
} else if (!result.lockfileExists) {
|
|
188
188
|
lines.push("π‘ Lockfile μμ");
|
|
189
189
|
lines.push(" 'mandu lock'μΌλ‘ μμ± κΆμ₯");
|
|
190
|
-
} else {
|
|
191
|
-
lines.push("β μ€μ λ¬΄κ²°μ± κ²μ¦ μ€ν¨");
|
|
192
|
-
|
|
193
|
-
for (const error of result.errors) {
|
|
194
|
-
lines.push(` π΄ ${error.message}`);
|
|
195
|
-
}
|
|
196
|
-
|
|
190
|
+
} else {
|
|
191
|
+
lines.push("β μ€μ λ¬΄κ²°μ± κ²μ¦ μ€ν¨");
|
|
192
|
+
|
|
193
|
+
for (const error of result.errors) {
|
|
194
|
+
lines.push(` π΄ ${error.message}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
lines.push("");
|
|
198
|
+
lines.push(" λ€μ λ¨κ³:");
|
|
199
|
+
lines.push(" β³ λ³κ²½ νμΈ: mandu lock --diff");
|
|
200
|
+
lines.push(" β³ μλν λ³κ²½μ΄λ©΄: mandu lock");
|
|
201
|
+
lines.push(" β³ ν¨ν€μ§ μ
λ°μ΄νΈ μ§νλΌλ©΄ μ Mandu κΈ°λ³Έκ°μΌλ‘ lockfileμ κ°±μ νμΈμ.");
|
|
202
|
+
}
|
|
197
203
|
|
|
198
204
|
if (result.warnings.length > 0 && result.lockfileExists) {
|
|
199
205
|
lines.push("");
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { FSRoutesGuardConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_FS_ROUTES_GUARD_POLICY: FSRoutesGuardConfig = {
|
|
4
|
+
noPageToPage: true,
|
|
5
|
+
pageCanImport: [
|
|
6
|
+
"client/pages",
|
|
7
|
+
"client/widgets",
|
|
8
|
+
"client/features",
|
|
9
|
+
"client/entities",
|
|
10
|
+
"client/shared",
|
|
11
|
+
"shared/contracts",
|
|
12
|
+
"shared/types",
|
|
13
|
+
"shared/utils/client",
|
|
14
|
+
],
|
|
15
|
+
layoutCanImport: [
|
|
16
|
+
"client/app",
|
|
17
|
+
"client/widgets",
|
|
18
|
+
"client/shared",
|
|
19
|
+
"shared/contracts",
|
|
20
|
+
"shared/types",
|
|
21
|
+
"shared/utils/client",
|
|
22
|
+
],
|
|
23
|
+
routeCanImport: [
|
|
24
|
+
"server/api",
|
|
25
|
+
"server/application",
|
|
26
|
+
"server/domain",
|
|
27
|
+
"server/infra",
|
|
28
|
+
"server/core",
|
|
29
|
+
"shared/contracts",
|
|
30
|
+
"shared/schema",
|
|
31
|
+
"shared/types",
|
|
32
|
+
"shared/utils/client",
|
|
33
|
+
"shared/utils/server",
|
|
34
|
+
"shared/env",
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function clonePolicy(policy: FSRoutesGuardConfig): FSRoutesGuardConfig {
|
|
39
|
+
return {
|
|
40
|
+
noPageToPage: policy.noPageToPage,
|
|
41
|
+
pageCanImport: policy.pageCanImport ? [...policy.pageCanImport] : undefined,
|
|
42
|
+
layoutCanImport: policy.layoutCanImport ? [...policy.layoutCanImport] : undefined,
|
|
43
|
+
routeCanImport: policy.routeCanImport ? [...policy.routeCanImport] : undefined,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getDefaultFsRoutesGuardPolicy(
|
|
48
|
+
enabled: boolean
|
|
49
|
+
): FSRoutesGuardConfig | undefined {
|
|
50
|
+
return enabled ? clonePolicy(DEFAULT_FS_ROUTES_GUARD_POLICY) : undefined;
|
|
51
|
+
}
|
package/src/guard/index.ts
CHANGED
|
@@ -120,12 +120,17 @@ export {
|
|
|
120
120
|
// Architecture Guard - Watcher
|
|
121
121
|
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
122
122
|
|
|
123
|
-
export {
|
|
124
|
-
createGuardWatcher,
|
|
125
|
-
checkFile,
|
|
126
|
-
checkDirectory,
|
|
127
|
-
clearAnalysisCache,
|
|
128
|
-
} from "./watcher";
|
|
123
|
+
export {
|
|
124
|
+
createGuardWatcher,
|
|
125
|
+
checkFile,
|
|
126
|
+
checkDirectory,
|
|
127
|
+
clearAnalysisCache,
|
|
128
|
+
} from "./watcher";
|
|
129
|
+
|
|
130
|
+
export {
|
|
131
|
+
DEFAULT_FS_ROUTES_GUARD_POLICY,
|
|
132
|
+
getDefaultFsRoutesGuardPolicy,
|
|
133
|
+
} from "./fs-routes-policy";
|
|
129
134
|
|
|
130
135
|
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
131
136
|
// Architecture Guard - Presets
|