@junejs/core 0.0.2

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/src/mcp.ts ADDED
@@ -0,0 +1,116 @@
1
+ // MCP server — projects the unified action registry as MCP tools over a
2
+ // Web-Standards (Request -> Response) handler, mounted at /mcp.
3
+ //
4
+ // Why a hand-rolled handler instead of the official SDK's server transport:
5
+ // `@modelcontextprotocol/sdk`'s StreamableHTTPServerTransport is Node-coupled
6
+ // (node:http IncomingMessage/ServerResponse), which breaks June's
7
+ // Web-Standards + Cloudflare story. The protocol surface we need (initialize,
8
+ // tools/list, tools/call) is small and stateless, so we implement it directly
9
+ // against the Streamable HTTP shape — identical on the native runtime and on
10
+ // Workers. (The SDK is still used client-side to verify spec compliance.)
11
+
12
+ import { ACTION_REGISTRY, invokeAction } from "./agent";
13
+ import type { ActionContext } from "./context";
14
+
15
+ const PROTOCOL_VERSION = "2025-06-18";
16
+
17
+ type Rpc = {
18
+ jsonrpc: "2.0";
19
+ id?: string | number | null;
20
+ method?: string;
21
+ params?: Record<string, unknown>;
22
+ };
23
+
24
+ function ok(id: Rpc["id"], result: unknown) {
25
+ return { jsonrpc: "2.0" as const, id, result };
26
+ }
27
+
28
+ function err(id: Rpc["id"], code: number, message: string) {
29
+ return { jsonrpc: "2.0" as const, id, error: { code, message } };
30
+ }
31
+
32
+ // Only rich actions (with a description) are surfaced as MCP tools; bare RSC
33
+ // server actions registered via action(fn, id) carry no schema.
34
+ function tools() {
35
+ return [...ACTION_REGISTRY.values()]
36
+ .filter((action) => action.description)
37
+ .map((action) => ({
38
+ name: action.id,
39
+ description: action.description,
40
+ inputSchema: action.input,
41
+ }));
42
+ }
43
+
44
+ async function handle(message: Rpc, ctx: ActionContext): Promise<object | null> {
45
+ const { id, method, params } = message;
46
+ // Notifications (no id) get no response.
47
+ if (id === undefined || id === null) return null;
48
+
49
+ switch (method) {
50
+ case "initialize":
51
+ return ok(id, {
52
+ protocolVersion: PROTOCOL_VERSION,
53
+ capabilities: { tools: { listChanged: false } },
54
+ serverInfo: { name: "june", version: "0.0.0" },
55
+ });
56
+ case "ping":
57
+ return ok(id, {});
58
+ case "tools/list":
59
+ return ok(id, { tools: tools() });
60
+ case "tools/call": {
61
+ const name = params?.name as string | undefined;
62
+ const args = (params?.arguments as Record<string, unknown>) ?? {};
63
+ if (!name) return err(id, -32602, "Missing tool name");
64
+ try {
65
+ // The agent's tool call runs through the SAME ctx (principal + resources)
66
+ // the UI uses — one authorization model for both.
67
+ const result = await invokeAction(name, args, ctx);
68
+ return ok(id, {
69
+ content: [{ type: "text", text: JSON.stringify(result) }],
70
+ });
71
+ } catch (error) {
72
+ return ok(id, {
73
+ content: [{ type: "text", text: String(error) }],
74
+ isError: true,
75
+ });
76
+ }
77
+ }
78
+ default:
79
+ return err(id, -32601, `Method not found: ${method}`);
80
+ }
81
+ }
82
+
83
+ // ctx (principal + resources) is injected by the host (the pipeline) so an
84
+ // agent's tool call runs under the same authorization as the UI. Defaults to {}
85
+ // for hosts/tests without one.
86
+ export async function mcpHandler(request: Request, ctx: ActionContext = {}): Promise<Response> {
87
+ if (request.method !== "POST") {
88
+ return new Response("MCP endpoint — POST JSON-RPC (Streamable HTTP)", {
89
+ status: 405,
90
+ headers: { allow: "POST" },
91
+ });
92
+ }
93
+
94
+ let body: unknown;
95
+ try {
96
+ body = await request.json();
97
+ } catch {
98
+ return Response.json(err(null, -32700, "Parse error"), { status: 400 });
99
+ }
100
+
101
+ const headers = { "mcp-protocol-version": PROTOCOL_VERSION };
102
+
103
+ if (Array.isArray(body)) {
104
+ const responses = (await Promise.all(body.map((m) => handle(m as Rpc, ctx)))).filter(
105
+ Boolean,
106
+ );
107
+ return responses.length
108
+ ? Response.json(responses, { headers })
109
+ : new Response(null, { status: 202, headers });
110
+ }
111
+
112
+ const response = await handle(body as Rpc, ctx);
113
+ return response
114
+ ? Response.json(response, { headers })
115
+ : new Response(null, { status: 202, headers });
116
+ }
@@ -0,0 +1,73 @@
1
+ // The data RESOURCE contract — the seam the framework depends on, NOT an ORM.
2
+ // PURE and host-free: these are type-only declarations (Promises, Web types), no
3
+ // `node:*`, no driver. The implementations (sqlite/d1/postgres, local/r2/s3,
4
+ // memory/redis) live in @junejs/server's adapters and behind them, Juno; this
5
+ // layer only names the contract so RouteContext can carry injected handles and
6
+ // any ORM can target the same shape. See docs/data-layer-boundary.md.
7
+
8
+ // --- db (relational / SQL) --------------------------------------------------
9
+
10
+ export type RunResult = { changes: number; lastInsertRowid: number | bigint };
11
+
12
+ // The async database surface. SELECT → query()/get(); writes → run(); DDL or
13
+ // multi-statement scripts → exec(). Async from day one so D1/edge slot in behind
14
+ // the same interface (the PoC's sync surface was the one dead end).
15
+ export interface JuneDb {
16
+ query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
17
+ get<T = unknown>(sql: string, params?: unknown[]): Promise<T | undefined>;
18
+ run(sql: string, params?: unknown[]): Promise<RunResult>;
19
+ exec(sql: string): Promise<void>;
20
+ transaction<T>(fn: (tx: JuneDb) => Promise<T>): Promise<T>;
21
+ close(): Promise<void>;
22
+ }
23
+
24
+ // --- kv (key-value / cache) -------------------------------------------------
25
+
26
+ export interface JuneKv {
27
+ get<T = unknown>(key: string): Promise<T | null>;
28
+ put(key: string, value: unknown, opts?: { ttl?: number }): Promise<void>;
29
+ delete(key: string): Promise<void>;
30
+ }
31
+
32
+ // --- blob (object / file) ---------------------------------------------------
33
+
34
+ export interface JuneBlob {
35
+ get(key: string): Promise<Uint8Array | null>;
36
+ put(key: string, data: Uint8Array | string): Promise<void>;
37
+ delete(key: string): Promise<void>;
38
+ list(prefix?: string): Promise<string[]>;
39
+ }
40
+
41
+ // --- factories (declared in june.config.ts; opened by the host) -------------
42
+ // Same shape as the cache CacheStoreFactory: a `kind` tag + an async open. The
43
+ // factory is abstract (pure); the concrete adapter (sqlite/d1/local/r2/...) is
44
+ // host code that implements the handle.
45
+
46
+ export interface DbFactory {
47
+ readonly kind: string;
48
+ open(): Promise<JuneDb>;
49
+ }
50
+ export interface KvFactory {
51
+ readonly kind: string;
52
+ open(): Promise<JuneKv>;
53
+ }
54
+ export interface BlobFactory {
55
+ readonly kind: string;
56
+ open(): Promise<JuneBlob>;
57
+ }
58
+
59
+ // What june.config.ts declares under `resources`. Omit one and it never exists
60
+ // (not instantiated, not bundled, compiled away for static apps).
61
+ export type ResourceConfig = {
62
+ db?: DbFactory;
63
+ kv?: KvFactory;
64
+ blob?: BlobFactory;
65
+ };
66
+
67
+ // The opened handles, injected onto RouteContext by the host. Each is present
68
+ // only when the matching resource was declared.
69
+ export type Resources = {
70
+ db?: JuneDb;
71
+ kv?: JuneKv;
72
+ blob?: JuneBlob;
73
+ };
package/src/route.ts ADDED
@@ -0,0 +1,122 @@
1
+ // route() — a single route definition that feeds one `load()` into multiple
2
+ // content-negotiated projections.
3
+ //
4
+ // export default route({
5
+ // async load(ctx) { return { users: await Users.all() }; },
6
+ // view({ users }) { return <UsersPage users={users} />; }, // HTML / Flight
7
+ // json({ users }) { return { users }; }, // data API
8
+ // agent({ users }) { return manifest.resource("users", users); },
9
+ // });
10
+ //
11
+ // The same data, three representations, never drifting apart. `view` is the
12
+ // React projection (named `view`, not `html`, because the framework decides
13
+ // HTML-SSR vs RSC Flight by negotiation). `json` is the plain data API. `agent`
14
+ // is the capability-described resource for agent clients.
15
+
16
+ import type { JuneDb, JuneKv, JuneBlob } from "./resources";
17
+ import type { Principal, Session } from "./context";
18
+
19
+ export type RenderTarget = "view" | "json" | "agent" | "md";
20
+
21
+ // Per-route document metadata. Static metadata keeps the streaming shell;
22
+ // a FUNCTION (deriving from load() data) forces an eager load — the <head>
23
+ // streams first, so dynamic metadata costs the loading-boundary for that
24
+ // route. Choose per route.
25
+ export type Metadata = {
26
+ title?: string;
27
+ description?: string;
28
+ canonical?: string;
29
+ robots?: string; // e.g. "noindex"
30
+ openGraph?: {
31
+ title?: string;
32
+ description?: string;
33
+ image?: string;
34
+ type?: string; // "website" | "article" | ...
35
+ };
36
+ };
37
+
38
+ export type RouteContext<
39
+ TParams extends Record<string, string> = Record<string, string>,
40
+ > = {
41
+ request: Request;
42
+ url: URL;
43
+ params: TParams;
44
+ target: RenderTarget;
45
+ // True when the request is SPECULATIVE (Sec-Purpose: prefetch / prerender):
46
+ // the page may never be seen. Skip side effects — view counters, analytics,
47
+ // rate-limit consumption. (Client-side twin: defer pageviews until
48
+ // !document.prerendering.)
49
+ speculative?: boolean;
50
+ // Resource handles injected by the host (the binding model), present only when
51
+ // declared in june.config.ts `resources`. The framework depends on these
52
+ // contracts, never on a specific ORM — see docs/data-layer-boundary.md.
53
+ db?: JuneDb;
54
+ kv?: JuneKv;
55
+ blob?: JuneBlob;
56
+ // The authenticated principal, populated by the auth integration off the
57
+ // request (undefined until @junejs/auth is wired). Routes gate on ctx.user;
58
+ // the SAME principal reaches actions via ActionContext.
59
+ user?: Principal;
60
+ session?: Session;
61
+ };
62
+
63
+ export type RouteCache = {
64
+ ttl?: number; // seconds; omit for no expiry (tag-invalidated only)
65
+ swr?: number; // extra stale-while-revalidate window, seconds (needs ttl)
66
+ tags?: string[]; // a mutation calling invalidate(tag) drops this route's cache
67
+ };
68
+
69
+ export type RouteDefinition<TData = unknown> = {
70
+ load?: (ctx: RouteContext) => TData | Promise<TData>;
71
+ view?: (data: TData, ctx: RouteContext) => React.ReactNode;
72
+ json?: (data: TData, ctx: RouteContext) => unknown | Promise<unknown>;
73
+ agent?: (data: TData, ctx: RouteContext) => unknown | Promise<unknown>;
74
+ // Markdown projection. If absent, the `md` target is auto-derived from `json`.
75
+ md?: (data: TData, ctx: RouteContext) => string | Promise<string>;
76
+ // Response cache: cache the rendered output of GET requests, keyed by
77
+ // target+URL, dropped by tag invalidation. Uses @junejs/core/cache.
78
+ cache?: RouteCache;
79
+ // `june build` renders this route's projections (html/md/json) to static
80
+ // files served by the Workers assets layer BEFORE the worker runs (0ms).
81
+ // Static routes only; opt-in because it freezes per-request behavior.
82
+ prerender?: boolean;
83
+ // Document metadata for the view projection (title/description/OG/...).
84
+ metadata?: Metadata | ((data: TData, ctx: RouteContext) => Metadata);
85
+ };
86
+
87
+ const ROUTE_BRAND = Symbol.for("june.route");
88
+
89
+ export type BrandedRoute<TData = unknown> = RouteDefinition<TData> & {
90
+ [ROUTE_BRAND]: true;
91
+ };
92
+
93
+ export function route<TData>(def: RouteDefinition<TData>): BrandedRoute<TData> {
94
+ return { ...def, [ROUTE_BRAND]: true };
95
+ }
96
+
97
+ export function isRouteDefinition(value: unknown): value is BrandedRoute {
98
+ return (
99
+ typeof value === "object" &&
100
+ value !== null &&
101
+ (value as Record<symbol, unknown>)[ROUTE_BRAND] === true
102
+ );
103
+ }
104
+
105
+ // The order each requested target degrades through when a projection is absent.
106
+ // An agent asking for /users.agent on a route with no `agent()` still gets the
107
+ // JSON projection rather than a 406.
108
+ const FALLBACK: Record<RenderTarget, RenderTarget[]> = {
109
+ agent: ["agent", "json", "view"],
110
+ json: ["json", "agent", "view"],
111
+ view: ["view", "json", "agent"],
112
+ // `md` is handled specially (auto-derived from json when md() is absent);
113
+ // this entry is only the last-resort fall-through.
114
+ md: ["md", "json", "view"],
115
+ };
116
+
117
+ export function resolveProjection(
118
+ def: BrandedRoute,
119
+ requested: RenderTarget,
120
+ ): RenderTarget {
121
+ return FALLBACK[requested].find((t) => typeof def[t] === "function") ?? "view";
122
+ }