@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/LICENSE +21 -0
- package/README.md +20 -0
- package/package.json +42 -0
- package/src/agent.ts +145 -0
- package/src/cache.ts +245 -0
- package/src/config.ts +111 -0
- package/src/context.ts +25 -0
- package/src/discovery.ts +106 -0
- package/src/document.tsx +115 -0
- package/src/index.ts +126 -0
- package/src/instrumentation.ts +156 -0
- package/src/islands-client.ts +52 -0
- package/src/islands.tsx +73 -0
- package/src/mcp.ts +116 -0
- package/src/resources.ts +73 -0
- package/src/route.ts +122 -0
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
|
+
}
|
package/src/resources.ts
ADDED
|
@@ -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
|
+
}
|