Skip to content

feat: json-records content type support #167

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Aug 7, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/model/events/body-formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { styled } from '../../styles';

import { ViewableContentType } from '../events/content-types';
import { ObservablePromise, observablePromise } from '../../util/observable';
import { bufferToString, bufferToHex } from '../../util/buffer';
import { bufferToString, bufferToHex, splitBuffer } from '../../util/buffer';

import type { WorkerFormatterKey } from '../../services/ui-worker-formatters';
import { formatBufferAsync } from '../../services/ui-worker-api';
Expand Down Expand Up @@ -127,6 +127,40 @@ export const Formatters: { [key in ViewableContentType]: Formatter } = {
}
}
},
'json-records': {
language: 'json',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this approach, we're parsing & converting the JSON records to a sort-of equivalent JSON array, and then just showing that as JSON content.

This sort of works but it would be better to create a separate language I think. There's a few problems here I can see already:

  • It's not an accurate representation of the content - it looks like you've received an array [ ... ] but you haven't really.
  • When you switch between this & normal JSON format on the same content, the formatting changes but the errors don't seem to reset (although, as in my other comment, we can fix this by just making JSON not appear in the dropdown) I think because they share the same language
  • Parsing & validation doesn't work properly - any parsing errors result in the content collapsing back to an {}�{{... unformatted string, which then just shows errors for the record separator characters (not the actual error in the content). We should be able to split & format the messages regardless of JSON parsing, and show nice errors inline.

Did you take a look at the XML support code? I think the same thing should be possible. If you want to look at Monaco's code for JSON support itself, the entrypoint is here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think the JSON-records is a kind of variant of JSON, so I reused the json language. Let me try to update this with a separate language

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I thought it did, and it has the internals but it doesn't expose that as you'd expect. I've just opened a PR to fix that jsonc-parser: microsoft/node-jsonc-parser#102.

No way to know how long that'll take to merge & release though, so better to assume we don't have it in the meantime. It's easy to work around though: you can copy the definition of parse in directly (from my PR there, so it includes the error line details) since it's just a small wrapper around jsoncParser.visit. Once/if the PR above is merged we can drop that and use jsoncParser.parse directly instead.

cacheKey: Symbol('json-records'),
isEditApplicable: false,
render: (input: Buffer, headers?: Headers) => {
if (input.byteLength < 10_000) {
try {
let records = new Array();
const separator = input[input.length - 1];
splitBuffer(input, separator).forEach((recordBuffer: Buffer) => {
if (recordBuffer.length > 0) {
const record = recordBuffer.toString('utf-8');
records.push(JSON.parse(record.trim()));
}
});
// For short-ish inputs, we return synchronously - conveniently this avoids
// showing the loading spinner that churns the layout in short content cases.
return JSON.stringify(
records,
null,
2
);
// ^ Same logic as in UI-worker-formatter
} catch (e) {
// Fallback to showing the raw un-formatted:
return bufferToString(input);
}
} else {
return observablePromise(
formatBufferAsync(input, 'json', headers)
);
}
}
},
javascript: {
language: 'javascript',
cacheKey: Symbol('javascript'),
Expand Down
19 changes: 18 additions & 1 deletion src/model/events/content-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ export type ViewableContentType =
| 'yaml'
| 'image'
| 'protobuf'
| 'grpc-proto';
| 'grpc-proto'
| 'json-records'
;

export const EditableContentTypes = [
'text',
Expand Down Expand Up @@ -122,6 +124,10 @@ const mimeTypeToContentTypeMap: { [mimeType: string]: ViewableContentType } = {
'application/octet-stream': 'raw'
} as const;

export const jsonRecordsSeparators = [
0x1E, // SignalR record separator https://github.com/dotnet/aspnetcore/blob/v8.0.0/src/SignalR/docs/specs/HubProtocol.md#json-encoding
];

export function getContentType(mimeType: string | undefined): ViewableContentType | undefined {
const baseContentType = getBaseContentType(mimeType);
return mimeTypeToContentTypeMap[baseContentType!];
Expand All @@ -141,6 +147,7 @@ export function getEditableContentType(mimeType: string | undefined): EditableCo

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

// Allow optionally formatting non-JSON-records as JSON-records, if it looks like it might be
if (body && body.length > 2 && firstChar === '{'
&& jsonRecordsSeparators.indexOf(body[body.length - 1]) > -1
) {
const secondToLastChar = body.subarray(body.length - 2, body.length - 1).toString('ascii');
if (secondToLastChar === '}') {
types.add('json-records');
}
}

// Allow optionally formatting non-JSON as JSON, if it looks like it might be
if (firstChar === '{' || firstChar === '[') {
types.add('json');
Expand Down
8 changes: 7 additions & 1 deletion src/model/events/stream-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { computed, observable } from 'mobx';
import { InputStreamMessage } from "../../types";
import { asBuffer } from '../../util/buffer';
import { ObservableCache } from '../observable-cache';
import { jsonRecordsSeparators } from './content-types';

export class StreamMessage {

Expand Down Expand Up @@ -57,7 +58,12 @@ export class StreamMessage {
startOfMessage.includes('{') ||
startOfMessage.includes('[') ||
this.subprotocol?.includes('json')
) return 'json';
) {
if (jsonRecordsSeparators.indexOf(this.content[this.content.length - 1]) > -1)
return 'json-records';
else
return 'json';
}

else return 'text';
}
Expand Down
17 changes: 16 additions & 1 deletion src/services/ui-worker-formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
import * as beautifyXml from 'xml-beautifier';

import { Headers } from '../types';
import { bufferToHex, bufferToString, getReadableSize } from '../util/buffer';
import { bufferToHex, bufferToString, getReadableSize, splitBuffer } from '../util/buffer';
import { parseRawProtobuf, extractProtobufFromGrpc } from '../util/protobuf';

const truncationMarker = (size: string) => `\n[-- Truncated to ${size} --]`;
Expand Down Expand Up @@ -80,6 +80,21 @@ const WorkerFormatters = {
return asString;
}
},
'json-records': (content: Buffer) => {
try {
let records = new Array();
const separator = content[content.length - 1];
splitBuffer(content, separator).forEach((recordBuffer: Buffer) => {
if (recordBuffer.length > 0) {
const record = recordBuffer.toString('utf-8');
records.push(JSON.parse(record.trim()));
}
});
return JSON.stringify(records, null, 2);
} catch (e) {
return content.toString('utf8');
}
},
javascript: (content: Buffer) => {
return beautifyJs(content.toString('utf8'), {
indent_size: 2
Expand Down
29 changes: 29 additions & 0 deletions src/util/buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,33 @@ export function getReadableSize(input: number | Buffer | string, siUnits = true)
let unitName = bytes === 1 ? 'byte' : units[unitIndex];

return (bytes / Math.pow(thresh, unitIndex)).toFixed(1).replace(/\.0$/, '') + ' ' + unitName;
}

/**
* Splits a Buffer into an array of Buffers using a specified separator.
* @param buffer The Buffer to split.
* @param separator The byte or Buffer sequence to split by.
* @returns An array of Buffers.
*/
export function splitBuffer(buffer: Buffer, separator: number | Buffer): Buffer[] {
const result: Buffer[] = [];
let currentOffset = 0;
let separatorIndex: number;

// Handle single byte separator vs. multi-byte separator
const separatorLength = typeof separator === 'number' ? 1 : separator.length;

while ((separatorIndex = buffer.indexOf(separator, currentOffset)) !== -1) {
// Add the chunk before the separator
result.push(buffer.slice(currentOffset, separatorIndex));
// Move the offset past the separator
currentOffset = separatorIndex + separatorLength;
}

// Add the last chunk (or the whole buffer if no separator was found)
if (currentOffset <= buffer.length) {
result.push(buffer.slice(currentOffset));
}

return result;
}