1 | import { getGUID, isUrlAbsolute, combine, CopyFrom, isFunc, hOP } from "@pnp/core";
|
2 | import { parseBinderWithErrorCheck } from "@pnp/queryable";
|
3 | import { spPost } from "./operations.js";
|
4 | import { _SPQueryable } from "./spqueryable.js";
|
5 | import { spfi, SPFI } from "./fi.js";
|
6 | import { Web, _Web } from "./webs/types.js";
|
7 | SPFI.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 | */
|
22 | const 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 | */
|
26 | const 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 | */
|
31 | function 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 | */
|
40 | class 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 | */
|
71 | export 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 | */
|
264 | export 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 | */
|
279 | function 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 |