UNPKG

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