@phreshos/node 0.1.12 → 0.1.14

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/dist/project.js CHANGED
@@ -6,7 +6,9 @@ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
6
  import { readFile } from "node:fs/promises";
7
7
  import { delimiter, isAbsolute, join, normalize, resolve, sep } from "node:path";
8
8
  import { createJiti } from "jiti";
9
+ import DevelopmentClient from "./development-client.js";
9
10
  const configFile = "phresh.config.ts";
11
+ const defaultClientPort = 5200;
10
12
  /** One loaded Program authoring project rooted at an absolute directory. */
11
13
  export class Project {
12
14
  directory;
@@ -52,10 +54,10 @@ export class Project {
52
54
  developmentDefinition() {
53
55
  return this.definition("development");
54
56
  }
55
- definition(mode) {
57
+ definition(mode, developmentClientUrl) {
56
58
  const config = this.config;
57
59
  const server = serverHalf(config.server, mode);
58
- const client = clientHalf(config.client, mode);
60
+ const client = clientHalf(config.client, mode, developmentClientUrl);
59
61
  if (mode === "development" && !config.server?.development && !config.client?.development) {
60
62
  throw new Error("Nothing here says how this Program is developed");
61
63
  }
@@ -83,7 +85,8 @@ export class Project {
83
85
  size: config.client?.size,
84
86
  position: config.client?.position,
85
87
  layer: config.client?.layer,
86
- minimize: config.client?.minimize
88
+ minimize: config.client?.minimize,
89
+ permissions: config.client?.permissions
87
90
  } }
88
91
  };
89
92
  }
@@ -115,9 +118,34 @@ export class Project {
115
118
  await this.build();
116
119
  return await this.run(system, this.productionDefinition(), options);
117
120
  }
118
- /** Return this Project's development Process lifecycle generator. */
121
+ /** Prepare this Project's Client development source and return its Process lifecycle. */
119
122
  async dev(system, options = {}) {
120
- return await this.run(system, this.developmentDefinition(), options);
123
+ const development = this.config.client?.development;
124
+ const startsClient = Boolean(development && (this.config.client?.start ?? true));
125
+ if (!startsClient || !development)
126
+ return await this.run(system, this.developmentDefinition(), options);
127
+ if (development.startCommand)
128
+ return this.developmentRun(system, development, options);
129
+ const prepared = await this.prepareDevelopment(system, development, options);
130
+ return prepared.client.supervise(prepared.lifecycle);
131
+ }
132
+ async *developmentRun(system, development, options) {
133
+ const prepared = await this.prepareDevelopment(system, development, options);
134
+ yield* prepared.client.supervise(prepared.lifecycle);
135
+ }
136
+ async prepareDevelopment(system, development, options) {
137
+ const client = await DevelopmentClient.prepare(development, this.directory);
138
+ const program = await system.forceCreateProgram(this.definition("development", client.url));
139
+ try {
140
+ await client.start(program.assetId, options.signal);
141
+ const lifecycle = program.process.run({ options: options.options ?? {} }, { signal: client.processSignal(options.signal) });
142
+ return { client, lifecycle };
143
+ }
144
+ catch (error) {
145
+ await client.dispose(error);
146
+ await program.forget().catch(() => undefined);
147
+ throw error;
148
+ }
121
149
  }
122
150
  /** Build this Project and return its Program installation generator. */
123
151
  async install(system) {
@@ -171,18 +199,20 @@ function serverHalf(half, mode) {
171
199
  function serverExecution(server) {
172
200
  return server.startCommand !== undefined ? { startCommand: server.startCommand } : { entryFile: server.entryFile };
173
201
  }
174
- function clientHalf(half, mode) {
202
+ function clientHalf(half, mode, developmentUrl) {
175
203
  if (!half)
176
204
  return null;
177
205
  const { development, ...declared } = half;
178
- return mode === "development" && development ? { ...declared, location: development.url } : declared;
206
+ return mode === "development" && development
207
+ ? { ...declared, location: developmentUrl ?? development.url ?? `http://localhost:${defaultClientPort}/` }
208
+ : declared;
179
209
  }
180
210
  function validateConfig(config) {
181
211
  if (typeof config.identity !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(config.identity)) {
182
212
  throw new Error("A Program's identity must be kebab-case");
183
213
  }
184
214
  if (!config.server && !config.client)
185
- throw new Error("A Program must have a Server half, a Client half, or both");
215
+ throw new Error("A Program must have a Server Endpoint, a Client Endpoint, or both");
186
216
  for (const field of ["name", "version", "description", "icon", "agent", "website"]) {
187
217
  if (config[field] !== undefined && typeof config[field] !== "string")
188
218
  throw new Error(`A Program's ${field} must be text`);
@@ -212,14 +242,23 @@ function validateConfig(config) {
212
242
  throw new Error("A Program's default Process must start a Server Endpoint, a Client Endpoint, or both");
213
243
  }
214
244
  if (config.server)
215
- execution(config.server, "A Server half");
245
+ execution(config.server, "A Server Endpoint");
216
246
  if (config.server?.development)
217
247
  execution(config.server.development, "server.development");
218
248
  if (config.client?.layer !== undefined && !layers.includes(config.client.layer)) {
219
- throw new Error(`A Client half's layer must be one of ${layers.join(", ")}`);
249
+ throw new Error(`A Client Endpoint's layer must be one of ${layers.join(", ")}`);
220
250
  }
221
- if (config.client?.development && !httpUrl(config.client.development.url)) {
222
- throw new Error("client.development.url must be a valid HTTP or HTTPS URL");
251
+ if (config.client?.development) {
252
+ const development = config.client.development;
253
+ if (development.url !== undefined && !httpUrl(development.url)) {
254
+ throw new Error("client.development.url must be a valid HTTP or HTTPS URL");
255
+ }
256
+ if (development.startCommand !== undefined && (typeof development.startCommand !== "string" || !development.startCommand.trim())) {
257
+ throw new Error("client.development.startCommand must be non-empty text");
258
+ }
259
+ if (development.url === undefined && development.startCommand === undefined) {
260
+ throw new Error("client.development must declare a URL or start command");
261
+ }
223
262
  }
224
263
  for (const [name, value] of [["size", config.client?.size], ["position", config.client?.position]]) {
225
264
  if (value === undefined)
@@ -286,7 +325,8 @@ function packageDefinition(config, version) {
286
325
  size: config.client.size,
287
326
  position: config.client.position,
288
327
  layer: config.client.layer,
289
- minimize: config.client.minimize
328
+ minimize: config.client.minimize,
329
+ permissions: config.client.permissions
290
330
  } }
291
331
  };
292
332
  }
@@ -0,0 +1,100 @@
1
+ import type { Appearance, ClientDeclaration, EndpointDeclaration, ServiceKey, WindowGeometry, WindowLayer } from "@phreshos/core";
2
+ import type { GatewayConnection } from "./transport.js";
3
+ export interface ProgramState {
4
+ reference: string;
5
+ identity: string;
6
+ assetId: string;
7
+ installed: boolean;
8
+ name: string;
9
+ version: string | null;
10
+ description: string | null;
11
+ hasAgent: boolean;
12
+ server: EndpointDeclaration | null;
13
+ client: ClientDeclaration | null;
14
+ }
15
+ export interface WindowState {
16
+ title: string;
17
+ position: WindowGeometry["position"];
18
+ size: WindowGeometry["size"];
19
+ depth: number;
20
+ minimized: boolean;
21
+ layer: WindowLayer;
22
+ location: string;
23
+ }
24
+ export interface ProcessState {
25
+ reference: string;
26
+ identity: string;
27
+ name: string | null;
28
+ program: string;
29
+ parent: ProcessAddress | null;
30
+ options: Record<string, string>;
31
+ startedAt: Date;
32
+ server: {
33
+ ready: boolean;
34
+ service: boolean;
35
+ } | null;
36
+ client: {
37
+ service: boolean;
38
+ sameOrigin: boolean;
39
+ window: WindowState;
40
+ } | null;
41
+ }
42
+ export interface ProcessAddress {
43
+ reference: string;
44
+ identity: string;
45
+ }
46
+ export type Observation = Readonly<{
47
+ scope: "endpoint";
48
+ process: string;
49
+ endpoint: "server" | "client";
50
+ event: string | null;
51
+ }> | Readonly<{
52
+ scope: "traffic";
53
+ process: string;
54
+ endpoint: "server" | "client";
55
+ kind: "publish" | "ask" | "answer";
56
+ event: string | null;
57
+ }> | Readonly<{
58
+ scope: "service";
59
+ key: ServiceKey;
60
+ kind: "events" | "lifecycle";
61
+ event: string | null;
62
+ }>;
63
+ type Listener = (...values: unknown[]) => unknown;
64
+ interface CommandEvent {
65
+ event?: string;
66
+ [key: string]: unknown;
67
+ }
68
+ /** A connection-owned, live representation of the authoritative System model. */
69
+ export default class SystemRepresentation {
70
+ private readonly connection;
71
+ readonly authorization: string;
72
+ readonly programs: Map<string, ProgramState>;
73
+ readonly processes: Map<string, ProcessState>;
74
+ appearance: Appearance;
75
+ private readonly listeners;
76
+ private readonly release;
77
+ constructor(connection: GatewayConnection);
78
+ activate(): void;
79
+ close(): void;
80
+ call<Result = unknown>(event: string, ...values: unknown[]): Promise<Result>;
81
+ on(event: string, listener: Listener): () => void;
82
+ follow(observation: Observation, listener: (event: string, value: unknown) => unknown, impossible?: (error: Error) => void): () => void;
83
+ /** Stream one Program command through the live LinkManager connection. */
84
+ command(operation: "install" | "uninstall" | "run", subject: ProgramAddress, value: unknown, signal?: AbortSignal): AsyncGenerator<CommandEvent, void, unknown>;
85
+ private followModel;
86
+ private arriveProgram;
87
+ private uninstallProgram;
88
+ private forgetProgram;
89
+ private createProcess;
90
+ private serverReady;
91
+ private changeEndpoint;
92
+ private exitProcess;
93
+ private changeWindow;
94
+ private emit;
95
+ }
96
+ export type ProgramAddress = Readonly<{
97
+ identity: string;
98
+ reference: string;
99
+ }>;
100
+ export {};
@@ -0,0 +1,331 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const maximumStreamQueue = 256;
3
+ /** A connection-owned, live representation of the authoritative System model. */
4
+ export default class SystemRepresentation {
5
+ connection;
6
+ authorization;
7
+ programs = new Map();
8
+ processes = new Map();
9
+ appearance;
10
+ listeners = new Map();
11
+ release = [];
12
+ constructor(connection) {
13
+ this.connection = connection;
14
+ const session = ownerSession(connection.session);
15
+ this.authorization = session.authorization;
16
+ this.appearance = session.linkManager.appearance.value;
17
+ for (const [, value] of session.authManager.programManager.programs) {
18
+ const program = programState(value);
19
+ this.programs.set(program.identity, program);
20
+ }
21
+ for (const [, value] of session.authManager.processManager.processes) {
22
+ const process = processState(value);
23
+ this.processes.set(process.identity, process);
24
+ }
25
+ this.followModel(session.linkManager.appearance.key);
26
+ }
27
+ activate() { this.connection.activate(); }
28
+ close() {
29
+ for (const stop of this.release.splice(0))
30
+ stop();
31
+ this.listeners.clear();
32
+ }
33
+ call(event, ...values) {
34
+ return this.connection.call(`/auth${event}`, this.authorization, ...values);
35
+ }
36
+ on(event, listener) {
37
+ const listeners = this.listeners.get(event) ?? new Set();
38
+ listeners.add(listener);
39
+ this.listeners.set(event, listeners);
40
+ return () => {
41
+ listeners.delete(listener);
42
+ if (!listeners.size)
43
+ this.listeners.delete(event);
44
+ };
45
+ }
46
+ follow(observation, listener, impossible) {
47
+ const subscription = randomUUID();
48
+ let active = true;
49
+ const stopEvent = this.connection.subscribe("/auth/process/followed", (received, event, value) => {
50
+ if (active && received === subscription && typeof event === "string")
51
+ listener(event, value);
52
+ });
53
+ const stopImpossible = this.connection.subscribe("/auth/process/impossible", (received, reason) => {
54
+ if (!active || received !== subscription)
55
+ return;
56
+ stop();
57
+ impossible?.(new Error(String(reason)));
58
+ });
59
+ const stop = () => {
60
+ if (!active)
61
+ return;
62
+ active = false;
63
+ stopEvent();
64
+ stopImpossible();
65
+ void this.call("/process/unfollow", subscription).catch(() => undefined);
66
+ };
67
+ void this.call("/process/follow", subscription, observation).catch(error => {
68
+ if (!active)
69
+ return;
70
+ stop();
71
+ impossible?.(exception(error));
72
+ });
73
+ return stop;
74
+ }
75
+ /** Stream one Program command through the live LinkManager connection. */
76
+ command(operation, subject, value, signal) {
77
+ const stream = randomUUID();
78
+ const queue = [];
79
+ let wake = null;
80
+ let ended = false;
81
+ let failure = null;
82
+ const stopOutput = this.connection.subscribe("/auth/program/command-output", (received, output) => {
83
+ if (received !== stream || ended)
84
+ return;
85
+ if (!record(output))
86
+ failure = new Error("The System returned an invalid Program command event");
87
+ else if (queue.length >= maximumStreamQueue)
88
+ failure = new Error(`System stream queue exceeded its capacity of ${maximumStreamQueue}`);
89
+ else
90
+ queue.push(output);
91
+ wake?.();
92
+ wake = null;
93
+ });
94
+ const cancel = () => { void this.call("/program/command-cancel", stream).catch(() => undefined); };
95
+ const abort = () => {
96
+ failure = abortReason(signal);
97
+ cancel();
98
+ wake?.();
99
+ wake = null;
100
+ };
101
+ if (signal?.aborted) {
102
+ failure = abortReason(signal);
103
+ ended = true;
104
+ }
105
+ else
106
+ signal?.addEventListener("abort", abort, { once: true });
107
+ const running = ended
108
+ ? Promise.resolve()
109
+ : this.call("/program/command", stream, operation, subject, value, "").then(() => { ended = true; wake?.(); wake = null; }, error => { failure = signal?.aborted ? abortReason(signal) : exception(error); ended = true; wake?.(); wake = null; });
110
+ return (async function* () {
111
+ try {
112
+ while (true) {
113
+ if (queue.length) {
114
+ yield queue.shift();
115
+ continue;
116
+ }
117
+ if (failure)
118
+ throw failure;
119
+ if (ended)
120
+ return;
121
+ await new Promise(resolve => { wake = resolve; });
122
+ }
123
+ }
124
+ finally {
125
+ stopOutput();
126
+ signal?.removeEventListener("abort", abort);
127
+ if (!ended)
128
+ cancel();
129
+ await running.catch(() => undefined);
130
+ }
131
+ })();
132
+ }
133
+ followModel(appearance) {
134
+ const subscribe = (event, listener) => this.release.push(this.connection.subscribe(event, listener));
135
+ subscribe(`property-update:${appearance}`, value => {
136
+ this.appearance = value;
137
+ this.emit("appearance", this.appearance);
138
+ });
139
+ subscribe("/auth/program/create", value => this.arriveProgram("create", value));
140
+ subscribe("/auth/program/install", value => this.arriveProgram("install", value));
141
+ subscribe("/auth/program/uninstall", (value, everything) => this.uninstallProgram(value, everything === true));
142
+ subscribe("/auth/program/forget", value => this.forgetProgram(value));
143
+ subscribe("/auth/process/created", value => this.createProcess(value));
144
+ subscribe("/auth/process/server-ready", identity => this.serverReady(identity));
145
+ subscribe("/auth/process/server-start", (identity, value) => this.changeEndpoint(identity, "server", value));
146
+ subscribe("/auth/process/server-stop", (identity, value) => this.changeEndpoint(identity, "server", value, false));
147
+ subscribe("/auth/process/client-start", (identity, value) => this.changeEndpoint(identity, "client", value));
148
+ subscribe("/auth/process/client-stop", (identity, value) => this.changeEndpoint(identity, "client", value, false));
149
+ subscribe("/auth/process/client-access", (identity, value) => this.changeEndpoint(identity, "client", value));
150
+ subscribe("/auth/process/exited", (value, code, signal) => this.exitProcess(value, code, signal));
151
+ for (const event of ["move", "resize", "geometry", "change-title", "raise", "minimize"]) {
152
+ subscribe(`/auth/process/${event}`, value => this.changeWindow(event, value));
153
+ }
154
+ }
155
+ arriveProgram(event, value) {
156
+ const program = programState(value);
157
+ this.programs.set(program.identity, program);
158
+ this.emit(`program:${program.reference}:change`, program);
159
+ this.emit(`program:${event}`, program);
160
+ }
161
+ uninstallProgram(value, everything) {
162
+ const program = programState(value);
163
+ this.programs.set(program.identity, program);
164
+ this.emit(`program:${program.reference}:change`, program);
165
+ this.emit(`program:${program.reference}:uninstall`, everything);
166
+ this.emit("program:uninstall", program, everything);
167
+ }
168
+ forgetProgram(value) {
169
+ if (typeof value !== "string")
170
+ return;
171
+ const program = this.programs.get(value);
172
+ if (!program)
173
+ return;
174
+ this.programs.delete(value);
175
+ this.emit(`program:${program.reference}:forget`);
176
+ this.emit("program:forget", program);
177
+ }
178
+ createProcess(value) {
179
+ const process = processState(value);
180
+ this.processes.set(process.identity, process);
181
+ this.emit("process:create", process);
182
+ this.emit(`program:${process.program}:process:create`, process);
183
+ }
184
+ serverReady(value) {
185
+ if (typeof value !== "string")
186
+ return;
187
+ const process = this.processes.get(value);
188
+ if (!process?.server)
189
+ return;
190
+ process.server.ready = true;
191
+ this.emit(`endpoint:${process.reference}:server:ready`);
192
+ }
193
+ changeEndpoint(identity, endpoint, value, running = true) {
194
+ if (typeof identity !== "string")
195
+ return;
196
+ const current = this.processes.get(identity);
197
+ if (!current)
198
+ return;
199
+ const incoming = processState(value);
200
+ current.server = incoming.server;
201
+ current.client = incoming.client;
202
+ this.emit(`endpoint:${current.reference}:${endpoint}:${running ? "start" : "stop"}`);
203
+ this.emit(`process:${current.reference}:change`, current);
204
+ }
205
+ exitProcess(value, code, signal) {
206
+ const received = processState(value);
207
+ const process = this.processes.get(received.identity) ?? received;
208
+ this.processes.delete(process.identity);
209
+ const exit = {
210
+ status: typeof signal === "string" ? "signaled" : "exited",
211
+ code: typeof code === "number" ? code : null,
212
+ signal: typeof signal === "string" ? signal : null
213
+ };
214
+ this.emit(`process:${process.reference}:exit`, exit);
215
+ this.emit("process:exit", process, exit);
216
+ this.emit(`program:${process.program}:process:exit`, process, exit);
217
+ }
218
+ changeWindow(event, value) {
219
+ if (!record(value) || typeof value.identity !== "string" || !record(value.window))
220
+ return;
221
+ const process = this.processes.get(value.identity);
222
+ if (!process?.client)
223
+ return;
224
+ process.client.window = windowState(value.window);
225
+ this.emit(`window:${process.identity}:${camel(event)}`, windowMessage(event, process));
226
+ this.emit(`process:${process.reference}:change`, process);
227
+ }
228
+ emit(event, ...values) {
229
+ for (const listener of this.listeners.get(event) ?? [])
230
+ listener(...values);
231
+ }
232
+ }
233
+ function ownerSession(value) {
234
+ if (!record(value) || typeof value.authorization !== "string")
235
+ throw new Error("The System Gateway returned an invalid owner session");
236
+ const linkManager = value.linkManager;
237
+ const authManager = value.authManager;
238
+ if (!record(linkManager) || !record(linkManager.appearance) || typeof linkManager.appearance.key !== "string")
239
+ throw new Error("The System Gateway returned invalid Appearance state");
240
+ if (!record(authManager) || !record(authManager.programManager) || !record(authManager.processManager))
241
+ throw new Error("The System Gateway returned an invalid System state");
242
+ const programs = authManager.programManager.programs;
243
+ const processes = authManager.processManager.processes;
244
+ if (!Array.isArray(programs) || !Array.isArray(processes))
245
+ throw new Error("The System Gateway returned invalid domain collections");
246
+ return {
247
+ authorization: value.authorization,
248
+ linkManager: { appearance: { key: linkManager.appearance.key, value: linkManager.appearance.value } },
249
+ authManager: {
250
+ programManager: { programs: programs },
251
+ processManager: { processes: processes }
252
+ }
253
+ };
254
+ }
255
+ function programState(value) {
256
+ if (!record(value) || typeof value.reference !== "string" || typeof value.identity !== "string" || typeof value.assetId !== "string" || typeof value.name !== "string") {
257
+ throw new Error("The System returned an invalid Program");
258
+ }
259
+ return {
260
+ reference: value.reference,
261
+ identity: value.identity,
262
+ assetId: value.assetId,
263
+ installed: value.installed === true,
264
+ name: value.name,
265
+ version: typeof value.version === "string" ? value.version : null,
266
+ description: typeof value.description === "string" ? value.description : null,
267
+ hasAgent: value.hasAgent === true,
268
+ server: value.server,
269
+ client: value.client
270
+ };
271
+ }
272
+ function processState(value) {
273
+ if (!record(value) || typeof value.reference !== "string" || typeof value.identity !== "string" || typeof value.program !== "string" || !record(value.options)) {
274
+ throw new Error("The System returned an invalid Process");
275
+ }
276
+ const startedAt = value.startedAt instanceof Date ? value.startedAt : new Date(String(value.startedAt));
277
+ if (Number.isNaN(startedAt.getTime()))
278
+ throw new Error("The System returned an invalid Process start time");
279
+ return {
280
+ reference: value.reference,
281
+ identity: value.identity,
282
+ name: typeof value.name === "string" ? value.name : null,
283
+ program: value.program,
284
+ parent: address(value.parent),
285
+ options: value.options,
286
+ startedAt,
287
+ server: record(value.server) ? { ready: value.server.ready === true, service: value.server.service === true } : null,
288
+ client: record(value.client) ? {
289
+ service: value.client.service === true,
290
+ sameOrigin: value.client.sameOrigin === true,
291
+ window: windowState(value.client.window)
292
+ } : null
293
+ };
294
+ }
295
+ function windowState(value) {
296
+ if (!record(value) || typeof value.title !== "string" || typeof value.location !== "string" || typeof value.depth !== "number" || typeof value.minimized !== "boolean") {
297
+ throw new Error("The System returned an invalid Window");
298
+ }
299
+ return {
300
+ title: value.title,
301
+ position: value.position,
302
+ size: value.size,
303
+ depth: value.depth,
304
+ minimized: value.minimized,
305
+ layer: value.layer,
306
+ location: value.location
307
+ };
308
+ }
309
+ function address(value) {
310
+ if (!record(value) || typeof value.reference !== "string" || typeof value.identity !== "string")
311
+ return null;
312
+ return { reference: value.reference, identity: value.identity };
313
+ }
314
+ function windowMessage(event, process) {
315
+ const window = process.client.window;
316
+ if (event === "move")
317
+ return window.position;
318
+ if (event === "resize")
319
+ return window.size;
320
+ if (event === "geometry")
321
+ return { position: window.position, size: window.size };
322
+ if (event === "change-title")
323
+ return window.title;
324
+ if (event === "minimize")
325
+ return window.minimized;
326
+ return true;
327
+ }
328
+ function camel(value) { return value === "change-title" ? "changeTitle" : value; }
329
+ function record(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
330
+ function exception(error) { return error instanceof Error ? error : new Error(String(error)); }
331
+ function abortReason(signal) { return signal.reason instanceof Error ? signal.reason : new Error("The operation was cancelled"); }
@@ -0,0 +1,3 @@
1
+ import type { ShellEvent, ShellOptions } from "@phreshos/core";
2
+ /** Execute one command locally while its iterator owns the complete process tree. */
3
+ export default function shell(command: string, options?: ShellOptions): AsyncGenerator<ShellEvent, void, void>;