wasm-platform-kit 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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -2
  3. package/assets/wasi/LICENSE +220 -0
  4. package/assets/wasi/cli.d.ts +43 -0
  5. package/assets/wasi/cli.js +147 -0
  6. package/assets/wasi/clocks.d.ts +3 -0
  7. package/assets/wasi/clocks.js +55 -0
  8. package/assets/wasi/common.d.ts +5 -0
  9. package/assets/wasi/common.js +16 -0
  10. package/assets/wasi/config.d.ts +2 -0
  11. package/assets/wasi/config.js +7 -0
  12. package/assets/wasi/environment.d.ts +5 -0
  13. package/assets/wasi/environment.js +24 -0
  14. package/assets/wasi/filesystem.d.ts +117 -0
  15. package/assets/wasi/filesystem.js +341 -0
  16. package/assets/wasi/helpers/cli.d.ts +7 -0
  17. package/assets/wasi/helpers/cli.js +25 -0
  18. package/assets/wasi/helpers/filesystem.d.ts +6 -0
  19. package/assets/wasi/helpers/filesystem.js +10 -0
  20. package/assets/wasi/http.d.ts +13 -0
  21. package/assets/wasi/http.js +953 -0
  22. package/assets/wasi/in-memory-filesystem.d.ts +38 -0
  23. package/assets/wasi/in-memory-filesystem.js +620 -0
  24. package/assets/wasi/in-memory-http.d.ts +8 -0
  25. package/assets/wasi/in-memory-http.js +11 -0
  26. package/assets/wasi/in-memory-sockets.d.ts +34 -0
  27. package/assets/wasi/in-memory-sockets.js +294 -0
  28. package/assets/wasi/index.d.ts +7 -0
  29. package/assets/wasi/index.js +7 -0
  30. package/assets/wasi/io.d.ts +67 -0
  31. package/assets/wasi/io.js +366 -0
  32. package/assets/wasi/opfs-filesystem.d.ts +52 -0
  33. package/assets/wasi/opfs-filesystem.js +262 -0
  34. package/assets/wasi/random.d.ts +4 -0
  35. package/assets/wasi/random.js +51 -0
  36. package/assets/wasi/sockets.d.ts +9 -0
  37. package/assets/wasi/sockets.js +85 -0
  38. package/browser.mjs +209 -0
  39. package/client.mjs +138 -0
  40. package/index.d.ts +199 -0
  41. package/install.d.ts +22 -0
  42. package/install.mjs +176 -0
  43. package/native-registration.mjs +70 -0
  44. package/node.mjs +332 -0
  45. package/package.json +66 -4
  46. package/policy.mjs +433 -0
  47. package/worker.mjs +389 -0
package/policy.mjs ADDED
@@ -0,0 +1,433 @@
1
+ // Embedding applications own these grants. Guest declarations confer no access.
2
+ // ref:DEC-host-sdk-direct-haskell
3
+ /** Carries a stable operation outcome without collapsing refusal into ordinary failure. */
4
+ export class HostError extends Error
5
+ {
6
+ /** Retains the broker outcome separately from its human-readable explanation. */
7
+ constructor(code, message, payload)
8
+ {
9
+ super(message);
10
+ this.name = 'HostError';
11
+ this.code = code;
12
+ if (payload !== undefined) this.payload = payload;
13
+ }
14
+ }
15
+ /** Application error payloads retain their data without inventing SDK status codes. */
16
+ export const outcomeCode = code =>
17
+ ['denied', 'unsupported', 'cancelled', 'exhausted', 'failed'].includes(code)
18
+ ? code
19
+ : 'failed';
20
+ /** Throws a classified outcome at a policy enforcement boundary. */
21
+ export const fail = (code, message) =>
22
+ {
23
+ throw new HostError(code, message);
24
+ };
25
+ const encoder = new TextEncoder();
26
+ /** WIT identifiers cannot contain $, so this integer marker cannot be a record field.
27
+ * The helper validates its decimal representation against the actual WIT width.
28
+ * Ordinary strings stay strings, including strings that look like large numbers.
29
+ */
30
+ export const stringifyTransport = value =>
31
+ JSON.stringify(value, (_, item) =>
32
+ typeof item === 'bigint'
33
+ ? {'$host-sdk-integer': item.toString()}
34
+ : item instanceof Uint8Array
35
+ ? Array.from(item)
36
+ : item,
37
+ );
38
+ /** Restores wide integers only from the helper's reserved canonical scalar marker. */
39
+ export const parseTransport = text =>
40
+ JSON.parse(text, (_, item) =>
41
+ {
42
+ if (
43
+ item &&
44
+ typeof item === 'object' &&
45
+ Object.hasOwn(item, '$host-sdk-integer')
46
+ )
47
+ {
48
+ if (
49
+ Object.keys(item).length !== 1 ||
50
+ typeof item['$host-sdk-integer'] !== 'string' ||
51
+ !/^-?(0|[1-9][0-9]*)$/.test(item['$host-sdk-integer'])
52
+ )
53
+ fail('failed', 'invalid integer transport');
54
+ return BigInt(item['$host-sdk-integer']);
55
+ }
56
+ return item;
57
+ });
58
+ /** Measures UTF-8 transport bytes, including JSON structure for composite messages. */
59
+ export const byteLength = value =>
60
+ encoder.encode(typeof value === 'string' ? value : stringifyTransport(value))
61
+ .length;
62
+ /** Bounds each instance even when the embedding application supplies no limits. */
63
+ export const defaults = Object.freeze({
64
+ memory: 512 * 1024 * 1024,
65
+ bytes: 16 * 1024 * 1024,
66
+ requests: 1000,
67
+ fuel: 10_000_000_000,
68
+ timeout: 30000,
69
+ });
70
+ const allowed = new Set([
71
+ 'files',
72
+ 'network',
73
+ 'storage',
74
+ 'environment',
75
+ 'ui',
76
+ 'native',
77
+ 'trusted',
78
+ ]);
79
+
80
+ /** Marks an embedding-owned callback that requires the explicit trusted profile. */
81
+ export function trustedJavaScript(execute)
82
+ {
83
+ if (typeof execute !== 'function')
84
+ fail('failed', 'trustedJavaScript needs a function');
85
+ return Object.freeze({kind: 'trusted', execute});
86
+ }
87
+
88
+ /** Names unrestricted DOM authority for explicit rejection in hostile mode. */
89
+ export const trustedDOM = execute =>
90
+ Object.freeze({...trustedJavaScript(execute), escape: 'dom'});
91
+ /** Names native FFI authority for explicit rejection in hostile mode. */
92
+ export const trustedNativeFFI = execute =>
93
+ Object.freeze({...trustedJavaScript(execute), escape: 'native-ffi'});
94
+ /** Names unsandboxed process authority for explicit rejection in hostile mode. */
95
+ export const trustedProcess = execute =>
96
+ Object.freeze({...trustedJavaScript(execute), escape: 'process'});
97
+
98
+ /** Clones policy data so later changes cannot escalate an existing instance. */
99
+ export function configure(options = {})
100
+ {
101
+ if (!['hostile', 'trusted'].includes(options.profile ?? 'hostile'))
102
+ fail('unsupported', 'unknown execution profile');
103
+ const limits = {...defaults, ...options.limits};
104
+ for (const [key, value] of Object.entries(limits))
105
+ {
106
+ if (!(key in defaults) || !Number.isSafeInteger(value) || value <= 0)
107
+ fail('failed', `invalid limit: ${key}`);
108
+ }
109
+ if (limits.memory > 1024 * 1024 * 1024 || limits.bytes > 64 * 1024 * 1024)
110
+ fail('unsupported', 'limit exceeds SDK maximum');
111
+ const grants = new Map();
112
+ for (const [name, input] of Object.entries(options.grants ?? {}))
113
+ {
114
+ if (!name || !input || !allowed.has(input.kind))
115
+ fail('unsupported', 'unknown provider kind');
116
+ if (input.kind === 'trusted' && options.profile !== 'trusted')
117
+ fail('denied', 'trustedJavaScript is rejected in the hostile profile');
118
+ // Functions and browser handles stay in the broker. Clone all mutable policy data.
119
+ const {handle, execute, validate, surface, ...data} = input;
120
+ grants.set(
121
+ name,
122
+ Object.freeze({
123
+ ...structuredClone(data),
124
+ handle,
125
+ execute,
126
+ validate,
127
+ surface,
128
+ }),
129
+ );
130
+ }
131
+ return {limits: Object.freeze(limits), grants};
132
+ }
133
+
134
+ /** Rejects traversal, absolute paths and ambiguous path segments before handle lookup. */
135
+ export function relativePath(path)
136
+ {
137
+ if (
138
+ typeof path !== 'string' ||
139
+ !path ||
140
+ path.includes('\\') ||
141
+ path.includes('\0') ||
142
+ path.split('/').some(x => !x || x === '.' || x === '..')
143
+ )
144
+ fail('denied', 'path outside grant');
145
+ return path.split('/');
146
+ }
147
+
148
+ /** Mediates each operation against immutable grants and the instance cancellation signal. */
149
+ export function createBroker(
150
+ configuration,
151
+ {signal, storage = new Map()} = {},
152
+ )
153
+ {
154
+ const {grants, limits} = configuration;
155
+ const frames = new Map();
156
+ let requests = 0;
157
+ const check = () =>
158
+ {
159
+ if (signal?.aborted) fail('cancelled', 'instance revoked');
160
+ };
161
+ async function perform(name, request)
162
+ {
163
+ check();
164
+ if (++requests > limits.requests) fail('exhausted', 'request limit');
165
+ if (byteLength(request) > limits.bytes)
166
+ fail('exhausted', 'request byte limit');
167
+ const grant = grants.get(name);
168
+ if (!grant) fail('denied', 'provider not granted');
169
+ const {tag, val} = request;
170
+ let result;
171
+ switch (grant.kind)
172
+ {
173
+ case 'files':
174
+ {
175
+ const reading = tag === 'read-text';
176
+ if (
177
+ (!reading && tag !== 'write-text') ||
178
+ !(reading ? grant.read : grant.write)
179
+ )
180
+ fail('denied', 'file operation not granted');
181
+ if (!grant.handle) fail('unsupported', 'browser file handle required');
182
+ const parts = relativePath(reading ? val : val.key);
183
+ let directory = grant.handle;
184
+ for (const part of parts.slice(0, -1))
185
+ directory = await directory.getDirectoryHandle(part, {
186
+ create: !reading,
187
+ });
188
+ check();
189
+ const file = await directory.getFileHandle(parts.at(-1), {
190
+ create: !reading,
191
+ });
192
+ if (reading)
193
+ {
194
+ const data = await file.getFile();
195
+ if (data.size > limits.bytes) fail('exhausted', 'file byte limit');
196
+ result = await data.text();
197
+ }
198
+ else
199
+ {
200
+ if (byteLength(val.value) > limits.bytes)
201
+ fail('exhausted', 'write byte limit');
202
+ const writer = await file.createWritable();
203
+ try
204
+ {
205
+ check();
206
+ await writer.write(val.value);
207
+ check();
208
+ await writer.close();
209
+ }
210
+ catch (error)
211
+ {
212
+ await writer.abort().catch(() =>
213
+ {});
214
+ throw error;
215
+ }
216
+ result = '';
217
+ }
218
+ break;
219
+ }
220
+ case 'network':
221
+ {
222
+ if (tag !== 'fetch') fail('denied', 'network operation not granted');
223
+ const url = new URL(val.url);
224
+ if (
225
+ url.protocol !== 'https:' &&
226
+ !(grant.allowHttp && url.protocol === 'http:')
227
+ )
228
+ fail('denied', 'network protocol not granted');
229
+ if (url.username || url.password || url.hash)
230
+ fail('denied', 'URL credentials and fragments are forbidden');
231
+ const method = val.method.toUpperCase();
232
+ const match = grant.destinations?.some(
233
+ d =>
234
+ url.origin === d.origin &&
235
+ (url.pathname === (d.path ?? '/') ||
236
+ (d.subpaths === true &&
237
+ url.pathname.startsWith(
238
+ (d.path ?? '/').replace(/\/$/, '') + '/',
239
+ ))) &&
240
+ d.methods?.includes(method),
241
+ );
242
+ if (!match)
243
+ fail('denied', 'network destination or operation not granted');
244
+ const response = await fetch(url, {
245
+ method,
246
+ body: method === 'GET' ? undefined : val.body,
247
+ credentials: 'omit',
248
+ redirect: 'manual',
249
+ signal,
250
+ headers: grant.headers ?? {},
251
+ });
252
+ if (
253
+ response.type === 'opaqueredirect' ||
254
+ (response.status >= 300 && response.status < 400)
255
+ )
256
+ fail('denied', 'redirect requires a separate authorized request');
257
+ if (!response.ok) fail('failed', `HTTP ${response.status}`);
258
+ const reader = response.body?.getReader();
259
+ let count = 0;
260
+ const chunks = [];
261
+ try
262
+ {
263
+ if (reader)
264
+ for (;;)
265
+ {
266
+ const {value, done} = await reader.read();
267
+ if (done) break;
268
+ count += value.byteLength;
269
+ if (count > limits.bytes)
270
+ fail('exhausted', 'network response limit');
271
+ chunks.push(value);
272
+ }
273
+ }
274
+ finally
275
+ {
276
+ await reader?.cancel().catch(() =>
277
+ {});
278
+ }
279
+ const bytes = new Uint8Array(count);
280
+ let offset = 0;
281
+ for (const chunk of chunks)
282
+ {
283
+ bytes.set(chunk, offset);
284
+ offset += chunk.length;
285
+ }
286
+ result = new TextDecoder('utf-8', {fatal: true}).decode(bytes);
287
+ break;
288
+ }
289
+ case 'storage':
290
+ {
291
+ if (
292
+ !grant.namespace ||
293
+ !Number.isSafeInteger(grant.quota) ||
294
+ grant.quota < 0
295
+ )
296
+ fail('failed', 'storage namespace and quota required');
297
+ let values = storage.get(grant.namespace);
298
+ if (!values)
299
+ {
300
+ values = new Map();
301
+ storage.set(grant.namespace, values);
302
+ }
303
+ if (tag === 'get-value' && grant.read) result = values.get(val) ?? '';
304
+ else if (tag === 'put-value' && grant.write)
305
+ {
306
+ let size = 0;
307
+ for (const [key, value] of values)
308
+ if (key !== val.key) size += byteLength(key) + byteLength(value);
309
+ if (size + byteLength(val.key) + byteLength(val.value) > grant.quota)
310
+ fail('exhausted', 'storage quota');
311
+ values.set(val.key, val.value);
312
+ result = '';
313
+ }
314
+ else fail('denied', 'storage operation not granted');
315
+ break;
316
+ }
317
+ case 'environment':
318
+ if (tag !== 'get-value' || !Object.hasOwn(grant.values ?? {}, val))
319
+ fail('denied', 'environment value not granted');
320
+ result = grant.values[val];
321
+ break;
322
+ case 'ui':
323
+ {
324
+ if (tag !== 'render') fail('denied', 'UI operation not granted');
325
+ if (typeof document === 'undefined' || !grant.surface)
326
+ fail('unsupported', 'UI requires an embedding surface');
327
+ const frame = document.createElement('iframe');
328
+ frame.setAttribute('sandbox', '');
329
+ // The baseline surface renders text; richer UI needs a separately reviewed protocol.
330
+ const text = val
331
+ .replaceAll('&', '&amp;')
332
+ .replaceAll('<', '&lt;')
333
+ .replaceAll('>', '&gt;');
334
+ frame.srcdoc = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'"><pre>${text}</pre>`;
335
+ frames.get(name)?.remove();
336
+ grant.surface.replaceChildren(frame);
337
+ frames.set(name, frame);
338
+ result = '';
339
+ break;
340
+ }
341
+ case 'native':
342
+ if (tag !== 'invoke') fail('denied', 'native operation not granted');
343
+ if (
344
+ typeof grant.validate !== 'function' ||
345
+ grant.validate(val) !== true
346
+ )
347
+ fail('denied', 'native input rejected');
348
+ if (typeof grant.execute !== 'function')
349
+ fail('unsupported', 'native provider unavailable');
350
+ result = await grant.execute(val, {signal});
351
+ break;
352
+ case 'trusted':
353
+ result = await grant.execute(request, {signal});
354
+ break;
355
+ default:
356
+ fail('unsupported', 'provider unavailable');
357
+ }
358
+ check();
359
+ if (typeof result !== 'string') fail('failed', 'provider must return text');
360
+ if (byteLength(result) > limits.bytes)
361
+ fail('exhausted', 'response byte limit');
362
+ return result;
363
+ }
364
+ return {
365
+ async perform(name, request)
366
+ {
367
+ try
368
+ {
369
+ return await perform(name, request);
370
+ }
371
+ catch (error)
372
+ {
373
+ if (error instanceof HostError) throw error;
374
+ const code = signal?.aborted
375
+ ? 'cancelled'
376
+ : ({
377
+ NotAllowedError: 'denied',
378
+ SecurityError: 'denied',
379
+ NotSupportedError: 'unsupported',
380
+ QuotaExceededError: 'exhausted',
381
+ AbortError: 'cancelled',
382
+ }[error?.name] ?? 'failed');
383
+ throw new HostError(code, error?.message ?? String(error));
384
+ }
385
+ },
386
+ dispose: () =>
387
+ {
388
+ for (const frame of frames.values()) frame.remove();
389
+ frames.clear();
390
+ },
391
+ };
392
+ }
393
+
394
+ // Resource identity never comes from caller-supplied fields or JSON. This module
395
+ // has one WeakMap for all instances so cross-instance use is rejected locally.
396
+ const resourceOwners = new WeakMap();
397
+ /** Translates opaque, instance-bound application resources at the transport edge. */
398
+ export function createResourceScope()
399
+ {
400
+ const scope = {};
401
+ const visit = (value, decode) =>
402
+ {
403
+ if (!value || typeof value !== 'object' || value instanceof Uint8Array)
404
+ return value;
405
+ const owner = resourceOwners.get(value);
406
+ if (owner)
407
+ {
408
+ if (decode || owner.scope !== scope)
409
+ fail('denied', 'resource belongs to another instance');
410
+ return {'$host-sdk-resource': owner.id};
411
+ }
412
+ if (Object.hasOwn(value, '$host-sdk-resource'))
413
+ {
414
+ if (!decode) fail('denied', 'forged resource token');
415
+ if (
416
+ Object.keys(value).length !== 1 ||
417
+ typeof value['$host-sdk-resource'] !== 'string'
418
+ )
419
+ fail('failed', 'invalid resource response');
420
+ const resource = Object.freeze(Object.create(null));
421
+ resourceOwners.set(resource, {scope, id: value['$host-sdk-resource']});
422
+ return resource;
423
+ }
424
+ if (Array.isArray(value)) return value.map(item => visit(item, decode));
425
+ return Object.fromEntries(
426
+ Object.entries(value).map(([key, item]) => [key, visit(item, decode)]),
427
+ );
428
+ };
429
+ return {
430
+ encode: value => visit(value, false),
431
+ decode: value => visit(value, true),
432
+ };
433
+ }