@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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }