Skip to content

Commit 3b91be8

Browse files
committed
feat: json-records content type support
1 parent ac44f8e commit 3b91be8

File tree

5 files changed

+113
-5
lines changed

5 files changed

+113
-5
lines changed

src/model/events/body-formatting.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { Headers } from '../../types';
22
import { styled } from '../../styles';
33

4-
import { ViewableContentType } from '../events/content-types';
4+
import { jsonRecordsSeparators, ViewableContentType } from '../events/content-types';
55
import { ObservablePromise, observablePromise } from '../../util/observable';
6-
import { bufferToString, bufferToHex } from '../../util/buffer';
6+
import { bufferToString, bufferToHex, splitBuffer } from '../../util/buffer';
77

88
import type { WorkerFormatterKey } from '../../services/ui-worker-formatters';
99
import { formatBufferAsync } from '../../services/ui-worker-api';
@@ -127,6 +127,43 @@ export const Formatters: { [key in ViewableContentType]: Formatter } = {
127127
}
128128
}
129129
},
130+
'json-records': {
131+
language: 'json',
132+
cacheKey: Symbol('json-records'),
133+
isEditApplicable: false,
134+
render: (input: Buffer, headers?: Headers) => {
135+
if (input.byteLength < 10_000) {
136+
const inputAsString = bufferToString(input);
137+
138+
try {
139+
let records = new Array();
140+
jsonRecordsSeparators.forEach((separator) => {
141+
splitBuffer(input, separator).forEach((recordBuffer: Buffer) => {
142+
if (recordBuffer.length > 0) {
143+
const record = recordBuffer.toString('utf-8');
144+
records.push(JSON.parse(record.trim()));
145+
}
146+
});
147+
});
148+
// For short-ish inputs, we return synchronously - conveniently this avoids
149+
// showing the loading spinner that churns the layout in short content cases.
150+
return JSON.stringify(
151+
records,
152+
null,
153+
2
154+
);
155+
// ^ Same logic as in UI-worker-formatter
156+
} catch (e) {
157+
// Fallback to showing the raw un-formatted:
158+
return inputAsString;
159+
}
160+
} else {
161+
return observablePromise(
162+
formatBufferAsync(input, 'json', headers)
163+
);
164+
}
165+
}
166+
},
130167
javascript: {
131168
language: 'javascript',
132169
cacheKey: Symbol('javascript'),

src/model/events/content-types.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export type ViewableContentType =
5151
| 'yaml'
5252
| 'image'
5353
| 'protobuf'
54-
| 'grpc-proto';
54+
| 'grpc-proto'
55+
| 'json-records'
56+
;
5557

5658
export const EditableContentTypes = [
5759
'text',
@@ -122,6 +124,10 @@ const mimeTypeToContentTypeMap: { [mimeType: string]: ViewableContentType } = {
122124
'application/octet-stream': 'raw'
123125
} as const;
124126

127+
export const jsonRecordsSeparators = [
128+
0x1E
129+
];
130+
125131
export function getContentType(mimeType: string | undefined): ViewableContentType | undefined {
126132
const baseContentType = getBaseContentType(mimeType);
127133
return mimeTypeToContentTypeMap[baseContentType!];
@@ -141,6 +147,7 @@ export function getEditableContentType(mimeType: string | undefined): EditableCo
141147

142148
export function getContentEditorName(contentType: ViewableContentType): string {
143149
return contentType === 'raw' ? 'Hex'
150+
: contentType === 'json-records' ? 'JSON Records'
144151
: contentType === 'json' ? 'JSON'
145152
: contentType === 'css' ? 'CSS'
146153
: contentType === 'url-encoded' ? 'URL-Encoded'
@@ -189,6 +196,16 @@ export function getCompatibleTypes(
189196
// Examine the first char of the body, assuming it's ascii
190197
const firstChar = body && body.subarray(0, 1).toString('ascii');
191198

199+
// Allow optionally formatting non-JSON-records as JSON-records, if it looks like it might be
200+
if (body && body.length > 2 && firstChar === '{'
201+
&& jsonRecordsSeparators.indexOf(body[body.length - 1]) > -1
202+
) {
203+
const secondToLastChar = body.subarray(body.length - 2, body.length - 1).toString('ascii');
204+
if (secondToLastChar === '}') {
205+
types.add('json-records');
206+
}
207+
}
208+
192209
// Allow optionally formatting non-JSON as JSON, if it looks like it might be
193210
if (firstChar === '{' || firstChar === '[') {
194211
types.add('json');

src/model/events/stream-message.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { computed, observable } from 'mobx';
33
import { InputStreamMessage } from "../../types";
44
import { asBuffer } from '../../util/buffer';
55
import { ObservableCache } from '../observable-cache';
6+
import { jsonRecordsSeparators } from './content-types';
67

78
export class StreamMessage {
89

@@ -57,7 +58,12 @@ export class StreamMessage {
5758
startOfMessage.includes('{') ||
5859
startOfMessage.includes('[') ||
5960
this.subprotocol?.includes('json')
60-
) return 'json';
61+
) {
62+
if (jsonRecordsSeparators.indexOf(this.content[this.content.length - 1]) > -1)
63+
return 'json-records';
64+
else
65+
return 'json';
66+
}
6167

6268
else return 'text';
6369
}

src/services/ui-worker-formatters.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import {
66
import * as beautifyXml from 'xml-beautifier';
77

88
import { Headers } from '../types';
9-
import { bufferToHex, bufferToString, getReadableSize } from '../util/buffer';
9+
import { bufferToHex, bufferToString, getReadableSize, splitBuffer } from '../util/buffer';
1010
import { parseRawProtobuf, extractProtobufFromGrpc } from '../util/protobuf';
11+
import { jsonRecordsSeparators } from '../model/events/content-types';
1112

1213
const truncationMarker = (size: string) => `\n[-- Truncated to ${size} --]`;
1314
const FIVE_MB = 1024 * 1024 * 5;
@@ -80,6 +81,24 @@ const WorkerFormatters = {
8081
return asString;
8182
}
8283
},
84+
'json-records': (content: Buffer) => {
85+
const asString = content.toString('utf8');
86+
87+
try {
88+
let records = new Array();
89+
jsonRecordsSeparators.forEach((separator) => {
90+
splitBuffer(content, separator).forEach((recordBuffer: Buffer) => {
91+
if (recordBuffer.length > 0) {
92+
const record = recordBuffer.toString('utf-8');
93+
records.push(JSON.parse(record.trim()));
94+
}
95+
});
96+
});
97+
return JSON.stringify(records, null, 2);
98+
} catch (e) {
99+
return asString;
100+
}
101+
},
83102
javascript: (content: Buffer) => {
84103
return beautifyJs(content.toString('utf8'), {
85104
indent_size: 2

src/util/buffer.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,33 @@ export function getReadableSize(input: number | Buffer | string, siUnits = true)
149149
let unitName = bytes === 1 ? 'byte' : units[unitIndex];
150150

151151
return (bytes / Math.pow(thresh, unitIndex)).toFixed(1).replace(/\.0$/, '') + ' ' + unitName;
152+
}
153+
154+
/**
155+
* Splits a Buffer into an array of Buffers using a specified separator.
156+
* @param buffer The Buffer to split.
157+
* @param separator The byte or Buffer sequence to split by.
158+
* @returns An array of Buffers.
159+
*/
160+
export function splitBuffer(buffer: Buffer, separator: number | Buffer): Buffer[] {
161+
const result: Buffer[] = [];
162+
let currentOffset = 0;
163+
let separatorIndex: number;
164+
165+
// Handle single byte separator vs. multi-byte separator
166+
const separatorLength = typeof separator === 'number' ? 1 : separator.length;
167+
168+
while ((separatorIndex = buffer.indexOf(separator, currentOffset)) !== -1) {
169+
// Add the chunk before the separator
170+
result.push(buffer.slice(currentOffset, separatorIndex));
171+
// Move the offset past the separator
172+
currentOffset = separatorIndex + separatorLength;
173+
}
174+
175+
// Add the last chunk (or the whole buffer if no separator was found)
176+
if (currentOffset <= buffer.length) {
177+
result.push(buffer.slice(currentOffset));
178+
}
179+
180+
return result;
152181
}

0 commit comments

Comments
 (0)