@junejs/server 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +46 -0
- package/src/app.ts +155 -0
- package/src/blob.ts +99 -0
- package/src/build.ts +367 -0
- package/src/client-bundle.ts +71 -0
- package/src/config-loader.ts +30 -0
- package/src/content.ts +102 -0
- package/src/db.ts +61 -0
- package/src/deploy.ts +72 -0
- package/src/dev-reload.ts +77 -0
- package/src/dev.ts +41 -0
- package/src/host.ts +234 -0
- package/src/index.ts +42 -0
- package/src/instrumentation.ts +33 -0
- package/src/kv.ts +34 -0
- package/src/negotiate.ts +57 -0
- package/src/pipeline.ts +248 -0
- package/src/resources.ts +28 -0
- package/src/router.ts +263 -0
- package/src/worker.ts +101 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 June.build
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @junejs/server
|
|
2
|
+
|
|
3
|
+
June's host layer: the dev server, the render pipeline, the build (Workers
|
|
4
|
+
bundle), and the host adapters (Bun / Node — detected at runtime). Depends
|
|
5
|
+
inward on the pure `@junejs/core` contract layer; apps usually consume this
|
|
6
|
+
through `@junejs/cli` rather than directly. **Preview (0.0.x): APIs will
|
|
7
|
+
change.**
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createApp, loadJuneConfig } from "@junejs/server";
|
|
11
|
+
|
|
12
|
+
const app = createApp({ appDir, config: await loadJuneConfig(root) });
|
|
13
|
+
// a June app is one Web-standard fetch handler
|
|
14
|
+
export default { fetch: (req: Request) => app.fetch(req) };
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Site & docs: [june.build](https://june.build).
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@junejs/server",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://june.build",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./host": "./src/host.ts",
|
|
11
|
+
"./db": "./src/db.ts",
|
|
12
|
+
"./kv": "./src/kv.ts",
|
|
13
|
+
"./blob": "./src/blob.ts",
|
|
14
|
+
"./config": "./src/config-loader.ts",
|
|
15
|
+
"./content": "./src/content.ts",
|
|
16
|
+
"./router": "./src/router.ts",
|
|
17
|
+
"./instrumentation": "./src/instrumentation.ts",
|
|
18
|
+
"./pipeline": "./src/pipeline.ts",
|
|
19
|
+
"./worker": "./src/worker.ts",
|
|
20
|
+
"./build": "./src/build.ts",
|
|
21
|
+
"./deploy": "./src/deploy.ts",
|
|
22
|
+
"./dev": "./src/dev.ts"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "bun test",
|
|
29
|
+
"typecheck": "tsc --noEmit"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@junejs/core": "0.0.0",
|
|
33
|
+
"marked": "^18.0.5"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"react": "^19.2.0",
|
|
37
|
+
"react-dom": "^19.2.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"rolldown": "^1.1.0"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/junebuild/june"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The dev server app: filesystem-driven discovery wired to the shared render
|
|
2
|
+
// core (pipeline.ts). createApp() returns a Web-standard fetch(); the ONLY
|
|
3
|
+
// dev-specific logic is the RouteResolver (walk app/, import the module, load
|
|
4
|
+
// the layout chain) and the per-request trace wrapper. Everything else —
|
|
5
|
+
// projections, discovery, document, layout wrapping — is the same code the
|
|
6
|
+
// built worker runs, so dev and prod surfaces match by construction.
|
|
7
|
+
//
|
|
8
|
+
// CONFIG IS LOAD-BEARING: site name, agent flags, view transitions, speculation
|
|
9
|
+
// all change observable output (test/config-output.test.ts) — the PoC shipped a
|
|
10
|
+
// dev server that silently ignored june.config.ts for days.
|
|
11
|
+
|
|
12
|
+
import { dirname, resolve } from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import type { ComponentType } from "react";
|
|
15
|
+
|
|
16
|
+
import { isRouteDefinition } from "@junejs/core/route";
|
|
17
|
+
import { resolveAgent, resolveSpeculationRules, type JuneConfig } from "@junejs/core/config";
|
|
18
|
+
import type { DocumentConfig } from "@junejs/core/document";
|
|
19
|
+
import { runWithTrace, type RequestTrace } from "@junejs/core/instrumentation";
|
|
20
|
+
|
|
21
|
+
import { findExtraFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles, type SegmentMatch } from "./router";
|
|
22
|
+
import { createPipeline, type ExtraHandler, type LayoutComponent, type Pipeline, type Resolved } from "./pipeline";
|
|
23
|
+
import { memoizeResources } from "./resources";
|
|
24
|
+
import { findClientEntry, bundleClientToString, CLIENT_SCRIPT_URL } from "./client-bundle";
|
|
25
|
+
|
|
26
|
+
export type CreateAppOptions = {
|
|
27
|
+
appDir: string;
|
|
28
|
+
config?: JuneConfig;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type JuneApp = {
|
|
32
|
+
fetch(request: Request): Promise<Response>;
|
|
33
|
+
// Import every route module once so defineAction() side effects register
|
|
34
|
+
// before the agent surface (discovery / mcp) is queried.
|
|
35
|
+
warmup(): Promise<void>;
|
|
36
|
+
routePaths(): Promise<string[]>;
|
|
37
|
+
earlyHints(): string[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Import a layout file's default export as a layout component (memoized).
|
|
41
|
+
const layoutCache = new Map<string, LayoutComponent>();
|
|
42
|
+
async function loadLayout(file: string): Promise<LayoutComponent | null> {
|
|
43
|
+
const cached = layoutCache.get(file);
|
|
44
|
+
if (cached) return cached;
|
|
45
|
+
const mod = (await import(pathToFileURL(file).href)) as { default?: LayoutComponent };
|
|
46
|
+
if (typeof mod.default !== "function") return null;
|
|
47
|
+
layoutCache.set(file, mod.default);
|
|
48
|
+
return mod.default;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function loadChain(segments: SegmentMatch[]): Promise<LayoutComponent[]> {
|
|
52
|
+
const chain: LayoutComponent[] = [];
|
|
53
|
+
for (const seg of segments) {
|
|
54
|
+
if (!seg.layout) continue;
|
|
55
|
+
const L = await loadLayout(seg.layout);
|
|
56
|
+
if (L) chain.push(L);
|
|
57
|
+
}
|
|
58
|
+
return chain;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions): JuneApp {
|
|
62
|
+
// Normalize once: rolldown resolves the client entry against an absolute cwd,
|
|
63
|
+
// so a relative appDir would double the path. Absolute from here on.
|
|
64
|
+
const appDir = resolve(appDirInput);
|
|
65
|
+
const agent = resolveAgent(config.agent);
|
|
66
|
+
const speculation = config.speculation;
|
|
67
|
+
// app/_client.* present → the dev document loads /client.js and we serve it
|
|
68
|
+
// (bundled lazily, memoized). Detected the same way the build freezes it, so
|
|
69
|
+
// dev and built surfaces agree.
|
|
70
|
+
const clientEntry = findClientEntry(appDir);
|
|
71
|
+
const docConfig: DocumentConfig = {
|
|
72
|
+
site: config.site ?? {},
|
|
73
|
+
speculationRules: resolveSpeculationRules(speculation ?? undefined),
|
|
74
|
+
speculationDelivery: speculation ? speculation.delivery ?? "inline" : "inline",
|
|
75
|
+
viewTransitions: config.viewTransitions ?? true,
|
|
76
|
+
clientScript: clientEntry ? CLIENT_SCRIPT_URL : null,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
let clientBundle: Promise<string> | undefined;
|
|
80
|
+
const serveClient = (): Promise<string> =>
|
|
81
|
+
// 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)));
|
|
84
|
+
|
|
85
|
+
const routePaths = () => listRoutes(appDir, { pageConvention: true });
|
|
86
|
+
|
|
87
|
+
const resources = memoizeResources(config.resources);
|
|
88
|
+
|
|
89
|
+
// The app's not-found.tsx is part of the dev surface too (the build freezes
|
|
90
|
+
// it into the manifest as `notFound`), and importing it is async — so the
|
|
91
|
+
// pipeline is built lazily on first fetch, memoized after.
|
|
92
|
+
let pipelinePromise: Promise<Pipeline> | undefined;
|
|
93
|
+
const getPipeline = (): Promise<Pipeline> => (pipelinePromise ??= buildPipeline());
|
|
94
|
+
async function buildPipeline(): Promise<Pipeline> {
|
|
95
|
+
const { notFound } = await resolveNotFound(appDir, "/");
|
|
96
|
+
let notFoundComponent: ComponentType<{ pathname: string }> | undefined;
|
|
97
|
+
if (notFound) {
|
|
98
|
+
const mod = (await import(pathToFileURL(notFound).href)) as { default?: unknown };
|
|
99
|
+
if (typeof mod.default === "function") {
|
|
100
|
+
notFoundComponent = mod.default as ComponentType<{ pathname: string }>;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
let extra: ExtraHandler | undefined;
|
|
104
|
+
const extraFile = findExtraFile(appDir);
|
|
105
|
+
if (extraFile) {
|
|
106
|
+
const mod = (await import(pathToFileURL(extraFile).href)) as { default?: unknown };
|
|
107
|
+
if (typeof mod.default === "function") extra = mod.default as ExtraHandler;
|
|
108
|
+
}
|
|
109
|
+
return createPipeline({
|
|
110
|
+
extra,
|
|
111
|
+
docConfig,
|
|
112
|
+
agent,
|
|
113
|
+
routeList: routePaths,
|
|
114
|
+
earlyHints: config.earlyHints,
|
|
115
|
+
resources,
|
|
116
|
+
notFoundComponent,
|
|
117
|
+
resolve: async (pathname): Promise<Resolved | null> => {
|
|
118
|
+
const match = await matchRouteTree(appDir, pathname, { pageConvention: true });
|
|
119
|
+
if (!match) return null;
|
|
120
|
+
const mod = (await import(pathToFileURL(match.file).href)) as { default?: unknown };
|
|
121
|
+
if (!isRouteDefinition(mod.default)) return null;
|
|
122
|
+
return { def: mod.default, params: match.params, chain: await loadChain(match.segments) };
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function newTrace(): RequestTrace {
|
|
128
|
+
return { id: crypto.randomUUID(), startedAt: performance.now(), events: [] };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
fetch(request) {
|
|
133
|
+
// Dev serves the islands runtime itself (build ships it as a static
|
|
134
|
+
// asset); bundled on first hit, memoized after.
|
|
135
|
+
if (clientEntry && new URL(request.url).pathname === CLIENT_SCRIPT_URL) {
|
|
136
|
+
return serveClient().then(
|
|
137
|
+
(code) =>
|
|
138
|
+
new Response(code, {
|
|
139
|
+
headers: { "content-type": "text/javascript; charset=utf-8" },
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return runWithTrace(newTrace(), async () => (await getPipeline()).fetch(request));
|
|
144
|
+
},
|
|
145
|
+
async warmup() {
|
|
146
|
+
for (const file of await routeFiles(appDir, { pageConvention: true })) {
|
|
147
|
+
await import(pathToFileURL(file).href).catch((err) => {
|
|
148
|
+
console.error(`[june] failed to load route ${file}`, err);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
routePaths,
|
|
153
|
+
earlyHints: () => config.earlyHints ?? [],
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/blob.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// `blob` resource adapters — the object/file store seam (JuneBlob). The
|
|
2
|
+
// zero-config dev default is a local directory; R2 (and S3-shaped backends) are
|
|
3
|
+
// the deploy adapters. Same binding model as db/kv: declare `resources.blob` in
|
|
4
|
+
// june.config.ts → injected as ctx.blob.
|
|
5
|
+
|
|
6
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
import type { BlobFactory, JuneBlob } from "@junejs/core/resources";
|
|
10
|
+
|
|
11
|
+
// Keys come from app code (often user input) — reject path traversal / absolute
|
|
12
|
+
// paths so a blob key can never escape the store directory.
|
|
13
|
+
function safeKey(key: string): string {
|
|
14
|
+
if (key.startsWith("/") || key.split(/[\\/]/).includes("..")) {
|
|
15
|
+
throw new Error(`unsafe blob key: ${key}`);
|
|
16
|
+
}
|
|
17
|
+
return key;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function localBlobHandle(dir: string): JuneBlob {
|
|
21
|
+
return {
|
|
22
|
+
async get(key) {
|
|
23
|
+
const file = join(dir, safeKey(key)); // validate BEFORE the try, or the
|
|
24
|
+
try {
|
|
25
|
+
return new Uint8Array(await readFile(file)); // catch would swallow it
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
async put(key, data) {
|
|
31
|
+
const file = join(dir, safeKey(key));
|
|
32
|
+
await mkdir(dirname(file), { recursive: true });
|
|
33
|
+
await writeFile(file, typeof data === "string" ? data : Buffer.from(data));
|
|
34
|
+
},
|
|
35
|
+
async delete(key) {
|
|
36
|
+
await rm(join(dir, safeKey(key)), { force: true });
|
|
37
|
+
},
|
|
38
|
+
async list(prefix = "") {
|
|
39
|
+
const out: string[] = [];
|
|
40
|
+
async function walk(d: string, base: string) {
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = await readdir(d, { withFileTypes: true });
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
const rel = base ? `${base}/${e.name}` : e.name;
|
|
49
|
+
if (e.isDirectory()) await walk(join(d, e.name), rel);
|
|
50
|
+
else if (rel.startsWith(prefix)) out.push(rel);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await walk(dir, "");
|
|
54
|
+
return out.sort();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function localBlob(opts: { dir?: string } = {}): BlobFactory {
|
|
60
|
+
const dir = opts.dir ?? ".june/blob";
|
|
61
|
+
return {
|
|
62
|
+
kind: "local",
|
|
63
|
+
open: async () => {
|
|
64
|
+
await mkdir(dir, { recursive: true });
|
|
65
|
+
return localBlobHandle(dir);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- R2 (Cloudflare) --------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
interface R2ObjectBody {
|
|
73
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
74
|
+
}
|
|
75
|
+
export interface R2Bucket {
|
|
76
|
+
get(key: string): Promise<R2ObjectBody | null>;
|
|
77
|
+
put(key: string, data: ArrayBuffer | Uint8Array | string): Promise<unknown>;
|
|
78
|
+
delete(key: string): Promise<void>;
|
|
79
|
+
list(opts?: { prefix?: string }): Promise<{ objects: { key: string }[] }>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function r2(bucket: R2Bucket): BlobFactory {
|
|
83
|
+
const handle: JuneBlob = {
|
|
84
|
+
async get(key) {
|
|
85
|
+
const obj = await bucket.get(key);
|
|
86
|
+
return obj ? new Uint8Array(await obj.arrayBuffer()) : null;
|
|
87
|
+
},
|
|
88
|
+
async put(key, data) {
|
|
89
|
+
await bucket.put(key, data);
|
|
90
|
+
},
|
|
91
|
+
async delete(key) {
|
|
92
|
+
await bucket.delete(key);
|
|
93
|
+
},
|
|
94
|
+
async list(prefix) {
|
|
95
|
+
return (await bucket.list({ prefix })).objects.map((o) => o.key);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
return { kind: "r2", open: async () => handle };
|
|
99
|
+
}
|