@swifttui/build 0.0.6
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/AGENTS.md +44 -0
- package/README.md +46 -0
- package/cli.ts +98 -0
- package/index.ts +4 -0
- package/package.json +28 -0
- package/src/build/buildAppWasm.test.ts +178 -0
- package/src/build/buildAppWasm.ts +107 -0
- package/src/build/buildSwiftTUIWebApp.ts +22 -0
- package/src/build/generateSceneManifest.ts +46 -0
- package/src/build/optimizePackagedWasm.ts +20 -0
- package/src/build/resolveSwiftArtifacts.test.ts +90 -0
- package/src/build/resolveSwiftArtifacts.ts +253 -0
- package/src/build/runCommand.ts +81 -0
- package/src/build/stripPackagedWasm.ts +19 -0
- package/src/build/swiftCommandPrefix.ts +9 -0
- package/src/build/wasmTypeDiagnostics.ts +313 -0
- package/tsconfig.json +21 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Guidance for agentic assistants working in **`@swifttui/build`**. Keep this
|
|
4
|
+
concise; [`README.md`](README.md) is the full reference.
|
|
5
|
+
|
|
6
|
+
## What this package is
|
|
7
|
+
|
|
8
|
+
The **build/packaging tooling** for SwiftTUI browser apps — the sibling of
|
|
9
|
+
[`@swifttui/web`](../web) (which owns the runtime). It captures the Swift app's
|
|
10
|
+
scene manifest and packages its WASI/wasm artifact for the browser. Exposes the
|
|
11
|
+
`swifttui-web` CLI (see `bin` in `package.json`) and a programmatic `index.ts`.
|
|
12
|
+
|
|
13
|
+
Keep the split clean: **packaging/build steps live here; browser-safe runtime
|
|
14
|
+
APIs live in `@swifttui/web`.** This package depends on `@swifttui/web`.
|
|
15
|
+
|
|
16
|
+
## Toolchains
|
|
17
|
+
|
|
18
|
+
- **Bun** for the CLI, bundling, and tests.
|
|
19
|
+
- **`swiftly`** Swift 6.3.1 for the wasm build it invokes
|
|
20
|
+
(`swiftly run swift ...`), not bare `swift`.
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun test # package tests
|
|
26
|
+
bun run build:manifest -- --app <Exe> # capture TUIGUI_MODE=manifest output
|
|
27
|
+
bun run build:wasm -- --app <Exe> # copy + validate the app's wasm
|
|
28
|
+
bun run build -- --app <Exe> # manifest + wasm
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`build:wasm`/`build` default to `--configuration release`; pass
|
|
32
|
+
`--configuration debug` for local debug wasm.
|
|
33
|
+
|
|
34
|
+
## Gotcha
|
|
35
|
+
|
|
36
|
+
WASI release builds need specific flags (`-Osize` plus
|
|
37
|
+
`-disable-llvm-merge-functions-pass`) to stay under the browser WebAssembly
|
|
38
|
+
API's 1000-parameter limit. The canonical command lives in this package's build
|
|
39
|
+
code — don't hand-roll the swift invocation. See
|
|
40
|
+
[`WebExample`](../../../swift-tui-examples/WebExample) for the full rationale.
|
|
41
|
+
|
|
42
|
+
## Conventions
|
|
43
|
+
|
|
44
|
+
`AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it. Edit `AGENTS.md`.
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# `@swifttui/build`
|
|
2
|
+
|
|
3
|
+
Build-time package for SwiftTUI browser deployment.
|
|
4
|
+
|
|
5
|
+
This package owns manifest generation, Swift WASI builds, browser
|
|
6
|
+
`WebAssembly.compile` validation, and wasm packaging. It intentionally sits
|
|
7
|
+
outside `@swifttui/web` so browser runtime imports do not pull in Swift process
|
|
8
|
+
spawning, Node filesystem APIs, or wasm packaging helpers.
|
|
9
|
+
|
|
10
|
+
Publication status: the package name is reserved for the first public web
|
|
11
|
+
release. Until it is published to npm or attached as a public release tarball,
|
|
12
|
+
use the source checkout and the `swift-tui-examples/WebExample` template.
|
|
13
|
+
|
|
14
|
+
## API
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { buildSwiftTUIWebApp } from "@swifttui/build";
|
|
18
|
+
|
|
19
|
+
await buildSwiftTUIWebApp({
|
|
20
|
+
packagePath: ".",
|
|
21
|
+
product: "MyApp",
|
|
22
|
+
outputDirectory: "dist",
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Toolchain defaults match the repo:
|
|
27
|
+
|
|
28
|
+
- Swift command: `swiftly run swift` when `swiftly` is on `PATH`, otherwise
|
|
29
|
+
`swift`
|
|
30
|
+
- SDK: `swift-6.3.1-RELEASE_wasm`
|
|
31
|
+
- Release Swift flags:
|
|
32
|
+
`-Xswiftc -Osize -Xswiftc -Xfrontend -Xswiftc -disable-llvm-merge-functions-pass`
|
|
33
|
+
- Initial memory: `536870912`
|
|
34
|
+
- Max memory: `4294967296`
|
|
35
|
+
- Stack size: `1048576`
|
|
36
|
+
|
|
37
|
+
Callers can override `swiftCommand`, `swiftSDK`, `configuration`,
|
|
38
|
+
`initialMemory`, `maxMemory`, `stackSize`, `extraSwiftcFlags`,
|
|
39
|
+
`extraLinkerFlags`, and `extraSwiftBuildArgs`.
|
|
40
|
+
|
|
41
|
+
## Scripts
|
|
42
|
+
|
|
43
|
+
- `bun test`
|
|
44
|
+
- `bun run build:manifest -- --app <AppExecutable>`
|
|
45
|
+
- `bun run build:wasm -- --app <AppExecutable>`
|
|
46
|
+
- `bun run build -- --app <AppExecutable>`
|
package/cli.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
buildAppWasm,
|
|
6
|
+
buildSwiftTUIWebApp,
|
|
7
|
+
generateSceneManifest,
|
|
8
|
+
type WasmBuildConfiguration,
|
|
9
|
+
} from "./index.ts";
|
|
10
|
+
|
|
11
|
+
void runCli(process.argv.slice(2));
|
|
12
|
+
|
|
13
|
+
async function runCli(argv: string[]): Promise<void> {
|
|
14
|
+
const command = argv[0] ?? "build";
|
|
15
|
+
const flags = parseFlags(argv.slice(1));
|
|
16
|
+
const packagePath = resolve(flags["package-path"] ?? "../../");
|
|
17
|
+
const distPath = resolve(flags["dist"] ?? "./dist");
|
|
18
|
+
const appExecutable = flags.app ?? flags.product ?? flags["app-product"] ?? "";
|
|
19
|
+
const configuration = parseWasmBuildConfiguration(flags.configuration ?? "release");
|
|
20
|
+
|
|
21
|
+
switch (command) {
|
|
22
|
+
case "build:manifest":
|
|
23
|
+
assertAppExecutable(appExecutable);
|
|
24
|
+
await generateSceneManifest({
|
|
25
|
+
packagePath,
|
|
26
|
+
outputPath: resolve(distPath, "scene-manifest.json"),
|
|
27
|
+
appExecutable,
|
|
28
|
+
});
|
|
29
|
+
return;
|
|
30
|
+
case "build:wasm":
|
|
31
|
+
assertAppExecutable(appExecutable);
|
|
32
|
+
await buildAppWasm({
|
|
33
|
+
configuration,
|
|
34
|
+
packagePath,
|
|
35
|
+
outputDirectory: distPath,
|
|
36
|
+
product: appExecutable,
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
case "build":
|
|
40
|
+
assertAppExecutable(appExecutable);
|
|
41
|
+
await buildSwiftTUIWebApp({
|
|
42
|
+
configuration,
|
|
43
|
+
packagePath,
|
|
44
|
+
outputDirectory: distPath,
|
|
45
|
+
product: appExecutable,
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
default:
|
|
49
|
+
throw new Error(`unknown command: ${command}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseFlags(
|
|
54
|
+
argv: string[]
|
|
55
|
+
): Record<string, string> {
|
|
56
|
+
const flags: Record<string, string> = {};
|
|
57
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
58
|
+
const value = argv[index];
|
|
59
|
+
if (!value.startsWith("--")) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const equalsIndex = value.indexOf("=");
|
|
63
|
+
if (equalsIndex !== -1) {
|
|
64
|
+
flags[value.slice(2, equalsIndex)] = value.slice(equalsIndex + 1);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const name = value.slice(2);
|
|
68
|
+
const next = argv[index + 1];
|
|
69
|
+
if (next && !next.startsWith("--")) {
|
|
70
|
+
flags[name] = next;
|
|
71
|
+
index += 1;
|
|
72
|
+
} else {
|
|
73
|
+
flags[name] = "true";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return flags;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseWasmBuildConfiguration(
|
|
80
|
+
value: string
|
|
81
|
+
): WasmBuildConfiguration {
|
|
82
|
+
switch (value) {
|
|
83
|
+
case "debug":
|
|
84
|
+
return "debug";
|
|
85
|
+
case "release":
|
|
86
|
+
return "release";
|
|
87
|
+
default:
|
|
88
|
+
throw new Error(`unsupported wasm build configuration: ${value}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function assertAppExecutable(
|
|
93
|
+
value: string
|
|
94
|
+
): asserts value is string {
|
|
95
|
+
if (!value) {
|
|
96
|
+
throw new Error("missing --app or --product flag");
|
|
97
|
+
}
|
|
98
|
+
}
|
package/index.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@swifttui/build",
|
|
3
|
+
"version": "0.0.6",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"module": "index.ts",
|
|
6
|
+
"bin": {
|
|
7
|
+
"swifttui-web": "./cli.ts"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./index.ts"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build:manifest": "bun run cli.ts build:manifest",
|
|
15
|
+
"build:wasm": "bun run cli.ts build:wasm",
|
|
16
|
+
"build": "bun run cli.ts build",
|
|
17
|
+
"test": "bun test"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@swifttui/web": "0.0.6"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bun": "1.3.13"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"typescript": "^5"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { packageBrowserValidatedWasm } from "./buildAppWasm.ts";
|
|
6
|
+
|
|
7
|
+
const minimalWasmBytes = new Uint8Array([
|
|
8
|
+
0x00, 0x61, 0x73, 0x6d,
|
|
9
|
+
0x01, 0x00, 0x00, 0x00,
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const temporaryDirectories: string[] = [];
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
for (const directory of temporaryDirectories.splice(0)) {
|
|
16
|
+
await rm(directory, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("falls back to the original wasm when strip tooling throws", async () => {
|
|
21
|
+
const fixture = await createFixture();
|
|
22
|
+
const warnings: string[] = [];
|
|
23
|
+
|
|
24
|
+
await packageBrowserValidatedWasm({
|
|
25
|
+
optimize: async () => {},
|
|
26
|
+
sourceWasmPath: fixture.sourceWasmPath,
|
|
27
|
+
outputWasmPath: fixture.outputWasmPath,
|
|
28
|
+
strip: async () => {
|
|
29
|
+
throw new Error("missing llvm-objcopy");
|
|
30
|
+
},
|
|
31
|
+
onWarning: (warning) => warnings.push(warning),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(await Bun.file(fixture.outputWasmPath).bytes())
|
|
35
|
+
.toEqual(minimalWasmBytes);
|
|
36
|
+
expect(warnings).toHaveLength(1);
|
|
37
|
+
expect(warnings[0]).toContain("keeping unstripped wasm");
|
|
38
|
+
expect(warnings[0]).toContain("missing llvm-objcopy");
|
|
39
|
+
await WebAssembly.compile(await Bun.file(fixture.outputWasmPath).arrayBuffer());
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("falls back to the original wasm when stripping corrupts the artifact", async () => {
|
|
43
|
+
const fixture = await createFixture();
|
|
44
|
+
const warnings: string[] = [];
|
|
45
|
+
|
|
46
|
+
await packageBrowserValidatedWasm({
|
|
47
|
+
optimize: async () => {},
|
|
48
|
+
sourceWasmPath: fixture.sourceWasmPath,
|
|
49
|
+
outputWasmPath: fixture.outputWasmPath,
|
|
50
|
+
strip: async (wasmPath) => {
|
|
51
|
+
await Bun.write(wasmPath, new Uint8Array([0x00, 0x61, 0x73, 0x6d]));
|
|
52
|
+
},
|
|
53
|
+
onWarning: (warning) => warnings.push(warning),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(await Bun.file(fixture.outputWasmPath).bytes())
|
|
57
|
+
.toEqual(minimalWasmBytes);
|
|
58
|
+
expect(warnings).toHaveLength(1);
|
|
59
|
+
expect(warnings[0])
|
|
60
|
+
.toContain("stripped wasm does not parse in browser WebAssembly");
|
|
61
|
+
await WebAssembly.compile(await Bun.file(fixture.outputWasmPath).arrayBuffer());
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("fails when the source wasm itself is not browser-parseable", async () => {
|
|
65
|
+
const fixture = await createFixture(buildHugeFunctionTypeWasm(1001));
|
|
66
|
+
|
|
67
|
+
await expect(
|
|
68
|
+
packageBrowserValidatedWasm({
|
|
69
|
+
optimize: async () => {},
|
|
70
|
+
sourceWasmPath: fixture.sourceWasmPath,
|
|
71
|
+
outputWasmPath: fixture.outputWasmPath,
|
|
72
|
+
strip: async () => {},
|
|
73
|
+
})
|
|
74
|
+
).rejects.toThrow("generated wasm does not parse in browser WebAssembly");
|
|
75
|
+
await expect(
|
|
76
|
+
packageBrowserValidatedWasm({
|
|
77
|
+
optimize: async () => {},
|
|
78
|
+
sourceWasmPath: fixture.sourceWasmPath,
|
|
79
|
+
outputWasmPath: fixture.outputWasmPath,
|
|
80
|
+
strip: async () => {},
|
|
81
|
+
})
|
|
82
|
+
).rejects.toThrow("maxTypeParameterCount=1001");
|
|
83
|
+
await expect(
|
|
84
|
+
packageBrowserValidatedWasm({
|
|
85
|
+
optimize: async () => {},
|
|
86
|
+
sourceWasmPath: fixture.sourceWasmPath,
|
|
87
|
+
outputWasmPath: fixture.outputWasmPath,
|
|
88
|
+
strip: async () => {},
|
|
89
|
+
})
|
|
90
|
+
).rejects.toThrow("overBrowserLimitTypes=0");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("uses optimized wasm when the raw compiler output is not browser-parseable", async () => {
|
|
94
|
+
const fixture = await createFixture(buildHugeFunctionTypeWasm(1001));
|
|
95
|
+
|
|
96
|
+
await packageBrowserValidatedWasm({
|
|
97
|
+
optimize: async (wasmPath) => {
|
|
98
|
+
await Bun.write(wasmPath, minimalWasmBytes);
|
|
99
|
+
},
|
|
100
|
+
sourceWasmPath: fixture.sourceWasmPath,
|
|
101
|
+
outputWasmPath: fixture.outputWasmPath,
|
|
102
|
+
strip: async () => {},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(await Bun.file(fixture.outputWasmPath).bytes())
|
|
106
|
+
.toEqual(minimalWasmBytes);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("reports the optimization failure when the raw wasm is still invalid", async () => {
|
|
110
|
+
const fixture = await createFixture(buildHugeFunctionTypeWasm(1001));
|
|
111
|
+
|
|
112
|
+
await expect(
|
|
113
|
+
packageBrowserValidatedWasm({
|
|
114
|
+
optimize: async () => {
|
|
115
|
+
throw new Error("missing wasm-opt");
|
|
116
|
+
},
|
|
117
|
+
sourceWasmPath: fixture.sourceWasmPath,
|
|
118
|
+
outputWasmPath: fixture.outputWasmPath,
|
|
119
|
+
strip: async () => {},
|
|
120
|
+
})
|
|
121
|
+
).rejects.toThrow("wasm optimization step failed: missing wasm-opt");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
async function createFixture(
|
|
125
|
+
sourceBytes: Uint8Array = minimalWasmBytes
|
|
126
|
+
): Promise<{ sourceWasmPath: string; outputWasmPath: string }> {
|
|
127
|
+
const directory = await mkdtemp(join(tmpdir(), "webhost-wasm-"));
|
|
128
|
+
temporaryDirectories.push(directory);
|
|
129
|
+
|
|
130
|
+
const sourceWasmPath = join(directory, "source.wasm");
|
|
131
|
+
const outputWasmPath = join(directory, "output.wasm");
|
|
132
|
+
await Bun.write(sourceWasmPath, sourceBytes);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
sourceWasmPath,
|
|
136
|
+
outputWasmPath,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildHugeFunctionTypeWasm(
|
|
141
|
+
parameterCount: number
|
|
142
|
+
): Uint8Array {
|
|
143
|
+
const payload = [
|
|
144
|
+
...encodeUnsignedLEB128(1),
|
|
145
|
+
0x60,
|
|
146
|
+
...encodeUnsignedLEB128(parameterCount),
|
|
147
|
+
...new Array<number>(parameterCount).fill(0x7f),
|
|
148
|
+
0x00,
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
return new Uint8Array([
|
|
152
|
+
...minimalWasmBytes,
|
|
153
|
+
0x01,
|
|
154
|
+
...encodeUnsignedLEB128(payload.length),
|
|
155
|
+
...payload,
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function encodeUnsignedLEB128(
|
|
160
|
+
value: number
|
|
161
|
+
): number[] {
|
|
162
|
+
if (value < 0) {
|
|
163
|
+
throw new Error("LEB128 values must be non-negative");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const bytes: number[] = [];
|
|
167
|
+
let remaining = value;
|
|
168
|
+
do {
|
|
169
|
+
let byte = remaining & 0x7f;
|
|
170
|
+
remaining >>>= 7;
|
|
171
|
+
if (remaining !== 0) {
|
|
172
|
+
byte |= 0x80;
|
|
173
|
+
}
|
|
174
|
+
bytes.push(byte);
|
|
175
|
+
} while (remaining !== 0);
|
|
176
|
+
|
|
177
|
+
return bytes;
|
|
178
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { optimizePackagedWasm } from "./optimizePackagedWasm.ts";
|
|
4
|
+
import {
|
|
5
|
+
resolveSwiftArtifacts,
|
|
6
|
+
type ResolveSwiftArtifactsOptions,
|
|
7
|
+
type SwiftArtifactPaths,
|
|
8
|
+
type WasmBuildConfiguration,
|
|
9
|
+
} from "./resolveSwiftArtifacts.ts";
|
|
10
|
+
import { stripPackagedWasm } from "./stripPackagedWasm.ts";
|
|
11
|
+
import { formatWasmTypeDiagnostics } from "./wasmTypeDiagnostics.ts";
|
|
12
|
+
|
|
13
|
+
export interface BuildAppWasmOptions extends ResolveSwiftArtifactsOptions {
|
|
14
|
+
configuration?: WasmBuildConfiguration;
|
|
15
|
+
packagePath: string;
|
|
16
|
+
outputDirectory: string;
|
|
17
|
+
product: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function buildAppWasm(
|
|
21
|
+
options: BuildAppWasmOptions
|
|
22
|
+
): Promise<SwiftArtifactPaths> {
|
|
23
|
+
const artifacts = await resolveSwiftArtifacts(options);
|
|
24
|
+
|
|
25
|
+
const packagedWasmPath = join(options.outputDirectory, "assets", "app.wasm");
|
|
26
|
+
await mkdir(join(options.outputDirectory, "assets"), { recursive: true });
|
|
27
|
+
await rm(packagedWasmPath, { force: true });
|
|
28
|
+
await packageBrowserValidatedWasm({
|
|
29
|
+
sourceWasmPath: artifacts.wasmPath,
|
|
30
|
+
outputWasmPath: packagedWasmPath,
|
|
31
|
+
});
|
|
32
|
+
return artifacts;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface PackageBrowserValidatedWasmOptions {
|
|
36
|
+
optimize?: (wasmPath: string) => Promise<void>;
|
|
37
|
+
sourceWasmPath: string;
|
|
38
|
+
outputWasmPath: string;
|
|
39
|
+
strip?: (wasmPath: string) => Promise<void>;
|
|
40
|
+
onWarning?: (message: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function packageBrowserValidatedWasm(
|
|
44
|
+
options: PackageBrowserValidatedWasmOptions
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const sourceBytes = await readFile(options.sourceWasmPath);
|
|
47
|
+
await writeFile(options.outputWasmPath, sourceBytes);
|
|
48
|
+
|
|
49
|
+
const optimize = options.optimize ?? optimizePackagedWasm;
|
|
50
|
+
try {
|
|
51
|
+
await optimize(options.outputWasmPath);
|
|
52
|
+
await validateBrowserWasm(options.outputWasmPath, "optimized wasm");
|
|
53
|
+
} catch (error) {
|
|
54
|
+
await writeFile(options.outputWasmPath, sourceBytes);
|
|
55
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await validateBrowserWasm(options.outputWasmPath, "generated wasm");
|
|
59
|
+
} catch (rawError) {
|
|
60
|
+
const rawMessage = rawError instanceof Error ? rawError.message : String(rawError);
|
|
61
|
+
throw new Error([
|
|
62
|
+
rawMessage,
|
|
63
|
+
`wasm optimization step failed: ${message}`,
|
|
64
|
+
].join("\n"));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const warning = [
|
|
68
|
+
`warning: keeping unoptimized wasm at ${options.outputWasmPath}`,
|
|
69
|
+
`wasm optimization step failed or did not produce browser-parseable output: ${message}`,
|
|
70
|
+
].join("\n");
|
|
71
|
+
(options.onWarning ?? console.warn)(warning);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const strip = options.strip ?? stripPackagedWasm;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await strip(options.outputWasmPath);
|
|
78
|
+
await validateBrowserWasm(options.outputWasmPath, "stripped wasm");
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// Stripping is a size optimization only. Keep the known-good raw wasm
|
|
81
|
+
// whenever toolchain-specific objcopy output fails browser validation.
|
|
82
|
+
await writeFile(options.outputWasmPath, sourceBytes);
|
|
83
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
84
|
+
const warning = [
|
|
85
|
+
`warning: keeping unstripped wasm at ${options.outputWasmPath}`,
|
|
86
|
+
`strip step failed browser validation or tooling requirements: ${message}`,
|
|
87
|
+
].join("\n");
|
|
88
|
+
(options.onWarning ?? console.warn)(warning);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function validateBrowserWasm(
|
|
93
|
+
wasmPath: string,
|
|
94
|
+
description: string
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
const bytes = await readFile(wasmPath);
|
|
97
|
+
try {
|
|
98
|
+
// Validate against the same JS API the browser uses before we publish it.
|
|
99
|
+
await WebAssembly.compile(bytes);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
102
|
+
throw new Error([
|
|
103
|
+
`${description} does not parse in browser WebAssembly (${wasmPath}): ${message}`,
|
|
104
|
+
formatWasmTypeDiagnostics(bytes),
|
|
105
|
+
].join("\n"));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { buildAppWasm, type BuildAppWasmOptions } from "./buildAppWasm.ts";
|
|
4
|
+
import { generateSceneManifest } from "./generateSceneManifest.ts";
|
|
5
|
+
|
|
6
|
+
export interface BuildSwiftTUIWebAppOptions extends BuildAppWasmOptions {
|
|
7
|
+
appExecutable?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function buildSwiftTUIWebApp(
|
|
11
|
+
options: BuildSwiftTUIWebAppOptions
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
await rm(options.outputDirectory, { recursive: true, force: true });
|
|
14
|
+
await mkdir(options.outputDirectory, { recursive: true });
|
|
15
|
+
await generateSceneManifest({
|
|
16
|
+
packagePath: options.packagePath,
|
|
17
|
+
outputPath: join(options.outputDirectory, "scene-manifest.json"),
|
|
18
|
+
appExecutable: options.appExecutable ?? options.product,
|
|
19
|
+
swiftCommand: options.swiftCommand,
|
|
20
|
+
});
|
|
21
|
+
await buildAppWasm(options);
|
|
22
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
loadWebHostSceneManifest,
|
|
5
|
+
webTUISceneManifestToJSON,
|
|
6
|
+
type WebHostSceneManifest,
|
|
7
|
+
} from "@swifttui/web/manifest";
|
|
8
|
+
import { runCommand } from "./runCommand.ts";
|
|
9
|
+
import { swiftCommandPrefix } from "./swiftCommandPrefix.ts";
|
|
10
|
+
|
|
11
|
+
export interface GenerateSceneManifestOptions {
|
|
12
|
+
packagePath: string;
|
|
13
|
+
outputPath: string;
|
|
14
|
+
appExecutable: string;
|
|
15
|
+
swiftCommand?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function generateSceneManifest(
|
|
19
|
+
options: GenerateSceneManifestOptions
|
|
20
|
+
): Promise<WebHostSceneManifest> {
|
|
21
|
+
const output = await runManifestCommand(options);
|
|
22
|
+
const manifest = await loadWebHostSceneManifest(output.trim());
|
|
23
|
+
await mkdir(dirname(options.outputPath), { recursive: true });
|
|
24
|
+
await writeFile(options.outputPath, webTUISceneManifestToJSON(manifest));
|
|
25
|
+
return manifest;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function runManifestCommand(
|
|
29
|
+
options: GenerateSceneManifestOptions
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
return await runCommand(
|
|
32
|
+
[
|
|
33
|
+
...(options.swiftCommand ?? swiftCommandPrefix()),
|
|
34
|
+
"run",
|
|
35
|
+
"--package-path",
|
|
36
|
+
options.packagePath,
|
|
37
|
+
options.appExecutable,
|
|
38
|
+
],
|
|
39
|
+
{
|
|
40
|
+
env: {
|
|
41
|
+
...process.env,
|
|
42
|
+
TUIGUI_MODE: "manifest",
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { findExecutable, runCommand } from "./runCommand.ts";
|
|
2
|
+
|
|
3
|
+
export async function optimizePackagedWasm(
|
|
4
|
+
wasmPath: string
|
|
5
|
+
): Promise<void> {
|
|
6
|
+
const wasmOptPath = findExecutable("wasm-opt");
|
|
7
|
+
if (!wasmOptPath) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
"missing wasm-opt in PATH; install Binaryen so wasm packaging is deterministic across environments"
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
await runCommand([
|
|
14
|
+
wasmOptPath,
|
|
15
|
+
"-Os",
|
|
16
|
+
wasmPath,
|
|
17
|
+
"-o",
|
|
18
|
+
wasmPath,
|
|
19
|
+
]);
|
|
20
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
formatCommandForLogs,
|
|
4
|
+
hasRequiredWasmFlags,
|
|
5
|
+
requiredWasmSwiftFlags,
|
|
6
|
+
wasmBuildConfigurationLogLines,
|
|
7
|
+
} from "./resolveSwiftArtifacts.ts";
|
|
8
|
+
|
|
9
|
+
test("detects the required wasm Swift flag sequence", () => {
|
|
10
|
+
expect(
|
|
11
|
+
hasRequiredWasmFlags([
|
|
12
|
+
"build",
|
|
13
|
+
"--swift-sdk",
|
|
14
|
+
"swift-6.3.1-RELEASE_wasm",
|
|
15
|
+
"-c",
|
|
16
|
+
"release",
|
|
17
|
+
...requiredWasmSwiftFlags,
|
|
18
|
+
"-Xlinker",
|
|
19
|
+
"--initial-memory=1",
|
|
20
|
+
])
|
|
21
|
+
).toBe(true);
|
|
22
|
+
|
|
23
|
+
expect(
|
|
24
|
+
hasRequiredWasmFlags([
|
|
25
|
+
"build",
|
|
26
|
+
"--swift-sdk",
|
|
27
|
+
"swift-6.3.1-RELEASE_wasm",
|
|
28
|
+
"-c",
|
|
29
|
+
"release",
|
|
30
|
+
"-Xswiftc",
|
|
31
|
+
"-Osize",
|
|
32
|
+
"-Xswiftc",
|
|
33
|
+
"-disable-llvm-merge-functions-pass",
|
|
34
|
+
])
|
|
35
|
+
).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("formats commands for readable CI logs", () => {
|
|
39
|
+
expect(
|
|
40
|
+
formatCommandForLogs([
|
|
41
|
+
"swiftly",
|
|
42
|
+
"run",
|
|
43
|
+
"swift",
|
|
44
|
+
"build",
|
|
45
|
+
"--package-path",
|
|
46
|
+
"/tmp/My Project",
|
|
47
|
+
"-Xlinker",
|
|
48
|
+
"stack-size=1048576",
|
|
49
|
+
])
|
|
50
|
+
).toBe(
|
|
51
|
+
"swiftly run swift build --package-path '/tmp/My Project' -Xlinker stack-size=1048576"
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("emits explicit CI log lines for flag confirmation and commands", () => {
|
|
56
|
+
const lines = wasmBuildConfigurationLogLines({
|
|
57
|
+
configuration: "release",
|
|
58
|
+
packagePath: "/tmp/pkg",
|
|
59
|
+
product: "WebExampleApp",
|
|
60
|
+
swiftlyWorkingDirectory: "/tmp",
|
|
61
|
+
buildCommand: ["swiftly", "run", "swift", "build", "--product", "WebExampleApp"],
|
|
62
|
+
showBinPathCommand: ["swiftly", "run", "swift", "build", "--show-bin-path"],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(lines).toContain("WASM_REQUIRED_FLAGS_CONFIRMED=true");
|
|
66
|
+
expect(lines).toContain("WASM_BUILD_CONFIGURATION_NAME=release");
|
|
67
|
+
expect(lines).toContain(
|
|
68
|
+
"WASM_REQUIRED_FLAGS=-Xswiftc -Osize -Xswiftc -Xfrontend -Xswiftc -disable-llvm-merge-functions-pass"
|
|
69
|
+
);
|
|
70
|
+
expect(lines).toContain(
|
|
71
|
+
'WASM_BUILD_COMMAND_ARGS_JSON=["swiftly","run","swift","build","--product","WebExampleApp"]'
|
|
72
|
+
);
|
|
73
|
+
expect(lines).toContain(
|
|
74
|
+
'WASM_SHOW_BIN_PATH_COMMAND_ARGS_JSON=["swiftly","run","swift","build","--show-bin-path"]'
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("marks required release flags as skipped for debug wasm builds", () => {
|
|
79
|
+
const lines = wasmBuildConfigurationLogLines({
|
|
80
|
+
configuration: "debug",
|
|
81
|
+
packagePath: "/tmp/pkg",
|
|
82
|
+
product: "WebExampleApp",
|
|
83
|
+
swiftlyWorkingDirectory: "/tmp",
|
|
84
|
+
buildCommand: ["swiftly", "run", "swift", "build", "-c", "debug"],
|
|
85
|
+
showBinPathCommand: ["swiftly", "run", "swift", "build", "-c", "debug", "--show-bin-path"],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(lines).toContain("WASM_BUILD_CONFIGURATION_NAME=debug");
|
|
89
|
+
expect(lines).toContain("WASM_REQUIRED_FLAGS_CONFIRMED=skipped");
|
|
90
|
+
});
|