@phreshos/client 0.1.0 → 0.1.2

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.
@@ -0,0 +1,54 @@
1
+ import Events from "./events.js";
2
+ import wire from "./wire.js";
3
+ /** Client pointer access bound to the current Process boundary. */
4
+ export default class ClientPointer extends Events {
5
+ listeners = 0;
6
+ sample = (event) => wire.samplePointer(event.movementX, event.movementY);
7
+ constructor() {
8
+ super((event, listener, impossible) => this.withSampling(event === "move", wire.on("host-pointer", event, value => {
9
+ const position = createPosition(value);
10
+ if (position)
11
+ listener(position);
12
+ }, null, impossible)), observer => this.withSampling(true, wire.onAll("host-pointer", (event, value) => {
13
+ const position = createPosition(value);
14
+ if (event === "move" && position)
15
+ observer(event, position);
16
+ })));
17
+ }
18
+ async position() {
19
+ const answer = await wire.request(["pointer"]);
20
+ if (answer[0] === null)
21
+ return null;
22
+ const position = createPosition(answer[0]);
23
+ if (!position)
24
+ throw new Error("The desktop returned an invalid Pointer position");
25
+ return position;
26
+ }
27
+ withSampling(sample, stop) {
28
+ if (!sample)
29
+ return stop;
30
+ if (this.listeners++ === 0)
31
+ window.addEventListener("pointermove", this.sample);
32
+ let active = true;
33
+ return () => {
34
+ if (!active)
35
+ return;
36
+ active = false;
37
+ stop();
38
+ this.listeners--;
39
+ if (this.listeners === 0)
40
+ window.removeEventListener("pointermove", this.sample);
41
+ };
42
+ }
43
+ }
44
+ function createPosition(value) {
45
+ if (typeof value !== "object" || value === null || Array.isArray(value))
46
+ return null;
47
+ const position = value;
48
+ if (!finite(position.x) || !finite(position.y))
49
+ return null;
50
+ return Object.freeze({ x: position.x, y: position.y });
51
+ }
52
+ function finite(value) {
53
+ return typeof value === "number" && Number.isFinite(value);
54
+ }
@@ -0,0 +1,29 @@
1
+ import type { Subscribable } from "@phreshos/core";
2
+ import Events from "./events.js";
3
+ /** The available workspace of this Client Window's layer. */
4
+ export type Surface = Readonly<{
5
+ /** Available width in CSS pixels. */
6
+ width: number;
7
+ /** Available height in CSS pixels. */
8
+ height: number;
9
+ }>;
10
+ /** Live changes to this Client Window's surface. */
11
+ export type SurfaceEvents = {
12
+ /** The available workspace resized. */
13
+ resize: Surface;
14
+ };
15
+ /** Explicit surface size reads and future resizes. */
16
+ export interface HostSurface extends Subscribable<SurfaceEvents, never> {
17
+ /** Reads this Client Window's current surface. */
18
+ size(): Promise<Surface>;
19
+ }
20
+ /** Client surface access bound to the current Process boundary. */
21
+ export default class ClientSurface extends Events {
22
+ constructor();
23
+ size(): Promise<Readonly<{
24
+ /** Available width in CSS pixels. */
25
+ width: number;
26
+ /** Available height in CSS pixels. */
27
+ height: number;
28
+ }>>;
29
+ }
@@ -0,0 +1,34 @@
1
+ import Events from "./events.js";
2
+ import wire from "./wire.js";
3
+ /** Client surface access bound to the current Process boundary. */
4
+ export default class ClientSurface extends Events {
5
+ constructor() {
6
+ super((event, listener, impossible) => wire.on("host-surface", event, value => {
7
+ const surface = createSurface(value);
8
+ if (surface)
9
+ listener(surface);
10
+ }, null, impossible), observer => wire.onAll("host-surface", (event, value) => {
11
+ const surface = createSurface(value);
12
+ if (typeof event === "string" && surface)
13
+ observer(event, surface);
14
+ }));
15
+ }
16
+ async size() {
17
+ const answer = await wire.request(["surface"]);
18
+ const surface = createSurface(answer[0]);
19
+ if (!surface)
20
+ throw new Error("The desktop returned an invalid Surface size");
21
+ return surface;
22
+ }
23
+ }
24
+ function createSurface(value) {
25
+ if (typeof value !== "object" || value === null || Array.isArray(value))
26
+ return null;
27
+ const surface = value;
28
+ if (!finite(surface.width) || !finite(surface.height))
29
+ return null;
30
+ return Object.freeze({ width: surface.width, height: surface.height });
31
+ }
32
+ function finite(value) {
33
+ return typeof value === "number" && Number.isFinite(value);
34
+ }
@@ -0,0 +1,13 @@
1
+ import Events from "./events.js";
2
+ /** Read-only system Theme reached explicitly through the desktop boundary. */
3
+ export default class ClientTheme extends Events {
4
+ constructor();
5
+ snapshot(): Promise<Readonly<{
6
+ background: string;
7
+ foreground: string;
8
+ accent: string;
9
+ spacing: number;
10
+ radius: number;
11
+ glass: import("@phreshos/core").ThemeGlass;
12
+ }>>;
13
+ }
package/dist/theme.js ADDED
@@ -0,0 +1,29 @@
1
+ import { createThemeSnapshot } from "@phreshos/core";
2
+ import Events from "./events.js";
3
+ import wire from "./wire.js";
4
+ /** Read-only system Theme reached explicitly through the desktop boundary. */
5
+ export default class ClientTheme extends Events {
6
+ constructor() {
7
+ super((event, listener, impossible) => wire.on("host-theme", event, value => {
8
+ if (isObject(value))
9
+ listener(createThemeSnapshot(value));
10
+ }, null, impossible), observer => wire.onAll("host-theme", (event, value) => {
11
+ if (typeof event === "string" && isObject(value))
12
+ observer(event, createThemeSnapshot(value));
13
+ }));
14
+ }
15
+ async snapshot() {
16
+ const answer = await wire.request(["theme"]);
17
+ if (!isObject(answer[0]))
18
+ throw new Error("The desktop returned an invalid Theme snapshot");
19
+ return createThemeSnapshot(answer[0]);
20
+ }
21
+ }
22
+ function isObject(value) {
23
+ if (typeof value !== "object" || value === null || Array.isArray(value))
24
+ return false;
25
+ const theme = value;
26
+ return typeof theme.background === "string" && typeof theme.foreground === "string" && typeof theme.accent === "string"
27
+ && typeof theme.spacing === "number" && typeof theme.radius === "number"
28
+ && typeof theme.glass === "object" && theme.glass !== null;
29
+ }
package/dist/wire.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Cleanup } from "@phreshos/core";
2
2
  import Deadline from "./deadline.js";
3
+ import type { HandleAddress } from "./domain.js";
3
4
  type Handler = (...values: unknown[]) => unknown;
4
5
  type Failure = (error: Error) => void;
5
6
  type TrafficKind = "publish" | "ask" | "answer";
@@ -10,20 +11,27 @@ declare class Wire {
10
11
  private readonly subscribers;
11
12
  private readonly every;
12
13
  private readonly impossible;
13
- private readonly boundarySubscriptions;
14
- private readonly observations;
15
- readonly identity: Promise<string>;
14
+ private identityPromise;
16
15
  constructor();
17
16
  send(route: string, ...values: unknown[]): void;
18
17
  request(values: unknown[], timeout?: number, transfer?: Transferable[]): Promise<unknown>;
18
+ /** Requests a value while treating the caller's deadline as an absent answer. */
19
+ requestOrNull(values: unknown[], timeout: number): Promise<unknown | null>;
19
20
  requestWithin(values: unknown[], deadline: Deadline, transfer?: Transferable[]): Promise<unknown>;
20
21
  askServerWithin(event: string, payload: unknown, deadline: Deadline): Promise<unknown>;
22
+ /** Resolves this endpoint's Process address only for operations that need it. */
23
+ identity(): Promise<{
24
+ process: string;
25
+ reference: string;
26
+ }>;
21
27
  private question;
22
28
  expectWithin(question: string, deadline: Deadline): Promise<unknown>;
23
29
  forget(question: string): void;
24
30
  on(route: string, event: string, handler: Handler, subject?: string | null, impossible?: Failure): Cleanup;
25
31
  onAll(route: string, handler: Handler, subject?: string | null): Cleanup;
26
- observe(target: string, half: "server" | "client", kind: TrafficKind, event: string | null, handler: Handler, impossible?: Failure): Cleanup;
32
+ observe(target: HandleAddress | null, half: "server" | "client", kind: TrafficKind, event: string | null, handler: Handler, impossible?: Failure): Cleanup;
33
+ /** Follow destinationless events emitted by one Endpoint. */
34
+ follow(target: HandleAddress | null, half: "server" | "client", event: string | null, handler: Handler, impossible?: Failure): Cleanup;
27
35
  samplePointer(movementX: number, movementY: number): void;
28
36
  private register;
29
37
  private unregister;
package/dist/wire.js CHANGED
@@ -8,37 +8,21 @@ class Wire {
8
8
  subscribers = new Map();
9
9
  every = new Map();
10
10
  impossible = new Map();
11
- boundarySubscriptions = new Map();
12
- observations = new Map();
13
- identity;
11
+ identityPromise = null;
14
12
  constructor() {
15
- let identify = () => undefined;
16
- this.identity = new Promise(resolve => { identify = resolve; });
17
13
  this.send("boundary", "document", crypto.randomUUID());
18
- this.send("boundary", "identity");
19
14
  window.addEventListener("message", event => {
20
15
  if (event.source !== this.parent || !Array.isArray(event.data))
21
16
  return;
22
17
  const [route, ...values] = event.data;
23
18
  if (route === "boundary") {
24
19
  const [operation, ...rest] = values;
25
- if (operation === "identity" && typeof rest[0] === "string")
26
- identify(rest[0]);
27
20
  if (operation === "impossible" && typeof rest[0] === "string" && typeof rest[1] === "string") {
28
21
  this.impossible.get(rest[0])?.(new Error(rest[1]));
29
22
  this.impossible.delete(rest[0]);
30
23
  }
31
24
  return;
32
25
  }
33
- if (route === "host-end" && values[0] === "observations-ready") {
34
- for (const [subscription, description] of this.boundarySubscriptions) {
35
- this.send("boundary", "subscribe", subscription, ...description);
36
- }
37
- for (const [subscription, description] of this.observations) {
38
- this.send("end-host", "observe", subscription, ...description);
39
- }
40
- return;
41
- }
42
26
  if (values[0] === "answer" && typeof values[1] === "string") {
43
27
  this.settle(values[1], values.at(-1));
44
28
  return;
@@ -52,13 +36,36 @@ class Wire {
52
36
  request(values, timeout = defaultTimeout, transfer = []) {
53
37
  return this.question("end-host", values, timeout, transfer);
54
38
  }
39
+ /** Requests a value while treating the caller's deadline as an absent answer. */
40
+ requestOrNull(values, timeout) {
41
+ return this.question("end-host", values, timeout, [], timeout, true);
42
+ }
55
43
  requestWithin(values, deadline, transfer = []) {
56
44
  return this.question("end-host", values, deadline.remaining(), transfer, deadline.milliseconds);
57
45
  }
58
46
  askServerWithin(event, payload, deadline) {
59
47
  return this.question("end-end", [event, payload], deadline.remaining(), [], deadline.milliseconds);
60
48
  }
61
- question(route, values, timeout, transfer = [], reportedTimeout = timeout) {
49
+ /** Resolves this endpoint's Process address only for operations that need it. */
50
+ identity() {
51
+ if (!this.identityPromise) {
52
+ const resolving = this.request(["process"]).then(value => {
53
+ const [record] = value;
54
+ if (typeof record?.identity !== "string" || typeof record.reference !== "string") {
55
+ throw new Error("The desktop returned an invalid Process identity");
56
+ }
57
+ return { process: record.identity, reference: record.reference };
58
+ });
59
+ const retained = resolving.catch(error => {
60
+ if (this.identityPromise === retained)
61
+ this.identityPromise = null;
62
+ throw error;
63
+ });
64
+ this.identityPromise = retained;
65
+ }
66
+ return this.identityPromise;
67
+ }
68
+ question(route, values, timeout, transfer = [], reportedTimeout = timeout, nullOnTimeout = false) {
62
69
  return new Promise((resolve, reject) => {
63
70
  let active = true;
64
71
  let question = null;
@@ -70,9 +77,13 @@ class Wire {
70
77
  this.pending.delete(question);
71
78
  this.send("boundary", "forget", question);
72
79
  }
73
- reject(new Error(`Answer timeout ${reportedTimeout}ms`));
80
+ if (nullOnTimeout)
81
+ resolve(null);
82
+ else
83
+ reject(new Error(`Answer timeout ${reportedTimeout}ms`));
74
84
  }, timeout);
75
- this.identity.then(identity => {
85
+ const begin = route === "end-end" ? this.identity() : Promise.resolve({ process: crypto.randomUUID() });
86
+ begin.then(({ process: identity }) => {
76
87
  if (!active)
77
88
  return;
78
89
  question = `client:${identity}:${crypto.randomUUID()}`;
@@ -106,6 +117,12 @@ class Wire {
106
117
  active = false;
107
118
  reject(error);
108
119
  }
120
+ }).catch(error => {
121
+ if (!active)
122
+ return;
123
+ active = false;
124
+ clearTimeout(timer);
125
+ reject(error);
109
126
  });
110
127
  });
111
128
  }
@@ -155,33 +172,39 @@ class Wire {
155
172
  observe(target, half, kind, event, handler, impossible) {
156
173
  const subscription = crypto.randomUUID();
157
174
  const stop = this.on("observed", subscription, handler);
158
- const description = [target, half, kind, event, impossible !== undefined];
159
- this.observations.set(subscription, description);
160
175
  if (impossible)
161
176
  this.impossible.set(subscription, impossible);
162
- this.send("end-host", "observe", subscription, ...description);
177
+ this.send("end-host", "observe", subscription, target, half, kind, event, impossible !== undefined);
163
178
  return once(() => {
164
179
  stop();
165
- this.observations.delete(subscription);
166
180
  this.impossible.delete(subscription);
167
181
  this.send("end-host", "unobserve", subscription);
168
182
  });
169
183
  }
184
+ /** Follow destinationless events emitted by one Endpoint. */
185
+ follow(target, half, event, handler, impossible) {
186
+ const subscription = crypto.randomUUID();
187
+ const stop = this.on("emitted", subscription, handler);
188
+ if (impossible)
189
+ this.impossible.set(subscription, impossible);
190
+ this.send("end-host", "follow", subscription, target, half, event, impossible !== undefined);
191
+ return once(() => {
192
+ stop();
193
+ this.impossible.delete(subscription);
194
+ this.send("end-host", "unfollow", subscription);
195
+ });
196
+ }
170
197
  samplePointer(movementX, movementY) {
171
198
  this.send("end-host", "pointerSample", movementX, movementY);
172
199
  }
173
200
  register(kind, route, event, subject, impossible) {
174
201
  const subscription = crypto.randomUUID();
175
- const description = [kind, route, event, subject, impossible !== undefined];
176
- this.boundarySubscriptions.set(subscription, description);
177
202
  if (impossible)
178
203
  this.impossible.set(subscription, impossible);
179
- this.send("boundary", "subscribe", subscription, ...description);
204
+ this.send("boundary", "subscribe", subscription, kind, route, event, subject, impossible !== undefined);
180
205
  return subscription;
181
206
  }
182
207
  unregister(subscription) {
183
- if (!this.boundarySubscriptions.delete(subscription))
184
- return;
185
208
  this.impossible.delete(subscription);
186
209
  this.send("boundary", "unsubscribe", subscription);
187
210
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phreshos/client",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "The SDK used by a Program's client endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -13,18 +13,43 @@
13
13
  },
14
14
  "files": [
15
15
  "dist",
16
+ "LICENSE",
16
17
  "README.md"
17
18
  ],
19
+ "author": "Zohayr SLILEH",
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/PhreshOS/client.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/PhreshOS/client/issues"
27
+ },
28
+ "homepage": "https://github.com/PhreshOS/client#readme",
29
+ "keywords": [
30
+ "phreshos",
31
+ "client",
32
+ "sdk",
33
+ "typescript"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "provenance": true
38
+ },
39
+ "packageManager": "bun@1.3.14",
18
40
  "scripts": {
41
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
19
42
  "check": "tsc --noEmit",
20
- "build": "tsc --noEmit false --outDir dist --rootDir source",
43
+ "build": "node --run clean && tsc --noEmit false --outDir dist --rootDir source",
44
+ "verify:package": "node scripts/verify-package.mjs",
45
+ "verify": "node --run check && node --run build && node --run verify:package",
21
46
  "prepack": "node --run build"
22
47
  },
23
48
  "peerDependencies": {
24
- "@phreshos/core": "^0.1.0"
49
+ "@phreshos/core": "^0.1.2"
25
50
  },
26
51
  "devDependencies": {
27
- "@phreshos/core": "0.1.0",
52
+ "@phreshos/core": "^0.1.2",
28
53
  "typescript": "^6.0.3"
29
54
  }
30
55
  }