|
|
@@ -612,7 +612,8 @@
|
|
|
});
|
|
|
if (stale.length) {
|
|
|
var directives = await postSlots(stale);
|
|
|
- applyDirectives(directives, { originElement: null });
|
|
|
+ var pfResult = applyDirectives(directives, { originElement: null });
|
|
|
+ executeTerminal(pfResult);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -636,6 +637,28 @@
|
|
|
return headers;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Collect the current frames held for the given slot keys, in the order the
|
|
|
+ * keys are first supplied. Used by the `transmit` directive (which forces a
|
|
|
+ * slot's frame to be posted as if its `transmit_after` had elapsed). Keys the
|
|
|
+ * client does not currently hold are skipped.
|
|
|
+ * @param {string[]} keys
|
|
|
+ * @returns {object[]}
|
|
|
+ */
|
|
|
+ function collectFramesForKeys(keys) {
|
|
|
+ var keySet = Object.create(null);
|
|
|
+ for (var i = 0; i < keys.length; i++) keySet[keys[i]] = true;
|
|
|
+ var frames = [];
|
|
|
+ store.forEach(function (entry) {
|
|
|
+ var key = entry.snapshot.slot && entry.snapshot.slot.key;
|
|
|
+ if (key && keySet[key]) {
|
|
|
+ frames.push(entry.frame);
|
|
|
+ delete keySet[key];
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return frames;
|
|
|
+ }
|
|
|
+
|
|
|
// ---------------------------------------------------------------------------
|
|
|
// HTTP helpers
|
|
|
// ---------------------------------------------------------------------------
|
|
|
@@ -667,19 +690,22 @@
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Apply the directives from a response. set/clear/subscribe/unsubscribe/notify
|
|
|
- * run first, then `render`, then terminal navigation (`navigate`, then `post`).
|
|
|
- * Error directives are collected (not thrown) so the caller can route them
|
|
|
- * through `reportFailure` (and thus `onErrorHandler`).
|
|
|
+ * Apply the non-terminal directives from a response (set/clear/subscribe/
|
|
|
+ * unsubscribe/notify/transmit-collection), then `render`.
|
|
|
+ *
|
|
|
+ * Terminal directives (`navigate`, `post`), error directives, and any
|
|
|
+ * transmit/retry intent are collected on the returned result rather than
|
|
|
+ * executed here: {@link handleResponse} performs the asynchronous
|
|
|
+ * transmission (and optional retry) and then runs terminal navigation, so
|
|
|
+ * that a `transmit` with `retry` can re-issue the request before the page is
|
|
|
+ * navigated away.
|
|
|
* @param {object[]} directives
|
|
|
* @param {{originElement?: HTMLElement}} [ctx]
|
|
|
- * @returns {{errors: StatumError[]}}
|
|
|
+ * @returns {{errors: StatumError[], transmitKeys: string[], retry: boolean, navigate: object|null, post: object|null}}
|
|
|
*/
|
|
|
function applyDirectives(directives, ctx) {
|
|
|
- var result = { errors: [] };
|
|
|
+ var result = { errors: [], transmitKeys: [], retry: false, navigate: null, post: null };
|
|
|
var list = Array.isArray(directives) ? directives : [];
|
|
|
- var navigateDirective = null;
|
|
|
- var postDirective = null;
|
|
|
|
|
|
for (var i = 0; i < list.length; i++) {
|
|
|
var d = list[i];
|
|
|
@@ -690,19 +716,28 @@
|
|
|
case 'subscribe': channelSubscribe(d.key); break;
|
|
|
case 'unsubscribe': channelUnsubscribe(d.key); break;
|
|
|
case 'notify': triggerNotify(d.kind, d.message); break;
|
|
|
+ case 'transmit':
|
|
|
+ if (d.key != null) {
|
|
|
+ if (result.transmitKeys.indexOf(d.key) === -1) result.transmitKeys.push(d.key);
|
|
|
+ if (d.retry) result.retry = true;
|
|
|
+ }
|
|
|
+ break;
|
|
|
case 'error': result.errors.push(new StatumError(d.message, d.code, d.data)); break;
|
|
|
- case 'navigate': navigateDirective = d; break;
|
|
|
- case 'post': postDirective = d; break;
|
|
|
+ case 'navigate': result.navigate = d; break;
|
|
|
+ case 'post': result.post = d; break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
renderAll();
|
|
|
-
|
|
|
- if (navigateDirective) doNavigate(navigateDirective);
|
|
|
- if (postDirective) doPost(postDirective);
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
+ /** Execute the terminal navigation directives collected by {@link applyDirectives}. */
|
|
|
+ function executeTerminal(result) {
|
|
|
+ if (result.navigate) doNavigate(result.navigate);
|
|
|
+ if (result.post) doPost(result.post);
|
|
|
+ }
|
|
|
+
|
|
|
/** Execute a `navigate` directive as a full page load. */
|
|
|
function doNavigate(d) {
|
|
|
if (d.uri) window.location.href = d.uri;
|
|
|
@@ -730,8 +765,15 @@
|
|
|
/**
|
|
|
* Handle a fetch response: if it is a Statum response, apply its directives;
|
|
|
* otherwise throw on HTTP errors and no-op on other successful responses.
|
|
|
+ *
|
|
|
+ * Directive ordering: non-terminal directives are applied and the page
|
|
|
+ * rendered; then any `transmit` directives force their slot frames to be
|
|
|
+ * posted (applying the directives the slot endpoint returns); then, if a
|
|
|
+ * transmit requested a retry, the originating request (`ctx.retry`) is
|
|
|
+ * re-issued and this response's terminal directives and errors are skipped.
|
|
|
+ * Otherwise terminal navigation runs, then error directives are surfaced.
|
|
|
* @param {Response} res
|
|
|
- * @param {{originElement?: HTMLElement}} [ctx]
|
|
|
+ * @param {{originElement?: HTMLElement, retry?: Function}} [ctx]
|
|
|
*/
|
|
|
async function handleResponse(res, ctx) {
|
|
|
var el = ctx && ctx.originElement;
|
|
|
@@ -742,6 +784,22 @@
|
|
|
var directives;
|
|
|
try { directives = await res.json(); } catch (e) { directives = []; }
|
|
|
var result = applyDirectives(directives, ctx);
|
|
|
+
|
|
|
+ if (result.transmitKeys && result.transmitKeys.length) {
|
|
|
+ var frames = collectFramesForKeys(result.transmitKeys);
|
|
|
+ if (frames.length) {
|
|
|
+ var txDirectives = await postSlots(frames);
|
|
|
+ applyDirectives(txDirectives, { originElement: el });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (result.retry && ctx && typeof ctx.retry === 'function') {
|
|
|
+ await ctx.retry();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ executeTerminal(result);
|
|
|
+
|
|
|
var errors = result.errors || [];
|
|
|
for (var i = 0; i < errors.length; i++) {
|
|
|
await reportFailure(errors[i], el);
|
|
|
@@ -754,22 +812,31 @@
|
|
|
|
|
|
/**
|
|
|
* Fetch the entrypoint for the current URL, applying its directives. Carries
|
|
|
- * slot headers (and pre-flights) like any other request.
|
|
|
+ * slot headers (and pre-flights) like any other request. The request is
|
|
|
+ * wrapped in an `attempt` closure passed as the response `retry` callback, so
|
|
|
+ * a `transmit` directive with `retry` can re-issue it after transmission.
|
|
|
* @returns {Promise<void>}
|
|
|
*/
|
|
|
- async function loadEntrypoint() {
|
|
|
- await prepareSlots();
|
|
|
- var headers = new Headers();
|
|
|
- headers.set('Accept', STATUM_CONTENT_TYPE);
|
|
|
- buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); });
|
|
|
- var url = config.entrypointUrl + '?uri=' + encodeURIComponent(window.location.href);
|
|
|
- var res = await fetch(url, { headers: headers, credentials: 'same-origin' });
|
|
|
- await handleResponse(res, { originElement: null });
|
|
|
+ function loadEntrypoint() {
|
|
|
+ var attempt = async function () {
|
|
|
+ await prepareSlots();
|
|
|
+ var headers = new Headers();
|
|
|
+ headers.set('Accept', STATUM_CONTENT_TYPE);
|
|
|
+ buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); });
|
|
|
+ var url = config.entrypointUrl + '?uri=' + encodeURIComponent(window.location.href);
|
|
|
+ var res = await fetch(url, { headers: headers, credentials: 'same-origin' });
|
|
|
+ await handleResponse(res, { originElement: null, retry: attempt });
|
|
|
+ };
|
|
|
+ return attempt();
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Perform an action request.
|
|
|
*
|
|
|
+ * The request is wrapped in an `attempt` closure passed as the response
|
|
|
+ * `retry` callback, so a `transmit` directive with `retry` can re-issue the
|
|
|
+ * same action after the transmission (rebuilding headers from current state).
|
|
|
+ *
|
|
|
* @param {object} action `{ uri, method, private }`
|
|
|
* @param {object} [data] Key/value parameters. Encoded as query string for
|
|
|
* GET/HEAD, otherwise as `application/x-www-form-urlencoded`.
|
|
|
@@ -778,34 +845,37 @@
|
|
|
* @returns {Promise<void>} Resolves once the request completes and its
|
|
|
* directives have run; rejects on HTTP failure or an `error` directive.
|
|
|
*/
|
|
|
- async function performAction(action, data, originElement) {
|
|
|
- await prepareSlots();
|
|
|
- var headers = new Headers();
|
|
|
- headers.set('Accept', STATUM_CONTENT_TYPE);
|
|
|
- buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); });
|
|
|
- if (action.private) headers.set(PRIVATE_HEADER, action.private);
|
|
|
-
|
|
|
- var method = (action.method || 'GET').toUpperCase();
|
|
|
- var params = data || {};
|
|
|
- var init = { method: method, headers: headers, credentials: 'same-origin' };
|
|
|
- var url = action.uri;
|
|
|
-
|
|
|
- if (method === 'GET' || method === 'HEAD') {
|
|
|
- var qs = new URLSearchParams(params).toString();
|
|
|
- if (qs) url += (url.indexOf('?') !== -1 ? '&' : '?') + qs;
|
|
|
- } else {
|
|
|
- init.body = new URLSearchParams(params).toString();
|
|
|
- headers.set('Content-Type', 'application/x-www-form-urlencoded');
|
|
|
- }
|
|
|
+ function performAction(action, data, originElement) {
|
|
|
+ var attempt = async function () {
|
|
|
+ await prepareSlots();
|
|
|
+ var headers = new Headers();
|
|
|
+ headers.set('Accept', STATUM_CONTENT_TYPE);
|
|
|
+ buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); });
|
|
|
+ if (action.private) headers.set(PRIVATE_HEADER, action.private);
|
|
|
+
|
|
|
+ var method = (action.method || 'GET').toUpperCase();
|
|
|
+ var params = data || {};
|
|
|
+ var init = { method: method, headers: headers, credentials: 'same-origin' };
|
|
|
+ var url = action.uri;
|
|
|
+
|
|
|
+ if (method === 'GET' || method === 'HEAD') {
|
|
|
+ var qs = new URLSearchParams(params).toString();
|
|
|
+ if (qs) url += (url.indexOf('?') !== -1 ? '&' : '?') + qs;
|
|
|
+ } else {
|
|
|
+ init.body = new URLSearchParams(params).toString();
|
|
|
+ headers.set('Content-Type', 'application/x-www-form-urlencoded');
|
|
|
+ }
|
|
|
|
|
|
- var res;
|
|
|
- try {
|
|
|
- res = await fetch(url, init);
|
|
|
- } catch (networkErr) {
|
|
|
- await reportFailure(new StatumError('Network error'), originElement);
|
|
|
- return;
|
|
|
- }
|
|
|
- await handleResponse(res, { originElement: originElement });
|
|
|
+ var res;
|
|
|
+ try {
|
|
|
+ res = await fetch(url, init);
|
|
|
+ } catch (networkErr) {
|
|
|
+ await reportFailure(new StatumError('Network error'), originElement);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await handleResponse(res, { originElement: originElement, retry: attempt });
|
|
|
+ };
|
|
|
+ return attempt();
|
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|