@markii/host 0.17.0-next.2 → 0.17.0-next.4

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 (67) hide show
  1. package/README.md +10 -2
  2. package/dist/browser-worker.classic.js +640 -0
  3. package/dist/browser-worker.js +40 -40
  4. package/dist/browser.d.ts +7 -3
  5. package/dist/browser.js +16 -4
  6. package/dist/conformance/host/05-export-html-pack-directive/expected.json +2 -0
  7. package/dist/conformance/host/06-install-pack-then-render/expected.json +6 -1
  8. package/dist/conformance/host/README.md +4 -2
  9. package/dist/export/note-export.d.ts +1 -1
  10. package/dist/host/adapter.d.ts +22 -25
  11. package/dist/host/conformance-runner.d.ts +1 -1
  12. package/dist/host/conformance-runner.js +21 -11
  13. package/dist/host/create-host.d.ts +29 -11
  14. package/dist/host/create-host.js +34 -23
  15. package/dist/host/editor-behavior.d.ts +2 -6
  16. package/dist/host/editor-behavior.js +5 -9
  17. package/dist/host/export-behavior.d.ts +6 -7
  18. package/dist/host/export-behavior.js +1 -1
  19. package/dist/host/labels.d.ts +5 -15
  20. package/dist/host/labels.js +8 -22
  21. package/dist/host/pack-archive.d.ts +4 -10
  22. package/dist/host/pack-archive.js +12 -21
  23. package/dist/host/pack-install.d.ts +11 -23
  24. package/dist/host/pack-install.js +26 -23
  25. package/dist/host/pack-load.d.ts +1 -2
  26. package/dist/host/pack-trust-behavior.js +1 -1
  27. package/dist/host/refresh-interval.d.ts +1 -3
  28. package/dist/host/refresh-interval.js +1 -3
  29. package/dist/host/run-behavior.d.ts +3 -4
  30. package/dist/host/run-behavior.js +23 -2
  31. package/dist/host/script-execution.d.ts +1 -1
  32. package/dist/html-engine.js +4 -4
  33. package/dist/index.d.ts +15 -80
  34. package/dist/index.js +18 -63
  35. package/dist/internal.d.ts +30 -0
  36. package/dist/internal.js +31 -0
  37. package/dist/node-fs.d.ts +0 -3
  38. package/dist/node-fs.js +1 -21
  39. package/dist/node-worker.d.ts +8 -0
  40. package/dist/node-worker.js +20678 -0
  41. package/dist/node.d.ts +3 -3
  42. package/dist/node.js +5 -4
  43. package/dist/packs/discover.d.ts +2 -0
  44. package/dist/packs/discover.js +2 -1
  45. package/dist/packs/pack-diagnostics.d.ts +6 -8
  46. package/dist/packs/pack-diagnostics.js +6 -8
  47. package/dist/packs/pack-paths.js +1 -2
  48. package/dist/packs/pack-render-registry.d.ts +9 -10
  49. package/dist/run/browser-isolate.d.ts +5 -5
  50. package/dist/run/browser-isolate.js +5 -1
  51. package/dist/run/isolate.d.ts +3 -1
  52. package/dist/run/net-bridge.d.ts +5 -5
  53. package/dist/run/net-bridge.js +7 -5
  54. package/dist/run/net-pinning.d.ts +2 -0
  55. package/dist/run/net-pinning.js +17 -3
  56. package/dist/run/net-provider.d.ts +3 -1
  57. package/dist/run/net-provider.js +40 -15
  58. package/dist/run/net-refusal.d.ts +4 -0
  59. package/dist/run/net-refusal.js +24 -0
  60. package/dist/run/node-isolate.d.ts +13 -21
  61. package/dist/run/node-isolate.js +31 -80
  62. package/dist/run/run-flow.d.ts +1 -2
  63. package/dist/run/run-host.d.ts +3 -3
  64. package/dist/run/worker-entry-browser.d.ts +24 -4
  65. package/dist/run/worker-entry-browser.js +99 -4
  66. package/dist/run/worker-entry.js +2 -0
  67. package/package.json +31 -12
package/dist/node.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { SpawnRunOptions } from './run/run-host.js';
2
- export { defaultWorkerPath, spawnRunInThread, workerThreadIsolate, } from './run/node-isolate.js';
2
+ export type { NodeHostIsolateOptions } from './run/node-isolate.js';
3
+ export { createNodeHostIsolate, workerThreadIsolate, } from './run/node-isolate.js';
3
4
  export { createNetProvider } from './run/net-provider.js';
4
- export { createNodeArchiveExtractFs, createNodeDirectoryLister, createNodeFileReader, createNodePackDiscoveryIo, createNodeReader as createNodePackScriptsReader, } from './node-fs.js';
5
- export { createHtmlExportEngine } from './html-engine.js';
5
+ export { createNodeDirectoryLister, createNodeFileReader, createNodePackDiscoveryIo, createNodeReader as createNodePackScriptsReader, } from './node-fs.js';
package/dist/node.js CHANGED
@@ -4,10 +4,11 @@
4
4
  // The main entry (`./index.ts`) is environment-neutral. This entry adds:
5
5
  // - the worker_threads isolate and the pinned network provider (`node:*`);
6
6
  // - the Node-backed file-system seams the neutral code takes by injection;
7
- // - the `@markii/html`-backed static export engine;
7
+ //
8
+ // The `@markii/html`-backed static export engine is its own entry,
9
+ // `@markii/host/html-engine`, so importing this one needs no optional peer.
8
10
  //
9
11
  // A Node host imports from both entries. No browser bundle imports this one.
10
- export { defaultWorkerPath, spawnRunInThread, workerThreadIsolate, } from './run/node-isolate.js';
12
+ export { createNodeHostIsolate, workerThreadIsolate, } from './run/node-isolate.js';
11
13
  export { createNetProvider } from './run/net-provider.js';
12
- export { createNodeArchiveExtractFs, createNodeDirectoryLister, createNodeFileReader, createNodePackDiscoveryIo, createNodeReader as createNodePackScriptsReader, } from './node-fs.js';
13
- export { createHtmlExportEngine } from './html-engine.js';
14
+ export { createNodeDirectoryLister, createNodeFileReader, createNodePackDiscoveryIo, createNodeReader as createNodePackScriptsReader, } from './node-fs.js';
@@ -30,6 +30,8 @@ export interface DiscoveredPack {
30
30
  /** The configured folder this pack was discovered in (absolute path, as given). */
31
31
  readonly folder: string;
32
32
  readonly manifest: PackManifest;
33
+ /** The exact text of the pack's `pack.json`, as read. A compiled script registers this text unchanged. Discovery always sets it; it is absent only on a pack a host assembled by hand. */
34
+ readonly manifestText?: string;
33
35
  /** Local component name -> absolute path to its declared source file (`manifest.components[name]`, resolved against `folder`). Informational: what a host consumes them for (bundling, `import()`, or locating the pack's prebuilt registration script) is that host's own concern. */
34
36
  readonly componentPaths: Readonly<Record<string, string>>;
35
37
  /** Engine id -> (local component name -> absolute source path), for every engine any component declares a source for. `componentPaths` is the `react` slice of this. A terminal build (`ansi`) reads its slice here. */
@@ -102,6 +102,7 @@ async function tryLoadPackAt(folder, readFile) {
102
102
  pack: {
103
103
  folder,
104
104
  manifest: result.manifest,
105
+ manifestText: text,
105
106
  componentPaths,
106
107
  componentPathsByEngine,
107
108
  scriptsDir: joinPath(folder, 'scripts'),
@@ -131,7 +132,7 @@ async function tryLoadPackAt(folder, readFile) {
131
132
  */
132
133
  /**
133
134
  * When a `ManifestAttempt` succeeded (`kind: 'found'`) but the manifest's
134
- * `engine` does not match `supportedEngine` (batch 7 #46), downgrades it to
135
+ * `engine` does not match `supportedEngine`, downgrades it to
135
136
  * a `SkippedPackFolder` instead of a `DiscoveredPack` — same effect
136
137
  * `@markii/react`'s `loadPack` already has for an unsupported engine
137
138
  * (nothing registered for that namespace, unknown-component fallback for
@@ -34,7 +34,7 @@ export interface PackDiagnosticsContext {
34
34
  /**
35
35
  * One entry per validated, non-colliding pack whose declared engine
36
36
  * `@markii/react`'s `loadPack` cannot run (`./pack-render-registry.ts`'s
37
- * `BuildRenderRegistryResult.droppedEngines`, batch 7 #46). Omitted (or
37
+ * `BuildRenderRegistryResult.droppedEngines`). Omitted (or
38
38
  * empty) contributes nothing — the ordinary case where every registered
39
39
  * pack's engine is supported.
40
40
  */
@@ -62,13 +62,11 @@ export declare function skippedPackCount(context: {
62
62
  }): number;
63
63
  /**
64
64
  * One informational line for a pack whose prebuilt `webview.js` shadows
65
- * component sources still present in the same folder — survey finding A9:
66
- * `apps/vscode/src/packs/pack-diagnostics.ts` and
67
- * `apps/obsidian/src/packs/pack-diagnostics.ts` each defined this exact
68
- * sentence shape, differing only in the tail clause naming how a user
69
- * would rebuild the prebuilt artifact from those sources (VS Code's own
70
- * Export Pack command versus, from Obsidian, VS Code's Export Pack command
71
- * by name, since Obsidian has no build command of its own).
65
+ * component sources still present in the same folder.
66
+ * The tail clause names how a user would rebuild the prebuilt artifact
67
+ * from those sources: VS Code's own Export Pack command, or, from
68
+ * Obsidian, VS Code's Export Pack command by name, since Obsidian has no
69
+ * build command of its own.
72
70
  *
73
71
  * Never a failure: shipping both the built artifact and its sources is a
74
72
  * supported distribution shape. `rebuildInstruction` is the one thing that
@@ -53,7 +53,7 @@ function duplicateComposedNameLines(context) {
53
53
  const duplicates = context.duplicateComposedNames ?? [];
54
54
  return duplicates.map((duplicate) => `Pack "${duplicate.skippedPack}"'s component composed to the directive name "${duplicate.composedName}", already claimed by pack "${duplicate.keptPack}"; the later component was skipped.`);
55
55
  }
56
- /** One line per pack dropped because this renderer cannot run its declared engine (batch 7 #46) — `[]` when there was none. */
56
+ /** One line per pack dropped because this renderer cannot run its declared engine — `[]` when there was none. */
57
57
  function droppedEngineLines(context) {
58
58
  const dropped = context.droppedEngines ?? [];
59
59
  return dropped.map((entry) => `Pack "${entry.name}" declares engine "${entry.engine}", which this renderer cannot run; it installed with zero components (every directive under its namespace falls back to the unknown-component box).`);
@@ -87,13 +87,11 @@ export function skippedPackCount(context) {
87
87
  }
88
88
  /**
89
89
  * One informational line for a pack whose prebuilt `webview.js` shadows
90
- * component sources still present in the same folder — survey finding A9:
91
- * `apps/vscode/src/packs/pack-diagnostics.ts` and
92
- * `apps/obsidian/src/packs/pack-diagnostics.ts` each defined this exact
93
- * sentence shape, differing only in the tail clause naming how a user
94
- * would rebuild the prebuilt artifact from those sources (VS Code's own
95
- * Export Pack command versus, from Obsidian, VS Code's Export Pack command
96
- * by name, since Obsidian has no build command of its own).
90
+ * component sources still present in the same folder.
91
+ * The tail clause names how a user would rebuild the prebuilt artifact
92
+ * from those sources: VS Code's own Export Pack command, or, from
93
+ * Obsidian, VS Code's Export Pack command by name, since Obsidian has no
94
+ * build command of its own.
97
95
  *
98
96
  * Never a failure: shipping both the built artifact and its sources is a
99
97
  * supported distribution shape. `rebuildInstruction` is the one thing that
@@ -12,8 +12,7 @@
12
12
  * host concern — reading the setting itself, and naming what "relative"
13
13
  * means in a diagnostic message, stays in each host's own app code.
14
14
  *
15
- * DECISION (per orchestrator direction, carried over from the original VS
16
- * Code module this was hoisted from): a relative entry is resolved against
15
+ * DECISION: a relative entry is resolved against
17
16
  * `baseDir` as given by the caller; an absolute entry is used as-is. This
18
17
  * matches how a user reads the setting's own description: "this pack
19
18
  * folder, relative to my project/vault".
@@ -1,21 +1,20 @@
1
1
  import type { PackManifest } from '@markii/pack';
2
- import type { Registry, RegistryEntry } from '@markii/react';
3
2
  /**
4
3
  * The renderer-specific half of building a pack-aware registry: the
5
4
  * renderer's own `loadPack`, `createRegistry` and `mergeRegistries`. A
6
5
  * React host passes `@markii/react`'s `reactPackEngine`; the host layer
7
6
  * itself names no renderer.
8
7
  */
9
- export interface PackRegistryEngine {
10
- loadPack(manifest: PackManifest, componentModules: Record<string, RegistryEntry>): {
11
- registry: Registry;
8
+ export interface PackRegistryEngine<TRegistry extends object = object, TEntry = unknown> {
9
+ loadPack(manifest: PackManifest, componentModules: Record<string, TEntry>): {
10
+ registry: TRegistry;
12
11
  dropped?: {
13
12
  name: string;
14
13
  engine: string;
15
14
  };
16
15
  };
17
- createRegistry(entries?: Record<string, RegistryEntry>): Registry;
18
- mergeRegistries(...registries: Registry[]): Registry;
16
+ createRegistry(entries?: Record<string, TEntry>): TRegistry;
17
+ mergeRegistries(...registries: TRegistry[]): TRegistry;
19
18
  }
20
19
  /** One registration a compiled pack script queued by calling `window.__markiiRegisterPack(manifestJson, componentModules)` (see `./pack-build.ts`'s top doc comment for the full registration convention). Structurally identical across every host that satisfies the convention. */
21
20
  export interface QueuedPackRegistration {
@@ -42,8 +41,8 @@ export interface DuplicateComposedName {
42
41
  readonly skippedPack: string;
43
42
  }
44
43
  /** What `buildRenderRegistry` reports alongside the merged registry — developer-facing diagnostics for a caller to fold into its own surface, never shown as page content. */
45
- export interface BuildRenderRegistryResult {
46
- readonly registry: Registry;
44
+ export interface BuildRenderRegistryResult<TRegistry extends object = object> {
45
+ readonly registry: TRegistry;
47
46
  /** One line per malformed registration, dropped rather than installed. */
48
47
  readonly invalidReasons: readonly string[];
49
48
  /** Non-empty only when two or more validated registrations shared a namespace — the WHOLE install was then rejected (docs/packs.md's install-time rejection rule) and `registry` falls back to `defaultRegistry` alone. */
@@ -52,7 +51,7 @@ export interface BuildRenderRegistryResult {
52
51
  readonly duplicateComposedNames: readonly DuplicateComposedName[];
53
52
  /**
54
53
  * One entry per validated, non-colliding pack whose manifest declares an
55
- * engine `@markii/react`'s `loadPack` cannot run (batch 7 #46). Before
54
+ * engine `@markii/react`'s `loadPack` cannot run. Before
56
55
  * this field existed, such a pack installed as an empty contribution
57
56
  * with no record anywhere that it had even been attempted — every
58
57
  * directive under its namespace fell through to the ordinary
@@ -80,4 +79,4 @@ export interface BuildRenderRegistryResult {
80
79
  * (see `DuplicateComposedName`) drops only the later claimant's
81
80
  * component, not the whole pack.
82
81
  */
83
- export declare function buildRenderRegistry(queued: readonly QueuedPackRegistration[], defaultRegistry: Registry, engine: PackRegistryEngine): BuildRenderRegistryResult;
82
+ export declare function buildRenderRegistry<TRegistry extends object, TEntry>(queued: readonly QueuedPackRegistration[], defaultRegistry: TRegistry, engine: PackRegistryEngine<TRegistry, TEntry>): BuildRenderRegistryResult<TRegistry>;
@@ -13,10 +13,10 @@
13
13
  * there is no `__dirname` inside a worker to find it beside the bundle.
14
14
  *
15
15
  * Network is served by the HOST, not the worker: `serveNetRequests` wires
16
- * every `markii:net-request` to the caller's own `NetProvider`, which is
17
- * the same pinned one a worker-thread isolate runs. The isolate has no
18
- * network stack of its own, so the allowlist cannot be bypassed from
19
- * inside it.
16
+ * every `markii:net-request` to the caller's own `NetProvider`. Lua cannot
17
+ * reach JavaScript, and the worker entry removes its own network globals
18
+ * before the first script runs (`./worker-entry-browser.ts`), so the
19
+ * provider's allowlist is the only path to the network.
20
20
  *
21
21
  * What this CANNOT do, stated plainly because it is a real difference: a
22
22
  * Web Worker takes no `maxOldGenerationSizeMb`, so `options`'s heap cap is
@@ -37,7 +37,7 @@ export interface BrowserIsolateOptions {
37
37
  /** Creates the worker. Injected so a test never needs a real one, and so a host can decide how it mints the URL. */
38
38
  createWorker: (entryPath: string) => WorkerLike;
39
39
  /** The host's own pinned provider. Every `net.*` call the script makes is answered by this, in the host, never in the isolate. */
40
- netProvider: (netAllowlist: string[], maxFetchBytes: number, netPolicy: unknown) => NetProvider;
40
+ netProvider: (netAllowlist: string[], maxFetchBytes: number, netPolicy: unknown, signal?: AbortSignal) => NetProvider;
41
41
  /** Where the worker should fetch wasmoon's `glue.wasm`; sent along with the job. */
42
42
  wasmUri?: string;
43
43
  }
@@ -19,6 +19,9 @@ export function createBrowserIsolate(options) {
19
19
  const worker = options.createWorker(spawn.entryPath);
20
20
  let provider;
21
21
  let alive = true;
22
+ // Aborted by `kill()`: a request still in flight when the run ends is
23
+ // cancelled rather than left running in the host.
24
+ const requests = new AbortController();
22
25
  const messageListeners = [];
23
26
  const errorListeners = [];
24
27
  worker.addEventListener('message', (event) => {
@@ -52,7 +55,7 @@ export function createBrowserIsolate(options) {
52
55
  return {
53
56
  send: (job) => {
54
57
  const { allowlist, maxFetchBytes, policy } = netArgsFrom(job);
55
- provider = options.netProvider(allowlist, maxFetchBytes, policy);
58
+ provider = options.netProvider(allowlist, maxFetchBytes, policy, requests.signal);
56
59
  worker.postMessage({
57
60
  ...job,
58
61
  ...(options.wasmUri !== undefined
@@ -64,6 +67,7 @@ export function createBrowserIsolate(options) {
64
67
  if (!alive)
65
68
  return;
66
69
  alive = false;
70
+ requests.abort();
67
71
  worker.terminate();
68
72
  },
69
73
  onMessage: (listener) => {
@@ -46,10 +46,12 @@ export interface RunIsolate {
46
46
  onExit(listener: (code: number | null) => void): void;
47
47
  }
48
48
  export interface SpawnIsolateOptions {
49
- /** The entry the isolate loads — resolved by the host, never guessed here (see `./run-host.ts`'s `defaultWorkerPath`). */
49
+ /** The entry the isolate loads — resolved by the host, never guessed here (`createNodeHostIsolate` supplies the packaged one). */
50
50
  entryPath: string;
51
51
  /** Node `execArgv` for a worker-thread isolate; meaningless to a Web Worker, which ignores it. */
52
52
  execArgv?: string[];
53
+ /** Environment for a worker-thread isolate; a Web Worker ignores it. */
54
+ env?: Record<string, string | undefined>;
53
55
  /** V8 old-space cap in MB. A worker thread honors it; a Web Worker cannot, and says so in its own doc comment. */
54
56
  maxOldGenerationSizeMb: number;
55
57
  }
@@ -8,11 +8,11 @@
8
8
  * host to perform it, and the host, which still has Node, runs exactly the
9
9
  * same `createNetProvider` the worker-thread isolate always used.
10
10
  *
11
- * This is a security improvement, not a compromise. The allowlist and the
12
- * pinning policy now live somewhere the sandboxed code cannot reach at
13
- * all: an isolate with no network stack has nothing to bypass them with.
14
- * What the worker can do is ask, and every ask is answered by the host's
15
- * own provider, against the host's own allowlist.
11
+ * The allowlist and the pinning policy live somewhere the sandboxed code
12
+ * cannot reach: Lua has no path to JavaScript, and the worker entry removes
13
+ * its network globals after startup (`./worker-entry-browser.ts`). What the
14
+ * worker can do is ask, and every ask is answered by the host's own
15
+ * provider, against the host's own allowlist.
16
16
  *
17
17
  * This file is the protocol and the HOST half. The worker half lives in
18
18
  * `./net-bridge-worker.ts` because it needs `@markii/lua`'s denial brand,
@@ -1,3 +1,4 @@
1
+ import { isNetRefusal } from './net-refusal.js';
1
2
  export function isNetBridgeRequest(value) {
2
3
  if (typeof value !== 'object' || value === null)
3
4
  return false;
@@ -49,11 +50,12 @@ export async function serveNetRequest(request, provider, post) {
49
50
  id: request.id,
50
51
  ok: false,
51
52
  error: error instanceof Error ? error.message : String(error),
52
- // Everything the host's provider refuses IS a policy denial: the
53
- // provider's own failures are allowlist, redirect, size, and pinning
54
- // refusals. Marking them so keeps the worker's classification
55
- // identical to the in-worker case.
56
- denied: true,
53
+ // A policy refusal (allowlist, redirect, size, address range) is
54
+ // marked by the provider and crosses as a denial, so the worker
55
+ // classifies it exactly as it would in-worker. A transport failure
56
+ // (DNS, connection, timeout, abort) is unmarked and crosses as an
57
+ // ordinary failure with its reason. Both reach the script as errors.
58
+ denied: isNetRefusal(error),
57
59
  });
58
60
  }
59
61
  }
@@ -40,6 +40,8 @@ export type PinResult = {
40
40
  } | {
41
41
  ok: false;
42
42
  reason: string;
43
+ /** `unresolved`: the name did not resolve (a transport failure). `refused`: the address policy refused it. */
44
+ kind: 'unresolved' | 'refused';
43
45
  };
44
46
  export interface PinPolicy {
45
47
  /**
@@ -79,29 +79,43 @@ export async function pinHostAddress(hostname, policy, lookup) {
79
79
  catch {
80
80
  // Deliberately not the resolver's own message: it varies by platform and
81
81
  // says nothing a note's author can act on.
82
- return { ok: false, reason: `host "${hostname}" could not be resolved` };
82
+ return {
83
+ ok: false,
84
+ kind: 'unresolved',
85
+ reason: `host "${hostname}" could not be resolved`,
86
+ };
83
87
  }
84
88
  if (addresses.length === 0) {
85
- return { ok: false, reason: `host "${hostname}" resolved to no address` };
89
+ return {
90
+ ok: false,
91
+ kind: 'unresolved',
92
+ reason: `host "${hostname}" resolved to no address`,
93
+ };
86
94
  }
87
95
  for (const candidate of addresses) {
88
96
  const scope = classifyIpAddress(candidate.address);
89
97
  if (scope === undefined) {
90
98
  return {
91
99
  ok: false,
100
+ kind: 'refused',
92
101
  reason: `host "${hostname}" resolved to an unusable address`,
93
102
  };
94
103
  }
95
104
  if (isRestrictedScope(scope) && !policy.allowRestrictedAddresses) {
96
105
  return {
97
106
  ok: false,
107
+ kind: 'refused',
98
108
  reason: `host "${hostname}" resolves to ${describeScope(scope)}`,
99
109
  };
100
110
  }
101
111
  }
102
112
  const chosen = addresses[0];
103
113
  if (chosen === undefined) {
104
- return { ok: false, reason: `host "${hostname}" resolved to no address` };
114
+ return {
115
+ ok: false,
116
+ kind: 'unresolved',
117
+ reason: `host "${hostname}" resolved to no address`,
118
+ };
105
119
  }
106
120
  const scope = classifyIpAddress(chosen.address) ?? 'reserved';
107
121
  return {
@@ -59,7 +59,9 @@ export declare function createNetProvider(allowlist: readonly string[], maxFetch
59
59
  * host marks every refusal on the wire (`./net-bridge.ts`'s `denied`
60
60
  * flag) and the worker rebuilds a properly branded error on arrival.
61
61
  */
62
- denial: (message: string) => Error, lookup?: HostLookup): NetProvider;
62
+ denial: (message: string) => Error, lookup?: HostLookup,
63
+ /** Aborted when the run ends: an in-flight request is destroyed and no further hop is made. */
64
+ signal?: AbortSignal): NetProvider;
63
65
  /**
64
66
  * An in-memory `CacheProvider` (`@markii/lua`) seeded from `snapshot` at
65
67
  * construction and readable back out afterward via `.snapshot()` — the
@@ -14,13 +14,14 @@
14
14
  * import from `@markii/lua` — the brand that marks a policy refusal — is
15
15
  * injected as `denial` instead. Types are `import type` and erase.
16
16
  *
17
- * The security properties are unchanged by the move, and one improves: an
18
- * isolate that has no network stack cannot bypass the allowlist enforced
19
- * here, because it has nothing to bypass it with.
17
+ * A Web Worker isolate asks this provider through the net bridge. The
18
+ * worker entry removes its own network globals after startup, so the
19
+ * allowlist enforced here is the only path to the network.
20
20
  */
21
21
  import * as http from 'node:http';
22
22
  import * as https from 'node:https';
23
23
  import * as dns from 'node:dns';
24
+ import { netRefusal } from './net-refusal.js';
24
25
  import { pinHostAddress, pinnedLookup, } from './net-pinning.js';
25
26
  function describeThrown(err) {
26
27
  return err instanceof Error ? err.message : String(err);
@@ -80,7 +81,7 @@ function bareHostname(hostname) {
80
81
  */
81
82
  function issueRequest(url, method, body, pinned, maxFetchBytes,
82
83
  /** See `createNetProvider`'s own `denial` parameter — injected for the same reason. */
83
- denial) {
84
+ denial, signal) {
84
85
  return new Promise((resolve, reject) => {
85
86
  const mod = url.protocol === 'https:' ? https : http;
86
87
  const bodyBuffer = body !== undefined ? Buffer.from(body, 'utf8') : undefined;
@@ -161,8 +162,20 @@ denial) {
161
162
  res.on('error', (err) => settleReject(err));
162
163
  });
163
164
  req.on('error', (err) => {
164
- settleReject(denial(`request failed: ${describeThrown(err)}`));
165
+ // A transport failure (connection refused, reset, timeout): an
166
+ // ordinary runtime failure with the reason, not a policy refusal.
167
+ settleReject(new Error(`request failed: ${describeThrown(err)}`));
165
168
  });
169
+ if (signal !== undefined) {
170
+ const onAbort = () => {
171
+ req.destroy();
172
+ settleReject(new Error('the run has ended'));
173
+ };
174
+ if (signal.aborted)
175
+ onAbort();
176
+ else
177
+ signal.addEventListener('abort', onAbort, { once: true });
178
+ }
166
179
  if (bodyBuffer)
167
180
  req.write(bodyBuffer);
168
181
  req.end();
@@ -227,8 +240,14 @@ export function createNetProvider(allowlist, maxFetchBytes, policy,
227
240
  * host marks every refusal on the wire (`./net-bridge.ts`'s `denied`
228
241
  * flag) and the worker rebuilds a properly branded error on arrival.
229
242
  */
230
- denial, lookup = defaultHostLookup) {
243
+ denial, lookup = defaultHostLookup,
244
+ /** Aborted when the run ends: an in-flight request is destroyed and no further hop is made. */
245
+ signal) {
231
246
  const allowed = new Set(allowlist.map((host) => host.toLowerCase()));
247
+ // Every policy refusal is built by the injected `denial` and then marked,
248
+ // so the net bridge can tell it from a transport failure without importing
249
+ // the Lua runtime. A transport failure is a plain `Error`.
250
+ const refuse = (message) => netRefusal(denial(message));
232
251
  async function fetchAllowed(startUrl, method, body) {
233
252
  let currentUrl = startUrl;
234
253
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
@@ -237,41 +256,47 @@ denial, lookup = defaultHostLookup) {
237
256
  parsed = new URL(currentUrl);
238
257
  }
239
258
  catch {
240
- throw denial(`"${currentUrl}" is not a valid URL`);
259
+ throw refuse(`"${currentUrl}" is not a valid URL`);
241
260
  }
242
261
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
243
- throw denial(`scheme "${parsed.protocol}" is not allowed`);
262
+ throw refuse(`scheme "${parsed.protocol}" is not allowed`);
244
263
  }
245
264
  // N-4: a URL carrying credentials is refused outright, on every hop.
246
265
  if (parsed.username !== '' || parsed.password !== '') {
247
- throw denial(`URL "${currentUrl}" embeds credentials, which is not allowed`);
266
+ throw refuse(`URL "${currentUrl}" embeds credentials, which is not allowed`);
248
267
  }
268
+ if (signal?.aborted)
269
+ throw new Error('the run has ended');
249
270
  const host = parsed.hostname.toLowerCase();
250
271
  if (!allowed.has(host)) {
251
- throw denial(`host "${host}" is not on the run's allowlist`);
272
+ throw refuse(`host "${host}" is not on the run's allowlist`);
252
273
  }
253
274
  // Issue #10: resolve-then-pin, closing the DNS-rebinding / private-
254
275
  // range SSRF gap a hostname allowlist alone cannot see.
255
276
  const pinResult = await pinHostAddress(host, policy, lookup);
256
277
  if (!pinResult.ok) {
257
- throw denial(pinResult.reason);
278
+ // A name that does not resolve is a transport failure; an address
279
+ // the policy refuses is a policy refusal.
280
+ throw pinResult.kind === 'unresolved'
281
+ ? new Error(pinResult.reason)
282
+ : refuse(pinResult.reason);
258
283
  }
259
- const response = await issueRequest(parsed, method, body, pinResult.pinned, maxFetchBytes, denial);
284
+ const response = await issueRequest(parsed, method, body, pinResult.pinned, maxFetchBytes, refuse, signal);
260
285
  if (response.status >= 300 && response.status < 400) {
261
286
  if (!response.location) {
262
- throw denial('redirect response carried no Location header');
287
+ throw refuse('redirect response carried no Location header');
263
288
  }
264
289
  try {
265
290
  currentUrl = new URL(response.location, currentUrl).toString();
266
291
  }
267
292
  catch {
268
- throw denial('redirect response carried an unparseable Location header');
293
+ throw refuse('redirect response carried an unparseable Location header');
269
294
  }
270
295
  continue;
271
296
  }
272
297
  return { status: response.status, body: response.body };
273
298
  }
274
- throw denial(`exceeded ${MAX_REDIRECTS} redirects`);
299
+ throw refuse(`exceeded ${MAX_REDIRECTS} redirects`);
275
300
  }
276
301
  return {
277
302
  get: (url) => fetchAllowed(url, 'GET', undefined),
@@ -0,0 +1,4 @@
1
+ /** Returns `error` marked as a policy refusal. A network provider throws one for every request it refuses on policy grounds. */
2
+ export declare function netRefusal(error: Error): Error;
3
+ /** True when `error` was marked by {@link netRefusal}. */
4
+ export declare function isNetRefusal(error: unknown): boolean;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Marks an `Error` as a network POLICY refusal (an unlisted host, a refused
3
+ * address range, an over-size body) as opposed to a transport failure (DNS,
4
+ * connection, timeout, abort).
5
+ *
6
+ * The brand is a global `Symbol.for` key on the error object, so it needs no
7
+ * runtime import and survives being created in one bundle and read in
8
+ * another. The net bridge reads it to decide whether a failure crosses to
9
+ * the worker as a capability denial or as an ordinary runtime failure. A
10
+ * refusal is always an error: a script can never read one as a success.
11
+ *
12
+ * It is a plain property, not a classification the Lua side trusts: the
13
+ * worker rebuilds its own branded denial from the `denied` flag on the wire.
14
+ */
15
+ const NET_REFUSAL = Symbol.for('markii.net.refusal');
16
+ /** Returns `error` marked as a policy refusal. A network provider throws one for every request it refuses on policy grounds. */
17
+ export function netRefusal(error) {
18
+ return Object.assign(error, { [NET_REFUSAL]: true });
19
+ }
20
+ /** True when `error` was marked by {@link netRefusal}. */
21
+ export function isNetRefusal(error) {
22
+ return (error instanceof Error &&
23
+ error[NET_REFUSAL] === true);
24
+ }
@@ -1,30 +1,22 @@
1
+ import type { HostIsolate } from '../host/adapter.js';
1
2
  import type { IsolateSpawner } from './isolate.js';
2
- import type { SpawnRunOptions } from './run-host.js';
3
- import type { RunResult } from './worker-entry.js';
4
- /**
5
- * The ONLY fallback this shared package supplies for an unset `workerPath`:
6
- * the sibling `worker-entry.ts`, run straight from source via `tsx`
7
- * (`TSX_DEV_EXEC_ARGV`, above). That is correct for this package's own
8
- * dev/Vitest runs — `__dirname` at that point is this real source
9
- * directory — but it says nothing about where a packaged host's bundled
10
- * worker lives, because this package does not know that host's `dist/`
11
- * layout. A packaged host (e.g. `apps/vscode`) MUST resolve its own bundled
12
- * worker path itself and pass it as `SpawnRunOptions.workerPath`; see
13
- * `apps/vscode/src/worker-path.ts` for that host's resolution and
14
- * `apps/vscode/esbuild.config.mjs`'s worker build for where it bundles to.
15
- */
16
- export declare function defaultWorkerPath(): string;
17
3
  /**
18
4
  * The default isolate: a `node:worker_threads` worker, which is what every
19
- * Node host (the VS Code extension host, this package's own tests) uses. It
20
- * is the only kind that accepts `resourceLimits`, so it is also the only
5
+ * Node host uses. It is the only kind that accepts `resourceLimits`, so it is also the only
21
6
  * kind whose V8 heap is bounded — see `./isolate.ts` for why a host might
22
7
  * still have to supply the other one.
23
8
  */
24
9
  export declare const workerThreadIsolate: IsolateSpawner;
10
+ export interface NodeHostIsolateOptions {
11
+ /** The worker entry on disk. Defaults to `node-worker.js` in the package's `dist/`. */
12
+ workerPath?: string;
13
+ }
25
14
  /**
26
- * `spawnRun` with the Node defaults filled in: the worker-thread isolate and
27
- * the dev/Vitest worker entry. For this package's own tests and any Node
28
- * caller that wants them; a packaged host passes its own `workerPath`.
15
+ * A `HostIsolate` of kind `node`: each run starts a `worker_threads` worker
16
+ * from the packaged, self-contained `node-worker.js`. A host that bundles
17
+ * the worker itself passes its own `workerPath`. The default is resolved
18
+ * when a run asks for it, so constructing the isolate never throws.
29
19
  */
30
- export declare function spawnRunInThread(options: Omit<SpawnRunOptions, 'spawnIsolate' | 'workerPath'> & Partial<Pick<SpawnRunOptions, 'spawnIsolate' | 'workerPath'>>): Promise<RunResult>;
20
+ export declare function createNodeHostIsolate(options?: NodeHostIsolateOptions): Extract<HostIsolate, {
21
+ kind: 'node';
22
+ }>;