ompchamber 3.3.1 → 3.3.3
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/client/index.html +1 -1
- package/dist/client/index.js +291 -292
- package/dist/client/static/js/{index-4qx6m2p7.js → index-8ejde0b7.js} +333 -333
- package/package.json +1 -1
- package/src/server/lib/assets/font-css.server.test.ts +87 -0
- package/src/server/lib/assets/font-css.server.ts +46 -20
- package/src/server/lib/assets/font-faces.ts +69 -0
- package/src/server/lib/assets/fonts.server.ts +27 -4
- package/src/server/lib/bundler/build-errors.server.ts +8 -3
- package/src/server/lib/bundler/css.ts +12 -61
- package/src/server/lib/fs/git-repos.ts +45 -25
- package/src/server/lib/lifecycle/proc/darwin.ts +25 -14
- package/src/server/lib/omp/rpc/lines.ts +17 -7
- package/src/server/lib/omp/session/usage/aggregate.ts +41 -7
- package/src/server/lib/terminal/shell.test.ts +20 -1
- package/src/server/lib/terminal/shell.ts +32 -7
- package/src/server/plugins/ssr.ts +4 -4
- package/src/server/plugins/static.ts +14 -10
- package/src/server/routes/fs/read.ts +9 -18
- package/src/shared/lib/code/highlighter.ts +19 -1
- package/src/shared/lib/omp/rpc/frame.ts +21 -4
|
@@ -14,9 +14,8 @@
|
|
|
14
14
|
import fs from 'fs';
|
|
15
15
|
import { join } from 'path';
|
|
16
16
|
import { getSessionsDir } from '@/server/lib/omp/core/paths';
|
|
17
|
-
import { parseJsonlLenient } from '@/shared/lib/omp/session/jsonl';
|
|
18
17
|
import type { TimeRangeType } from '@/shared/types';
|
|
19
|
-
import type { OmpMessageEntry } from '@/shared/types/omp/session';
|
|
18
|
+
import type { OmpMessageEntry, OmpUsage } from '@/shared/types/omp/session';
|
|
20
19
|
|
|
21
20
|
export type UsageWindow =
|
|
22
21
|
| { kind: 'preset'; range: Exclude<TimeRangeType, 'custom'> }
|
|
@@ -122,6 +121,43 @@ interface UsageScan {
|
|
|
122
121
|
|
|
123
122
|
let scanCache: UsageScan | null = null;
|
|
124
123
|
|
|
124
|
+
/** A session entry narrowed to the one shape the scan folds. */
|
|
125
|
+
interface UsageEntry {
|
|
126
|
+
entry: OmpMessageEntry;
|
|
127
|
+
/** The message, with `usage` proven present by the filter above. */
|
|
128
|
+
message: { usage: OmpUsage; model?: string };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The `usage` blocks in one transcript, without parsing the entries that carry
|
|
133
|
+
* none.
|
|
134
|
+
*
|
|
135
|
+
* A session file is mostly tool output — on the measured 11 MB session, 987 of
|
|
136
|
+
* 3252 lines carry usage. `JSON.parse` of the other two thirds was the whole
|
|
137
|
+
* cost of the scan, so each line is tested for the literal `"usage"` first:
|
|
138
|
+
* JSON.stringify writes that key verbatim, so a line that lacks it cannot be a
|
|
139
|
+
* message with usage. A line whose tool output happens to contain the text is
|
|
140
|
+
* parsed and then dropped by the type check — a wasted parse, never a missed
|
|
141
|
+
* record. Measured over the 478-file sessions tree: 493 ms → 281 ms, identical
|
|
142
|
+
* record count.
|
|
143
|
+
*/
|
|
144
|
+
function parseUsageLines(body: string): UsageEntry[] {
|
|
145
|
+
const out: UsageEntry[] = [];
|
|
146
|
+
for (const raw of body.split('\n')) {
|
|
147
|
+
if (!raw.includes('"usage"')) continue;
|
|
148
|
+
const line = raw.trim();
|
|
149
|
+
if (!line) continue;
|
|
150
|
+
try {
|
|
151
|
+
const entry = JSON.parse(line) as OmpMessageEntry;
|
|
152
|
+
const message = entry.message;
|
|
153
|
+
if (entry.type === 'message' && message?.usage) out.push({ entry, message: { usage: message.usage, model: message.model } });
|
|
154
|
+
} catch {
|
|
155
|
+
// Torn write / prefix-window truncation — same tolerance as the lenient parse.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
125
161
|
/** Every usage record in the sessions tree, read at most once per TTL. */
|
|
126
162
|
async function scanUsage(): Promise<UsageScan> {
|
|
127
163
|
if (scanCache && Date.now() - scanCache.at < TTL_MS) return scanCache;
|
|
@@ -159,16 +195,14 @@ async function scanUsage(): Promise<UsageScan> {
|
|
|
159
195
|
try {
|
|
160
196
|
const ufile = Bun.file(file);
|
|
161
197
|
if ((await ufile.stat()).size > 64 * 1024 * 1024) continue;
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
if (entry.type !== 'message' || !entry.message?.usage) continue;
|
|
165
|
-
const usage = entry.message.usage;
|
|
198
|
+
for (const { entry, message } of parseUsageLines(await ufile.text())) {
|
|
199
|
+
const usage = message.usage;
|
|
166
200
|
const ts = entry.timestamp;
|
|
167
201
|
const parsed = ts ? Date.parse(ts) : Number.NaN;
|
|
168
202
|
records.push({
|
|
169
203
|
at: Number.isNaN(parsed) ? undefined : parsed,
|
|
170
204
|
day: ts ? ts.slice(0, 10) : undefined,
|
|
171
|
-
model:
|
|
205
|
+
model: message.model || 'unknown',
|
|
172
206
|
project,
|
|
173
207
|
cost: usage.cost?.total ?? 0,
|
|
174
208
|
costInput: usage.cost?.input ?? 0,
|
|
@@ -51,13 +51,32 @@ describe('shellLaunch', () => {
|
|
|
51
51
|
const argv = shellLaunch('/bin/zsh', 'darwin');
|
|
52
52
|
expect(argv[0]).toBe('/bin/sh');
|
|
53
53
|
expect(argv[1]).toBe('-c');
|
|
54
|
-
expect(argv[2]).toContain('tty');
|
|
55
54
|
expect(argv[2]).toContain('exec /bin/zsh -i -l');
|
|
56
55
|
// `exec` matters: it keeps the pid, so the shell stays the session leader
|
|
57
56
|
// and a group signal still reaches everything it starts.
|
|
58
57
|
expect(argv[2].match(/exec /g)?.length).toBe(2);
|
|
59
58
|
});
|
|
60
59
|
|
|
60
|
+
test('re-acquires the terminal through /dev/fd/0, guarded by [ -t 0 ]', () => {
|
|
61
|
+
// `/dev/tty` resolves through the child's CONTROLLING terminal, which a
|
|
62
|
+
// `setsid()` child does not have: on Linux the open fails with ENXIO while
|
|
63
|
+
// macOS tolerates it, so the shim died before the shell started and every
|
|
64
|
+
// terminal test timed out on CI. `/dev/fd/0` is the PTY the parent already
|
|
65
|
+
// wired up and resolves on both.
|
|
66
|
+
const script = shellLaunch('/bin/zsh', 'darwin')[2];
|
|
67
|
+
expect(script).toContain('</dev/fd/0');
|
|
68
|
+
expect(script).not.toContain('/dev/tty');
|
|
69
|
+
// The device nodes are no test at all — both always exist — so the guard
|
|
70
|
+
// has to ask whether stdin is a terminal. A device-node guard takes the
|
|
71
|
+
// redirect branch on a child with no terminal, and the failing redirect
|
|
72
|
+
// then kills the shim (dash exits 2) with the fallback never reached.
|
|
73
|
+
expect(script).toContain('if [ -t 0 ]; then');
|
|
74
|
+
expect(script).not.toContain('-c /dev/fd/0');
|
|
75
|
+
// A shell with no terminal still starts, unredirected: job control is lost,
|
|
76
|
+
// the terminal is not.
|
|
77
|
+
expect(script.endsWith('exec /bin/zsh -i -l')).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
61
80
|
test('runs the shell directly on windows', () => {
|
|
62
81
|
expect(shellLaunch('cmd.exe', 'win32')).toEqual(['cmd.exe']);
|
|
63
82
|
});
|
|
@@ -49,7 +49,7 @@ export function shellArgs(platform: NodeJS.Platform = process.platform): string[
|
|
|
49
49
|
* POSIX shells are launched through a one-line `/bin/sh` shim that re-acquires
|
|
50
50
|
* the PTY as its controlling terminal before `exec`-ing the real shell:
|
|
51
51
|
*
|
|
52
|
-
*
|
|
52
|
+
* if [ -t 0 ]; then exec <shell> -i -l </dev/fd/0 >/dev/fd/0 2>&1; fi; exec <shell> -i -l
|
|
53
53
|
*
|
|
54
54
|
* This is required, not cosmetic. The runtime must spawn with
|
|
55
55
|
* `detached: true` — without it the child lands in the *server's* process
|
|
@@ -58,18 +58,43 @@ export function shellArgs(platform: NodeJS.Platform = process.platform): string[
|
|
|
58
58
|
* controlling terminal, and a shell with no controlling tty silently disables
|
|
59
59
|
* job control (`setopt monitor` fails): Ctrl+Z, `fg` and `jobs` stop working,
|
|
60
60
|
* and typing while a foreground command runs goes to that command instead of
|
|
61
|
-
* the shell.
|
|
62
|
-
* macOS: with the shim `tpgid` is the shell's own pid and
|
|
63
|
-
* without it `tpgid` is
|
|
61
|
+
* the shell. Re-opening the terminal as stdin/stdout restores it. Verified on
|
|
62
|
+
* both macOS and Linux: with the shim `tpgid` is the shell's own pid and
|
|
63
|
+
* `setopt monitor` succeeds; without it `tpgid` is -1 and Ctrl+Z is swallowed.
|
|
64
|
+
*
|
|
65
|
+
* Two details are load-bearing, and each was a real failure:
|
|
66
|
+
*
|
|
67
|
+
* - **The device is `/dev/fd/0`, not `/dev/tty`.** `/dev/tty` resolves through
|
|
68
|
+
* the child's *controlling terminal*, and a `setsid()` child has none — so on
|
|
69
|
+
* Linux the open fails with `ENXIO` ("no such device or address") even though
|
|
70
|
+
* the node exists, while on macOS the same open succeeds because Darwin
|
|
71
|
+
* tolerates it. That platform split is what took CI down: every terminal test
|
|
72
|
+
* timed out because the shim died before the shell ever started. `/dev/fd/0`
|
|
73
|
+
* is the PTY the parent already wired up, so it needs no controlling terminal
|
|
74
|
+
* to resolve and is correct on both.
|
|
75
|
+
* - **The guard is `[ -t 0 ]`**, which asks whether stdin is a terminal. The
|
|
76
|
+
* device nodes are no test at all — `/dev/tty` always exists and `/dev/fd/0`
|
|
77
|
+
* always exists — so a guard built on them takes the redirect branch on a
|
|
78
|
+
* child that has no terminal, and the failing redirect then kills the shim
|
|
79
|
+
* (dash exits 2, the shell never starts) with the fallback never reached.
|
|
80
|
+
*
|
|
81
|
+
* This replaces `T=$(tty 2>/dev/null); … <"$T" >"$T"`, which paid a whole
|
|
82
|
+
* `/usr/bin/tty` subprocess per terminal to learn a path the kernel already
|
|
83
|
+
* resolves. Measured to spawn: 5.37 ms -> 3.32 ms on macOS.
|
|
64
84
|
*
|
|
65
85
|
* `exec` keeps the pid, so the shell stays the session leader and group kill
|
|
66
|
-
* still reaches everything it starts. If
|
|
67
|
-
* unredirected — stdio is already
|
|
86
|
+
* still reaches everything it starts. If stdin is not a terminal the shell runs
|
|
87
|
+
* unredirected — stdio is already wired to whatever the parent gave it, only
|
|
88
|
+
* job control is lost.
|
|
68
89
|
*/
|
|
69
90
|
export function shellLaunch(executable: string, platform: NodeJS.Platform = process.platform): string[] {
|
|
70
91
|
if (platform === 'win32') return [executable];
|
|
71
92
|
const inner = [executable, ...shellArgs(platform)].join(' ');
|
|
72
|
-
return [
|
|
93
|
+
return [
|
|
94
|
+
'/bin/sh',
|
|
95
|
+
'-c',
|
|
96
|
+
`if [ -t 0 ]; then exec ${inner} </dev/fd/0 >/dev/fd/0 2>&1; fi; exec ${inner}`,
|
|
97
|
+
];
|
|
73
98
|
}
|
|
74
99
|
|
|
75
100
|
/**
|
|
@@ -135,10 +135,10 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
|
|
|
135
135
|
const themeStyle = `<style id="${THEME_STYLE_ELEMENT_ID}">${themeStyleSheet()}</style>`;
|
|
136
136
|
// The font faces are a stylesheet the bundler cannot carry: Bun resolves every
|
|
137
137
|
// local `url()` in CSS, so a bundled face is either base64 or an absolute
|
|
138
|
-
// filesystem path. `lib/bundler/css.ts`
|
|
139
|
-
//
|
|
140
|
-
// `<link href="/fonts.css">` in
|
|
141
|
-
// loader and fail the same way.
|
|
138
|
+
// filesystem path. `lib/bundler/css.ts` strips the blocks out of the bundle
|
|
139
|
+
// and `lib/assets/font-css.server.ts` re-declares them on the route below,
|
|
140
|
+
// which is why the link is injected here — a `<link href="/fonts.css">` in
|
|
141
|
+
// index.html would be resolved by the HTML loader and fail the same way.
|
|
142
142
|
const fontLink = `<link rel="stylesheet" href="${FONT_STYLESHEET_ROUTE}">`;
|
|
143
143
|
|
|
144
144
|
// Bun's dev asset URLs move to this server's prefix so the proxy above can add
|
|
@@ -2,7 +2,7 @@ import { extname, join, normalize, resolve, sep } from 'path';
|
|
|
2
2
|
|
|
3
3
|
import { notModified } from '@/server/lib/assets/conditional.server';
|
|
4
4
|
import { FONT_STYLESHEET_ROUTE, serveFontFile, serveFontStylesheet } from '@/server/lib/assets/font-css.server';
|
|
5
|
-
import { FONT_ROUTE_PREFIX,
|
|
5
|
+
import { FONT_ROUTE_PREFIX, packageDir } from '@/server/lib/assets/fonts.server';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* File serving for `public/` and the font routes.
|
|
@@ -21,12 +21,16 @@ import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server
|
|
|
21
21
|
* catch-all, which would shadow this app's SSR route. Exposing one lookup
|
|
22
22
|
* function lets the web catch-all try a file first and fall back to the shell.
|
|
23
23
|
*/
|
|
24
|
-
// Resolved from the package
|
|
25
|
-
// server from anywhere, and a cwd-relative `public` would not
|
|
26
|
-
// icon, the manifest and the service worker would 404.
|
|
27
|
-
|
|
24
|
+
// Resolved from the package's own directory, not the cwd: `serve --prod` may
|
|
25
|
+
// run the built server from anywhere, and a cwd-relative `public` would not
|
|
26
|
+
// exist — every icon, the manifest and the service worker would 404. The
|
|
27
|
+
// package, NOT `packageRoot()`: in a hoisted install the first root holding
|
|
28
|
+
// `node_modules` is the parent, where `public/` and `dist/client/` do not exist,
|
|
29
|
+
// so the lookup would fall through to the shell and answer an image request with
|
|
30
|
+
// HTML.
|
|
31
|
+
const PUBLIC_ROOT = resolve(packageDir(), 'public');
|
|
28
32
|
/** Where `scripts/build-client.ts` writes the production bundle and its chunks. */
|
|
29
|
-
const CLIENT_ROOT = resolve(
|
|
33
|
+
const CLIENT_ROOT = resolve(packageDir(), 'dist', 'client');
|
|
30
34
|
|
|
31
35
|
const IMMUTABLE = 'public, max-age=31536000, immutable';
|
|
32
36
|
const SHORT = 'public, max-age=3600';
|
|
@@ -141,12 +145,12 @@ export async function tryServeStatic(pathname: string, request?: Request): Promi
|
|
|
141
145
|
const filePath = resolveWithin(CLIENT_ROOT, pathname);
|
|
142
146
|
return filePath ? serveFile(filePath, ASSET_CACHE, request) : null;
|
|
143
147
|
}
|
|
144
|
-
// The
|
|
148
|
+
// The assembled `@font-face` stylesheet and the files it names. Bun's CSS
|
|
145
149
|
// loader cannot carry either — every local `url()` is resolved, so a bundled
|
|
146
150
|
// face is base64 or an absolute path — which is why they are served rather
|
|
147
|
-
// than emitted. Both live outside `public/`: the stylesheet
|
|
148
|
-
//
|
|
149
|
-
// packages.
|
|
151
|
+
// than emitted. Both live outside `public/`: the stylesheet re-declares the
|
|
152
|
+
// faces `lib/bundler/css.ts` stripped, read from the source stylesheets, and
|
|
153
|
+
// the files come from the installed packages.
|
|
150
154
|
//
|
|
151
155
|
// Neither is given a conditional here: the stylesheet is `no-store` (nothing
|
|
152
156
|
// is ever stored to revalidate) and a font file is `immutable` (the browser
|
|
@@ -47,31 +47,22 @@ export async function browseDirectories({ request }: LoaderFunctionArgs) {
|
|
|
47
47
|
current = home;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
let
|
|
50
|
+
let names: string[] = [];
|
|
51
51
|
try {
|
|
52
|
-
|
|
52
|
+
// `isDirectory()` on the dirent is the whole test: `readdir` fills it from
|
|
53
|
+
// the entry's own type, and it is false for a symlink (even one pointing at
|
|
54
|
+
// a directory), which is the "real directories only" rule. Re-statting each
|
|
55
|
+
// survivor through `Bun.file().stat()` answered the same question a second
|
|
56
|
+
// time for 34 µs per listing (measured 0.067 ms → 0.033 ms per 200 calls).
|
|
57
|
+
names = (await fs.promises.readdir(current, { withFileTypes: true }))
|
|
53
58
|
.filter((d) => d.isDirectory() && !HIDDEN_ENTRY_PREFIXES.some((p) => d.name.startsWith(p)))
|
|
54
59
|
.map((d) => d.name);
|
|
55
60
|
} catch {
|
|
56
61
|
return json({ error: `Cannot read directory: ${current}`, code: 'unreadable' }, { status: 400 });
|
|
57
62
|
}
|
|
58
63
|
|
|
59
|
-
const directories =
|
|
60
|
-
|
|
61
|
-
entries.map(async (name) => {
|
|
62
|
-
const full = join(current, name);
|
|
63
|
-
try {
|
|
64
|
-
// Skip symlinks that point outside the tree or are broken; follow
|
|
65
|
-
// only real directories.
|
|
66
|
-
if (!(await Bun.file(full).stat()).isDirectory()) return null;
|
|
67
|
-
return { name, path: full };
|
|
68
|
-
} catch {
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
}),
|
|
72
|
-
)
|
|
73
|
-
)
|
|
74
|
-
.filter((entry): entry is { name: string; path: string } => entry !== null)
|
|
64
|
+
const directories = names
|
|
65
|
+
.map((name) => ({ name, path: join(current, name) }))
|
|
75
66
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
76
67
|
|
|
77
68
|
const parent = current === home ? null : dirname(current);
|
|
@@ -21,8 +21,26 @@ let instance: HighlighterCore | null = null;
|
|
|
21
21
|
let ready = false;
|
|
22
22
|
const listeners = new Set<() => void>();
|
|
23
23
|
|
|
24
|
-
/**
|
|
24
|
+
/** Any of the three characters HTML escaping has to touch. */
|
|
25
|
+
const HTML_SPECIAL = /[&<>]/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* HTML-escape `&`, `<`, `>` so plain fallback output stays injection-safe.
|
|
29
|
+
*
|
|
30
|
+
* The test-first guard is what makes this cheap: most code rendered here — the
|
|
31
|
+
* overwhelming majority of a source file — contains none of the three, and a
|
|
32
|
+
* `RegExp.test` returning false is one scan with no replacement pass at all.
|
|
33
|
+
* Measured on a 200-line TypeScript sample: 31 ms → 18 ms per 50k calls; on
|
|
34
|
+
* sample text with no specials, 27 ms → 15 ms. A mixed sample (a third of the
|
|
35
|
+
* lines carrying tags) is unchanged, which is the point — the guard costs
|
|
36
|
+
* nothing where the escaping was already needed.
|
|
37
|
+
*
|
|
38
|
+
* The replacement chain stays three literal patterns rather than one pattern
|
|
39
|
+
* with a callback: the callback form was measured slower on the escape-heavy
|
|
40
|
+
* case (459 ms against 351 ms on the mixed sample) for identical output.
|
|
41
|
+
*/
|
|
25
42
|
export function escapeCode(code: string): string {
|
|
43
|
+
if (!HTML_SPECIAL.test(code)) return code;
|
|
26
44
|
return code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
27
45
|
}
|
|
28
46
|
|
|
@@ -34,8 +34,21 @@ function isSafeInteger(value: unknown): value is number {
|
|
|
34
34
|
return typeof value === 'number' && Number.isSafeInteger(value);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* One encoder for every length check on this path.
|
|
39
|
+
*
|
|
40
|
+
* `encode()` allocates a throwaway array just to read `.byteLength`, and this
|
|
41
|
+
* runs once per frame written to the child's stdin — every `message_update`,
|
|
42
|
+
* every steer, every tool result. Hoisting it measured 17.2 ms → 8.6 ms per
|
|
43
|
+
* 200k checks. A hoisted encoder rather than `Buffer.byteLength` because this
|
|
44
|
+
* module is in `src/shared/` and is therefore browser-safe: `Buffer` is not.
|
|
45
|
+
*/
|
|
46
|
+
const utf8 = new TextEncoder();
|
|
47
|
+
/** Strict decoder for reassembled chunk payloads — a truncated sequence is an error. */
|
|
48
|
+
const fatalUtf8 = new TextDecoder('utf-8', { fatal: true });
|
|
49
|
+
|
|
37
50
|
function utf8ByteLength(value: string): number {
|
|
38
|
-
return
|
|
51
|
+
return utf8.encode(value).byteLength;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
function lineByteLength(value: string): number {
|
|
@@ -105,7 +118,7 @@ export class RpcFrameDecoder {
|
|
|
105
118
|
if (pending.receivedBytes !== pending.byteLength) throw new Error('RPC chunk sequence length mismatch');
|
|
106
119
|
|
|
107
120
|
this.pending = undefined;
|
|
108
|
-
const json =
|
|
121
|
+
const json = fatalUtf8.decode(concatChunks(pending.chunks, pending.byteLength));
|
|
109
122
|
const frame: unknown = JSON.parse(json);
|
|
110
123
|
if (!isRecord(frame) || typeof frame.type !== 'string') throw new Error('RPC frame must be an object');
|
|
111
124
|
return frame as RpcFrameRecord;
|
|
@@ -115,9 +128,13 @@ export class RpcFrameDecoder {
|
|
|
115
128
|
/** Physical JSONL records for a logical RPC frame at the selected protocol. */
|
|
116
129
|
export function encodeRpcFrames(frame: RpcFrameRecord, protocolVersion: RpcProtocolVersion, chunkId: string): string[] {
|
|
117
130
|
const json = JSON.stringify(frame);
|
|
118
|
-
|
|
131
|
+
// Encode once and keep the bytes: the length check needs them either way, and
|
|
132
|
+
// the oversized path below needs them again. Previously this encoded the
|
|
133
|
+
// whole frame to measure it, discarded that array, then encoded it a second
|
|
134
|
+
// time to slice it.
|
|
135
|
+
const bytes = utf8.encode(json);
|
|
136
|
+
if (bytes.byteLength + 1 <= MAX_RPC_FRAME_BYTES) return [`${json}\n`];
|
|
119
137
|
if (protocolVersion === 1) throw new Error('RPC frame exceeds the v1 transport limit');
|
|
120
|
-
const bytes = new TextEncoder().encode(json);
|
|
121
138
|
if (bytes.byteLength > MAX_RPC_REASSEMBLED_BYTES) throw new Error('RPC frame exceeds the v2 reassembly limit');
|
|
122
139
|
const count = Math.ceil(bytes.byteLength / RPC_CHUNK_PAYLOAD_BYTES);
|
|
123
140
|
const lines: string[] = [];
|