1 | import { getGUID, isUrlAbsolute, combine, CopyFrom, isFunc, hOP } from "@pnp/core";
|
2 | import { parseBinderWithErrorCheck } from "@pnp/queryable";
|
3 | import { _SPQueryable, spPost } from "./spqueryable.js";
|
4 | import { spfi, SPFI } from "./fi.js";
|
5 | import { Web, _Web } from "./webs/types.js";
|
6 | SPFI.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 | */
|
21 | const 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 | */
|
25 | const 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 | */
|
30 | function 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 | */
|
39 | class 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 | */
|
70 | export 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 | */
|
263 | export 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 | */
|
278 | function 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 |