@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/discovery.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Agent discovery emitters — all derived from the app graph (route list +
|
|
2
|
+
// unified action registry), never hand-authored. Gated by the agent config.
|
|
3
|
+
// See docs/agent-discoverability.md.
|
|
4
|
+
|
|
5
|
+
import { ACTION_REGISTRY } from "./agent";
|
|
6
|
+
import type { AgentConfig } from "./config";
|
|
7
|
+
|
|
8
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
9
|
+
|
|
10
|
+
function toolNames() {
|
|
11
|
+
return [...ACTION_REGISTRY.values()]
|
|
12
|
+
.filter((a) => a.description)
|
|
13
|
+
.map((a) => a.id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The homepage Link header advertises the whole discovery tree in one place, so
|
|
17
|
+
// an agent fetching any page finds everything without guessing well-known paths.
|
|
18
|
+
export function buildLinkHeader(agent: AgentConfig): string | null {
|
|
19
|
+
if (!agent.discovery) return null;
|
|
20
|
+
const links = [
|
|
21
|
+
`</llms.txt>; rel="llms-txt"`,
|
|
22
|
+
`</llms.txt>; rel="describedby"; type="text/markdown"`,
|
|
23
|
+
`</sitemap.xml>; rel="sitemap"`,
|
|
24
|
+
`</.well-known/api-catalog>; rel="api-catalog"`,
|
|
25
|
+
`</.well-known/mcp/server-card.json>; rel="mcp-server"`,
|
|
26
|
+
];
|
|
27
|
+
if (!agent.mcp) links.pop(); // no MCP server card if MCP is off
|
|
28
|
+
return links.join(", ");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function llmsTxt(
|
|
32
|
+
origin: string,
|
|
33
|
+
routes: string[],
|
|
34
|
+
agent: AgentConfig,
|
|
35
|
+
site?: { name?: string; description?: string },
|
|
36
|
+
) {
|
|
37
|
+
const lines = [
|
|
38
|
+
`# ${site?.name ?? "June app"}`,
|
|
39
|
+
"",
|
|
40
|
+
...(site?.description ? [`> ${site.description}`, ""] : []),
|
|
41
|
+
"> Server-rendered React app. Every route also answers as JSON (`.json`),",
|
|
42
|
+
"> as an agent capability manifest (`.agent`), and as Markdown (`.md`).",
|
|
43
|
+
"",
|
|
44
|
+
// Canonical names travel with EVERY June app's llms.txt — this is the
|
|
45
|
+
// grounding artifact agents fetch first; never let them guess npm names.
|
|
46
|
+
"## Framework (canonical names — do not guess)",
|
|
47
|
+
"",
|
|
48
|
+
"Built with June, the agent-native React framework — https://june.build",
|
|
49
|
+
"- Framework npm package: `@junejs/core` — NOT `june` (an unrelated package), not `junejs`.",
|
|
50
|
+
"- Scaffold: `npm create june my-app` (package `create-june`).",
|
|
51
|
+
"- NOT `@june/*` — that scope is not June's; June's scopes are `@junejs` and `@junebuild`.",
|
|
52
|
+
"",
|
|
53
|
+
"## Routes",
|
|
54
|
+
...routes.map((r) => `- [${r}](${r})`),
|
|
55
|
+
];
|
|
56
|
+
if (agent.mcp) {
|
|
57
|
+
lines.push("", "## Tools (MCP)", `- MCP server: ${origin}/mcp`);
|
|
58
|
+
for (const name of toolNames()) lines.push(`- tool: ${name}`);
|
|
59
|
+
}
|
|
60
|
+
return lines.join("\n") + "\n";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function robotsTxt(origin: string) {
|
|
64
|
+
return (
|
|
65
|
+
[
|
|
66
|
+
"User-agent: *",
|
|
67
|
+
"Allow: /",
|
|
68
|
+
// Cloudflare-style content signals: how AI may use this content.
|
|
69
|
+
"Content-Signal: search=yes, ai-train=yes, ai-input=yes",
|
|
70
|
+
`Sitemap: ${origin}/sitemap.xml`,
|
|
71
|
+
].join("\n") + "\n"
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function sitemapXml(origin: string, routes: string[]) {
|
|
76
|
+
const urls = routes
|
|
77
|
+
.filter((r) => !r.includes("[")) // skip dynamic templates — not enumerable
|
|
78
|
+
.map((r) => ` <url><loc>${origin}${r}</loc></url>`)
|
|
79
|
+
.join("\n");
|
|
80
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// RFC 9727 API Catalog (linkset+json).
|
|
84
|
+
export function apiCatalog(origin: string, agent: AgentConfig) {
|
|
85
|
+
const service: Record<string, unknown> = {
|
|
86
|
+
anchor: `${origin}/`,
|
|
87
|
+
"service-doc": [{ href: `${origin}/llms.txt`, type: "text/markdown" }],
|
|
88
|
+
};
|
|
89
|
+
if (agent.mcp) {
|
|
90
|
+
service["service-desc"] = [
|
|
91
|
+
{ href: `${origin}/.well-known/mcp/server-card.json`, type: "application/json" },
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
return { linkset: [service] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function mcpServerCard(origin: string) {
|
|
98
|
+
return {
|
|
99
|
+
name: "june",
|
|
100
|
+
version: "0.0.0",
|
|
101
|
+
url: `${origin}/mcp`,
|
|
102
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
103
|
+
capabilities: { tools: { listChanged: false } },
|
|
104
|
+
tools: toolNames(),
|
|
105
|
+
};
|
|
106
|
+
}
|
package/src/document.tsx
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The shared HTML document shell — ONE implementation drives both the Bun/Node
|
|
2
|
+
// dev+prod server and the generated Workers entry, so `june build` output
|
|
3
|
+
// renders byte-equivalent heads to `june dev`.
|
|
4
|
+
import React from "react";
|
|
5
|
+
|
|
6
|
+
import type { Metadata } from "./route";
|
|
7
|
+
|
|
8
|
+
// The serializable slice of app config the document needs. The server feeds it
|
|
9
|
+
// from AppConfig; the generated worker inlines it as literals at build time.
|
|
10
|
+
export type DocumentConfig = {
|
|
11
|
+
site: { name?: string; titleTemplate?: string; description?: string };
|
|
12
|
+
speculationRules: string | null;
|
|
13
|
+
speculationDelivery: "inline" | "header";
|
|
14
|
+
viewTransitions: boolean;
|
|
15
|
+
// URL of the client islands runtime bundle. Set by the host (dev serves it,
|
|
16
|
+
// build freezes its hashed path) when the app has islands; the document then
|
|
17
|
+
// loads it as a deferred module so `"use client"` islands hydrate. Absent /
|
|
18
|
+
// null → the page ships zero client JS.
|
|
19
|
+
clientScript?: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Cross-document View Transitions: same-origin MPA navigations cross-fade
|
|
23
|
+
// (and pair with prerender: activation + smooth transition = SPA feel, no
|
|
24
|
+
// SPA). prefers-reduced-motion users get instant cuts — accessibility first.
|
|
25
|
+
export const VIEW_TRANSITION_CSS = `
|
|
26
|
+
@view-transition { navigation: auto; }
|
|
27
|
+
@media (prefers-reduced-motion: reduce) {
|
|
28
|
+
::view-transition-group(*),
|
|
29
|
+
::view-transition-old(*),
|
|
30
|
+
::view-transition-new(*) { animation: none !important; }
|
|
31
|
+
}`;
|
|
32
|
+
|
|
33
|
+
export const PREFETCH_FALLBACK = `(function(){if(HTMLScriptElement.supports&&HTMLScriptElement.supports('speculationrules'))return;var seen=new Set();document.addEventListener('pointerover',function(e){var a=e.target&&e.target.closest&&e.target.closest('a[href]');if(!a)return;var u=new URL(a.href,location.href);if(u.origin!==location.origin||seen.has(u.pathname)||u.pathname===location.pathname)return;if(/\.(md|json|agent)$/.test(u.pathname)||u.pathname==='/mcp')return;seen.add(u.pathname);var l=document.createElement('link');l.rel='prefetch';l.href=u.pathname+u.search;document.head.appendChild(l);},{passive:true});})();`;
|
|
34
|
+
|
|
35
|
+
export function documentTitle(
|
|
36
|
+
meta: Metadata | undefined,
|
|
37
|
+
site: DocumentConfig["site"],
|
|
38
|
+
): string {
|
|
39
|
+
if (meta?.title) {
|
|
40
|
+
// The site name as a page title means "this IS the site" (homepages) —
|
|
41
|
+
// don't template it into "Site · Site".
|
|
42
|
+
if (meta.title === site.name) return meta.title;
|
|
43
|
+
return site.titleTemplate ? site.titleTemplate.replace("%s", meta.title) : meta.title;
|
|
44
|
+
}
|
|
45
|
+
return site.name ?? "June app";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function Document({
|
|
49
|
+
children,
|
|
50
|
+
metadata,
|
|
51
|
+
config,
|
|
52
|
+
}: {
|
|
53
|
+
children: React.ReactNode;
|
|
54
|
+
metadata?: Metadata;
|
|
55
|
+
config: DocumentConfig;
|
|
56
|
+
}) {
|
|
57
|
+
const title = documentTitle(metadata, config.site);
|
|
58
|
+
const description = metadata?.description ?? config.site.description;
|
|
59
|
+
const og = metadata?.openGraph;
|
|
60
|
+
return (
|
|
61
|
+
<html lang="en">
|
|
62
|
+
<head>
|
|
63
|
+
{/* charset IN the document (must be in the first 1024 bytes): prerendered
|
|
64
|
+
pages are served by asset layers whose content-type may lack the
|
|
65
|
+
charset param — without this, UTF-8 text mojibakes as windows-1252. */}
|
|
66
|
+
<meta charSet="utf-8" />
|
|
67
|
+
<title>{title}</title>
|
|
68
|
+
{description ? <meta name="description" content={description} /> : null}
|
|
69
|
+
{metadata?.canonical ? <link rel="canonical" href={metadata.canonical} /> : null}
|
|
70
|
+
{metadata?.robots ? <meta name="robots" content={metadata.robots} /> : null}
|
|
71
|
+
{og ? <meta property="og:title" content={og.title ?? title} /> : null}
|
|
72
|
+
{og?.description ?? description ? (
|
|
73
|
+
<meta property="og:description" content={og?.description ?? description} />
|
|
74
|
+
) : null}
|
|
75
|
+
{og?.image ? <meta property="og:image" content={og.image} /> : null}
|
|
76
|
+
{og ? <meta property="og:type" content={og.type ?? "website"} /> : null}
|
|
77
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
78
|
+
{config.speculationRules && config.speculationDelivery === "inline" ? (
|
|
79
|
+
<script
|
|
80
|
+
type="speculationrules"
|
|
81
|
+
dangerouslySetInnerHTML={{ __html: config.speculationRules }}
|
|
82
|
+
/>
|
|
83
|
+
) : null}
|
|
84
|
+
{config.speculationRules ? (
|
|
85
|
+
<script dangerouslySetInnerHTML={{ __html: PREFETCH_FALLBACK }} />
|
|
86
|
+
) : null}
|
|
87
|
+
<style>{`${config.viewTransitions ? VIEW_TRANSITION_CSS : ""}
|
|
88
|
+
body {
|
|
89
|
+
margin: 0;
|
|
90
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
91
|
+
background: #fbfbf8;
|
|
92
|
+
color: #1d1d1f;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
main {
|
|
96
|
+
width: min(720px, calc(100vw - 32px));
|
|
97
|
+
margin: 72px auto;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
code {
|
|
101
|
+
background: #ecebe4;
|
|
102
|
+
border-radius: 4px;
|
|
103
|
+
padding: 2px 5px;
|
|
104
|
+
}
|
|
105
|
+
`}</style>
|
|
106
|
+
</head>
|
|
107
|
+
<body>
|
|
108
|
+
{children}
|
|
109
|
+
{/* type="module" defers automatically: the island runtime runs after the
|
|
110
|
+
markup is parsed, so markers exist when it scans for them. */}
|
|
111
|
+
{config.clientScript ? <script type="module" src={config.clientScript} /> : null}
|
|
112
|
+
</body>
|
|
113
|
+
</html>
|
|
114
|
+
);
|
|
115
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @junejs/core — the agent-native React framework.
|
|
2
|
+
//
|
|
3
|
+
// This barrel re-exports the PURE, host-free contract layer (Phase 1). Each
|
|
4
|
+
// concern is also importable by subpath (`@junejs/core/route`, `@junejs/core/agent`,
|
|
5
|
+
// `@junejs/core/mcp`, ...) so apps and host adapters pull in exactly what they need
|
|
6
|
+
// without dragging the whole surface into a Workers bundle.
|
|
7
|
+
//
|
|
8
|
+
// Host-coupled pieces (the dev server, build/deploy, the fs config loader, the
|
|
9
|
+
// content pipeline, the data layer) layer ON TOP of this in later phases — they
|
|
10
|
+
// never live here, because nothing in this file may touch `node:*` or `Bun.*`.
|
|
11
|
+
|
|
12
|
+
// Routing + content negotiation
|
|
13
|
+
export {
|
|
14
|
+
route,
|
|
15
|
+
isRouteDefinition,
|
|
16
|
+
resolveProjection,
|
|
17
|
+
type RenderTarget,
|
|
18
|
+
type Metadata,
|
|
19
|
+
type RouteContext,
|
|
20
|
+
type RouteCache,
|
|
21
|
+
type RouteDefinition,
|
|
22
|
+
type BrandedRoute,
|
|
23
|
+
} from "./route";
|
|
24
|
+
|
|
25
|
+
// Config schema + pure resolvers
|
|
26
|
+
export {
|
|
27
|
+
defineJune,
|
|
28
|
+
resolveAgent,
|
|
29
|
+
resolveSpeculationRules,
|
|
30
|
+
type AgentConfig,
|
|
31
|
+
type SpeculationConfig,
|
|
32
|
+
type JuneConfig,
|
|
33
|
+
} from "./config";
|
|
34
|
+
|
|
35
|
+
// The data resource contract (the seam the framework depends on, not an ORM)
|
|
36
|
+
export type {
|
|
37
|
+
JuneDb,
|
|
38
|
+
JuneKv,
|
|
39
|
+
JuneBlob,
|
|
40
|
+
RunResult,
|
|
41
|
+
DbFactory,
|
|
42
|
+
KvFactory,
|
|
43
|
+
BlobFactory,
|
|
44
|
+
ResourceConfig,
|
|
45
|
+
Resources,
|
|
46
|
+
} from "./resources";
|
|
47
|
+
|
|
48
|
+
// Request-scoped context: the principal + resources routes and actions receive
|
|
49
|
+
export type { Principal, Session, ActionContext } from "./context";
|
|
50
|
+
|
|
51
|
+
// The shared document shell
|
|
52
|
+
export {
|
|
53
|
+
Document,
|
|
54
|
+
documentTitle,
|
|
55
|
+
VIEW_TRANSITION_CSS,
|
|
56
|
+
PREFETCH_FALLBACK,
|
|
57
|
+
type DocumentConfig,
|
|
58
|
+
} from "./document";
|
|
59
|
+
|
|
60
|
+
// Client islands — explicit "use client" interactivity (SSR marker + hydrate)
|
|
61
|
+
export {
|
|
62
|
+
Island,
|
|
63
|
+
serializeIslandProps,
|
|
64
|
+
deserializeIslandProps,
|
|
65
|
+
ISLAND_TAG,
|
|
66
|
+
ISLAND_NAME_ATTR,
|
|
67
|
+
ISLAND_PROPS_ATTR,
|
|
68
|
+
type IslandProps,
|
|
69
|
+
} from "./islands";
|
|
70
|
+
|
|
71
|
+
// The unified action registry (UI action == agent tool == MCP tool)
|
|
72
|
+
export {
|
|
73
|
+
defineAction,
|
|
74
|
+
invokeAction,
|
|
75
|
+
manifest,
|
|
76
|
+
ResourceManifest,
|
|
77
|
+
isResourceManifest,
|
|
78
|
+
setServerReferenceRegistrar,
|
|
79
|
+
ACTION_REGISTRY,
|
|
80
|
+
type ActionDefinition,
|
|
81
|
+
type AnyAction,
|
|
82
|
+
type JsonSchema,
|
|
83
|
+
type ResourceManifestJson,
|
|
84
|
+
} from "./agent";
|
|
85
|
+
|
|
86
|
+
// Agent discovery emitters
|
|
87
|
+
export {
|
|
88
|
+
buildLinkHeader,
|
|
89
|
+
llmsTxt,
|
|
90
|
+
robotsTxt,
|
|
91
|
+
sitemapXml,
|
|
92
|
+
apiCatalog,
|
|
93
|
+
mcpServerCard,
|
|
94
|
+
} from "./discovery";
|
|
95
|
+
|
|
96
|
+
// The Web-standard MCP endpoint
|
|
97
|
+
export { mcpHandler } from "./mcp";
|
|
98
|
+
|
|
99
|
+
// Cache primitives + the CacheStore seam
|
|
100
|
+
export {
|
|
101
|
+
cache,
|
|
102
|
+
invalidate,
|
|
103
|
+
memory,
|
|
104
|
+
redis,
|
|
105
|
+
registerCache,
|
|
106
|
+
configureCache,
|
|
107
|
+
type CacheEntry,
|
|
108
|
+
type CacheStore,
|
|
109
|
+
type CacheStoreFactory,
|
|
110
|
+
type CacheOptions,
|
|
111
|
+
} from "./cache";
|
|
112
|
+
|
|
113
|
+
// Request tracing (host installs the async-context provider)
|
|
114
|
+
export {
|
|
115
|
+
installTraceContext,
|
|
116
|
+
tracingEnabled,
|
|
117
|
+
currentTrace,
|
|
118
|
+
runWithTrace,
|
|
119
|
+
recordTableRead,
|
|
120
|
+
recordTableWrite,
|
|
121
|
+
recordTiming,
|
|
122
|
+
measure,
|
|
123
|
+
type RequestTrace,
|
|
124
|
+
type AsyncContext,
|
|
125
|
+
type TimingKind,
|
|
126
|
+
} from "./instrumentation";
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Request tracing — timings + the table read/write sets that drive automatic
|
|
2
|
+
// cache tagging. Lives in the PURE contract layer, so it carries NO `node:*`
|
|
3
|
+
// import: the async-context provider (node:async_hooks AsyncLocalStorage on
|
|
4
|
+
// Bun/Node, or workerd's nodejs_compat equivalent) is INJECTED by the host via
|
|
5
|
+
// `installTraceContext()`. Hosts that never install one run untraced — every
|
|
6
|
+
// recorder degrades to a no-op, requests still serve.
|
|
7
|
+
//
|
|
8
|
+
// (In the PoC this module did a top-level `await import("node:async_hooks")`,
|
|
9
|
+
// which forced every bundle reaching it to register a node:* specifier — the
|
|
10
|
+
// exact failure mode that breaks workerd assets-mode. Inverting the dependency
|
|
11
|
+
// keeps the layer host-free.)
|
|
12
|
+
|
|
13
|
+
// The minimal slice of AsyncLocalStorage the trace machinery needs. A host
|
|
14
|
+
// installs a concrete implementation; the type stays structural so this layer
|
|
15
|
+
// never names a runtime.
|
|
16
|
+
export type AsyncContext<T> = {
|
|
17
|
+
getStore(): T | undefined;
|
|
18
|
+
run<R>(store: T, fn: () => R): R;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type TimingKind = "db" | "page" | "route" | "rsc" | "view" | "cache";
|
|
22
|
+
|
|
23
|
+
export type TimingEvent = {
|
|
24
|
+
kind: TimingKind;
|
|
25
|
+
label: string;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
detail?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type RequestTrace = {
|
|
31
|
+
id: string;
|
|
32
|
+
startedAt: number;
|
|
33
|
+
events: TimingEvent[];
|
|
34
|
+
// Tables touched this request — for automatic cache tagging/invalidation.
|
|
35
|
+
reads?: Set<string>;
|
|
36
|
+
writes?: Set<string>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let traces: AsyncContext<RequestTrace> | null = null;
|
|
40
|
+
|
|
41
|
+
// Host seam: install the async-context provider once at startup. The Bun/Node
|
|
42
|
+
// hosts pass `new (await import("node:async_hooks")).AsyncLocalStorage()`.
|
|
43
|
+
export function installTraceContext(context: AsyncContext<RequestTrace>) {
|
|
44
|
+
traces = context;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// True once a host has wired a provider — useful for hosts deciding whether to
|
|
48
|
+
// bother constructing a trace at all.
|
|
49
|
+
export function tracingEnabled(): boolean {
|
|
50
|
+
return traces !== null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ms(value: number) {
|
|
54
|
+
return value.toFixed(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function prefix(trace: RequestTrace) {
|
|
58
|
+
return `[${trace.id.slice(0, 8)}]`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function currentTrace() {
|
|
62
|
+
return traces?.getStore();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function runWithTrace<T>(trace: RequestTrace, fn: () => T) {
|
|
66
|
+
return traces ? traces.run(trace, fn) : fn();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The data layer records every table it reads/writes, so the cache layer can
|
|
70
|
+
// derive tags automatically instead of relying on hand-declared ones.
|
|
71
|
+
export function recordTableRead(table: string) {
|
|
72
|
+
const trace = currentTrace();
|
|
73
|
+
if (trace) (trace.reads ??= new Set()).add(table);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function recordTableWrite(table: string) {
|
|
77
|
+
const trace = currentTrace();
|
|
78
|
+
if (trace) (trace.writes ??= new Set()).add(table);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function recordTiming(
|
|
82
|
+
kind: TimingKind,
|
|
83
|
+
label: string,
|
|
84
|
+
durationMs: number,
|
|
85
|
+
detail?: string,
|
|
86
|
+
) {
|
|
87
|
+
const trace = currentTrace();
|
|
88
|
+
if (!trace) return;
|
|
89
|
+
|
|
90
|
+
trace.events.push({ kind, label, durationMs, detail });
|
|
91
|
+
|
|
92
|
+
if (kind === "db") {
|
|
93
|
+
const suffix = detail ? ` ${detail}` : "";
|
|
94
|
+
console.log(`${prefix(trace)} SQL (${ms(durationMs)}ms)${suffix}`);
|
|
95
|
+
} else if (kind === "cache") {
|
|
96
|
+
console.log(`${prefix(trace)} CACHE ${label}${detail ? ` ${detail}` : ""}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function measure<T>(
|
|
101
|
+
kind: TimingKind,
|
|
102
|
+
label: string,
|
|
103
|
+
fn: () => T | Promise<T>,
|
|
104
|
+
detail?: string,
|
|
105
|
+
) {
|
|
106
|
+
const startedAt = performance.now();
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
return await fn();
|
|
110
|
+
} finally {
|
|
111
|
+
recordTiming(kind, label, performance.now() - startedAt, detail);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function timingTotal(trace: RequestTrace, kind: TimingKind) {
|
|
116
|
+
return trace.events
|
|
117
|
+
.filter((event) => event.kind === kind)
|
|
118
|
+
.reduce((total, event) => total + event.durationMs, 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function requestDuration(trace: RequestTrace) {
|
|
122
|
+
return performance.now() - trace.startedAt;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function timingSummary(trace: RequestTrace) {
|
|
126
|
+
const page = timingTotal(trace, "page");
|
|
127
|
+
const rsc = timingTotal(trace, "rsc");
|
|
128
|
+
const view = timingTotal(trace, "view");
|
|
129
|
+
const db = timingTotal(trace, "db");
|
|
130
|
+
const render = rsc > 0 ? `RSC: ${ms(rsc)}ms` : `Views: ${ms(view)}ms`;
|
|
131
|
+
|
|
132
|
+
return `Page: ${ms(page)}ms | ${render} | DB: ${ms(db)}ms`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function logStarted(request: Request, trace: RequestTrace) {
|
|
136
|
+
const url = new URL(request.url);
|
|
137
|
+
const forwardedFor = request.headers.get("x-forwarded-for");
|
|
138
|
+
const remote = forwardedFor?.split(",")[0]?.trim() || "unknown";
|
|
139
|
+
|
|
140
|
+
console.log(
|
|
141
|
+
`${prefix(trace)} Started ${request.method} "${url.pathname}${url.search}" for ${remote} at ${new Date().toISOString()}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function logProcessing(routeFile: string) {
|
|
146
|
+
const trace = currentTrace();
|
|
147
|
+
if (!trace) return;
|
|
148
|
+
|
|
149
|
+
console.log(`${prefix(trace)} Processing by ${routeFile}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function logCompleted(response: Response, trace: RequestTrace) {
|
|
153
|
+
console.log(
|
|
154
|
+
`${prefix(trace)} Completed ${response.status} ${response.statusText || "OK"} in ${ms(requestDuration(trace))}ms (${timingSummary(trace)})`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The client half of the islands contract — the hydration runtime.
|
|
2
|
+
//
|
|
3
|
+
// Bundled into the app's `client.js` (NOT the server/worker graph), it runs once
|
|
4
|
+
// after the document parses: scan for `<june-island>` markers, look each up in
|
|
5
|
+
// the app's explicit registry, and `hydrateRoot` it in place. The server already
|
|
6
|
+
// SSR'd the markup, so hydration only attaches behavior — `useState`, `onClick` —
|
|
7
|
+
// with no flash and no mismatch (same component, same props, both graphs).
|
|
8
|
+
//
|
|
9
|
+
// PURE per the contract layer's rule (no `node:*` / `Bun.*`) — it is browser-only
|
|
10
|
+
// (it touches `document` + `react-dom/client`), so it is exposed ONLY as the
|
|
11
|
+
// `@junejs/core/islands-client` subpath and is deliberately NOT re-exported from the
|
|
12
|
+
// barrel: pulling `react-dom/client` into the worker graph is exactly what we
|
|
13
|
+
// must not do.
|
|
14
|
+
import React from "react";
|
|
15
|
+
import { hydrateRoot } from "react-dom/client";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
ISLAND_TAG,
|
|
19
|
+
ISLAND_NAME_ATTR,
|
|
20
|
+
ISLAND_PROPS_ATTR,
|
|
21
|
+
deserializeIslandProps,
|
|
22
|
+
} from "./islands";
|
|
23
|
+
|
|
24
|
+
// The app maps each island `name` to its component. v0.1 is explicit: the app
|
|
25
|
+
// writes this by hand in its client entry. v0.2 generates it from `"use client"`.
|
|
26
|
+
export type IslandRegistry = Record<string, React.ComponentType<any>>;
|
|
27
|
+
|
|
28
|
+
// Hydrate every island marker found under `root` (the whole document by default).
|
|
29
|
+
// Returns the count hydrated — handy for tests and dev diagnostics.
|
|
30
|
+
export function hydrateIslands(
|
|
31
|
+
registry: IslandRegistry,
|
|
32
|
+
root: ParentNode = document,
|
|
33
|
+
): number {
|
|
34
|
+
const markers = root.querySelectorAll(`${ISLAND_TAG}[${ISLAND_NAME_ATTR}]`);
|
|
35
|
+
let hydrated = 0;
|
|
36
|
+
for (const el of markers) {
|
|
37
|
+
const name = el.getAttribute(ISLAND_NAME_ATTR);
|
|
38
|
+
if (!name) continue;
|
|
39
|
+
const Component = registry[name];
|
|
40
|
+
if (!Component) {
|
|
41
|
+
// Explicit registry: an unregistered island is an author mistake (forgot to
|
|
42
|
+
// add it to the client entry), so surface it instead of silently leaving a
|
|
43
|
+
// dead, non-interactive marker on the page.
|
|
44
|
+
console.warn(`[june] island "${name}" is on the page but not registered for hydration`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const props = deserializeIslandProps(el.getAttribute(ISLAND_PROPS_ATTR));
|
|
48
|
+
hydrateRoot(el as Element, React.createElement(Component, props));
|
|
49
|
+
hydrated++;
|
|
50
|
+
}
|
|
51
|
+
return hydrated;
|
|
52
|
+
}
|
package/src/islands.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Client islands — explicit, transform-free interactivity for v0.1.
|
|
2
|
+
//
|
|
3
|
+
// A page is server-rendered with zero client JS by default. To make ONE subtree
|
|
4
|
+
// interactive, an author marks the component `"use client"` (convention) and
|
|
5
|
+
// drops it into the server tree through `<Island>`. The server SSRs the
|
|
6
|
+
// component inside a `<june-island>` marker that also carries its props as JSON;
|
|
7
|
+
// the rest of the page ships no JS. The client runtime (Phase: hydration) scans
|
|
8
|
+
// for these markers, looks the component up in an explicit registry, and
|
|
9
|
+
// `hydrateRoot`s each one in place — so `useState`/`onClick` come alive without
|
|
10
|
+
// the page becoming an SPA.
|
|
11
|
+
//
|
|
12
|
+
// PURE: this is React-only (no `node:*` / `Bun.*`), so it lives in the contract
|
|
13
|
+
// layer and both the dev server and the built worker render identical markers.
|
|
14
|
+
//
|
|
15
|
+
// v0.1 is EXPLICIT: the author names the island and registers it on the client
|
|
16
|
+
// by hand. Auto-scanning `"use client"` files into a generated registry (so the
|
|
17
|
+
// `name` and the client import are derived, not written) is deferred to v0.2.
|
|
18
|
+
import React from "react";
|
|
19
|
+
|
|
20
|
+
// The marker element + attributes are the contract between this server-side
|
|
21
|
+
// primitive and the client hydration runtime. Both sides import these constants
|
|
22
|
+
// so a rename can never desync the two halves.
|
|
23
|
+
export const ISLAND_TAG = "june-island";
|
|
24
|
+
export const ISLAND_NAME_ATTR = "data-june-island";
|
|
25
|
+
export const ISLAND_PROPS_ATTR = "data-june-props";
|
|
26
|
+
|
|
27
|
+
// Props cross the server→client boundary as JSON, so they must be
|
|
28
|
+
// JSON-serializable (no functions, no class instances) — the v0.1 island
|
|
29
|
+
// contract. The empty object is the canonical "no props" value so the client
|
|
30
|
+
// can `JSON.parse` unconditionally.
|
|
31
|
+
export function serializeIslandProps(props: unknown): string {
|
|
32
|
+
return JSON.stringify(props ?? {});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function deserializeIslandProps(raw: string | null | undefined): Record<string, unknown> {
|
|
36
|
+
if (!raw) return {};
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
40
|
+
} catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type IslandProps<P extends Record<string, unknown> = Record<string, unknown>> = {
|
|
46
|
+
// The registry key the client runtime hydrates against. Must match the key the
|
|
47
|
+
// app registers in its client entry (v0.1: written by hand).
|
|
48
|
+
name: string;
|
|
49
|
+
// The component to SSR now and hydrate later — the SAME component runs in both
|
|
50
|
+
// graphs, so its server markup and client tree match (no hydration mismatch).
|
|
51
|
+
component: React.ComponentType<P>;
|
|
52
|
+
// JSON-serializable props, embedded in the marker for the client to rehydrate.
|
|
53
|
+
props?: P;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Wrap a client component in its hydration marker. The marker SSRs the component
|
|
57
|
+
// (so the island is visible + indexable with zero JS) AND stamps the name +
|
|
58
|
+
// serialized props the client runtime needs to bring it to life.
|
|
59
|
+
export function Island<P extends Record<string, unknown>>({
|
|
60
|
+
name,
|
|
61
|
+
component: Component,
|
|
62
|
+
props,
|
|
63
|
+
}: IslandProps<P>): React.ReactElement {
|
|
64
|
+
const resolved = (props ?? {}) as P;
|
|
65
|
+
return React.createElement(
|
|
66
|
+
ISLAND_TAG,
|
|
67
|
+
{
|
|
68
|
+
[ISLAND_NAME_ATTR]: name,
|
|
69
|
+
[ISLAND_PROPS_ATTR]: serializeIslandProps(resolved),
|
|
70
|
+
},
|
|
71
|
+
React.createElement(Component, resolved),
|
|
72
|
+
);
|
|
73
|
+
}
|