Skip to content
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

support custom types #58

Merged
merged 7 commits into from Feb 10, 2023
Merged

support custom types #58

merged 7 commits into from Feb 10, 2023

Conversation

Rich-Harris
Copy link
Owner

This adds support for custom types. For example you could encode promises in an ndjson stream like this (note: code is untested!):

const response = new Response(
  new ReadableStream(
    {
      start(controller) {
        let uid = 1;
        let count = 0;

        const revivers = {
          Promise: (thing) => {
            if (typeof thing?.then === 'function') {
              const id = uid++;
              count += 1;

              thing.then((value, error) => {
                const line = devalue.stringify({ id, value, error }, revivers);
                controller.enqueue(line);

                count -= 1;
                if (count === 0) {
                  controller.close();
                }
              });

              return id;
            }
          }
        }

        controller.enqueue(devalue.stringify(data, revivers));
      }
    }
  ),
  {
    headers: {
      'content-type': 'application/x-ndjson'
    }
  }
);

On the browser side something like this should work:

async function* split(response) {
  const decoder = new TextDecoder("utf-8");
  let reader = response.body.getReader();
  let { value: chunk, done } = await reader.read();
  chunk = chunk ? decoder.decode(chunk, { stream: true }) : "";

  let pattern = /\r\n|\n|\r/gm;
  let index = 0;

  for (;;) {
    let result = pattern.exec(chunk);
    if (!result) {
      if (done) break;
      
      let remainder = chunk.substr(index);
      ({ value: chunk, done } = await reader.read());
      chunk = remainder + (chunk ? decoder.decode(chunk, { stream: true }) : "");
      index = pattern.lastIndex = 0;
      continue;
    }

    yield chunk.substring(index, result.index);
    index = pattern.lastIndex;
  }

  if (index < chunk.length) {
    // last line didn't end in a newline char
    yield chunk.substr(index);
  }
}

function stream(url) {
  return new Promise(async (fulfil) => {
    const deferreds = new Map();

    const revivers = {
      Promise: (id) => {
        return new Promise((fulfil, reject) => {
          deferreds.set(id, { fulfil, reject });
        });
      }
    };

    let streaming = false;

    for await (let line of split(await fetch(url))) {
      if (streaming) {
        const { id, value, error } = devalue.parse(line, revivers);
        const deferred = deferreds.get(id);
        deferreds.delete(id);

        if (error) {
          deferred.reject(error);
        } else {
          deferred.fulfil(value);
        }
      } else {
        const data = devalue.parse(line, revivers);
        streaming = true;

        fulfil(data);
      }
    }
  });
}

const data = await stream('/foo/__data.ndjson');

@Conduitry
Copy link
Contributor

Are there any built-in names that users would need to be careful not to collide with? (I'm not sure whether these user-provided names end up in the same 'spot' in the serialized data as built-in names.) Would adding new built-in things then become a breaking change?

Do we need to worry about uneval? I know SvelteKit doesn't use it anymore, and that is the primary reason for adding this feature, so the answer may well be 'yes, but not now'.

@Rich-Harris
Copy link
Owner Author

We actually do use devalue.uneval for the SSR'd payload — we only use devalue.stringify for __data.json. I probably should have marked this as a draft.

Custom types are handled first, precisely so that we can avoid the breaking change problem (I heard your voice in my head right as I was about to commit the first attempt, which didn't do that). If someone does this...

const str = devalue.stringify(thing, {
  Record: (value) => ...,
  Tuple: (value) => ...
});

...and we later support Record and Tuple natively, it will continue to use the custom reducers/revivers.

@Rich-Harris
Copy link
Owner Author

Rich-Harris commented Feb 10, 2023

Implemented custom replacers for uneval. More pseudo-code that's relevant to our imagined use case:

let uid = 1;
let count = 0;

function replacer(thing) {
  if (typeof thing?.then !== 'function') return;

  const id = uid++;
  count += 1;

  thing.then((value, error) => {
    controller.enqueue(`<script>__resolve(${devalue.uneval({ id, value, error }, replacer)}</script>`);

    count -= 1;
    if (count === 0) {
      controller.close();
    }
  });

  return `__defer(${id})`;
}

const serialized = devalue.uneval(data, replacer);

if (count > 0) {
  controller.enqueue(`
    <script type="module">
      // ...

      const deferred = new Map();

      __defer = (id) => new Promise((fulfil, reject) => {
        deferred.set(id, { fulfil, reject });
      });

      __resolve = ({ id, value, error }) => {
        const { fulfil, reject } = deferred.get(id);
        deferred.delete(id);

        if (error) reject(error);
        else fulfil(value);
      };

      start({
        // ...
        data: ${serialized}
      });
    </script>
  `);
} else {
  controller.enqueue(`
    <script type="module">
      // ...

      start({
        // ...
        data: ${serialized}
      });
    </script>
  `);
}

@Conduitry
Copy link
Contributor

The lack of parity between the reducers/revivers API and the custom replacer API is bugging me. I'm not sure that there's a reasonable way around that. I assume this is something you've spent some time thinking about already.

Is the goal of this within SvelteKit just to allow us to internally be able to serialize more types of data for the defer feature - or are you imagining it as an eventual solution to the occasionally requested feature of custom serializers for +page.server.js data? I can't see people being too pleased about needing to write two sets of encoders and decoders, if that's what we end up exposing to them.

@dummdidumm
Copy link

I also don't see a way around this, it's too distinct ways to go about the problem. If we worry about people having to write things three times, we could only expose the stringify/revive API and under the hood add some mapping logic. Like new Date(devalue.parse('the-string-from-devalue-stringified')). That hurts performance though.

I don't know, I feel like if people really want to use their custom types, they can live with a little friction. And people could publish libraries for reuse.

@Rich-Harris
Copy link
Owner Author

The immediate goal is only to support streaming, which doesn't require anything to be publicly exposed. If we did want people to be able to register custom types then I think we'd have to find a way to use stringify everywhere. But I think we can cross that bridge when we come to it

@dummdidumm
Copy link

Looked at the pseudo code once more, looks good to me 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants