wasm-platform-kit-build 0.0.0-stage → 0.2.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 +2 -3
- package/haskell/HostSDK/Canonical.hs +369 -0
- package/haskell/LICENSE +21 -0
- package/haskell/host-sdk-guest.cabal +12 -0
- package/package.json +26 -3
- package/tools/component.mjs +269 -0
- package/tools/facade.mjs +133 -0
- package/tools/generate.mjs +362 -0
- package/tools/index.mjs +8 -0
- package/tools/native.mjs +194 -0
- package/tools/paths.mjs +43 -0
- package/wit/host.wit +24 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// Host-owned build pipeline: accepts Wasm and WIT, never plugin JavaScript.
|
|
2
|
+
// ref:DEC-host-sdk-direct-haskell
|
|
3
|
+
import {execFileSync} from 'node:child_process';
|
|
4
|
+
import {format} from 'prettier';
|
|
5
|
+
import {declarations} from './facade.mjs';
|
|
6
|
+
import {registerLocalNative} from './native.mjs';
|
|
7
|
+
import braceStyle from 'prettier-plugin-brace-style';
|
|
8
|
+
import {
|
|
9
|
+
readFile,
|
|
10
|
+
writeFile,
|
|
11
|
+
mkdir,
|
|
12
|
+
readdir,
|
|
13
|
+
copyFile,
|
|
14
|
+
rm,
|
|
15
|
+
cp,
|
|
16
|
+
} from 'node:fs/promises';
|
|
17
|
+
import {createHash} from 'node:crypto';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import {fileURLToPath} from 'node:url';
|
|
20
|
+
import {generatorPath, runtimeRoot, jcoRoot, toolchainRoot} from './paths.mjs';
|
|
21
|
+
/** Identifies exact artifact bytes so the runtime can reject changed components. */
|
|
22
|
+
export const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
23
|
+
const run = (tool, args) =>
|
|
24
|
+
execFileSync(tool, args, {stdio: 'pipe', maxBuffer: 32 * 1024 * 1024});
|
|
25
|
+
const toolroot = toolchainRoot;
|
|
26
|
+
const wasmTools = path.join(toolroot, 'wasmtime/bin/wasm-tools');
|
|
27
|
+
|
|
28
|
+
/** Builds and registers executable glue from Wasm; callers never provide JavaScript. */
|
|
29
|
+
export async function packageComponent({
|
|
30
|
+
core,
|
|
31
|
+
wit,
|
|
32
|
+
world,
|
|
33
|
+
directory,
|
|
34
|
+
generated,
|
|
35
|
+
interfaceName,
|
|
36
|
+
component,
|
|
37
|
+
requirements = [],
|
|
38
|
+
})
|
|
39
|
+
{
|
|
40
|
+
if (
|
|
41
|
+
!Array.isArray(requirements) ||
|
|
42
|
+
requirements.some(
|
|
43
|
+
requirement =>
|
|
44
|
+
!requirement ||
|
|
45
|
+
typeof requirement.provider !== 'string' ||
|
|
46
|
+
!requirement.provider ||
|
|
47
|
+
![
|
|
48
|
+
'files',
|
|
49
|
+
'network',
|
|
50
|
+
'storage',
|
|
51
|
+
'environment',
|
|
52
|
+
'ui',
|
|
53
|
+
'native',
|
|
54
|
+
].includes(requirement.kind),
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
throw new Error('invalid informational capability requirements');
|
|
58
|
+
await rm(directory, {recursive: true, force: true});
|
|
59
|
+
await mkdir(directory, {recursive: true});
|
|
60
|
+
const target = path.join(directory, 'component.wasm');
|
|
61
|
+
if (component) await copyFile(component, target);
|
|
62
|
+
else
|
|
63
|
+
{
|
|
64
|
+
run(generatorPath(), [
|
|
65
|
+
'remap',
|
|
66
|
+
core,
|
|
67
|
+
path.join(directory, 'remapped.wasm'),
|
|
68
|
+
path.join(generated, 'symbols.json'),
|
|
69
|
+
]);
|
|
70
|
+
run(path.join(toolroot, 'binaryen/bin/wasm-merge'), [
|
|
71
|
+
path.join(directory, 'remapped.wasm'),
|
|
72
|
+
'guest',
|
|
73
|
+
path.join(generated, 'initialize.wat'),
|
|
74
|
+
'init',
|
|
75
|
+
'--all-features',
|
|
76
|
+
'--disable-compact-imports',
|
|
77
|
+
'-o',
|
|
78
|
+
path.join(directory, 'initialized.wasm'),
|
|
79
|
+
]);
|
|
80
|
+
run(wasmTools, [
|
|
81
|
+
'component',
|
|
82
|
+
'embed',
|
|
83
|
+
wit,
|
|
84
|
+
path.join(directory, 'initialized.wasm'),
|
|
85
|
+
'-w',
|
|
86
|
+
world,
|
|
87
|
+
'-o',
|
|
88
|
+
path.join(directory, 'embedded.wasm'),
|
|
89
|
+
]);
|
|
90
|
+
run(wasmTools, [
|
|
91
|
+
'component',
|
|
92
|
+
'new',
|
|
93
|
+
path.join(directory, 'embedded.wasm'),
|
|
94
|
+
'--adapt',
|
|
95
|
+
`wasi_snapshot_preview1=${path.join(jcoRoot(), 'lib/wasi_snapshot_preview1.reactor.wasm')}`,
|
|
96
|
+
'-o',
|
|
97
|
+
target,
|
|
98
|
+
]);
|
|
99
|
+
for (const name of ['remapped.wasm', 'initialized.wasm', 'embedded.wasm'])
|
|
100
|
+
await rm(path.join(directory, name));
|
|
101
|
+
}
|
|
102
|
+
run(wasmTools, ['validate', target]);
|
|
103
|
+
const actualWit = run(wasmTools, ['component', 'wit', target]).toString();
|
|
104
|
+
const schemaPath = path.join(directory, 'interface.wit');
|
|
105
|
+
await writeFile(schemaPath, actualWit);
|
|
106
|
+
const contract = JSON.parse(
|
|
107
|
+
run(generatorPath(), ['schema', schemaPath, 'root']),
|
|
108
|
+
);
|
|
109
|
+
await rm(schemaPath);
|
|
110
|
+
if (!contract.interfaces[interfaceName])
|
|
111
|
+
throw new Error('unknown application interface');
|
|
112
|
+
const asyncExports = Object.keys(contract.interfaces[interfaceName]).map(
|
|
113
|
+
name => `${interfaceName}#${name}`,
|
|
114
|
+
);
|
|
115
|
+
run(process.execPath, [
|
|
116
|
+
path.join(jcoRoot(), 'dist/jco.js'),
|
|
117
|
+
'transpile',
|
|
118
|
+
target,
|
|
119
|
+
'-o',
|
|
120
|
+
directory,
|
|
121
|
+
'--name',
|
|
122
|
+
'guest',
|
|
123
|
+
'--instantiation',
|
|
124
|
+
'async',
|
|
125
|
+
'--no-wasi-shim',
|
|
126
|
+
'--base64-cutoff',
|
|
127
|
+
'0',
|
|
128
|
+
'--strict',
|
|
129
|
+
'--async-mode',
|
|
130
|
+
'jspi',
|
|
131
|
+
'--async-imports',
|
|
132
|
+
'host:sdk/host@0.2.0#perform',
|
|
133
|
+
'--async-exports',
|
|
134
|
+
...asyncExports,
|
|
135
|
+
]);
|
|
136
|
+
const mapping = path.join(directory, 'bounds.json');
|
|
137
|
+
await writeFile(
|
|
138
|
+
mapping,
|
|
139
|
+
JSON.stringify({memoryBytes: 256 * 1024 * 1024, imports: {}, exports: {}}),
|
|
140
|
+
);
|
|
141
|
+
const assets = {};
|
|
142
|
+
let memoryBudget = 0;
|
|
143
|
+
for (const name of await readdir(directory))
|
|
144
|
+
{
|
|
145
|
+
if (name.endsWith('.wasm') && name !== 'component.wasm')
|
|
146
|
+
{
|
|
147
|
+
const budget = JSON.parse(
|
|
148
|
+
run(generatorPath(), [
|
|
149
|
+
'remap',
|
|
150
|
+
path.join(directory, name),
|
|
151
|
+
path.join(directory, name + '.bounded'),
|
|
152
|
+
mapping,
|
|
153
|
+
]),
|
|
154
|
+
);
|
|
155
|
+
memoryBudget += budget.memory;
|
|
156
|
+
await copyFile(
|
|
157
|
+
path.join(directory, name + '.bounded'),
|
|
158
|
+
path.join(directory, name),
|
|
159
|
+
);
|
|
160
|
+
await rm(path.join(directory, name + '.bounded'));
|
|
161
|
+
}
|
|
162
|
+
if (name.endsWith('.js') || name.endsWith('.wasm'))
|
|
163
|
+
assets[name] = digest(await readFile(path.join(directory, name)));
|
|
164
|
+
}
|
|
165
|
+
await rm(mapping);
|
|
166
|
+
const manifest = {
|
|
167
|
+
version: 1,
|
|
168
|
+
sdk: '0.2.0',
|
|
169
|
+
component: 'component.wasm',
|
|
170
|
+
sha256: assets['component.wasm'],
|
|
171
|
+
interface: interfaceName,
|
|
172
|
+
contract,
|
|
173
|
+
assets,
|
|
174
|
+
browser: {jspi: true, memory: memoryBudget},
|
|
175
|
+
generators: {
|
|
176
|
+
witParser: '0.263.0',
|
|
177
|
+
jco: '1.41.0',
|
|
178
|
+
bindings: 'wit-haskell-0.2.0',
|
|
179
|
+
},
|
|
180
|
+
requirements: structuredClone(requirements),
|
|
181
|
+
providers: digest(
|
|
182
|
+
Buffer.concat(
|
|
183
|
+
await Promise.all(
|
|
184
|
+
[
|
|
185
|
+
'policy.mjs',
|
|
186
|
+
'node.mjs',
|
|
187
|
+
'browser.mjs',
|
|
188
|
+
'worker.mjs',
|
|
189
|
+
'client.mjs',
|
|
190
|
+
].map(name => readFile(path.join(runtimeRoot(), name))),
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
),
|
|
194
|
+
};
|
|
195
|
+
await writeFile(
|
|
196
|
+
path.join(directory, 'manifest.json'),
|
|
197
|
+
JSON.stringify(manifest, null, 2) + '\n',
|
|
198
|
+
);
|
|
199
|
+
await registerLocalNative({directory, component: target});
|
|
200
|
+
const registration = digest(JSON.stringify(manifest));
|
|
201
|
+
const runtime = path.join(directory, 'runtime');
|
|
202
|
+
await mkdir(runtime, {recursive: true});
|
|
203
|
+
for (const name of ['browser.mjs', 'worker.mjs', 'policy.mjs', 'client.mjs'])
|
|
204
|
+
await copyFile(path.join(runtimeRoot(), name), path.join(runtime, name));
|
|
205
|
+
await cp(
|
|
206
|
+
path.join(runtimeRoot(), 'assets/wasi'),
|
|
207
|
+
path.join(runtime, 'assets/wasi'),
|
|
208
|
+
{recursive: true},
|
|
209
|
+
);
|
|
210
|
+
await writeFile(
|
|
211
|
+
path.join(directory, 'worker.mjs'),
|
|
212
|
+
`// Host-generated allowlist. Plugin manifests cannot modify it.\nimport {startWorker} from './runtime/worker.mjs';\nstartWorker(${JSON.stringify([registration])});\n`,
|
|
213
|
+
);
|
|
214
|
+
await writeFile(
|
|
215
|
+
path.join(directory, 'browser.mjs'),
|
|
216
|
+
`// Generated embedding entrypoint.\nimport {createHost} from './runtime/browser.mjs';\nimport {createClient} from './runtime/client.mjs';\nconst contract = ${JSON.stringify(contract)};\nexport const createApplication = async (options = {}) => createClient(await createHost({...options, manifest: new URL('./manifest.json', import.meta.url), registration: {manifests: ${JSON.stringify([registration])}, workerURL: new URL('./worker.mjs', import.meta.url)}}), contract, ${JSON.stringify(interfaceName)});\n`,
|
|
217
|
+
);
|
|
218
|
+
await writeFile(
|
|
219
|
+
path.join(directory, 'node.mjs'),
|
|
220
|
+
`// Generated Node entrypoint.\nimport {createHost} from 'wasm-platform-kit';\nimport {nativeArtifacts} from './node-registration.mjs';\nimport {createClient} from './runtime/client.mjs';\nconst contract = ${JSON.stringify(contract)};\nexport const createApplication = async (options = {}) => createClient(await createHost({...options, nativeArtifacts, manifest: new URL('./manifest.json', import.meta.url)}), contract, ${JSON.stringify(interfaceName)});\n`,
|
|
221
|
+
);
|
|
222
|
+
const types = declarations(contract, interfaceName);
|
|
223
|
+
for (const file of ['node.d.mts', 'browser.d.mts'])
|
|
224
|
+
await writeFile(path.join(directory, file), types);
|
|
225
|
+
for (const file of [
|
|
226
|
+
'worker.mjs',
|
|
227
|
+
'browser.mjs',
|
|
228
|
+
'node.mjs',
|
|
229
|
+
'node-registration.mjs',
|
|
230
|
+
'install.mjs',
|
|
231
|
+
'node.d.mts',
|
|
232
|
+
'browser.d.mts',
|
|
233
|
+
])
|
|
234
|
+
{
|
|
235
|
+
const target = path.join(directory, file);
|
|
236
|
+
await writeFile(
|
|
237
|
+
target,
|
|
238
|
+
await format(await readFile(target, 'utf8'), {
|
|
239
|
+
filepath: target,
|
|
240
|
+
printWidth: 80,
|
|
241
|
+
tabWidth: 2,
|
|
242
|
+
singleQuote: true,
|
|
243
|
+
bracketSpacing: false,
|
|
244
|
+
trailingComma: 'all',
|
|
245
|
+
arrowParens: 'avoid',
|
|
246
|
+
plugins: [braceStyle],
|
|
247
|
+
braceStyle: 'allman',
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
await writeFile(
|
|
252
|
+
path.join(directory, 'deployment.json'),
|
|
253
|
+
JSON.stringify(
|
|
254
|
+
{
|
|
255
|
+
standard: {
|
|
256
|
+
'Content-Security-Policy':
|
|
257
|
+
"default-src 'self'; script-src 'self' blob: 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'",
|
|
258
|
+
},
|
|
259
|
+
isolated: {
|
|
260
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
261
|
+
'Cross-Origin-Embedder-Policy': 'require-corp',
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
null,
|
|
265
|
+
2,
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
return manifest;
|
|
269
|
+
}
|
package/tools/facade.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Generated public declarations come from the same resolved WIT as the runtime.
|
|
2
|
+
// ref:DEC-host-sdk-typed-application-boundary
|
|
3
|
+
const camel = name =>
|
|
4
|
+
name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
5
|
+
const pascal = name =>
|
|
6
|
+
{
|
|
7
|
+
const value = camel(name);
|
|
8
|
+
return value[0].toUpperCase() + value.slice(1);
|
|
9
|
+
};
|
|
10
|
+
/** Emits an asynchronous application API with opaque resource types. */
|
|
11
|
+
export function declarations(contract, interfaceName)
|
|
12
|
+
{
|
|
13
|
+
const resultType = type =>
|
|
14
|
+
{
|
|
15
|
+
if (typeof type !== 'number') return undefined;
|
|
16
|
+
const kind = contract.types[type].kind;
|
|
17
|
+
return kind.type !== undefined ? resultType(kind.type) : kind.result;
|
|
18
|
+
};
|
|
19
|
+
const functions = Object.values(contract.interfaces[interfaceName]);
|
|
20
|
+
const visited = new Set();
|
|
21
|
+
const names = new Map();
|
|
22
|
+
function typeName(id)
|
|
23
|
+
{
|
|
24
|
+
const name = contract.types[id].name;
|
|
25
|
+
const label = name ? pascal(name) : `Type${id}`;
|
|
26
|
+
if (names.has(label) && names.get(label) !== id)
|
|
27
|
+
throw new Error('unsupported: colliding façade type names');
|
|
28
|
+
names.set(label, id);
|
|
29
|
+
return label;
|
|
30
|
+
}
|
|
31
|
+
function type(ty)
|
|
32
|
+
{
|
|
33
|
+
if (ty == null) return 'void';
|
|
34
|
+
if (typeof ty === 'string')
|
|
35
|
+
{
|
|
36
|
+
const value = {
|
|
37
|
+
string: 'string',
|
|
38
|
+
bool: 'boolean',
|
|
39
|
+
u8: 'number',
|
|
40
|
+
s8: 'number',
|
|
41
|
+
u16: 'number',
|
|
42
|
+
s16: 'number',
|
|
43
|
+
u32: 'number',
|
|
44
|
+
s32: 'number',
|
|
45
|
+
u64: 'bigint',
|
|
46
|
+
s64: 'bigint',
|
|
47
|
+
}[ty];
|
|
48
|
+
if (!value) throw new Error('unsupported façade scalar ' + ty);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
if (contract.types[ty].kind.type !== undefined)
|
|
52
|
+
return type(contract.types[ty].kind.type);
|
|
53
|
+
if (visited.has(ty)) return typeName(ty);
|
|
54
|
+
visited.add(ty);
|
|
55
|
+
const {kind} = contract.types[ty];
|
|
56
|
+
if (kind === 'resource') return typeName(ty);
|
|
57
|
+
// Traverse every referenced type now, including error types exposed by WIT.
|
|
58
|
+
expression(kind);
|
|
59
|
+
return typeName(ty);
|
|
60
|
+
}
|
|
61
|
+
function expression(kind)
|
|
62
|
+
{
|
|
63
|
+
if (kind.type !== undefined) return type(kind.type);
|
|
64
|
+
if (kind.handle) return type(kind.handle.own ?? kind.handle.borrow);
|
|
65
|
+
if (kind.list !== undefined)
|
|
66
|
+
return kind.list === 'u8' ? 'Uint8Array' : `Array<${type(kind.list)}>`;
|
|
67
|
+
if (kind.option !== undefined) return `(${type(kind.option)} | null)`;
|
|
68
|
+
if (kind.result)
|
|
69
|
+
return `{tag: 'ok'; val: ${type(kind.result.ok)}} | {tag: 'err'; val: ${type(kind.result.err)}}`;
|
|
70
|
+
if (kind.record)
|
|
71
|
+
return `{${kind.record.fields.map(field => `${JSON.stringify(camel(field.name))}: ${type(field.type)}`).join('; ')}}`;
|
|
72
|
+
if (kind.variant)
|
|
73
|
+
return kind.variant.cases
|
|
74
|
+
.map(
|
|
75
|
+
item =>
|
|
76
|
+
`{tag: ${JSON.stringify(item.name)}${item.type == null ? '' : `; val: ${type(item.type)}`}}`,
|
|
77
|
+
)
|
|
78
|
+
.join(' | ');
|
|
79
|
+
if (kind.enum)
|
|
80
|
+
return kind.enum.cases.map(item => JSON.stringify(item.name)).join(' | ');
|
|
81
|
+
throw new Error('unsupported façade type');
|
|
82
|
+
}
|
|
83
|
+
function signature(fn, method = false)
|
|
84
|
+
{
|
|
85
|
+
if (method) type(fn.params[0].type);
|
|
86
|
+
const params = fn.params
|
|
87
|
+
.slice(method ? 1 : 0)
|
|
88
|
+
.map(param => `${camel(param.name)}: ${type(param.type)}`);
|
|
89
|
+
params.push('controls?: {signal?: AbortSignal}');
|
|
90
|
+
const result = resultType(fn.result);
|
|
91
|
+
// Record the error schema too, while Promise rejection carries the failure.
|
|
92
|
+
type(fn.result);
|
|
93
|
+
return `(${params.join(', ')}): Promise<${type(result ? result.ok : fn.result)}>`;
|
|
94
|
+
}
|
|
95
|
+
const operations = functions
|
|
96
|
+
.filter(fn => fn.kind?.method === undefined)
|
|
97
|
+
.map(fn =>
|
|
98
|
+
{
|
|
99
|
+
const name = fn.name.startsWith('[constructor]')
|
|
100
|
+
? 'new' + pascal(fn.name.slice(13))
|
|
101
|
+
: camel(fn.name.replace(/^\[static\]/, '').replace('.', '-'));
|
|
102
|
+
if (
|
|
103
|
+
['identity', 'invoke', 'drop', 'close', 'revoke', 'then'].includes(name)
|
|
104
|
+
)
|
|
105
|
+
throw new Error('unsupported: export conflicts with client lifecycle');
|
|
106
|
+
return `${JSON.stringify(name)}${signature(fn)};`;
|
|
107
|
+
});
|
|
108
|
+
for (const fn of functions) signature(fn, fn.kind?.method !== undefined);
|
|
109
|
+
const types = [];
|
|
110
|
+
for (const id of visited)
|
|
111
|
+
{
|
|
112
|
+
const {kind} = contract.types[id];
|
|
113
|
+
if (kind === 'resource')
|
|
114
|
+
{
|
|
115
|
+
const methods = functions
|
|
116
|
+
.filter(fn => fn.kind?.method === id)
|
|
117
|
+
.map(fn =>
|
|
118
|
+
{
|
|
119
|
+
const name = camel(fn.name.split('.')[1]);
|
|
120
|
+
if (name === 'dispose' || name === 'then')
|
|
121
|
+
throw new Error(
|
|
122
|
+
'unsupported: resource method conflicts with disposal',
|
|
123
|
+
);
|
|
124
|
+
return `${JSON.stringify(name)}${signature(fn, true)};`;
|
|
125
|
+
});
|
|
126
|
+
types.push(
|
|
127
|
+
`export interface ${typeName(id)} {readonly [resourceBrand]: ${JSON.stringify(typeName(id))}; dispose(controls?: {signal?: AbortSignal}): Promise<void>; ${methods.join('\n')}}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
else types.push(`export type ${typeName(id)} = ${expression(kind)};`);
|
|
131
|
+
}
|
|
132
|
+
return `// Generated from resolved WIT. Do not edit.\nimport type {HostInstance, HostOptions} from 'wasm-platform-kit';\ndeclare const resourceBrand: unique symbol;\n${types.join('\n')}\nexport interface Application extends HostInstance {${operations.join('\n')}}\nexport function createApplication(options?: Omit<HostOptions, 'manifest' | 'nativeArtifacts'>): Promise<Application>;\n`;
|
|
133
|
+
}
|