@junejs/server 0.1.3 → 0.2.0

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.
Files changed (128) hide show
  1. package/assets/icon-latin.LICENSE.txt +92 -0
  2. package/assets/icon-latin.ttf +0 -0
  3. package/dist/adapter-DcZ-W1-7.d.ts +62 -0
  4. package/dist/adapter.d.ts +2 -56
  5. package/dist/adapter.js +62 -5
  6. package/dist/agent-compile.d.ts +38 -0
  7. package/dist/agent-compile.js +145 -0
  8. package/dist/agent-discover.d.ts +4 -4
  9. package/dist/agent-discover.js +30 -44
  10. package/dist/agent-durable.d.ts +94 -19
  11. package/dist/agent-durable.js +647 -32
  12. package/dist/agent-native.d.ts +44 -13
  13. package/dist/agent-native.js +264 -27
  14. package/dist/app.d.ts +5 -5
  15. package/dist/app.js +42 -10
  16. package/dist/blob.d.ts +4 -5
  17. package/dist/build.d.ts +8 -30
  18. package/dist/build.js +135 -296
  19. package/dist/client-bundle.d.ts +8 -9
  20. package/dist/client-bundle.js +1 -1
  21. package/dist/config-freeze-CyXvNqi2.d.ts +17 -0
  22. package/dist/config-freeze.d.ts +2 -0
  23. package/dist/config-freeze.js +54 -0
  24. package/dist/config-loader.d.ts +3 -4
  25. package/dist/connection-auth.d.ts +38 -0
  26. package/dist/connection-auth.js +31 -0
  27. package/dist/content-freeze-CyiW2fRK.d.ts +4 -0
  28. package/dist/content-freeze.d.ts +2 -0
  29. package/dist/content-freeze.js +83 -0
  30. package/dist/content.d.ts +22 -9
  31. package/dist/content.js +43 -3
  32. package/dist/core-version.d.ts +4 -0
  33. package/dist/core-version.js +12 -0
  34. package/dist/css-modules-ambient.d.ts +2 -1
  35. package/dist/css-modules.d.ts +10 -11
  36. package/dist/css.d.ts +9 -10
  37. package/dist/d1-migrate.d.ts +6 -7
  38. package/dist/d1-migrate.js +3 -2
  39. package/dist/db.d.ts +2 -2
  40. package/dist/deploy.d.ts +3 -4
  41. package/dist/deploy.js +3 -2
  42. package/dist/dev-reload.d.ts +3 -4
  43. package/dist/dev.d.ts +8 -5
  44. package/dist/dev.js +40 -9
  45. package/dist/favicon.d.ts +37 -0
  46. package/dist/favicon.js +248 -0
  47. package/dist/host.d.ts +6 -5
  48. package/dist/host.js +2 -1
  49. package/dist/icon-letter.d.ts +3 -0
  50. package/dist/icon-letter.js +8 -0
  51. package/dist/index.d.ts +11 -5
  52. package/dist/index.js +9 -3
  53. package/dist/instrumentation.d.ts +2 -3
  54. package/dist/instrumentation.js +2 -1
  55. package/dist/island-registry.d.ts +6 -7
  56. package/dist/isolate-local.d.ts +5 -0
  57. package/dist/isolate-local.js +12 -0
  58. package/dist/kv.d.ts +3 -4
  59. package/dist/llms-links.d.ts +9 -0
  60. package/dist/llms-links.js +65 -0
  61. package/dist/manifest-CwTT1zVs.d.ts +11 -0
  62. package/dist/manifest.d.ts +2 -0
  63. package/dist/manifest.js +113 -0
  64. package/dist/negotiate.d.ts +4 -3
  65. package/dist/negotiate.js +24 -14
  66. package/dist/{pipeline-CCp9idQ4.d.ts → pipeline-BHUjBO0G.d.ts} +13 -1
  67. package/dist/pipeline.d.ts +2 -2
  68. package/dist/pipeline.js +286 -35
  69. package/dist/resources.d.ts +5 -6
  70. package/dist/route-rank.d.ts +6 -7
  71. package/dist/route-scan-BMbBzPIB.d.ts +20 -0
  72. package/dist/route-scan.d.ts +2 -0
  73. package/dist/route-scan.js +100 -0
  74. package/dist/router.d.ts +12 -13
  75. package/dist/router.js +51 -38
  76. package/dist/rsc-build.d.ts +6 -7
  77. package/dist/rsc-bundle.d.ts +6 -7
  78. package/dist/rsc-manifest.d.ts +5 -6
  79. package/dist/rsc-runtime/flight-render-routes.d.ts +3 -4
  80. package/dist/rsc-runtime/flight-render.d.ts +2 -3
  81. package/dist/rsc-runtime/flight-to-html.d.ts +2 -3
  82. package/dist/rsc-runtime/flight-to-html.js +3 -2
  83. package/dist/rsc-runtime/rsc-worker.js +11 -8
  84. package/dist/rsc-runtime/rsdw-shims.d.ts +2 -1
  85. package/dist/segment.d.ts +4 -5
  86. package/dist/sqlite-driver.d.ts +11 -11
  87. package/dist/sqlite-driver.js +17 -4
  88. package/dist/static-files.d.ts +4 -5
  89. package/dist/tsconfig-jsx.d.ts +4 -4
  90. package/dist/tsconfig-jsx.js +1 -1
  91. package/dist/turn-events.d.ts +4 -0
  92. package/dist/turn-events.js +46 -0
  93. package/dist/web-manifest.d.ts +5 -0
  94. package/dist/web-manifest.js +42 -0
  95. package/dist/worker-2qbQjj2c.d.ts +64 -0
  96. package/dist/worker.d.ts +2 -57
  97. package/dist/worker.js +69 -27
  98. package/package.json +22 -9
  99. package/src/adapter.ts +84 -3
  100. package/src/agent-compile.ts +271 -0
  101. package/src/agent-discover.ts +77 -63
  102. package/src/agent-durable.ts +998 -30
  103. package/src/agent-native.ts +370 -35
  104. package/src/app.ts +75 -6
  105. package/src/build.ts +230 -446
  106. package/src/config-freeze.ts +109 -0
  107. package/src/connection-auth.ts +101 -0
  108. package/src/content-freeze.ts +151 -0
  109. package/src/content.ts +55 -4
  110. package/src/core-version.ts +34 -0
  111. package/src/dev.ts +41 -8
  112. package/src/favicon.ts +325 -0
  113. package/src/host.ts +4 -3
  114. package/src/icon-letter.ts +14 -0
  115. package/src/index.ts +22 -0
  116. package/src/isolate-local.ts +33 -0
  117. package/src/llms-links.ts +96 -0
  118. package/src/manifest.ts +130 -0
  119. package/src/negotiate.ts +39 -15
  120. package/src/pipeline.ts +494 -44
  121. package/src/route-scan.ts +161 -0
  122. package/src/router.ts +66 -54
  123. package/src/rsc-runtime/rsc-worker.tsx +5 -4
  124. package/src/sqlite-driver.ts +36 -6
  125. package/src/tsconfig-jsx.ts +2 -2
  126. package/src/turn-events.ts +43 -0
  127. package/src/web-manifest.ts +38 -0
  128. package/src/worker.ts +131 -35
package/src/favicon.ts ADDED
@@ -0,0 +1,325 @@
1
+ // The default PNG icons: apple-touch-icon.png (180), icon.png (32), and a real
2
+ // favicon.ico — the site name's first character on the same dark square as the
3
+ // SVG favicon the pipeline serves. Build + dev host only (resvg-wasm, node:fs);
4
+ // the worker never imports this, it serves the files `june build` wrote.
5
+ //
6
+ // A rasterizer has no browser fonts, so each icon picks a font for the script
7
+ // of its character:
8
+ // Latin / Greek / Cyrillic caps, digits → Inter caps, bundled (offline, deterministic)
9
+ // Han → the Noto Sans CJK face for site.lang, via CLDR likely subtags:
10
+ // zh → SC, zh-TW / zh-Hant → TC, zh-HK / zh-MO / yue → HK, ja → JP, ko → KR;
11
+ // a lang with no Han signal (en) → SC, CLDR's default for Han
12
+ // kana → Noto Sans JP · Hangul → Noto Sans KR · emoji → Noto Emoji
13
+ // Arabic, Devanagari, Thai, … → that script's Noto Sans face
14
+ // Non-bundled faces come from Google Fonts subset to that ONE character (a few
15
+ // KB), cached on disk, so only the first build touches the network. Any miss
16
+ // (offline, unknown script, a glyph the font lacks) draws the square without a
17
+ // character, and never fails the build.
18
+ import { createHash } from "node:crypto";
19
+ import { existsSync, lstatSync } from "node:fs";
20
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
21
+ import { createRequire } from "node:module";
22
+ import { join } from "node:path";
23
+
24
+ import type { DocumentConfig } from "@junejs/core/document";
25
+
26
+ import { iconLetter } from "./icon-letter";
27
+
28
+ type Site = DocumentConfig["site"];
29
+
30
+ export const ICON_BG = "#1d1d1f";
31
+ export const ICON_FG = "#fbfbf8";
32
+
33
+ // Filenames the icons are served at (from the app root), and the public/ files
34
+ // that mean the app brings its own icon — then June generates none.
35
+ export const GENERATED_ICONS = ["apple-touch-icon.png", "icon.png", "favicon.ico", "icon-192.png", "icon-512.png"] as const;
36
+ export type GeneratedIconFile = (typeof GENERATED_ICONS)[number];
37
+ // The app's own main icon, in preference order: the first one present becomes
38
+ // the page's favicon link (so none of them is ever silently unreferenced).
39
+ const PRIMARY_ICON_FILES = ["favicon.svg", "icon.svg", "favicon.png", "icon.png", "favicon.ico"];
40
+ const CUSTOM_ICON_FILES = [...PRIMARY_ICON_FILES, "apple-touch-icon.png"];
41
+ const MANIFEST_FILES = ["manifest.webmanifest", "manifest.json", "site.webmanifest"];
42
+
43
+ // `inPublic` for resolveIcons over an app's public/ dir. A symlinked public/ is
44
+ // ignored, as the build and dev server ignore it.
45
+ export function publicFileCheck(publicDir: string): (file: string) => boolean {
46
+ let real = false;
47
+ try {
48
+ real = lstatSync(publicDir).isDirectory();
49
+ } catch {
50
+ /* no public/ */
51
+ }
52
+ return (file) => real && existsSync(join(publicDir, file));
53
+ }
54
+
55
+ // Which icons the document links, and whether June generates them. `inPublic`
56
+ // answers whether public/<file> exists. Generated when the app has no icon of
57
+ // its own. Otherwise every icon file it has is linked: the first of
58
+ // PRIMARY_ICON_FILES as the favicon (site.icon still wins in the document),
59
+ // plus icon.png and apple-touch-icon.png when present.
60
+ //
61
+ // The web manifest: the app's own public/manifest.webmanifest (or
62
+ // manifest.json) is linked whenever present. Otherwise June generates one only
63
+ // alongside its own icons — an app with custom icons may have no 192/512 PNGs
64
+ // for a manifest to point at.
65
+ export function resolveIcons(
66
+ site: Site,
67
+ inPublic: (file: string) => boolean,
68
+ ): { generate: boolean; icons: NonNullable<DocumentConfig["icons"]> } {
69
+ const ownManifest = MANIFEST_FILES.find(inPublic);
70
+ const custom = Boolean(site.icon) || CUSTOM_ICON_FILES.some(inPublic);
71
+ if (!custom) {
72
+ return {
73
+ generate: true,
74
+ icons: {
75
+ png: "/icon.png",
76
+ appleTouch: "/apple-touch-icon.png",
77
+ manifest: ownManifest ? `/${ownManifest}` : "/manifest.webmanifest",
78
+ generated: !ownManifest,
79
+ },
80
+ };
81
+ }
82
+ const primary = PRIMARY_ICON_FILES.find(inPublic);
83
+ return {
84
+ generate: false,
85
+ icons: {
86
+ primary: primary ? `/${primary}` : undefined,
87
+ png: inPublic("icon.png") ? "/icon.png" : undefined,
88
+ appleTouch: inPublic("apple-touch-icon.png") ? "/apple-touch-icon.png" : undefined,
89
+ manifest: ownManifest ? `/${ownManifest}` : undefined,
90
+ },
91
+ };
92
+ }
93
+
94
+ const is = (re: RegExp) => (ch: string) => re.test(ch);
95
+ const isBundled = is(/^[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}0-9•]/u);
96
+ const isEmoji = is(/^\p{Extended_Pictographic}/u);
97
+ const isKana = is(/^[\p{Script=Hiragana}\p{Script=Katakana}]/u);
98
+ const isHangul = is(/^\p{Script=Hangul}/u);
99
+ const isHan = is(/^\p{Script=Han}/u);
100
+
101
+ // Scripts with a Noto Sans face on Google Fonts, by Unicode script name.
102
+ const NOTO_SCRIPTS = [
103
+ "Arabic", "Armenian", "Bengali", "Devanagari", "Ethiopic", "Georgian", "Gujarati", "Gurmukhi",
104
+ "Hebrew", "Kannada", "Khmer", "Lao", "Malayalam", "Myanmar", "Sinhala", "Tamil", "Telugu", "Thai",
105
+ ] as const;
106
+ const NOTO_SCRIPT_RES = NOTO_SCRIPTS.map((s) => [s, new RegExp(`^\\p{Script=${s}}`, "u")] as const);
107
+
108
+ // The Noto Sans CJK face whose Han glyph forms match `lang`. Resolved through
109
+ // CLDR likely subtags (Intl.Locale#maximize) instead of a hand-picked region.
110
+ export function hanFamily(lang: string | undefined): string {
111
+ let loc: Intl.Locale;
112
+ try {
113
+ loc = new Intl.Locale(lang || "und").maximize();
114
+ } catch {
115
+ return "Noto Sans SC";
116
+ }
117
+ if (loc.script === "Jpan") return "Noto Sans JP";
118
+ if (loc.script === "Kore") return "Noto Sans KR";
119
+ if (loc.script === "Hant") return loc.region === "HK" || loc.region === "MO" ? "Noto Sans HK" : "Noto Sans TC";
120
+ return "Noto Sans SC"; // Hans, or a language with no Han signal — CLDR's und-Hani is zh-Hans
121
+ }
122
+
123
+ // The Google Fonts family for a character; null = the bundled Inter caps;
124
+ // undefined = no known face (draw the square alone).
125
+ export function iconFamily(ch: string, lang: string | undefined): string | null | undefined {
126
+ if (!ch) return undefined;
127
+ if (isBundled(ch)) return null;
128
+ if (isEmoji(ch)) return "Noto Emoji";
129
+ if (isKana(ch)) return "Noto Sans JP";
130
+ if (isHangul(ch)) return "Noto Sans KR";
131
+ if (isHan(ch)) return hanFamily(lang);
132
+ const script = NOTO_SCRIPT_RES.find(([, re]) => re.test(ch))?.[0];
133
+ return script ? `Noto Sans ${script}` : undefined;
134
+ }
135
+
136
+ // Fetches a font subset: resolves to the TTF bytes, or null on any failure.
137
+ export type FontFetcher = (family: string, text: string) => Promise<Uint8Array | null>;
138
+
139
+ // A legacy Safari UA makes Google Fonts answer with TTF (resvg reads TTF/OTF,
140
+ // not woff2) — the same trick @junejs/og uses.
141
+ const LEGACY_UA =
142
+ "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1";
143
+
144
+ // Google Fonts, subset to `text`, cached under cacheDir by request URL so a
145
+ // rebuild never refetches. timeoutMs bounds each request (offline builds fall
146
+ // back quickly instead of hanging).
147
+ export function googleFontFetcher(
148
+ cacheDir: string,
149
+ opts: { fetch?: typeof fetch; timeoutMs?: number } = {},
150
+ ): FontFetcher {
151
+ const doFetch = opts.fetch ?? fetch;
152
+ const timeoutMs = opts.timeoutMs ?? 3000;
153
+ return async (family, text) => {
154
+ const cssUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@600&text=${encodeURIComponent(text)}`;
155
+ const file = join(cacheDir, createHash("sha256").update(cssUrl).digest("hex").slice(0, 32) + ".ttf");
156
+ try {
157
+ return new Uint8Array(await readFile(file));
158
+ } catch {
159
+ /* not cached */
160
+ }
161
+ try {
162
+ const signal = AbortSignal.timeout(timeoutMs);
163
+ const cssRes = await doFetch(cssUrl, { headers: { "User-Agent": LEGACY_UA }, signal });
164
+ if (!cssRes.ok) return null;
165
+ const url = (await cssRes.text()).match(/src: url\((.+?)\)/)?.[1];
166
+ if (!url) return null;
167
+ const fontRes = await doFetch(url, { signal });
168
+ if (!fontRes.ok) return null;
169
+ const bytes = new Uint8Array(await fontRes.arrayBuffer());
170
+ await mkdir(cacheDir, { recursive: true });
171
+ await writeFile(file, bytes);
172
+ return bytes;
173
+ } catch {
174
+ return null;
175
+ }
176
+ };
177
+ }
178
+
179
+ type ResvgModule = typeof import("@resvg/resvg-wasm");
180
+ let resvgReady: Promise<ResvgModule> | undefined;
181
+ function loadResvg(): Promise<ResvgModule> {
182
+ return (resvgReady ??= (async () => {
183
+ const mod = await import("@resvg/resvg-wasm");
184
+ const wasmPath = createRequire(import.meta.url).resolve("@resvg/resvg-wasm/index_bg.wasm");
185
+ await mod.initWasm(await readFile(wasmPath));
186
+ return mod;
187
+ })());
188
+ }
189
+
190
+ let bundledFont: Promise<Uint8Array> | undefined;
191
+ const loadBundledFont = () =>
192
+ (bundledFont ??= readFile(new URL("../assets/icon-latin.ttf", import.meta.url)).then((b) => new Uint8Array(b)));
193
+ const BUNDLED_FAMILY = "Inter";
194
+
195
+ const escapeXml = (s: string) => s.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`);
196
+
197
+ // Where the glyph goes on the 64×64 square: font size + origin, from its INK
198
+ // box — not its em box — so every script sits optically centred at one size.
199
+ // A probe render measures the ink; cap height and a CJK ideograph both come out
200
+ // INK_H tall, and a wide glyph is held to INK_W. The ink box's corners stay
201
+ // within 0.4 of the size from the centre, which is the maskable-icon safe zone,
202
+ // so the manifest can mark the full-bleed art "maskable" (pinned in a test).
203
+ const PROBE = 1000;
204
+ export const INK_H = 28;
205
+ export const INK_W = 40;
206
+ type Placement = { family: string; font: Uint8Array; size: number; x: number; y: number };
207
+
208
+ function inkBox(resvg: ResvgModule, ch: string, family: string, font: Uint8Array) {
209
+ const probe =
210
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${PROBE}" height="${PROBE}">` +
211
+ `<text x="${PROBE / 2}" y="${PROBE * 0.7}" font-family="${family}" font-size="${PROBE * 0.6}">${escapeXml(ch)}</text></svg>`;
212
+ const r = new resvg.Resvg(probe, { font: { fontBuffers: [font], loadSystemFonts: false, defaultFontFamily: family } });
213
+ const bbox = r.getBBox();
214
+ const box = bbox && bbox.width > 0 && bbox.height > 0 ? { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height } : null;
215
+ bbox?.free();
216
+ r.free();
217
+ return box;
218
+ }
219
+
220
+ // A private-use code point no subset font maps: whatever it draws is .notdef.
221
+ const NOTDEF_PROBE = "";
222
+
223
+ function place(resvg: ResvgModule, ch: string, family: string, font: Uint8Array): Placement | null {
224
+ const bbox = inkBox(resvg, ch, family, font);
225
+ if (!bbox) return null;
226
+ // A font without the glyph draws .notdef (tofu), which HAS ink — so compare
227
+ // with the font's own .notdef box. The same box means a missing glyph.
228
+ const notdef = inkBox(resvg, NOTDEF_PROBE, family, font);
229
+ if (notdef && notdef.x === bbox.x && notdef.y === bbox.y && notdef.width === bbox.width && notdef.height === bbox.height) {
230
+ return null;
231
+ }
232
+ const scale = Math.min(INK_H / bbox.height, INK_W / bbox.width);
233
+ const cx = bbox.x + bbox.width / 2 - PROBE / 2;
234
+ const cy = bbox.y + bbox.height / 2 - PROBE * 0.7;
235
+ return { family, font, size: PROBE * 0.6 * scale, x: 32 - cx * scale, y: 32 - cy * scale };
236
+ }
237
+
238
+ // The icon as SVG on a 64 grid. rounded=false for apple-touch-icon: iOS masks
239
+ // the corners itself and wants a full-bleed square.
240
+ function iconSvg(ch: string, p: Placement | null, rounded: boolean): string {
241
+ const glyph = p
242
+ ? `<text x="${p.x.toFixed(2)}" y="${p.y.toFixed(2)}" font-family="${p.family}" font-size="${p.size.toFixed(2)}" fill="${ICON_FG}">${escapeXml(ch)}</text>`
243
+ : "";
244
+ return (
245
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">` +
246
+ `<rect width="64" height="64"${rounded ? ` rx="12"` : ""} fill="${ICON_BG}"/>${glyph}</svg>`
247
+ );
248
+ }
249
+
250
+ // A single-image .ico whose entry is a PNG (valid since Windows Vista; every
251
+ // current browser reads it).
252
+ export function pngToIco(png: Uint8Array, size: number): Uint8Array<ArrayBuffer> {
253
+ const out = new Uint8Array(22 + png.length);
254
+ const v = new DataView(out.buffer);
255
+ v.setUint16(2, 1, true); // type: icon
256
+ v.setUint16(4, 1, true); // one image
257
+ out[6] = size >= 256 ? 0 : size; // width
258
+ out[7] = size >= 256 ? 0 : size; // height
259
+ v.setUint16(10, 1, true); // colour planes
260
+ v.setUint16(12, 32, true); // bits per pixel
261
+ v.setUint32(14, png.length, true);
262
+ v.setUint32(18, 22, true); // image data offset
263
+ out.set(png, 22);
264
+ return out;
265
+ }
266
+
267
+ export type GeneratedIcons = {
268
+ "apple-touch-icon.png": Uint8Array<ArrayBuffer>;
269
+ "icon.png": Uint8Array<ArrayBuffer>;
270
+ "favicon.ico": Uint8Array<ArrayBuffer>;
271
+ // The web manifest's icons (Android home screen). Full-bleed like the apple
272
+ // icon: the glyph's ink stays inside the maskable safe zone (a centred circle
273
+ // of radius 0.4), so one image serves "any" and "maskable".
274
+ "icon-192.png": Uint8Array<ArrayBuffer>;
275
+ "icon-512.png": Uint8Array<ArrayBuffer>;
276
+ // What was drawn, for the build log and tests: the character and its font
277
+ // family, or family null when the square went out without one.
278
+ letter: string;
279
+ family: string | null;
280
+ };
281
+
282
+ export async function renderIcons(opts: {
283
+ site: Site;
284
+ fetchFont: FontFetcher;
285
+ warn?: (msg: string) => void;
286
+ }): Promise<GeneratedIcons> {
287
+ const resvg = await loadResvg();
288
+ const ch = iconLetter(opts.site.name);
289
+ const family = iconFamily(ch, opts.site.lang);
290
+ let placement: Placement | null = null;
291
+ if (family === null) {
292
+ placement = place(resvg, ch, BUNDLED_FAMILY, await loadBundledFont());
293
+ } else if (family !== undefined) {
294
+ const font = await opts.fetchFont(family, ch);
295
+ if (font) placement = place(resvg, ch, family, font);
296
+ }
297
+ if (ch && !placement) {
298
+ opts.warn?.(
299
+ `[june] default icon: no font for "${ch}"${family ? ` (${family} unavailable — offline?)` : ""}; ` +
300
+ `drew the square without it. Set site.icon or add public/icon.png to use your own.`,
301
+ );
302
+ }
303
+ const fonts = placement ? [placement.font] : [];
304
+ const render = (size: number, rounded: boolean) => {
305
+ const r = new resvg.Resvg(iconSvg(ch, placement, rounded), {
306
+ fitTo: { mode: "width", value: size },
307
+ font: { fontBuffers: fonts, loadSystemFonts: false, defaultFontFamily: placement?.family ?? BUNDLED_FAMILY },
308
+ });
309
+ const image = r.render();
310
+ const png = image.asPng() as Uint8Array<ArrayBuffer>; // a JS-owned copy, not a view into wasm memory
311
+ image.free();
312
+ r.free();
313
+ return png;
314
+ };
315
+ const icon32 = render(32, true);
316
+ return {
317
+ "apple-touch-icon.png": render(180, false),
318
+ "icon.png": icon32,
319
+ "favicon.ico": pngToIco(icon32, 32),
320
+ "icon-192.png": render(192, false),
321
+ "icon-512.png": render(512, false),
322
+ letter: ch,
323
+ family: placement?.family ?? null,
324
+ };
325
+ }
package/src/host.ts CHANGED
@@ -42,7 +42,8 @@ export interface JuneHost {
42
42
  readonly name: "bun" | "node";
43
43
  serve(
44
44
  handler: (req: Request) => Promise<Response>,
45
- opts: { port: number; earlyHints?: () => string[] },
45
+ // hostname: the address to bind. Absent = all interfaces (production, containers).
46
+ opts: { port: number; hostname?: string; earlyHints?: () => string[] },
46
47
  ): ServeHandle;
47
48
  // Spawn a module in a child runtime (the react-server Flight renderer, which
48
49
  // must run under a different module-resolution condition). Phase 4 supersedes
@@ -63,7 +64,7 @@ function bunHost(): JuneHost {
63
64
  // idleTimeout 0: Bun's 10s default kills quiet long-lived responses —
64
65
  // the dev live-reload SSE most visibly (the browser then reconnects,
65
66
  // reads it as a server restart, and reloads the page every 10s).
66
- const server = Bun.serve({ port: opts.port, fetch: handler, idleTimeout: 0 });
67
+ const server = Bun.serve({ port: opts.port, hostname: opts.hostname, fetch: handler, idleTimeout: 0 });
67
68
  return { port: server.port ?? opts.port, stop: (force) => void server.stop(force) };
68
69
  },
69
70
  spawnModule(entry, args, opts) {
@@ -125,7 +126,7 @@ function nodeHost(): JuneHost {
125
126
  res.end("Internal Server Error");
126
127
  }
127
128
  });
128
- server.listen(opts.port);
129
+ server.listen(opts.port, opts.hostname);
129
130
  return {
130
131
  get port() {
131
132
  const addr = server.address();
@@ -0,0 +1,14 @@
1
+ // The default icon's character — shared by the worker's SVG favicon
2
+ // (pipeline.ts) and the build's PNG icons (favicon.ts), so both draw the same
3
+ // one. Worker-safe: no node:* imports.
4
+
5
+ // The name's first grapheme, uppercased. A grapheme — not charAt(0) — so an
6
+ // emoji, a flag, or a character outside the BMP stays whole instead of
7
+ // becoming half a surrogate pair. "" when the name has none.
8
+ export function iconLetter(name: string | undefined): string {
9
+ const trimmed = (name ?? "").trim();
10
+ if (!trimmed) return "";
11
+ const first = new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(trimmed)[Symbol.iterator]().next()
12
+ .value?.segment;
13
+ return (first ?? "").toUpperCase();
14
+ }
package/src/index.ts CHANGED
@@ -81,6 +81,20 @@ export {
81
81
  type Resolved,
82
82
  type LayoutComponent,
83
83
  } from "./pipeline";
84
+ // Connection auth helpers — turn an authenticated principal into a per-call
85
+ // bearer token for an outbound connection (provider/mcp/openapi). The auth
86
+ // INTEGRATION lives in the host layer by design, so these sit here (not in pure
87
+ // @junejs/core). Generic + overridable; the Better Auth variant is structural
88
+ // (adds no dependency).
89
+ export {
90
+ linkedAccountAuth,
91
+ betterAuthAccessToken,
92
+ betterAuthAccountTokenStore,
93
+ type AccountTokenStore,
94
+ type LinkedAccountAuthOptions,
95
+ type ConnectionAuth,
96
+ type BetterAuthLike,
97
+ } from "./connection-auth";
84
98
  export { createApp, type JuneApp, type CreateAppOptions } from "./app";
85
99
  export { createWorker, type WorkerManifest } from "./worker";
86
100
  export {
@@ -91,6 +105,14 @@ export {
91
105
  freezeConfig,
92
106
  type BuildResult,
93
107
  } from "./build";
108
+ export {
109
+ generateAgentModule,
110
+ findAgentDir,
111
+ scanAgentDir,
112
+ emitAgentModule,
113
+ AGENT_MODULE_FILE,
114
+ type AgentDirScan,
115
+ } from "./agent-compile";
94
116
  export { juneDeploy, type DeployResult } from "./deploy";
95
117
  export { migrateD1, wranglerD1, resolveD1Database, type D1Exec } from "./d1-migrate";
96
118
  export { startDevServer, type DevServer, type DevServerOptions } from "./dev";
@@ -0,0 +1,33 @@
1
+ // isolate-local.ts — read @junejs/db's `isolateLocal` WITHOUT a named import.
2
+ //
3
+ // Same hazard core-version.ts exists for: a NAMED import of an export the
4
+ // resolved copy doesn't have dies at module LINK time with an unhelpful
5
+ // SyntaxError, before any of our own diagnostics can run. `isolateLocal` is new
6
+ // in @junejs/db 0.1.0-dev.2, and a version range cannot save us here — the
7
+ // floor has to stay satisfiable by the workspace's own db (which is the very
8
+ // version that lacks it), and `bun pm pack` rewrites a `workspace:*` dep to
9
+ // whatever version it resolves, which can be OLDER still.
10
+ //
11
+ // So: namespace import + optional read, with a local fallback. Unlike the core
12
+ // runtime contract, this one does NOT warrant throwing — isolate-scoped
13
+ // memoization is a lifetime optimization, not a correctness contract. The
14
+ // fallback memoizes in this module instance, so callers keep their cache; the
15
+ // only thing lost against an older db is sharing one value across DUPLICATE
16
+ // copies of the module (workspace symlinks), which costs a missed cache hit,
17
+ // never a wrong answer.
18
+ import * as juneDb from "@junejs/db";
19
+
20
+ export type IsolateLocal = <T>(key: string | symbol, make: () => T) => T;
21
+
22
+ // Exported for tests: the ambient read below can only ever see the workspace's
23
+ // own (matching) db, so the degraded path needs a direct handle to be provable.
24
+ export function makeIsolateLocalFallback(): IsolateLocal {
25
+ const values = new Map<string | symbol, unknown>();
26
+ return <T>(key: string | symbol, make: () => T): T => {
27
+ if (!values.has(key)) values.set(key, make());
28
+ return values.get(key) as T;
29
+ };
30
+ }
31
+
32
+ export const isolateLocal: IsolateLocal =
33
+ (juneDb as { isolateLocal?: IsolateLocal }).isolateLocal ?? makeIsolateLocalFallback();
@@ -0,0 +1,96 @@
1
+ // /llms.txt links, collected from the routes themselves. Each route may export
2
+ // `llms` (see LlmsEntry in @junejs/core/route): a static route defaults to one link
3
+ // under "Pages" built from its static metadata; a dynamic route — a [param]
4
+ // template an agent can't fetch — lists each real page it serves, or is skipped.
5
+ // Links point at the markdown projection (<path>.md) unless the route turned md off,
6
+ // so an agent reads the page rather than its HTML (llmstxt.org's recommendation).
7
+ import type { LlmsLink, SitemapPage } from "@junejs/core/discovery";
8
+ import type { LlmsEntry } from "@junejs/core/route";
9
+
10
+ import type { RouteResolver } from "./pipeline";
11
+
12
+ const DEFAULT_SECTION = "Pages";
13
+
14
+ const mdPath = (path: string) => (path === "/" ? "/index.md" : `${path.replace(/\/+$/, "")}.md`);
15
+
16
+ export async function collectLlmsLinks(origin: string, routes: string[], resolve: RouteResolver): Promise<LlmsLink[]> {
17
+ const links: LlmsLink[] = [];
18
+ const seen = new Set<string>();
19
+ for (const route of routes) {
20
+ // A dynamic template resolves against its own pattern ("/docs/[slug]" matches
21
+ // /docs/:slug), which is all we need: the route definition, not its data.
22
+ const resolved = await resolve(route);
23
+ if (!resolved || !("def" in resolved)) continue; // resource routes have no page to list
24
+ const { def } = resolved;
25
+ if (def.llms === false) continue;
26
+
27
+ const dynamic = route.includes("[");
28
+ const declared = typeof def.llms === "function" ? await def.llms() : def.llms;
29
+ const entries: LlmsEntry[] = declared ? (Array.isArray(declared) ? declared : [declared]) : dynamic ? [] : [{}];
30
+ // Static metadata only: a metadata FUNCTION needs loader data we don't run here.
31
+ const meta = typeof def.metadata === "object" ? def.metadata : undefined;
32
+
33
+ for (const entry of entries) {
34
+ const path = entry.path ?? (dynamic ? undefined : route);
35
+ if (!path) continue; // a dynamic entry must name its page
36
+ const url = origin + (def.md === false ? path : mdPath(path));
37
+ // dedupe on the FINAL url: "/a" and "/a/" both render /a.md, so they're one page
38
+ if (seen.has(url)) continue;
39
+ seen.add(url);
40
+ const own = path === route; // the route's own page → its metadata describes it
41
+ links.push({
42
+ title: entry.title ?? (own ? meta?.title : undefined) ?? path,
43
+ url,
44
+ description: entry.description ?? (own ? meta?.description : undefined),
45
+ section: entry.section ?? DEFAULT_SECTION,
46
+ optional: entry.optional === true,
47
+ });
48
+ }
49
+ }
50
+ return links;
51
+ }
52
+
53
+ // /sitemap.xml pages, enumerated like /llms.txt. A static route is one page; a
54
+ // dynamic route lists its real pages via its `llms` entries (their `lastModified`
55
+ // becomes <lastmod>) — the runtime-safe hook /llms.txt already runs. A route
56
+ // that names none stays out: a [param] template isn't a URL. Resource routes
57
+ // (route.*) are machine endpoints, not pages. `llms = false` keeps a static
58
+ // route listed (its URL is known without entries); on a dynamic route it leaves
59
+ // no entries, so its pages drop out of the runtime sitemap too.
60
+ // `staticPaths` join in only when opts.staticPaths is set — the static() build's
61
+ // prerender, the one place the route contract runs them. Even then they're
62
+ // skipped under i18n: they arrive locale-prefixed, while the sitemap derives
63
+ // each page's locale variants from its canonical path.
64
+ export async function collectSitemapPages(
65
+ routes: string[],
66
+ resolve: RouteResolver,
67
+ opts: { i18n?: boolean; staticPaths?: boolean } = {},
68
+ ): Promise<SitemapPage[]> {
69
+ const pages = new Map<string, SitemapPage>();
70
+ const add = (path: string, lastModified?: string | Date) => {
71
+ const key = path === "/" ? "/" : path.replace(/\/+$/, "");
72
+ const prev = pages.get(key);
73
+ if (!prev) pages.set(key, lastModified ? { path: key, lastModified } : { path: key });
74
+ else if (lastModified && !prev.lastModified) prev.lastModified = lastModified;
75
+ };
76
+ for (const route of routes) {
77
+ const resolved = await resolve(route);
78
+ if (!resolved || !("def" in resolved)) continue;
79
+ const { def } = resolved;
80
+ const dynamic = route.includes("[");
81
+ const declared = def.llms ? (typeof def.llms === "function" ? await def.llms() : def.llms) : undefined;
82
+ const entries: LlmsEntry[] = declared ? (Array.isArray(declared) ? declared : [declared]) : [];
83
+ // A static route is exactly one URL — its own. An entry naming another path
84
+ // (an llms.txt pointer elsewhere) is not a page this route serves.
85
+ if (!dynamic) {
86
+ add(route, entries.find((e) => (e.path ?? route) === route)?.lastModified);
87
+ continue;
88
+ }
89
+ for (const e of entries) if (e.path) add(e.path, e.lastModified);
90
+ if (opts.staticPaths && !opts.i18n && def.staticPaths) {
91
+ const sp = typeof def.staticPaths === "function" ? await def.staticPaths() : def.staticPaths;
92
+ for (const p of sp) add(p);
93
+ }
94
+ }
95
+ return [...pages.values()];
96
+ }
@@ -0,0 +1,130 @@
1
+ // The FREEZE, in-process: import route modules + layouts, build the manifest a
2
+ // createWorker() can run immediately. Extracted from build.ts; used by
3
+ // prerender and by the parity test (its render path is identical to the
4
+ // Rolldown-bundled worker).
5
+
6
+ import { existsSync } from "node:fs";
7
+ import { basename, join } from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
11
+ import { freezeConfig } from "./config-freeze";
12
+ import type { ExtraHandler, LayoutComponent, LoadingComponent, ResourceHandler } from "./pipeline";
13
+ import { scanAppRoutes } from "./route-scan";
14
+ import { findMiddlewareFile } from "./router";
15
+ import { resolveBoundary } from "./segment";
16
+ import type { WorkerManifest } from "./worker";
17
+
18
+ export type ImportedLayout = { component: LayoutComponent; boundary: boolean };
19
+ export async function importLayout(file: string): Promise<ImportedLayout | null> {
20
+ const mod = (await import(pathToFileURL(file).href)) as {
21
+ default?: LayoutComponent;
22
+ segmentBoundary?: unknown;
23
+ };
24
+ return typeof mod.default === "function"
25
+ ? { component: mod.default, boundary: mod.segmentBoundary === true }
26
+ : null;
27
+ }
28
+
29
+ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
30
+ const appDir = join(appRoot, "app");
31
+ const frozen = await freezeConfig(appRoot);
32
+ const scanned = await scanAppRoutes(appRoot);
33
+
34
+ const layoutCache = new Map<string, ImportedLayout | null>();
35
+ const loadCached = async (f: string): Promise<ImportedLayout | null> => {
36
+ if (!layoutCache.has(f)) layoutCache.set(f, await importLayout(f));
37
+ return layoutCache.get(f) ?? null;
38
+ };
39
+ // The chain (root→leaf) + boundary index + shell key, via the SHARED resolver
40
+ // (the one place the deepest-wins rule lives), so the frozen manifest and the
41
+ // dev resolver can't drift — the parity contract.
42
+ const componentsFor = async (
43
+ files: string[],
44
+ ): Promise<{ chain: LayoutComponent[]; boundaryIndex: number | null; key: string | null }> => {
45
+ const items = [];
46
+ for (const f of files) {
47
+ const c = await loadCached(f);
48
+ items.push({ file: f, entry: c?.component ?? null, boundary: !!c?.boundary });
49
+ }
50
+ return resolveBoundary(items);
51
+ };
52
+
53
+ const loadingCache = new Map<string, LoadingComponent | null>();
54
+ const loadingFor = async (file?: string): Promise<LoadingComponent | undefined> => {
55
+ if (!file) return undefined;
56
+ if (!loadingCache.has(file)) {
57
+ const loaded = await importLayout(file);
58
+ loadingCache.set(file, loaded ? (loaded.component as LoadingComponent) : null);
59
+ }
60
+ return loadingCache.get(file) ?? undefined;
61
+ };
62
+
63
+ const routes: Record<string, BrandedRoute> = {};
64
+ const dynamicRoutes: Array<{ pattern: string; def: BrandedRoute }> = [];
65
+ const resourceRoutes: Array<{ pattern: string; handler: ResourceHandler }> = [];
66
+ const generatedRoutes = scanned.filter((r) => r.generated).map((r) => r.path);
67
+ const layoutChains: Record<string, LayoutComponent[]> = {};
68
+ const layoutBoundaries: Record<string, { index: number; key: string }> = {};
69
+ const loadings: Record<string, LoadingComponent> = {};
70
+
71
+ for (const r of scanned) {
72
+ const mod = await import(pathToFileURL(r.file).href);
73
+ // Resource route (route.*): the default export is the Response handler.
74
+ if (r.resource) {
75
+ const handler = (mod as { default?: unknown }).default;
76
+ if (typeof handler === "function") resourceRoutes.push({ pattern: r.path, handler: handler as ResourceHandler });
77
+ continue;
78
+ }
79
+ const def = routeFromModule(mod);
80
+ if (!def) continue;
81
+ const { chain, boundaryIndex, key } = await componentsFor(r.layouts);
82
+ const loading = await loadingFor(r.loading);
83
+ if (r.dynamic) {
84
+ dynamicRoutes.push({ pattern: r.path, def });
85
+ layoutChains[r.path] = chain;
86
+ } else {
87
+ routes[r.path] = def;
88
+ layoutChains[r.path] = chain;
89
+ }
90
+ if (boundaryIndex !== null && key !== null) layoutBoundaries[r.path] = { index: boundaryIndex, key };
91
+ if (loading) loadings[r.path] = loading;
92
+ }
93
+
94
+ let extra: ExtraHandler | undefined;
95
+ const extraFile = findMiddlewareFile(appDir);
96
+ if (extraFile) {
97
+ const mod = (await import(pathToFileURL(extraFile).href)) as { default?: unknown };
98
+ if (typeof mod.default === "function") extra = mod.default as ExtraHandler;
99
+ }
100
+
101
+ // The durable agent's DO address (createWorker routes the chat endpoint by
102
+ // it). Set whenever an agent/ directory exists — the surface is inert without
103
+ // an env.AGENT binding, so in-process consumers (prerender, the parity test)
104
+ // are unaffected.
105
+ let agentName: string | undefined;
106
+ const agentDir = join(appDir, frozen.agent.runtime.dir);
107
+ if (frozen.agent.runtime.enabled && existsSync(agentDir)) {
108
+ const cfgFile = join(agentDir, "agent.ts");
109
+ const cfg = existsSync(cfgFile)
110
+ ? ((await import(pathToFileURL(cfgFile).href)).default as { name?: string } | undefined)
111
+ : undefined;
112
+ agentName = cfg?.name ?? basename(agentDir);
113
+ }
114
+
115
+ return {
116
+ routes,
117
+ dynamicRoutes,
118
+ resourceRoutes,
119
+ ...(generatedRoutes.length ? { generatedRoutes } : {}),
120
+ layoutChains,
121
+ layoutBoundaries,
122
+ loadings,
123
+ document: frozen.document,
124
+ agent: frozen.agent,
125
+ agentName,
126
+ i18n: frozen.i18n,
127
+ earlyHints: frozen.earlyHints,
128
+ extra,
129
+ };
130
+ }