Skip to content

Commit c34bf53

Browse files
authored
feat: put each JDK module but java.base in its own image file (#75)
1 parent c2bc0b2 commit c34bf53

10 files changed

Lines changed: 256 additions & 51 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Node, which is how the tests run.
3232
IndexedDB and one VM per about:blank iframe. Keep these working.
3333
- The release form is an npm package of the built files only
3434
(`java-web.js`, `java-web-worker.js`, `node.js` (the Node entry), `cli.js` (the
35-
`java-web` command), `boot.pack`, `jdk/image.jar`,
35+
`java-web` command), `boot.pack`, the `jdk/*.jar` image files,
3636
`jdk/javac.pack`, and OpenJDK's notices in `jdk/legal/`), versioned, with
3737
nothing built at install. A GitHub Actions workflow triggered by a release
3838
publishes it to npm; never publish by hand.

‎README.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,9 @@ and its page API matches CheerpJ's, so existing pages can switch.
1010
pnpm add @project516/java-web
1111
```
1212

13-
Serve `node_modules/@project516/java-web/dist/` as static files (on GitHub
14-
Pages, deploy from Actions: a `gh-pages` branch cannot hold the 120 MB
15-
`jdk/image.jar`; on a project site, `/app/` is `user.github.io`, so files are
16-
under `/app/<repo>/`), then:
13+
Serve `node_modules/@project516/java-web/dist/` as static files (on a GitHub
14+
Pages project site, `/app/` is the site root `user.github.io`, so the page's
15+
files are under `/app/<repo>/`), then:
1716

1817
```html
1918
<script src="java-web.js"></script>

‎docs/browser.md‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@ The npm package holds the built files: `java-web.js`, `java-web-worker.js`,
55
and load `java-web.js` with a classic `<script>` tag. It finds the other files
66
next to itself, or where `cheerpjInit({ runtime: "/path/to/dist/" })` says.
77

8-
- The server should answer range requests for `jdk/image.jar`, the whole JDK
9-
image, so a page reads only the classes it needs. `boot.pack` holds what a
10-
typical start reads, and `jdk/javac.pack` what javac adds. A host that
11-
compresses ranged responses still works, at the cost of one full download of
12-
`jdk/image.jar` (about 120 MB).
13-
- `jdk/image.jar` is over GitHub's 100 MB limit for a file in a git
14-
repository, so a site published by pushing a `gh-pages` branch cannot hold
15-
it. GitHub Pages deployed from Actions (`actions/upload-pages-artifact`)
16-
serves it, Range requests included.
8+
- The server should answer range requests for the `jdk/*.jar` files, the JDK
9+
image, so a page reads only the classes it needs. `jdk/image.jar` holds
10+
`java.base` and the files around the classes, each other module is in
11+
`jdk/<module>.jar`, and `jdk/ct.sym.jar` holds what `javac --release` reads.
12+
`boot.pack` holds what a typical start reads, and `jdk/javac.pack` what javac
13+
adds. A host that compresses ranged responses still works, at the cost of one
14+
full download of each image file a page reads.
15+
- A host can leave out the `jdk/<module>.jar` of a module its pages never
16+
load, and `jdk/ct.sym.jar` when nothing compiles with `--release`. A class of
17+
a left-out module does not load (`NoClassDefFoundError`), and the console
18+
names the missing file. Without `localeData` nothing reads
19+
`jdk/jdk.localedata.jar` (26 MB); javac reads `jdk.compiler`,
20+
`java.compiler` and `jdk.zipfs`.
1721
- The JIT compiles hot Java methods with `new Function`, so a page with a
1822
Content Security Policy needs `'unsafe-eval'` in `script-src`. Without it,
1923
Java still runs, only interpreted.

‎docs/development.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ included, builds the same image, with the Linux class library whose natives
88
java-web implements. The host JDK is the HotSpot reference the tests run, linked
99
at `build/hotspot`. The build links the selected modules into `build/jdk/lib/modules`;
1010
`lib/packages.txt` indexes packages for the boot class loader. `pnpm build`
11-
puts it, with the files around it, in `dist/jdk/image.jar`, read by byte range.
11+
puts it, with the files around it, in `dist/jdk/image.jar` and, for each module
12+
but `java.base`, `dist/jdk/<module>.jar`, all read by byte range.
1213
It packs the byte ranges a typical start reads into `boot.pack`, and those javac
1314
adds into `jdk/javac.pack`. Without `localeData`, the VM leaves `jdk.localedata`
1415
out with `jdk.module.limitmods`, as `--limit-modules` does.

‎scripts/build-web.ts‎

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -173,14 +173,56 @@ const rangeEntries = (ranges: Map<string, [number, number][]>, src: Recorder) =>
173173
[...ranges].flatMap(([p, spans]) => spans.map(([s, e]): [string, Uint8Array] => [`@${s}:${p}`, src.readRange(p, s, e - s)!]));
174174
const inJavacPack = new Set(javacFiles);
175175

176-
// Every java.home file goes in one file, jdk/image.jar, read in ranges:
177-
// lib/modules (the jimage with every class) and the conf/ and lib/ files
178-
// around it. The index says where each file is, so the worker can stat and
179-
// list offline.
176+
// A jimage's resources as runs of one module's, [module, start, end) in image
177+
// order. Empty resources and module-info.class are left out: jlink puts the
178+
// latter with the index, and javac reads them all.
179+
function moduleRuns(image: Uint8Array): [string, number, number][] {
180+
const v = new DataView(image.buffer, image.byteOffset, image.byteLength);
181+
const count = v.getUint32(16, true);
182+
const locations = 28 + count * 8, strings = locations + v.getUint32(20, true);
183+
const resources = strings + v.getUint32(24, true);
184+
const str = (off: number) => {
185+
let s = "";
186+
for (let i = strings + off; image[i]; i++) s += String.fromCharCode(image[i]);
187+
return s;
188+
};
189+
const found: [string, number, number][] = [];
190+
for (let i = 0; i < count; i++) {
191+
let p = locations + v.getUint32(28 + count * 4 + i * 4, true);
192+
const attrs = Array<number>(8).fill(0);
193+
while (image[p] > 7) {
194+
const b = image[p++];
195+
let value = 0;
196+
for (let n = (b & 7) + 1; n > 0; n--) value = value * 256 + image[p++];
197+
attrs[b >> 3] = value;
198+
}
199+
const size = attrs[6] || attrs[7];
200+
if (size && !(!attrs[2] && str(attrs[3]) === "module-info")) found.push([str(attrs[1]), resources + attrs[5], resources + attrs[5] + size]);
201+
}
202+
const runs: [string, number, number][] = [];
203+
for (const [module, start, end] of found.sort((a, b) => a[1] - b[1])) {
204+
const last = runs[runs.length - 1];
205+
if (last?.[0] === module) last[2] = Math.max(last[2], end);
206+
else runs.push([module, start, end]);
207+
}
208+
return runs;
209+
}
210+
211+
// The java.home files go in jdk/image.jar, read in ranges, with the index
212+
// saying where each is, so the worker can stat and list offline. Parts a page
213+
// may never read go in their own files, which a host can leave out: each
214+
// module of lib/modules but java.base in jdk/<module>.jar, and lib/ct.sym
215+
// (javac --release) in jdk/ct.sym.jar.
216+
const linked = new Set(/^MODULES="(.*)"$/m.exec(readFileSync(join(jdk, "release"), "utf8"))![1].split(" "));
217+
const imageSizes = new Map<string, number>();
218+
// appends data to jdk/<name>.jar, returning where it starts there
219+
const put = (name: string, data: Uint8Array) => {
220+
const at = imageSizes.get(name) ?? 0;
221+
(at ? appendFileSync : writeFileSync)(join(dist, "jdk", `${name}.jar`), data);
222+
imageSizes.set(name, at + data.length);
223+
return at;
224+
};
180225
const lines: string[] = [];
181-
const imageFile = join(dist, "jdk", "image.jar");
182-
writeFileSync(imageFile, "");
183-
let imageSize = 0;
184226
const walk = (dir: string) => {
185227
for (const name of readdirSync(dir).sort()) {
186228
const p = join(dir, name);
@@ -190,10 +232,25 @@ const walk = (dir: string) => {
190232
if (st.isDirectory()) {
191233
lines.push(`d ${rel}`);
192234
walk(p);
235+
} else if (rel === "/lib/modules") {
236+
const data = readFileSync(p);
237+
const parts: [number, string][] = [];
238+
let pos = 0;
239+
for (const [module, start, end] of moduleRuns(data)) {
240+
if (module === "java.base" || !linked.has(module)) continue;
241+
if (parts.some(([, m]) => m === module)) throw new Error(`lib/modules has ${module} in more than one place`);
242+
if (start > pos) parts.push([pos, "image"]);
243+
parts.push([start, module]);
244+
pos = end;
245+
}
246+
if (pos < data.length) parts.push([pos, "image"]);
247+
parts.forEach(([start, image], i) => {
248+
const at = put(image, data.subarray(start, i + 1 < parts.length ? parts[i + 1][0] : data.length));
249+
lines.push(start ? `p ${rel} ${start} ${image}:${at}` : `f ${rel} ${st.size} ${image}:${at}`);
250+
});
193251
} else {
194-
lines.push(`f ${rel} ${st.size} image:${imageSize}${inJavacPack.has(rel) ? " javac" : ""}`);
195-
appendFileSync(imageFile, readFileSync(p));
196-
imageSize += st.size;
252+
const image = rel === "/lib/ct.sym" ? "ct.sym" : "image";
253+
lines.push(`f ${rel} ${st.size} ${image}:${put(image, readFileSync(p))}${inJavacPack.has(rel) ? " javac" : ""}`);
197254
}
198255
}
199256
};
@@ -249,4 +306,6 @@ const javacPack = writePack(join(dist, "jdk", "javac.pack"), [
249306
...javacFiles.map((p): [string, Uint8Array] => [p, javacRecorder.read(p)!]),
250307
...rangeEntries(javacRanges, javacRecorder),
251308
]);
252-
console.log(`dist/ ready: boot.pack ${boot}, jdk/javac.pack ${javacPack}, jdk/image.jar ${(imageSize / 2 ** 20).toFixed(0)} MB`);
309+
const mb = (n: number) => `${(n / 2 ** 20).toFixed(0)} MB`;
310+
const others = [...imageSizes].filter(([n]) => n !== "image").reduce((n, [, size]) => n + size, 0);
311+
console.log(`dist/ ready: boot.pack ${boot}, jdk/javac.pack ${javacPack}, jdk/image.jar ${mb(imageSizes.get("image")!)}, ${imageSizes.size - 1} more images ${mb(others)}`);

‎src/browser/http.ts‎

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export interface Pack {
1212
lazy: Map<string, string>;
1313
// path -> the image file holding it and its offset there
1414
offsets: Map<string, { image: string; at: number }>;
15+
// path -> where the bytes from each start on are, for a file split across
16+
// image files, sorted by start; offsets has the first part
17+
parts: Map<string, { start: number; image: string; at: number }[]>;
1518
// path -> parts of a file read in ranges (the JDK image), as [offset, bytes]
1619
// sorted by offset
1720
ranges: Map<string, [number, Uint8Array][]>;
@@ -25,8 +28,10 @@ const utf8 = new TextDecoder();
2528
// Pack layout: repeated [u16 path length][path][u32 size][bytes]. In the boot
2629
// pack, "/index.prefix" holds prefix-coded "d path" and "f path size
2730
// [image:offset] [pack]" lines. A file with an offset is read in ranges from
28-
// <image>.jar; a named pack is <name>.pack next to it and is
29-
// fetched whole the first time one of its files is read. An entry named
31+
// <image>.jar, and a "p path start image:offset" line after it says the
32+
// file's bytes from start on are in another image. A named pack is
33+
// <name>.pack next to it and is fetched whole the first time one of its files
34+
// is read. An entry named
3035
// "@<offset>:<path>" holds part of a file read in ranges, and one named
3136
// "@lazy:<pack>:<path>" lists, as u32 start and end pairs, the parts of it
3237
// that pack holds. Images are .jar
@@ -44,7 +49,7 @@ export function parsePack(buf: ArrayBuffer): Pack {
4449
files.set(path, u8.subarray(p + 4, p + 4 + size));
4550
p += 4 + size;
4651
}
47-
const pack: Pack = { files, index: null, lazy: new Map(), offsets: new Map(), ranges: new Map(), lazyRanges: new Map() };
52+
const pack: Pack = { files, index: null, lazy: new Map(), offsets: new Map(), parts: new Map(), ranges: new Map(), lazyRanges: new Map() };
4853
for (const [p, data] of files) {
4954
if (p[0] !== "@") continue;
5055
files.delete(p);
@@ -169,10 +174,34 @@ export function addIndex(pack: Pack, text: string, prefix = false) {
169174
b = c;
170175
}
171176
pack.lazy.set(path, line.slice(b + 1));
177+
} else if (line[0] === "p") {
178+
const b = line.indexOf(" ", a + 1), colon = line.lastIndexOf(":");
179+
const name = line.slice(b + 1, colon);
180+
let image = images.get(name);
181+
if (image === undefined) images.set(name, (image = `/${name}.jar`));
182+
let parts = pack.parts.get(path);
183+
if (!parts) pack.parts.set(path, (parts = []));
184+
parts.push({ start: +line.slice(a + 1, b), image, at: +line.slice(colon + 1) });
172185
}
173186
}
174187
}
175188

189+
// Where byte off of a file read in ranges is: the image file, the offset
190+
// there, and how many bytes from off on that part of the file holds.
191+
export function imagePart(pack: Pack, path: string, off: number): { image: string; at: number; len: number } | undefined {
192+
const first = pack.offsets.get(path);
193+
if (!first) return undefined;
194+
let { image, at } = first, start = 0, end = pack.index!.get(path)!;
195+
for (const p of pack.parts.get(path) ?? []) {
196+
if (p.start > off) {
197+
end = p.start;
198+
break;
199+
}
200+
({ image, at, start } = p);
201+
}
202+
return { image, at: at + off - start, len: end - off };
203+
}
204+
176205
const BLOCK = 64 << 10;
177206

178207
function encodePath(path: string): string {
@@ -204,8 +233,10 @@ export class HttpBackend implements Backend {
204233
private fetchedPacks = new Set<string>();
205234
// 64 KB blocks of files read in parts, by path
206235
private blocks = new Map<string, Map<number, Uint8Array>>();
236+
// files read in ranges that the server does not have
237+
private missing = new Set<string>();
207238

208-
constructor(base: string, pack: Pack = { files: new Map(), index: null, lazy: new Map(), offsets: new Map(), ranges: new Map(), lazyRanges: new Map() }) {
239+
constructor(base: string, pack: Pack = { files: new Map(), index: null, lazy: new Map(), offsets: new Map(), parts: new Map(), ranges: new Map(), lazyRanges: new Map() }) {
209240
this.base = base.endsWith("/") ? base.slice(0, -1) : base;
210241
this.pack = pack;
211242
}
@@ -290,8 +321,18 @@ export class HttpBackend implements Backend {
290321
return this.readRange(path, off, len);
291322
}
292323
// has() parses the path's module first; image files themselves are not indexed
293-
const at = this.pack.index?.has(path) ? this.pack.offsets.get(path) : undefined;
294-
if (at !== undefined) return this.readRange(at.image, at.at + off, len);
324+
const at = this.pack.index?.has(path) ? imagePart(this.pack, path, off) : undefined;
325+
if (at !== undefined) {
326+
if (len <= at.len || at.len <= 0) return this.readRange(at.image, at.at, len);
327+
const head = this.readRange(at.image, at.at, at.len);
328+
const rest = head && this.readRange(path, off + at.len, len - at.len);
329+
if (!head || !rest) return null;
330+
const out = new Uint8Array(len);
331+
out.set(head);
332+
out.set(rest, head.length);
333+
return out;
334+
}
335+
if (this.missing.has(path)) return null;
295336
let blocks = this.blocks.get(path);
296337
if (!blocks) this.blocks.set(path, (blocks = new Map()));
297338
const first = Math.floor(off / BLOCK), last = Math.floor((off + len - 1) / BLOCK);
@@ -315,6 +356,10 @@ export class HttpBackend implements Backend {
315356
this.blocks.delete(path);
316357
return data.subarray(off, off + len);
317358
}
359+
if (xhr.status === 404) {
360+
this.missing.add(path);
361+
console.warn(`java-web: ${this.base}${path} is missing, so the classes in it do not load`);
362+
}
318363
if (xhr.status !== 206) return null;
319364
if (!rangeOk(xhr, b * BLOCK, data.length)) return this.readWhole(path, off, len);
320365
for (let i = b; i <= end; i++) blocks.set(i, data.subarray((i - b) * BLOCK, (i - b + 1) * BLOCK));
@@ -332,11 +377,13 @@ export class HttpBackend implements Backend {
332377
}
333378

334379
// When ranges of a file come back wrong, reads all of it once: a whole
335-
// response decodes correctly even from a host that compresses it.
380+
// response decodes correctly even from a host that compresses it. A range
381+
// that fails to decode throws as a network error does, so a failed read
382+
// here is taken as the network's, and later reads try again.
336383
private readWhole(path: string, off: number, len: number): Uint8Array | null {
337-
console.warn(`java-web: range reads of ${this.base}${path} came back compressed or wrong, so it is read whole. Serve it without compression (as .jar files are on GitHub Pages).`);
338384
const xhr = this.request("GET", path);
339385
if (!xhr) return null;
386+
console.warn(`java-web: range reads of ${this.base}${path} came back compressed or wrong, so it is read whole. Serve it without compression (as .jar files are on GitHub Pages).`);
340387
const data = new Uint8Array(xhr.response as ArrayBuffer);
341388
this.cache.set(path, data);
342389
this.blocks.delete(path);

‎src/image.ts‎

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// The JDK image as `pnpm build` packages it in dist/, read from disk under
22
// Node: boot.pack holds the index and the files and image ranges a typical
3-
// start reads, and the rest is read at its offset in jdk/image.jar.
3+
// start reads, and the rest is read at its offset in the jdk/*.jar images.
44

55
import * as fs from "node:fs";
66
import * as path from "node:path";
7-
import { listIndex, packedRange, parsePack } from "./browser/http.ts";
7+
import { imagePart, listIndex, packedRange, parsePack } from "./browser/http.ts";
88
import type { Pack } from "./browser/http.ts";
99
import type { Backend, Stat } from "./fs.ts";
1010

@@ -20,6 +20,9 @@ export function imageFd(file: string): number {
2020
return fd;
2121
}
2222

23+
// image files a read failed in, warned about once
24+
const unreadable = new Set<string>();
25+
2326
export class ImageBackend implements Backend {
2427
readonly writable = false;
2528
// this VM's descriptor of each image file
@@ -55,23 +58,28 @@ export class ImageBackend implements Backend {
5558
if (packed) return packed.subarray(off, off + len);
5659
const part = packedRange(this.pack, p, off, len);
5760
if (part) return part;
58-
const at = this.pack.offsets.get(p);
59-
if (!at) return null;
61+
if (!this.pack.offsets.has(p)) return null;
6062
const out = new Uint8Array(len);
61-
let n = 0;
63+
let n = 0, image = "";
6264
try {
63-
let fd = this.fds.get(at.image);
64-
if (fd === undefined) this.fds.set(at.image, (fd = imageFd(path.join(this.dir, "jdk", at.image))));
6565
while (n < len) {
66-
const got = fs.readSync(fd, out, n, len - n, at.at + off + n);
66+
const at = imagePart(this.pack, p, off + n)!;
67+
image = at.image;
68+
let fd = this.fds.get(image);
69+
if (fd === undefined) this.fds.set(image, (fd = imageFd(path.join(this.dir, "jdk", image))));
70+
const got = fs.readSync(fd, out, n, Math.min(len - n, at.len), at.at);
6771
if (got === 0) break;
6872
n += got;
6973
}
70-
} catch {
71-
return null;
74+
} catch {}
75+
if (n === len) return out;
76+
// a missing, short or unreadable image reads as missing rather than cut off
77+
const file = path.join(this.dir, "jdk", image);
78+
if (!unreadable.has(file)) {
79+
unreadable.add(file);
80+
console.warn(`java-web: cannot read ${file}, so the classes in it do not load`);
7281
}
73-
// a short or unreadable image is damaged; the file reads as missing rather than cut off
74-
return n === len ? out : null;
82+
return null;
7583
}
7684

7785
list(p: string): string[] | null {

‎src/jimage.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ export class JImage {
6363
if (actual !== full) return null;
6464
if (attrs[6]) throw new Error("compressed jimage resource");
6565
const data = this.fs.readRange(this.path, this.index.length + attrs[5], attrs[7]);
66-
if (!data || data.length !== attrs[7]) throw new Error(`cannot read image resource: ${full}`);
66+
// a host may leave out the image file of a module its pages never load
67+
if (!data) return null;
68+
if (data.length !== attrs[7]) throw new Error(`cannot read image resource: ${full}`);
6769
return data;
6870
}
6971
}

0 commit comments

Comments
 (0)