Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,6 @@ jobs:
git -C build/ref/jdk25u sparse-checkout set test/lib test/jdk/lib test/jdk/tools/lib $dirs
node scripts/jtreg/run.ts build/ref/jdk25u --only-passing --jobs 4
- run: pnpm build
- name: Node entry on the built dist/
run: node --expose-gc --test test/image.test.ts
- run: npm publish
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ jobs:
- run: pnpm typecheck
- run: pnpm test
- run: pnpm build
- name: Node entry on the built dist/
run: node --expose-gc --test test/image.test.ts
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ Node, which is how the tests run.
(`natives`, `javaProperties`), `cheerpjRunMain`, `cheerpjRunLibrary`
(static String calls), `cheerpOSAddStringFile`, `cjFileBlob`, `/files/` in
IndexedDB and one VM per about:blank iframe. Keep these working.
- The release form is an npm package of the built browser files only
(`java-web.js`, `java-web-worker.js`, `boot.pack`, `jdk/<module>.jar`,
- The release form is an npm package of the built files only
(`java-web.js`, `java-web-worker.js`, `node.js` (the Node entry and the
`java-web` command), `boot.pack`, `jdk/<module>.jar`,
`jdk/javac.pack`, `jdk/jdk.localedata.txt`, and OpenJDK's notices in `jdk/legal/`), versioned, with
nothing built at install. A GitHub Actions workflow triggered by a release
publishes it to npm; never publish by hand.
Expand All @@ -50,7 +51,8 @@ Node, which is how the tests run.
`font*.ts` are the font natives (scaler, fontconfig, layout) over one TrueType font
- `src/sockets.ts` sockets (`Sock`, POSIX readiness) and the in-VM loopback backend;
`src/net-node.ts` host sockets, DNS and interfaces for Node
- `src/node.ts` Node entry; `scripts/build-jdk.sh` builds `build/jdk`
- `src/node.ts` Node entry, over `build/jdk` or, built as `dist/node.js`, the
packaged image (`src/image.ts`); `scripts/build-jdk.sh` builds `build/jdk`
- `src/http-proxy.ts` the browser's virtual HTTP proxy (`fetch`); `scripts/tls/` the
`javaweb.tls` pass-through TLS provider it relies on for `https:`

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ Serve `node_modules/java-web/dist/` as static files, then:
`cheerpjRunMain`, `cheerpjRunLibrary` (call Java from JS), JS natives and
`/files/` storage work too. See [docs/browser.md](docs/browser.md).

## Use it under Node

```sh
npx java-web -cp classes Main # or: npx java-web -jar app.jar
```

```js
import { runNode } from "java-web";
const code = await runNode({ classpath: ["classes"], mainClass: "Main" });
```

See [docs/node.md](docs/node.md).

## What runs

- Console apps, libraries and javac: every Java SE module, plus `jdk.compiler`,
Expand Down
2 changes: 1 addition & 1 deletion docs/browser.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Using java-web in a page

The npm package holds the built files: `java-web.js`, `java-web-worker.js`,
`boot.pack` and `jdk/`. Serve `node_modules/java-web/dist/` as static files
`boot.pack` and `jdk/` for pages, and `node.js` for Node ([node.md](node.md)). Serve `node_modules/java-web/dist/` as static files
and load `java-web.js` with a classic `<script>` tag. It finds the other files
next to itself, or where `cheerpjInit({ runtime: "/path/to/dist/" })` says.

Expand Down
55 changes: 55 additions & 0 deletions docs/node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Using java-web under Node

The package's Node entry runs Java on Node 23.6 or newer, against the same
packaged JDK image (`dist/boot.pack`, `dist/jdk/`) that pages use. The host file
system is the VFS root, and Java sees the host's environment.

## Command

```sh
npx java-web [options] Main [args]
npx java-web [options] -jar app.jar [args]
```

| Option | Meaning |
|---|---|
| `-cp`, `-classpath`, `--class-path` | class path, `:`-separated (default `.`) |
| `-Dkey=value` | a system property |
| `-ea`, `-da`, `-esa` and the rest | assertions, as with `java` |
| `--enable-preview` | preview features |
| `--add-exports`, `--add-opens`, `--add-reads`, `--add-modules`, `--module-path`, `-p` | module options, as with `java` |
| `--locale-data` | locales other than English (see [support.md](support.md#locales)) |
| `--max-rss MB` | exit when resident memory passes this (default 2048) |
| `--jdk DIR` | an exploded JDK image instead of the packaged one |
| `--debug` | log VM events |

Other `-X` and HotSpot-only options are ignored.

## From JS

```js
import { runNode } from "java-web";

const out = [];
const code = await runNode({
classpath: ["classes"],
mainClass: "Main", // or jar: "app.jar"
args: ["a", "b"],
stdout: (bytes) => out.push(bytes),
});
```

The package ships TypeScript declarations (`dist/node.d.ts`). `runNode`
resolves to the exit code. Its options:

- `classpath`, `mainClass` or `jar`, `args`, `props` (system properties)
- `stdout`, `stderr` (byte callbacks; default the process's), `input` (fixed stdin bytes)
- `env` (what `System.getenv()` returns; default the host's environment)
- `natives`: Java `native` methods in JS, as in the browser ([browser.md](browser.md))
- `assertions` (the flags, for example `["-ea"]`), `enablePreview`, `localeData`
- `hostName` (what `InetAddress.getLocalHost()` reports), `maxRssMb`, `jdk`, `debug`
- `fetch`: sockets stay inside the VM, and `http:` requests go to this `fetch`, as in the browser

`startLibrary(options)` starts a VM in library mode, and `libraryOf(vm)` is its
`lib`, used as `cheerpjRunLibrary`'s result is in the browser. `vm.halt(0)`
stops it.
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,20 @@
"license": "AGPL-3.0-only",
"type": "module",
"exports": {
".": {
"types": "./dist/node.d.ts",
"default": "./dist/node.js"
},
"./dist/*": "./dist/*"
},
"bin": {
"java-web": "dist/node.js"
},
"files": [
"dist/java-web.js",
"dist/java-web-worker.js",
"dist/node.js",
"dist/node.d.ts",
"dist/boot.pack",
"dist/jdk/*.jar",
"dist/jdk/javac.pack",
Expand Down
14 changes: 13 additions & 1 deletion scripts/build-web.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Builds dist/: the browser runtime (java-web.js + worker), the JDK image,
// Builds dist/: the browser runtime (java-web.js + worker), the Node entry, the JDK image,
// a boot pack with the JDK files a typical start needs, a javac pack the
// worker fetches the first time javac runs, and the demo page.
// Run `pnpm jdk` first.
Expand Down Expand Up @@ -33,6 +33,18 @@ for (const [entry, out] of [["api.ts", "java-web.js"], ["worker.ts", "java-web-w
});
}

// the Node entry (runNode and the java-web command), over the packaged image next to it
await build({
entryPoints: [join(root, "src", "node.ts")],
outfile: join(dist, "node.js"),
bundle: true,
platform: "node",
format: "esm",
target: "node23",
banner: { js: "#!/usr/bin/env node" },
});
cpSync(join(root, "types", "node.d.ts"), join(dist, "node.d.ts"));

mkdirSync(join(dist, "jdk"), { recursive: true });

const samples = join(root, "web", "samples");
Expand Down
21 changes: 12 additions & 9 deletions src/browser/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,14 +214,17 @@ export class HttpBackend implements Backend {
}

list(path: string): string[] | null {
const idx = this.pack.index;
if (!idx) return null;
if (idx.get(path) !== -1) return null;
const prefix = path === "/" ? "/" : path + "/";
const out: string[] = [];
for (const p of idx.keys()) {
if (p.startsWith(prefix) && p.length > prefix.length && !p.slice(prefix.length).includes("/")) out.push(p.slice(prefix.length));
}
return out;
return this.pack.index ? listIndex(this.pack.index, path) : null;
}
}

// The names in a directory of a pack index, or null if path is not one.
export function listIndex(idx: Map<string, number>, path: string): string[] | null {
if (idx.get(path) !== -1) return null;
const prefix = path === "/" ? "/" : path + "/";
const out: string[] = [];
for (const p of idx.keys()) {
if (p.startsWith(prefix) && p.length > prefix.length && !p.slice(prefix.length).includes("/")) out.push(p.slice(prefix.length));
}
return out;
}
68 changes: 68 additions & 0 deletions src/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// The JDK image as `pnpm build` packages it in dist/, read from disk under
// Node: boot.pack holds the index and the files a typical start reads, and
// every other file is read at its offset in jdk/<module>.jar.

import * as fs from "node:fs";
import * as path from "node:path";
import { addIndex, listIndex, parsePack } from "./browser/http.ts";
import type { Pack } from "./browser/http.ts";
import type { Backend, Stat } from "./fs.ts";

export class ImageBackend implements Backend {
readonly writable = false;
private readonly dir: string;
private readonly pack: Pack;

// dir holds boot.pack and jdk/; localeData adds jdk.localedata's files
constructor(dir: string, localeData = false) {
this.dir = path.resolve(dir);
if (!fs.existsSync(path.join(this.dir, "jdk", "java.base.jar"))) {
throw new Error(`no packaged JDK image in ${this.dir}: boot.pack needs jdk/ next to it`);
}
const buf = fs.readFileSync(path.join(this.dir, "boot.pack"));
this.pack = parsePack(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
if (localeData) addIndex(this.pack, fs.readFileSync(path.join(this.dir, "jdk", "jdk.localedata.txt"), "utf8"));
}

stat(p: string): Stat | null {
const size = this.pack.index!.get(p);
return size === undefined ? null : { dir: size < 0, size: Math.max(size, 0), mtime: 0 };
}

read(p: string): Uint8Array | null {
const size = this.pack.index!.get(p);
return size === undefined || size < 0 ? null : this.readRange(p, 0, size);
}

readRange(p: string, off: number, len: number): Uint8Array | null {
const size = this.pack.index!.get(p);
if (size === undefined || size < 0) return null;
len = Math.max(0, Math.min(len, size - off));
const packed = this.pack.files.get(p);
if (packed) return packed.subarray(off, off + len);
const at = this.pack.offsets.get(p);
if (!at) return null;
const out = new Uint8Array(len);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
let n = 0;
try {
const fd = fs.openSync(path.join(this.dir, "jdk", at.image), "r");
try {
while (n < len) {
const got = fs.readSync(fd, out, n, len - n, at.at + off + n);
if (got === 0) break;
n += got;
}
} finally {
fs.closeSync(fd);
}
} catch {
return null;
}
// a short or unreadable image is damaged; the file reads as missing rather than cut off
return n === len ? out : null;
}

list(p: string): string[] | null {
return listIndex(this.pack.index!, p);
}
}
25 changes: 19 additions & 6 deletions src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { Backend, Stat } from "./fs.ts";
import { Bridge, nativeTable } from "./bridge.ts";
import { JVM, LM_CLASS, LM_JAR } from "./jvm.ts";
import { hostNatives } from "./hostnatives.ts";
import { ImageBackend } from "./image.ts";
import { NodeNet } from "./net-node.ts";
import type { NetBackend } from "./sockets.ts";

Expand Down Expand Up @@ -116,14 +117,16 @@ export class NodeBackend implements Backend {
}
}

export const defaultJdk = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "build", "jdk");
const here = path.dirname(fileURLToPath(import.meta.url));
export const defaultJdk = path.join(here, "..", "build", "jdk");

export interface NodeRunOptions {
classpath: string[];
mainClass?: string;
jar?: string;
args?: string[];
props?: Record<string, string>;
// an exploded JDK image instead of build/jdk (or, for dist/node.js, the packaged one)
jdk?: string;
debug?: boolean;
stdout?: (b: Uint8Array) => void;
Expand Down Expand Up @@ -158,13 +161,19 @@ function fixedInput(data: Uint8Array) {
};
}

const bridges = new WeakMap<JVM, Bridge>();
const bridges = new WeakMap<object, Bridge>();

// The host file system is the VFS root, with the JDK image mounted at /jdk.
function nodeVm(o: NodeRunOptions): JVM {
const vfs = new VFS();
vfs.mount("/", new NodeBackend("/", true));
vfs.mount("/jdk", new NodeBackend(o.jdk ?? defaultJdk));
// built as dist/node.js, it reads the packaged image next to it
const packaged = !o.jdk && fs.existsSync(path.join(here, "boot.pack"));
Comment thread
project516-review-bot[bot] marked this conversation as resolved.
Resolved
Comment thread
project516-review-bot[bot] marked this conversation as resolved.
Resolved
const jdk = o.jdk ?? defaultJdk;
if (!packaged && !fs.existsSync(path.join(jdk, "lib", "packages.txt"))) {
throw new Error(`no JDK image at ${jdk}: run \`pnpm jdk\` in a checkout, or keep dist/node.js next to boot.pack and jdk/`);
}
vfs.mount("/jdk", packaged ? new ImageBackend(here, o.localeData) : new NodeBackend(jdk));
if (!o.localeData) vfs.hide("/jdk/modules/jdk.localedata");
const toVfs = (p: string) => path.resolve(p);
const classpath = o.jar ? [toVfs(o.jar)] : o.classpath.map(toVfs);
Expand Down Expand Up @@ -230,7 +239,7 @@ export async function startLibrary(o: NodeRunOptions): Promise<JVM> {
return vm;
}

export function libraryOf(vm: JVM): any {
export function libraryOf(vm: object): any {
return bridges.get(vm)!.root(0);
}

Expand Down Expand Up @@ -285,7 +294,11 @@ function main(argv: string[]) {
console.error("usage: java-web [-cp path] [-Dkey=value] Main [args] | -jar app.jar [args]");
process.exit(2);
}
runNode(o).then((code) => process.exit(code));
Promise.resolve().then(() => runNode(o)).then((code) => process.exit(code), (e) => {
console.error(`java-web: ${(e as Error).message}`);
process.exit(1);
});
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
// run as a script, also through the symlink npm puts in node_modules/.bin
if (process.argv[1] && fs.existsSync(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) main(process.argv.slice(2));
Loading
Loading