@junejs/server 0.0.6 → 0.0.8

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.6",
3
+ "version": "0.0.8",
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",
@@ -29,7 +29,7 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@junejs/core": "0.0.6",
32
+ "@junejs/core": "0.0.8",
33
33
  "marked": "^18.0.5",
34
34
  "rolldown": "^1.1.0"
35
35
  },
package/src/build.ts CHANGED
@@ -341,15 +341,24 @@ ${chains.join("\n")}
341
341
  const pkg = existsSync(pkgPath)
342
342
  ? (JSON.parse(await Bun.file(pkgPath).text()) as { name?: string })
343
343
  : {};
344
+ const deployCfg = (await loadJuneConfig(appRoot)).deploy;
344
345
  await writeFile(
345
346
  join(outDir, "wrangler.jsonc"),
346
347
  JSON.stringify(
347
348
  {
348
- name: (pkg.name ?? basename(appRoot)).replace(/[^a-z0-9-]/gi, "-").toLowerCase(),
349
+ name:
350
+ deployCfg?.name ??
351
+ (pkg.name ?? basename(appRoot)).replace(/[^a-z0-9-]/gi, "-").toLowerCase(),
349
352
  main: "./worker.js",
350
353
  compatibility_date: "2025-01-01",
351
354
  compatibility_flags: ["nodejs_compat"],
352
355
  ...(hasAssets ? { assets: { directory: "./assets" } } : {}),
356
+ // config deploy.domain → a Workers custom domain; without it the
357
+ // generated file regenerating on every build would silently drop a
358
+ // hand-attached domain.
359
+ ...(deployCfg?.domain
360
+ ? { routes: [{ pattern: deployCfg.domain, custom_domain: true }] }
361
+ : {}),
353
362
  },
354
363
  null,
355
364
  2,
package/src/db.ts CHANGED
@@ -3,15 +3,28 @@
3
3
  // by the host, injected onto RouteContext as `ctx.db`. The framework depends on
4
4
  // the JuneDb contract; these adapters (and Juno on top) are swappable.
5
5
 
6
+ import { mkdirSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+
6
9
  import type { DbFactory, JuneDb, RunResult } from "@junejs/core/resources";
7
10
 
8
11
  import { host } from "./host";
9
12
 
10
- // Local SQLite — the zero-config dev default (embedded file or :memory:). Built
11
- // on the demoted host.openDb primitive (the sync bun:/node: driver wrapped async).
13
+ // Local SQLite — the zero-config dev default. A persistent FILE, not :memory:,
14
+ // on purpose: `june dev` restarts the process on every save (the watch
15
+ // supervisor), so an in-memory default would evaporate your dev data at each
16
+ // edit. A file also makes the dev db inspectable with the sqlite3 every
17
+ // machine already has (`sqlite3 .june/dev.sqlite`). Pass ":memory:" explicitly
18
+ // for ephemerality.
12
19
  export function sqlite(opts: { path?: string } = {}): DbFactory {
13
- const path = opts.path ?? ":memory:";
14
- return { kind: "sqlite", open: () => host.openDb(path) };
20
+ const path = opts.path ?? ".june/dev.sqlite";
21
+ return {
22
+ kind: "sqlite",
23
+ open: () => {
24
+ if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
25
+ return host.openDb(path);
26
+ },
27
+ };
15
28
  }
16
29
 
17
30
  // --- D1 (Cloudflare) — the third openDb impl (rebuild-plan Phase 5) ----------
package/src/dev-reload.ts CHANGED
@@ -37,11 +37,24 @@ const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → location.re
37
37
  `;
38
38
 
39
39
  function devEvents(): Response {
40
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
40
41
  const stream = new ReadableStream<Uint8Array>({
41
42
  start(controller) {
42
- // One greeting so the browser fires `open`; then the socket just stays
43
- // up until this process dies — the restart is the next event.
44
- controller.enqueue(new TextEncoder().encode("retry: 300\n\ndata: connected\n\n"));
43
+ // One greeting so the browser fires `open`, then a comment heartbeat:
44
+ // a silent stream gets culled by idle timeouts (hosts, proxies), and a
45
+ // culled stream reads as a restart to the client — which reloads.
46
+ const enc = new TextEncoder();
47
+ controller.enqueue(enc.encode("retry: 300\n\ndata: connected\n\n"));
48
+ heartbeat = setInterval(() => {
49
+ try {
50
+ controller.enqueue(enc.encode(":hb\n\n"));
51
+ } catch {
52
+ clearInterval(heartbeat);
53
+ }
54
+ }, 20_000);
55
+ },
56
+ cancel() {
57
+ clearInterval(heartbeat);
45
58
  },
46
59
  });
47
60
  return new Response(stream, {
package/src/host.ts CHANGED
@@ -108,7 +108,10 @@ function bunHost(): JuneHost {
108
108
  return {
109
109
  name: "bun",
110
110
  serve(handler, opts) {
111
- const server = Bun.serve({ port: opts.port, fetch: handler });
111
+ // idleTimeout 0: Bun's 10s default kills quiet long-lived responses —
112
+ // the dev live-reload SSE most visibly (the browser then reconnects,
113
+ // reads it as a server restart, and reloads the page every 10s).
114
+ const server = Bun.serve({ port: opts.port, fetch: handler, idleTimeout: 0 });
112
115
  return { port: server.port ?? opts.port, stop: (force) => void server.stop(force) };
113
116
  },
114
117
  spawnModule(entry, args, opts) {
package/src/pipeline.ts CHANGED
@@ -95,6 +95,23 @@ function text(body: string, contentType: string, init?: ResponseInit) {
95
95
  return new Response(body, { ...init, headers });
96
96
  }
97
97
 
98
+ // The default favicon: the site name's first character in a rounded square —
99
+ // a plain SVG string, so it needs no fonts, no rasterizer, and works for CJK
100
+ // names as readily as latin ones. Served at /favicon.svg AND /favicon.ico
101
+ // (browsers respect the svg content-type), so no June app 404s its icon.
102
+ function letterFavicon(siteName: string | undefined): Response {
103
+ const first = (siteName ?? "").trim().charAt(0) || "•";
104
+ const letter = first.toUpperCase().replace(/[<>&"']/g, "");
105
+ return text(
106
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">` +
107
+ `<rect width="64" height="64" rx="12" fill="#1d1d1f"/>` +
108
+ `<text x="32" y="32" dy=".36em" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="34" font-weight="600" fill="#fbfbf8">${letter || "•"}</text>` +
109
+ `</svg>\n`,
110
+ "image/svg+xml",
111
+ { headers: { "cache-control": "public, max-age=86400" } },
112
+ );
113
+ }
114
+
98
115
  export function createPipeline(cfg: PipelineConfig): Pipeline {
99
116
  const { docConfig, agent } = cfg;
100
117
  const NotFound = cfg.notFoundComponent ?? DefaultNotFound;
@@ -219,6 +236,15 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
219
236
  if (out) return out;
220
237
  }
221
238
 
239
+ // --- default favicon (after extra so an app can override it) ----------
240
+ if (
241
+ request.method === "GET" &&
242
+ !docConfig.site.icon &&
243
+ (url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico")
244
+ ) {
245
+ return letterFavicon(docConfig.site.name);
246
+ }
247
+
222
248
  // --- routes ----------------------------------------------------------
223
249
  const { target, pathname, speculative } = negotiate(url, request);
224
250
  const resolved = await cfg.resolve(pathname);
package/src/router.ts CHANGED
@@ -67,8 +67,12 @@ function isSpecialFile(file: string) {
67
67
 
68
68
  const isRouteGroup = (name: string) => /^\(.+\)$/.test(name);
69
69
  const isParamDir = (name: string) => /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
70
+ // [[slug]] / [[...slug]] — match like their required forms, and ALSO match the
71
+ // segment being absent (the param is then simply missing from ctx.params).
72
+ const isOptionalDir = (name: string) => /^\[\[([A-Za-z_][A-Za-z0-9_]*)\]\]$/.test(name);
73
+ const isOptionalCatchAllDir = (name: string) => /^\[\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]\]$/.test(name);
70
74
  const isCatchAllDir = (name: string) => /^\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
71
- const paramName = (name: string) => name.replace(/^\[(\.\.\.)?|\]$/g, "");
75
+ const paramName = (name: string) => name.replace(/^\[+(\.\.\.)?|\]+$/g, "");
72
76
 
73
77
  type DirEntry = { name: string; dir: boolean };
74
78
 
@@ -164,7 +168,16 @@ export async function matchRouteTree(
164
168
  if (hit) return hit;
165
169
  }
166
170
 
167
- if (rest.length === 0) return null;
171
+ if (rest.length === 0) {
172
+ // Optional segments match ABSENCE too: descend without consuming and
173
+ // without setting the param.
174
+ for (const e of entries) {
175
+ if (!e.dir || !(isOptionalDir(e.name) || isOptionalCatchAllDir(e.name))) continue;
176
+ const hit = await descend(join(dir, e.name), [], params, segments);
177
+ if (hit) return hit;
178
+ }
179
+ return null;
180
+ }
168
181
  const [head, ...tail] = rest as [string, ...string[]];
169
182
 
170
183
  // 1) exact static dir
@@ -173,9 +186,9 @@ export async function matchRouteTree(
173
186
  const hit = await descend(join(dir, head), tail, params, segments);
174
187
  if (hit) return hit;
175
188
  }
176
- // 2) [param] dirs
189
+ // 2) [param] and [[param]] dirs consume one segment
177
190
  for (const e of entries) {
178
- if (!e.dir || !isParamDir(e.name)) continue;
191
+ if (!e.dir || !(isParamDir(e.name) || isOptionalDir(e.name))) continue;
179
192
  const hit = await descend(
180
193
  join(dir, e.name),
181
194
  tail,
@@ -184,9 +197,9 @@ export async function matchRouteTree(
184
197
  );
185
198
  if (hit) return hit;
186
199
  }
187
- // 3) [...catchAll] dirs consume everything remaining
200
+ // 3) [...catchAll] and [[...catchAll]] dirs consume everything remaining
188
201
  for (const e of entries) {
189
- if (!e.dir || !isCatchAllDir(e.name)) continue;
202
+ if (!e.dir || !(isCatchAllDir(e.name) || isOptionalCatchAllDir(e.name))) continue;
190
203
  const hit = await descend(
191
204
  join(dir, e.name),
192
205
  [],
package/src/worker.ts CHANGED
@@ -41,26 +41,45 @@ export type WorkerManifest = {
41
41
 
42
42
  type Compiled = { regex: RegExp; names: string[]; def: BrandedRoute; pattern: string };
43
43
 
44
- // "/posts/[slug]" | "/docs/[...path]" → matcher
44
+ // "/posts/[slug]" | "/docs/[...path]" | "/notes/[[tag]]" → matcher. Optional
45
+ // segments ([[x]], [[...x]]) wrap slash + capture together so absence matches;
46
+ // a pattern of ONLY optional segments must also match the bare "/".
45
47
  function compilePattern(pattern: string): { regex: RegExp; names: string[] } {
46
48
  const names: string[] = [];
47
- const source = pattern
48
- .split("/")
49
- .map((seg) => {
50
- const ca = seg.match(/^\[\.\.\.(\w+)\]$/);
51
- if (ca) {
52
- names.push(ca[1]!);
53
- return "(.+)";
54
- }
55
- const p = seg.match(/^\[(\w+)\]$/);
56
- if (p) {
57
- names.push(p[1]!);
58
- return "([^/]+)";
59
- }
60
- return seg.replace(/[.*+?^${}()|\\]/g, "\\$&");
61
- })
62
- .join("/");
63
- return { regex: new RegExp(`^${source}$`), names };
49
+ let source = "";
50
+ let allOptional = true;
51
+ for (const seg of pattern.split("/").filter(Boolean)) {
52
+ const oca = seg.match(/^\[\[\.\.\.(\w+)\]\]$/);
53
+ if (oca) {
54
+ names.push(oca[1]!);
55
+ source += "(?:/(.+))?";
56
+ continue;
57
+ }
58
+ const ca = seg.match(/^\[\.\.\.(\w+)\]$/);
59
+ if (ca) {
60
+ names.push(ca[1]!);
61
+ source += "/(.+)";
62
+ allOptional = false;
63
+ continue;
64
+ }
65
+ const op = seg.match(/^\[\[(\w+)\]\]$/);
66
+ if (op) {
67
+ names.push(op[1]!);
68
+ source += "(?:/([^/]+))?";
69
+ continue;
70
+ }
71
+ const p = seg.match(/^\[(\w+)\]$/);
72
+ if (p) {
73
+ names.push(p[1]!);
74
+ source += "/([^/]+)";
75
+ allOptional = false;
76
+ continue;
77
+ }
78
+ source += "/" + seg.replace(/[.*+?^${}()|\\]/g, "\\$&");
79
+ allOptional = false;
80
+ }
81
+ if (source === "") return { regex: /^\/$/, names };
82
+ return { regex: new RegExp(allOptional ? `^(?:${source}|/)$` : `^${source}$`), names };
64
83
  }
65
84
 
66
85
  export function createWorker(manifest: WorkerManifest): { fetch(request: Request): Promise<Response> } {
@@ -91,7 +110,14 @@ export function createWorker(manifest: WorkerManifest): { fetch(request: Request
91
110
  for (const d of dynamic) {
92
111
  const m = pathname.match(d.regex);
93
112
  if (m) {
94
- const params = Object.fromEntries(d.names.map((n, i) => [n, decodeURIComponent(m[i + 1]!)]));
113
+ // Optional segments leave their capture undefined — the param is
114
+ // then absent, matching the dev matcher's semantics.
115
+ const params = Object.fromEntries(
116
+ d.names.flatMap((n, i) => {
117
+ const v = m[i + 1];
118
+ return v === undefined ? [] : [[n, decodeURIComponent(v)]];
119
+ }),
120
+ );
95
121
  return { def: d.def, params, chain: chainFor(d.pattern) };
96
122
  }
97
123
  }