@mandujs/core 0.45.0 → 0.46.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/package.json +2 -2
- package/src/brain/adapters/__tests__/resolver.test.ts +37 -21
- package/src/brain/adapters/base.ts +4 -3
- package/src/brain/adapters/index.ts +319 -333
- package/src/brain/adapters/openai-oauth.ts +3 -5
- package/src/brain/brain.ts +8 -11
- package/src/brain/consent.ts +1 -1
- package/src/brain/index.ts +4 -1
- package/src/config/mandu.ts +12 -14
- package/src/config/validate.ts +3 -10
- package/src/deploy/cache.ts +139 -0
- package/src/deploy/index.ts +62 -0
- package/src/deploy/inference/context.ts +173 -0
- package/src/deploy/inference/heuristic.ts +182 -0
- package/src/deploy/intent.ts +173 -0
- package/src/deploy/plan.ts +178 -0
- package/src/runtime/server.ts +30 -0
- package/src/brain/adapters/ollama.ts +0 -235
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic deploy intent inferer.
|
|
3
|
+
*
|
|
4
|
+
* Issue #250 — Phase 1.
|
|
5
|
+
*
|
|
6
|
+
* The rule tree below is the offline, brain-free inferer. It produces
|
|
7
|
+
* an intent for every route the manifest contains using nothing but
|
|
8
|
+
* the source code and manifest metadata gathered by `context.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Design constraints:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Conservative on doubt.** When a signal is ambiguous, prefer
|
|
13
|
+
* `node`/`bun` over `edge` and `no-store` over caching. A
|
|
14
|
+
* slightly-too-pessimistic intent is fixable with one explicit
|
|
15
|
+
* `.deploy()` call; a too-aggressive one breaks production.
|
|
16
|
+
*
|
|
17
|
+
* 2. **Deterministic.** Same context → same intent, byte-for-byte.
|
|
18
|
+
* The cache hashes the source so unchanged routes never re-
|
|
19
|
+
* infer; we don't want flapping intents across runs.
|
|
20
|
+
*
|
|
21
|
+
* 3. **Reasoned.** Every result carries a `rationale` string. Plan
|
|
22
|
+
* diff surfaces it so reviewers can audit "why edge here?".
|
|
23
|
+
*
|
|
24
|
+
* 4. **Replaceable.** The brain inferer (M4) calls into the same
|
|
25
|
+
* shape (`InferenceResult`) so adapters never need to know which
|
|
26
|
+
* engine produced the intent.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { DeployIntent } from "../intent";
|
|
30
|
+
import type { DependencyClass, DeployInferenceContext } from "./context";
|
|
31
|
+
|
|
32
|
+
export interface InferenceResult {
|
|
33
|
+
intent: DeployIntent;
|
|
34
|
+
rationale: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Run the rule tree on a single route context.
|
|
39
|
+
*
|
|
40
|
+
* The rules below are checked top-to-bottom; the first match wins.
|
|
41
|
+
* Adding a new rule? Put it in priority order — the most specific /
|
|
42
|
+
* highest-confidence rule first.
|
|
43
|
+
*/
|
|
44
|
+
export function inferDeployIntentHeuristic(
|
|
45
|
+
ctx: DeployInferenceContext,
|
|
46
|
+
): InferenceResult {
|
|
47
|
+
// ─── Metadata routes (sitemap.xml, robots.txt, llms.txt, manifest.json)
|
|
48
|
+
if (ctx.kind === "metadata") {
|
|
49
|
+
return {
|
|
50
|
+
intent: {
|
|
51
|
+
runtime: "static",
|
|
52
|
+
cache: { sMaxAge: 3600, swr: 86_400 },
|
|
53
|
+
visibility: "public",
|
|
54
|
+
},
|
|
55
|
+
rationale:
|
|
56
|
+
"metadata route — sitemap/robots/llms.txt are stable across requests; prerender + 1h s-maxage with 1d SWR.",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Page routes
|
|
61
|
+
if (ctx.kind === "page") {
|
|
62
|
+
// Static-prerenderable: no dynamic segments, OR has generateStaticParams.
|
|
63
|
+
if (!ctx.isDynamic) {
|
|
64
|
+
return {
|
|
65
|
+
intent: {
|
|
66
|
+
runtime: "static",
|
|
67
|
+
cache: { sMaxAge: 31_536_000, swr: 86_400 },
|
|
68
|
+
visibility: "public",
|
|
69
|
+
},
|
|
70
|
+
rationale: "page with no dynamic segments — prerender at build time.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (ctx.hasGenerateStaticParams) {
|
|
74
|
+
return {
|
|
75
|
+
intent: {
|
|
76
|
+
runtime: "static",
|
|
77
|
+
cache: { sMaxAge: 31_536_000, swr: 86_400 },
|
|
78
|
+
visibility: "public",
|
|
79
|
+
},
|
|
80
|
+
rationale:
|
|
81
|
+
"dynamic page exports generateStaticParams — every parameter combination prerenders at build time.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// Dynamic page without static params: needs SSR. Pick edge unless
|
|
85
|
+
// the handler imports something edge can't run.
|
|
86
|
+
if (canRunOnEdge(ctx.dependencyClasses)) {
|
|
87
|
+
return {
|
|
88
|
+
intent: {
|
|
89
|
+
runtime: "edge",
|
|
90
|
+
cache: "no-store",
|
|
91
|
+
visibility: "public",
|
|
92
|
+
},
|
|
93
|
+
rationale:
|
|
94
|
+
"dynamic page (SSR) with no Node/Bun-only dependencies — edge runtime keeps latency low.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
intent: {
|
|
99
|
+
runtime: pickServerRuntime(ctx.dependencyClasses),
|
|
100
|
+
cache: "no-store",
|
|
101
|
+
visibility: "public",
|
|
102
|
+
},
|
|
103
|
+
rationale: explainServerRuntime(ctx.dependencyClasses, "page"),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── API routes
|
|
108
|
+
// APIs are never `static`. Pick edge when stateless, server runtime
|
|
109
|
+
// otherwise. Cache always defaults to `no-store` — caching API
|
|
110
|
+
// responses is a deliberate decision, not something we infer.
|
|
111
|
+
if (canRunOnEdge(ctx.dependencyClasses)) {
|
|
112
|
+
return {
|
|
113
|
+
intent: {
|
|
114
|
+
runtime: "edge",
|
|
115
|
+
cache: "no-store",
|
|
116
|
+
visibility: "public",
|
|
117
|
+
},
|
|
118
|
+
rationale:
|
|
119
|
+
"API route with only fetch-class dependencies — edge runtime; opt into caching with .deploy({ cache }).",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
intent: {
|
|
124
|
+
runtime: pickServerRuntime(ctx.dependencyClasses),
|
|
125
|
+
cache: "no-store",
|
|
126
|
+
visibility: "public",
|
|
127
|
+
},
|
|
128
|
+
rationale: explainServerRuntime(ctx.dependencyClasses, "API"),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Rule-tree primitives ────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The classes that disqualify edge — DB drivers (open TCP sockets and
|
|
136
|
+
* ship native modules), Node-only filesystem/networking modules, Bun
|
|
137
|
+
* native primitives (sqlite/ffi/s3), AI SDKs (long-running, large
|
|
138
|
+
* payloads), and explicitly heavy native libraries (sharp/playwright).
|
|
139
|
+
*/
|
|
140
|
+
const NON_EDGE_CLASSES: ReadonlySet<DependencyClass> = new Set([
|
|
141
|
+
"db",
|
|
142
|
+
"node-fs",
|
|
143
|
+
"node-net",
|
|
144
|
+
"node-child",
|
|
145
|
+
"bun-native",
|
|
146
|
+
"ai-sdk",
|
|
147
|
+
"heavy",
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
function canRunOnEdge(classes: ReadonlySet<DependencyClass>): boolean {
|
|
151
|
+
for (const cls of classes) {
|
|
152
|
+
if (NON_EDGE_CLASSES.has(cls)) return false;
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Choose between `bun` and `node` for routes that must run on a
|
|
159
|
+
* server runtime. `bun-native` imports force `bun`; everything else
|
|
160
|
+
* defaults to `node` for maximum portability (every adapter supports
|
|
161
|
+
* Node; not every adapter supports Bun yet).
|
|
162
|
+
*/
|
|
163
|
+
function pickServerRuntime(classes: ReadonlySet<DependencyClass>): "node" | "bun" {
|
|
164
|
+
return classes.has("bun-native") ? "bun" : "node";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function explainServerRuntime(
|
|
168
|
+
classes: ReadonlySet<DependencyClass>,
|
|
169
|
+
routeLabel: string,
|
|
170
|
+
): string {
|
|
171
|
+
const reasons: string[] = [];
|
|
172
|
+
if (classes.has("db")) reasons.push("imports a database driver");
|
|
173
|
+
if (classes.has("node-fs")) reasons.push("uses node:fs");
|
|
174
|
+
if (classes.has("node-net")) reasons.push("uses node:net/tls/dgram");
|
|
175
|
+
if (classes.has("node-child")) reasons.push("uses node:child_process or worker_threads");
|
|
176
|
+
if (classes.has("bun-native")) reasons.push("imports bun:* primitives");
|
|
177
|
+
if (classes.has("ai-sdk")) reasons.push("imports an AI SDK (long latency)");
|
|
178
|
+
if (classes.has("heavy")) reasons.push("imports a heavy native dependency");
|
|
179
|
+
const why = reasons.length > 0 ? reasons.join(", ") : "non-edge dependencies";
|
|
180
|
+
const runtime = pickServerRuntime(classes);
|
|
181
|
+
return `${routeLabel} ${why} — ${runtime} runtime required.`;
|
|
182
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy Intent — typed declaration of how a route should be deployed.
|
|
3
|
+
*
|
|
4
|
+
* Issue #250 — Phase 1.
|
|
5
|
+
*
|
|
6
|
+
* The schema is the contract between three parts of Mandu:
|
|
7
|
+
*
|
|
8
|
+
* 1. Inference (`packages/core/src/deploy/inference/`) — emits intents
|
|
9
|
+
* from route source + manifest metadata. Heuristic first; OpenAI
|
|
10
|
+
* brain is optional.
|
|
11
|
+
*
|
|
12
|
+
* 2. Cache (`.mandu/deploy.intent.json`, see `cache.ts`) — versioned,
|
|
13
|
+
* committable file the inferer writes and adapters read. Re-using
|
|
14
|
+
* it across runs is what makes deploys deterministic and offline-
|
|
15
|
+
* capable (no brain call required at deploy time).
|
|
16
|
+
*
|
|
17
|
+
* 3. Adapters (`packages/cli/src/commands/deploy/adapters/*`) —
|
|
18
|
+
* compile a manifest + intent cache into a provider-specific
|
|
19
|
+
* config (`vercel.json`, `fly.toml`, …). Adapters never invent
|
|
20
|
+
* defaults; missing intent for any non-static route is a hard
|
|
21
|
+
* error so deploys can't drift silently.
|
|
22
|
+
*
|
|
23
|
+
* Schema portability: only fields every supported provider can express
|
|
24
|
+
* live in the core schema. Provider-specific fields (Fly's `vm_size`,
|
|
25
|
+
* Vercel's `memory`, …) go in `overrides` keyed by target name.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
|
|
30
|
+
// ─── Runtime ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deploy runtime — the execution model the route ships in.
|
|
34
|
+
*
|
|
35
|
+
* - `static` — prerendered at build time, served from a CDN. No
|
|
36
|
+
* server execution at request time. Only valid when the route can
|
|
37
|
+
* be rendered to bytes ahead of time (no per-request data).
|
|
38
|
+
* - `edge` — short-lived, V8-isolate-class environment near the
|
|
39
|
+
* viewer. No filesystem, no native modules. Best for low-latency
|
|
40
|
+
* transforms and stateless API.
|
|
41
|
+
* - `node` — long-running server with full Node API. Use when the
|
|
42
|
+
* route imports things edge can't run (DB drivers, file IO, large
|
|
43
|
+
* dependencies).
|
|
44
|
+
* - `bun` — long-running Bun server. Same role as `node` but with
|
|
45
|
+
* Bun primitives (`bun:sqlite`, `Bun.s3`, etc.). Provider support
|
|
46
|
+
* varies — Vercel `@vercel/bun` is required for this on Vercel.
|
|
47
|
+
*/
|
|
48
|
+
export const DeployRuntime = z.enum(["static", "edge", "node", "bun"]);
|
|
49
|
+
export type DeployRuntime = z.infer<typeof DeployRuntime>;
|
|
50
|
+
|
|
51
|
+
// ─── Cache ────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Cache directive — compiled to provider-specific cache headers /
|
|
55
|
+
* `s-maxage` / `stale-while-revalidate` / etc.
|
|
56
|
+
*
|
|
57
|
+
* Three forms are accepted:
|
|
58
|
+
*
|
|
59
|
+
* - `"no-store"` — never cache. Default for API routes.
|
|
60
|
+
* - `"public"` — cache without explicit lifetimes. Use for assets.
|
|
61
|
+
* - `{ maxAge?, sMaxAge?, swr? }` — explicit lifetimes (seconds).
|
|
62
|
+
* `maxAge` controls the browser cache, `sMaxAge` the shared CDN
|
|
63
|
+
* cache, `swr` the stale-while-revalidate window.
|
|
64
|
+
*/
|
|
65
|
+
export const DeployCacheLifetime = z.object({
|
|
66
|
+
/** Browser cache lifetime in seconds. */
|
|
67
|
+
maxAge: z.number().int().nonnegative().optional(),
|
|
68
|
+
/** Shared CDN cache lifetime in seconds. */
|
|
69
|
+
sMaxAge: z.number().int().nonnegative().optional(),
|
|
70
|
+
/** stale-while-revalidate window in seconds. */
|
|
71
|
+
swr: z.number().int().nonnegative().optional(),
|
|
72
|
+
});
|
|
73
|
+
export type DeployCacheLifetime = z.infer<typeof DeployCacheLifetime>;
|
|
74
|
+
|
|
75
|
+
export const DeployCache = z.union([
|
|
76
|
+
z.literal("no-store"),
|
|
77
|
+
z.literal("public"),
|
|
78
|
+
DeployCacheLifetime,
|
|
79
|
+
]);
|
|
80
|
+
export type DeployCache = z.infer<typeof DeployCache>;
|
|
81
|
+
|
|
82
|
+
// ─── Visibility ───────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Network visibility — public reach vs internal-only. Adapters with no
|
|
86
|
+
* native private-route concept reject this when set to `"private"`.
|
|
87
|
+
*/
|
|
88
|
+
export const DeployVisibility = z.enum(["public", "private"]);
|
|
89
|
+
export type DeployVisibility = z.infer<typeof DeployVisibility>;
|
|
90
|
+
|
|
91
|
+
// ─── Target ───────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Per-route target override. When set, the route ships to this
|
|
95
|
+
* provider regardless of the top-level `mandu deploy --target=...`
|
|
96
|
+
* flag. Phase 3 makes this load-bearing for heterogeneous deploys
|
|
97
|
+
* ("docs to cf-pages, api to fly, admin to docker"). Phase 1 stores it
|
|
98
|
+
* but the multi-target dispatcher is not yet implemented — a non-
|
|
99
|
+
* matching `target` in Phase 1 raises an error from the adapter.
|
|
100
|
+
*/
|
|
101
|
+
export const DeployTarget = z.enum(["vercel", "fly", "cf-pages", "docker"]);
|
|
102
|
+
export type DeployTarget = z.infer<typeof DeployTarget>;
|
|
103
|
+
|
|
104
|
+
// ─── Intent ───────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The intent itself. Adapters MUST treat this as the single source of
|
|
108
|
+
* truth — no out-of-band defaults, no provider-specific shortcuts that
|
|
109
|
+
* bypass the schema.
|
|
110
|
+
*/
|
|
111
|
+
export const DeployIntent = z.object({
|
|
112
|
+
runtime: DeployRuntime,
|
|
113
|
+
cache: DeployCache.default("no-store"),
|
|
114
|
+
/** Geographic regions (provider-specific identifiers). */
|
|
115
|
+
regions: z.array(z.string().min(1)).optional(),
|
|
116
|
+
/** Lower bound on warm instances. */
|
|
117
|
+
minInstances: z.number().int().nonnegative().optional(),
|
|
118
|
+
/** Upper bound on concurrent instances. */
|
|
119
|
+
maxInstances: z.number().int().positive().optional(),
|
|
120
|
+
/** Per-request execution timeout in milliseconds. */
|
|
121
|
+
timeout: z.number().int().positive().optional(),
|
|
122
|
+
visibility: DeployVisibility.default("public"),
|
|
123
|
+
target: DeployTarget.optional(),
|
|
124
|
+
/**
|
|
125
|
+
* Provider-specific overrides keyed by adapter name. The schema
|
|
126
|
+
* accepts any shape; each adapter validates its own slice. Use
|
|
127
|
+
* sparingly — prefer pushing recurring needs into the core schema.
|
|
128
|
+
*
|
|
129
|
+
* @example { vercel: { memory: 1024 }, fly: { vm: "shared-cpu-2x" } }
|
|
130
|
+
*/
|
|
131
|
+
overrides: z.record(z.string(), z.unknown()).optional(),
|
|
132
|
+
});
|
|
133
|
+
export type DeployIntent = z.infer<typeof DeployIntent>;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Partial-input variant for `.deploy()` builder calls — every field
|
|
137
|
+
* stays optional at the call site so users can declare just `runtime`
|
|
138
|
+
* or just `cache`. The full `DeployIntent.parse()` step happens at the
|
|
139
|
+
* cache-write boundary, where defaults fill in.
|
|
140
|
+
*/
|
|
141
|
+
export const DeployIntentInput = DeployIntent.partial();
|
|
142
|
+
export type DeployIntentInput = z.input<typeof DeployIntentInput>;
|
|
143
|
+
|
|
144
|
+
// ─── Validation helpers ──────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `runtime: "static"` requires the route to be prerenderable. A
|
|
148
|
+
* dynamic-segment page with no `generateStaticParams` cannot satisfy
|
|
149
|
+
* that — adapters should refuse to compile such intents and surface
|
|
150
|
+
* the route id in the error.
|
|
151
|
+
*/
|
|
152
|
+
export function isStaticIntentValidFor(
|
|
153
|
+
intent: DeployIntent,
|
|
154
|
+
route: { isDynamic: boolean; hasGenerateStaticParams: boolean; kind: string },
|
|
155
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
156
|
+
if (intent.runtime !== "static") return { ok: true };
|
|
157
|
+
|
|
158
|
+
if (route.kind === "api") {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
reason:
|
|
162
|
+
"runtime: \"static\" is not valid for an API route — APIs execute at request time. Use \"edge\", \"node\", or \"bun\".",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (route.isDynamic && !route.hasGenerateStaticParams) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
reason:
|
|
169
|
+
"runtime: \"static\" requires the dynamic route to export `generateStaticParams` so all parameter combinations are known at build time.",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { ok: true };
|
|
173
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan a deploy — compute the next `DeployIntentCache` from a
|
|
3
|
+
* manifest and the previous cache.
|
|
4
|
+
*
|
|
5
|
+
* Issue #250 — Phase 1.
|
|
6
|
+
*
|
|
7
|
+
* The plan step is pure: it takes the manifest + previous cache +
|
|
8
|
+
* inferer and returns the new cache + a diff. No filesystem writes
|
|
9
|
+
* here; that's the CLI's job. Keeping this pure makes the diff easy
|
|
10
|
+
* to render in tests and lets future surfaces (kitchen UI, MCP tool)
|
|
11
|
+
* reuse the same plan logic.
|
|
12
|
+
*
|
|
13
|
+
* Override hierarchy (highest wins):
|
|
14
|
+
*
|
|
15
|
+
* 1. **Explicit** — entry where `source === "explicit"` in the
|
|
16
|
+
* previous cache. Never overwritten by inference. Re-keyed by
|
|
17
|
+
* `sourceHash` only so users can hand-edit the file and have
|
|
18
|
+
* their edits stick.
|
|
19
|
+
* 2. **Cached, source unchanged** — same `sourceHash` ⇒ reuse the
|
|
20
|
+
* stored intent. This is the cost cap on brain calls.
|
|
21
|
+
* 3. **Inferred** — call the inferer. Default is heuristic; brain
|
|
22
|
+
* can swap in via the `infer` parameter.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { RoutesManifest } from "../spec/schema";
|
|
26
|
+
import {
|
|
27
|
+
emptyDeployIntentCache,
|
|
28
|
+
type DeployIntentCache,
|
|
29
|
+
type DeployIntentCacheEntry,
|
|
30
|
+
} from "./cache";
|
|
31
|
+
import { buildDeployInferenceContext, type DeployInferenceContext } from "./inference/context";
|
|
32
|
+
import {
|
|
33
|
+
inferDeployIntentHeuristic,
|
|
34
|
+
type InferenceResult,
|
|
35
|
+
} from "./inference/heuristic";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Per-route diff entry surfaced to the CLI / tests.
|
|
39
|
+
*
|
|
40
|
+
* - `added` — no prior entry, new intent inferred.
|
|
41
|
+
* - `unchanged` — same source hash; entry kept verbatim.
|
|
42
|
+
* - `changed` — source hash differs OR explicit override updated;
|
|
43
|
+
* the `previous` field carries the old entry for
|
|
44
|
+
* diff rendering.
|
|
45
|
+
* - `removed` — route exists in cache but not in manifest. Pruned.
|
|
46
|
+
* - `pinned` — explicit entry; inference skipped.
|
|
47
|
+
*/
|
|
48
|
+
export type PlanDiffEntryKind =
|
|
49
|
+
| "added"
|
|
50
|
+
| "unchanged"
|
|
51
|
+
| "changed"
|
|
52
|
+
| "removed"
|
|
53
|
+
| "pinned";
|
|
54
|
+
|
|
55
|
+
export interface PlanDiffEntry {
|
|
56
|
+
routeId: string;
|
|
57
|
+
pattern: string;
|
|
58
|
+
kind: PlanDiffEntryKind;
|
|
59
|
+
next?: DeployIntentCacheEntry;
|
|
60
|
+
previous?: DeployIntentCacheEntry;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PlanResult {
|
|
64
|
+
cache: DeployIntentCache;
|
|
65
|
+
diff: PlanDiffEntry[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PlanDeployOptions {
|
|
69
|
+
rootDir: string;
|
|
70
|
+
manifest: RoutesManifest;
|
|
71
|
+
previous?: DeployIntentCache;
|
|
72
|
+
/**
|
|
73
|
+
* Override the inferer. Defaults to the offline heuristic. The
|
|
74
|
+
* brain inferer (M4) plugs in here without changing the plan flow.
|
|
75
|
+
*/
|
|
76
|
+
infer?: (ctx: DeployInferenceContext) => Promise<InferenceResult> | InferenceResult;
|
|
77
|
+
/** Identifier written into `cache.brainModel`. Defaults to `"heuristic"`. */
|
|
78
|
+
brainModel?: string;
|
|
79
|
+
/** ISO timestamp override — primarily for test determinism. */
|
|
80
|
+
now?: () => string;
|
|
81
|
+
/** Force re-inference even when the source hash matches. */
|
|
82
|
+
reinfer?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function planDeploy(opts: PlanDeployOptions): Promise<PlanResult> {
|
|
86
|
+
const previous = opts.previous ?? emptyDeployIntentCache();
|
|
87
|
+
const infer =
|
|
88
|
+
opts.infer ?? ((ctx: DeployInferenceContext) => inferDeployIntentHeuristic(ctx));
|
|
89
|
+
const now = opts.now ?? (() => new Date().toISOString());
|
|
90
|
+
const brainModel = opts.brainModel ?? "heuristic";
|
|
91
|
+
const reinfer = opts.reinfer === true;
|
|
92
|
+
|
|
93
|
+
const nextIntents: Record<string, DeployIntentCacheEntry> = {};
|
|
94
|
+
const diff: PlanDiffEntry[] = [];
|
|
95
|
+
|
|
96
|
+
for (const route of opts.manifest.routes) {
|
|
97
|
+
const ctx = await buildDeployInferenceContext(opts.rootDir, route);
|
|
98
|
+
const prevEntry = previous.intents[route.id];
|
|
99
|
+
|
|
100
|
+
// 1. Explicit override — never re-infer; only refresh sourceHash.
|
|
101
|
+
if (prevEntry && prevEntry.source === "explicit") {
|
|
102
|
+
const refreshed: DeployIntentCacheEntry = {
|
|
103
|
+
...prevEntry,
|
|
104
|
+
sourceHash: ctx.sourceHash,
|
|
105
|
+
};
|
|
106
|
+
nextIntents[route.id] = refreshed;
|
|
107
|
+
diff.push({
|
|
108
|
+
routeId: route.id,
|
|
109
|
+
pattern: route.pattern,
|
|
110
|
+
kind: "pinned",
|
|
111
|
+
next: refreshed,
|
|
112
|
+
previous: prevEntry,
|
|
113
|
+
});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 2. Cached, source unchanged.
|
|
118
|
+
if (
|
|
119
|
+
!reinfer &&
|
|
120
|
+
prevEntry &&
|
|
121
|
+
prevEntry.sourceHash === ctx.sourceHash
|
|
122
|
+
) {
|
|
123
|
+
nextIntents[route.id] = prevEntry;
|
|
124
|
+
diff.push({
|
|
125
|
+
routeId: route.id,
|
|
126
|
+
pattern: route.pattern,
|
|
127
|
+
kind: "unchanged",
|
|
128
|
+
next: prevEntry,
|
|
129
|
+
previous: prevEntry,
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 3. Infer.
|
|
135
|
+
const result = await infer(ctx);
|
|
136
|
+
const entry: DeployIntentCacheEntry = {
|
|
137
|
+
intent: result.intent,
|
|
138
|
+
source: "inferred",
|
|
139
|
+
rationale: result.rationale,
|
|
140
|
+
sourceHash: ctx.sourceHash,
|
|
141
|
+
inferredAt: now(),
|
|
142
|
+
};
|
|
143
|
+
nextIntents[route.id] = entry;
|
|
144
|
+
diff.push({
|
|
145
|
+
routeId: route.id,
|
|
146
|
+
pattern: route.pattern,
|
|
147
|
+
kind: prevEntry ? "changed" : "added",
|
|
148
|
+
next: entry,
|
|
149
|
+
previous: prevEntry,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 4. Detect removed entries (cache had them, manifest doesn't).
|
|
154
|
+
const manifestIds = new Set(opts.manifest.routes.map((r) => r.id));
|
|
155
|
+
for (const [routeId, entry] of Object.entries(previous.intents)) {
|
|
156
|
+
if (manifestIds.has(routeId)) continue;
|
|
157
|
+
diff.push({
|
|
158
|
+
routeId,
|
|
159
|
+
pattern: "(removed from manifest)",
|
|
160
|
+
kind: "removed",
|
|
161
|
+
previous: entry,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const cache: DeployIntentCache = {
|
|
166
|
+
version: 1,
|
|
167
|
+
generatedAt: now(),
|
|
168
|
+
brainModel,
|
|
169
|
+
intents: nextIntents,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return { cache, diff };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** True if the diff contains any non-`unchanged` entry. */
|
|
176
|
+
export function planHasChanges(diff: readonly PlanDiffEntry[]): boolean {
|
|
177
|
+
return diff.some((d) => d.kind !== "unchanged" && d.kind !== "pinned");
|
|
178
|
+
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -1638,17 +1638,38 @@ async function isPathSafe(filePath: string, allowedDir: string): Promise<boolean
|
|
|
1638
1638
|
}
|
|
1639
1639
|
}
|
|
1640
1640
|
|
|
1641
|
+
/**
|
|
1642
|
+
* Issue #251 — public 폴더의 자산을 root URL로도 서빙하기 위한 화이트리스트.
|
|
1643
|
+
*
|
|
1644
|
+
* `mandu build --static` 은 `public/*` 을 dist 루트로 평탄화하므로 prod 에서는
|
|
1645
|
+
* `/images/foo.webp` 가 동작한다. dev 에서는 평탄화가 없어서 같은 URL 이 404
|
|
1646
|
+
* 였다 — 작성자는 `/public/...` (dev OK, prod 도 vercel rewrite 로 OK) 또는
|
|
1647
|
+
* `/...` (dev 깨짐, prod OK) 중 하나를 골라야 했다. 이제 dev 도 자산 확장자가
|
|
1648
|
+
* 있는 경로에 한해 `public/<path>` 를 fallback 으로 시도한다.
|
|
1649
|
+
*
|
|
1650
|
+
* 자산 확장자만 fallback 하므로 `/api/foo` 같은 라우트가 가려질 위험은 없다.
|
|
1651
|
+
* 파일이 없으면 `{ handled: false }` 를 반환해 라우터가 정상 매칭하도록 한다.
|
|
1652
|
+
*/
|
|
1653
|
+
const PUBLIC_FLAT_ASSET_EXTENSIONS = new Set<string>([
|
|
1654
|
+
".webp", ".avif", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
|
|
1655
|
+
".pdf", ".zip", ".mp4", ".webm", ".mp3", ".wav",
|
|
1656
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
1657
|
+
".css", ".js", ".map",
|
|
1658
|
+
]);
|
|
1659
|
+
|
|
1641
1660
|
/**
|
|
1642
1661
|
* 정적 파일 서빙
|
|
1643
1662
|
* - /.mandu/client/* : 클라이언트 번들 (Island hydration)
|
|
1644
1663
|
* - /public/* : 정적 에셋 (이미지, CSS 등)
|
|
1645
1664
|
* - /favicon.ico : 파비콘
|
|
1665
|
+
* - /<asset>.<ext> : public/<asset>.<ext> fallback (issue #251)
|
|
1646
1666
|
*
|
|
1647
1667
|
* 보안: Path traversal 공격 방지를 위해 모든 경로를 검증합니다.
|
|
1648
1668
|
*/
|
|
1649
1669
|
async function serveStaticFile(pathname: string, settings: ServerRegistrySettings, request?: Request): Promise<StaticFileResult> {
|
|
1650
1670
|
let filePath: string | null = null;
|
|
1651
1671
|
let isBundleFile = false;
|
|
1672
|
+
let isPublicFlatFallback = false;
|
|
1652
1673
|
let allowedBaseDir: string;
|
|
1653
1674
|
let relativePath: string;
|
|
1654
1675
|
|
|
@@ -1678,6 +1699,12 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
|
|
|
1678
1699
|
) {
|
|
1679
1700
|
relativePath = path.basename(pathname);
|
|
1680
1701
|
allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
|
|
1702
|
+
}
|
|
1703
|
+
// 5. Public flat fallback (#251) — `mandu build --static` 의 평탄화와 dev 패리티
|
|
1704
|
+
else if (PUBLIC_FLAT_ASSET_EXTENSIONS.has(path.extname(pathname).toLowerCase())) {
|
|
1705
|
+
relativePath = pathname.slice(1);
|
|
1706
|
+
allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
|
|
1707
|
+
isPublicFlatFallback = true;
|
|
1681
1708
|
} else {
|
|
1682
1709
|
return { handled: false }; // 정적 파일이 아님
|
|
1683
1710
|
}
|
|
@@ -1717,6 +1744,9 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
|
|
|
1717
1744
|
const exists = await file.exists();
|
|
1718
1745
|
|
|
1719
1746
|
if (!exists) {
|
|
1747
|
+
// #251 — flat fallback 은 라우트와 path 충돌이 가능하므로 미존재 시
|
|
1748
|
+
// 404 대신 라우터로 흘려보낸다 (e.g. `/foo.json` 라우트가 가려지지 않도록).
|
|
1749
|
+
if (isPublicFlatFallback) return { handled: false };
|
|
1720
1750
|
return { handled: true, response: createStaticErrorResponse(404) };
|
|
1721
1751
|
}
|
|
1722
1752
|
|