broapp 0.1.0
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 +46 -0
- package/package.json +64 -0
- package/src/cli/build-binary.ts +98 -0
- package/src/cli/build-page.ts +222 -0
- package/src/cli/config.ts +77 -0
- package/src/cli/dev.ts +158 -0
- package/src/cli/index.ts +17 -0
- package/src/cli/main.ts +183 -0
- package/src/cli/targets.ts +51 -0
- package/src/client/client.ts +203 -0
- package/src/client/index.ts +12 -0
- package/src/host/app.ts +283 -0
- package/src/host/index.ts +25 -0
- package/src/host/open-browser.ts +45 -0
- package/src/host/paths.ts +53 -0
- package/src/host/runtime.ts +198 -0
- package/src/react/hooks.tsx +389 -0
- package/src/react/index.ts +15 -0
- package/src/shared/contract.ts +134 -0
- package/src/shared/errors.ts +134 -0
- package/src/shared/index.ts +36 -0
- package/src/shared/ndjson.ts +72 -0
- package/src/shared/schema.ts +248 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Praveen Vijayan
|
|
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,46 @@
|
|
|
1
|
+
# broapp
|
|
2
|
+
|
|
3
|
+
Runtime and build tooling for local applications made of a Bun host, a browser
|
|
4
|
+
UI, and a [Brobridge](https://github.com/praveenvijayan/brobridge) connection
|
|
5
|
+
between them.
|
|
6
|
+
|
|
7
|
+
You normally get this as a dependency of a generated project:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun create broapp my-app
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Full documentation lives in the
|
|
14
|
+
[repository](https://github.com/praveenvijayan/broapp).
|
|
15
|
+
|
|
16
|
+
## Entry points
|
|
17
|
+
|
|
18
|
+
| Import | What it holds |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| `broapp/shared` | The contract, schemas, error types. Safe for both sides. |
|
|
21
|
+
| `broapp/host` | `createHostApp`, `startApp`, the data directory, the browser launcher. |
|
|
22
|
+
| `broapp/client` | The framework-agnostic browser client. |
|
|
23
|
+
| `broapp/react` | `BroappProvider`, `useOperation`, `useStream`, `useConnection`. |
|
|
24
|
+
| `broapp/build` | `buildPage`, `buildBinary`, `defineConfig`. |
|
|
25
|
+
|
|
26
|
+
Never import `broapp/host` from browser code: it pulls in `node:fs` and
|
|
27
|
+
`Bun.spawn`, and a browser bundle that reaches it fails the build — which is the
|
|
28
|
+
intended outcome.
|
|
29
|
+
|
|
30
|
+
## The command
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
broapp dev [--no-open] Watch, rebuild, restart the host
|
|
34
|
+
broapp build [--target <id>] Build the UI and compile an executable
|
|
35
|
+
broapp build --page Build the UI document only
|
|
36
|
+
broapp build --all-targets Every supported target
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
Bun 1.2 or newer. Peer dependency on React 18 or newer, and only if you use
|
|
42
|
+
`broapp/react`.
|
|
43
|
+
|
|
44
|
+
## Licence
|
|
45
|
+
|
|
46
|
+
MIT.
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "broapp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Build tooling and runtime for local applications made of a Bun host, a browser UI, and a Brobridge connection between them",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/praveenvijayan/broapp#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/praveenvijayan/broapp.git",
|
|
11
|
+
"directory": "packages/broapp"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/praveenvijayan/broapp/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"bun",
|
|
18
|
+
"brobridge",
|
|
19
|
+
"local-first",
|
|
20
|
+
"desktop",
|
|
21
|
+
"browser-ui",
|
|
22
|
+
"single-file-executable"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"bun": ">=1.2.0"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"broapp": "src/cli/main.ts"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"exports": {
|
|
39
|
+
".": "./src/shared/index.ts",
|
|
40
|
+
"./shared": "./src/shared/index.ts",
|
|
41
|
+
"./host": "./src/host/index.ts",
|
|
42
|
+
"./client": "./src/client/index.ts",
|
|
43
|
+
"./react": "./src/react/index.ts",
|
|
44
|
+
"./build": "./src/cli/index.ts",
|
|
45
|
+
"./package.json": "./package.json"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@brobridgejs/client": "^0.2.1",
|
|
49
|
+
"@brobridgejs/core": "^0.2.1",
|
|
50
|
+
"brobridge": "^0.2.1"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"react": ">=18"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"react": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/react": "^19.0.0",
|
|
62
|
+
"react": "^19.0.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiling the host into a single-file executable.
|
|
3
|
+
*
|
|
4
|
+
* `bun build --compile` bundles the host entry point together with the Bun
|
|
5
|
+
* runtime. The UI arrives through an ordinary import of the built HTML
|
|
6
|
+
* document with `{ type: "text" }`, which Bun inlines into the bundle — so
|
|
7
|
+
* there is no asset directory to ship, nothing to resolve at runtime relative
|
|
8
|
+
* to the executable, and nothing to break when the binary is moved.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, stat } from 'node:fs/promises';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import type { Target } from './targets.ts';
|
|
14
|
+
import { currentTarget, findTarget, TARGETS } from './targets.ts';
|
|
15
|
+
|
|
16
|
+
/** Options for {@link buildBinary}. */
|
|
17
|
+
export interface BuildBinaryOptions {
|
|
18
|
+
/** Host entry point, e.g. `src/host/main.ts`. */
|
|
19
|
+
readonly entry: string;
|
|
20
|
+
/** Executable base name; the target suffix and `.exe` are added. */
|
|
21
|
+
readonly name: string;
|
|
22
|
+
/** Output directory. Default `release`. */
|
|
23
|
+
readonly outDir?: string;
|
|
24
|
+
/** Target ids. Default: the current platform only. */
|
|
25
|
+
readonly targets?: readonly string[];
|
|
26
|
+
/** Project root. Default `process.cwd()`. */
|
|
27
|
+
readonly root?: string;
|
|
28
|
+
/** Default `true`. */
|
|
29
|
+
readonly minify?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Compile to bytecode. Default `true`.
|
|
32
|
+
*
|
|
33
|
+
* It cuts startup time noticeably and costs binary size. Turn it off if a
|
|
34
|
+
* dependency misbehaves under it.
|
|
35
|
+
*/
|
|
36
|
+
readonly bytecode?: boolean;
|
|
37
|
+
/** Append the target id to the filename. Default `true` for multi-target builds. */
|
|
38
|
+
readonly suffixTarget?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One produced executable. */
|
|
42
|
+
export interface BuiltBinary {
|
|
43
|
+
readonly target: Target;
|
|
44
|
+
readonly path: string;
|
|
45
|
+
readonly bytes: number;
|
|
46
|
+
/** True when this binary can run on the machine that built it. */
|
|
47
|
+
readonly native: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Compile one executable per requested target. */
|
|
51
|
+
export async function buildBinary(options: BuildBinaryOptions): Promise<BuiltBinary[]> {
|
|
52
|
+
const root = options.root ?? process.cwd();
|
|
53
|
+
const outDir = resolve(root, options.outDir ?? 'release');
|
|
54
|
+
const entry = resolve(root, options.entry);
|
|
55
|
+
const native = currentTarget();
|
|
56
|
+
|
|
57
|
+
const requested = options.targets ?? [native.id];
|
|
58
|
+
const targets = requested.map((id) => {
|
|
59
|
+
const target = findTarget(id);
|
|
60
|
+
if (target === undefined) {
|
|
61
|
+
throw new Error(`unknown target ${JSON.stringify(id)}. Supported: ${TARGETS.map((t) => t.id).join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
return target;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const suffix = options.suffixTarget ?? targets.length > 1;
|
|
67
|
+
await mkdir(outDir, { recursive: true });
|
|
68
|
+
|
|
69
|
+
const built: BuiltBinary[] = [];
|
|
70
|
+
for (const target of targets) {
|
|
71
|
+
const filename = `${options.name}${suffix ? `-${target.id}` : ''}${target.ext}`;
|
|
72
|
+
const outFile = join(outDir, filename);
|
|
73
|
+
await mkdir(dirname(outFile), { recursive: true });
|
|
74
|
+
|
|
75
|
+
const argv = [
|
|
76
|
+
'bun',
|
|
77
|
+
'build',
|
|
78
|
+
'--compile',
|
|
79
|
+
`--target=bun-${target.id}`,
|
|
80
|
+
...(options.minify === false ? [] : ['--minify']),
|
|
81
|
+
...(options.bytecode === false ? [] : ['--bytecode']),
|
|
82
|
+
entry,
|
|
83
|
+
'--outfile',
|
|
84
|
+
outFile,
|
|
85
|
+
];
|
|
86
|
+
const child = Bun.spawn(argv, { cwd: root, stdout: 'inherit', stderr: 'inherit' });
|
|
87
|
+
if ((await child.exited) !== 0) {
|
|
88
|
+
throw new Error(`compilation failed for ${target.id}`);
|
|
89
|
+
}
|
|
90
|
+
built.push({
|
|
91
|
+
target,
|
|
92
|
+
path: outFile,
|
|
93
|
+
bytes: (await stat(outFile)).size,
|
|
94
|
+
native: target.id === native.id,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return built;
|
|
98
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundling the browser UI into one self-contained HTML document.
|
|
3
|
+
*
|
|
4
|
+
* Brobridge's route table is exactly `/`, `/ws` and `/rpc` — there is no
|
|
5
|
+
* static file route, by design, so nothing about the bridge ever touches the
|
|
6
|
+
* filesystem in response to a request. That constraint decides the whole
|
|
7
|
+
* packaging story: the UI must be *one document*, with its CSS and JavaScript
|
|
8
|
+
* inline, because a `<script src="/assets/app.js">` would 404.
|
|
9
|
+
*
|
|
10
|
+
* That turns out to suit a compiled single-file application well. One document
|
|
11
|
+
* is one `import … with { type: "text" }`, which Bun embeds in the executable,
|
|
12
|
+
* and there is no asset manifest to keep in step.
|
|
13
|
+
*
|
|
14
|
+
* Two properties are enforced here rather than trusted:
|
|
15
|
+
*
|
|
16
|
+
* 1. No off-origin references. A local application that pulls a font or a
|
|
17
|
+
* script from a CDN stops working offline and hands a third party a view of
|
|
18
|
+
* when the user runs it. The check is a scan of the built document.
|
|
19
|
+
* 2. A restrictive `Content-Security-Policy`, with the inline script pinned by
|
|
20
|
+
* SHA-256 hash rather than allowed by `'unsafe-inline'`. The hash is
|
|
21
|
+
* computed from the actual bundle, so it cannot drift. Brobridge sets no
|
|
22
|
+
* CSP of its own (it sets `Referrer-Policy`, `Cache-Control: no-store` and
|
|
23
|
+
* `X-Content-Type-Options`), and it only lets a host application supply a
|
|
24
|
+
* body, so the policy travels in a `<meta http-equiv>`.
|
|
25
|
+
*/
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { dirname, resolve } from 'node:path';
|
|
28
|
+
import { mkdir } from 'node:fs/promises';
|
|
29
|
+
|
|
30
|
+
/** Options for {@link buildPage}. */
|
|
31
|
+
export interface BuildPageOptions {
|
|
32
|
+
/** Browser entry point, e.g. `src/ui/main.tsx`. */
|
|
33
|
+
readonly entry: string;
|
|
34
|
+
/** HTML shell containing `<!--BROAPP_HEAD-->` and `<!--BROAPP_BODY-->`. */
|
|
35
|
+
readonly template: string;
|
|
36
|
+
/** Where to write the document. */
|
|
37
|
+
readonly outFile: string;
|
|
38
|
+
/** Default `true`. Off makes a development bundle readable in devtools. */
|
|
39
|
+
readonly minify?: boolean;
|
|
40
|
+
/** Project root the entry and template are resolved against. Default `process.cwd()`. */
|
|
41
|
+
readonly root?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Extra CSP sources, merged into the generated policy.
|
|
44
|
+
*
|
|
45
|
+
* `connect-src` already covers the bridge's own origin over `http:` and
|
|
46
|
+
* `ws:`. Adding a remote origin here defeats the offline guarantee, so the
|
|
47
|
+
* off-origin scan still runs.
|
|
48
|
+
*/
|
|
49
|
+
readonly csp?: Readonly<Record<string, readonly string[]>>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What {@link buildPage} produced. */
|
|
53
|
+
export interface BuildPageResult {
|
|
54
|
+
readonly outFile: string;
|
|
55
|
+
readonly bytes: number;
|
|
56
|
+
readonly scriptHash: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The base policy.
|
|
61
|
+
*
|
|
62
|
+
* `default-src 'none'` means every directive not listed below is denied, so a
|
|
63
|
+
* directive nobody thought about fails closed. `connect-src` allows the page's
|
|
64
|
+
* own origin plus `ws:`/`wss:` — the WebSocket URL has a different scheme from
|
|
65
|
+
* the document's, and `'self'` does not cover it in any current browser.
|
|
66
|
+
*/
|
|
67
|
+
function policy(scriptHash: string, styleHash: string | null, extra: BuildPageOptions['csp']): string {
|
|
68
|
+
const directives: Record<string, string[]> = {
|
|
69
|
+
'default-src': ["'none'"],
|
|
70
|
+
'script-src': [`'sha256-${scriptHash}'`],
|
|
71
|
+
// A hash of the empty string is not a policy — it declares that a
|
|
72
|
+
// stylesheet which is not there is permitted. With no CSS, say `'none'`.
|
|
73
|
+
'style-src': styleHash === null ? ["'none'"] : [`'sha256-${styleHash}'`],
|
|
74
|
+
'img-src': ["'self'", 'data:'],
|
|
75
|
+
'font-src': ["'self'", 'data:'],
|
|
76
|
+
// The bridge's WebSocket URL has a `ws:` scheme while the document has
|
|
77
|
+
// `http:`. CSP Level 3 says `'self'` covers that upgrade, but not every
|
|
78
|
+
// engine implements it, so the one host the bridge can bind is named
|
|
79
|
+
// explicitly. The port is ephemeral and unknowable at build time, hence
|
|
80
|
+
// `:*`.
|
|
81
|
+
//
|
|
82
|
+
// Only `127.0.0.1`. Broapp does not forward Brobridge's `host` option, so
|
|
83
|
+
// the authority is always that — and Brobridge's trust fence refuses any
|
|
84
|
+
// request whose `Host` header names something else. Listing `localhost` or
|
|
85
|
+
// `[::1]` would widen the policy to cover origins the application can
|
|
86
|
+
// never actually be served from.
|
|
87
|
+
'connect-src': ["'self'", 'ws://127.0.0.1:*'],
|
|
88
|
+
'base-uri': ["'none'"],
|
|
89
|
+
'form-action': ["'none'"],
|
|
90
|
+
// No `frame-ancestors`. It is one of the three directives the CSP
|
|
91
|
+
// specification requires user agents to *ignore* in a `<meta>` element
|
|
92
|
+
// (with `report-uri` and `sandbox`), and a meta element is the only way a
|
|
93
|
+
// host application can express a policy — Brobridge sets the response
|
|
94
|
+
// headers and offers no hook for adding one. Declaring it here would put
|
|
95
|
+
// an inert directive in the document and invite the reader to count it as
|
|
96
|
+
// protection.
|
|
97
|
+
//
|
|
98
|
+
// Framing is refused anyway, one layer down: Brobridge's trust fence
|
|
99
|
+
// allows only `Sec-Fetch-Site: same-origin` or `none`, so a page on
|
|
100
|
+
// another origin that frames the application gets a 403 rather than a
|
|
101
|
+
// rendered frame. See docs/security.md.
|
|
102
|
+
'object-src': ["'none'"],
|
|
103
|
+
};
|
|
104
|
+
for (const [directive, sources] of Object.entries(extra ?? {})) {
|
|
105
|
+
directives[directive] = [...(directives[directive] ?? []), ...sources];
|
|
106
|
+
}
|
|
107
|
+
return Object.entries(directives)
|
|
108
|
+
.map(([directive, sources]) => `${directive} ${sources.join(' ')}`)
|
|
109
|
+
.join('; ');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sha256(input: string): string {
|
|
113
|
+
return createHash('sha256').update(input, 'utf8').digest('base64');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A `</script` inside string data would end the inline script element early —
|
|
118
|
+
* the HTML tokenizer does not know it is inside a JavaScript string. The same
|
|
119
|
+
* goes for a comment opener.
|
|
120
|
+
*/
|
|
121
|
+
function escapeForInlineScript(code: string): string {
|
|
122
|
+
return code.replaceAll('</script', String.raw`<\/script`).replaceAll('<!--', String.raw`<\!--`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Positions in a document that actually cause a network fetch.
|
|
127
|
+
*
|
|
128
|
+
* A bare `"https://…"` inside JavaScript is a *string*, not a request — React's
|
|
129
|
+
* production build embeds `https://react.dev/errors/…` in its error messages,
|
|
130
|
+
* and rejecting that would reject React. What matters is a URL somewhere the
|
|
131
|
+
* browser will load from: an HTML attribute, a CSS `url()`, or an `@import`.
|
|
132
|
+
*
|
|
133
|
+
* The Content-Security-Policy is the real enforcement — `default-src 'none'`
|
|
134
|
+
* blocks every off-origin fetch at runtime whatever the source says. This scan
|
|
135
|
+
* exists so the failure happens at build time, where a developer can see it,
|
|
136
|
+
* rather than as an empty box in somebody's browser.
|
|
137
|
+
*/
|
|
138
|
+
const LOAD_POSITIONS: readonly RegExp[] = [
|
|
139
|
+
/\b(?:src|href|srcset|poster|data|action|formaction)\s*=\s*["']?(?:https?:)?\/\/[^"'\s>]+/gi,
|
|
140
|
+
/url\(\s*["']?(?:https?:)?\/\/[^"')]+/gi,
|
|
141
|
+
/@import\s+(?:url\()?\s*["'](?:https?:)?\/\/[^"']+/gi,
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
/** The first off-origin load position in `html`, or `null`. */
|
|
145
|
+
function findOffOrigin(html: string): string | null {
|
|
146
|
+
for (const pattern of LOAD_POSITIONS) {
|
|
147
|
+
pattern.lastIndex = 0;
|
|
148
|
+
const match = pattern.exec(html);
|
|
149
|
+
if (match !== null) return match[0].slice(0, 160);
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Build the single-document UI. */
|
|
155
|
+
export async function buildPage(options: BuildPageOptions): Promise<BuildPageResult> {
|
|
156
|
+
const root = options.root ?? process.cwd();
|
|
157
|
+
const entry = resolve(root, options.entry);
|
|
158
|
+
const templatePath = resolve(root, options.template);
|
|
159
|
+
const outFile = resolve(root, options.outFile);
|
|
160
|
+
|
|
161
|
+
const built = await Bun.build({
|
|
162
|
+
entrypoints: [entry],
|
|
163
|
+
target: 'browser',
|
|
164
|
+
minify: options.minify !== false,
|
|
165
|
+
// Not `--splitting`: a second chunk would need a second HTTP route, and
|
|
166
|
+
// there is none.
|
|
167
|
+
splitting: false,
|
|
168
|
+
define: { 'process.env.NODE_ENV': JSON.stringify(options.minify === false ? 'development' : 'production') },
|
|
169
|
+
});
|
|
170
|
+
if (!built.success) {
|
|
171
|
+
throw new Error(`UI bundle failed:\n${built.logs.map(String).join('\n')}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const chunks = built.outputs.filter((output) => output.kind === 'entry-point' || output.kind === 'chunk');
|
|
175
|
+
if (chunks.length !== 1) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`expected exactly one JavaScript chunk, got ${String(chunks.length)}. A dynamic import would need a second HTTP route, and the bridge serves only "/".`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const firstChunk = chunks[0];
|
|
181
|
+
if (firstChunk === undefined) throw new Error('UI bundle produced no output');
|
|
182
|
+
const script = await firstChunk.text();
|
|
183
|
+
|
|
184
|
+
const cssOutputs = built.outputs.filter((output) => output.path.endsWith('.css'));
|
|
185
|
+
let css = '';
|
|
186
|
+
for (const output of cssOutputs) css += await output.text();
|
|
187
|
+
|
|
188
|
+
const template = await Bun.file(templatePath).text();
|
|
189
|
+
if (!template.includes('<!--BROAPP_HEAD-->') || !template.includes('<!--BROAPP_BODY-->')) {
|
|
190
|
+
throw new Error(`${templatePath} must contain <!--BROAPP_HEAD--> and <!--BROAPP_BODY--> markers`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const styleTag = css === '' ? '' : `<style>${css}</style>`;
|
|
194
|
+
// Hash the script *as the browser will see it*: the escaping below changes
|
|
195
|
+
// the bytes, so hashing before it would produce a policy that blocks the
|
|
196
|
+
// very script it was computed from.
|
|
197
|
+
const scriptBody = escapeForInlineScript(script);
|
|
198
|
+
const head =
|
|
199
|
+
`<meta http-equiv="Content-Security-Policy" content="${policy(sha256(scriptBody), css === '' ? null : sha256(css), options.csp)}">` +
|
|
200
|
+
styleTag;
|
|
201
|
+
const finalHtml = template
|
|
202
|
+
.replace('<!--BROAPP_HEAD-->', () => head)
|
|
203
|
+
.replace('<!--BROAPP_BODY-->', () => `<script type="module">${scriptBody}</script>`);
|
|
204
|
+
|
|
205
|
+
// The policy element itself names schemes and hosts; exclude it from the scan.
|
|
206
|
+
const withoutPolicy = finalHtml.replace(/<meta http-equiv="Content-Security-Policy"[^>]*>/g, '');
|
|
207
|
+
const offOrigin = findOffOrigin(withoutPolicy);
|
|
208
|
+
if (offOrigin !== null) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`the built page loads from an off-origin URL, which would break offline operation and leak when the application runs:\n ${offOrigin}\nInline or embed the asset instead.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await mkdir(dirname(outFile), { recursive: true });
|
|
215
|
+
await Bun.write(outFile, finalHtml);
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
outFile,
|
|
219
|
+
bytes: new TextEncoder().encode(finalHtml).byteLength,
|
|
220
|
+
scriptHash: sha256(scriptBody),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project configuration.
|
|
3
|
+
*
|
|
4
|
+
* One optional file, `broapp.config.ts`, with sensible defaults for every
|
|
5
|
+
* field — so a generated project has a config that is mostly there to be read
|
|
6
|
+
* rather than edited.
|
|
7
|
+
*/
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
/** What `broapp.config.ts` may set. */
|
|
12
|
+
export interface BroappConfig {
|
|
13
|
+
/** Browser entry point. Default `src/ui/main.tsx`. */
|
|
14
|
+
readonly uiEntry?: string;
|
|
15
|
+
/** HTML shell. Default `src/ui/index.html`. */
|
|
16
|
+
readonly uiTemplate?: string;
|
|
17
|
+
/** Host entry point. Default `src/host/main.ts`. */
|
|
18
|
+
readonly hostEntry?: string;
|
|
19
|
+
/** Where the built page goes. Must match what the host imports. Default `dist/ui.html`. */
|
|
20
|
+
readonly pageOut?: string;
|
|
21
|
+
/** Executable base name. Default: the package name. */
|
|
22
|
+
readonly binaryName?: string;
|
|
23
|
+
/** Output directory for executables. Default `release`. */
|
|
24
|
+
readonly outDir?: string;
|
|
25
|
+
/** Extra Content-Security-Policy sources. */
|
|
26
|
+
readonly csp?: Readonly<Record<string, readonly string[]>>;
|
|
27
|
+
/** Compile to bytecode. Default `true`. */
|
|
28
|
+
readonly bytecode?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A config with every default applied. */
|
|
32
|
+
export interface ResolvedConfig extends Required<Omit<BroappConfig, 'csp'>> {
|
|
33
|
+
readonly csp: Readonly<Record<string, readonly string[]>>;
|
|
34
|
+
readonly root: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const CONFIG_FILES = ['broapp.config.ts', 'broapp.config.js', 'broapp.config.mjs'];
|
|
38
|
+
|
|
39
|
+
/** Read and resolve the project's configuration. */
|
|
40
|
+
export async function loadConfig(root: string = process.cwd()): Promise<ResolvedConfig> {
|
|
41
|
+
let user: BroappConfig = {};
|
|
42
|
+
for (const candidate of CONFIG_FILES) {
|
|
43
|
+
const path = resolve(root, candidate);
|
|
44
|
+
if (!existsSync(path)) continue;
|
|
45
|
+
const module = (await import(path)) as { default?: BroappConfig };
|
|
46
|
+
user = module.default ?? {};
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let packageName = 'app';
|
|
51
|
+
const packageJson = resolve(root, 'package.json');
|
|
52
|
+
if (existsSync(packageJson)) {
|
|
53
|
+
const parsed = (await Bun.file(packageJson).json()) as { name?: string };
|
|
54
|
+
if (typeof parsed.name === 'string' && parsed.name !== '') {
|
|
55
|
+
// A scoped name is not a legal filename on Windows and is awkward
|
|
56
|
+
// everywhere else, so the scope is dropped for the executable.
|
|
57
|
+
packageName = parsed.name.replace(/^@[^/]+\//, '');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
root,
|
|
63
|
+
uiEntry: user.uiEntry ?? 'src/ui/main.tsx',
|
|
64
|
+
uiTemplate: user.uiTemplate ?? 'src/ui/index.html',
|
|
65
|
+
hostEntry: user.hostEntry ?? 'src/host/main.ts',
|
|
66
|
+
pageOut: user.pageOut ?? 'dist/ui.html',
|
|
67
|
+
binaryName: user.binaryName ?? packageName,
|
|
68
|
+
outDir: user.outDir ?? 'release',
|
|
69
|
+
bytecode: user.bytecode ?? true,
|
|
70
|
+
csp: user.csp ?? {},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Type helper, so `broapp.config.ts` gets completion. */
|
|
75
|
+
export function defineConfig(config: BroappConfig): BroappConfig {
|
|
76
|
+
return config;
|
|
77
|
+
}
|