portless-studio 0.1.0 → 0.1.1

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.
@@ -2,7 +2,10 @@ import * as fs from "node:fs";
2
2
  import { err, ok } from "../../../core/result.js";
3
3
  import { appError } from "../../application/app-error.js";
4
4
  import { addAlias, detectPortless, installPortless, removeAlias, startProxy, stopProxy, } from "./portless-cli.js";
5
- import { readProxyPid, readProxyStatus, readRoutes } from "./state-files.js";
5
+ import { readProxyPid, readProxyStatus, readRouteEntries, routesFromEntries, } from "./state-files.js";
6
+ /** How long a write waits for a half-written `routes.json` to be whole again. */
7
+ const TORN_RETRIES = 10;
8
+ const TORN_RETRY_MS = 50;
6
9
  /** Reads portless state files directly and writes through the portless CLI, which owns the lock. */
7
10
  export class PortlessRegistry {
8
11
  #stateDir;
@@ -16,6 +19,8 @@ export class PortlessRegistry {
16
19
  // Bumped by an install, so a detection that started before it cannot cache a stale answer.
17
20
  #detectGeneration = 0;
18
21
  #installing = false;
22
+ /** The entries of the last `routes.json` that parsed, shown while portless rewrites it. */
23
+ #lastEntries = [];
19
24
  /** One per watcher: compares the installation with what that watcher last saw. */
20
25
  #installationChecks = new Set();
21
26
  constructor(options) {
@@ -29,7 +34,7 @@ export class PortlessRegistry {
29
34
  async readSnapshot() {
30
35
  const portless = await this.#detect();
31
36
  const proxy = readProxyStatus(this.#stateDir);
32
- return { proxy, portless, routes: readRoutes(this.#stateDir, proxy), readAt: Date.now() };
37
+ return { proxy, portless, routes: this.#routes(proxy), readAt: Date.now() };
33
38
  }
34
39
  watch(onChange) {
35
40
  let last = this.#fingerprint();
@@ -103,8 +108,9 @@ export class PortlessRegistry {
103
108
  }
104
109
  async createAlias(name, port, force) {
105
110
  // `portless alias` silently replaces an existing alias, so the conflict check lives here.
111
+ await this.#waitForWholeRoutesFile();
106
112
  const proxy = readProxyStatus(this.#stateDir);
107
- const existing = readRoutes(this.#stateDir, proxy).find((route) => route.name === name);
113
+ const existing = this.#routes(proxy).find((route) => route.name === name);
108
114
  if (existing && !force) {
109
115
  if (existing.port === port)
110
116
  return ok(undefined);
@@ -149,6 +155,27 @@ export class PortlessRegistry {
149
155
  /** The snapshot without its read time, so two reads of the same state compare equal. */
150
156
  #fingerprint() {
151
157
  const proxy = readProxyStatus(this.#stateDir);
152
- return JSON.stringify({ proxy, routes: readRoutes(this.#stateDir, proxy) });
158
+ return JSON.stringify({ proxy, routes: this.#routes(proxy) });
159
+ }
160
+ /**
161
+ * Routes from `routes.json`. portless rewrites the file in place, so a read can land on an empty
162
+ * or cut-off file; that read keeps the last entries that parsed, and the next file event or poll
163
+ * reads it again. A missing file, or one in a format Studio does not know, means no routes.
164
+ */
165
+ #routes(proxy) {
166
+ const read = readRouteEntries(this.#stateDir);
167
+ if (read.kind === "ok")
168
+ this.#lastEntries = read.entries;
169
+ else if (read.kind !== "torn")
170
+ this.#lastEntries = [];
171
+ return routesFromEntries(this.#lastEntries, proxy);
172
+ }
173
+ /** Gives portless a moment to finish a write, so a name check never runs on a torn file. */
174
+ async #waitForWholeRoutesFile() {
175
+ for (let attempt = 0; attempt < TORN_RETRIES; attempt += 1) {
176
+ if (readRouteEntries(this.#stateDir).kind !== "torn")
177
+ return;
178
+ await new Promise((resolve) => setTimeout(resolve, TORN_RETRY_MS));
179
+ }
153
180
  }
154
181
  }
@@ -34,21 +34,29 @@ export function readProxyPid(dir) {
34
34
  const pid = Number.parseInt(readText(dir, PID_FILE) ?? "", 10);
35
35
  return Number.isInteger(pid) && pid > 0 ? pid : null;
36
36
  }
37
- /** Live routes from `routes.json`. Invalid entries and apps whose process died are left out. */
38
- export function readRoutes(dir, proxy) {
37
+ export function readRouteEntries(dir) {
39
38
  const raw = readText(dir, ROUTES_FILE);
40
39
  if (raw === null)
41
- return [];
40
+ return { kind: "missing" };
41
+ if (raw.trim() === "")
42
+ return { kind: "torn" };
42
43
  let parsed;
43
44
  try {
44
45
  parsed = JSON.parse(raw);
45
46
  }
46
47
  catch {
47
- return [];
48
+ return { kind: "torn" };
48
49
  }
49
- if (!Array.isArray(parsed))
50
- return [];
51
- return parsed
50
+ return Array.isArray(parsed) ? { kind: "ok", entries: parsed } : { kind: "unexpected" };
51
+ }
52
+ /** Live routes from `routes.json`. Invalid entries and apps whose process died are left out. */
53
+ export function readRoutes(dir, proxy) {
54
+ const read = readRouteEntries(dir);
55
+ return read.kind === "ok" ? routesFromEntries(read.entries, proxy) : [];
56
+ }
57
+ /** The routes in a parsed `routes.json`, without invalid entries and apps whose process died. */
58
+ export function routesFromEntries(entries, proxy) {
59
+ return entries
52
60
  .flatMap((entry) => {
53
61
  const route = toRoute(entry, proxy);
54
62
  return route ? [route] : [];
@@ -56,6 +64,14 @@ export function readRoutes(dir, proxy) {
56
64
  .filter((route) => route.pid === null || isProcessAlive(route.pid))
57
65
  .sort((a, b) => a.name.localeCompare(b.name));
58
66
  }
67
+ export function inspectRoutesFile(dir) {
68
+ const read = readRouteEntries(dir);
69
+ if (read.kind !== "ok")
70
+ return { state: read.kind, entries: 0, unreadable: 0 };
71
+ const proxy = { running: false, port: null, https: false, tlds: [DEFAULT_TLD] };
72
+ const unreadable = read.entries.filter((entry) => toRoute(entry, proxy) === null).length;
73
+ return { state: "ok", entries: read.entries.length, unreadable };
74
+ }
59
75
  /** Signal 0 checks existence without touching the process; EPERM means it exists. */
60
76
  export function isProcessAlive(pid) {
61
77
  try {
@@ -127,6 +127,7 @@ export async function collectDoctorFacts(options) {
127
127
  studioPort: { port: config.port, holder: await portHolder(config.port, options) },
128
128
  loginService,
129
129
  ...(systemNotifier === undefined ? {} : { systemNotifier }),
130
+ ...(options.routesFile ? { routesFile: options.routesFile() } : {}),
130
131
  };
131
132
  }
132
133
  /** Paths are built for the platform the facts describe, not the one running the code. */
@@ -0,0 +1,20 @@
1
+ /** Public npm metadata of the latest release; Studio sends nothing but this GET. */
2
+ export const LATEST_RELEASE_URL = "https://registry.npmjs.org/portless-studio/latest";
3
+ const TIMEOUT_MS = 3000;
4
+ /** The `version` npm reports as latest, or null on any failure, including the 3 s timeout. */
5
+ export async function fetchLatestVersion(options = {}) {
6
+ const fetchImpl = options.fetch ?? ((url, init) => fetch(url, init));
7
+ try {
8
+ const response = await fetchImpl(options.url ?? LATEST_RELEASE_URL, {
9
+ signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
10
+ });
11
+ if (!response.ok)
12
+ return null;
13
+ const body = await response.json();
14
+ const version = typeof body === "object" && body !== null ? Reflect.get(body, "version") : undefined;
15
+ return typeof version === "string" && /^\d+\.\d+\.\d+\S*$/.test(version) ? version : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
@@ -102,6 +102,11 @@ export function createHttpServer(options) {
102
102
  sendJson(res, 200, await options.studio.readSnapshot());
103
103
  return;
104
104
  }
105
+ if (pathname === API_PATHS.update && method === "GET") {
106
+ if (!options.updates)
107
+ return sendAppError(res, unsupported);
108
+ return sendJson(res, 200, await options.updates.status());
109
+ }
105
110
  if (pathname === API_PATHS.config && method === "GET") {
106
111
  const result = await options.studio.readConfig();
107
112
  if (!result.ok)
@@ -16,6 +16,7 @@ const STATUS_BY_CODE = {
16
16
  process_not_allowed: 403,
17
17
  unsupported: 501,
18
18
  command_failed: 502,
19
+ port_in_use: 409,
19
20
  unknown_app: 404,
20
21
  path_not_allowed: 403,
21
22
  terminal_not_running: 409,
@@ -5,20 +5,24 @@ import * as path from "node:path";
5
5
  import { createDoctorService } from "./application/doctor.js";
6
6
  import { OpenWith } from "./application/open-with.js";
7
7
  import { controlProxy } from "./application/proxy-control.js";
8
+ import { confirmProxyStarts } from "./application/proxy-start-check.js";
8
9
  import { createAlias, renameAlias } from "./application/route-use-cases.js";
9
10
  import { stopProcess } from "./application/stop-process.js";
10
11
  import { StudioRuntime } from "./application/studio-runtime.js";
11
12
  import { TerminalManager } from "./application/terminals.js";
13
+ import { UpdateChecker, updateChecksAllowed } from "./application/update-check.js";
12
14
  import { systemAppCatalog } from "./infrastructure/apps/app-catalog.js";
13
15
  import { createDetachedLauncher } from "./infrastructure/apps/detached-launcher.js";
14
16
  import { JsonCustomAppStore, JsonRouteProjectStore, openWithPathFor, routeProjectsPathFor, } from "./infrastructure/apps/json-stores.js";
15
17
  import { autoNamedPathFor, JsonAutoNamedStore, } from "./infrastructure/config/json-auto-named-store.js";
16
18
  import { defaultConfigPath, JsonConfigStore } from "./infrastructure/config/json-config-store.js";
19
+ import { JsonUpdateCache, updateCheckPathFor } from "./infrastructure/config/json-update-cache.js";
17
20
  import { proxyStartRecordPathFor, readProxyStartRecord, recordProxyStarts, } from "./infrastructure/config/proxy-start-record.js";
18
21
  import { PortlessRegistry } from "./infrastructure/portless/portless-registry.js";
19
- import { resolveStateDir } from "./infrastructure/portless/state-files.js";
22
+ import { inspectRoutesFile, resolveStateDir } from "./infrastructure/portless/state-files.js";
20
23
  import { collectDoctorFacts, proxyPidsAmong } from "./infrastructure/system/doctor-facts.js";
21
24
  import { FaviconFetcher } from "./infrastructure/system/favicon-fetcher.js";
25
+ import { fetchLatestVersion } from "./infrastructure/system/npm-registry.js";
22
26
  import { OsNotifier } from "./infrastructure/system/os-notifier.js";
23
27
  import { ProjectInfoReader } from "./infrastructure/system/project-info.js";
24
28
  import { nodeSystemIo } from "./infrastructure/system/system-io.js";
@@ -39,9 +43,11 @@ export function createStudioServices(options = {}) {
39
43
  bin: options.portlessBin ?? (process.env.PORTLESS_BIN || "portless"),
40
44
  ...(options.npmBin === undefined ? {} : { npmBin: options.npmBin }),
41
45
  });
42
- // Every proxy Studio starts is written down, so `uninstall` stops only Studio's own.
43
- const registry = recordProxyStarts(portless, proxyStartRecordPathFor(configPath));
44
46
  const scanner = createPortScanner();
47
+ const proxyPids = (pids) => proxyPidsAmong(process.platform, nodeSystemIo, pids);
48
+ // A start counts only once the proxy listens, and every proxy Studio starts is written down,
49
+ // so `uninstall` stops only Studio's own.
50
+ const registry = recordProxyStarts(confirmProxyStarts(portless, { scan: () => scanner.scan(), proxyPids }), proxyStartRecordPathFor(configPath));
45
51
  const configStore = new JsonConfigStore(configPath);
46
52
  const notifier = new OsNotifier({ platform: process.platform });
47
53
  const runtime = new StudioRuntime({
@@ -51,7 +57,7 @@ export function createStudioServices(options = {}) {
51
57
  projects: new ProjectInfoReader(),
52
58
  studioPort: options.studioPort ?? null,
53
59
  probe: (port) => probeHttpResponse(port),
54
- proxyPids: (pids) => proxyPidsAmong(process.platform, nodeSystemIo, pids),
60
+ proxyPids,
55
61
  autoNamedStore: new JsonAutoNamedStore(autoNamedPathFor(configPath)),
56
62
  notifier,
57
63
  icons: new FaviconFetcher(),
@@ -97,10 +103,20 @@ export function createStudioServices(options = {}) {
97
103
  probe: (port) => probeHttpResponse(port),
98
104
  ...(studioService ? { loginService: () => studioService.status() } : {}),
99
105
  systemNotifier: () => notifier.availability(),
106
+ routesFile: () => inspectRoutesFile(stateDir),
100
107
  }),
101
108
  controlProxy: proxy,
102
109
  updateConfig: (config) => runtime.updateConfig(config),
103
110
  });
111
+ const version = options.version;
112
+ const updates = version === undefined
113
+ ? undefined
114
+ : new UpdateChecker({
115
+ current: version,
116
+ enabled: async () => updateChecksAllowed(process.env) && (await runtime.settings()).checkForUpdates,
117
+ fetchLatest: () => fetchLatestVersion(),
118
+ cache: new JsonUpdateCache(updateCheckPathFor(configPath)),
119
+ });
104
120
  const services = {
105
121
  registry,
106
122
  scanner,
@@ -117,6 +133,7 @@ export function createStudioServices(options = {}) {
117
133
  openWith,
118
134
  terminals,
119
135
  openUrl: options.openUrl,
136
+ updates,
120
137
  };
121
138
  runtimes.set(services, runtime);
122
139
  return services;
@@ -130,6 +147,7 @@ export function createUninstallServices(configPath = defaultConfigPath()) {
130
147
  recordPath,
131
148
  routeProjectsPathFor(configPath),
132
149
  openWithPathFor(configPath),
150
+ updateCheckPathFor(configPath),
133
151
  ];
134
152
  const processes = new SystemProcessControl({
135
153
  platform: process.platform,
@@ -174,6 +192,7 @@ export async function startServer(options) {
174
192
  ...(services.openWith ? { openWith: services.openWith } : {}),
175
193
  ...(services.terminals ? { terminals: services.terminals } : {}),
176
194
  ...(services.openUrl ? { openUrl: services.openUrl } : {}),
195
+ ...(services.updates ? { updates: services.updates } : {}),
177
196
  });
178
197
  await new Promise((resolve, reject) => {
179
198
  http.server.once("error", reject);