@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 June.build
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# @junejs/core
|
|
2
|
+
|
|
3
|
+
The agent-native React framework. This package is the **pure, host-free
|
|
4
|
+
contract layer** — the most stable artifacts of the design, with zero `node:*`
|
|
5
|
+
or `Bun.*` (enforced by `test/purity.test.ts`).
|
|
6
|
+
|
|
7
|
+
| Subpath | What |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `@junejs/core/route` | `route()` + content-negotiated projections (view/json/agent/md) |
|
|
10
|
+
| `@junejs/core/config` | config schema + pure resolvers (`defineJune`, `resolveAgent`, speculation rules) |
|
|
11
|
+
| `@junejs/core/document` | the shared HTML shell — one document drives dev + built worker (charset lives here) |
|
|
12
|
+
| `@junejs/core/agent` | the unified action registry: `defineAction`, `manifest`, `invokeAction` |
|
|
13
|
+
| `@junejs/core/discovery` | llms.txt (with the canonical-names stanza), sitemap, api-catalog, MCP card, Link header |
|
|
14
|
+
| `@junejs/core/mcp` | the Web-standard MCP endpoint (`mcpHandler`) |
|
|
15
|
+
| `@junejs/core/cache` | `cache()` / `invalidate()` + the `CacheStore` seam (memory built in) |
|
|
16
|
+
| `@junejs/core/instrumentation` | request tracing; the host installs the async-context provider |
|
|
17
|
+
|
|
18
|
+
Host-coupled concerns (fs config loader, content pipeline, dev server,
|
|
19
|
+
build/deploy, data layer) live in later layers and import *from* here — never
|
|
20
|
+
the other way around.
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@junejs/core",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "June — the agent-native React framework. Contract layer (pure, host-free).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://june.build",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./route": "./src/route.ts",
|
|
11
|
+
"./config": "./src/config.ts",
|
|
12
|
+
"./resources": "./src/resources.ts",
|
|
13
|
+
"./context": "./src/context.ts",
|
|
14
|
+
"./document": "./src/document.tsx",
|
|
15
|
+
"./islands": "./src/islands.tsx",
|
|
16
|
+
"./islands-client": "./src/islands-client.ts",
|
|
17
|
+
"./agent": "./src/agent.ts",
|
|
18
|
+
"./mcp": "./src/mcp.ts",
|
|
19
|
+
"./discovery": "./src/discovery.ts",
|
|
20
|
+
"./cache": "./src/cache.ts",
|
|
21
|
+
"./instrumentation": "./src/instrumentation.ts"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "bun test",
|
|
28
|
+
"typecheck": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"react": "^19.2.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"react": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/junebuild/june"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// agent.ts — the unified action registry + the agent-facing surface.
|
|
2
|
+
//
|
|
3
|
+
// One `defineAction()` entry is the single source of truth: it is the UI server
|
|
4
|
+
// action, the `.agent` manifest tool, AND the MCP tool — all invoked by the
|
|
5
|
+
// same id against this one registry. The RSC server-action path registers into
|
|
6
|
+
// THIS registry too, so a server action and an agent/MCP tool are no longer
|
|
7
|
+
// "the same thing described twice."
|
|
8
|
+
//
|
|
9
|
+
// 1. defineAction(): id + description + input schema + run.
|
|
10
|
+
// 2. manifest.resource(name, data).actions([...]): the capability manifest a
|
|
11
|
+
// route returns to an agent client.
|
|
12
|
+
// 3. invokeAction(id, input): the JSON dispatch path (agent / MCP). RSC's
|
|
13
|
+
// Flight dispatch resolves the same registry.
|
|
14
|
+
|
|
15
|
+
import { currentTrace } from "./instrumentation";
|
|
16
|
+
import type { ActionContext } from "./context";
|
|
17
|
+
|
|
18
|
+
export type JsonSchema = {
|
|
19
|
+
type: "object";
|
|
20
|
+
properties: Record<string, { type: string; description?: string }>;
|
|
21
|
+
required?: string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ActionDefinition<I = unknown, O = unknown> = {
|
|
25
|
+
id: string;
|
|
26
|
+
description: string;
|
|
27
|
+
input: JsonSchema;
|
|
28
|
+
// run() receives the request-scoped context (principal + resources) as its
|
|
29
|
+
// second arg, so the SAME authorization runs on the UI and /mcp paths. An
|
|
30
|
+
// action that ignores ctx (one-param run) is still assignable here.
|
|
31
|
+
run: (input: I, ctx: ActionContext) => O | Promise<O>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// `ActionDefinition` is invariant in its input type (the `run` param), so a
|
|
35
|
+
// concretely-typed action is not assignable to `ActionDefinition<unknown>`.
|
|
36
|
+
// Collections of heterogeneous actions use this loose alias.
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
38
|
+
export type AnyAction = ActionDefinition<any, any>;
|
|
39
|
+
|
|
40
|
+
// The single, unified action registry — keyed on globalThis so any dispatch
|
|
41
|
+
// path (agent POST, MCP tools/call, RSC Flight) resolves an action by id even
|
|
42
|
+
// when the resolver loads this module twice (workspace symlinks can give the
|
|
43
|
+
// app and the framework different @junejs/core paths; a module-level Map would
|
|
44
|
+
// then split registrations across two instances).
|
|
45
|
+
const REGISTRY_KEY = Symbol.for("june.actionRegistry");
|
|
46
|
+
export const ACTION_REGISTRY: Map<string, AnyAction> = ((
|
|
47
|
+
globalThis as Record<symbol, Map<string, AnyAction> | undefined>
|
|
48
|
+
)[REGISTRY_KEY] ??= new Map());
|
|
49
|
+
|
|
50
|
+
// The RSC runtime (which owns the `react-server` condition) injects how to mark
|
|
51
|
+
// a run() as a React server reference, so the SAME action is passable as a UI
|
|
52
|
+
// prop without agent.ts importing react-server. No-op outside the RSC runtime.
|
|
53
|
+
type ServerReferenceRegistrar = (
|
|
54
|
+
fn: (...args: unknown[]) => unknown,
|
|
55
|
+
id: string,
|
|
56
|
+
) => unknown;
|
|
57
|
+
let serverReferenceRegistrar: ServerReferenceRegistrar | null = null;
|
|
58
|
+
export function setServerReferenceRegistrar(fn: ServerReferenceRegistrar) {
|
|
59
|
+
serverReferenceRegistrar = fn;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function defineAction<I, O>(
|
|
63
|
+
def: ActionDefinition<I, O>,
|
|
64
|
+
): ActionDefinition<I, O> {
|
|
65
|
+
ACTION_REGISTRY.set(def.id, def);
|
|
66
|
+
serverReferenceRegistrar?.(def.run as (...args: unknown[]) => unknown, def.id);
|
|
67
|
+
return def;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// JSON dispatch path (agent / MCP): invoke an action by id with a single input
|
|
71
|
+
// and the request-scoped context (principal + resources). ctx defaults to {} so
|
|
72
|
+
// callers that don't have one (tests, anonymous dispatch) still work.
|
|
73
|
+
export async function invokeAction(
|
|
74
|
+
id: string,
|
|
75
|
+
input: unknown,
|
|
76
|
+
ctx: ActionContext = {},
|
|
77
|
+
): Promise<unknown> {
|
|
78
|
+
const action = ACTION_REGISTRY.get(id);
|
|
79
|
+
if (!action) throw new Error(`Unknown action: ${id}`);
|
|
80
|
+
const result = await action.run(input, ctx);
|
|
81
|
+
|
|
82
|
+
// Cache coherence is a property of the ACTION, not of one dispatch path:
|
|
83
|
+
// every table this action wrote invalidates its `table:<name>` tag (plus the
|
|
84
|
+
// coarse `flight` tag), no matter how it was invoked — UI POST, /mcp
|
|
85
|
+
// tools/call, or Flight. (Idempotent if a caller also invalidates.)
|
|
86
|
+
const writes = currentTrace()?.writes;
|
|
87
|
+
if (writes && writes.size > 0) {
|
|
88
|
+
const { invalidate } = await import("./cache");
|
|
89
|
+
for (const table of writes) await invalidate(`table:${table}`);
|
|
90
|
+
await invalidate("flight");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type ActionManifestEntry = {
|
|
97
|
+
id: string;
|
|
98
|
+
description: string;
|
|
99
|
+
input: JsonSchema;
|
|
100
|
+
// How an agent invokes it. Mirrors the same dispatch the UI uses.
|
|
101
|
+
invoke: { method: "POST"; header: "x-june-action"; action: string };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type ResourceManifestJson = {
|
|
105
|
+
resource: string;
|
|
106
|
+
data: unknown;
|
|
107
|
+
actions: ActionManifestEntry[];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export class ResourceManifest<T = unknown> {
|
|
111
|
+
private declared: AnyAction[] = [];
|
|
112
|
+
|
|
113
|
+
constructor(
|
|
114
|
+
private readonly name: string,
|
|
115
|
+
private readonly data: T,
|
|
116
|
+
) {}
|
|
117
|
+
|
|
118
|
+
actions(actions: AnyAction[]) {
|
|
119
|
+
this.declared = actions;
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
toManifest(): ResourceManifestJson {
|
|
124
|
+
return {
|
|
125
|
+
resource: this.name,
|
|
126
|
+
data: this.data,
|
|
127
|
+
actions: this.declared.map((action) => ({
|
|
128
|
+
id: action.id,
|
|
129
|
+
description: action.description,
|
|
130
|
+
input: action.input,
|
|
131
|
+
invoke: { method: "POST", header: "x-june-action", action: action.id },
|
|
132
|
+
})),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function isResourceManifest(value: unknown): value is ResourceManifest {
|
|
138
|
+
return value instanceof ResourceManifest;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const manifest = {
|
|
142
|
+
resource<T>(name: string, data: T) {
|
|
143
|
+
return new ResourceManifest<T>(name, data);
|
|
144
|
+
},
|
|
145
|
+
};
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Cache primitives — first-class, not an afterthought (benchmarks show cache is
|
|
2
|
+
// the difference between a render-bound ~5k rps and the native HTTP ceiling).
|
|
3
|
+
//
|
|
4
|
+
// cache(fn, { key, ttl, tags }) memoize an async result
|
|
5
|
+
// invalidate(tag) drop every entry carrying a tag
|
|
6
|
+
//
|
|
7
|
+
// Same adapter shape as the data layer: a CacheStore seam with a built-in
|
|
8
|
+
// in-memory store (full tag support) and pluggable backends (redis, KV) behind
|
|
9
|
+
// the same interface. The framework's route response cache uses this too.
|
|
10
|
+
//
|
|
11
|
+
// PURITY: the in-memory store and all of cache()/invalidate() are host-free.
|
|
12
|
+
// The only host touch is the redis() factory's connect(), which dynamic-imports
|
|
13
|
+
// "bun" through a NON-LITERAL specifier so no bundler resolves it statically —
|
|
14
|
+
// the import only runs if a host opts into the redis store.
|
|
15
|
+
|
|
16
|
+
import { currentTrace, recordTiming } from "./instrumentation";
|
|
17
|
+
|
|
18
|
+
export type CacheEntry = {
|
|
19
|
+
value: unknown;
|
|
20
|
+
expiresAt: number | null; // fresh until this epoch ms (null = always fresh)
|
|
21
|
+
staleUntil: number | null; // servable-stale until this epoch ms (null = never dies)
|
|
22
|
+
tags: string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface CacheStore {
|
|
26
|
+
get(key: string): Promise<CacheEntry | null>;
|
|
27
|
+
set(key: string, entry: CacheEntry): Promise<void>;
|
|
28
|
+
delete(key: string): Promise<void>;
|
|
29
|
+
invalidateTag(tag: string): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CacheStoreFactory {
|
|
33
|
+
readonly kind: string;
|
|
34
|
+
connect(): Promise<CacheStore>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Built-in in-memory store with a tag -> keys index for O(tag) invalidation.
|
|
38
|
+
class MemoryStore implements CacheStore {
|
|
39
|
+
private readonly entries = new Map<string, CacheEntry>();
|
|
40
|
+
private readonly tagIndex = new Map<string, Set<string>>();
|
|
41
|
+
|
|
42
|
+
async get(key: string): Promise<CacheEntry | null> {
|
|
43
|
+
const entry = this.entries.get(key);
|
|
44
|
+
if (!entry) return null;
|
|
45
|
+
// Alive (fresh or servable-stale) until staleUntil; cache() decides which.
|
|
46
|
+
if (entry.staleUntil !== null && entry.staleUntil < Date.now()) {
|
|
47
|
+
await this.delete(key);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return entry;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async set(key: string, entry: CacheEntry): Promise<void> {
|
|
54
|
+
await this.delete(key); // clear any stale tag links
|
|
55
|
+
this.entries.set(key, entry);
|
|
56
|
+
for (const tag of entry.tags) {
|
|
57
|
+
let keys = this.tagIndex.get(tag);
|
|
58
|
+
if (!keys) this.tagIndex.set(tag, (keys = new Set()));
|
|
59
|
+
keys.add(key);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async delete(key: string): Promise<void> {
|
|
64
|
+
const entry = this.entries.get(key);
|
|
65
|
+
this.entries.delete(key);
|
|
66
|
+
if (entry) {
|
|
67
|
+
for (const tag of entry.tags) this.tagIndex.get(tag)?.delete(key);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async invalidateTag(tag: string): Promise<void> {
|
|
72
|
+
const keys = this.tagIndex.get(tag);
|
|
73
|
+
if (!keys) return;
|
|
74
|
+
for (const key of [...keys]) await this.delete(key);
|
|
75
|
+
this.tagIndex.delete(tag);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let store: CacheStore | null = null;
|
|
80
|
+
|
|
81
|
+
export function registerCache(s: CacheStore) {
|
|
82
|
+
store = s;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function configureCache(factory: CacheStoreFactory) {
|
|
86
|
+
store = await factory.connect();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function active(): CacheStore {
|
|
90
|
+
// Zero-config fallback: the built-in in-memory store.
|
|
91
|
+
return store ?? (store = new MemoryStore());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type CacheOptions = {
|
|
95
|
+
key: string;
|
|
96
|
+
ttl?: number; // fresh window, seconds
|
|
97
|
+
swr?: number; // extra stale-while-revalidate window, seconds (needs ttl)
|
|
98
|
+
tags?: string[];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function entryOf(opts: CacheOptions, value: unknown, tags: string[]): CacheEntry {
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
const expiresAt = opts.ttl ? now + opts.ttl * 1000 : null;
|
|
104
|
+
const staleUntil =
|
|
105
|
+
expiresAt !== null && opts.swr ? expiresAt + opts.swr * 1000 : expiresAt;
|
|
106
|
+
return { value, expiresAt, staleUntil, tags };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Coalesce background refreshes so a burst of stale hits triggers one recompute.
|
|
110
|
+
const revalidating = new Set<string>();
|
|
111
|
+
|
|
112
|
+
async function revalidate<T>(
|
|
113
|
+
s: CacheStore,
|
|
114
|
+
fn: () => T | Promise<T>,
|
|
115
|
+
opts: CacheOptions,
|
|
116
|
+
tags: string[], // reuse the stale entry's tags (no request trace in the background)
|
|
117
|
+
) {
|
|
118
|
+
if (revalidating.has(opts.key)) return;
|
|
119
|
+
revalidating.add(opts.key);
|
|
120
|
+
try {
|
|
121
|
+
await s.set(opts.key, entryOf(opts, await fn(), tags));
|
|
122
|
+
} catch {
|
|
123
|
+
/* keep serving the stale value */
|
|
124
|
+
} finally {
|
|
125
|
+
revalidating.delete(opts.key);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Memoize an async result. Fresh hits return immediately; within the SWR window
|
|
130
|
+
// a stale value is returned immediately while a refresh runs in the background.
|
|
131
|
+
export async function cache<T>(
|
|
132
|
+
fn: () => T | Promise<T>,
|
|
133
|
+
opts: CacheOptions,
|
|
134
|
+
): Promise<T> {
|
|
135
|
+
const s = active();
|
|
136
|
+
const entry = await s.get(opts.key);
|
|
137
|
+
|
|
138
|
+
if (entry) {
|
|
139
|
+
const fresh = entry.expiresAt === null || entry.expiresAt > Date.now();
|
|
140
|
+
if (fresh) {
|
|
141
|
+
recordTiming("cache", "HIT", 0, opts.key);
|
|
142
|
+
return entry.value as T;
|
|
143
|
+
}
|
|
144
|
+
// Stale but within the SWR window: serve stale now, refresh in background.
|
|
145
|
+
recordTiming("cache", "STALE", 0, opts.key);
|
|
146
|
+
void revalidate(s, fn, opts, entry.tags);
|
|
147
|
+
return entry.value as T;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Miss — auto-tag by the tables fn() reads (diff the trace before/after), so
|
|
151
|
+
// invalidation works without hand-declared tags. Explicit tags merge in.
|
|
152
|
+
const before = new Set(currentTrace()?.reads ?? []);
|
|
153
|
+
const value = await fn();
|
|
154
|
+
const autoTags = [...(currentTrace()?.reads ?? [])]
|
|
155
|
+
.filter((t) => !before.has(t))
|
|
156
|
+
.map((t) => `table:${t}`);
|
|
157
|
+
|
|
158
|
+
await s.set(opts.key, entryOf(opts, value, [...(opts.tags ?? []), ...autoTags]));
|
|
159
|
+
recordTiming(
|
|
160
|
+
"cache",
|
|
161
|
+
"MISS",
|
|
162
|
+
0,
|
|
163
|
+
`${opts.key}${autoTags.length ? ` [${autoTags.join(",")}]` : ""}`,
|
|
164
|
+
);
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Drop every cache entry carrying this tag. Call from mutations.
|
|
169
|
+
export async function invalidate(tag: string): Promise<void> {
|
|
170
|
+
await active().invalidateTag(tag);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Config form: `cache: memory()` (default) or `cache: redis({ url })`.
|
|
174
|
+
export function memory(): CacheStoreFactory {
|
|
175
|
+
return {
|
|
176
|
+
kind: "memory",
|
|
177
|
+
async connect() {
|
|
178
|
+
return new MemoryStore();
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Redis-backed store (Bun's native client). Tags use Redis sets for O(tag)
|
|
184
|
+
// invalidation. Needs a Redis server — same seam as the in-memory store.
|
|
185
|
+
interface RedisLike {
|
|
186
|
+
get(key: string): Promise<string | null>;
|
|
187
|
+
set(key: string, value: string): Promise<unknown>;
|
|
188
|
+
del(key: string): Promise<unknown>;
|
|
189
|
+
expire(key: string, seconds: number): Promise<unknown>;
|
|
190
|
+
sadd(key: string, member: string): Promise<unknown>;
|
|
191
|
+
srem(key: string, member: string): Promise<unknown>;
|
|
192
|
+
smembers(key: string): Promise<string[]>;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
class RedisStore implements CacheStore {
|
|
196
|
+
constructor(private readonly client: RedisLike) {}
|
|
197
|
+
|
|
198
|
+
async get(key: string): Promise<CacheEntry | null> {
|
|
199
|
+
const raw = await this.client.get(key);
|
|
200
|
+
if (!raw) return null;
|
|
201
|
+
const entry = JSON.parse(raw) as CacheEntry;
|
|
202
|
+
if (entry.staleUntil !== null && entry.staleUntil < Date.now()) {
|
|
203
|
+
await this.delete(key);
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return entry;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async set(key: string, entry: CacheEntry): Promise<void> {
|
|
210
|
+
await this.client.set(key, JSON.stringify(entry));
|
|
211
|
+
if (entry.staleUntil) {
|
|
212
|
+
await this.client.expire(key, Math.ceil((entry.staleUntil - Date.now()) / 1000));
|
|
213
|
+
}
|
|
214
|
+
for (const tag of entry.tags) await this.client.sadd(`tag:${tag}`, key);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async delete(key: string): Promise<void> {
|
|
218
|
+
const raw = await this.client.get(key);
|
|
219
|
+
await this.client.del(key);
|
|
220
|
+
if (raw) {
|
|
221
|
+
const entry = JSON.parse(raw) as CacheEntry;
|
|
222
|
+
for (const tag of entry.tags) await this.client.srem(`tag:${tag}`, key);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async invalidateTag(tag: string): Promise<void> {
|
|
227
|
+
for (const key of await this.client.smembers(`tag:${tag}`)) await this.delete(key);
|
|
228
|
+
await this.client.del(`tag:${tag}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function redis(opts: { url: string }): CacheStoreFactory {
|
|
233
|
+
return {
|
|
234
|
+
kind: "redis",
|
|
235
|
+
async connect() {
|
|
236
|
+
// Non-literal specifier: bundlers (wrangler/esbuild for Workers) must not
|
|
237
|
+
// try to resolve "bun" — it exists only at Bun runtime.
|
|
238
|
+
const bunSpecifier = "bun";
|
|
239
|
+
const mod = (await import(bunSpecifier)) as unknown as {
|
|
240
|
+
RedisClient: new (url: string) => RedisLike;
|
|
241
|
+
};
|
|
242
|
+
return new RedisStore(new mod.RedisClient(opts.url));
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// june.config.ts support — the resource manifest / feature config.
|
|
2
|
+
//
|
|
3
|
+
// The agent surface (discovery + MCP) is ON by default: it exposes the same
|
|
4
|
+
// actions and the same authorization the UI already exposes, so it does not
|
|
5
|
+
// widen the attack surface. Gate sensitive actions with permissions, not by
|
|
6
|
+
// hiding the endpoint. Turn any of it off here when you must.
|
|
7
|
+
//
|
|
8
|
+
// PURITY: this module is the config SCHEMA and its pure resolvers only. The
|
|
9
|
+
// `loadJuneConfig(appDir)` reader (node:fs / node:path / dynamic import of the
|
|
10
|
+
// user's june.config.ts) is a HOST concern and lives in the Phase-2 host layer
|
|
11
|
+
// — keeping the contract layer free of `node:*` (zero node:*/Bun.* in this layer).
|
|
12
|
+
|
|
13
|
+
import type { CacheStoreFactory } from "./cache";
|
|
14
|
+
import type { ResourceConfig } from "./resources";
|
|
15
|
+
|
|
16
|
+
export type AgentConfig = {
|
|
17
|
+
enabled: boolean; // master switch
|
|
18
|
+
discovery: boolean; // Link header, llms.txt, sitemap, api-catalog, mcp server-card
|
|
19
|
+
mcp: boolean; // the /mcp execution endpoint
|
|
20
|
+
webmcp: boolean; // inject WebMCP tool registrations into the view
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SpeculationConfig = {
|
|
24
|
+
// hover-intent prerender: "moderate" (hover) | "conservative" (mousedown,
|
|
25
|
+
// for heavy pages) | false. Default "moderate" — light MPA pages get 0ms
|
|
26
|
+
// navigations for free.
|
|
27
|
+
prerender?: "moderate" | "conservative" | false;
|
|
28
|
+
prefetch?: "moderate" | "conservative" | false;
|
|
29
|
+
// App-specific exclusions, ADDED to the built-in ones (agent surfaces:
|
|
30
|
+
// *.md *.json *.agent *.txt *.xml /mcp — those are always excluded).
|
|
31
|
+
exclude?: string[];
|
|
32
|
+
// "inline" (default): rules in a <script type=speculationrules>.
|
|
33
|
+
// "header": rules served at /__june/speculation-rules and referenced by a
|
|
34
|
+
// `Speculation-Rules` response header — smaller HTML, CDN-injectable.
|
|
35
|
+
delivery?: "inline" | "header";
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type JuneConfig = {
|
|
39
|
+
agent?: Partial<AgentConfig>;
|
|
40
|
+
cache?: CacheStoreFactory; // memory() (default) | redis({ url }) | custom
|
|
41
|
+
// Data resources (db / blob / kv), declared = enabled. Generic names, not
|
|
42
|
+
// Cloudflare-branded; each has a zero-config local default and deploy
|
|
43
|
+
// adapters. Omit one and it never exists. See docs/data-layer-boundary.md.
|
|
44
|
+
resources?: ResourceConfig;
|
|
45
|
+
speculation?: SpeculationConfig | false; // false = no speculation rules at all
|
|
46
|
+
// Cross-document View Transitions (@view-transition CSS): MPA navigations
|
|
47
|
+
// animate (default cross-fade) with ZERO JS; browsers without support (or
|
|
48
|
+
// users with prefers-reduced-motion) get instant navigation — the floor.
|
|
49
|
+
viewTransitions?: boolean; // default true
|
|
50
|
+
// Early Hints (IETF RFC 8297): Link rel=preload values for critical assets
|
|
51
|
+
// (fonts/CSS), e.g. ["</fonts/inter.woff2>; rel=preload; as=font; crossorigin"].
|
|
52
|
+
// Floor: sent as a Link header on HTML responses (Cloudflare upgrades it to
|
|
53
|
+
// a real 103 at the edge). On the Node host, June emits the 103 itself.
|
|
54
|
+
earlyHints?: string[];
|
|
55
|
+
// Site-wide metadata defaults: per-route metadata merges over these.
|
|
56
|
+
// titleTemplate: "%s" is replaced by the route's title ("%s — Acme").
|
|
57
|
+
site?: { name?: string; titleTemplate?: string; description?: string };
|
|
58
|
+
// `june build` options. external: packages left UNBUNDLED in dist/worker.js
|
|
59
|
+
// (wrangler resolves them at deploy with its own rules — needed for packages
|
|
60
|
+
// that import .wasm, e.g. workers-og).
|
|
61
|
+
build?: { external?: string[] };
|
|
62
|
+
// `june deploy` options. The deploy VERB is fixed; the target is an adapter
|
|
63
|
+
// (same seam philosophy as JuneHost) — "workers" today, "node"/"june-cloud"
|
|
64
|
+
// later. name defaults to package.json name.
|
|
65
|
+
deploy?: { target?: "workers"; name?: string };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const DEFAULT_AGENT: AgentConfig = {
|
|
69
|
+
enabled: true,
|
|
70
|
+
discovery: true,
|
|
71
|
+
mcp: true,
|
|
72
|
+
webmcp: true,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function defineJune(config: JuneConfig): JuneConfig {
|
|
76
|
+
return config;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function resolveAgent(partial?: Partial<AgentConfig>): AgentConfig {
|
|
80
|
+
const merged = { ...DEFAULT_AGENT, ...(partial ?? {}) };
|
|
81
|
+
// The master switch turns the whole agent surface off.
|
|
82
|
+
if (!merged.enabled) {
|
|
83
|
+
return { enabled: false, discovery: false, mcp: false, webmcp: false };
|
|
84
|
+
}
|
|
85
|
+
return merged;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- speculation (hover prerender/prefetch) -----------------------------------
|
|
89
|
+
|
|
90
|
+
// Agent surfaces are ALWAYS excluded from human-intent speculation — humans
|
|
91
|
+
// hover, agents don't; a footer link to llms.txt must not prerender.
|
|
92
|
+
const BUILTIN_EXCLUDES = ["/*.md", "/*.json", "/*.agent", "/*.txt", "/*.xml", "/mcp"];
|
|
93
|
+
|
|
94
|
+
export function resolveSpeculationRules(config?: SpeculationConfig | false): string | null {
|
|
95
|
+
if (config === false) return null;
|
|
96
|
+
const prerender = config?.prerender ?? "moderate";
|
|
97
|
+
const prefetch = config?.prefetch ?? "moderate";
|
|
98
|
+
if (!prerender && !prefetch) return null;
|
|
99
|
+
const where = {
|
|
100
|
+
and: [
|
|
101
|
+
{ href_matches: "/*" },
|
|
102
|
+
...[...BUILTIN_EXCLUDES, ...(config?.exclude ?? [])].map((p) => ({
|
|
103
|
+
not: { href_matches: p },
|
|
104
|
+
})),
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
const rules: Record<string, unknown> = {};
|
|
108
|
+
if (prerender) rules.prerender = [{ where, eagerness: prerender }];
|
|
109
|
+
if (prefetch) rules.prefetch = [{ where, eagerness: prefetch }];
|
|
110
|
+
return JSON.stringify(rules);
|
|
111
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Request-scoped context shared by routes and actions: the authenticated
|
|
2
|
+
// principal + the data resources. PURE — the principal types are abstract; the
|
|
3
|
+
// auth integration (@junejs/auth, Better Auth) refines them. @junejs/core never
|
|
4
|
+
// imports an auth library; the host populates these off the request.
|
|
5
|
+
// See docs/auth-integration.md.
|
|
6
|
+
|
|
7
|
+
import type { JuneDb, JuneKv, JuneBlob } from "./resources";
|
|
8
|
+
|
|
9
|
+
// The authenticated user. Abstract on purpose — Better Auth's user shape slots
|
|
10
|
+
// in via declaration merging / the auth package's own typing.
|
|
11
|
+
export type Principal = { id: string; [key: string]: unknown };
|
|
12
|
+
export type Session = { id: string; [key: string]: unknown };
|
|
13
|
+
|
|
14
|
+
// What a defineAction's run() receives as its SECOND argument: the same
|
|
15
|
+
// request-scoped principal + resources a route's load() sees. This is what lets
|
|
16
|
+
// the UI dispatch and the /mcp path share ONE authorization model — an agent
|
|
17
|
+
// calling a tool runs through the exact same `ctx.user` check as the UI.
|
|
18
|
+
export type ActionContext = {
|
|
19
|
+
request?: Request;
|
|
20
|
+
user?: Principal;
|
|
21
|
+
session?: Session;
|
|
22
|
+
db?: JuneDb;
|
|
23
|
+
kv?: JuneKv;
|
|
24
|
+
blob?: JuneBlob;
|
|
25
|
+
};
|