@averyyy/pi-server 0.82.1-piclient.3 → 0.82.1-piclient.5

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/CHANGELOG.md CHANGED
@@ -20,8 +20,20 @@
20
20
  ### Fixed
21
21
 
22
22
  - Used `--legacy-peer-deps` for npm-global fork updates so existing upstream Pi installs do not trigger peer override warnings for forked prerelease aliases.
23
+ - Fixed provider streams that close without a terminal event and made duplicate `runId` requests single-flight, replayable, and bounded by count and retention time.
24
+ - Fixed static-context hashes to include canonical constrained-sampling tool configuration.
25
+ - Fixed Windows updates to invoke the validated npm CLI through the active Node executable and terminate stalled git or npm steps with actionable errors.
26
+ - Added durable provider-run journals with cursor replay, terminal acknowledgement, restart-uncertainty handling, bounded lossless persistence backpressure, and immediate provider cancellation.
27
+ - Reworked session persistence as checksummed append-only WAL records plus periodic snapshots, with strict revision/digest validation and narrow torn-write recovery.
28
+ - Made server-side compaction authoritative, replayable, heartbeat-streamed, branch-local, and compatible with native Pi compaction hooks and cancellation.
29
+ - Bounded session history, request, response, SSE, subscriber, run-journal, and upload resources with explicit structured failures instead of silent truncation, and reduced chunk reassembly peak memory with independently decoded base64 segments.
30
+ - Made session tree mutation and file receive retries idempotent while rejecting divergent duplicate IDs or destination contents.
31
+ - Added per-filesystem-operation no-progress watchdogs for durable run persistence and a bounded CLI fatal-shutdown grace, without imposing a total provider-run deadline or retrying indeterminate writes.
32
+ - Made compaction capacity admission exact and atomic before provider execution and journal settlement, serialized deletion against queued work, and enforced single-owner fail-stop persistence within and across processes.
33
+ - Removed provider-execution fingerprint admission checks so established custom-provider pi-client sessions are accepted again.
23
34
 
24
35
  ### Changed
25
36
 
26
37
  - Updated the upstream Pi base through commit `0e6909f0`, including the latest provider and compaction fixes.
27
38
  - Rebased the server on upstream Pi `0.80.6`, including GPT-5.6 model metadata and `max` thinking support.
39
+ - Added structured, secret-free provider-stream failure logs with response metadata preserved across the proxy.
package/README.md CHANGED
@@ -36,6 +36,31 @@ PI_SERVER_UPLOAD_DIR=/path/to/upload_files
36
36
 
37
37
  Received files default to `~/.pi/upload_files`.
38
38
 
39
+ Long-run safety limits are explicit and configurable:
40
+
41
+ ```bash
42
+ PI_SERVER_SESSION_MAX_ENTRIES=250000
43
+ PI_SERVER_SESSION_MAX_LOGICAL_BYTES=268435456
44
+ PI_SERVER_SESSIONS_MAX_ENTRIES=500000
45
+ PI_SERVER_SESSIONS_MAX_LOGICAL_BYTES=536870912
46
+ PI_SERVER_MAX_LOADED_SESSIONS=1024
47
+ ```
48
+
49
+ The server rejects a mutation with structured HTTP `507` before exceeding these limits. It does not truncate,
50
+ delete, or compact durable history to make room.
51
+
52
+ Transport and persistence watchdogs measure continuous lack of progress, not total operation duration:
53
+
54
+ ```bash
55
+ PI_SERVER_REQUEST_BODY_NO_PROGRESS_TIMEOUT_MS=90000
56
+ PI_SERVER_STREAM_DRAIN_IDLE_TIMEOUT_MS=90000
57
+ PI_SERVER_STREAM_RUN_IO_NO_PROGRESS_TIMEOUT_MS=120000
58
+ PI_SERVER_COMPACT_RUN_IO_NO_PROGRESS_TIMEOUT_MS=120000
59
+ ```
60
+
61
+ An active provider or compaction run has no short total-duration deadline; it continues while making progress and
62
+ remaining within configured capacity.
63
+
39
64
  ## Connect A Client
40
65
 
41
66
  ```bash
package/bin/update.js CHANGED
@@ -1,8 +1,13 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, statSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
+ const GIT_STATUS_TIMEOUT_MS = 30_000;
7
+ const GIT_UPDATE_TIMEOUT_MS = 5 * 60_000;
8
+ const NPM_UPDATE_TIMEOUT_MS = 20 * 60_000;
9
+ const UPDATE_KILL_SIGNAL = "SIGTERM";
10
+
6
11
  function defaultPackageRoot() {
7
12
  return dirname(dirname(fileURLToPath(import.meta.url)));
8
13
  }
@@ -15,28 +20,126 @@ function readPackageMetadata(packageRoot) {
15
20
  return JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf-8"));
16
21
  }
17
22
 
18
- function runStep(runner, command, args, cwd, stdio = "inherit") {
19
- return runner(command, args, { cwd, stdio, encoding: "utf-8" });
23
+ function isFile(path) {
24
+ try {
25
+ return statSync(path).isFile();
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ function resolveNpmInvocation(platform, nodePath) {
32
+ if (platform !== "win32") {
33
+ return { command: "npm", argsPrefix: [] };
34
+ }
35
+
36
+ if (!isFile(nodePath)) {
37
+ throw new Error(`Node executable not found: ${nodePath}`);
38
+ }
39
+
40
+ const npmPackagePath = resolve(dirname(nodePath), "node_modules", "npm", "package.json");
41
+ if (!isFile(npmPackagePath)) {
42
+ throw new Error(`npm package metadata not found: ${npmPackagePath}`);
43
+ }
44
+
45
+ let npmPackage;
46
+ try {
47
+ npmPackage = JSON.parse(readFileSync(npmPackagePath, "utf-8"));
48
+ } catch (error) {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ throw new Error(`unable to read npm package metadata ${npmPackagePath}: ${message}`, { cause: error });
51
+ }
52
+
53
+ const npmBin = typeof npmPackage.bin === "string" ? npmPackage.bin : npmPackage.bin?.npm;
54
+ if (typeof npmBin !== "string" || npmBin.length === 0) {
55
+ throw new Error(`npm CLI entry missing from package metadata: ${npmPackagePath}`);
56
+ }
57
+
58
+ const npmCliPath = resolve(dirname(npmPackagePath), npmBin);
59
+ if (!isFile(npmCliPath)) {
60
+ throw new Error(`npm CLI not found: ${npmCliPath}`);
61
+ }
62
+
63
+ return { command: nodePath, argsPrefix: [npmCliPath] };
64
+ }
65
+
66
+ function runStep(runner, command, args, cwd, timeout, stdio = "inherit") {
67
+ try {
68
+ return runner(command, args, {
69
+ cwd,
70
+ stdio,
71
+ encoding: "utf-8",
72
+ timeout,
73
+ killSignal: UPDATE_KILL_SIGNAL,
74
+ });
75
+ } catch (error) {
76
+ return { status: null, error };
77
+ }
78
+ }
79
+
80
+ function reportStepFailure(stderr, displayCommand, result, timeout) {
81
+ if (result.error?.code === "ETIMEDOUT") {
82
+ stderr.write(
83
+ `pi-server update failed: ${displayCommand} timed out after ${timeout} ms and was terminated with ${UPDATE_KILL_SIGNAL}\n`,
84
+ );
85
+ return 1;
86
+ }
87
+
88
+ if (result.error) {
89
+ const errorCode = typeof result.error.code === "string" ? ` (${result.error.code})` : "";
90
+ const message = result.error instanceof Error ? result.error.message : String(result.error);
91
+ stderr.write(`pi-server update failed: unable to start ${displayCommand}${errorCode}: ${message}\n`);
92
+ return 1;
93
+ }
94
+
95
+ if (result.signal) {
96
+ stderr.write(`pi-server update failed: ${displayCommand} was terminated by signal ${result.signal}\n`);
97
+ return 1;
98
+ }
99
+
100
+ if (typeof result.status === "number") {
101
+ stderr.write(`pi-server update failed: ${displayCommand} exited with code ${result.status}\n`);
102
+ return result.status === 0 ? 1 : result.status;
103
+ }
104
+
105
+ stderr.write(`pi-server update failed: ${displayCommand} ended without an exit status\n`);
106
+ return 1;
20
107
  }
21
108
 
22
109
  function isMissingGitCheckout(result) {
23
110
  const text = `${String(result.stdout ?? "")}\n${String(result.stderr ?? "")}`;
24
- return result.status !== 0 && text.includes("not a git repository");
111
+ return !result.error && !result.signal && result.status !== 0 && text.includes("not a git repository");
25
112
  }
26
113
 
27
- async function runNpmGlobalUpdate(runner, repoRoot, stdout, stderr) {
114
+ function getNpmInvocation(platform, nodePath, stderr) {
115
+ try {
116
+ return resolveNpmInvocation(platform, nodePath);
117
+ } catch (error) {
118
+ const message = error instanceof Error ? error.message : String(error);
119
+ stderr.write(`pi-server update failed: ${message}\n`);
120
+ return null;
121
+ }
122
+ }
123
+
124
+ async function runNpmGlobalUpdate(runner, npmInvocation, repoRoot, stdout, stderr) {
28
125
  stdout.write("Updating npm packages: @averyyy/pi-client@latest @averyyy/pi-server@latest\n");
126
+ const npmArgs = [
127
+ "install",
128
+ "-g",
129
+ "--ignore-scripts",
130
+ "--legacy-peer-deps",
131
+ "@averyyy/pi-client@latest",
132
+ "@averyyy/pi-server@latest",
133
+ ];
29
134
  const result = runStep(
30
135
  runner,
31
- "npm",
32
- ["install", "-g", "--ignore-scripts", "--legacy-peer-deps", "@averyyy/pi-client@latest", "@averyyy/pi-server@latest"],
136
+ npmInvocation.command,
137
+ [...npmInvocation.argsPrefix, ...npmArgs],
33
138
  repoRoot,
139
+ NPM_UPDATE_TIMEOUT_MS,
34
140
  );
35
141
  if (result.status !== 0) {
36
- stderr.write(
37
- "pi-server update failed: npm install -g --ignore-scripts --legacy-peer-deps @averyyy/pi-client@latest @averyyy/pi-server@latest\n",
38
- );
39
- return result.status ?? 1;
142
+ return reportStepFailure(stderr, `npm ${npmArgs.join(" ")}`, result, NPM_UPDATE_TIMEOUT_MS);
40
143
  }
41
144
  stdout.write("pi-server update complete\n");
42
145
  return 0;
@@ -48,36 +151,66 @@ export async function runPiServerUpdate(_args = [], options = {}) {
48
151
  const runner = options.runner ?? spawnSync;
49
152
  const stdout = options.stdout ?? process.stdout;
50
153
  const stderr = options.stderr ?? process.stderr;
154
+ const platform = options.platform ?? process.platform;
155
+ const nodePath = options.nodePath ?? process.execPath;
51
156
  const pkg = readPackageMetadata(packageRoot);
52
157
 
53
158
  stdout.write(`pi-server ${pkg.version}\n`);
54
159
  stdout.write(`Updating checkout: ${repoRoot}\n`);
55
160
 
56
- const status = runStep(runner, "git", ["status", "--porcelain"], repoRoot, "pipe");
161
+ const statusArgs = ["status", "--porcelain"];
162
+ const status = runStep(runner, "git", statusArgs, repoRoot, GIT_STATUS_TIMEOUT_MS, "pipe");
57
163
  if (status.status !== 0) {
58
164
  if (isMissingGitCheckout(status)) {
59
- return runNpmGlobalUpdate(runner, repoRoot, stdout, stderr);
165
+ const npmInvocation = getNpmInvocation(platform, nodePath, stderr);
166
+ if (!npmInvocation) {
167
+ return 1;
168
+ }
169
+ return runNpmGlobalUpdate(runner, npmInvocation, repoRoot, stdout, stderr);
60
170
  }
61
- stderr.write("pi-server update failed: unable to inspect git status\n");
62
- return status.status ?? 1;
171
+ return reportStepFailure(stderr, `git ${statusArgs.join(" ")}`, status, GIT_STATUS_TIMEOUT_MS);
63
172
  }
64
173
  if (String(status.stdout ?? "").trim().length > 0) {
65
174
  stderr.write("pi-server update failed: working tree has uncommitted changes\n");
66
175
  return 1;
67
176
  }
68
177
 
178
+ const npmInvocation = getNpmInvocation(platform, nodePath, stderr);
179
+ if (!npmInvocation) {
180
+ return 1;
181
+ }
182
+
69
183
  const steps = [
70
- ["git", ["pull", "--ff-only"]],
71
- ["npm", ["install", "--ignore-scripts"]],
72
- ["npm", ["run", "install:pi-client"]],
73
- ["npm", ["run", "install:pi-server"]],
184
+ {
185
+ command: "git",
186
+ args: ["pull", "--ff-only"],
187
+ displayCommand: "git pull --ff-only",
188
+ timeout: GIT_UPDATE_TIMEOUT_MS,
189
+ },
190
+ {
191
+ command: npmInvocation.command,
192
+ args: [...npmInvocation.argsPrefix, "install", "--ignore-scripts"],
193
+ displayCommand: "npm install --ignore-scripts",
194
+ timeout: NPM_UPDATE_TIMEOUT_MS,
195
+ },
196
+ {
197
+ command: npmInvocation.command,
198
+ args: [...npmInvocation.argsPrefix, "run", "install:pi-client"],
199
+ displayCommand: "npm run install:pi-client",
200
+ timeout: NPM_UPDATE_TIMEOUT_MS,
201
+ },
202
+ {
203
+ command: npmInvocation.command,
204
+ args: [...npmInvocation.argsPrefix, "run", "install:pi-server"],
205
+ displayCommand: "npm run install:pi-server",
206
+ timeout: NPM_UPDATE_TIMEOUT_MS,
207
+ },
74
208
  ];
75
209
 
76
- for (const [command, args] of steps) {
77
- const result = runStep(runner, command, args, repoRoot);
210
+ for (const step of steps) {
211
+ const result = runStep(runner, step.command, step.args, repoRoot, step.timeout);
78
212
  if (result.status !== 0) {
79
- stderr.write(`pi-server update failed: ${command} ${args.join(" ")}\n`);
80
- return result.status ?? 1;
213
+ return reportStepFailure(stderr, step.displayCommand, result, step.timeout);
81
214
  }
82
215
  }
83
216
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { loadConfig, type ServerConfig } from "./config.ts";
2
2
  export { encodeErrorEvent, encodeProxyEvent, parseProxyEvent } from "./event-encoding.ts";
3
- export { createPiServer, startServer } from "./server.ts";
4
- export { appendAssistantResponse, appendMessages, clearAllSessions, deleteSession, exportSessionState, getOrCreateSession, getSession, hashSessionEntries, listSessions, type PersistedSessionState, restoreSessionState, type SessionState, type SessionStaticContext, type SessionSummary, setStaticContext, } from "./session-store.ts";
3
+ export { createPiServer, type PiServerOptions, startServer } from "./server.ts";
4
+ export { appendAssistantResponse, appendMessages, applySessionMutation, calculateSessionLogicalBytes, clearAllSessions, configureSessionCapacityLimits, DEFAULT_SESSION_CAPACITY_LIMITS, deleteSession, exportSessionState, getOrCreateSession, getSession, getSessionCapacityLimits, getSessionCapacityUsage, hashSessionEntries, listSessions, type PersistedSessionState, preflightSessionCapacityMutation, resetSessionCapacityLimits, restoreSessionState, SessionCapacityError, type SessionCapacityLimits, type SessionCapacityMutation, type SessionCapacityProjection, type SessionCapacityUsage, type SessionState, type SessionStaticContext, type SessionSummary, setStaticContext, } from "./session-store.ts";
5
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC1F,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EACN,uBAAuB,EACvB,cAAc,EACd,gBAAgB,EAChB,aAAa,EACb,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,kBAAkB,EAClB,YAAY,EACZ,KAAK,qBAAqB,EAC1B,mBAAmB,EACnB,KAAK,YAAY,EACjB,KAAK,oBAAoB,EACzB,KAAK,cAAc,EACnB,gBAAgB,GAChB,MAAM,oBAAoB,CAAC","sourcesContent":["export { loadConfig, type ServerConfig } from \"./config.ts\";\nexport { encodeErrorEvent, encodeProxyEvent, parseProxyEvent } from \"./event-encoding.ts\";\nexport { createPiServer, startServer } from \"./server.ts\";\nexport {\n\tappendAssistantResponse,\n\tappendMessages,\n\tclearAllSessions,\n\tdeleteSession,\n\texportSessionState,\n\tgetOrCreateSession,\n\tgetSession,\n\thashSessionEntries,\n\tlistSessions,\n\ttype PersistedSessionState,\n\trestoreSessionState,\n\ttype SessionState,\n\ttype SessionStaticContext,\n\ttype SessionSummary,\n\tsetStaticContext,\n} from \"./session-store.ts\";\n"]}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC1F,OAAO,EAAE,cAAc,EAAE,KAAK,eAAe,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAChF,OAAO,EACN,uBAAuB,EACvB,cAAc,EACd,oBAAoB,EACpB,4BAA4B,EAC5B,gBAAgB,EAChB,8BAA8B,EAC9B,+BAA+B,EAC/B,aAAa,EACb,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,wBAAwB,EACxB,uBAAuB,EACvB,kBAAkB,EAClB,YAAY,EACZ,KAAK,qBAAqB,EAC1B,gCAAgC,EAChC,0BAA0B,EAC1B,mBAAmB,EACnB,oBAAoB,EACpB,KAAK,qBAAqB,EAC1B,KAAK,uBAAuB,EAC5B,KAAK,yBAAyB,EAC9B,KAAK,oBAAoB,EACzB,KAAK,YAAY,EACjB,KAAK,oBAAoB,EACzB,KAAK,cAAc,EACnB,gBAAgB,GAChB,MAAM,oBAAoB,CAAC","sourcesContent":["export { loadConfig, type ServerConfig } from \"./config.ts\";\nexport { encodeErrorEvent, encodeProxyEvent, parseProxyEvent } from \"./event-encoding.ts\";\nexport { createPiServer, type PiServerOptions, startServer } from \"./server.ts\";\nexport {\n\tappendAssistantResponse,\n\tappendMessages,\n\tapplySessionMutation,\n\tcalculateSessionLogicalBytes,\n\tclearAllSessions,\n\tconfigureSessionCapacityLimits,\n\tDEFAULT_SESSION_CAPACITY_LIMITS,\n\tdeleteSession,\n\texportSessionState,\n\tgetOrCreateSession,\n\tgetSession,\n\tgetSessionCapacityLimits,\n\tgetSessionCapacityUsage,\n\thashSessionEntries,\n\tlistSessions,\n\ttype PersistedSessionState,\n\tpreflightSessionCapacityMutation,\n\tresetSessionCapacityLimits,\n\trestoreSessionState,\n\tSessionCapacityError,\n\ttype SessionCapacityLimits,\n\ttype SessionCapacityMutation,\n\ttype SessionCapacityProjection,\n\ttype SessionCapacityUsage,\n\ttype SessionState,\n\ttype SessionStaticContext,\n\ttype SessionSummary,\n\tsetStaticContext,\n} from \"./session-store.ts\";\n"]}
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { loadConfig } from "./config.js";
2
2
  export { encodeErrorEvent, encodeProxyEvent, parseProxyEvent } from "./event-encoding.js";
3
3
  export { createPiServer, startServer } from "./server.js";
4
- export { appendAssistantResponse, appendMessages, clearAllSessions, deleteSession, exportSessionState, getOrCreateSession, getSession, hashSessionEntries, listSessions, restoreSessionState, setStaticContext, } from "./session-store.js";
4
+ export { appendAssistantResponse, appendMessages, applySessionMutation, calculateSessionLogicalBytes, clearAllSessions, configureSessionCapacityLimits, DEFAULT_SESSION_CAPACITY_LIMITS, deleteSession, exportSessionState, getOrCreateSession, getSession, getSessionCapacityLimits, getSessionCapacityUsage, hashSessionEntries, listSessions, preflightSessionCapacityMutation, resetSessionCapacityLimits, restoreSessionState, SessionCapacityError, setStaticContext, } from "./session-store.js";
5
5
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAqB,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC1F,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EACN,uBAAuB,EACvB,cAAc,EACd,gBAAgB,EAChB,aAAa,EACb,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,kBAAkB,EAClB,YAAY,EAEZ,mBAAmB,EAInB,gBAAgB,GAChB,MAAM,oBAAoB,CAAC","sourcesContent":["export { loadConfig, type ServerConfig } from \"./config.ts\";\nexport { encodeErrorEvent, encodeProxyEvent, parseProxyEvent } from \"./event-encoding.ts\";\nexport { createPiServer, startServer } from \"./server.ts\";\nexport {\n\tappendAssistantResponse,\n\tappendMessages,\n\tclearAllSessions,\n\tdeleteSession,\n\texportSessionState,\n\tgetOrCreateSession,\n\tgetSession,\n\thashSessionEntries,\n\tlistSessions,\n\ttype PersistedSessionState,\n\trestoreSessionState,\n\ttype SessionState,\n\ttype SessionStaticContext,\n\ttype SessionSummary,\n\tsetStaticContext,\n} from \"./session-store.ts\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAqB,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC1F,OAAO,EAAE,cAAc,EAAwB,WAAW,EAAE,MAAM,aAAa,CAAC;AAChF,OAAO,EACN,uBAAuB,EACvB,cAAc,EACd,oBAAoB,EACpB,4BAA4B,EAC5B,gBAAgB,EAChB,8BAA8B,EAC9B,+BAA+B,EAC/B,aAAa,EACb,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,wBAAwB,EACxB,uBAAuB,EACvB,kBAAkB,EAClB,YAAY,EAEZ,gCAAgC,EAChC,0BAA0B,EAC1B,mBAAmB,EACnB,oBAAoB,EAQpB,gBAAgB,GAChB,MAAM,oBAAoB,CAAC","sourcesContent":["export { loadConfig, type ServerConfig } from \"./config.ts\";\nexport { encodeErrorEvent, encodeProxyEvent, parseProxyEvent } from \"./event-encoding.ts\";\nexport { createPiServer, type PiServerOptions, startServer } from \"./server.ts\";\nexport {\n\tappendAssistantResponse,\n\tappendMessages,\n\tapplySessionMutation,\n\tcalculateSessionLogicalBytes,\n\tclearAllSessions,\n\tconfigureSessionCapacityLimits,\n\tDEFAULT_SESSION_CAPACITY_LIMITS,\n\tdeleteSession,\n\texportSessionState,\n\tgetOrCreateSession,\n\tgetSession,\n\tgetSessionCapacityLimits,\n\tgetSessionCapacityUsage,\n\thashSessionEntries,\n\tlistSessions,\n\ttype PersistedSessionState,\n\tpreflightSessionCapacityMutation,\n\tresetSessionCapacityLimits,\n\trestoreSessionState,\n\tSessionCapacityError,\n\ttype SessionCapacityLimits,\n\ttype SessionCapacityMutation,\n\ttype SessionCapacityProjection,\n\ttype SessionCapacityUsage,\n\ttype SessionState,\n\ttype SessionStaticContext,\n\ttype SessionSummary,\n\tsetStaticContext,\n} from \"./session-store.ts\";\n"]}
@@ -4,6 +4,22 @@ export interface PiServerStaticContext {
4
4
  systemPrompt?: string;
5
5
  tools?: Tool[];
6
6
  }
7
+ export interface PiServerSessionIdentity {
8
+ staticContextHash: string;
9
+ treeHash: string;
10
+ entryCount: number;
11
+ leafId: string | null;
12
+ revision: number;
13
+ }
14
+ export interface PiServerStreamBaseIdentity {
15
+ baseStaticContextHash: string;
16
+ baseTreeHash: string;
17
+ baseEntryCount: number;
18
+ baseLeafId: string | null;
19
+ baseRevision: number;
20
+ }
21
+ export declare function matchesPiServerStreamBase(base: PiServerStreamBaseIdentity, session: PiServerSessionIdentity): boolean;
22
+ export declare function canonicalJsonStringify(value: unknown): string | undefined;
7
23
  export declare function hashPiServerStaticContext(context: PiServerStaticContext | undefined): string;
8
24
  export declare const PI_SERVER_EMPTY_TREE_HASH: string;
9
25
  export declare function hashPiServerTreeEntry(entry: SessionTreeEntry): string;
@@ -1 +1 @@
1
- {"version":3,"file":"pi-server-protocol.d.ts","sourceRoot":"","sources":["../src/pi-server-protocol.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AACtE,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAC;AAElD,MAAM,WAAW,qBAAqB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;CACf;AAED,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,qBAAqB,GAAG,SAAS,GAAG,MAAM,CAW5F;AAED,eAAO,MAAM,yBAAyB,QAA0D,CAAC;AAEjG,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAErE;AAED,wBAAgB,sBAAsB,CAAC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAEtF;AAED,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,MAAM,EAAE,CAMnF;AAED,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,MAAM,CAE9E","sourcesContent":["import { createHash } from \"node:crypto\";\nimport type { SessionTreeEntry } from \"@earendil-works/pi-agent-core\";\nimport type { Tool } from \"@earendil-works/pi-ai\";\n\nexport interface PiServerStaticContext {\n\tsystemPrompt?: string;\n\ttools?: Tool[];\n}\n\nexport function hashPiServerStaticContext(context: PiServerStaticContext | undefined): string {\n\tif (!context) return \"\";\n\tconst canonical = {\n\t\tsystemPrompt: context.systemPrompt,\n\t\ttools: context.tools?.map((tool) => ({\n\t\t\tname: tool.name,\n\t\t\tdescription: tool.description,\n\t\t\tparameters: tool.parameters,\n\t\t})),\n\t};\n\treturn createHash(\"sha256\").update(JSON.stringify(canonical)).digest(\"hex\");\n}\n\nexport const PI_SERVER_EMPTY_TREE_HASH = createHash(\"sha256\").update(\"pi-tree-v1\").digest(\"hex\");\n\nexport function hashPiServerTreeEntry(entry: SessionTreeEntry): string {\n\treturn createHash(\"sha256\").update(JSON.stringify(entry)).digest(\"hex\");\n}\n\nexport function appendPiServerTreeHash(previousHash: string, entryHash: string): string {\n\treturn createHash(\"sha256\").update(`${previousHash}:${entryHash}`).digest(\"hex\");\n}\n\nexport function buildPiServerTreePrefixHashes(entries: SessionTreeEntry[]): string[] {\n\tconst hashes = [PI_SERVER_EMPTY_TREE_HASH];\n\tfor (const entry of entries) {\n\t\thashes.push(appendPiServerTreeHash(hashes[hashes.length - 1], hashPiServerTreeEntry(entry)));\n\t}\n\treturn hashes;\n}\n\nexport function hashPiServerSessionEntries(entries: SessionTreeEntry[]): string {\n\treturn buildPiServerTreePrefixHashes(entries)[entries.length];\n}\n"]}
1
+ {"version":3,"file":"pi-server-protocol.d.ts","sourceRoot":"","sources":["../src/pi-server-protocol.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AACtE,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAC;AAElD,MAAM,WAAW,qBAAqB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC;CACf;AAED,MAAM,WAAW,uBAAuB;IACvC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,0BAA0B;IAC1C,qBAAqB,EAAE,MAAM,CAAC;IAC9B,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,0BAA0B,EAAE,OAAO,EAAE,uBAAuB,GAAG,OAAO,CAQrH;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAgBzE;AAED,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,qBAAqB,GAAG,SAAS,GAAG,MAAM,CAgB5F;AAED,eAAO,MAAM,yBAAyB,QAA0D,CAAC;AAEjG,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAErE;AAED,wBAAgB,sBAAsB,CAAC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAEtF;AAED,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,MAAM,EAAE,CAMnF;AAED,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,MAAM,CAE9E","sourcesContent":["import { createHash } from \"node:crypto\";\nimport type { SessionTreeEntry } from \"@earendil-works/pi-agent-core\";\nimport type { Tool } from \"@earendil-works/pi-ai\";\n\nexport interface PiServerStaticContext {\n\tsystemPrompt?: string;\n\ttools?: Tool[];\n}\n\nexport interface PiServerSessionIdentity {\n\tstaticContextHash: string;\n\ttreeHash: string;\n\tentryCount: number;\n\tleafId: string | null;\n\trevision: number;\n}\n\nexport interface PiServerStreamBaseIdentity {\n\tbaseStaticContextHash: string;\n\tbaseTreeHash: string;\n\tbaseEntryCount: number;\n\tbaseLeafId: string | null;\n\tbaseRevision: number;\n}\n\nexport function matchesPiServerStreamBase(base: PiServerStreamBaseIdentity, session: PiServerSessionIdentity): boolean {\n\treturn (\n\t\tbase.baseStaticContextHash === session.staticContextHash &&\n\t\tbase.baseTreeHash === session.treeHash &&\n\t\tbase.baseEntryCount === session.entryCount &&\n\t\tbase.baseLeafId === session.leafId &&\n\t\tbase.baseRevision === session.revision\n\t);\n}\n\nexport function canonicalJsonStringify(value: unknown): string | undefined {\n\tif (Array.isArray(value)) {\n\t\treturn `[${Array.from(value, (item) => canonicalJsonStringify(item) ?? \"null\").join(\",\")}]`;\n\t}\n\tif (value !== null && typeof value === \"object\") {\n\t\tconst record = value as Record<string, unknown>;\n\t\tconst properties: string[] = [];\n\t\tfor (const key of Object.keys(record).sort()) {\n\t\t\tconst serialized = canonicalJsonStringify(record[key]);\n\t\t\tif (serialized !== undefined) {\n\t\t\t\tproperties.push(`${JSON.stringify(key)}:${serialized}`);\n\t\t\t}\n\t\t}\n\t\treturn `{${properties.join(\",\")}}`;\n\t}\n\treturn JSON.stringify(value);\n}\n\nexport function hashPiServerStaticContext(context: PiServerStaticContext | undefined): string {\n\tif (!context) return \"\";\n\tconst canonical = {\n\t\tsystemPrompt: context.systemPrompt,\n\t\ttools: context.tools?.map((tool) => ({\n\t\t\tname: tool.name,\n\t\t\tdescription: tool.description,\n\t\t\tparameters: tool.parameters,\n\t\t\tconstrainedSampling: tool.constrainedSampling,\n\t\t})),\n\t};\n\tconst serialized = canonicalJsonStringify(canonical);\n\tif (serialized === undefined) {\n\t\tthrow new Error(\"Failed to serialize pi-server static context\");\n\t}\n\treturn createHash(\"sha256\").update(serialized).digest(\"hex\");\n}\n\nexport const PI_SERVER_EMPTY_TREE_HASH = createHash(\"sha256\").update(\"pi-tree-v1\").digest(\"hex\");\n\nexport function hashPiServerTreeEntry(entry: SessionTreeEntry): string {\n\treturn createHash(\"sha256\").update(JSON.stringify(entry)).digest(\"hex\");\n}\n\nexport function appendPiServerTreeHash(previousHash: string, entryHash: string): string {\n\treturn createHash(\"sha256\").update(`${previousHash}:${entryHash}`).digest(\"hex\");\n}\n\nexport function buildPiServerTreePrefixHashes(entries: SessionTreeEntry[]): string[] {\n\tconst hashes = [PI_SERVER_EMPTY_TREE_HASH];\n\tfor (const entry of entries) {\n\t\thashes.push(appendPiServerTreeHash(hashes[hashes.length - 1], hashPiServerTreeEntry(entry)));\n\t}\n\treturn hashes;\n}\n\nexport function hashPiServerSessionEntries(entries: SessionTreeEntry[]): string {\n\treturn buildPiServerTreePrefixHashes(entries)[entries.length];\n}\n"]}
@@ -1,4 +1,28 @@
1
1
  import { createHash } from "node:crypto";
2
+ export function matchesPiServerStreamBase(base, session) {
3
+ return (base.baseStaticContextHash === session.staticContextHash &&
4
+ base.baseTreeHash === session.treeHash &&
5
+ base.baseEntryCount === session.entryCount &&
6
+ base.baseLeafId === session.leafId &&
7
+ base.baseRevision === session.revision);
8
+ }
9
+ export function canonicalJsonStringify(value) {
10
+ if (Array.isArray(value)) {
11
+ return `[${Array.from(value, (item) => canonicalJsonStringify(item) ?? "null").join(",")}]`;
12
+ }
13
+ if (value !== null && typeof value === "object") {
14
+ const record = value;
15
+ const properties = [];
16
+ for (const key of Object.keys(record).sort()) {
17
+ const serialized = canonicalJsonStringify(record[key]);
18
+ if (serialized !== undefined) {
19
+ properties.push(`${JSON.stringify(key)}:${serialized}`);
20
+ }
21
+ }
22
+ return `{${properties.join(",")}}`;
23
+ }
24
+ return JSON.stringify(value);
25
+ }
2
26
  export function hashPiServerStaticContext(context) {
3
27
  if (!context)
4
28
  return "";
@@ -8,9 +32,14 @@ export function hashPiServerStaticContext(context) {
8
32
  name: tool.name,
9
33
  description: tool.description,
10
34
  parameters: tool.parameters,
35
+ constrainedSampling: tool.constrainedSampling,
11
36
  })),
12
37
  };
13
- return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
38
+ const serialized = canonicalJsonStringify(canonical);
39
+ if (serialized === undefined) {
40
+ throw new Error("Failed to serialize pi-server static context");
41
+ }
42
+ return createHash("sha256").update(serialized).digest("hex");
14
43
  }
15
44
  export const PI_SERVER_EMPTY_TREE_HASH = createHash("sha256").update("pi-tree-v1").digest("hex");
16
45
  export function hashPiServerTreeEntry(entry) {
@@ -1 +1 @@
1
- {"version":3,"file":"pi-server-protocol.js","sourceRoot":"","sources":["../src/pi-server-protocol.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AASzC,MAAM,UAAU,yBAAyB,CAAC,OAA0C,EAAU;IAC7F,IAAI,CAAC,OAAO;QAAE,OAAO,EAAE,CAAC;IACxB,MAAM,SAAS,GAAG;QACjB,YAAY,EAAE,OAAO,CAAC,YAAY;QAClC,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;YACpC,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,WAAW,EAAE,IAAI,CAAC,WAAW;YAC7B,UAAU,EAAE,IAAI,CAAC,UAAU;SAC3B,CAAC,CAAC;KACH,CAAC;IACF,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAAA,CAC5E;AAED,MAAM,CAAC,MAAM,yBAAyB,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAEjG,MAAM,UAAU,qBAAqB,CAAC,KAAuB,EAAU;IACtE,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAAA,CACxE;AAED,MAAM,UAAU,sBAAsB,CAAC,YAAoB,EAAE,SAAiB,EAAU;IACvF,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,YAAY,IAAI,SAAS,EAAE,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAAA,CACjF;AAED,MAAM,UAAU,6BAA6B,CAAC,OAA2B,EAAY;IACpF,MAAM,MAAM,GAAG,CAAC,yBAAyB,CAAC,CAAC;IAC3C,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC7B,MAAM,CAAC,IAAI,CAAC,sBAAsB,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,EAAE,qBAAqB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC9F,CAAC;IACD,OAAO,MAAM,CAAC;AAAA,CACd;AAED,MAAM,UAAU,0BAA0B,CAAC,OAA2B,EAAU;IAC/E,OAAO,6BAA6B,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;AAAA,CAC9D","sourcesContent":["import { createHash } from \"node:crypto\";\nimport type { SessionTreeEntry } from \"@earendil-works/pi-agent-core\";\nimport type { Tool } from \"@earendil-works/pi-ai\";\n\nexport interface PiServerStaticContext {\n\tsystemPrompt?: string;\n\ttools?: Tool[];\n}\n\nexport function hashPiServerStaticContext(context: PiServerStaticContext | undefined): string {\n\tif (!context) return \"\";\n\tconst canonical = {\n\t\tsystemPrompt: context.systemPrompt,\n\t\ttools: context.tools?.map((tool) => ({\n\t\t\tname: tool.name,\n\t\t\tdescription: tool.description,\n\t\t\tparameters: tool.parameters,\n\t\t})),\n\t};\n\treturn createHash(\"sha256\").update(JSON.stringify(canonical)).digest(\"hex\");\n}\n\nexport const PI_SERVER_EMPTY_TREE_HASH = createHash(\"sha256\").update(\"pi-tree-v1\").digest(\"hex\");\n\nexport function hashPiServerTreeEntry(entry: SessionTreeEntry): string {\n\treturn createHash(\"sha256\").update(JSON.stringify(entry)).digest(\"hex\");\n}\n\nexport function appendPiServerTreeHash(previousHash: string, entryHash: string): string {\n\treturn createHash(\"sha256\").update(`${previousHash}:${entryHash}`).digest(\"hex\");\n}\n\nexport function buildPiServerTreePrefixHashes(entries: SessionTreeEntry[]): string[] {\n\tconst hashes = [PI_SERVER_EMPTY_TREE_HASH];\n\tfor (const entry of entries) {\n\t\thashes.push(appendPiServerTreeHash(hashes[hashes.length - 1], hashPiServerTreeEntry(entry)));\n\t}\n\treturn hashes;\n}\n\nexport function hashPiServerSessionEntries(entries: SessionTreeEntry[]): string {\n\treturn buildPiServerTreePrefixHashes(entries)[entries.length];\n}\n"]}
1
+ {"version":3,"file":"pi-server-protocol.js","sourceRoot":"","sources":["../src/pi-server-protocol.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash } from \"node:crypto\";\nimport type { SessionTreeEntry } from \"@earendil-works/pi-agent-core\";\nimport type { Tool } from \"@earendil-works/pi-ai\";\n\nexport interface PiServerStaticContext {\n\tsystemPrompt?: string;\n\ttools?: Tool[];\n}\n\nexport interface PiServerSessionIdentity {\n\tstaticContextHash: string;\n\ttreeHash: string;\n\tentryCount: number;\n\tleafId: string | null;\n\trevision: number;\n}\n\nexport interface PiServerStreamBaseIdentity {\n\tbaseStaticContextHash: string;\n\tbaseTreeHash: string;\n\tbaseEntryCount: number;\n\tbaseLeafId: string | null;\n\tbaseRevision: number;\n}\n\nexport function matchesPiServerStreamBase(base: PiServerStreamBaseIdentity, session: PiServerSessionIdentity): boolean {\n\treturn (\n\t\tbase.baseStaticContextHash === session.staticContextHash &&\n\t\tbase.baseTreeHash === session.treeHash &&\n\t\tbase.baseEntryCount === session.entryCount &&\n\t\tbase.baseLeafId === session.leafId &&\n\t\tbase.baseRevision === session.revision\n\t);\n}\n\nexport function canonicalJsonStringify(value: unknown): string | undefined {\n\tif (Array.isArray(value)) {\n\t\treturn `[${Array.from(value, (item) => canonicalJsonStringify(item) ?? \"null\").join(\",\")}]`;\n\t}\n\tif (value !== null && typeof value === \"object\") {\n\t\tconst record = value as Record<string, unknown>;\n\t\tconst properties: string[] = [];\n\t\tfor (const key of Object.keys(record).sort()) {\n\t\t\tconst serialized = canonicalJsonStringify(record[key]);\n\t\t\tif (serialized !== undefined) {\n\t\t\t\tproperties.push(`${JSON.stringify(key)}:${serialized}`);\n\t\t\t}\n\t\t}\n\t\treturn `{${properties.join(\",\")}}`;\n\t}\n\treturn JSON.stringify(value);\n}\n\nexport function hashPiServerStaticContext(context: PiServerStaticContext | undefined): string {\n\tif (!context) return \"\";\n\tconst canonical = {\n\t\tsystemPrompt: context.systemPrompt,\n\t\ttools: context.tools?.map((tool) => ({\n\t\t\tname: tool.name,\n\t\t\tdescription: tool.description,\n\t\t\tparameters: tool.parameters,\n\t\t\tconstrainedSampling: tool.constrainedSampling,\n\t\t})),\n\t};\n\tconst serialized = canonicalJsonStringify(canonical);\n\tif (serialized === undefined) {\n\t\tthrow new Error(\"Failed to serialize pi-server static context\");\n\t}\n\treturn createHash(\"sha256\").update(serialized).digest(\"hex\");\n}\n\nexport const PI_SERVER_EMPTY_TREE_HASH = createHash(\"sha256\").update(\"pi-tree-v1\").digest(\"hex\");\n\nexport function hashPiServerTreeEntry(entry: SessionTreeEntry): string {\n\treturn createHash(\"sha256\").update(JSON.stringify(entry)).digest(\"hex\");\n}\n\nexport function appendPiServerTreeHash(previousHash: string, entryHash: string): string {\n\treturn createHash(\"sha256\").update(`${previousHash}:${entryHash}`).digest(\"hex\");\n}\n\nexport function buildPiServerTreePrefixHashes(entries: SessionTreeEntry[]): string[] {\n\tconst hashes = [PI_SERVER_EMPTY_TREE_HASH];\n\tfor (const entry of entries) {\n\t\thashes.push(appendPiServerTreeHash(hashes[hashes.length - 1], hashPiServerTreeEntry(entry)));\n\t}\n\treturn hashes;\n}\n\nexport function hashPiServerSessionEntries(entries: SessionTreeEntry[]): string {\n\treturn buildPiServerTreePrefixHashes(entries)[entries.length];\n}\n"]}
@@ -1,3 +1,8 @@
1
+ export declare const RECEIVE_UPLOAD_MAX_ENTRIES = 10000;
2
+ export declare const RECEIVE_UPLOAD_MAX_NAME_CHARS = 255;
3
+ export declare const RECEIVE_UPLOAD_MAX_PATH_CHARS = 4096;
4
+ export declare const RECEIVE_UPLOAD_MAX_PATH_SEGMENT_CHARS = 255;
5
+ export declare const RECEIVE_UPLOAD_MAX_DECODED_BYTES: number;
1
6
  export declare class ReceiveUploadError extends Error {
2
7
  status: number;
3
8
  constructor(status: number, message: string);
@@ -1 +1 @@
1
- {"version":3,"file":"receive-upload.d.ts","sourceRoot":"","sources":["../src/receive-upload.ts"],"names":[],"mappings":"AASA,qBAAa,kBAAmB,SAAQ,KAAK;IAC5C,MAAM,EAAE,MAAM,CAAC;IAEf,YAAY,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAG1C;CACD;AA8DD,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAqB/F","sourcesContent":["import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\n\ninterface UploadEntry {\n\tpath: string;\n\ttype: \"file\" | \"directory\";\n\tdata?: Buffer;\n}\n\nexport class ReceiveUploadError extends Error {\n\tstatus: number;\n\n\tconstructor(status: number, message: string) {\n\t\tsuper(message);\n\t\tthis.status = status;\n\t}\n}\n\nfunction invalid(message: string): never {\n\tthrow new ReceiveUploadError(400, message);\n}\n\nfunction validatePath(path: unknown): string {\n\tif (typeof path !== \"string\") invalid(\"entry path must be a string\");\n\tif (path.includes(\"\\\\\") || path.includes(\"\\0\")) invalid(`invalid entry path: ${path}`);\n\tif (path !== \"\" && path.split(\"/\").some((part) => part === \"\" || part === \".\" || part === \"..\")) {\n\t\tinvalid(`invalid entry path: ${path}`);\n\t}\n\treturn path;\n}\n\nfunction validateBody(body: unknown): { name: string; entries: UploadEntry[] } {\n\tif (typeof body !== \"object\" || body === null) invalid(\"upload body must be an object\");\n\tconst upload = body as { name?: unknown; entries?: unknown };\n\tif (\n\t\ttypeof upload.name !== \"string\" ||\n\t\tupload.name === \"\" ||\n\t\tupload.name === \".\" ||\n\t\tupload.name === \"..\" ||\n\t\tupload.name.includes(\"/\") ||\n\t\tupload.name.includes(\"\\\\\") ||\n\t\tupload.name.includes(\"\\0\")\n\t) {\n\t\tinvalid(\"invalid upload name\");\n\t}\n\tif (!Array.isArray(upload.entries) || upload.entries.length === 0) invalid(\"entries must be a non-empty array\");\n\n\tconst entries: UploadEntry[] = [];\n\tconst entryTypes = new Map<string, \"file\" | \"directory\">();\n\tfor (const value of upload.entries) {\n\t\tif (typeof value !== \"object\" || value === null) invalid(\"each entry must be an object\");\n\t\tconst entry = value as { path?: unknown; type?: unknown; data?: unknown };\n\t\tconst path = validatePath(entry.path);\n\t\tif (entry.type !== \"file\" && entry.type !== \"directory\") invalid(`invalid entry type: ${String(entry.type)}`);\n\t\tif (entryTypes.has(path)) invalid(`duplicate entry path: ${path}`);\n\t\tentryTypes.set(path, entry.type);\n\t\tif (entry.type === \"directory\") {\n\t\t\tif (entry.data !== undefined) invalid(`directory entry must not contain data: ${path}`);\n\t\t\tentries.push({ path, type: \"directory\" });\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof entry.data !== \"string\") invalid(`file entry data must be base64: ${path}`);\n\t\tconst data = Buffer.from(entry.data, \"base64\");\n\t\tif (data.toString(\"base64\") !== entry.data) invalid(`file entry data must be valid base64: ${path}`);\n\t\tentries.push({ path, type: \"file\", data });\n\t}\n\n\tconst rootType = entryTypes.get(\"\");\n\tif (!rootType) invalid(\"entries must include the root path\");\n\tif (rootType === \"file\" && entries.length !== 1) invalid(\"a file upload cannot contain child entries\");\n\tfor (const entry of entries) {\n\t\tif (entry.path === \"\") continue;\n\t\tconst parent = entry.path.includes(\"/\") ? entry.path.slice(0, entry.path.lastIndexOf(\"/\")) : \"\";\n\t\tif (entryTypes.get(parent) !== \"directory\") invalid(`missing directory entry: ${parent}`);\n\t}\n\treturn { name: upload.name, entries };\n}\n\nexport function receiveUpload(uploadDir: string, body: unknown): { path: string; files: number } {\n\tconst upload = validateBody(body);\n\tmkdirSync(uploadDir, { recursive: true });\n\tconst destination = join(uploadDir, upload.name);\n\tif (existsSync(destination)) throw new ReceiveUploadError(409, `destination already exists: ${destination}`);\n\tconst temporaryDir = mkdtempSync(join(uploadDir, \".upload-\"));\n\tconst temporaryRoot = join(temporaryDir, upload.name);\n\ttry {\n\t\tfor (const entry of upload.entries.filter((entry) => entry.type === \"directory\")) {\n\t\t\tmkdirSync(join(temporaryRoot, entry.path), { recursive: true });\n\t\t}\n\t\tfor (const entry of upload.entries.filter((entry) => entry.type === \"file\")) {\n\t\t\tconst path = join(temporaryRoot, entry.path);\n\t\t\tmkdirSync(dirname(path), { recursive: true });\n\t\t\twriteFileSync(path, entry.data!);\n\t\t}\n\t\trenameSync(temporaryRoot, destination);\n\t} finally {\n\t\trmSync(temporaryDir, { recursive: true, force: true });\n\t}\n\treturn { path: destination, files: upload.entries.filter((entry) => entry.type === \"file\").length };\n}\n"]}
1
+ {"version":3,"file":"receive-upload.d.ts","sourceRoot":"","sources":["../src/receive-upload.ts"],"names":[],"mappings":"AAqBA,eAAO,MAAM,0BAA0B,QAAS,CAAC;AACjD,eAAO,MAAM,6BAA6B,MAAM,CAAC;AACjD,eAAO,MAAM,6BAA6B,OAAO,CAAC;AAClD,eAAO,MAAM,qCAAqC,MAAM,CAAC;AACzD,eAAO,MAAM,gCAAgC,QAAmB,CAAC;AAEjE,qBAAa,kBAAmB,SAAQ,KAAK;IAC5C,MAAM,EAAE,MAAM,CAAC;IAEf,YAAY,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAG1C;CACD;AAuID,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA2B/F","sourcesContent":["import {\n\tcloseSync,\n\texistsSync,\n\tlstatSync,\n\tmkdirSync,\n\tmkdtempSync,\n\topenSync,\n\treaddirSync,\n\treadSync,\n\trenameSync,\n\trmSync,\n\twriteFileSync,\n} from \"node:fs\";\nimport { dirname, join, relative, sep } from \"node:path\";\n\ninterface UploadEntry {\n\tpath: string;\n\ttype: \"file\" | \"directory\";\n\tdata?: Buffer;\n}\n\nexport const RECEIVE_UPLOAD_MAX_ENTRIES = 10_000;\nexport const RECEIVE_UPLOAD_MAX_NAME_CHARS = 255;\nexport const RECEIVE_UPLOAD_MAX_PATH_CHARS = 4096;\nexport const RECEIVE_UPLOAD_MAX_PATH_SEGMENT_CHARS = 255;\nexport const RECEIVE_UPLOAD_MAX_DECODED_BYTES = 64 * 1024 * 1024;\n\nexport class ReceiveUploadError extends Error {\n\tstatus: number;\n\n\tconstructor(status: number, message: string) {\n\t\tsuper(message);\n\t\tthis.status = status;\n\t}\n}\n\nfunction invalid(message: string): never {\n\tthrow new ReceiveUploadError(400, message);\n}\n\nfunction validatePath(path: unknown): string {\n\tif (typeof path !== \"string\") invalid(\"entry path must be a string\");\n\tif (path.length > RECEIVE_UPLOAD_MAX_PATH_CHARS) {\n\t\tinvalid(`entry path must not exceed ${RECEIVE_UPLOAD_MAX_PATH_CHARS} characters`);\n\t}\n\tif (path.includes(\"\\\\\") || path.includes(\"\\0\")) invalid(`invalid entry path: ${path}`);\n\tif (path !== \"\" && path.split(\"/\").some((part) => part === \"\" || part === \".\" || part === \"..\")) {\n\t\tinvalid(`invalid entry path: ${path}`);\n\t}\n\tif (path.split(\"/\").some((part) => part.length > RECEIVE_UPLOAD_MAX_PATH_SEGMENT_CHARS)) {\n\t\tinvalid(`entry path segment must not exceed ${RECEIVE_UPLOAD_MAX_PATH_SEGMENT_CHARS} characters`);\n\t}\n\treturn path;\n}\n\nfunction validateBody(body: unknown): { name: string; entries: UploadEntry[] } {\n\tif (typeof body !== \"object\" || body === null) invalid(\"upload body must be an object\");\n\tconst upload = body as { name?: unknown; entries?: unknown };\n\tif (\n\t\ttypeof upload.name !== \"string\" ||\n\t\tupload.name === \"\" ||\n\t\tupload.name === \".\" ||\n\t\tupload.name === \"..\" ||\n\t\tupload.name.includes(\"/\") ||\n\t\tupload.name.includes(\"\\\\\") ||\n\t\tupload.name.includes(\"\\0\") ||\n\t\tupload.name.length > RECEIVE_UPLOAD_MAX_NAME_CHARS\n\t) {\n\t\tinvalid(\"invalid upload name\");\n\t}\n\tif (!Array.isArray(upload.entries) || upload.entries.length === 0) invalid(\"entries must be a non-empty array\");\n\tif (upload.entries.length > RECEIVE_UPLOAD_MAX_ENTRIES) {\n\t\tinvalid(`entries must not exceed ${RECEIVE_UPLOAD_MAX_ENTRIES}`);\n\t}\n\n\tconst entries: UploadEntry[] = [];\n\tconst entryTypes = new Map<string, \"file\" | \"directory\">();\n\tlet decodedBytes = 0;\n\tfor (const value of upload.entries) {\n\t\tif (typeof value !== \"object\" || value === null) invalid(\"each entry must be an object\");\n\t\tconst entry = value as { path?: unknown; type?: unknown; data?: unknown };\n\t\tconst path = validatePath(entry.path);\n\t\tif (entry.type !== \"file\" && entry.type !== \"directory\") invalid(`invalid entry type: ${String(entry.type)}`);\n\t\tif (entryTypes.has(path)) invalid(`duplicate entry path: ${path}`);\n\t\tentryTypes.set(path, entry.type);\n\t\tif (entry.type === \"directory\") {\n\t\t\tif (entry.data !== undefined) invalid(`directory entry must not contain data: ${path}`);\n\t\t\tentries.push({ path, type: \"directory\" });\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof entry.data !== \"string\") invalid(`file entry data must be base64: ${path}`);\n\t\tconst data = Buffer.from(entry.data, \"base64\");\n\t\tif (data.toString(\"base64\") !== entry.data) invalid(`file entry data must be valid base64: ${path}`);\n\t\tdecodedBytes += data.byteLength;\n\t\tif (decodedBytes > RECEIVE_UPLOAD_MAX_DECODED_BYTES) {\n\t\t\tinvalid(`decoded upload data must not exceed ${RECEIVE_UPLOAD_MAX_DECODED_BYTES} bytes`);\n\t\t}\n\t\tentries.push({ path, type: \"file\", data });\n\t}\n\n\tconst rootType = entryTypes.get(\"\");\n\tif (!rootType) invalid(\"entries must include the root path\");\n\tif (rootType === \"file\" && entries.length !== 1) invalid(\"a file upload cannot contain child entries\");\n\tfor (const entry of entries) {\n\t\tif (entry.path === \"\") continue;\n\t\tconst parent = entry.path.includes(\"/\") ? entry.path.slice(0, entry.path.lastIndexOf(\"/\")) : \"\";\n\t\tif (entryTypes.get(parent) !== \"directory\") invalid(`missing directory entry: ${parent}`);\n\t}\n\treturn { name: upload.name, entries };\n}\n\nfunction fileMatches(path: string, expected: Buffer): boolean {\n\tconst stat = lstatSync(path);\n\tif (!stat.isFile() || stat.size !== expected.byteLength) return false;\n\tconst handle = openSync(path, \"r\");\n\tconst buffer = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, expected.byteLength)));\n\tlet offset = 0;\n\ttry {\n\t\twhile (offset < expected.byteLength) {\n\t\t\tconst bytesRead = readSync(\n\t\t\t\thandle,\n\t\t\t\tbuffer,\n\t\t\t\t0,\n\t\t\t\tMath.min(buffer.byteLength, expected.byteLength - offset),\n\t\t\t\toffset,\n\t\t\t);\n\t\t\tif (bytesRead === 0 || !buffer.subarray(0, bytesRead).equals(expected.subarray(offset, offset + bytesRead))) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\toffset += bytesRead;\n\t\t}\n\t\treturn true;\n\t} finally {\n\t\tcloseSync(handle);\n\t}\n}\n\nfunction existingDestinationMatches(destination: string, entries: UploadEntry[]): boolean {\n\tconst expected = new Map(entries.map((entry) => [entry.path, entry]));\n\tconst root = expected.get(\"\");\n\tif (!root) return false;\n\tconst destinationStat = lstatSync(destination);\n\tif (root.type === \"file\") {\n\t\treturn !destinationStat.isSymbolicLink() && fileMatches(destination, root.data!);\n\t}\n\tif (!destinationStat.isDirectory() || destinationStat.isSymbolicLink()) return false;\n\n\tlet visited = 1;\n\tconst visitDirectory = (directory: string): boolean => {\n\t\tfor (const child of readdirSync(directory, { withFileTypes: true })) {\n\t\t\tif (child.isSymbolicLink()) return false;\n\t\t\tconst childPath = join(directory, child.name);\n\t\t\tconst entryPath = relative(destination, childPath).split(sep).join(\"/\");\n\t\t\tconst entry = expected.get(entryPath);\n\t\t\tif (!entry) return false;\n\t\t\tvisited++;\n\t\t\tif (visited > expected.size) return false;\n\t\t\tif (entry.type === \"directory\") {\n\t\t\t\tif (!child.isDirectory() || !visitDirectory(childPath)) return false;\n\t\t\t} else if (!child.isFile() || !fileMatches(childPath, entry.data!)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\t\treturn true;\n\t};\n\n\treturn visitDirectory(destination) && visited === expected.size;\n}\n\nexport function receiveUpload(uploadDir: string, body: unknown): { path: string; files: number } {\n\tconst upload = validateBody(body);\n\tmkdirSync(uploadDir, { recursive: true });\n\tconst destination = join(uploadDir, upload.name);\n\tconst fileCount = upload.entries.filter((entry) => entry.type === \"file\").length;\n\tif (existsSync(destination)) {\n\t\tif (existingDestinationMatches(destination, upload.entries)) {\n\t\t\treturn { path: destination, files: fileCount };\n\t\t}\n\t\tthrow new ReceiveUploadError(409, `destination already exists with different contents: ${destination}`);\n\t}\n\tconst temporaryDir = mkdtempSync(join(uploadDir, \".upload-\"));\n\tconst temporaryRoot = join(temporaryDir, upload.name);\n\ttry {\n\t\tfor (const entry of upload.entries.filter((entry) => entry.type === \"directory\")) {\n\t\t\tmkdirSync(join(temporaryRoot, entry.path), { recursive: true });\n\t\t}\n\t\tfor (const entry of upload.entries.filter((entry) => entry.type === \"file\")) {\n\t\t\tconst path = join(temporaryRoot, entry.path);\n\t\t\tmkdirSync(dirname(path), { recursive: true });\n\t\t\twriteFileSync(path, entry.data!);\n\t\t}\n\t\trenameSync(temporaryRoot, destination);\n\t} finally {\n\t\trmSync(temporaryDir, { recursive: true, force: true });\n\t}\n\treturn { path: destination, files: fileCount };\n}\n"]}
@@ -1,5 +1,10 @@
1
- import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { dirname, join, relative, sep } from "node:path";
3
+ export const RECEIVE_UPLOAD_MAX_ENTRIES = 10_000;
4
+ export const RECEIVE_UPLOAD_MAX_NAME_CHARS = 255;
5
+ export const RECEIVE_UPLOAD_MAX_PATH_CHARS = 4096;
6
+ export const RECEIVE_UPLOAD_MAX_PATH_SEGMENT_CHARS = 255;
7
+ export const RECEIVE_UPLOAD_MAX_DECODED_BYTES = 64 * 1024 * 1024;
3
8
  export class ReceiveUploadError extends Error {
4
9
  status;
5
10
  constructor(status, message) {
@@ -13,11 +18,17 @@ function invalid(message) {
13
18
  function validatePath(path) {
14
19
  if (typeof path !== "string")
15
20
  invalid("entry path must be a string");
21
+ if (path.length > RECEIVE_UPLOAD_MAX_PATH_CHARS) {
22
+ invalid(`entry path must not exceed ${RECEIVE_UPLOAD_MAX_PATH_CHARS} characters`);
23
+ }
16
24
  if (path.includes("\\") || path.includes("\0"))
17
25
  invalid(`invalid entry path: ${path}`);
18
26
  if (path !== "" && path.split("/").some((part) => part === "" || part === "." || part === "..")) {
19
27
  invalid(`invalid entry path: ${path}`);
20
28
  }
29
+ if (path.split("/").some((part) => part.length > RECEIVE_UPLOAD_MAX_PATH_SEGMENT_CHARS)) {
30
+ invalid(`entry path segment must not exceed ${RECEIVE_UPLOAD_MAX_PATH_SEGMENT_CHARS} characters`);
31
+ }
21
32
  return path;
22
33
  }
23
34
  function validateBody(body) {
@@ -30,13 +41,18 @@ function validateBody(body) {
30
41
  upload.name === ".." ||
31
42
  upload.name.includes("/") ||
32
43
  upload.name.includes("\\") ||
33
- upload.name.includes("\0")) {
44
+ upload.name.includes("\0") ||
45
+ upload.name.length > RECEIVE_UPLOAD_MAX_NAME_CHARS) {
34
46
  invalid("invalid upload name");
35
47
  }
36
48
  if (!Array.isArray(upload.entries) || upload.entries.length === 0)
37
49
  invalid("entries must be a non-empty array");
50
+ if (upload.entries.length > RECEIVE_UPLOAD_MAX_ENTRIES) {
51
+ invalid(`entries must not exceed ${RECEIVE_UPLOAD_MAX_ENTRIES}`);
52
+ }
38
53
  const entries = [];
39
54
  const entryTypes = new Map();
55
+ let decodedBytes = 0;
40
56
  for (const value of upload.entries) {
41
57
  if (typeof value !== "object" || value === null)
42
58
  invalid("each entry must be an object");
@@ -58,6 +74,10 @@ function validateBody(body) {
58
74
  const data = Buffer.from(entry.data, "base64");
59
75
  if (data.toString("base64") !== entry.data)
60
76
  invalid(`file entry data must be valid base64: ${path}`);
77
+ decodedBytes += data.byteLength;
78
+ if (decodedBytes > RECEIVE_UPLOAD_MAX_DECODED_BYTES) {
79
+ invalid(`decoded upload data must not exceed ${RECEIVE_UPLOAD_MAX_DECODED_BYTES} bytes`);
80
+ }
61
81
  entries.push({ path, type: "file", data });
62
82
  }
63
83
  const rootType = entryTypes.get("");
@@ -74,12 +94,74 @@ function validateBody(body) {
74
94
  }
75
95
  return { name: upload.name, entries };
76
96
  }
97
+ function fileMatches(path, expected) {
98
+ const stat = lstatSync(path);
99
+ if (!stat.isFile() || stat.size !== expected.byteLength)
100
+ return false;
101
+ const handle = openSync(path, "r");
102
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, expected.byteLength)));
103
+ let offset = 0;
104
+ try {
105
+ while (offset < expected.byteLength) {
106
+ const bytesRead = readSync(handle, buffer, 0, Math.min(buffer.byteLength, expected.byteLength - offset), offset);
107
+ if (bytesRead === 0 || !buffer.subarray(0, bytesRead).equals(expected.subarray(offset, offset + bytesRead))) {
108
+ return false;
109
+ }
110
+ offset += bytesRead;
111
+ }
112
+ return true;
113
+ }
114
+ finally {
115
+ closeSync(handle);
116
+ }
117
+ }
118
+ function existingDestinationMatches(destination, entries) {
119
+ const expected = new Map(entries.map((entry) => [entry.path, entry]));
120
+ const root = expected.get("");
121
+ if (!root)
122
+ return false;
123
+ const destinationStat = lstatSync(destination);
124
+ if (root.type === "file") {
125
+ return !destinationStat.isSymbolicLink() && fileMatches(destination, root.data);
126
+ }
127
+ if (!destinationStat.isDirectory() || destinationStat.isSymbolicLink())
128
+ return false;
129
+ let visited = 1;
130
+ const visitDirectory = (directory) => {
131
+ for (const child of readdirSync(directory, { withFileTypes: true })) {
132
+ if (child.isSymbolicLink())
133
+ return false;
134
+ const childPath = join(directory, child.name);
135
+ const entryPath = relative(destination, childPath).split(sep).join("/");
136
+ const entry = expected.get(entryPath);
137
+ if (!entry)
138
+ return false;
139
+ visited++;
140
+ if (visited > expected.size)
141
+ return false;
142
+ if (entry.type === "directory") {
143
+ if (!child.isDirectory() || !visitDirectory(childPath))
144
+ return false;
145
+ }
146
+ else if (!child.isFile() || !fileMatches(childPath, entry.data)) {
147
+ return false;
148
+ }
149
+ }
150
+ return true;
151
+ };
152
+ return visitDirectory(destination) && visited === expected.size;
153
+ }
77
154
  export function receiveUpload(uploadDir, body) {
78
155
  const upload = validateBody(body);
79
156
  mkdirSync(uploadDir, { recursive: true });
80
157
  const destination = join(uploadDir, upload.name);
81
- if (existsSync(destination))
82
- throw new ReceiveUploadError(409, `destination already exists: ${destination}`);
158
+ const fileCount = upload.entries.filter((entry) => entry.type === "file").length;
159
+ if (existsSync(destination)) {
160
+ if (existingDestinationMatches(destination, upload.entries)) {
161
+ return { path: destination, files: fileCount };
162
+ }
163
+ throw new ReceiveUploadError(409, `destination already exists with different contents: ${destination}`);
164
+ }
83
165
  const temporaryDir = mkdtempSync(join(uploadDir, ".upload-"));
84
166
  const temporaryRoot = join(temporaryDir, upload.name);
85
167
  try {
@@ -96,6 +178,6 @@ export function receiveUpload(uploadDir, body) {
96
178
  finally {
97
179
  rmSync(temporaryDir, { recursive: true, force: true });
98
180
  }
99
- return { path: destination, files: upload.entries.filter((entry) => entry.type === "file").length };
181
+ return { path: destination, files: fileCount };
100
182
  }
101
183
  //# sourceMappingURL=receive-upload.js.map