@ekrooh/bare 0.5.0 → 0.6.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/README.md CHANGED
@@ -3,11 +3,14 @@
3
3
  **One soul, many platforms.** One codebase and identity across Android, iOS,
4
4
  web, and desktop.
5
5
 
6
+ **Docs: <https://anderscan.github.io/ekrooh>**
7
+
6
8
  [![CI](https://github.com/AndersCan/ekrooh/actions/workflows/test.yml/badge.svg)](https://github.com/AndersCan/ekrooh/actions/workflows/test.yml)
9
+ [![Release](https://img.shields.io/github/v/release/AndersCan/ekrooh)](https://github.com/AndersCan/ekrooh/releases/latest)
7
10
 
8
11
  The boring bootstrap for cross-platform apps on the Bare runtime (holepunch): a
9
12
  binary wire protocol, plugin kernel, RPC messenger, transports, and native host
10
- bridges, plus a reference app. Public docs: <https://anderscan.github.io/ekrooh>.
13
+ bridges, plus a reference app.
11
14
 
12
15
  - [`vision.md`](vision.md): what this project is and is not.
13
16
  - [`AGENTS.md`](AGENTS.md): how to develop it and the canonical commands.
@@ -99,16 +102,23 @@ ephemeral port. Every request is gated by a per-session token:
99
102
 
100
103
  1. The shell injects `window.__ekrooh = { bootstrap }`, a single-use nonce (never the raw token).
101
104
  2. The page calls `POST /login` with the nonce and receives a
102
- `bare_session` cookie (`HttpOnly; SameSite=Lax; Path=/`).
105
+ `bare_session_<port>` cookie (`HttpOnly; SameSite=Strict; Path=/`). The port
106
+ in the name keeps two loopback servers in one cookie store apart. The raw
107
+ token is never a login credential.
103
108
  3. The cookie authorizes `<img>`, `<video>`, `fetch`, and the WS upgrade.
104
109
  Non-browser clients send `X-Bare-Token`. There is no `?token=` fallback
105
110
  (see `docs/adr/0003-loopback-auth-surface.md`).
106
111
 
107
112
  Only the bootstrap mount at `/` is public pre-auth. The worklet writes
108
- `{ origin, port, token, bootstrap }` to `handoff.json` in the host's sandbox dir;
109
- the host keeps `token` and loads `http://127.0.0.1:<port>/index.html`.
113
+ `{ origin, port, bootstrap }` to `handoff.json` in the host's sandbox dir (never
114
+ the session token); the host loads `http://127.0.0.1:<port>/index.html` and
115
+ deletes the file.
116
+
117
+ Every request must also target the bound loopback server (`Host`), and a page
118
+ `Origin` must be that server or an explicitly allowed one, in every mode.
110
119
 
111
- The dev backend runs the same bundle with auth off on `ws://localhost:8080`.
120
+ The dev backend (`mode=dev`) runs the same bundle with auth off on
121
+ `ws://localhost:8080`, allowing the Vite dev origin (`origin=` tokens).
112
122
 
113
123
  ## Testing
114
124
 
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- export { h as CoreError, g as ErrorCode, _ as MAX_FRAME_BYTES, v as MAX_HEADER_BYTES, o as MessageProtocol, y as MessageType, b as VERSION, f as coerceErrorCode, n as createBareRuntimeContext, l as createPluginBus, u as createPluginRegistry, d as createPluginRouter, s as createProtocolMessenger, r as definePlugin, i as dispatchEvent, p as err, t as installWebConsoleCapture, a as invokeEvent, m as ok } from "./messages-BbpF3wNH.mjs";
1
+ export { h as CoreError, g as ErrorCode, _ as MAX_FRAME_BYTES, v as MAX_HEADER_BYTES, o as MessageProtocol, y as MessageType, b as VERSION, f as coerceErrorCode, n as createBareRuntimeContext, l as createPluginBus, u as createPluginRegistry, d as createPluginRouter, s as createProtocolMessenger, r as definePlugin, i as dispatchEvent, p as err, t as installWebConsoleCapture, a as invokeEvent, m as ok } from "./messages-CevD8qkV.mjs";
@@ -296,9 +296,9 @@ function isPluginInvokeResponseHeader(header) {
296
296
  /**
297
297
  * CSPRNG-backed correlation id for RPC requests.
298
298
  *
299
- * Shared by the production messenger (`rpc-messenger.ts`) and the mantaq
300
- * prototype (`rpc-messenger.mantaq.ts`) so the unguessable,
301
- * collision-resistant guarantee can never regress in a single copy. Request
299
+ * Shared by the RPC messenger (`rpc-messenger.ts`) and the host IPC bridge
300
+ * (`host-ipc.ts`) so the unguessable, collision-resistant guarantee can never
301
+ * regress in a single copy. Request
302
302
  * IDs must never be derived from `Math.random()` — a guessable id lets a peer
303
303
  * spoof or collide with an in-flight request and corrupt correlation. See
304
304
  * ekrooh#141.
@@ -747,9 +747,11 @@ function dispatchEvent(spec, args, payload) {
747
747
  // core/messages/create-bare-runtime-context.ts
748
748
  function createBareRuntimeContext(plugins = [], routerOptions) {
749
749
  const runtime = "bare";
750
+ const textEncoder = new TextEncoder$1();
751
+ const textDecoder = new TextDecoder$1();
750
752
  const protocol = new MessageProtocol({
751
- encode: (str) => new TextEncoder$1().encode(str),
752
- decode: (bytes) => new TextDecoder$1().decode(bytes)
753
+ encode: (str) => textEncoder.encode(str),
754
+ decode: (bytes) => textDecoder.decode(bytes)
753
755
  });
754
756
  const pluginRegistry = createPluginRegistry();
755
757
  for (const plugin of plugins) pluginRegistry.register(plugin);
@@ -1,4 +1,4 @@
1
- import { g as ErrorCode, m as ok, p as err, r as definePlugin } from "./messages-BbpF3wNH.mjs";
1
+ import { g as ErrorCode, m as ok, p as err, r as definePlugin } from "./messages-CevD8qkV.mjs";
2
2
  import { discoverySpecs } from "./plugins-discovery-events.mjs";
3
3
  import { healthSpecs } from "./plugins-health-events.mjs";
4
4
  import { logsSpecs } from "./plugins-logs-events.mjs";
@@ -84,6 +84,7 @@ function createLogsPlugin(deps) {
84
84
  * mounted at once and evict the least-recently-used so picked/captured files
85
85
  * do not accumulate on the loopback server for the whole session. */
86
86
  const MEDIA_MOUNT_CAP = 4;
87
+ const MEDIA_KINDS = ["image", "video"];
87
88
  function newRequestId() {
88
89
  const rand = crypto.randomBytes(16).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
89
90
  return `${Date.now().toString(36)}-${rand}`;
@@ -108,18 +109,20 @@ function createMediaPlugin(deps) {
108
109
  }
109
110
  };
110
111
  const serve = async (event, args) => {
112
+ const kind = args?.kind;
113
+ if (kind !== void 0 && !MEDIA_KINDS.includes(kind)) return err(ErrorCode.PLUGIN_ERROR, `Unknown media kind for ${event}`);
111
114
  const host = await deps.invokeOnHost({
112
115
  type: "INVOKE_REQUEST",
113
116
  pluginId: "vendor.media",
114
117
  event,
115
118
  requestId: newRequestId(),
116
- args
119
+ args: kind === void 0 ? {} : { kind }
117
120
  }, /* @__PURE__ */ new Uint8Array(0));
118
121
  if (!host) return err(ErrorCode.HOST_ERROR, `Host did not answer ${event}`);
119
122
  if (host.error) return err(host.error.code || ErrorCode.HOST_ERROR, host.error.message ?? `Host failed ${event}`);
120
123
  const path = host.result?.path;
121
124
  if (!path) return err(ErrorCode.HOST_ERROR, `Host returned no path for ${event}`);
122
- const id = `${args?.kind ?? "media"}-${newRequestId()}`;
125
+ const id = `${kind ?? "media"}-${newRequestId()}`;
123
126
  mountMedia(id, path);
124
127
  const url = await deps.staticServer.url(`/media/${id}`);
125
128
  return ok({ url });
@@ -1,4 +1,4 @@
1
- import { a as invokeEvent } from "./messages-BbpF3wNH.mjs";
1
+ import { a as invokeEvent } from "./messages-CevD8qkV.mjs";
2
2
  // plugins/discovery/events.ts
3
3
  const discoverySpecs = { list: {
4
4
  pluginId: "core.discovery",
@@ -1,4 +1,4 @@
1
- import { a as invokeEvent } from "./messages-BbpF3wNH.mjs";
1
+ import { a as invokeEvent } from "./messages-CevD8qkV.mjs";
2
2
  // plugins/health/events.ts
3
3
  const healthSpecs = {
4
4
  ping: {
@@ -1,4 +1,4 @@
1
- import { a as invokeEvent } from "./messages-BbpF3wNH.mjs";
1
+ import { a as invokeEvent } from "./messages-CevD8qkV.mjs";
2
2
  // plugins/logs/events.ts
3
3
  const logsSpecs = {
4
4
  view: {
@@ -1,4 +1,4 @@
1
- import { a as invokeEvent } from "./messages-BbpF3wNH.mjs";
1
+ import { a as invokeEvent } from "./messages-CevD8qkV.mjs";
2
2
  // plugins/media/events.ts
3
3
  const MEDIA_INVOKE_TIMEOUT_MS = 3e5;
4
4
  const mediaSpecs = {
@@ -1,4 +1,4 @@
1
- import { a as invokeEvent } from "./messages-BbpF3wNH.mjs";
1
+ import { a as invokeEvent } from "./messages-CevD8qkV.mjs";
2
2
  // plugins/permissions/events.ts
3
3
  const permissionSpecs = {
4
4
  request: {
@@ -1,5 +1,5 @@
1
1
  import { F as CapabilityDescriptor, Q as PluginManifest, X as PluginInvokeRequestHeader, Z as PluginInvokeResponseHeader, a as LogStore, et as RuntimeTarget } from "./index-BkwUzwVt.mjs";
2
- import { n as LoopbackServer } from "./static-file-server-C8pIX1JD.mjs";
2
+ import { n as LoopbackServer } from "./static-file-server-COxd-PFR.mjs";
3
3
  type DiscoveryPluginDeps = {
4
4
  listBareCapabilities: () => Array<{
5
5
  pluginId: string;
package/dist/plugins.mjs CHANGED
@@ -1 +1 @@
1
- export { t as createDefaultPlugins } from "./plugins-DZu_NWVp.mjs";
1
+ export { t as createDefaultPlugins } from "./plugins-Ddp2AgOe.mjs";
@@ -1,17 +1,19 @@
1
1
  import { C as createPluginRegistry, F as CapabilityDescriptor, K as MessageHeader, Q as PluginManifest, X as PluginInvokeRequestHeader, Z as PluginInvokeResponseHeader, g as MessageProtocol, o as BareRuntimeContext, tt as WireMessage, w as createPluginRouter } from "./index-BkwUzwVt.mjs";
2
- import { i as createLoopbackServer, n as LoopbackServer, r as collectRequestBody, t as LoopbackRouteHandler } from "./static-file-server-C8pIX1JD.mjs";
2
+ import { i as createLoopbackServer, n as LoopbackServer, r as collectRequestBody, t as LoopbackRouteHandler } from "./static-file-server-COxd-PFR.mjs";
3
3
  type BareIpcLike = {
4
- write(data: Uint8Array | Buffer | string): boolean;
4
+ write(data: Uint8Array | string): boolean;
5
5
  };
6
6
  declare function createHostIpcBridge(config: {
7
7
  ipc: BareIpcLike;
8
8
  protocol: MessageProtocol;
9
9
  }): {
10
10
  tryConsumeDownstream: (parsed: WireMessage) => boolean;
11
- tryConsumeDownstreamFromHost(raw: Uint8Array | ArrayBuffer | Buffer): boolean;
11
+ tryConsumeDownstreamFromHost(raw: Uint8Array | ArrayBuffer): boolean;
12
12
  queryCapabilities(timeoutMs?: number): Promise<CapabilityDescriptor[]>;
13
13
  invokeOnHost(header: PluginInvokeRequestHeader, payload: Uint8Array, timeoutMs?: number): Promise<PluginInvokeResponseHeader | null>;
14
14
  };
15
+ /** Envelope encoder bound to a loopback server's connected protocol socket —
16
+ * the server-initiated (backend → web) push seam. */
15
17
  type LoopbackPush = (header: MessageHeader, payload?: Uint8Array | ArrayBuffer | null) => boolean;
16
18
  /** Builds the push seam for the server's connected protocol socket: encodes a
17
19
  * `DISPATCH`-style envelope and writes it, mirroring how `attachWebSocketProtocol`
@@ -20,8 +22,9 @@ export declare function createLoopbackPush(server: LoopbackServer, protocol: Mes
20
22
  /**
21
23
  * Attaches the framed-protocol socket to the unified loopback server. The
22
24
  * server owns the WebSocket upgrade (origin check, cookie/token auth, single
23
- * client, idle timeout); this layer only routes decoded envelopes through the
24
- * plugin router and writes responses back as raw frames.
25
+ * client, idle timeout); this layer runs one frame channel per socket. A peer
26
+ * that sends a malformed frame is not recoverable on that socket, so it is
27
+ * torn down and the client reconnects.
25
28
  */
26
29
  export declare function attachWebSocketProtocol(server: LoopbackServer, context: BareRuntimeContext): void;
27
30
  /**
@@ -29,36 +32,36 @@ export declare function attachWebSocketProtocol(server: LoopbackServer, context:
29
32
  */
30
33
  interface BareIPC extends NodeJS.EventEmitter {
31
34
  read(n?: number): Buffer | null;
32
- write(data: Buffer | string): boolean;
35
+ write(data: Uint8Array | string): boolean;
33
36
  resume?(): void;
34
37
  pause?(): void;
35
38
  }
36
39
  /**
37
- * Returns the active IPC channel.
38
- * In BareKit (Android), it uses BareKit.IPC.
40
+ * Returns the active IPC channel, or null when there is no host.
41
+ * In BareKit (Android, iOS), it uses BareKit.IPC.
39
42
  * In Bare (Sidecar), it uses Bare.IPC.
40
43
  */
41
- export declare function getIPC(): BareIPC;
44
+ export declare function getIPC(): BareIPC | null;
42
45
  /** Resolves the host-provided worklet configuration from `start(...)`
43
46
  * arguments. On-device hosts pass `["<webAssets>", "<storage>", "<cache>"]`
44
- * (three existing directories, in that order), landing at `Bare.argv[0..2]`;
45
- * the Bare CLI (dev) passes the binary and the script path instead, so a
46
- * missing/absent config means dev mode (auth off, fixed port, no handoff
47
- * file). Falls back to the labeled CLI token syntax (see `resolveCliConfig`)
48
- * when the positional shape doesn't match, so the same entry works for both
49
- * device and dev runs. */
47
+ * (three existing directories, in that order), landing at `Bare.argv[0..2]`.
48
+ * Falls back to the labeled CLI token syntax (see `resolveCliConfig`) when the
49
+ * positional shape doesn't match, so the same entry works for device and dev
50
+ * runs. Nothing here turns auth off: the dev backend is selected only by an
51
+ * explicit `mode=dev` token. */
50
52
  export declare function resolveWorkletConfig(): WorkletRuntimeOptions;
51
53
  /**
52
- * Parses the dev/CLI labeled-token configuration from bare CLI arguments:
53
- * `webassets=<dir>`, `storage=<dir>`, `cache=<dir>`, `host=<addr>`,
54
- * `port=<n>`, `auth=<on|off|true|false|1|0>` — a single `key=value` separator.
55
- * Unknown labels (e.g. `bootstrap=127.0.0.1:49737`) are ignored and stay
56
- * consumer-owned.
54
+ * Parses labeled-token configuration from bare CLI arguments, one `key=value`
55
+ * per token: `webassets=<dir>`, `storage=<dir>`, `cache=<dir>`,
56
+ * `host=<addr>`, `port=<n>`, `auth=<on|off|true|false|1|0>`,
57
+ * `mode=<device|dev>` and `origin=<scheme://host[:port]>` (repeatable; an
58
+ * extra allowed page origin, e.g. the Vite dev server). Unknown labels (e.g.
59
+ * `bootstrap=127.0.0.1:49737`) are ignored and stay consumer-owned.
57
60
  *
58
- * Returns the equivalent `WorkletRuntimeOptions` in **dev mode**: `deviceMode:
59
- * false` (so a `storage=` token does not flip auth on or make the port
60
- * ephemeral), `auth: false`, `port: 8080`, each overridable by a token. When
61
- * no labeled tokens are present it returns `{}` (plain dev defaults).
61
+ * Without `mode=dev` the result keeps the secure device defaults (auth on, an
62
+ * ephemeral port), and `auth=off` is ignored with a warning. `mode=dev` selects the dev backend: `deviceMode: false`,
63
+ * `auth: false`, `port: 8080`, each overridable by a token. Returns `{}` when
64
+ * no labeled tokens are present.
62
65
  */
63
66
  export declare function resolveCliConfig(argv?: readonly string[]): WorkletRuntimeOptions;
64
67
  export type WorkletRuntimeOptions = {
@@ -73,16 +76,21 @@ export type WorkletRuntimeOptions = {
73
76
  /** Port to bind; `0` picks an ephemeral port (on-device). Defaults to an
74
77
  * ephemeral port in device mode, 8080 in dev mode. */
75
78
  port?: number;
76
- /** Require the per-session token/cookie. Defaults to on in device mode. */
79
+ /** Require the per-session token/cookie. Defaults to on in device mode and
80
+ * off in dev mode. The origin check applies either way. */
77
81
  auth?: boolean;
78
- /** Write the credentials to `<storage>/handoff.json` for the host to read
79
- * (auth on only). Defaults to true; a host that receives them over its own
80
- * channel (the desktop host reads them from a pipe) sets false so the
81
- * session token never touches disk. */
82
+ /** Extra page origins allowed to call the loopback server, e.g. the Vite dev
83
+ * server (`http://localhost:5173`). The bound origin and its loopback
84
+ * aliases are always allowed. */
85
+ allowedOrigins?: readonly string[];
86
+ /** Write `{ origin, port, bootstrap }` to `<storage>/handoff.json` for the
87
+ * host to read (auth on only; the session token is never written). Defaults
88
+ * to true; a host that receives them over its own channel (the desktop host
89
+ * reads them from a pipe) sets false so nothing touches disk. */
82
90
  handoffFile?: boolean;
83
- /** Explicit device-mode override. Defaults to on whenever `storage` is set
84
- * (the on-device 3-arg contract); `resolveCliConfig` resolves it to false so
85
- * a dev backend keeps auth off + fixed port even with a storage dir. */
91
+ /** Device mode (the default): auth on, an ephemeral port, and the handoff
92
+ * file when `storage` is set. Pass `false` (or the `mode=dev` CLI token) for
93
+ * the dev backend: auth off and port 8080 unless set explicitly. */
86
94
  deviceMode?: boolean;
87
95
  /** Additional plugin manifests to register after the canonical defaults
88
96
  * (consumer product plugins, e.g. `app.photos`). */
@@ -93,7 +101,8 @@ export type WorkletRuntimeOptions = {
93
101
  * is destroyed, freeing the slot. Defaults to 10s. */
94
102
  wsDeadPeerTimeoutMs?: number;
95
103
  /** Minimum gap (ms) before an accepted protocol socket may be replaced by a
96
- * new authenticated one. Defaults to 1s. */
104
+ * new authenticated one; an upgrade that arrives sooner is held until then.
105
+ * Defaults to 1s. */
97
106
  wsReplaceMinIntervalMs?: number;
98
107
  /** Bytes queued behind a protocol socket whose reader stopped reading before
99
108
  * server pushes are dropped (newest first). Defaults to 2 MiB. */