@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
@@ -0,0 +1,109 @@
1
+ // Freeze june.config.ts → the serializable bits the worker inlines, plus the
2
+ // small config-derived helpers (deploy adapter resolution, base path and worker
3
+ // name normalization). Extracted from build.ts; consumed by buildManifest and
4
+ // juneBuild so both freeze through the SAME code (the parity contract).
5
+
6
+ import { existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ import { deployOrigin, resolveAgent, resolveClientRouter, resolveSpeculationRules } from "@junejs/core/config";
10
+ import type { JuneConfig } from "@junejs/core/config";
11
+ import type { DocumentConfig } from "@junejs/core/document";
12
+ import { workers, vercel, deno, staticSite, type JuneAdapter } from "./adapter";
13
+ import { findClientEntry, CLIENT_SCRIPT_URL } from "./client-bundle";
14
+ import { loadJuneConfig } from "./config-loader";
15
+ import { publicFileCheck, resolveIcons } from "./favicon";
16
+ import { findGlobalCss, globalCssUsesTailwind, STYLES_URL } from "./css";
17
+ import type { WorkerManifest } from "./worker";
18
+
19
+ // Resolve the deploy adapter from config. An explicit `adapter` INSTANCE wins; otherwise the
20
+ // `target` NAME selects the matching built-in — so a DECLARATIVE config (e.g. kura.toml, which
21
+ // can't express a `vercel()` call) can pick any target by string, not just "static". "workers" is
22
+ // the default. Kept in lockstep with deploy.ts's own target→deployer switch so a build for one
23
+ // target is never deployed as another. vercel()/deno() take their opts (runtime/regions, org/app)
24
+ // which JuneConfig.deploy doesn't carry yet, so they use defaults here.
25
+ export function resolveDeployAdapter(deploy: JuneConfig["deploy"]): JuneAdapter {
26
+ if (deploy?.adapter) return deploy.adapter as JuneAdapter;
27
+ switch (deploy?.target) {
28
+ case "static":
29
+ return staticSite();
30
+ case "vercel":
31
+ return vercel();
32
+ case "deno":
33
+ return deno();
34
+ default:
35
+ return workers({ name: deploy?.name, domain: deploy?.domain });
36
+ }
37
+ }
38
+
39
+ // Freeze june.config.ts → the serializable bits the worker inlines.
40
+ export async function freezeConfig(appRoot: string): Promise<{
41
+ document: DocumentConfig;
42
+ agent: WorkerManifest["agent"];
43
+ i18n: WorkerManifest["i18n"];
44
+ earlyHints: string[];
45
+ buildExternal: string[];
46
+ }> {
47
+ const cfg = await loadJuneConfig(appRoot);
48
+ // An app with a client entry gets the islands runtime URL frozen into its
49
+ // document. Detected HERE (not just in juneBuild) so the prerender path —
50
+ // which re-freezes through buildManifest — sets the SAME clientScript, keeping
51
+ // prerendered pages byte-equivalent to the live worker (parity).
52
+ // Client entry: app/_client.* wins; fall back to .june/routes/_client.* (framework slot).
53
+ const hasClient =
54
+ findClientEntry(join(appRoot, "app")) !== undefined ||
55
+ findClientEntry(join(appRoot, ".june", "routes")) !== undefined;
56
+ const hasCss = findGlobalCss(join(appRoot, "app")) !== null;
57
+ return {
58
+ document: {
59
+ site: cfg.site ?? {},
60
+ deployOrigin: deployOrigin(cfg),
61
+ speculationRules: resolveSpeculationRules(cfg.speculation ?? undefined),
62
+ speculationDelivery: "inline",
63
+ viewTransitions: cfg.viewTransitions ?? true,
64
+ // Default the baseline reset OFF when the app uses Tailwind (its Preflight is the reset).
65
+ cssReset: cfg.cssReset ?? !globalCssUsesTailwind(join(appRoot, "app")),
66
+ clientRouter: resolveClientRouter(cfg.clientRouter),
67
+ clientScript: hasClient ? CLIENT_SCRIPT_URL : null,
68
+ styles: hasCss ? STYLES_URL : null,
69
+ // Deploy subpath (JuneConfig.basePath). Frozen so the document prefixes its
70
+ // asset URLs; the prerender path re-freezes through buildManifest and gets the
71
+ // same value, keeping static pages' asset links correct under the subpath.
72
+ basePath: normalizeBase(cfg.basePath),
73
+ // PNG icon links: June's generated set unless public/ or site.icon brings one
74
+ // (juneBuild writes the files; resolveIcons makes the same call there).
75
+ icons: resolveIcons(cfg.site ?? {}, publicFileCheck(join(appRoot, "public"))).icons,
76
+ },
77
+ agent: resolveAgent(cfg.agent),
78
+ // Pass i18n through as-is: the in-process buildManifest keeps a resolveLocale
79
+ // hook (parity test), and the codegen JSON.stringify drops the function
80
+ // (worker hook support is the codegen pass — see the manifest field comment).
81
+ i18n: cfg.i18n,
82
+ earlyHints: cfg.earlyHints ?? [],
83
+ buildExternal: cfg.build?.external ?? [],
84
+ };
85
+ }
86
+
87
+ // A deploy basePath is stored with a leading slash and no trailing slash; empty
88
+ // ("" — the default) means a root deploy. So "/openab/docs/" → "/openab/docs",
89
+ // "openab" → "/openab", undefined → "".
90
+ export function normalizeBase(base?: string): string {
91
+ if (!base) return "";
92
+ const b = base.startsWith("/") ? base : `/${base}`;
93
+ return b.endsWith("/") ? b.slice(0, -1) : b;
94
+ }
95
+
96
+ // A wrangler-valid worker name from a package name or directory name. Wrangler requires lowercase
97
+ // [a-z0-9-] not starting/ending with a dash — so a SCOPED package name (`@scope/pkg`) must lose its
98
+ // scope (else the `@` sanitizes to a leading dash and wrangler rejects the config). Drop the scope,
99
+ // lowercase, collapse non-alphanumerics to single dashes, trim edge dashes; fall back to "app" if
100
+ // nothing survives (e.g. an all-punctuation name). Exported for tests.
101
+ export function workerName(raw: string): string {
102
+ return (
103
+ raw
104
+ .replace(/^@[^/]+\//, "")
105
+ .toLowerCase()
106
+ .replace(/[^a-z0-9-]+/g, "-")
107
+ .replace(/^-+|-+$/g, "") || "app"
108
+ );
109
+ }
@@ -0,0 +1,101 @@
1
+ // connection-auth.ts — turn an authenticated principal into a per-call bearer
2
+ // token for an outbound connection (provider / mcp / openapi).
3
+ //
4
+ // A connection's `auth(ctx)` seam resolves the credential PER CALL, server-side,
5
+ // so the token never reaches the model. The common, fiddly case is "use the
6
+ // CALLER's linked OAuth account" — look up the principal's stored token for a
7
+ // provider, fail closed when it isn't there. This module standardizes that:
8
+ //
9
+ // • linkedAccountAuth — the generic core. You inject a `store` (where account
10
+ // tokens live); it handles the ctx→userId extraction and the fail-closed
11
+ // contract. Reusable across connections whose `auth` is resolved PER CALL
12
+ // with the caller's identity — i.e. PROVIDER connections (Google Drive), and
13
+ // MCP/OpenAPI remotes that don't authenticate discovery.
14
+ //
15
+ // ⚠️ Not for MCP/OpenAPI connections that DO require auth for discovery:
16
+ // connectMcp/connectOpenapi call `auth(undefined)` at initialize/tools-list/
17
+ // schema-fetch time (before any turn), and this helper fails closed on a
18
+ // missing principal — so discovery would report zero tools. Those need a
19
+ // discovery-scoped credential, not a per-caller one.
20
+ // • betterAuthAccountTokenStore / betterAuthAccessToken — the blessed Better
21
+ // Auth convenience. Kept STRUCTURAL (no `better-auth` import), so wiring it
22
+ // adds no dependency and stays fully overridable — swap the store for a
23
+ // custom table, a secrets vault, or a Service Account minter.
24
+ //
25
+ // Pure logic (no node:*), tied only to the identity types in @junejs/core — the
26
+ // auth INTEGRATION lives in the host layer by design (see docs/auth-integration.md),
27
+ // which is why it is here and not in the pure contract layer.
28
+
29
+ import type { ActionContext } from "@junejs/core/context";
30
+
31
+ // Where a principal's OAuth account tokens live. Given the caller's principal id
32
+ // and a provider, return a FRESH access token (the store refreshes if needed),
33
+ // or null when the user hasn't linked that provider. Better Auth's account table
34
+ // is the blessed implementation; anything satisfying this shape works.
35
+ export type AccountTokenStore = (args: {
36
+ userId: string;
37
+ providerId: string;
38
+ }) => Promise<{ accessToken: string } | null> | { accessToken: string } | null;
39
+
40
+ export type LinkedAccountAuthOptions = {
41
+ // Which linked provider to mint a token for, e.g. "google".
42
+ providerId: string;
43
+ // The token source (injected — this is the overridable seam).
44
+ store: AccountTokenStore;
45
+ };
46
+
47
+ // The connection `auth(ctx)` shape: resolve a bearer token from the call's
48
+ // identity, server-side. (Structurally identical to connections' internal Auth
49
+ // and to GoogleDriveAuth.)
50
+ export type ConnectionAuth = (ctx?: ActionContext) => Promise<{ token: string }>;
51
+
52
+ // Build an `auth(ctx)` for ANY June connection that mints the CALLER's
53
+ // linked-account token. FAIL CLOSED: a missing principal or an unlinked account
54
+ // throws — so an authenticated-but-unlinked user gets a clear error, and
55
+ // (together with the connection's `requiresPrincipal`, which hides the tools from
56
+ // anonymous turns) the capability is never reachable without a real credential.
57
+ export function linkedAccountAuth(opts: LinkedAccountAuthOptions): ConnectionAuth {
58
+ return async (ctx?: ActionContext): Promise<{ token: string }> => {
59
+ const userId = ctx?.user?.id;
60
+ if (!userId) {
61
+ throw new Error(`connection auth (${opts.providerId}): no authenticated principal — set requiresPrincipal and resolve identity on the surface`);
62
+ }
63
+ const record = await opts.store({ userId, providerId: opts.providerId });
64
+ if (!record?.accessToken) {
65
+ throw new Error(`connection auth (${opts.providerId}): user "${userId}" has no linked "${opts.providerId}" account`);
66
+ }
67
+ return { token: record.accessToken };
68
+ };
69
+ }
70
+
71
+ // The minimal Better-Auth surface this reads — STRUCTURAL, so importing the
72
+ // helper doesn't drag in `better-auth`. A real Better Auth server instance
73
+ // satisfies it: `auth.api.getAccessToken` takes an ENDPOINT INPUT shaped as
74
+ // `{ body: { providerId, userId, accountId? }, headers? }` and refreshes the
75
+ // stored token when expired. The reply key differs across versions (accessToken
76
+ // vs token), so both are accepted.
77
+ export type BetterAuthLike = {
78
+ api: {
79
+ getAccessToken: (input: {
80
+ body: { providerId: string; userId?: string; accountId?: string };
81
+ headers?: HeadersInit;
82
+ }) => Promise<{ accessToken?: string; token?: string } | null | undefined>;
83
+ };
84
+ };
85
+
86
+ // Adapt a Better-Auth-shaped instance into an AccountTokenStore. Passes `userId`
87
+ // in the endpoint `body` (server-side lookup — no request session needed).
88
+ export function betterAuthAccountTokenStore(auth: BetterAuthLike): AccountTokenStore {
89
+ return async ({ userId, providerId }) => {
90
+ const res = await auth.api.getAccessToken({ body: { providerId, userId } });
91
+ const accessToken = res?.accessToken ?? res?.token;
92
+ return accessToken ? { accessToken } : null;
93
+ };
94
+ }
95
+
96
+ // Sugar: a fail-closed `auth(ctx)` backed by Better Auth in one call.
97
+ //
98
+ // googleDriveConnection({ requiresPrincipal: true, auth: betterAuthAccessToken(auth, { providerId: "google" }) })
99
+ export function betterAuthAccessToken(auth: BetterAuthLike, opts: { providerId: string }): ConnectionAuth {
100
+ return linkedAccountAuth({ providerId: opts.providerId, store: betterAuthAccountTokenStore(auth) });
101
+ }
@@ -0,0 +1,151 @@
1
+ // The content FREEZE: content/<collection>/*.md → app/_content.ts, the
2
+ // build-time content manifest. Extracted from build.ts — this is the whole
3
+ // "remove node:fs from the worker graph" step, including the self-healing
4
+ // bootstrap for wrapper-generated configs that import app/_content.ts.
5
+
6
+ import { execFileSync } from "node:child_process";
7
+ import { existsSync } from "node:fs";
8
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import { join, resolve } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+
12
+ import type { ContentSource, JuneConfig } from "@junejs/core/config";
13
+ import { findJuneConfigPath, loadJuneConfig } from "./config-loader";
14
+ import { generateContentModule } from "./content";
15
+
16
+ // content/<collection>/*.md → app/_content.ts (the build-time content manifest).
17
+ // This is the FREEZE that removes node:fs from the worker graph: routes import
18
+ // frozen entries from ./_content instead of reading the filesystem at request.
19
+ //
20
+ // Extra sources (config `content.sources`) merge dirs OUTSIDE `content/` into named
21
+ // collections — the docs-as-code seam (a repo's own `docs/` feeds the site directly).
22
+ // Their dirs resolve against the app root, so "../docs" reaches a sibling directory.
23
+ //
24
+ // Locale buckets are DECLARED, not guessed: only dirs named in config i18n
25
+ // (defaultLocale + locales keys) split off as locale mirrors. Guessing by shape
26
+ // (the old BCP-47 regex) silently swallowed any 2–3-letter folder — `cli/`,
27
+ // `sdk/`, `api/`, `faq/` all read as locales and vanished from the default set.
28
+ // No i18n config ⇒ NO buckets (an undeclared locale is not a locale). The regex
29
+ // remains only as the fallback when the config itself cannot be loaded.
30
+ export async function generateContent(appRoot: string): Promise<string[]> {
31
+ const contentDir = join(appRoot, "content");
32
+ // app/ is the output dir for _content.ts (and the seed). Ensure it exists so the now-always
33
+ // write can't throw an opaque ENOENT when called standalone (e.g. `june gen`); the full build's
34
+ // own "no app/ directory" check runs earlier, so this never masks that clearer error.
35
+ await mkdir(join(appRoot, "app"), { recursive: true });
36
+ const emit = async (sources: ContentSource[], knownLocales: readonly string[] | undefined): Promise<string[]> => {
37
+ // Emission (incl. the per-locale layout and source merging) lives in ./content so it's
38
+ // pure and unit-testable; this stays the thin fs wrapper.
39
+ const { code, names } = generateContentModule(contentDir, knownLocales, sources);
40
+ // Always write, even with zero collections: `code` still carries the canonical ContentEntry
41
+ // type, which is the single source of truth the bootstrap seed appends its stubs against (an
42
+ // app with no local content/ — docs-as-code — needs this valid empty module to exist).
43
+ await writeFile(join(appRoot, "app", "_content.ts"), code);
44
+ return names;
45
+ };
46
+ const resolveSources = (sources: ContentSource[]): ContentSource[] =>
47
+ sources.map((s) => ({ ...s, dir: resolve(appRoot, s.dir) }));
48
+ const declaredLocales = (config: JuneConfig): string[] =>
49
+ config.i18n ? [...new Set([config.i18n.defaultLocale, ...Object.keys(config.i18n.locales)])] : [];
50
+ // Learn sources + locales from june.config.ts — TOLERANTLY. A wrapper-generated config (e.g.
51
+ // Kura's) imports app/_content.ts, which does not exist before the FIRST freeze. So on a failed
52
+ // config load: generate a default scan to create that import's target, then re-probe the config
53
+ // and regenerate with the real sources/locales. Self-healing, no bootstrap flag. Content errors
54
+ // from emit() (slug collision, missing source dir) stay loud — only the CONFIG LOAD is tolerated.
55
+ let config: JuneConfig | null = null;
56
+ try {
57
+ config = await loadJuneConfig(appRoot);
58
+ } catch {
59
+ /* two-pass below */
60
+ }
61
+ if (config) return emit(resolveSources(config.content?.sources ?? []), declaredLocales(config));
62
+ // Pass 1 (throwaway when the probe succeeds): legacy regex locale detection, since the
63
+ // declared set is unknowable without the config. emit() writes app/_content.ts with the
64
+ // canonical ContentEntry type plus a `const` per collection FOUND — but a docs-as-code app
65
+ // keeps ALL content in external content.sources with NO local content/, so it finds zero
66
+ // collections and thus emits no EXPORTS (no `DOCS`). seedContentImports then appends stubs for
67
+ // the exact names the config imports (e.g. Kura's `import { DOCS }`), so the re-probe can load.
68
+ const names = await emit([], undefined);
69
+ await seedContentImports(appRoot);
70
+ const probed = probeConfigFresh(appRoot);
71
+ if (probed === null) {
72
+ console.warn("[june gen] june.config.ts failed to load — content.sources/i18n locales (if any) not applied");
73
+ return names;
74
+ }
75
+ return emit(resolveSources(probed.sources), probed.locales);
76
+ }
77
+
78
+ // Bootstrap seed: ensure app/_content.ts exports every name the config imports from it, so the
79
+ // re-probe's config load resolves BEFORE the first real freeze. A docs-as-code app (content only
80
+ // in external content.sources, no local content/) leaves Pass 1's default scan empty — nothing
81
+ // seeds the collections — so here we append stubs for the EXACT named imports the config takes
82
+ // from the app/_content module (a bare `import { DOCS }` of it → `export const DOCS = []`). The
83
+ // caller's emit() already wrote the module's canonical ContentEntry type (even when empty), so the
84
+ // stubs type against that single source of truth. Overwritten by the real freeze that follows a
85
+ // successful probe; a no-op once content exists.
86
+ async function seedContentImports(appRoot: string): Promise<void> {
87
+ const cfgPath = findJuneConfigPath(appRoot);
88
+ if (!cfgPath) return;
89
+ const contentFile = join(appRoot, "app", "_content.ts");
90
+ const current = existsSync(contentFile) ? await readFile(contentFile, "utf8") : "";
91
+ const cfgSrc = await readFile(cfgPath, "utf8");
92
+ // Only stub valid JS identifiers — a name carrying comments/other tokens (rare, but valid TS)
93
+ // must never reach `new RegExp(...)` (would throw) or the emitted stub (would be invalid TS).
94
+ const isIdent = (s: string): boolean => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s);
95
+ // A Set so a name matched more than once (e.g. a comment mentioning the import + the real one)
96
+ // yields ONE stub — a duplicate `export const <name>` would be invalid TS. Also skip names the
97
+ // module already exports.
98
+ const seen = new Set<string>();
99
+ const stubs: string[] = [];
100
+ // Each named `import { A, B as C }` the config takes from the app/_content module → stub each.
101
+ // The `(?:\.\w+)?` tolerates an explicit extension (`app/_content.ts`/`.js`/`.mjs`), which
102
+ // NodeNext / verbatimModuleSyntax configs require — else the import goes undetected and unstubbed.
103
+ for (const m of cfgSrc.matchAll(/import\s*(?:type\s+)?\{([^}]*)\}\s*from\s*["'][^"']*app\/_content(?:\.\w+)?["']/g)) {
104
+ // Strip block + line comments from the specifier list BEFORE splitting: a valid
105
+ // `{ DOCS /* keep */ }` must still yield the identifier `DOCS` (not be dropped), and a comment
106
+ // could otherwise carry a `,` that breaks the split. Then drop `type` markers per specifier.
107
+ const inner = (m[1] ?? "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
108
+ for (const part of inner.split(",")) {
109
+ const name = part.replace(/\btype\b/g, "").split(/\s+as\s+/).pop()?.trim();
110
+ if (!name || !isIdent(name) || seen.has(name)) continue;
111
+ seen.add(name);
112
+ // Escape before interpolating: a valid identifier may contain `$`, a regex metachar (an
113
+ // anchor), which would corrupt the already-exported check. The stub below uses the raw name.
114
+ const reName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
115
+ if (!new RegExp(`export (?:const|function|type) ${reName}\\b`).test(current)) {
116
+ stubs.push(`export const ${name}: ContentEntry[] = [];`);
117
+ }
118
+ }
119
+ }
120
+ if (stubs.length) await writeFile(contentFile, current + stubs.join("\n") + "\n");
121
+ }
122
+
123
+ // Re-probe ONLY the content-relevant config (sources + declared i18n locales) in a FRESH
124
+ // subprocess. The bootstrap retry can't re-import in-process: a failed ESM load is cached as
125
+ // errored, and Bun also caches the failed RESOLUTION of the config's own imports — so a
126
+ // same-process retry (even cache-busted) re-rejects after the missing app/_content.ts appears.
127
+ // A child process has a clean module map, and both fields are plain JSON, so they survive the
128
+ // pipe. Returns null when the config is genuinely broken.
129
+ function probeConfigFresh(appRoot: string): { sources: ContentSource[]; locales: string[] } | null {
130
+ const path = findJuneConfigPath(appRoot);
131
+ if (!path) return { sources: [], locales: [] };
132
+ // Markers isolate the JSON from anything the config prints at import time. `.then` (not TLA)
133
+ // so the eval works as CJS under both `bun -e` and `node -e`; execArgv carries loader flags
134
+ // (e.g. --experimental-strip-types) so the child can read the same TS config the parent does.
135
+ const code =
136
+ `import(${JSON.stringify(pathToFileURL(path).href)}).then(` +
137
+ `(m) => { const c = m.default ?? {}; process.stdout.write("\\n__JUNE_CONTENT__" + JSON.stringify({ ` +
138
+ `sources: c.content?.sources ?? [], ` +
139
+ `locales: c.i18n ? [...new Set([c.i18n.defaultLocale, ...Object.keys(c.i18n.locales ?? {})])] : [] ` +
140
+ `}) + "__END__"); }, () => process.exit(42));`;
141
+ try {
142
+ const out = execFileSync(process.execPath, [...process.execArgv, "-e", code], {
143
+ encoding: "utf8",
144
+ stdio: ["ignore", "pipe", "ignore"],
145
+ });
146
+ const m = out.match(/__JUNE_CONTENT__(.*?)__END__/s);
147
+ return m ? (JSON.parse(m[1]!) as { sources: ContentSource[]; locales: string[] }) : null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
package/src/content.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { toHtmlSync, initSync } from "@momiji-rs/sparkdown/gfm";
15
+ import { createSlugger } from "@junejs/core/slug";
15
16
 
16
17
  import type { ContentSource } from "@junejs/core/config";
17
18
 
@@ -29,6 +30,48 @@ const ensureWasm = () => {
29
30
  }
30
31
  };
31
32
 
33
+ export type Heading = { depth: number; text: string; id: string };
34
+
35
+ // &-entities the renderer emits in heading text, decoded so the text (and so the
36
+ // slug) is what a reader sees: "Q&amp;A" slugs as "Q&A" → "qa", as on GitHub.
37
+ const NAMED: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
38
+ const decodeEntities = (s: string) =>
39
+ s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e: string) =>
40
+ e[0] === "#"
41
+ ? String.fromCodePoint(e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : Number(e.slice(1)))
42
+ : (NAMED[e.toLowerCase()] ?? m),
43
+ );
44
+
45
+ // Give every heading in rendered HTML a GitHub-compatible id — one slugger per
46
+ // document, in order, so repeats de-duplicate exactly as GitHub's do — and collect
47
+ // them. Markdown headings render bare (`<h2>…</h2>`) and get only an id. Raw HTML an
48
+ // author wrote is preserved by CommonMark, so a heading may arrive with attributes:
49
+ // its attributes are kept, an authored id is REUSED (the author's explicit anchor),
50
+ // and a generated id never repeats any id already in the document — authored ones
51
+ // included — so every heading's anchor is unique.
52
+ const ID_ATTR = /\sid\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
53
+ export function headingIds(html: string): { html: string; headings: Heading[] } {
54
+ const slug = createSlugger();
55
+ const taken = new Set<string>();
56
+ for (const m of html.matchAll(new RegExp(ID_ATTR.source, "gi"))) taken.add((m[1] ?? m[2])!);
57
+ const headings: Heading[] = [];
58
+ const out = html.replace(/<h([1-6])(\s[^>]*)?>([\s\S]*?)<\/h\1>/gi, (_m, depth: string, attrs: string | undefined, inner: string) => {
59
+ const text = decodeEntities(inner.replace(/<[^>]*>/g, "")).trim();
60
+ const own = attrs ? ID_ATTR.exec(attrs) : null;
61
+ const authored = own ? (own[1] ?? own[2]) : undefined;
62
+ let id = authored;
63
+ if (!id) {
64
+ do id = slug(text);
65
+ while (taken.has(id)); // the slugger suffixes each repeat: -1, -2, …
66
+ taken.add(id);
67
+ }
68
+ headings.push({ depth: Number(depth), text, id });
69
+ // an authored-but-empty id="" is no anchor: drop it, so the heading carries exactly one id
70
+ return authored ? _m : `<h${depth}${(attrs ?? "").replace(ID_ATTR, "")} id="${id}">${inner}</h${depth}>`;
71
+ });
72
+ return { html: out, headings };
73
+ }
74
+
32
75
  export type ContentEntry = {
33
76
  slug: string;
34
77
  file: string;
@@ -38,8 +81,13 @@ export type ContentEntry = {
38
81
  body: string;
39
82
  /** The authored file, verbatim — the agent-facing .md projection. */
40
83
  original: string;
41
- /** The body rendered to HTML (CommonMark + GFM, via @momiji-rs/sparkdown/gfm wasm). */
84
+ /** The body rendered to HTML (CommonMark + GFM, via @momiji-rs/sparkdown/gfm wasm).
85
+ * Every heading carries a GitHub-compatible `id` (see @junejs/core/slug) and nothing
86
+ * else — no injected links or classes; how an anchor looks is the app's call. */
42
87
  html: string;
88
+ /** The headings of `html`, in document order: their depth (1–6), plain text, and the
89
+ * `id` each carries in `html` — for a table of contents, or a structured projection. */
90
+ headings: Heading[];
43
91
  /** The locale this entry was authored in — set for files under a `<locale>/`
44
92
  * subdir, undefined for the flat (default-locale) files. */
45
93
  locale?: string;
@@ -96,13 +144,15 @@ function loadEntry(file: string, slug: string, locale?: string): ContentEntry {
96
144
  if (h1?.[1]) data.title = h1[1];
97
145
  }
98
146
  ensureWasm(); // sync, idempotent — instantiate the renderer wasm on first use only
147
+ const { html, headings } = headingIds(toHtmlSync(body));
99
148
  const entry: ContentEntry = {
100
149
  slug,
101
150
  file,
102
151
  data,
103
152
  body,
104
153
  original,
105
- html: toHtmlSync(body),
154
+ html,
155
+ headings,
106
156
  ...(locale ? { locale } : {}),
107
157
  };
108
158
  memo.set(key, { mtime, entry });
@@ -287,6 +337,7 @@ export function generateContentModule(
287
337
  body: e.body,
288
338
  original: e.original,
289
339
  html: e.html,
340
+ headings: e.headings,
290
341
  ...(e.locale ? { locale: e.locale } : {}),
291
342
  });
292
343
  // Default scan: each `content/<name>/` is a collection. Guarded — a sources-only app
@@ -340,8 +391,8 @@ export function generateContentModule(
340
391
  names.push(name);
341
392
  }
342
393
  const entryType = anyLocale
343
- ? "export type ContentEntry = { slug: string; data: Record<string, string | string[]>; body: string; original: string; html: string; locale?: string };\n"
344
- : "export type ContentEntry = { slug: string; data: Record<string, string | string[]>; body: string; original: string; html: string };\n";
394
+ ? "export type ContentEntry = { slug: string; data: Record<string, string | string[]>; body: string; original: string; html: string; headings: Array<{ depth: number; text: string; id: string }>; locale?: string };\n"
395
+ : "export type ContentEntry = { slug: string; data: Record<string, string | string[]>; body: string; original: string; html: string; headings: Array<{ depth: number; text: string; id: string }> };\n";
345
396
  const code =
346
397
  "// AUTO-GENERATED by `june build` — edit content/**/*.md, not this file.\n" + entryType + body;
347
398
  return { code, names };
@@ -0,0 +1,34 @@
1
+ // core-version.ts — the #94 belt-and-braces: fail at POWER-ON when the @junejs/core
2
+ // copy this server resolved is not the one it was built for.
3
+ //
4
+ // The primary fix is @junejs/core being a peerDependency (the app's single resolution
5
+ // always wins), but a nested second copy can still appear (npm legacy peer handling,
6
+ // a stale lockfile, a transitive pin). When it does, the failure without this check
7
+ // is far from the cause and mid-turn ("sink.emit is not a function", first seen in
8
+ // crisp-agent production); with it, construction throws with both versions named.
9
+ //
10
+ // Namespace import + optional read on purpose: a PRE-#94 core has no
11
+ // RUNTIME_API_VERSION export, and a named import of a missing export would die at
12
+ // module link time with an unhelpful SyntaxError — exactly the confusion this
13
+ // module exists to remove.
14
+ import * as coreRuntime from "@junejs/core/agent-runtime";
15
+
16
+ // The contract number this server tree is built against. Bump in lockstep with
17
+ // core's RUNTIME_API_VERSION whenever the server↔core runtime contract changes.
18
+ const EXPECTED_CORE_RUNTIME_API = 7;
19
+
20
+ export function assertCoreRuntimeVersion(surface: string): void {
21
+ checkCoreRuntimeVersion(surface, (coreRuntime as { RUNTIME_API_VERSION?: number }).RUNTIME_API_VERSION);
22
+ }
23
+
24
+ // The pure check, split out so the mismatch paths are unit-testable (the ambient
25
+ // read above can only ever see the workspace's own — matching — core).
26
+ export function checkCoreRuntimeVersion(surface: string, actual: number | undefined, expected: number = EXPECTED_CORE_RUNTIME_API): void {
27
+ if (actual === expected) return;
28
+ throw new Error(
29
+ `${surface}: @junejs/server is built against @junejs/core runtime API v${expected}, ` +
30
+ `but the @junejs/core it resolved provides ${actual === undefined ? "no RUNTIME_API_VERSION (a pre-0.1.1-dev.16 core)" : `v${actual}`} — ` +
31
+ `two copies of @junejs/core are likely installed (one nested under @junejs/server). ` +
32
+ `Align the app's @junejs/core with @junejs/server's peer range, then reinstall/dedupe so one copy remains.`,
33
+ );
34
+ }
package/src/dev.ts CHANGED
@@ -19,20 +19,41 @@ import { findGlobalCss, processCssCached, invalidateCss } from "./css";
19
19
  export type DevServerOptions = {
20
20
  appDir: string;
21
21
  port?: number;
22
+ // The address to bind. Default 127.0.0.1: the dev server is reachable from this
23
+ // machine only (MCP: "when running locally, servers SHOULD bind only to
24
+ // localhost"). "0.0.0.0" opens it to the LAN — `june dev --host`.
25
+ hostname?: string;
22
26
  host?: JuneHost;
23
27
  };
24
28
 
25
- export type DevServer = ServeHandle & { url: string };
29
+ // The Host names a dev server answers /mcp and /api for (#308): localhost and its
30
+ // subdomains, plus IP literals (always allowed — rebinding needs a domain name),
31
+ // plus a named --host and whatever the config allows.
32
+ // The URL to print and return: localhost for loopback or a wildcard bind (it
33
+ // answers there too), else the one address bound — nothing listens on loopback then.
34
+ export function devUrl(hostname: string, port: number): string {
35
+ if (["127.0.0.1", "0.0.0.0", "::", "localhost"].includes(hostname)) return `http://localhost:${port}`;
36
+ return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`;
37
+ }
38
+
39
+ export function devAllowedHosts(configured: readonly string[] | undefined, hostname: string): string[] {
40
+ const hosts = new Set(["localhost", ".localhost", ...(configured ?? [])]);
41
+ if (!/^[\d.]+$|:/.test(hostname)) hosts.add(hostname.toLowerCase()); // a name, not 0.0.0.0 / an IP
42
+ return [...hosts];
43
+ }
44
+
45
+ // stop() also shuts the app down (its agent runtime, #317); await it to know that's done.
46
+ export type DevServer = Omit<ServeHandle, "stop"> & { url: string; stop(force?: boolean): Promise<void> };
26
47
 
27
48
  // A taken default port must not be a dead end in dev — walk forward until a
28
49
  // port binds (the Vite convention). Probed with node:net, which both hosts
29
50
  // implement, so the host interface stays untouched.
30
- async function findFreePort(start: number, tries = 20): Promise<number> {
51
+ async function findFreePort(start: number, hostname: string, tries = 20): Promise<number> {
31
52
  for (let p = start; p < start + tries; p++) {
32
53
  const free = await new Promise<boolean>((resolve) => {
33
54
  const probe = createNetServer();
34
55
  probe.once("error", () => resolve(false));
35
- probe.listen(p, () => probe.close(() => resolve(true)));
56
+ probe.listen(p, hostname, () => probe.close(() => resolve(true)));
36
57
  });
37
58
  if (free) return p;
38
59
  }
@@ -42,10 +63,13 @@ async function findFreePort(start: number, tries = 20): Promise<number> {
42
63
  export async function startDevServer({
43
64
  appDir,
44
65
  port = 3000,
66
+ hostname = "127.0.0.1",
45
67
  host = defaultHost,
46
68
  }: DevServerOptions): Promise<DevServer> {
47
69
  await installAsyncContext();
48
- const config = await loadJuneConfig(appDir);
70
+ const loaded = await loadJuneConfig(appDir);
71
+ // The DNS-rebinding check (#308): a local server is exactly what rebinding targets.
72
+ const config = { ...loaded, agent: { ...loaded.agent, allowedHosts: devAllowedHosts(loaded.agent?.allowedHosts, hostname) } };
49
73
 
50
74
  // Apply pending migrations before serving — dev auto-applies the SAFE ones; a
51
75
  // destructive one is reported and skipped (the server still starts, but the
@@ -57,7 +81,7 @@ export async function startDevServer({
57
81
  const app = createApp({ appDir, config });
58
82
  await app.warmup();
59
83
 
60
- const freePort = await findFreePort(port);
84
+ const freePort = await findFreePort(port, hostname);
61
85
  if (freePort !== port) console.log(`[june] port ${port} is taken → using ${freePort}`);
62
86
  port = freePort;
63
87
 
@@ -65,6 +89,7 @@ export async function startDevServer({
65
89
  // dev/built parity) never sees it. See dev-reload.ts.
66
90
  const handle = host.serve(withLiveReload((req) => app.fetch(req)), {
67
91
  port,
92
+ hostname,
68
93
  earlyHints: () => app.earlyHints(),
69
94
  });
70
95
 
@@ -87,7 +112,15 @@ export async function startDevServer({
87
112
  void processCssCached(appDir).catch(() => {});
88
113
  }
89
114
 
90
- const url = `http://localhost:${handle.port}`;
91
- console.log(`june dev → ${url} (host: ${host.name})`);
92
- return { ...handle, url };
115
+ const url = devUrl(hostname, handle.port);
116
+ const lan = hostname === "127.0.0.1" ? "" : ` · listening on ${hostname}`;
117
+ console.log(`june dev → ${url} (host: ${host.name})${lan}`);
118
+ return {
119
+ ...handle,
120
+ url,
121
+ async stop(force?: boolean) {
122
+ handle.stop(force);
123
+ await app.close();
124
+ },
125
+ };
93
126
  }