UNPKG

14.1 kBJavaScriptView Raw
1import { getGUID, isUrlAbsolute, combine, CopyFrom, isFunc } 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|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 return;
85 }
86 const batchBody = [];
87 let currentChangeSetId = "";
88 for (let i = 0; i < requests.length; i++) {
89 const [, url, init] = requests[i];
90 if (init.method === "GET") {
91 if (currentChangeSetId.length > 0) {
92 // end an existing change set
93 batchBody.push(`--changeset_${currentChangeSetId}--\n\n`);
94 currentChangeSetId = "";
95 }
96 batchBody.push(`--batch_${batchId}\n`);
97 }
98 else {
99 if (currentChangeSetId.length < 1) {
100 // start new change set
101 currentChangeSetId = getGUID();
102 batchBody.push(`--batch_${batchId}\n`);
103 batchBody.push(`Content-Type: multipart/mixed; boundary="changeset_${currentChangeSetId}"\n\n`);
104 }
105 batchBody.push(`--changeset_${currentChangeSetId}\n`);
106 }
107 // common batch part prefix
108 batchBody.push("Content-Type: application/http\n");
109 batchBody.push("Content-Transfer-Encoding: binary\n\n");
110 // these are the per-request headers
111 const headers = new Headers(init.headers);
112 // this is the url of the individual request within the batch
113 const reqUrl = isUrlAbsolute(url) ? url : combine(batchQuery.requestBaseUrl, url);
114 if (init.method !== "GET") {
115 let method = init.method;
116 if (headers.has("X-HTTP-Method")) {
117 method = headers.get("X-HTTP-Method");
118 headers.delete("X-HTTP-Method");
119 }
120 batchBody.push(`${method} ${reqUrl} HTTP/1.1\n`);
121 }
122 else {
123 batchBody.push(`${init.method} ${reqUrl} HTTP/1.1\n`);
124 }
125 // lastly we apply any default headers we need that may not exist
126 if (!headers.has("Accept")) {
127 headers.append("Accept", "application/json");
128 }
129 if (!headers.has("Content-Type")) {
130 headers.append("Content-Type", "application/json;charset=utf-8");
131 }
132 // write headers into batch body
133 headers.forEach((value, name) => {
134 if (headersCopyPattern.test(name)) {
135 batchBody.push(`${name}: ${value}\n`);
136 }
137 });
138 batchBody.push("\n");
139 if (init.body) {
140 batchBody.push(`${init.body}\n\n`);
141 }
142 }
143 if (currentChangeSetId.length > 0) {
144 // Close the changeset
145 batchBody.push(`--changeset_${currentChangeSetId}--\n\n`);
146 currentChangeSetId = "";
147 }
148 batchBody.push(`--batch_${batchId}--\n`);
149 // we need to set our own headers here
150 batchQuery.using(InjectHeaders({
151 "Content-Type": `multipart/mixed; boundary=batch_${batchId}`,
152 }));
153 const responses = await spPost(batchQuery, { body: batchBody.join("") });
154 if (responses.length !== requests.length) {
155 throw Error("Could not properly parse responses to match requests in batch.");
156 }
157 // this structure ensures that we resolve the batched requests in the order we expect
158 return responses.reduce((p, response, index) => p.then(async () => {
159 const [, , , resolve, reject] = requests[index];
160 try {
161 resolve(response);
162 }
163 catch (e) {
164 reject(e);
165 }
166 }), Promise.resolve(void (0))).then(() => Promise.all(completePromises).then(() => void (0)));
167 };
168 const register = (instance) => {
169 instance.on.init(function () {
170 if (isFunc(this[RegistrationCompleteSym])) {
171 throw Error("This instance is already part of a batch. Please review the docs at https://pnp.github.io/pnpjs/concepts/batching#reuse.");
172 }
173 // we need to ensure we wait to start execute until all our batch children hit the .send method to be fully registered
174 registrationPromises.push(new Promise((resolve) => {
175 this[RegistrationCompleteSym] = resolve;
176 }));
177 return this;
178 });
179 // the entire request will be auth'd - we don't need to run this for each batch request
180 instance.on.auth.clear();
181 // we replace the send function with our batching logic
182 instance.on.send.replace(async function (url, init) {
183 // this is the promise that Queryable will see returned from .emit.send
184 const promise = new Promise((resolve, reject) => {
185 // add the request information into the batch
186 requests.push([this, url.toString(), init, resolve, reject]);
187 });
188 this.log(`[batch:${batchId}] (${(new Date()).getTime()}) Adding request ${init.method} ${url.toString()} to batch.`, 0);
189 // we need to ensure we wait to resolve execute until all our batch children have fully completed their request timelines
190 completePromises.push(new Promise((resolve) => {
191 this[RequestCompleteSym] = resolve;
192 }));
193 // indicate that registration of this request is complete
194 this[RegistrationCompleteSym]();
195 return promise;
196 });
197 instance.on.dispose(function () {
198 if (isFunc(this[RegistrationCompleteSym])) {
199 // 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
200 // because the request will never reach the "send" moment as the result is returned from "pre"
201 this[RegistrationCompleteSym]();
202 // remove the symbol props we added for good hygene
203 delete this[RegistrationCompleteSym];
204 }
205 if (isFunc(this[RequestCompleteSym])) {
206 // let things know we are done with this request
207 this[RequestCompleteSym]();
208 delete this[RequestCompleteSym];
209 // there is a code path where you may invoke a batch, say on items.add, whose return
210 // is an object like { data: any, item: IItem }. The expectation from v1 on is `item` in that object
211 // is immediately usable to make additional queries. Without this step when that IItem instance is
212 // created using "this.getById" within IITems.add all of the current observers of "this" are
213 // linked to the IItem instance created (expected), BUT they will be the set of observers setup
214 // to handle the batch, meaning invoking `item` will result in a half batched call that
215 // doesn't really work. To deliver the expected functionality we "reset" the
216 // observers using the original instance, mimicing the behavior had
217 // the IItem been created from that base without a batch involved. We use CopyFrom to ensure
218 // that we maintain the references to the InternalResolve and InternalReject events through
219 // the end of this timeline lifecycle. This works because CopyFrom by design uses Object.keys
220 // which ignores symbol properties.
221 this.using(CopyFrom(batchQuery, "replace", (k) => /(auth|send|init|dispose)/i.test(k)));
222 }
223 });
224 return instance;
225 };
226 return [register, execute];
227}
228/**
229 * Parses the text body returned by the server from a batch request
230 *
231 * @param body String body from the server response
232 * @returns Parsed response objects
233 */
234function parseResponse(body) {
235 const responses = [];
236 const header = "--batchresponse_";
237 // Ex. "HTTP/1.1 500 Internal Server Error"
238 const statusRegExp = new RegExp("^HTTP/[0-9.]+ +([0-9]+) +(.*)", "i");
239 const lines = body.split("\n");
240 let state = "batch";
241 let status;
242 let statusText;
243 let headers = {};
244 for (let i = 0; i < lines.length; ++i) {
245 const line = lines[i];
246 switch (state) {
247 case "batch":
248 if (line.substring(0, header.length) === header) {
249 state = "batchHeaders";
250 }
251 else {
252 if (line.trim() !== "") {
253 throw Error(`Invalid response, line ${i}`);
254 }
255 }
256 break;
257 case "batchHeaders":
258 if (line.trim() === "") {
259 state = "status";
260 }
261 break;
262 case "status": {
263 const parts = statusRegExp.exec(line);
264 if (parts.length !== 3) {
265 throw Error(`Invalid status, line ${i}`);
266 }
267 status = parseInt(parts[1], 10);
268 statusText = parts[2];
269 state = "statusHeaders";
270 break;
271 }
272 case "statusHeaders":
273 if (line.trim() === "") {
274 state = "body";
275 }
276 else {
277 const headerParts = line.split(":");
278 if ((headerParts === null || headerParts === void 0 ? void 0 : headerParts.length) === 2) {
279 headers[headerParts[0].trim()] = headerParts[1].trim();
280 }
281 }
282 break;
283 case "body":
284 responses.push(new Response(status === 204 ? null : line, { status, statusText, headers }));
285 state = "batch";
286 headers = {};
287 break;
288 }
289 }
290 if (state !== "status") {
291 throw Error("Unexpected end of input");
292 }
293 return responses;
294}
295//# sourceMappingURL=batching.js.map
\No newline at end of file