@junejs/server 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@
11
11
  "./db": "./src/db.ts",
12
12
  "./kv": "./src/kv.ts",
13
13
  "./blob": "./src/blob.ts",
14
+ "./resources": "./src/resources.ts",
14
15
  "./config": "./src/config-loader.ts",
15
16
  "./content": "./src/content.ts",
16
17
  "./router": "./src/router.ts",
@@ -29,9 +30,13 @@
29
30
  "typecheck": "tsc --noEmit"
30
31
  },
31
32
  "dependencies": {
32
- "@junejs/core": "0.0.9",
33
+ "@junejs/core": "0.0.11",
34
+ "@junejs/db": "0.0.11",
33
35
  "marked": "^18.0.5",
34
- "rolldown": "^1.1.0"
36
+ "rolldown": "^1.1.0",
37
+ "postcss": "^8.4.0",
38
+ "postcss-modules": "^6.0.0",
39
+ "lightningcss": "^1.27.0"
35
40
  },
36
41
  "peerDependencies": {
37
42
  "react": "^19.2.0",
package/src/adapter.ts ADDED
@@ -0,0 +1,226 @@
1
+ // Deploy adapters — the seam between June's portable `fetch(Request) → Response`
2
+ // build and a deploy target. June's core artifact (createWorker(manifest)) is
3
+ // already runtime-portable (the cake demo runs the SAME bundle on Workers AND
4
+ // Vercel Edge), so an adapter does PACKAGING + BINDINGS + entry glue, never a
5
+ // re-bundle. See docs/adapter-interface-spec.md.
6
+ //
7
+ // `workers()` is the zero-config default (this file, no import). Other targets
8
+ // are explicit `deploy: { adapter: vercel() }` from their own package.
9
+ import { existsSync } from "node:fs";
10
+ import { cp, copyFile, mkdir, readdir, rm, writeFile } from "node:fs/promises";
11
+ import { basename, join } from "node:path";
12
+
13
+ import type { JuneConfig } from "@junejs/core/config";
14
+
15
+ export type AdapterCapabilities = {
16
+ runtime: "edge" | "node" | "serverless" | "native" | "static";
17
+ // SSE/WebSocket → live-RSC push & server-push HMR. The framework degrades
18
+ // features that need it (with a build notice) when false. (Not yet consumed —
19
+ // the foundation for it.)
20
+ persistentConnections: boolean;
21
+ // Who serves prerendered static files: the platform CDN, the server, or none.
22
+ assets: "platform" | "server" | "none";
23
+ };
24
+
25
+ // What the adapter contributes to the generated worker entry: extra imports and
26
+ // the `export default` that wraps the portable pipeline.
27
+ export type AdapterEntry = {
28
+ imports: string[];
29
+ wrap(pipelineVar: string): string;
30
+ };
31
+
32
+ // What the app's declared resources need from the platform. Derived from
33
+ // config.resources at build time; the adapter turns it into platform bindings
34
+ // (workers() → wrangler `d1_databases` etc.). Target-neutral so a future
35
+ // vercel()/node() adapter maps the SAME plan to its own provisioning.
36
+ export type ResourcePlan = {
37
+ // A declared `db` → a D1 binding named `binding`. The runtime provider
38
+ // (bindWorkerResources) reads env[binding]; keep in sync (default "DB").
39
+ db?: { binding: string; databaseName: string };
40
+ };
41
+
42
+ export type AdapterEmitContext = {
43
+ appRoot: string;
44
+ outDir: string;
45
+ hasAssets: boolean;
46
+ linkHeader: string | null;
47
+ config: JuneConfig;
48
+ // Platform bindings the declared resources need (empty object = none).
49
+ plan: ResourcePlan;
50
+ // package.json name (or sanitized dir name) — the fallback worker/app name.
51
+ defaultName: string;
52
+ };
53
+
54
+ export interface JuneAdapter {
55
+ readonly name: string;
56
+ readonly capabilities: AdapterCapabilities;
57
+ // Rolldown export conditions BAKED into the bundle at build time (the target
58
+ // runtime has none). Target-specific: workers → "workerd", vercel → "edge-light".
59
+ // The same portable graph, resolved for the platform's module surface.
60
+ readonly conditions: string[];
61
+ // Reject a config this target can't honor, BEFORE the expensive build — e.g.
62
+ // Vercel has no D1, so a declared db fails fast with a clear message instead of
63
+ // a cryptic prerender/runtime error. Optional; default is "accept everything".
64
+ validate?(ctx: { plan: ResourcePlan; config: JuneConfig }): void;
65
+ // Wrap the portable pipeline into the platform's entry source.
66
+ entry(opts: { linkHeader: string | null }): AdapterEntry;
67
+ // Write the deploy structure (config files, asset placement, server entry).
68
+ emit(ctx: AdapterEmitContext): Promise<void>;
69
+ }
70
+
71
+ // The built-in default. Reproduces exactly what build.ts emitted inline:
72
+ // withAssets(pipeline) as the entry, and a wrangler.jsonc with the assets
73
+ // binding (run_worker_first) + optional custom domain.
74
+ export function workers(opts?: { name?: string; domain?: string }): JuneAdapter {
75
+ return {
76
+ name: "workers",
77
+ capabilities: { runtime: "edge", persistentConnections: true, assets: "platform" },
78
+ conditions: ["workerd", "edge", "import", "default"],
79
+
80
+ entry({ linkHeader }) {
81
+ return {
82
+ imports: [`import { withAssets } from "@junejs/server/worker";`],
83
+ wrap: (pipelineVar) =>
84
+ `export default withAssets(${pipelineVar}, { link: ${JSON.stringify(linkHeader)} });`,
85
+ };
86
+ },
87
+
88
+ async emit({ appRoot, outDir, hasAssets, config, plan, defaultName }) {
89
+ // An app that manages its own wrangler config wins — don't overwrite it.
90
+ if (existsSync(join(appRoot, "wrangler.toml")) || existsSync(join(appRoot, "wrangler.jsonc"))) {
91
+ return;
92
+ }
93
+ const deployCfg = config.deploy;
94
+ await writeFile(
95
+ join(outDir, "wrangler.jsonc"),
96
+ JSON.stringify(
97
+ {
98
+ name: opts?.name ?? deployCfg?.name ?? defaultName,
99
+ main: "./worker.js",
100
+ compatibility_date: "2025-01-01",
101
+ compatibility_flags: ["nodejs_compat"],
102
+ // run_worker_first: the worker wraps asset serving (withAssets) so
103
+ // prerendered pages still get Link headers + Accept:markdown
104
+ // negotiation. The ASSETS binding lets the worker serve assets.
105
+ ...(hasAssets
106
+ ? { assets: { directory: "./assets", binding: "ASSETS", run_worker_first: true } }
107
+ : {}),
108
+ // A declared `db` resource → a D1 binding. database_id is per-account
109
+ // (run `wrangler d1 create <name>` and paste it); emitted empty so
110
+ // the binding is wired and the one missing value is obvious. With it,
111
+ // the same `sqlite()` declaration runs on D1 at the edge.
112
+ ...(plan.db
113
+ ? {
114
+ d1_databases: [
115
+ {
116
+ binding: plan.db.binding,
117
+ database_name: plan.db.databaseName,
118
+ database_id: "",
119
+ },
120
+ ],
121
+ }
122
+ : {}),
123
+ // config deploy.domain → a Workers custom domain; without it the
124
+ // regenerated file would silently drop a hand-attached domain.
125
+ ...(opts?.domain ?? deployCfg?.domain
126
+ ? { routes: [{ pattern: opts?.domain ?? deployCfg?.domain, custom_domain: true }] }
127
+ : {}),
128
+ },
129
+ null,
130
+ 2,
131
+ ) + "\n",
132
+ );
133
+ },
134
+ };
135
+ }
136
+
137
+ // Vercel target — emits the Build Output API v3 tree (.vercel/output/). June's
138
+ // worker is already a Web-standard fetch handler, so this is packaging, not a
139
+ // re-bundle: the same portable graph, built with edge-light conditions, becomes a
140
+ // Vercel Edge Function. v1 scope: SSR + static + the full agent surface. No db
141
+ // (D1 is Cloudflare-only) — declaring one fails fast; an HTTP-driver db backend
142
+ // for Vercel is a planned follow-up.
143
+ //
144
+ // Routing: only the hashed framework assets (/_june/*) are served statically by
145
+ // the CDN (their exact-path URLs match June's scheme); every page + projection
146
+ // (.md/.json/mcp/llms.txt) renders through the one edge function, so the
147
+ // dual-audience surface is identical to Workers.
148
+ const VERCEL_FUNCTION = "__june"; // → /__june, the catch-all SSR function
149
+
150
+ export function vercel(opts?: { regions?: string[] }): JuneAdapter {
151
+ return {
152
+ name: "vercel",
153
+ capabilities: { runtime: "edge", persistentConnections: false, assets: "platform" },
154
+ conditions: ["edge-light", "worker", "browser", "import", "default"],
155
+
156
+ validate({ plan }) {
157
+ if (plan.db) {
158
+ throw new Error(
159
+ "vercel(): the Vercel adapter has no db backend yet (D1 is Cloudflare-only).\n" +
160
+ " Remove the `db` resource from june.config.ts, or deploy to Workers.\n" +
161
+ " An HTTP-driver db (Neon/Turso) for Vercel is planned.",
162
+ );
163
+ }
164
+ },
165
+
166
+ entry() {
167
+ // No withAssets: the CDN serves /_june/*; this function renders everything
168
+ // else. Resources arrive via process.env (Vercel injects env vars into the
169
+ // edge runtime); there are no platform bindings.
170
+ return {
171
+ imports: [],
172
+ wrap: (pipelineVar) =>
173
+ `const __env = typeof process !== "undefined" && process.env ? process.env : {};\n` +
174
+ `export default (request) => ${pipelineVar}.fetch(request, __env);`,
175
+ };
176
+ },
177
+
178
+ async emit({ appRoot, outDir, hasAssets }) {
179
+ const out = join(appRoot, ".vercel", "output");
180
+ const fnDir = join(out, "functions", `${VERCEL_FUNCTION}.func`);
181
+ await rm(out, { recursive: true, force: true }); // clean — no stale outputs
182
+ await mkdir(fnDir, { recursive: true });
183
+
184
+ // the edge function = June's portable worker bundle. Rolldown code-splits
185
+ // (worker.js imports ./cache-*.js, ./instrumentation-*.js, …), so copy EVERY
186
+ // top-level .js — the chunks must sit beside the entry or Vercel rejects the
187
+ // function ("referencing unsupported modules"). assets/ is handled below.
188
+ const jsFiles = (await readdir(outDir)).filter((f) => f.endsWith(".js"));
189
+ for (const f of jsFiles) await copyFile(join(outDir, f), join(fnDir, f));
190
+ await writeFile(
191
+ join(fnDir, ".vc-config.json"),
192
+ JSON.stringify(
193
+ { runtime: "edge", entrypoint: "worker.js", ...(opts?.regions ? { regions: opts.regions } : {}) },
194
+ null,
195
+ 2,
196
+ ) + "\n",
197
+ );
198
+
199
+ // static: only the hashed framework assets under /_june/ (exact-path URLs)
200
+ if (hasAssets) {
201
+ const src = join(outDir, "assets", "_june");
202
+ if (existsSync(src)) await cp(src, join(out, "static", "_june"), { recursive: true });
203
+ }
204
+
205
+ await writeFile(
206
+ join(out, "config.json"),
207
+ JSON.stringify(
208
+ {
209
+ version: 3,
210
+ routes: [
211
+ {
212
+ src: "^/_june/(.*)$",
213
+ headers: { "cache-control": "public, max-age=31536000, immutable" },
214
+ continue: true,
215
+ },
216
+ { handle: "filesystem" }, // serve any static file by exact path
217
+ { src: "^/.*$", dest: `/${VERCEL_FUNCTION}` }, // everything else → SSR
218
+ ],
219
+ },
220
+ null,
221
+ 2,
222
+ ) + "\n",
223
+ );
224
+ },
225
+ };
226
+ }
package/src/app.ts CHANGED
@@ -22,6 +22,8 @@ import { findExtraFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles,
22
22
  import { createPipeline, type ExtraHandler, type LayoutComponent, type Pipeline, type Resolved } from "./pipeline";
23
23
  import { memoizeResources } from "./resources";
24
24
  import { findClientEntry, bundleClientToString, CLIENT_SCRIPT_URL } from "./client-bundle";
25
+ import { findGlobalCss, processCssCached, STYLES_URL } from "./css";
26
+ import { buildModuleCss, registerCssModules, MODULE_STYLES_URL, type ModuleMaps } from "./css-modules";
25
27
 
26
28
  export type CreateAppOptions = {
27
29
  appDir: string;
@@ -48,6 +50,20 @@ async function loadLayout(file: string): Promise<LayoutComponent | null> {
48
50
  return mod.default;
49
51
  }
50
52
 
53
+ // The nearest loading.tsx up the segment chain (deepest wins) → the streaming
54
+ // Suspense fallback. Memoized like layouts.
55
+ const loadingCache = new Map<string, React.ComponentType>();
56
+ async function nearestLoading(segments: SegmentMatch[]): Promise<React.ComponentType | undefined> {
57
+ const file = [...segments].reverse().find((s) => s.loading)?.loading;
58
+ if (!file) return undefined;
59
+ const cached = loadingCache.get(file);
60
+ if (cached) return cached;
61
+ const mod = (await import(pathToFileURL(file).href)) as { default?: React.ComponentType };
62
+ if (typeof mod.default !== "function") return undefined;
63
+ loadingCache.set(file, mod.default);
64
+ return mod.default;
65
+ }
66
+
51
67
  async function loadChain(segments: SegmentMatch[]): Promise<LayoutComponent[]> {
52
68
  const chain: LayoutComponent[] = [];
53
69
  for (const seg of segments) {
@@ -68,19 +84,29 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
68
84
  // (bundled lazily, memoized). Detected the same way the build freezes it, so
69
85
  // dev and built surfaces agree.
70
86
  const clientEntry = findClientEntry(appDir);
87
+ const cssEntry = findGlobalCss(appDir);
71
88
  const docConfig: DocumentConfig = {
72
89
  site: config.site ?? {},
73
90
  speculationRules: resolveSpeculationRules(speculation ?? undefined),
74
91
  speculationDelivery: speculation ? speculation.delivery ?? "inline" : "inline",
75
92
  viewTransitions: config.viewTransitions ?? true,
93
+ clientRouter: config.clientRouter ?? false,
76
94
  clientScript: clientEntry ? CLIENT_SCRIPT_URL : null,
95
+ styles: cssEntry ? STYLES_URL : null,
77
96
  };
78
97
 
98
+ // CSS Modules: glob + transform app/**/*.module.css ONCE (memoized) → the maps
99
+ // (runtime interceptor + client bundle look these up) + the collected sheet.
100
+ let moduleCssPromise: Promise<{ maps: ModuleMaps; css: string | null }> | undefined;
101
+ const getModuleCss = () => (moduleCssPromise ??= buildModuleCss(appDir, dirname(appDir)));
102
+
79
103
  let clientBundle: Promise<string> | undefined;
80
104
  const serveClient = (): Promise<string> =>
81
105
  // cwd = the app ROOT (appDir's parent) so rolldown resolves node_modules
82
- // from the project, exactly like the build does.
83
- (clientBundle ??= bundleClientToString(clientEntry!, dirname(appDir)));
106
+ // from the project, exactly like the build does. Islands may import .module.css.
107
+ (clientBundle ??= getModuleCss().then(({ maps }) =>
108
+ bundleClientToString(clientEntry!, dirname(appDir), maps),
109
+ ));
84
110
 
85
111
  const routePaths = () => listRoutes(appDir, { pageConvention: true });
86
112
 
@@ -92,6 +118,13 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
92
118
  let pipelinePromise: Promise<Pipeline> | undefined;
93
119
  const getPipeline = (): Promise<Pipeline> => (pipelinePromise ??= buildPipeline());
94
120
  async function buildPipeline(): Promise<Pipeline> {
121
+ // Wire CSS Modules BEFORE any route import: the runtime interceptor must be
122
+ // active so `import "x.module.css"` in a route resolves to the scoped map,
123
+ // and the document must link the collected sheet.
124
+ const { maps, css } = await getModuleCss();
125
+ await registerCssModules(maps);
126
+ if (css !== null) docConfig.moduleStyles = MODULE_STYLES_URL;
127
+
95
128
  const { notFound } = await resolveNotFound(appDir, "/");
96
129
  let notFoundComponent: ComponentType<{ pathname: string }> | undefined;
97
130
  if (notFound) {
@@ -120,7 +153,12 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
120
153
  const mod = await import(pathToFileURL(match.file).href);
121
154
  const def = routeFromModule(mod);
122
155
  if (!def) return null;
123
- return { def, params: match.params, chain: await loadChain(match.segments) };
156
+ return {
157
+ def,
158
+ params: match.params,
159
+ chain: await loadChain(match.segments),
160
+ loading: await nearestLoading(match.segments),
161
+ };
124
162
  },
125
163
  });
126
164
  }
@@ -131,6 +169,30 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
131
169
 
132
170
  return {
133
171
  fetch(request) {
172
+ // Dev serves the global stylesheet (build ships it as a static asset);
173
+ // re-read+processed each hit so edits show on reload, like /client.js.
174
+ if (cssEntry && new URL(request.url).pathname === STYLES_URL) {
175
+ return processCssCached(appDir).then(
176
+ (css) =>
177
+ new Response(css ?? "", {
178
+ headers: { "content-type": "text/css; charset=utf-8" },
179
+ }),
180
+ (err: unknown) => {
181
+ console.error("[june] global.css failed:", err);
182
+ return new Response(`/* global.css failed: ${err instanceof Error ? err.message : String(err)} */\n`, {
183
+ status: 500,
184
+ headers: { "content-type": "text/css; charset=utf-8" },
185
+ });
186
+ },
187
+ );
188
+ }
189
+ // Dev serves the collected CSS-Modules stylesheet (build ships it hashed).
190
+ if (new URL(request.url).pathname === MODULE_STYLES_URL) {
191
+ return getModuleCss().then(
192
+ ({ css }) =>
193
+ new Response(css ?? "", { headers: { "content-type": "text/css; charset=utf-8" } }),
194
+ );
195
+ }
134
196
  // Dev serves the islands runtime itself (build ships it as a static
135
197
  // asset); bundled on first hit, memoized after.
136
198
  if (clientEntry && new URL(request.url).pathname === CLIENT_SCRIPT_URL) {
package/src/blob.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // `blob` resource adapters — the object/file store seam (JuneBlob). The
2
2
  // zero-config dev default is a local directory; R2 (and S3-shaped backends) are
3
3
  // the deploy adapters. Same binding model as db/kv: declare `resources.blob` in
4
- // june.config.ts → injected as ctx.blob.
4
+ // june.config.ts → use the ambient `blob` (decoupled from ctx).
5
5
 
6
6
  import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
7
7
  import { dirname, join } from "node:path";