UNPKG

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