@junejs/server 0.2.0 → 0.2.1

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.
@@ -29,6 +29,7 @@ type AdapterEmitContext = {
29
29
  plan: ResourcePlan;
30
30
  defaultName: string;
31
31
  publicFiles?: string[];
32
+ binaryImports?: string[];
32
33
  };
33
34
  interface JuneAdapter {
34
35
  readonly name: string;
@@ -45,6 +46,7 @@ interface JuneAdapter {
45
46
  emit(ctx: AdapterEmitContext): Promise<void>;
46
47
  }
47
48
  declare function hasDurableBinding(text: string, file: string, binding: string, className: string): boolean;
49
+ declare function hasLoaderRule(text: string, file: string, ext: string): boolean | undefined;
48
50
  declare function workers(opts?: {
49
51
  name?: string;
50
52
  domain?: string;
@@ -59,4 +61,4 @@ declare function deno(opts?: {
59
61
  }): JuneAdapter;
60
62
  declare function staticSite(): JuneAdapter;
61
63
  //#endregion
62
- export { ResourcePlan as a, staticSite as c, JuneAdapter as i, vercel as l, AdapterEmitContext as n, deno as o, AdapterEntry as r, hasDurableBinding as s, AdapterCapabilities as t, workers as u };
64
+ export { ResourcePlan as a, hasLoaderRule as c, workers as d, JuneAdapter as i, staticSite as l, AdapterEmitContext as n, deno as o, AdapterEntry as r, hasDurableBinding as s, AdapterCapabilities as t, vercel as u };
package/dist/adapter.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as ResourcePlan, c as staticSite, i as JuneAdapter, l as vercel, n as AdapterEmitContext, o as deno, r as AdapterEntry, s as hasDurableBinding, t as AdapterCapabilities, u as workers } from "./adapter-DcZ-W1-7.js";
2
- export { AdapterCapabilities, AdapterEmitContext, AdapterEntry, JuneAdapter, ResourcePlan, deno, hasDurableBinding, staticSite, vercel, workers };
1
+ import { a as ResourcePlan, c as hasLoaderRule, d as workers, i as JuneAdapter, l as staticSite, n as AdapterEmitContext, o as deno, r as AdapterEntry, s as hasDurableBinding, t as AdapterCapabilities, u as vercel } from "./adapter-D0UnYBuN.js";
2
+ export { AdapterCapabilities, AdapterEmitContext, AdapterEntry, JuneAdapter, ResourcePlan, deno, hasDurableBinding, hasLoaderRule, staticSite, vercel, workers };
package/dist/adapter.js CHANGED
@@ -5,15 +5,7 @@ import { basename, dirname, join } from "node:path";
5
5
  //#region src/adapter.ts
6
6
  function hasDurableBinding(text, file, binding, className) {
7
7
  if (file.endsWith(".toml")) {
8
- const lines = text.split("\n").map((line) => {
9
- let inString = false;
10
- for (let i = 0; i < line.length; i++) {
11
- const ch = line[i];
12
- if (ch === "\"" && line[i - 1] !== "\\") inString = !inString;
13
- else if (ch === "#" && !inString) return line.slice(0, i);
14
- }
15
- return line;
16
- });
8
+ const lines = stripTomlComments(text).split("\n");
17
9
  let inBindings = false;
18
10
  let name;
19
11
  let cls;
@@ -38,6 +30,39 @@ function hasDurableBinding(text, file, binding, className) {
38
30
  return text.includes(className);
39
31
  }
40
32
  }
33
+ function hasLoaderRule(text, file, ext) {
34
+ const names = (glob) => new RegExp(`\\.(${ext}|\\{[^}]*\\b${ext}\\b[^}]*\\})$`, "i").test(glob);
35
+ if (file.endsWith(".toml")) {
36
+ const body = stripTomlComments(text);
37
+ let inRules = false;
38
+ for (const line of body.split("\n")) {
39
+ const t = line.trim();
40
+ if (t.startsWith("[")) {
41
+ inRules = /^\[\[\s*rules\s*\]\]$/.test(t);
42
+ continue;
43
+ }
44
+ const globs = inRules ? t.match(/^globs\s*=\s*\[(.*)\]/) : null;
45
+ if (globs && [...globs[1].matchAll(/"([^"]*)"/g)].some((m) => names(m[1]))) return true;
46
+ }
47
+ return false;
48
+ }
49
+ try {
50
+ return (JSON.parse(stripJsonc(text)).rules ?? []).some((r) => (r.globs ?? []).some(names));
51
+ } catch {
52
+ return;
53
+ }
54
+ }
55
+ function stripTomlComments(text) {
56
+ return text.split("\n").map((line) => {
57
+ let inString = false;
58
+ for (let i = 0; i < line.length; i++) {
59
+ const ch = line[i];
60
+ if (ch === "\"" && line[i - 1] !== "\\") inString = !inString;
61
+ else if (ch === "#" && !inString) return line.slice(0, i);
62
+ }
63
+ return line;
64
+ }).join("\n");
65
+ }
41
66
  function workers(opts) {
42
67
  return {
43
68
  name: "workers",
@@ -61,7 +86,8 @@ function workers(opts) {
61
86
  wrap: (pipelineVar) => `export default withAssets(${pipelineVar}, { link: ${JSON.stringify(linkHeader)} });`
62
87
  };
63
88
  },
64
- async emit({ appRoot, outDir, hasAssets, config, plan, defaultName }) {
89
+ async emit({ appRoot, outDir, hasAssets, config, plan, defaultName, binaryImports }) {
90
+ const dataExts = (binaryImports ?? []).filter((e) => e !== "wasm");
65
91
  const own = ["wrangler.toml", "wrangler.jsonc"].map((f) => join(appRoot, f)).find(existsSync);
66
92
  if (own) {
67
93
  if (plan.agent) {
@@ -71,6 +97,19 @@ function workers(opts) {
71
97
  console.warn(`[june build] durable agent built, but ${basename(own)} doesn't bind it — add:\n${snippet}`);
72
98
  }
73
99
  }
100
+ const text = dataExts.length ? await readFile(own, "utf8") : "";
101
+ const found = dataExts.map((ext) => [ext, hasLoaderRule(text, own, ext)]);
102
+ const unloaded = found.filter(([, has]) => has === false).map(([ext]) => ext);
103
+ const unknown = found.filter(([, has]) => has === void 0).map(([ext]) => ext);
104
+ if (unknown.length) {
105
+ const globs = unknown.map((e) => `"**/*.${e}"`).join(", ");
106
+ console.warn(`[june build] the worker imports ${unknown.map((e) => `.${e}`).join(", ")} files, and ${basename(own)} couldn't be parsed to check for a rule that loads them — make sure it has one, or the deploy fails with "No loader is configured":\n"rules": [{ "type": "Data", "globs": [${globs}], "fallthrough": true }]`);
107
+ }
108
+ if (unloaded.length) {
109
+ const globs = unloaded.map((e) => `"**/*.${e}"`).join(", ");
110
+ const snippet = own.endsWith(".toml") ? `[[rules]]\ntype = "Data"\nglobs = [${globs}]\nfallthrough = true` : `"rules": [{ "type": "Data", "globs": [${globs}], "fallthrough": true }]`;
111
+ console.warn(`[june build] the worker imports ${unloaded.map((e) => `.${e}`).join(", ")} files, but ${basename(own)} has no rule to load them (the deploy will fail with "No loader is configured") — add:\n${snippet}`);
112
+ }
74
113
  return;
75
114
  }
76
115
  const deployCfg = config.deploy;
@@ -99,6 +138,11 @@ function workers(opts) {
99
138
  new_sqlite_classes: [plan.agent.className]
100
139
  }]
101
140
  } : {},
141
+ ...dataExts.length ? { rules: [{
142
+ type: "Data",
143
+ globs: dataExts.map((e) => `**/*.${e}`),
144
+ fallthrough: true
145
+ }] } : {},
102
146
  ...opts?.domain ?? deployCfg?.domain ? { routes: [{
103
147
  pattern: opts?.domain ?? deployCfg?.domain,
104
148
  custom_domain: true
@@ -265,4 +309,4 @@ function staticSite() {
265
309
  };
266
310
  }
267
311
  //#endregion
268
- export { deno, hasDurableBinding, staticSite, vercel, workers };
312
+ export { deno, hasDurableBinding, hasLoaderRule, staticSite, vercel, workers };
@@ -0,0 +1,23 @@
1
+ //#region src/assets.d.ts
2
+ export type AssetRef = {
3
+ name: string;
4
+ sha256: string;
5
+ size: number;
6
+ urls: string[];
7
+ };
8
+ export type AssetCacheOptions = {
9
+ dir: string;
10
+ offline?: boolean;
11
+ urls?: (ref: AssetRef) => string[];
12
+ fetch?: typeof fetch;
13
+ };
14
+ export interface AssetCache {
15
+ (ref: AssetRef): Promise<Uint8Array>;
16
+ cached(ref: AssetRef): Promise<boolean>;
17
+ egress(refs: AssetRef[]): Promise<{
18
+ host: string;
19
+ sends: "nothing";
20
+ }[]>;
21
+ }
22
+ export declare function assetCache(opts: AssetCacheOptions): AssetCache;
23
+ //#endregion
package/dist/assets.js ADDED
@@ -0,0 +1,123 @@
1
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ //#region src/assets.ts
4
+ const STALE_TEMP_MS = 36e5;
5
+ const TEMP_PREFIX = ".june-tmp-";
6
+ function assetCache(opts) {
7
+ const fetcher = opts.fetch ?? fetch;
8
+ const urlsFor = opts.urls ?? ((ref) => ref.urls);
9
+ const inflight = /* @__PURE__ */ new Map();
10
+ let swept;
11
+ const pathOf = (ref) => join(opts.dir, safeName(ref.name));
12
+ async function readMatching(ref) {
13
+ const path = pathOf(ref);
14
+ let info;
15
+ try {
16
+ info = await stat(path);
17
+ } catch (err) {
18
+ if (err.code === "ENOENT") return void 0;
19
+ throw err;
20
+ }
21
+ if (!info.isFile()) throw new Error(`${path} is in the way of ${ref.name}: it isn't a file`);
22
+ if (info.size !== ref.size) return void 0;
23
+ const bytes = new Uint8Array(await readFile(path));
24
+ return await sha256(bytes) === ref.sha256 ? bytes : void 0;
25
+ }
26
+ async function get(ref) {
27
+ pathOf(ref);
28
+ const hit = await readMatching(ref);
29
+ if (hit) return hit;
30
+ if (opts.offline) {
31
+ const state = await stat(pathOf(ref)).then(() => true, () => false) ? `in ${opts.dir} but doesn't match` : `not in ${opts.dir}`;
32
+ throw new Error(`${ref.name} is ${state}, and this cache is offline: copy the file with sha256 ${ref.sha256} (${ref.size} bytes) to ${pathOf(ref)} — it is at ${ref.urls.join(" or ")}`);
33
+ }
34
+ await (swept ??= sweepTemps(opts.dir).catch(() => {}));
35
+ const bytes = await download(ref, urlsFor(ref), fetcher);
36
+ await mkdir(opts.dir, { recursive: true });
37
+ const temp = join(opts.dir, `${TEMP_PREFIX}${crypto.randomUUID()}-${safeName(ref.name)}`);
38
+ try {
39
+ await writeFile(temp, bytes);
40
+ await rename(temp, pathOf(ref));
41
+ } catch (err) {
42
+ await rm(temp, { force: true }).catch(() => {});
43
+ throw err;
44
+ }
45
+ return bytes;
46
+ }
47
+ const cache = ((ref) => {
48
+ const key = `${ref.name}\0${ref.sha256}\0${ref.size}`;
49
+ const running = inflight.get(key);
50
+ if (running) return running;
51
+ const p = get(ref).finally(() => inflight.delete(key));
52
+ inflight.set(key, p);
53
+ return p;
54
+ });
55
+ cache.cached = async (ref) => await readMatching(ref) !== void 0;
56
+ cache.egress = async (refs) => {
57
+ if (opts.offline) return [];
58
+ const missing = (await Promise.all(refs.map(async (r) => await cache.cached(r) ? [] : urlsFor(r)))).flat();
59
+ return [...new Set(missing.map((u) => new URL(u).host))].map((host) => ({
60
+ host,
61
+ sends: "nothing"
62
+ }));
63
+ };
64
+ return cache;
65
+ }
66
+ async function download(ref, urls, fetcher) {
67
+ const failures = [];
68
+ for (const url of urls) try {
69
+ const res = await fetcher(url);
70
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
71
+ const declared = Number(res.headers.get("content-length") ?? NaN);
72
+ if (declared > ref.size) throw new Error(`${declared} bytes declared, expected ${ref.size}`);
73
+ const bytes = await readCapped(res, ref.size);
74
+ const digest = await sha256(bytes);
75
+ if (bytes.length !== ref.size || digest !== ref.sha256) throw new Error(`got ${bytes.length} bytes with sha256 ${digest}, expected ${ref.size} with ${ref.sha256}`);
76
+ return bytes;
77
+ } catch (err) {
78
+ failures.push(`${url}: ${err instanceof Error ? err.message : String(err)}`);
79
+ }
80
+ throw new Error(`${ref.name} could not be fetched (${failures.join("; ") || "no URLs"}). To run without the network, copy the file into the cache directory and pass offline: true`);
81
+ }
82
+ async function readCapped(res, size) {
83
+ if (!res.body) return new Uint8Array(await res.arrayBuffer());
84
+ const out = new Uint8Array(size + 1);
85
+ let length = 0;
86
+ const reader = res.body.getReader();
87
+ for (;;) {
88
+ const { done, value } = await reader.read();
89
+ if (done) break;
90
+ if (length + value.length >= out.length) {
91
+ out.set(value.subarray(0, out.length - length), length);
92
+ length = out.length;
93
+ await reader.cancel();
94
+ break;
95
+ }
96
+ out.set(value, length);
97
+ length += value.length;
98
+ }
99
+ return out.subarray(0, length);
100
+ }
101
+ async function sha256(bytes) {
102
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
103
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
104
+ }
105
+ function safeName(name) {
106
+ if (!name || name === "." || name === ".." || /[\\/]/.test(name) || name.startsWith(TEMP_PREFIX)) throw new Error(`unsafe asset name: ${JSON.stringify(name)}`);
107
+ return name;
108
+ }
109
+ async function sweepTemps(dir) {
110
+ let names;
111
+ try {
112
+ names = await readdir(dir);
113
+ } catch {
114
+ return;
115
+ }
116
+ const now = Date.now();
117
+ await Promise.all(names.filter((n) => n.startsWith(TEMP_PREFIX)).map(async (n) => {
118
+ const s = await stat(join(dir, n)).catch(() => void 0);
119
+ if (s && now - s.mtimeMs > STALE_TEMP_MS) await rm(join(dir, n), { force: true });
120
+ }));
121
+ }
122
+ //#endregion
123
+ export { assetCache };
package/dist/build.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { a as scanAppRoutes, o as scanRoutes, t as RouteEntry } from "./route-scan-BMbBzPIB.js";
2
2
  import { t as generateContent } from "./content-freeze-CyiW2fRK.js";
3
- import { i as workerName, n as normalizeBase, r as resolveDeployAdapter, t as freezeConfig } from "./config-freeze-CyXvNqi2.js";
3
+ import { i as workerName, n as normalizeBase, r as resolveDeployAdapter, t as freezeConfig } from "./config-freeze-r5p-vTWv.js";
4
4
  import { n as buildManifest } from "./manifest-CwTT1zVs.js";
5
5
  //#region src/build.d.ts
6
6
  export type BuildResult = {
package/dist/build.js CHANGED
@@ -243,6 +243,7 @@ ${doClass}`;
243
243
  const entryFile = join(genDir, "worker-entry.tsx");
244
244
  await writeFile(entryFile, entry);
245
245
  const { rolldown } = await import("rolldown");
246
+ const binaryImports = /* @__PURE__ */ new Set();
246
247
  const bundle = await rolldown({
247
248
  input: entryFile,
248
249
  cwd: appRoot,
@@ -255,7 +256,11 @@ ${doClass}`;
255
256
  external: (id) => {
256
257
  if (isBunSpecifier(id)) return true;
257
258
  if (id.startsWith("cloudflare:")) return true;
258
- if (/\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/.test(id)) return true;
259
+ const binary = /\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/i.exec(id);
260
+ if (binary) {
261
+ binaryImports.add(binary[1].toLowerCase());
262
+ return true;
263
+ }
259
264
  return [...adapter.buildExternal ?? [], ...options.external ?? frozen.buildExternal].some((e) => id === e || id.startsWith(`${e}/`));
260
265
  },
261
266
  resolve: { conditionNames: adapter.conditions }
@@ -421,7 +426,8 @@ ${doClass}`;
421
426
  config: fullConfig,
422
427
  plan,
423
428
  defaultName,
424
- publicFiles
429
+ publicFiles,
430
+ binaryImports: [...binaryImports].sort()
425
431
  });
426
432
  if (plan.db) console.log(` ↳ d1 binding "${plan.db.binding}" emitted — set database_id in wrangler.jsonc (wrangler d1 create ${plan.db.databaseName})`);
427
433
  if (plan.agent) console.log(` ↳ durable agent mounted (${plan.agent.className} → env.${plan.agent.binding}; chat at ${frozen.agent.runtime.chat.path})`);
@@ -1,4 +1,4 @@
1
- import { i as JuneAdapter } from "./adapter-DcZ-W1-7.js";
1
+ import { i as JuneAdapter } from "./adapter-D0UnYBuN.js";
2
2
  import { n as WorkerManifest } from "./worker-2qbQjj2c.js";
3
3
  import { JuneConfig } from "@junejs/core/config";
4
4
  import { DocumentConfig } from "@junejs/core/document";
@@ -1,2 +1,2 @@
1
- import { i as workerName, n as normalizeBase, r as resolveDeployAdapter, t as freezeConfig } from "./config-freeze-CyXvNqi2.js";
1
+ import { i as workerName, n as normalizeBase, r as resolveDeployAdapter, t as freezeConfig } from "./config-freeze-r5p-vTWv.js";
2
2
  export { freezeConfig, normalizeBase, resolveDeployAdapter, workerName };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ResourcePlan, c as staticSite, i as JuneAdapter, l as vercel, n as AdapterEmitContext, o as deno, r as AdapterEntry, t as AdapterCapabilities, u as workers } from "./adapter-DcZ-W1-7.js";
1
+ import { a as ResourcePlan, d as workers, i as JuneAdapter, l as staticSite, n as AdapterEmitContext, o as deno, r as AdapterEntry, t as AdapterCapabilities, u as vercel } from "./adapter-D0UnYBuN.js";
2
2
  import { AGENT_MODULE_FILE, AgentDirScan, emitAgentModule, findAgentDir, generateAgentModule, scanAgentDir } from "./agent-compile.js";
3
3
  import { CreateAppOptions, JuneApp, createApp } from "./app.js";
4
4
  import { R2Bucket, localBlob, r2 } from "./blob.js";
@@ -6,7 +6,7 @@ import { o as scanRoutes } from "./route-scan-BMbBzPIB.js";
6
6
  import { t as generateContent } from "./content-freeze-CyiW2fRK.js";
7
7
  import { a as Pipeline, d as createPipeline, n as LayoutComponent, o as PipelineConfig, s as Resolved, u as RouteResolver } from "./pipeline-BHUjBO0G.js";
8
8
  import { n as WorkerManifest, r as createWorker } from "./worker-2qbQjj2c.js";
9
- import { t as freezeConfig } from "./config-freeze-CyXvNqi2.js";
9
+ import { t as freezeConfig } from "./config-freeze-r5p-vTWv.js";
10
10
  import { n as buildManifest } from "./manifest-CwTT1zVs.js";
11
11
  import { BuildResult, juneBuild } from "./build.js";
12
12
  import { loadJuneConfig } from "./config-loader.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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",
@@ -60,6 +60,12 @@
60
60
  "types": "./dist/blob.d.ts",
61
61
  "default": "./dist/blob.js"
62
62
  },
63
+ "./assets": {
64
+ "source": "./src/assets.ts",
65
+ "bun": "./src/assets.ts",
66
+ "types": "./dist/assets.d.ts",
67
+ "default": "./dist/assets.js"
68
+ },
63
69
  "./resources": {
64
70
  "source": "./src/resources.ts",
65
71
  "bun": "./src/resources.ts",
@@ -132,7 +138,7 @@
132
138
  "build": "tsdown"
133
139
  },
134
140
  "dependencies": {
135
- "@junejs/db": "^0.2.0",
141
+ "@junejs/db": "^0.2.1",
136
142
  "@momiji-rs/sparkdown": "^0.0.4",
137
143
  "@resvg/resvg-wasm": "^2.6.2",
138
144
  "lightningcss": "^1.27.0",
@@ -140,7 +146,7 @@
140
146
  "rolldown": "^1.1.0"
141
147
  },
142
148
  "peerDependencies": {
143
- "@junejs/core": "^0.2.0",
149
+ "@junejs/core": "^0.2.1",
144
150
  "react": "^19.2.0",
145
151
  "react-dom": "^19.2.0",
146
152
  "react-server-dom-webpack": "19.3.0",
package/src/adapter.ts CHANGED
@@ -62,6 +62,9 @@ export type AdapterEmitContext = {
62
62
  // copied into outDir/assets. Adapters that build a separate static tier (vercel)
63
63
  // place these there; whole-assets targets (workers/static/deno) ignore it.
64
64
  publicFiles?: string[];
65
+ // Extensions (lowercase, no dot: "wasm", "otf") of the binary files the worker bundle
66
+ // imports and leaves external for the platform's bundler to load.
67
+ binaryImports?: string[];
65
68
  };
66
69
 
67
70
  export interface JuneAdapter {
@@ -97,16 +100,7 @@ export interface JuneAdapter {
97
100
  // (advisory) warning, never a suppressed one. Pure — unit-tested directly.
98
101
  export function hasDurableBinding(text: string, file: string, binding: string, className: string): boolean {
99
102
  if (file.endsWith(".toml")) {
100
- // Strip comments: '#' to end of line, unless inside a double-quoted string.
101
- const lines = text.split("\n").map((line) => {
102
- let inString = false;
103
- for (let i = 0; i < line.length; i++) {
104
- const ch = line[i];
105
- if (ch === '"' && line[i - 1] !== "\\") inString = !inString;
106
- else if (ch === "#" && !inString) return line.slice(0, i);
107
- }
108
- return line;
109
- });
103
+ const lines = stripTomlComments(text).split("\n");
110
104
  let inBindings = false;
111
105
  let name: string | undefined;
112
106
  let cls: string | undefined;
@@ -133,6 +127,52 @@ export function hasDurableBinding(text: string, file: string, binding: string, c
133
127
  }
134
128
  }
135
129
 
130
+ // Does an app-owned wrangler config give `.ext` files a loader? Any rule with a glob ending in
131
+ // the extension (`**/*.otf`, `**/*.{otf,ttf}`) counts, whatever its type. The warning this
132
+ // feeds is advisory, so a glob it can't read may draw a spurious warning, never suppress one;
133
+ // comments are stripped first, so a commented-out rule doesn't count. undefined: the JSONC
134
+ // couldn't be parsed, so the answer is unknown. Pure — unit-tested.
135
+ export function hasLoaderRule(text: string, file: string, ext: string): boolean | undefined {
136
+ const names = (glob: string) => new RegExp(`\\.(${ext}|\\{[^}]*\\b${ext}\\b[^}]*\\})$`, "i").test(glob);
137
+ if (file.endsWith(".toml")) {
138
+ // The globs = [...] arrays of [[rules]] tables, with '#' comments removed.
139
+ const body = stripTomlComments(text);
140
+ let inRules = false;
141
+ for (const line of body.split("\n")) {
142
+ const t = line.trim();
143
+ if (t.startsWith("[")) {
144
+ inRules = /^\[\[\s*rules\s*\]\]$/.test(t);
145
+ continue;
146
+ }
147
+ const globs = inRules ? t.match(/^globs\s*=\s*\[(.*)\]/) : null;
148
+ if (globs && [...globs[1]!.matchAll(/"([^"]*)"/g)].some((m) => names(m[1]!))) return true;
149
+ }
150
+ return false;
151
+ }
152
+ try {
153
+ const parsed = JSON.parse(stripJsonc(text)) as { rules?: { globs?: string[] }[] };
154
+ return (parsed.rules ?? []).some((r) => (r.globs ?? []).some(names));
155
+ } catch {
156
+ return undefined; // unparsable — the caller warns that it couldn't check, never crashes the build
157
+ }
158
+ }
159
+
160
+ // '#' to end of line, unless inside a double-quoted string.
161
+ function stripTomlComments(text: string): string {
162
+ return text
163
+ .split("\n")
164
+ .map((line) => {
165
+ let inString = false;
166
+ for (let i = 0; i < line.length; i++) {
167
+ const ch = line[i];
168
+ if (ch === '"' && line[i - 1] !== "\\") inString = !inString;
169
+ else if (ch === "#" && !inString) return line.slice(0, i);
170
+ }
171
+ return line;
172
+ })
173
+ .join("\n");
174
+ }
175
+
136
176
  // The built-in default. Reproduces exactly what build.ts emitted inline:
137
177
  // withAssets(pipeline) as the entry, and a wrangler.jsonc with the assets
138
178
  // binding (run_worker_first) + optional custom domain.
@@ -154,7 +194,13 @@ export function workers(opts?: { name?: string; domain?: string }): JuneAdapter
154
194
  };
155
195
  },
156
196
 
157
- async emit({ appRoot, outDir, hasAssets, config, plan, defaultName }) {
197
+ async emit({ appRoot, outDir, hasAssets, config, plan, defaultName, binaryImports }) {
198
+ // Wrangler loads .wasm by default (CompiledWasm) and has no loader for the other
199
+ // binaries build.ts leaves external, so a font package's .otf failed the deploy with
200
+ // "No loader is configured" (#379). Each one the bundle imports gets a Data rule: the
201
+ // module is the file's bytes, which is what a font or image package's workerd entry
202
+ // expects.
203
+ const dataExts = (binaryImports ?? []).filter((e) => e !== "wasm");
158
204
  // An app that manages its own wrangler config wins — don't overwrite it.
159
205
  // But a durable agent NEEDS its DO binding: warn when the app-owned config
160
206
  // doesn't BIND the generated class under the name createWorker reads
@@ -173,6 +219,21 @@ export function workers(opts?: { name?: string; domain?: string }): JuneAdapter
173
219
  console.warn(`[june build] durable agent built, but ${basename(own)} doesn't bind it — add:\n${snippet}`);
174
220
  }
175
221
  }
222
+ const text = dataExts.length ? await readFile(own, "utf8") : "";
223
+ const found = dataExts.map((ext) => [ext, hasLoaderRule(text, own, ext)] as const);
224
+ const unloaded = found.filter(([, has]) => has === false).map(([ext]) => ext);
225
+ const unknown = found.filter(([, has]) => has === undefined).map(([ext]) => ext);
226
+ if (unknown.length) {
227
+ const globs = unknown.map((e) => `"**/*.${e}"`).join(", ");
228
+ console.warn(`[june build] the worker imports ${unknown.map((e) => `.${e}`).join(", ")} files, and ${basename(own)} couldn't be parsed to check for a rule that loads them — make sure it has one, or the deploy fails with "No loader is configured":\n"rules": [{ "type": "Data", "globs": [${globs}], "fallthrough": true }]`);
229
+ }
230
+ if (unloaded.length) {
231
+ const globs = unloaded.map((e) => `"**/*.${e}"`).join(", ");
232
+ const snippet = own.endsWith(".toml")
233
+ ? `[[rules]]\ntype = "Data"\nglobs = [${globs}]\nfallthrough = true`
234
+ : `"rules": [{ "type": "Data", "globs": [${globs}], "fallthrough": true }]`;
235
+ console.warn(`[june build] the worker imports ${unloaded.map((e) => `.${e}`).join(", ")} files, but ${basename(own)} has no rule to load them (the deploy will fail with "No loader is configured") — add:\n${snippet}`);
236
+ }
176
237
  return;
177
238
  }
178
239
  const deployCfg = config.deploy;
@@ -214,6 +275,9 @@ export function workers(opts?: { name?: string; domain?: string }): JuneAdapter
214
275
  migrations: [{ tag: "v1", new_sqlite_classes: [plan.agent.className] }],
215
276
  }
216
277
  : {}),
278
+ // Binary files the bundle imports that wrangler has no default loader for.
279
+ // fallthrough: without it wrangler drops its own default Data rule (**/*.bin).
280
+ ...(dataExts.length ? { rules: [{ type: "Data", globs: dataExts.map((e) => `**/*.${e}`), fallthrough: true }] } : {}),
217
281
  // config deploy.domain → a Workers custom domain; without it the
218
282
  // regenerated file would silently drop a hand-attached domain.
219
283
  ...(opts?.domain ?? deployCfg?.domain
package/src/assets.ts ADDED
@@ -0,0 +1,200 @@
1
+ // assetCache — a loader for the large files an `unbundled` entry leaves out of a build (#383):
2
+ // the PDF engine, a CJK font. Given a directory the app owns (a data volume), it serves each
3
+ // file from there and fetches it the first time, so a single binary doesn't carry 40 MiB it
4
+ // may never use:
5
+ //
6
+ // import { assetCache } from "@junejs/server/assets";
7
+ // const load = assetCache({ dir: "/data/june-assets" });
8
+ // typstWasmRenderer({ load, fonts: [notoSansTC({ load })] });
9
+ //
10
+ // What it guarantees, so it can sit under a production app:
11
+ // • a file is returned only when its size and SHA-256 match the ref — on disk or downloaded;
12
+ // a stale or corrupt file is replaced, never used (the packages check again regardless);
13
+ // • a download stops at the ref's size, so a wrong URL can't fill the disk;
14
+ // • the file lands by rename from a temp file in the same directory, so a crash leaves no
15
+ // half-written file under the real name;
16
+ // • one download per file per cache, however many renders ask at once (share one `load`;
17
+ // two caches over the same dir each download, and the rename keeps that safe);
18
+ // • `offline: true` never touches the network: for hosts without egress, the files are
19
+ // copied in beforehand (`dir/<name>`, e.g. dir/typst.wasm), and a missing one is an error
20
+ // that says so.
21
+ //
22
+ // The request is a fixed URL: no document data leaves. `egress(refs)` says which hosts a
23
+ // call would still contact, for a setup check.
24
+ //
25
+ // AssetRef is the shape @junejs/pdf-typst-wasm defines; it is repeated here, so the server
26
+ // depends on no PDF package.
27
+
28
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
29
+ import { join } from "node:path";
30
+
31
+ export type AssetRef = { name: string; sha256: string; size: number; urls: string[] };
32
+
33
+ export type AssetCacheOptions = {
34
+ // Where the files live: one per ref, at dir/<name>.
35
+ dir: string;
36
+ // Never download: serve what is in `dir`, and fail naming the file to copy in when it's
37
+ // missing or doesn't match.
38
+ offline?: boolean;
39
+ // Where to fetch a ref from, in order (default: ref.urls) — a mirror the app controls.
40
+ urls?: (ref: AssetRef) => string[];
41
+ // How to fetch (default: global fetch).
42
+ fetch?: typeof fetch;
43
+ };
44
+
45
+ export interface AssetCache {
46
+ // The file's bytes, from `dir` or downloaded into it; always matching the ref.
47
+ (ref: AssetRef): Promise<Uint8Array>;
48
+ // Whether `dir` already holds a file matching the ref.
49
+ cached(ref: AssetRef): Promise<boolean>;
50
+ // The hosts a call for these refs would contact: [] when all are cached, or when offline.
51
+ egress(refs: AssetRef[]): Promise<{ host: string; sends: "nothing" }[]>;
52
+ }
53
+
54
+ // A temp file older than this is a crashed download; younger ones may be another process's.
55
+ const STALE_TEMP_MS = 60 * 60 * 1000;
56
+ // Temp files carry this prefix, and no asset may: the sweep removes nothing else.
57
+ const TEMP_PREFIX = ".june-tmp-";
58
+
59
+ export function assetCache(opts: AssetCacheOptions): AssetCache {
60
+ const fetcher = opts.fetch ?? fetch;
61
+ const urlsFor = opts.urls ?? ((ref: AssetRef) => ref.urls);
62
+ const inflight = new Map<string, Promise<Uint8Array>>();
63
+ let swept: Promise<void> | undefined;
64
+
65
+ const pathOf = (ref: AssetRef) => join(opts.dir, safeName(ref.name));
66
+
67
+ // The file at dir/<name> when it matches the ref; undefined when it's missing or doesn't.
68
+ // Sized first, so a large wrong file is never read; a read error other than "missing" (a
69
+ // directory in its place, no permission) is the caller's problem, not a cache miss.
70
+ async function readMatching(ref: AssetRef): Promise<Uint8Array | undefined> {
71
+ const path = pathOf(ref);
72
+ let info: Awaited<ReturnType<typeof stat>>;
73
+ try {
74
+ info = await stat(path);
75
+ } catch (err) {
76
+ if ((err as { code?: string }).code === "ENOENT") return undefined;
77
+ throw err;
78
+ }
79
+ if (!info.isFile()) throw new Error(`${path} is in the way of ${ref.name}: it isn't a file`);
80
+ if (info.size !== ref.size) return undefined;
81
+ const bytes = new Uint8Array(await readFile(path));
82
+ return (await sha256(bytes)) === ref.sha256 ? bytes : undefined;
83
+ }
84
+
85
+ async function get(ref: AssetRef): Promise<Uint8Array> {
86
+ pathOf(ref); // an unsafe name fails here, before any read or fetch
87
+ const hit = await readMatching(ref);
88
+ if (hit) return hit;
89
+ if (opts.offline) {
90
+ const there = await stat(pathOf(ref)).then(() => true, () => false);
91
+ const state = there ? `in ${opts.dir} but doesn't match` : `not in ${opts.dir}`;
92
+ throw new Error(
93
+ `${ref.name} is ${state}, and this cache is offline: ` +
94
+ `copy the file with sha256 ${ref.sha256} (${ref.size} bytes) to ${pathOf(ref)} — it is at ${ref.urls.join(" or ")}`,
95
+ );
96
+ }
97
+ // Housekeeping, best effort: a temp it can't remove must not fail this download or any later.
98
+ await (swept ??= sweepTemps(opts.dir).catch(() => {}));
99
+ const bytes = await download(ref, urlsFor(ref), fetcher);
100
+ await mkdir(opts.dir, { recursive: true });
101
+ const temp = join(opts.dir, `${TEMP_PREFIX}${crypto.randomUUID()}-${safeName(ref.name)}`);
102
+ try {
103
+ await writeFile(temp, bytes);
104
+ await rename(temp, pathOf(ref)); // atomic on one filesystem: never a half-written file
105
+ } catch (err) {
106
+ await rm(temp, { force: true }).catch(() => {}); // don't leave it for the next sweep
107
+ throw err;
108
+ }
109
+ return bytes;
110
+ }
111
+
112
+ // Keyed by the ref's whole identity (name, content, size): only identical refs share a
113
+ // download, so each call gets bytes that match its own ref.
114
+ const cache = ((ref: AssetRef) => {
115
+ const key = `${ref.name}\0${ref.sha256}\0${ref.size}`;
116
+ const running = inflight.get(key);
117
+ if (running) return running;
118
+ const p = get(ref).finally(() => inflight.delete(key));
119
+ inflight.set(key, p);
120
+ return p;
121
+ }) as AssetCache;
122
+ cache.cached = async (ref) => (await readMatching(ref)) !== undefined;
123
+ cache.egress = async (refs) => {
124
+ if (opts.offline) return [];
125
+ const missing = (await Promise.all(refs.map(async (r) => ((await cache.cached(r)) ? [] : urlsFor(r))))).flat();
126
+ return [...new Set(missing.map((u) => new URL(u).host))].map((host) => ({ host, sends: "nothing" as const }));
127
+ };
128
+ return cache;
129
+ }
130
+
131
+ // Each URL in turn; the first whose bytes match wins. The error lists every attempt.
132
+ async function download(ref: AssetRef, urls: string[], fetcher: typeof fetch): Promise<Uint8Array> {
133
+ const failures: string[] = [];
134
+ for (const url of urls) {
135
+ try {
136
+ const res = await fetcher(url);
137
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
138
+ const declared = Number(res.headers.get("content-length") ?? NaN);
139
+ if (declared > ref.size) throw new Error(`${declared} bytes declared, expected ${ref.size}`);
140
+ const bytes = await readCapped(res, ref.size);
141
+ const digest = await sha256(bytes);
142
+ if (bytes.length !== ref.size || digest !== ref.sha256) throw new Error(`got ${bytes.length} bytes with sha256 ${digest}, expected ${ref.size} with ${ref.sha256}`);
143
+ return bytes;
144
+ } catch (err) {
145
+ failures.push(`${url}: ${err instanceof Error ? err.message : String(err)}`);
146
+ }
147
+ }
148
+ throw new Error(`${ref.name} could not be fetched (${failures.join("; ") || "no URLs"}). To run without the network, copy the file into the cache directory and pass offline: true`);
149
+ }
150
+
151
+ // The body, cut off one byte past the expected size: enough to know it's wrong, never more.
152
+ async function readCapped(res: Response, size: number): Promise<Uint8Array> {
153
+ if (!res.body) return new Uint8Array(await res.arrayBuffer());
154
+ const out = new Uint8Array(size + 1);
155
+ let length = 0;
156
+ const reader = res.body.getReader();
157
+ for (;;) {
158
+ const { done, value } = await reader.read();
159
+ if (done) break;
160
+ if (length + value.length >= out.length) {
161
+ out.set(value.subarray(0, out.length - length), length);
162
+ length = out.length;
163
+ await reader.cancel();
164
+ break;
165
+ }
166
+ out.set(value, length);
167
+ length += value.length;
168
+ }
169
+ return out.subarray(0, length);
170
+ }
171
+
172
+ async function sha256(bytes: Uint8Array): Promise<string> {
173
+ const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>);
174
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
175
+ }
176
+
177
+ // A ref's name becomes a file name: one path segment, nothing that climbs out of `dir`.
178
+ function safeName(name: string): string {
179
+ if (!name || name === "." || name === ".." || /[\\/]/.test(name) || name.startsWith(TEMP_PREFIX)) throw new Error(`unsafe asset name: ${JSON.stringify(name)}`);
180
+ return name;
181
+ }
182
+
183
+ // Temp files a crashed download left behind; ones still young may belong to another process.
184
+ async function sweepTemps(dir: string): Promise<void> {
185
+ let names: string[];
186
+ try {
187
+ names = await readdir(dir);
188
+ } catch {
189
+ return;
190
+ }
191
+ const now = Date.now();
192
+ await Promise.all(
193
+ names
194
+ .filter((n) => n.startsWith(TEMP_PREFIX))
195
+ .map(async (n) => {
196
+ const s = await stat(join(dir, n)).catch(() => undefined);
197
+ if (s && now - s.mtimeMs > STALE_TEMP_MS) await rm(join(dir, n), { force: true });
198
+ }),
199
+ );
200
+ }
package/src/build.ts CHANGED
@@ -460,6 +460,9 @@ ${doClass}`;
460
460
 
461
461
  // ---- bundle (Rolldown; self-contained ESM for workerd) -------------------
462
462
  const { rolldown } = await import("rolldown");
463
+ // Extensions of the binary files the graph imports and leaves to the platform's bundler
464
+ // (below): the adapter needs them to give each a loader (workers() → wrangler rules, #379).
465
+ const binaryImports = new Set<string>();
463
466
  const bundle = await rolldown({
464
467
  input: entryFile,
465
468
  cwd: appRoot,
@@ -483,8 +486,13 @@ ${doClass}`;
483
486
  if (isBunSpecifier(id)) return true;
484
487
  // workerd runtime modules (the durable-agent entry imports cloudflare:workers).
485
488
  if (id.startsWith("cloudflare:")) return true;
486
- // Binary assets stay external — wrangler's CompiledWasm/Data rules own them.
487
- if (/\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/.test(id)) return true;
489
+ // Binary assets stay external — the platform's bundler loads them (wrangler: a
490
+ // CompiledWasm or Data rule, which workers() emits for what is recorded here).
491
+ const binary = /\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/i.exec(id);
492
+ if (binary) {
493
+ binaryImports.add(binary[1]!.toLowerCase());
494
+ return true;
495
+ }
488
496
  // Merge: adapter.buildExternal (target-specific, e.g. workers-og for the
489
497
  // Workers adapter) + config build.external (user additions). User config
490
498
  // wins additions but can never REMOVE the adapter's own required externals.
@@ -754,7 +762,7 @@ ${doClass}`;
754
762
  // and the class the generated entry exports.
755
763
  ...(agentModule ? { agent: { binding: "AGENT", className: "JuneAgentDO" } } : {}),
756
764
  };
757
- await adapter.emit({ appRoot, outDir, hasAssets, linkHeader, config: fullConfig, plan, defaultName, publicFiles });
765
+ await adapter.emit({ appRoot, outDir, hasAssets, linkHeader, config: fullConfig, plan, defaultName, publicFiles, binaryImports: [...binaryImports].sort() });
758
766
  if (plan.db) {
759
767
  console.log(
760
768
  ` ↳ d1 binding "${plan.db.binding}" emitted — set database_id in wrangler.jsonc (wrangler d1 create ${plan.db.databaseName})`,