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

added streaming support #5

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Expand Up @@ -31,4 +31,6 @@ const ChuckNorrisJoke = () => {
export default ChuckNorrisJoke;
```

Or if you want a stream of jokes, assuming the endpoint supports it, just pass `{streaming: true}` as second argument. Where `data` will now be continues chunks received from the streaming endpoint.

See this [CodeSandbox](https://codesandbox.io/s/n5q6xmwwq0) for a running example.
10,282 changes: 10,282 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

17 changes: 10 additions & 7 deletions release/index.d.ts
@@ -1,7 +1,10 @@
declare const useAbortableFetch: <T>(url: string | null, init?: RequestInit) => {
data: T | null;
loading: boolean;
error: Error | null;
abort: () => void;
};
export default useAbortableFetch;
interface RequestInitStreaming extends RequestInit {
streaming?: boolean;
}
declare const useAbortableFetch: <T>(url: string | null, init?: RequestInitStreaming) => {
data: T | Uint8Array | null;
loading: boolean;
error: Error | null;
abort: () => void;
};
export default useAbortableFetch;
2 changes: 1 addition & 1 deletion release/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion release/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion release/index.mjs
@@ -1,2 +1,2 @@
import{useState as n,useEffect as r,useLayoutEffect as t,useRef as o}from"react";export default function(e,a){void 0===a&&(a={});var u=n({data:null,loading:0,error:null,controller:null}),l=u[0],i=u[1],c=o(!1);return t(function(){return c.current=!0,function(){c.current=!1}},[]),r(function(){var n=new AbortController;return e&&(i(function(r){return{data:null,loading:r.loading+1,error:null,controller:n}}),function(n,r,t,o){var e=Object.assign({},r,{signal:t});fetch(n,e).then(function(n){return n.ok?Promise.resolve(n):Promise.reject({message:n.statusText,status:n.status})}).then(function(n){return n.json()}).then(function(n){o(function(r){return Object.assign({},r,{data:n,loading:r.loading-1})})}).catch(function(n){var r="AbortError"!==n.name?n:null;o(function(n){return Object.assign({},n,{error:r,loading:n.loading-1})})})}(e,a,n.signal,function(n){c.current&&i(n)})),function(){return n.abort()}},[e]),{data:l.data,loading:!!l.loading,error:l.error,abort:function(){return l.controller&&l.controller.abort()}}}
import{useState as n,useEffect as t,useLayoutEffect as r,useRef as e}from"react";const o=function(){function n(){}return n.prototype.then=function(t,r){const e=new n,o=this.s;if(o){const n=1&o?t:r;if(n){try{i(e,1,n(this.v))}catch(n){i(e,2,n)}return e}return this}return this.o=function(n){try{const o=n.v;1&n.s?i(e,1,t?t(o):o):r?i(e,1,r(o)):i(e,2,o)}catch(n){i(e,2,n)}},e},n}();function i(n,t,r){if(!n.s){if(r instanceof o){if(!r.s)return void(r.o=i.bind(null,n,t));1&t&&(t=r.s),r=r.v}if(r&&r.then)return void r.then(i.bind(null,n,t),i.bind(null,n,2));n.s=t,n.v=r;const e=n.o;e&&e(n)}}function u(n){return n instanceof o&&1&n.s}const l={};!function(){function n(n){this._entry=n,this._pact=null,this._resolve=null,this._return=null,this._promise=null}function t(n){return{value:n,done:!0}}function r(n){return{value:n,done:!1}}n.prototype[Symbol.asyncIterator||(Symbol.asyncIterator=Symbol("Symbol.asyncIterator"))]=function(){return this},n.prototype._yield=function(n){return this._resolve(n&&n.then?n.then(r):r(n)),this._pact=new o},n.prototype.next=function(n){const r=this;return r._promise=new Promise(function(e){const u=r._pact;if(null===u){const n=r._entry;if(null===n)return e(r._promise);function c(n){r._resolve(n&&n.then?n.then(t):t(n)),r._pact=null,r._resolve=null}r._entry=null,r._resolve=e,n(r).then(c,function(n){if(n===l)c(r._return);else{const t=new o;r._resolve(t),r._pact=null,r._resolve=null,_resolve(t,2,n)}})}else r._pact=null,r._resolve=e,i(u,1,n)})},n.prototype.return=function(n){const r=this;return r._promise=new Promise(function(e){const o=r._pact;if(null===o)return null===r._entry?e(r._promise):(r._entry=null,e(n&&n.then?n.then(t):t(n)));r._return=n,r._resolve=e,r._pact=null,i(o,2,l)})},n.prototype.throw=function(n){const t=this;return t._promise=new Promise(function(r,e){const o=t._pact;if(null===o)return null===t._entry?r(t._promise):(t._entry=null,e(n));t._resolve=r,t._pact=null,i(o,2,n)})}}();export default function(l,c){void 0===c&&(c={});var s=n({data:null,loading:0,error:null,controller:null}),a=s[0],f=s[1],h=e(!1);return r(function(){return h.current=!0,function(){h.current=!1}},[]),t(function(){var n=new AbortController;return l&&(f(function(t){return{data:null,loading:t.loading+1,error:null,controller:n}}),function(t,r,e,l){var c=Object.assign({},r,{signal:n.signal});fetch(t,c).then(function(n){return n.ok?Promise.resolve(n):Promise.reject({message:n.statusText,status:n.status})}).then(function(n){if(!r.streaming)return n.json();if(!n.body)throw new Error("Invalid response body");var t=n.body.getReader();!function(){try{var n=!1,r=function(n,t,r){for(var e;;){var l=n();if(u(l)&&(l=l.v),!l)return c;if(l.then){e=0;break}var c=r();if(c&&c.then){if(!u(c)){e=1;break}c=c.s}if(t){var s=t();if(s&&s.then&&!u(s)){e=2;break}}}var a=new o,f=i.bind(null,a,2);return(0===e?l.then(v):1===e?c.then(h):s.then(d)).then(void 0,f),a;function h(e){c=e;do{if(t&&(s=t())&&s.then&&!u(s))return void s.then(d).then(void 0,f);if(!(l=n())||u(l)&&!l.v)return void i(a,1,c);if(l.then)return void l.then(v).then(void 0,f);u(c=r())&&(c=c.v)}while(!c||!c.then);c.then(h).then(void 0,f)}function v(n){n?(c=r())&&c.then?c.then(h).then(void 0,f):h(c):i(a,1,c)}function d(){(l=n())?l.then?l.then(v).then(void 0,f):v(l):i(a,1,c)}}(function(){return!n},void 0,function(){return Promise.resolve(t.read()).then(function(t){var r=t.value;t.done?n=!0:l(function(n){return Object.assign({},n,{data:r})})})});Promise.resolve(r&&r.then?r.then(function(){}):void 0)}catch(n){return Promise.reject(n)}}()}).then(function(n){l(function(t){return Object.assign({},t,{data:n,loading:t.loading-1})})}).catch(function(n){var t="AbortError"!==n.name?n:null;l(function(n){return Object.assign({},n,{error:t,loading:n.loading-1})})})}(l,c,0,function(n){h.current&&f(n)})),function(){return n.abort()}},[l]),{data:a.data,loading:!!a.loading,error:a.error,abort:function(){return a.controller&&a.controller.abort()}}}
//# sourceMappingURL=index.mjs.map
2 changes: 1 addition & 1 deletion release/index.mjs.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion release/index.umd.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion release/index.umd.js.map

Large diffs are not rendered by default.

32 changes: 27 additions & 5 deletions src/index.ts
Expand Up @@ -7,25 +7,29 @@ import {
SetStateAction
} from 'react';

interface RequestInitStreaming extends RequestInit {
Copy link
Owner

Choose a reason for hiding this comment

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

The streaming option is not used until the response handling. Can't the hook detect a streaming response based on the HTTP response headers? I was under the impression that Transfer-Encoding: chunked together with a content-type that supports streaming would indicate this.

Note: The code now incorrectly assumes that the response is JSON. I was already planning to add a header check for JSON and return rsp.text() if it isn't JSON data.

Copy link
Author

Choose a reason for hiding this comment

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

Right, I think this is actually a better way so we don't need the stream option. Will play around with it to see what other indicators can be used to infer if streaming or not. And yes for json, we can just check the content-type.

Copy link
Owner

Choose a reason for hiding this comment

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

I was searching for some but can't really find them. I would say that structured formats with a clear start and end like JSON, XML should not be streamed, even with Transfer-Encoding: chunked as a partial file is invalid. Formats like text, YAML, binary could be streamed. But I guess even then the client has to know when the last chunk has arrived and the response is complete.

Copy link
Author

@marconi marconi Mar 2, 2019

Choose a reason for hiding this comment

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

Agreed, streaming is dictated by the server. But even then, there's actually no reliable way to detect it atm as Transfer-Encoding header isn't even available on cors request. See https://developers.google.com/web/updates/2015/03/introduction-to-fetch#response_types.

If somehow you have access to the server, you can expose the Transfer-Encoding header via https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Access-Control-Expose-Headers but that's still unreliable since you're talking to different service, and don't even have access to it to be able to expose some headers.

We can't even call .text() or .json() even if content-type says so because it could also be text or json but still streaming in which case .text() or .json() will never resolve. A more reliable way is to just return the body promise and let the caller decide what they want. Then if streaming flag is passed, we do our chunk reading and return collected chunks as they get aggregated. Thoughts?

Copy link
Owner

Choose a reason for hiding this comment

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

It seems strange if a server allows for CORS calls, set the Transfer-Encoding: chunked header but then doesn't set the Access-Control-Expose-Headers for the client to use. But then strange things happen 😞

I would suggest using streaming makes sense if the client can read the Transfer-Encoding: chunked header and the Content-Type makes sense for streaming. If not use the .json() or .text() depending on the Content-Type.

Copy link
Author

Choose a reason for hiding this comment

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

So just to be clear, we can't use both Transfer-Encoding and Content-Type to decide whether to stream or not. First, Transfer-Encoding isn't exposed to us and second, Content-Type has nothing to do with streaming or not since it could be text/plain but still streaming so we can't rely on that either.

But we could do:

  1. pass option { streaming: true } - return aggregated chunks as ArrayBuffer
  2. no option - return rsp.body promise and let caller decide to call .json() or .text() or however they want to handle the data

streaming?: boolean;
}

const useAbortableFetch = <T>(
url: string | null,
init: RequestInit = {}
init: RequestInitStreaming = {}
): {
data: T | null;
data: T | Uint8Array | null;
loading: boolean;
error: Error | null;
abort: () => void;
} => {
type FetchState = {
data: T | null;
data: T | Uint8Array | null;
loading: number;
error: null | Error;
controller: AbortController | null;
};

const fetchData = (
url: string,
init: RequestInit,
init: RequestInitStreaming,
signal: AbortSignal,
setState: Dispatch<SetStateAction<FetchState>>
) => {
Expand All @@ -40,7 +44,25 @@ const useAbortableFetch = <T>(
status: rsp.status
})
)
.then(rsp => rsp.json())
.then(rsp => {
if (!init.streaming) return rsp.json();
if (!rsp.body) throw new Error('Invalid response body');

const reader = rsp.body.getReader();
(async () => {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}

setState((oldState: FetchState) => ({
...oldState,
data: value,
marconi marked this conversation as resolved.
Show resolved Hide resolved
}));
}
})();
})
.then(data => {
setState((oldState: FetchState) => ({
...oldState,
Expand Down