UNPKG

17.1 kBJavaScriptView Raw
1import { getGUID, isUrlAbsolute, combine, CopyFrom, isFunc, hOP } from "@pnp/core";
2import { parseBinderWithErrorCheck } from "@pnp/queryable";
3import { spPost } from "./operations.js";
4import { _SPQueryable } from "./spqueryable.js";
5import { spfi, SPFI } from "./fi.js";
6import { Web, _Web } from "./webs/types.js";
7SPFI.prototype.batched = function (props) {
8 const batched = spfi(this);
9 const [behavior, execute] = createBatch(batched._root, props);
10 batched.using(behavior);
11 return [batched, execute];
12};
13_Web.prototype.batched = function (props) {
14 const batched = Web(this);
15 const [behavior, execute] = createBatch(batched, props);
16 batched.using(behavior);
17 return [batched, execute];
18};
19/**
20 * Tracks on a batched instance that registration is complete (the child request has gotten to the send moment and the request is included in the batch)
21 */
22const RegistrationCompleteSym = Symbol.for("batch_registration");
23/**
24 * Tracks on a batched instance that the child request timeline lifecycle is complete (called in child.dispose)
25 */
26const RequestCompleteSym = Symbol.for("batch_request");
27/**
28 * Special batch parsing behavior used to convert the batch response text into a set of Response objects for each request
29 * @returns A parser behavior
30 */
31function BatchParse() {
32 return parseBinderWithErrorCheck(async (response) => {
33 const text = await response.text();
34 return parseResponse(text);
35 });
36}
37/**
38 * Internal class used to execute the batch request through the timeline lifecycle
39 */
40class BatchQueryable extends _SPQueryable {
41 constructor(base, requestBaseUrl = base.toUrl().replace(/_api[\\|/].*$/i, "")) {
42 super(requestBaseUrl, "_api/$batch");
43 this.requestBaseUrl = requestBaseUrl;
44 // this will copy over the current observables from the base associated with this batch
45 // this will replace any other parsing present
46 this.using(CopyFrom(base, "replace"), BatchParse());
47 this.on.dispose(() => {
48 // there is a code path where you may invoke a batch, say on items.add, whose return
49 // is an object like { data: any, item: IItem }. The expectation from v1 on is `item` in that object
50 // is immediately usable to make additional queries. Without this step when that IItem instance is
51 // created using "this.getById" within IITems.add all of the current observers of "this" are
52 // linked to the IItem instance created (expected), BUT they will be the set of observers setup
53 // to handle the batch, meaning invoking `item` will result in a half batched call that
54 // doesn't really work. To deliver the expected functionality we "reset" the
55 // observers using the original instance, mimicing the behavior had
56 // the IItem been created from that base without a batch involved. We use CopyFrom to ensure
57 // that we maintain the references to the InternalResolve and InternalReject events through
58 // the end of this timeline lifecycle. This works because CopyFrom by design uses Object.keys
59 // which ignores symbol properties.
60 base.using(CopyFrom(this, "replace", (k) => /(auth|send|pre|init)/i.test(k)));
61 });
62 }
63}
64/**
65 * Creates a batched version of the supplied base, meaning that all chained fluent operations from the new base are part of the batch
66 *
67 * @param base The base from which to initialize the batch
68 * @param props Any properties used to initialize the batch functionality
69 * @returns A tuple of [behavior used to assign objects to the batch, the execute function used to resolve the batch requests]
70 */
71export function createBatch(base, props) {
72 const registrationPromises = [];
73 const completePromises = [];
74 const requests = [];
75 const batchId = getGUID();
76 const batchQuery = new BatchQueryable(base);
77 // this query is used to copy back the behaviors after the batch executes
78 // it should not manipulated or have behaviors added.
79 const refQuery = new BatchQueryable(base);
80 const { headersCopyPattern } = {
81 headersCopyPattern: /Accept|Content-Type|IF-Match/i,
82 ...props,
83 };
84 const execute = async () => {
85 await Promise.all(registrationPromises);
86 if (requests.length < 1) {
87 // even if we have no requests we need to await the complete promises to ensure
88 // that execute only resolves AFTER every child request disposes #2457
89 // this likely means caching is being used, we returned values for all child requests from the cache
90 return Promise.all(completePromises).then(() => void (0));
91 }
92 const batchBody = [];
93 let currentChangeSetId = "";
94 for (let i = 0; i < requests.length; i++) {
95 const [, url, init] = requests[i];
96 if (init.method === "GET") {
97 if (currentChangeSetId.length > 0) {
98 // end an existing change set
99 batchBody.push(`--changeset_${currentChangeSetId}--\n\n`);
100 currentChangeSetId = "";
101 }
102 batchBody.push(`--batch_${batchId}\n`);
103 }
104 else {
105 if (currentChangeSetId.length < 1) {
106 // start new change set
107 currentChangeSetId = getGUID();
108 batchBody.push(`--batch_${batchId}\n`);
109 batchBody.push(`Content-Type: multipart/mixed; boundary="changeset_${currentChangeSetId}"\n\n`);
110 }
111 batchBody.push(`--changeset_${currentChangeSetId}\n`);
112 }
113 // common batch part prefix
114 batchBody.push("Content-Type: application/http\n");
115 batchBody.push("Content-Transfer-Encoding: binary\n\n");
116 // these are the per-request headers
117 const headers = new Headers(init.headers);
118 // this is the url of the individual request within the batch
119 const reqUrl = isUrlAbsolute(url) ? url : combine(batchQuery.requestBaseUrl, url);
120 if (init.method !== "GET") {
121 let method = init.method;
122 if (headers.has("X-HTTP-Method")) {
123 method = headers.get("X-HTTP-Method");
124 headers.delete("X-HTTP-Method");
125 }
126 batchBody.push(`${method} ${reqUrl} HTTP/1.1\n`);
127 }
128 else {
129 batchBody.push(`${init.method} ${reqUrl} HTTP/1.1\n`);
130 }
131 // lastly we apply any default headers we need that may not exist
132 if (!headers.has("Accept")) {
133 headers.append("Accept", "application/json");
134 }
135 if (!headers.has("Content-Type")) {
136 headers.append("Content-Type", "application/json;charset=utf-8");
137 }
138 // write headers into batch body
139 headers.forEach((value, name) => {
140 if (headersCopyPattern.test(name)) {
141 batchBody.push(`${name}: ${value}\n`);
142 }
143 });
144 batchBody.push("\n");
145 if (init.body) {
146 batchBody.push(`${init.body}\n\n`);
147 }
148 }
149 if (currentChangeSetId.length > 0) {
150 // Close the changeset
151 batchBody.push(`--changeset_${currentChangeSetId}--\n\n`);
152 currentChangeSetId = "";
153 }
154 batchBody.push(`--batch_${batchId}--\n`);
155 const responses = await spPost(batchQuery, {
156 body: batchBody.join(""),
157 headers: {
158 "Content-Type": `multipart/mixed; boundary=batch_${batchId}`,
159 },
160 });
161 if (responses.length !== requests.length) {
162 throw Error("Could not properly parse responses to match requests in batch.");
163 }
164 return new Promise((res, rej) => {
165 try {
166 for (let index = 0; index < responses.length; index++) {
167 const [, , , resolve, reject] = requests[index];
168 try {
169 resolve(responses[index]);
170 }
171 catch (e) {
172 reject(e);
173 }
174 }
175 // this small delay allows the promises to resolve correctly in order by dropping this resolve behind
176 // the other work in the event loop. Feels hacky, but it works so 🤷
177 setTimeout(res, 0);
178 }
179 catch (e) {
180 setTimeout(() => rej(e), 0);
181 }
182 }).then(() => Promise.all(completePromises)).then(() => void (0));
183 };
184 const register = (instance) => {
185 instance.on.init(function () {
186 if (isFunc(this[RegistrationCompleteSym])) {
187 throw Error("This instance is already part of a batch. Please review the docs at https://pnp.github.io/pnpjs/concepts/batching#reuse.");
188 }
189 // we need to ensure we wait to start execute until all our batch children hit the .send method to be fully registered
190 registrationPromises.push(new Promise((resolve) => {
191 this[RegistrationCompleteSym] = resolve;
192 }));
193 return this;
194 });
195 instance.on.pre(async function (url, init, result) {
196 // Do not add to timeline if using BatchNever behavior
197 if (hOP(init.headers, "X-PnP-BatchNever")) {
198 // clean up the init operations from the timeline
199 // not strictly necessary as none of the logic that uses this should be in the request, but good to keep things tidy
200 if (typeof (this[RequestCompleteSym]) === "function") {
201 this[RequestCompleteSym]();
202 delete this[RequestCompleteSym];
203 }
204 this.using(CopyFrom(refQuery, "replace", (k) => /(init|pre)/i.test(k)));
205 return [url, init, result];
206 }
207 // the entire request will be auth'd - we don't need to run this for each batch request
208 this.on.auth.clear();
209 // we replace the send function with our batching logic
210 this.on.send.replace(async function (url, init) {
211 // this is the promise that Queryable will see returned from .emit.send
212 const promise = new Promise((resolve, reject) => {
213 // add the request information into the batch
214 requests.push([this, url.toString(), init, resolve, reject]);
215 });
216 this.log(`[batch:${batchId}] (${(new Date()).getTime()}) Adding request ${init.method} ${url.toString()} to batch.`, 0);
217 // we need to ensure we wait to resolve execute until all our batch children have fully completed their request timelines
218 completePromises.push(new Promise((resolve) => {
219 this[RequestCompleteSym] = resolve;
220 }));
221 // indicate that registration of this request is complete
222 this[RegistrationCompleteSym]();
223 return promise;
224 });
225 this.on.dispose(function () {
226 if (isFunc(this[RegistrationCompleteSym])) {
227 // if this request is in a batch and caching is in play we need to resolve the registration promises to unblock processing of the batch
228 // because the request will never reach the "send" moment as the result is returned from "pre"
229 this[RegistrationCompleteSym]();
230 // remove the symbol props we added for good hygene
231 delete this[RegistrationCompleteSym];
232 }
233 if (isFunc(this[RequestCompleteSym])) {
234 // let things know we are done with this request
235 this[RequestCompleteSym]();
236 delete this[RequestCompleteSym];
237 // there is a code path where you may invoke a batch, say on items.add, whose return
238 // is an object like { data: any, item: IItem }. The expectation from v1 on is `item` in that object
239 // is immediately usable to make additional queries. Without this step when that IItem instance is
240 // created using "this.getById" within IITems.add all of the current observers of "this" are
241 // linked to the IItem instance created (expected), BUT they will be the set of observers setup
242 // to handle the batch, meaning invoking `item` will result in a half batched call that
243 // doesn't really work. To deliver the expected functionality we "reset" the
244 // observers using the original instance, mimicing the behavior had
245 // the IItem been created from that base without a batch involved. We use CopyFrom to ensure
246 // that we maintain the references to the InternalResolve and InternalReject events through
247 // the end of this timeline lifecycle. This works because CopyFrom by design uses Object.keys
248 // which ignores symbol properties.
249 this.using(CopyFrom(refQuery, "replace", (k) => /(auth|pre|send|init|dispose)/i.test(k)));
250 }
251 });
252 return [url, init, result];
253 });
254 return instance;
255 };
256 return [register, execute];
257}
258/**
259 * Behavior that blocks batching for the request regardless of "method"
260 *
261 * This is used for requests to bypass batching methods. Example - Request Digest where we need to get a request-digest inside of a batch.
262 * @returns TimelinePipe
263 */
264export function BatchNever() {
265 return (instance) => {
266 instance.on.pre.prepend(async function (url, init, result) {
267 init.headers = { ...init.headers, "X-PnP-BatchNever": "1" };
268 return [url, init, result];
269 });
270 return instance;
271 };
272}
273/**
274 * Parses the text body returned by the server from a batch request
275 *
276 * @param body String body from the server response
277 * @returns Parsed response objects
278 */
279function parseResponse(body) {
280 const responses = [];
281 const header = "--batchresponse_";
282 // Ex. "HTTP/1.1 500 Internal Server Error"
283 const statusRegExp = new RegExp("^HTTP/[0-9.]+ +([0-9]+) +(.*)", "i");
284 const lines = body.split("\n");
285 let state = "batch";
286 let status;
287 let statusText;
288 let headers = {};
289 const bodyReader = [];
290 for (let i = 0; i < lines.length; ++i) {
291 let line = lines[i];
292 switch (state) {
293 case "batch":
294 if (line.substring(0, header.length) === header) {
295 state = "batchHeaders";
296 }
297 else {
298 if (line.trim() !== "") {
299 throw Error(`Invalid response, line ${i}`);
300 }
301 }
302 break;
303 case "batchHeaders":
304 if (line.trim() === "") {
305 state = "status";
306 }
307 break;
308 case "status": {
309 const parts = statusRegExp.exec(line);
310 if (parts.length !== 3) {
311 throw Error(`Invalid status, line ${i}`);
312 }
313 status = parseInt(parts[1], 10);
314 statusText = parts[2];
315 state = "statusHeaders";
316 break;
317 }
318 case "statusHeaders":
319 if (line.trim() === "") {
320 state = "body";
321 }
322 else {
323 const headerParts = line.split(":");
324 if ((headerParts === null || headerParts === void 0 ? void 0 : headerParts.length) === 2) {
325 headers[headerParts[0].trim()] = headerParts[1].trim();
326 }
327 }
328 break;
329 case "body":
330 // reset the body reader
331 bodyReader.length = 0;
332 // this allows us to capture batch bodies that are returned as multi-line (renderListDataAsStream, #2454)
333 while (line.substring(0, header.length) !== header) {
334 bodyReader.push(line);
335 line = lines[++i];
336 }
337 // because we have read the closing --batchresponse_ line, we need to move the line pointer back one
338 // so that the logic works as expected either to get the next result or end processing
339 i--;
340 responses.push(new Response(status === 204 ? null : bodyReader.join(""), { status, statusText, headers }));
341 state = "batch";
342 headers = {};
343 break;
344 }
345 }
346 if (state !== "status") {
347 throw Error("Unexpected end of input");
348 }
349 return responses;
350}
351//# sourceMappingURL=batching.js.map
\No newline at end of file