@phreshos/node 0.1.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.
@@ -0,0 +1,272 @@
1
+ import { isRelativeValue, layers } from "@phreshos/core";
2
+ import AdmZip from "adm-zip";
3
+ import { createHash } from "node:crypto";
4
+ import { spawn } from "node:child_process";
5
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
+ import { readFile } from "node:fs/promises";
7
+ import { delimiter, isAbsolute, join, normalize, resolve, sep } from "node:path";
8
+ import { createJiti } from "jiti";
9
+ const configFile = "phresh.config.ts";
10
+ /** One loaded Program authoring project rooted at an absolute directory. */
11
+ export class Project {
12
+ directory;
13
+ config;
14
+ constructor(config, directory) {
15
+ validateConfig(config);
16
+ this.directory = resolve(directory);
17
+ this.config = Object.freeze(config);
18
+ }
19
+ /** Discover a project from cwd, a directory, or a phresh.config.ts path. */
20
+ static async open(source = process.cwd()) {
21
+ const selected = resolve(source);
22
+ const path = selected.endsWith(configFile) ? selected : resolve(selected, configFile);
23
+ if (!existsSync(path))
24
+ throw new Error(`There is no ${configFile} here — run: phresh init`);
25
+ const config = await createJiti(import.meta.url).import(path, { default: true }).catch((error) => {
26
+ throw new Error(`${configFile} could not be read (${error.message})`);
27
+ });
28
+ if (!config)
29
+ throw new Error(`${configFile} must export its config as the default export`);
30
+ return new Project(config, resolve(path, ".."));
31
+ }
32
+ /** Create a project from an already loaded definition. */
33
+ static define(config, options = {}) {
34
+ return new Project(config, options.directory ?? process.cwd());
35
+ }
36
+ /** Read this project's package manifest. */
37
+ async manifest() {
38
+ const path = resolve(this.directory, "package.json");
39
+ if (!existsSync(path))
40
+ throw new Error("There is no package.json here");
41
+ const manifest = JSON.parse(await readFile(path, "utf8"));
42
+ if (typeof manifest.name !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.name)) {
43
+ throw new Error("package.json must have a kebab-case name");
44
+ }
45
+ return manifest;
46
+ }
47
+ /** Resolve this authoring definition into one runnable Program description. */
48
+ description(mode) {
49
+ const config = this.config;
50
+ const server = serverHalf(config.server, mode);
51
+ const client = clientHalf(config.client, mode);
52
+ if (mode === "development" && !config.server?.development && !config.client?.development) {
53
+ throw new Error("Nothing here says how this Program is developed");
54
+ }
55
+ return {
56
+ identity: config.identity,
57
+ name: config.name,
58
+ version: config.version,
59
+ description: config.description,
60
+ icon: config.icon && resolve(this.directory, config.icon),
61
+ agent: config.agent && resolve(this.directory, config.agent),
62
+ storage: resolve(this.directory, "storage"),
63
+ ...server && { server: {
64
+ location: resolve(this.directory, server.location),
65
+ start: server.start,
66
+ installCommand: config.server?.installCommand,
67
+ uninstallCommand: config.server?.uninstallCommand,
68
+ ...serverExecution(server)
69
+ } },
70
+ ...client && { client: {
71
+ location: /^https?:\/\//i.test(client.location) ? client.location : resolve(this.directory, client.location),
72
+ start: client.start,
73
+ title: config.client?.title,
74
+ size: config.client?.size,
75
+ position: config.client?.position,
76
+ layer: config.client?.layer,
77
+ minimize: config.client?.minimize
78
+ } }
79
+ };
80
+ }
81
+ /** Run the optional author-owned production build command. */
82
+ async build() {
83
+ const command = this.config.buildCommand;
84
+ if (!command)
85
+ return;
86
+ await new Promise((done, fail) => {
87
+ const child = spawn(command, {
88
+ cwd: this.directory,
89
+ env: commandEnvironment(this.directory),
90
+ shell: true,
91
+ stdio: "inherit"
92
+ });
93
+ child.once("error", error => fail(new Error(`Build command failed: ${error.message}`)));
94
+ child.once("exit", (code, signal) => {
95
+ if (signal)
96
+ fail(new Error(`Build command ended on ${signal}`));
97
+ else if (code !== 0)
98
+ fail(new Error(`Build command exited with ${code ?? 0}`));
99
+ else
100
+ done();
101
+ });
102
+ });
103
+ }
104
+ /** Build and package this Program into its canonical release shape. */
105
+ async pack() {
106
+ await this.build();
107
+ const manifest = await this.manifest();
108
+ const version = this.config.version ?? manifest.version ?? "0.0.0";
109
+ const zip = new AdmZip();
110
+ if (this.config.server)
111
+ place(zip, this.directory, this.config.server.location, "server");
112
+ if (this.config.client) {
113
+ place(zip, this.directory, this.config.client.location, "client");
114
+ if (!zip.getEntry("client/index.html"))
115
+ throw new Error(`The Client files have no index.html at ${this.config.client.location}`);
116
+ }
117
+ if (this.config.icon)
118
+ file(zip, this.directory, this.config.icon, "icon.png", "Program icon");
119
+ if (this.config.agent)
120
+ file(zip, this.directory, this.config.agent, "agent.md", "Program agent documentation");
121
+ const declaration = Buffer.from(JSON.stringify(packageDescription(this.config, version), null, 4) + "\n");
122
+ zip.addFile("program.json", declaration);
123
+ const archive = `${this.config.identity}@${version}.zip`;
124
+ const bytes = zip.toBuffer();
125
+ const digest = createHash("sha256").update(bytes).digest("hex");
126
+ const archivePath = resolve(this.directory, archive);
127
+ const declarationPath = resolve(this.directory, "program.json");
128
+ const checksumPath = resolve(this.directory, `${archive}.sha256`);
129
+ writeFileSync(declarationPath, declaration);
130
+ writeFileSync(archivePath, bytes);
131
+ writeFileSync(checksumPath, `${digest} ${archive}\n`);
132
+ return Object.freeze({ archive, archivePath, checksumPath, declarationPath, digest });
133
+ }
134
+ }
135
+ function serverHalf(half, mode) {
136
+ if (!half)
137
+ return null;
138
+ const { development, startCommand, entryFile, ...description } = half;
139
+ if (mode === "production" || !development)
140
+ return { ...description, ...serverExecution({ startCommand, entryFile }) };
141
+ return { ...description, location: ".", ...serverExecution(development) };
142
+ }
143
+ function serverExecution(server) {
144
+ return server.startCommand !== undefined ? { startCommand: server.startCommand } : { entryFile: server.entryFile };
145
+ }
146
+ function clientHalf(half, mode) {
147
+ if (!half)
148
+ return null;
149
+ const { development, ...declared } = half;
150
+ return mode === "development" && development ? { ...declared, location: development.url } : declared;
151
+ }
152
+ function validateConfig(config) {
153
+ if (typeof config.identity !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(config.identity)) {
154
+ throw new Error("A Program's identity must be kebab-case");
155
+ }
156
+ if (!config.server && !config.client)
157
+ throw new Error("A Program must have a Server half, a Client half, or both");
158
+ for (const field of ["name", "version", "description", "icon", "agent", "website"]) {
159
+ if (config[field] !== undefined && typeof config[field] !== "string")
160
+ throw new Error(`A Program's ${field} must be text`);
161
+ }
162
+ if (config.agent !== undefined && config.agent.trim().length === 0)
163
+ throw new Error("A Program's agent documentation must be a non-empty path");
164
+ if (config.website !== undefined) {
165
+ try {
166
+ new URL(config.website);
167
+ }
168
+ catch {
169
+ throw new Error("A Program's website must be a valid URL");
170
+ }
171
+ }
172
+ for (const half of ["server", "client"]) {
173
+ const declared = config[half];
174
+ if (!declared)
175
+ continue;
176
+ if (typeof declared.location !== "string")
177
+ throw new Error(`A declared ${half} half must have a location`);
178
+ if (declared.start !== undefined && typeof declared.start !== "boolean")
179
+ throw new Error(`A declared ${half} Endpoint's start default must be true or false`);
180
+ }
181
+ if (!(config.server && (config.server.start ?? true)) && !(config.client && (config.client.start ?? true))) {
182
+ throw new Error("A Program's default Process must start a Server Endpoint, a Client Endpoint, or both");
183
+ }
184
+ if (config.server)
185
+ execution(config.server, "A Server half");
186
+ if (config.server?.development)
187
+ execution(config.server.development, "server.development");
188
+ if (config.client?.layer !== undefined && !layers.includes(config.client.layer)) {
189
+ throw new Error(`A Client half's layer must be one of ${layers.join(", ")}`);
190
+ }
191
+ if (config.client?.development && !httpUrl(config.client.development.url)) {
192
+ throw new Error("client.development.url must be a valid HTTP or HTTPS URL");
193
+ }
194
+ for (const [name, value] of [["size", config.client?.size], ["position", config.client?.position]]) {
195
+ if (value === undefined)
196
+ continue;
197
+ const pair = name === "size" ? [value.width, value.height] : [value.x, value.y];
198
+ if (!pair.every(isRelativeValue))
199
+ throw new Error(`A Window's ${name} values must be pixels or relative expressions`);
200
+ }
201
+ }
202
+ function execution(value, owner) {
203
+ const command = typeof value.startCommand === "string" && value.startCommand.trim().length > 0;
204
+ const entry = typeof value.entryFile === "string" && value.entryFile.trim().length > 0;
205
+ if (command === entry)
206
+ throw new Error(`${owner} must declare exactly one non-empty startCommand or entryFile`);
207
+ if (entry && !contained(value.entryFile))
208
+ throw new Error(`${owner}'s entryFile must remain inside its Server directory`);
209
+ }
210
+ function contained(entry) {
211
+ if (isAbsolute(entry))
212
+ return false;
213
+ const path = normalize(entry);
214
+ return path !== ".." && !path.startsWith(`..${sep}`);
215
+ }
216
+ function httpUrl(value) {
217
+ if (typeof value !== "string" || !value.trim())
218
+ return false;
219
+ try {
220
+ const url = new URL(value);
221
+ return url.protocol === "http:" || url.protocol === "https:";
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ }
227
+ function commandEnvironment(directory) {
228
+ const key = Object.keys(process.env).find(name => name.toLowerCase() === "path") ?? "PATH";
229
+ const inherited = process.env[key];
230
+ return { ...process.env, [key]: [join(directory, "node_modules", ".bin"), inherited].filter(Boolean).join(delimiter) };
231
+ }
232
+ function packageDescription(config, version) {
233
+ return {
234
+ identity: config.identity,
235
+ name: config.name,
236
+ version,
237
+ description: config.description,
238
+ icon: config.icon ? "icon.png" : undefined,
239
+ agent: config.agent ? "agent.md" : undefined,
240
+ categories: config.categories,
241
+ keywords: config.keywords,
242
+ website: config.website,
243
+ ...config.server && { server: {
244
+ location: "server",
245
+ start: config.server.start,
246
+ installCommand: config.server.installCommand,
247
+ uninstallCommand: config.server.uninstallCommand,
248
+ ...serverExecution(config.server)
249
+ } },
250
+ ...config.client && { client: {
251
+ location: "client",
252
+ start: config.client.start,
253
+ title: config.client.title,
254
+ size: config.client.size,
255
+ position: config.client.position,
256
+ layer: config.client.layer,
257
+ minimize: config.client.minimize
258
+ } }
259
+ };
260
+ }
261
+ function place(zip, directory, location, half) {
262
+ const from = resolve(directory, location);
263
+ if (!existsSync(from))
264
+ throw new Error(`The ${half} files are not at ${location}`);
265
+ zip.addLocalFolder(from, half);
266
+ }
267
+ function file(zip, directory, location, target, label) {
268
+ const from = resolve(directory, location);
269
+ if (!existsSync(from) || !statSync(from).isFile())
270
+ throw new Error(`The ${label} is not at ${location}`);
271
+ zip.addFile(target, readFileSync(from));
272
+ }
@@ -0,0 +1,3 @@
1
+ import type { Storage } from "@phreshos/core";
2
+ /** Create one filesystem implementation bounded beneath an absolute root. */
3
+ export declare function filesystemStorage(root: string, label: string): Storage;
@@ -0,0 +1,109 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createReadStream, createWriteStream, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
3
+ import { rm } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ /** Create one filesystem implementation bounded beneath an absolute root. */
8
+ export function filesystemStorage(root, label) {
9
+ if (!isAbsolute(root))
10
+ throw new Error("A Storage root must be absolute");
11
+ const resolve = (...parts) => contained(root, parts);
12
+ async function stream(...parts) {
13
+ const destination = resolve(...parts);
14
+ const found = describe(destination);
15
+ if (!found)
16
+ throw new Error(`There is no ${parts.join("/")} in ${label}`);
17
+ if (found.kind !== "file")
18
+ throw new Error(`${parts.join("/")} is not a file`);
19
+ return Readable.toWeb(createReadStream(destination));
20
+ }
21
+ async function write(...args) {
22
+ const parts = args.slice(0, -1);
23
+ const destination = resolve(...parts);
24
+ const temporary = join(dirname(destination), `.${randomUUID()}.writing`);
25
+ mkdirSync(dirname(destination), { recursive: true });
26
+ try {
27
+ await pipeline(Readable.fromWeb(content(args.at(-1))), createWriteStream(temporary, { flags: "wx" }));
28
+ renameSync(temporary, destination);
29
+ }
30
+ catch (error) {
31
+ await rm(temporary, { force: true }).catch(() => undefined);
32
+ throw error;
33
+ }
34
+ }
35
+ return {
36
+ stream,
37
+ async bytes(...parts) { return new Uint8Array(await new Response(await stream(...parts)).arrayBuffer()); },
38
+ async text(...parts) { return new Response(await stream(...parts)).text(); },
39
+ async json(...parts) { return JSON.parse(await new Response(await stream(...parts)).text()); },
40
+ write,
41
+ async stat(...parts) { return describe(resolve(...parts)); },
42
+ async list(...parts) { return readdirSync(resolve(...parts)).sort(); },
43
+ async delete(...parts) {
44
+ if (!parts.length)
45
+ throw new Error("Emptying a place is clear, not delete");
46
+ rmSync(resolve(...parts), { recursive: true, force: true });
47
+ },
48
+ async clear(...parts) {
49
+ const destination = resolve(...parts);
50
+ const found = describe(destination);
51
+ if (found && found.kind !== "directory")
52
+ throw new Error("Only a Storage directory can be cleared");
53
+ rmSync(destination, { recursive: true, force: true });
54
+ mkdirSync(destination, { recursive: true });
55
+ }
56
+ };
57
+ }
58
+ function content(value) {
59
+ if (value instanceof ReadableStream)
60
+ return value;
61
+ if (value instanceof Uint8Array)
62
+ return new Blob([bytes(value)]).stream();
63
+ if (value instanceof ArrayBuffer)
64
+ return new Blob([value]).stream();
65
+ if (typeof Blob !== "undefined" && value instanceof Blob)
66
+ return value.stream();
67
+ if (typeof value === "string")
68
+ return new Blob([value]).stream();
69
+ return new Blob([JSON.stringify(value)]).stream();
70
+ }
71
+ function bytes(value) {
72
+ return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
73
+ }
74
+ function contained(root, parts) {
75
+ const destination = join(root, ...parts);
76
+ const step = relative(root, destination);
77
+ if (step === ".." || step.startsWith(`..${sep}`) || isAbsolute(step))
78
+ throw new Error("A Storage path may not leave its configured directory");
79
+ let current = root;
80
+ for (const part of step.split(sep).filter(Boolean)) {
81
+ current = join(current, part);
82
+ try {
83
+ if (lstatSync(current).isSymbolicLink())
84
+ throw new Error("A Storage path may not pass through a symbolic link");
85
+ }
86
+ catch (error) {
87
+ if (error.code === "ENOENT")
88
+ break;
89
+ throw error;
90
+ }
91
+ }
92
+ return destination;
93
+ }
94
+ function describe(path) {
95
+ let value;
96
+ try {
97
+ value = statSync(path);
98
+ }
99
+ catch (error) {
100
+ if (error.code === "ENOENT")
101
+ return null;
102
+ throw error;
103
+ }
104
+ if (value.isFile())
105
+ return { kind: "file", size: value.size, modifiedAt: value.mtimeMs };
106
+ if (value.isDirectory())
107
+ return { kind: "directory", modifiedAt: value.mtimeMs };
108
+ return { kind: "other", modifiedAt: value.mtimeMs };
109
+ }
@@ -0,0 +1,9 @@
1
+ import { type System } from "@phreshos/core";
2
+ import type { GatewayEvent } from "./transport.js";
3
+ export interface SystemTransport {
4
+ control(request: object, signal?: AbortSignal): Promise<unknown>;
5
+ api(request: object, signal?: AbortSignal): Promise<unknown>;
6
+ lifecycle(request: object, signal?: AbortSignal): AsyncGenerator<GatewayEvent, void, void>;
7
+ }
8
+ /** Build the exact shared System contract over an owner-local Gateway transport. */
9
+ export declare function gatewaySystem(transport: SystemTransport): System;