#Several concerns about a json-streaming example

35 messages ยท Page 1 of 1 (latest)

viscid mesa
#

ah, looks like the post was deleted because I posted the remaining too fast

half robin
#

Dyno got a bit aggressive

I tried to use something more explicit than the default CountQueuingStrategy when instantiating the ReadableStream (typically just a simple new CountQueuingStrategy({ highWaterMark: 1 }), but I got errors of the kind:
(index):71 Uncaught (in promise) RangeError: Failed to construct 'ReadableStream': Cannot create byte stream with size() defined on the strategy

this puzzles me a bit because there is no mention of this behavior in MDN and I couldn't find one in the specification pages associated to those API. in particular, this one default should be the same as the spec mentions, but still raises the error. I don't get it

#

It also deleted the first post with the actual issue but it's not in the log. Probably a bug of Dyno and forum posts ๐Ÿค”

viscid mesa
#

maybe I could post it back ?

half robin
#

Go ahead

viscid mesa
#

hello, I have a small concern about some example code I'm doing. concretely, I have a synchronous iterator of bytes, basically made up as follow:

const stream = (function* chunks(json) {
    const jsonString = JSON.stringify(json);
    const jsonBytes = new TextEncoder().encode(jsonString);

    let index = 0;
    while(index < jsonBytes.length) {
        const i = index;
        const j = Math.min(jsonBytes.length, index + 10);

        const slice = jsonBytes.slice(i, j);
        index = j;
        yield slice;
    }
})({ "Hello": [2023, 2024 ]});

my goal is to recreate the data based on the stream above. in order to do that (and since JSON.parse cann't accept stream), I'm using the Response API (and related):

const jsonBack = await new Response(new ReadableStream({
    type: "bytes",
    start: function(controller) {
        for(const chunk of stream) {
            controller.enqueue(chunk);
        }
        controller.close();
    }
})).json();

I've followed pages on MDN (https://developer.mozilla.org/en-US/docs/Web/API/Response, https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/ReadableStream, https://developer.mozilla.org/en-US/docs/Web/API/ReadableByteStreamController)

however, I have several questions:

half robin
#

In general for JSON streaming however, one would use JSONL/NDJSON

viscid mesa
#

second question is:

since I cannot control the number of chunks that will be stacked before back-pressuring (apparently the default is higherMark=1 but anyway), I made the choice to slice the small buffers as they are emitted. I did right, because modifying this to subarray causes unexpected end of JSON error (not clear why because in my case, I don't mutate the subarray, so that's a bit weird to me. in practice, later, the "big" array will be mutable so I'm all fine with slicing it. I just don't get the reason of error (so changing slice to subarray yields:

VM318:1 Uncaught (in promise) SyntaxError: Unexpected end of JSON input
#

third question is:

if I use the following data, I got no error due to UTF-8 sequence being truncated:

{"Hello!๐Ÿ˜Š": [2023, 2024 ]} // The emoji happens at index 9 and takes more than 1 UTF-8 => split on 2 chunks

when I use subarray I get an error related to the UTF-8 sequence terminating too soon; when I use slice , I don't get that behavior. maybe it relates with the previous question about slice vs subarray. I took the time to review the spec of both methods but they don't seem different (except slice copies the buffer if I got it right, while subarray only makes a view of it): they don't seem to differ in the way they handle the start and end parameter

is it safe to split UTF-8 sequences like this while streaming? (I would assume that might really well happen in case of a chunked-http rquest but I prefer to ask)

half robin
#

2nd and 3rd are the same EDITed

viscid mesa
#

my bad

#

and then very last question:

Is the usage of start function legit here? The example on MDN used one of the same kind (using a timer but essentially the same idea). I cannot use pull in my case, so the current behavior of start is kind of crucial. is it safe to directly enqueue in the controller when the stream starts, without relying on pull at all?

#

(sorry for the long post)

viscid mesa
half robin
#

(2nd question) From what I understand, you can't parse with .subarray because it checks the updated arraybuffer, which changes on each chunk received

#

While with a copy you don't mind this update

#

(3rd question) It's not safe to consider the last byte of a chunk as valid UTF-8, you would need to check if it's part of a surrogate

viscid mesa
viscid mesa
viscid mesa
#

no I really don't get it. what ever I do to compare both versions, the arrays passed to the controller look exactly he same (except ofc one is a lice and the other is a subarray)

const stream = (function* chunks(json) {
    const jsonString = JSON.stringify(json);
    const jsonBytes = new TextEncoder().encode(jsonString);

    let index = 0;
    while(index < jsonBytes.length) {
        const i = index;
        const j = Math.min(jsonBytes.length, index + 10);

        const slice = jsonBytes.subarray(i, j);    // This makes it crash. slice does not
        index = j;
        yield slice;
    }
})({"Hello!": [2023, 2024 ]});

const jsonBack = await new Response(new ReadableStream({
    type: "bytes",
    start: function(controller) {
        for(const chunk of stream) {
            controller.enqueue(chunk);
        }
        controller.close();
    }
})).json();
viscid mesa
#

ok, for the CountQueuingStrategy issue, this is this part of the spec:

viscid mesa
#

The situation on Firefox game me a hint:

When using .subarray instead of .slice, the error is:

SyntaxError: JSON.parse: unexpected end of data at line 1 column 11 of the JSON data

which is one byte after the chunk size. so I digged a bit deeper about how the BytesStreamController was handling .enqueue:

https://streams.spec.whatwg.org/#readable-byte-stream-controller-enqueue

#

I feel like something will be wrong at step 3. not too sure what they mean by viewedArrayBuffer, because my chunks never have that property

but if the implementations inspect the .buffer property of the typed array, then it starts to make a bit of sense, because in the .subarray case and in the .slice case, they differ. maybe there is something wrong in the way TransferArrayBuffer is then created or something

more generally, maybe the mistake I made is that I shouldn't be passing a TypedArray to .enqueue, but a correctly shaped ArrayBuffer instead

#
TypeError: Failed to execute 'enqueue' on 'ReadableByteStreamController': parameter 1 is not of type 'ArrayBufferView'

so passing an array buffer directly won't work, I need to pass a view always. I think there is something off in the way the algorithm is written then, because as a functionality, it just feels so bad the behavior depends on whether or not the chunk is a subarray or a slice

viscid mesa
#

all that just doesn't make any sense. wtf is going on when .subarray

viscid mesa
#

=======
ok, I've found the issue

#

@half robin if ever you're interested, the reason (I believe) is subtly explained here:

#

doing .enqueue actually detaches the buffer (confirmed by debugging the detached property before and after the call)

#

I believe that's why in the subarray case, the buffer gets detached after chunk 1 is enqueued, and then nothing is appened in the queue anymore

(although as per the spec, a TypeError should have been raised... I never got it. not sure why)
I never got the issue because when a buffer is detached, its length goes down to 0. therefore, my loop abruptly terminates and no chunk is emitted anymore, creating an early closure of the stream

half robin
#

Nice to know. One fewer reason to use .subarray and go with copies ๐Ÿ˜

viscid mesa
#

basically, I needed that functionality because I'm creating a wasm module that outputs JSON. it does it by writing in the memory buffer that represents the memory; this one has fixed size. so it basically prints until the buffer is full, and then flush the buffer

flushing the buffer is a JS external method that triggers the machinery I've sketched, more or less (just phrased differently to become callback friendly). so the output json can get streamed as it is flushed by the wasm module

(I didn't want to rely on a third-party, if there was a straightforward way to do it with built-in's api. I'm quite satisfied with the current solution, as it's really short in the end. just took me a bunch of time to really understand what was going on, but I guess that's not lost knowledge)

half robin
#

You probably know more about buffers now that the majority of this server