esri-gl 1.0.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,163 @@
1
+ import { BasemapStyleSession } from '@esri/arcgis-rest-basemap-sessions';
2
+ import { SearchQueryBuilder, searchItems, getItem, getItemData } from '@esri/arcgis-rest-portal';
3
+ import { ApiKeyManager, request } from '@esri/arcgis-rest-request';
4
+ import { queryFeatures, getLayer, getAllLayersAndTables, queryRelated, decodeValues, addFeatures, updateFeatures, deleteFeatures, applyEdits, getAttachments, deleteAttachments } from '@esri/arcgis-rest-feature-service';
1
5
  import * as tilebelt from '@mapbox/tilebelt';
2
6
  import tileDecode from 'arcgis-pbf-parser';
3
7
 
8
+ /**
9
+ * Shared request + authentication adapter built on `@esri/arcgis-rest-request`.
10
+ *
11
+ * Every network call in esri-gl that talks to an ArcGIS REST endpoint flows
12
+ * through this module so that authentication, error handling and parameter
13
+ * encoding are handled once by the official ArcGIS REST JS client instead of
14
+ * the bespoke `fetch` wrappers this library used to ship.
15
+ */
16
+ /**
17
+ * Normalise the various auth inputs into an `IAuthenticationManager` (or
18
+ * `undefined` for anonymous requests). A bare string is wrapped in an
19
+ * `ApiKeyManager` so it is sent as the `token` parameter, matching ArcGIS REST
20
+ * conventions.
21
+ */
22
+ function resolveAuthentication(options) {
23
+ if (!options) return undefined;
24
+ const {
25
+ authentication,
26
+ apiKey,
27
+ token
28
+ } = options;
29
+ if (authentication) {
30
+ return typeof authentication === 'string' ? ApiKeyManager.fromKey(authentication) : authentication;
31
+ }
32
+ const key = apiKey ?? token;
33
+ return key ? ApiKeyManager.fromKey(key) : undefined;
34
+ }
35
+ /**
36
+ * Thin wrapper over `@esri/arcgis-rest-request`'s `request()` that applies
37
+ * esri-gl's auth resolution and sensible defaults. Throws `ArcGISRequestError`
38
+ * (and friends) on ArcGIS service-level or HTTP errors.
39
+ */
40
+ function esriRequest(url, options = {}) {
41
+ const {
42
+ params,
43
+ httpMethod,
44
+ rawResponse,
45
+ signal,
46
+ headers,
47
+ ...auth
48
+ } = options;
49
+ const requestOptions = {
50
+ params: {
51
+ f: 'json',
52
+ ...params
53
+ },
54
+ authentication: resolveAuthentication(auth)
55
+ };
56
+ if (httpMethod) requestOptions.httpMethod = httpMethod;
57
+ if (rawResponse) requestOptions.rawResponse = true;
58
+ if (signal) requestOptions.signal = signal;
59
+ if (headers) requestOptions.headers = headers;
60
+ return request(url, requestOptions);
61
+ }
62
+ /**
63
+ * Fetch a **binary** ArcGIS response (PBF tiles, exported images) as a raw
64
+ * `Response`, applying esri-gl auth. Used instead of `esriRequest`'s deprecated
65
+ * `rawResponse` option (removed in ArcGIS REST JS v5).
66
+ */
67
+ async function esriRawRequest(url, options = {}) {
68
+ const {
69
+ params,
70
+ signal,
71
+ headers,
72
+ ...auth
73
+ } = options;
74
+ const search = new URLSearchParams();
75
+ for (const [key, value] of Object.entries(params ?? {})) {
76
+ if (value === undefined || value === null) continue;
77
+ search.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
78
+ }
79
+ const manager = resolveAuthentication(auth);
80
+ if (manager) {
81
+ try {
82
+ const token = await manager.getToken(url);
83
+ if (token) search.append('token', token);
84
+ } catch {
85
+ // fall through and request anonymously
86
+ }
87
+ }
88
+ const response = await fetch(`${url}?${search.toString()}`, {
89
+ signal,
90
+ headers
91
+ });
92
+ if (!response.ok) {
93
+ throw new Error(`Request failed: HTTP ${response.status}`);
94
+ }
95
+ return response;
96
+ }
97
+
4
98
  function cleanTrailingSlash(url) {
5
99
  return url.replace(/\/$/, '');
6
100
  }
101
+ /**
102
+ * Append a `token` query parameter if a non-empty token is supplied.
103
+ * Centralizes the duplicated token-injection logic used by every service.
104
+ */
105
+ function appendTokenIfExists(params, token) {
106
+ if (token) {
107
+ params.append('token', token);
108
+ }
109
+ }
110
+ /**
111
+ * Remove every layer that references the given source, then remove the
112
+ * source itself. Safe against disposed maps, missing style methods, and
113
+ * layers that were already removed elsewhere.
114
+ */
115
+ function removeMapSource(map, sourceId) {
116
+ if (!map || typeof map.removeSource !== 'function') return;
117
+ try {
118
+ if (!map.style) return;
119
+ const mapWithStyle = map;
120
+ if (typeof mapWithStyle.getStyle === 'function') {
121
+ const style = mapWithStyle.getStyle();
122
+ const layers = style?.layers || [];
123
+ layers.forEach(layer => {
124
+ if (layer.source !== sourceId) return;
125
+ if (typeof mapWithStyle.getLayer !== 'function' || typeof mapWithStyle.removeLayer !== 'function') {
126
+ return;
127
+ }
128
+ let hasLayer = false;
129
+ try {
130
+ hasLayer = Boolean(mapWithStyle.getLayer(layer.id));
131
+ } catch {
132
+ hasLayer = false;
133
+ }
134
+ if (!hasLayer) return;
135
+ try {
136
+ mapWithStyle.removeLayer(layer.id);
137
+ } catch (error) {
138
+ console.warn(`Failed to remove layer ${layer.id} for source ${sourceId}:`, error);
139
+ }
140
+ });
141
+ }
142
+ if (typeof mapWithStyle.getSource === 'function') {
143
+ let hasSource = false;
144
+ try {
145
+ hasSource = Boolean(mapWithStyle.getSource(sourceId));
146
+ } catch {
147
+ hasSource = false;
148
+ }
149
+ if (hasSource) {
150
+ try {
151
+ map.removeSource(sourceId);
152
+ } catch (error) {
153
+ console.warn(`Failed to remove source ${sourceId}:`, error);
154
+ }
155
+ }
156
+ }
157
+ } catch (error) {
158
+ console.warn(`Failed to remove source ${sourceId}:`, error);
159
+ }
160
+ }
7
161
  /**
8
162
  * Check if an error represents an AbortError (request was cancelled)
9
163
  * Handles various error shapes from different browsers and environments
@@ -27,22 +181,18 @@ function isAbortError(error) {
27
181
  const stringified = String(error).toLowerCase();
28
182
  return stringified.includes('abort');
29
183
  }
30
- async function getServiceDetails(url, fetchOptions = {}, token) {
31
- const params = new URLSearchParams({
32
- f: 'json'
184
+ /**
185
+ * Fetch the `?f=json` metadata document for an ArcGIS service endpoint.
186
+ *
187
+ * Delegates to `@esri/arcgis-rest-request` via {@link esriRequest}, which
188
+ * handles authentication and ArcGIS error responses. Accepts an
189
+ * {@link EsriRequestOptions} object (`token` / `apiKey` / `authentication`).
190
+ */
191
+ async function getServiceDetails(url, options = {}) {
192
+ return esriRequest(url, {
193
+ httpMethod: 'GET',
194
+ ...options
33
195
  });
34
- if (token) {
35
- params.append('token', token);
36
- }
37
- const response = await fetch(`${url}?${params.toString()}`, fetchOptions);
38
- if (!response.ok) {
39
- throw new Error(`Failed to fetch service details: HTTP ${response.status}`);
40
- }
41
- const data = await response.json();
42
- if (data.error) {
43
- throw new Error(data.error.message || 'Service returned an error');
44
- }
45
- return data;
46
196
  }
47
197
  const POWERED_BY_ESRI_ATTRIBUTION_STRING = 'Powered by <a href="https://www.esri.com">Esri</a>';
48
198
  // This requires hooking into some undocumented properties
@@ -274,12 +424,11 @@ class Service {
274
424
  const wrappedCallback = this._createServiceCallback(method, path, params, (error, response) => {
275
425
  callback(error, response);
276
426
  });
427
+ // Token/apiKey/authentication are applied by the request adapter, not as
428
+ // raw params, so they are not injected here.
277
429
  let finalParams = {
278
430
  ...params
279
431
  };
280
- if (this.options.token) {
281
- finalParams.token = this.options.token;
282
- }
283
432
  if (this.options.requestParams) {
284
433
  finalParams = {
285
434
  ...finalParams,
@@ -326,7 +475,7 @@ class Service {
326
475
  });
327
476
  }
328
477
  async _makeRequest(method, path, params, callback) {
329
- const url = this.options.proxy ? `${this.options.proxy}?${this.options.url}${path}` : `${this.options.url}${path}`;
478
+ const url = `${this.options.url}${path}`;
330
479
  // Set up abort controller for timeout support
331
480
  const controller = new AbortController();
332
481
  let timeoutId;
@@ -336,71 +485,22 @@ class Service {
336
485
  }, this.options.timeout);
337
486
  }
338
487
  try {
339
- let response;
340
- const fetchOptions = {
341
- signal: controller.signal
342
- };
343
- // Add API key header if present
344
- if (this.options.apiKey) {
345
- fetchOptions.headers = {
346
- 'X-Esri-Authorization': `Bearer ${this.options.apiKey}`
347
- };
348
- }
349
- if (method === 'POST') {
350
- const formData = new FormData();
351
- Object.keys(params).forEach(key => {
352
- const value = params[key];
353
- if (value !== undefined && value !== null) {
354
- if (typeof value === 'object') {
355
- formData.append(key, JSON.stringify(value));
356
- } else {
357
- formData.append(key, value.toString());
358
- }
359
- }
360
- });
361
- response = await fetch(url, {
362
- ...fetchOptions,
363
- method: 'POST',
364
- body: formData
365
- });
366
- } else {
367
- const searchParams = new URLSearchParams();
368
- Object.keys(params).forEach(key => {
369
- const value = params[key];
370
- if (value !== undefined && value !== null) {
371
- if (Array.isArray(value)) {
372
- searchParams.append(key, value.join(','));
373
- } else if (typeof value === 'object') {
374
- searchParams.append(key, JSON.stringify(value));
375
- } else {
376
- searchParams.append(key, value.toString());
377
- }
378
- }
379
- });
380
- const fullUrl = `${url}?${searchParams.toString()}`;
381
- response = await fetch(fullUrl, fetchOptions);
382
- }
383
- if (!response.ok) {
384
- const error = new Error(`HTTP error! status: ${response.status}`);
385
- error.code = response.status;
386
- throw error;
387
- }
388
- const data = await response.json();
389
- // Check for AGOL JSON-level errors (HTTP 200 with error body)
390
- if (data && typeof data === 'object' && data.error) {
391
- const err = new Error(data.error.message || 'ArcGIS service error');
392
- err.code = data.error.code;
393
- err.details = data.error.details;
394
- callback(err);
395
- return;
396
- }
488
+ const data = await esriRequest(url, {
489
+ params,
490
+ httpMethod: method === 'POST' ? 'POST' : 'GET',
491
+ signal: controller.signal,
492
+ token: this.options.token,
493
+ apiKey: this.options.apiKey,
494
+ authentication: this.options.authentication
495
+ });
397
496
  callback(undefined, data);
398
497
  } catch (error) {
399
498
  // Provide clearer error message for timeout
400
499
  if (error instanceof Error && error.name === 'AbortError' && this.options.timeout > 0) {
401
- const timeoutError = new Error(`Request timed out after ${this.options.timeout}ms`);
402
- callback(timeoutError);
500
+ callback(new Error(`Request timed out after ${this.options.timeout}ms`));
403
501
  } else {
502
+ // ArcGISRequestError exposes a numeric `code` used downstream for
503
+ // authentication (498/499) detection.
404
504
  callback(error);
405
505
  }
406
506
  } finally {
@@ -797,10 +897,21 @@ class DynamicMapService {
797
897
  if (where) this.setLayerDefinition(layerId, where);
798
898
  }
799
899
  _appendTokenIfExists(params) {
800
- const token = this.esriServiceOptions.token;
801
- if (token) {
802
- params.append('token', token);
803
- }
900
+ // apiKey is sent as the token URL parameter, matching the request-based paths.
901
+ const auth = this.esriServiceOptions;
902
+ appendTokenIfExists(params, auth.token ?? auth.apiKey);
903
+ }
904
+ _auth() {
905
+ const o = this.esriServiceOptions;
906
+ return {
907
+ token: o.token,
908
+ apiKey: o.apiKey,
909
+ authentication: o.authentication
910
+ };
911
+ }
912
+ /** Resolved ArcGIS REST JS auth manager for the typed feature-service helpers. */
913
+ _authentication() {
914
+ return resolveAuthentication(this._auth());
804
915
  }
805
916
  _escapeValue(val) {
806
917
  if (val === null) return 'NULL';
@@ -876,7 +987,7 @@ class DynamicMapService {
876
987
  getMetadata() {
877
988
  if (this._serviceMetadata !== null) return Promise.resolve(this._serviceMetadata);
878
989
  return new Promise((resolve, reject) => {
879
- getServiceDetails(this.esriServiceOptions.url, this.esriServiceOptions.fetchOptions, this.esriServiceOptions.token).then(data => {
990
+ getServiceDetails(this.esriServiceOptions.url, this._auth()).then(data => {
880
991
  this._serviceMetadata = data;
881
992
  resolve(this._serviceMetadata);
882
993
  }).catch(err => reject(err));
@@ -891,7 +1002,7 @@ class DynamicMapService {
891
1002
  const canvas = this._map.getCanvas();
892
1003
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
893
1004
  const bounds = this._map.getBounds().toArray();
894
- const params = new URLSearchParams({
1005
+ const params = {
895
1006
  sr: '4326',
896
1007
  geometryType: 'esriGeometryPoint',
897
1008
  geometry: JSON.stringify({
@@ -905,21 +1016,15 @@ class DynamicMapService {
905
1016
  returnGeometry: returnGeometry.toString(),
906
1017
  imageDisplay: `${canvas.width},${canvas.height},${this.options.dpi}`,
907
1018
  mapExtent: `${bounds[0][0]},${bounds[0][1]},${bounds[1][0]},${bounds[1][1]}`,
908
- layers: this._layersStrIdentify || '',
909
- f: 'json'
1019
+ layers: this._layersStrIdentify || ''
1020
+ };
1021
+ if (this._layerDefs) params.layerDefs = this._layerDefs;
1022
+ if (this._dynamicLayers) params.dynamicLayers = this._dynamicLayers;
1023
+ if (this._time) params.time = this._time;
1024
+ const data = await esriRequest(`${this.esriServiceOptions.url}/identify`, {
1025
+ params,
1026
+ ...this._auth()
910
1027
  });
911
- if (this._layerDefs) params.append('layerDefs', this._layerDefs);
912
- if (this._dynamicLayers) params.append('dynamicLayers', this._dynamicLayers);
913
- if (this._time) params.append('time', this._time);
914
- this._appendTokenIfExists(params);
915
- const response = await fetch(`${this.esriServiceOptions.url}/identify?${params.toString()}`, this.esriServiceOptions.fetchOptions);
916
- if (!response.ok) {
917
- throw new Error(`Identify request failed: HTTP ${response.status}`);
918
- }
919
- const data = await response.json();
920
- if (data.error) {
921
- throw new Error(`Identify request failed: ${data.error.message}`);
922
- }
923
1028
  return data;
924
1029
  }
925
1030
  // ========================================
@@ -1046,71 +1151,44 @@ class DynamicMapService {
1046
1151
  }
1047
1152
  /** Get statistics for a sublayer */
1048
1153
  async getLayerStatistics(layerId, statisticFields, options = {}) {
1049
- const queryUrl = `${this.esriServiceOptions.url}/${layerId}/query`;
1050
- const params = new URLSearchParams({
1051
- f: 'json',
1154
+ const data = await queryFeatures({
1155
+ url: `${this.esriServiceOptions.url}/${layerId}`,
1052
1156
  where: options.where || '1=1',
1053
- outStatistics: JSON.stringify(statisticFields),
1054
- returnGeometry: 'false'
1157
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1158
+ outStatistics: statisticFields,
1159
+ groupByFieldsForStatistics: options.groupByFieldsForStatistics,
1160
+ returnGeometry: false,
1161
+ authentication: this._authentication()
1055
1162
  });
1056
- if (options.groupByFieldsForStatistics) {
1057
- params.append('groupByFieldsForStatistics', options.groupByFieldsForStatistics);
1058
- }
1059
- this._appendTokenIfExists(params);
1060
- const response = await fetch(`${queryUrl}?${params.toString()}`);
1061
- if (!response.ok) {
1062
- throw new Error(`Statistics query failed: HTTP ${response.status}`);
1063
- }
1064
- const data = await response.json();
1065
- if (data.error) {
1066
- throw new Error(`Statistics query failed: ${data.error.message}`);
1067
- }
1068
1163
  return data.features || [];
1069
1164
  }
1070
1165
  /** Query features from a specific sublayer */
1071
1166
  async queryLayerFeatures(layerId, options = {}) {
1072
- const queryUrl = `${this.esriServiceOptions.url}/${layerId}/query`;
1073
- const params = new URLSearchParams({
1074
- f: 'json',
1167
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1168
+ const requestOptions = {
1169
+ url: `${this.esriServiceOptions.url}/${layerId}`,
1075
1170
  where: options.where || '1=1',
1076
- returnGeometry: options.returnGeometry !== false ? 'true' : 'false',
1077
- outFields: Array.isArray(options.outFields) ? options.outFields.join(',') : options.outFields || '*'
1078
- });
1171
+ returnGeometry: options.returnGeometry !== false,
1172
+ outFields: options.outFields || '*',
1173
+ authentication: this._authentication()
1174
+ };
1079
1175
  if (options.geometry) {
1080
- params.append('geometry', JSON.stringify(options.geometry));
1081
- params.append('geometryType', options.geometryType || 'esriGeometryEnvelope');
1082
- params.append('spatialRel', options.spatialRel || 'esriSpatialRelIntersects');
1083
- }
1084
- if (options.orderByFields) {
1085
- params.append('orderByFields', options.orderByFields);
1086
- }
1087
- if (options.resultOffset) {
1088
- params.append('resultOffset', options.resultOffset.toString());
1089
- }
1090
- if (options.resultRecordCount) {
1091
- params.append('resultRecordCount', options.resultRecordCount.toString());
1092
- }
1093
- if (options.returnCountOnly) {
1094
- params.append('returnCountOnly', 'true');
1095
- }
1096
- if (options.returnIdsOnly) {
1097
- params.append('returnIdsOnly', 'true');
1098
- }
1099
- this._appendTokenIfExists(params);
1100
- const response = await fetch(`${queryUrl}?${params.toString()}`);
1101
- if (!response.ok) {
1102
- throw new Error(`Layer query failed: HTTP ${response.status}`);
1103
- }
1104
- const data = await response.json();
1105
- if (data.error) {
1106
- throw new Error(`Layer query failed: ${data.error.message}`);
1107
- }
1176
+ requestOptions.geometry = options.geometry;
1177
+ requestOptions.geometryType = options.geometryType || 'esriGeometryEnvelope';
1178
+ requestOptions.spatialRel = options.spatialRel || 'esriSpatialRelIntersects';
1179
+ }
1180
+ if (options.orderByFields) requestOptions.orderByFields = options.orderByFields;
1181
+ if (options.resultOffset) requestOptions.resultOffset = options.resultOffset;
1182
+ if (options.resultRecordCount) requestOptions.resultRecordCount = options.resultRecordCount;
1183
+ if (options.returnCountOnly) requestOptions.returnCountOnly = true;
1184
+ if (options.returnIdsOnly) requestOptions.returnIdsOnly = true;
1185
+ const data = await queryFeatures(requestOptions);
1108
1186
  return data;
1109
1187
  }
1110
1188
  /** Export high-resolution map image */
1111
1189
  async exportMapImage(options) {
1112
1190
  const exportUrl = `${this.esriServiceOptions.url}/export`;
1113
- const params = new URLSearchParams({
1191
+ const params = {
1114
1192
  f: 'image',
1115
1193
  bbox: options.bbox.join(','),
1116
1194
  size: options.size.join(','),
@@ -1119,62 +1197,45 @@ class DynamicMapService {
1119
1197
  dpi: (options.dpi || 96).toString(),
1120
1198
  bboxSR: (options.bboxSR || 3857).toString(),
1121
1199
  imageSR: (options.imageSR || 3857).toString()
1122
- });
1200
+ };
1123
1201
  if (options.layerDefs) {
1124
- params.append('layerDefs', JSON.stringify(options.layerDefs));
1202
+ params.layerDefs = JSON.stringify(options.layerDefs);
1125
1203
  }
1126
1204
  if (options.dynamicLayers) {
1127
1205
  const normalized = this._ensureAllVisibleLayers(options.dynamicLayers);
1128
- params.append('dynamicLayers', JSON.stringify(normalized));
1206
+ params.dynamicLayers = JSON.stringify(normalized);
1129
1207
  }
1130
1208
  if (options.gdbVersion) {
1131
- params.append('gdbVersion', options.gdbVersion);
1209
+ params.gdbVersion = options.gdbVersion;
1132
1210
  }
1133
1211
  if (options.historicMoment) {
1134
- params.append('historicMoment', options.historicMoment.toString());
1135
- }
1136
- this._appendTokenIfExists(params);
1137
- const response = await fetch(`${exportUrl}?${params.toString()}`);
1138
- if (!response.ok) {
1139
- throw new Error(`Export failed: ${response.statusText}`);
1212
+ params.historicMoment = options.historicMoment.toString();
1140
1213
  }
1214
+ const response = await esriRawRequest(`${exportUrl}`, {
1215
+ params,
1216
+ ...this._auth()
1217
+ });
1141
1218
  return response.blob();
1142
1219
  }
1143
1220
  /** Generate legend information for layers */
1144
1221
  async generateLegend(layerIds) {
1145
1222
  const legendUrl = `${this.esriServiceOptions.url}/legend`;
1146
- const params = new URLSearchParams({
1147
- f: 'json'
1148
- });
1223
+ const params = {};
1149
1224
  if (layerIds?.length) {
1150
- params.append('layers', layerIds.join(','));
1151
- }
1152
- this._appendTokenIfExists(params);
1153
- const response = await fetch(`${legendUrl}?${params.toString()}`);
1154
- if (!response.ok) {
1155
- throw new Error(`Legend generation failed: HTTP ${response.status}`);
1156
- }
1157
- const data = await response.json();
1158
- if (data.error) {
1159
- throw new Error(`Legend generation failed: ${data.error.message}`);
1225
+ params.layers = layerIds.join(',');
1160
1226
  }
1227
+ const data = await esriRequest(legendUrl, {
1228
+ params,
1229
+ ...this._auth()
1230
+ });
1161
1231
  return data.layers || [];
1162
1232
  }
1163
1233
  /** Get detailed information about a specific layer */
1164
1234
  async getLayerInfo(layerId) {
1165
- const layerUrl = `${this.esriServiceOptions.url}/${layerId}`;
1166
- const params = new URLSearchParams({
1167
- f: 'json'
1235
+ const data = await getLayer({
1236
+ url: `${this.esriServiceOptions.url}/${layerId}`,
1237
+ authentication: this._authentication()
1168
1238
  });
1169
- this._appendTokenIfExists(params);
1170
- const response = await fetch(`${layerUrl}?${params.toString()}`);
1171
- if (!response.ok) {
1172
- throw new Error(`Layer info request failed: HTTP ${response.status}`);
1173
- }
1174
- const data = await response.json();
1175
- if (data.error) {
1176
- throw new Error(`Layer info request failed: ${data.error.message}`);
1177
- }
1178
1239
  return data;
1179
1240
  }
1180
1241
  /** Get field information for a layer */
@@ -1192,19 +1253,10 @@ class DynamicMapService {
1192
1253
  }
1193
1254
  /** Discover all layers in the service */
1194
1255
  async discoverLayers() {
1195
- const serviceUrl = this.esriServiceOptions.url;
1196
- const params = new URLSearchParams({
1197
- f: 'json'
1256
+ const data = await getAllLayersAndTables({
1257
+ url: this.esriServiceOptions.url,
1258
+ authentication: this._authentication()
1198
1259
  });
1199
- this._appendTokenIfExists(params);
1200
- const response = await fetch(`${serviceUrl}?${params.toString()}`);
1201
- if (!response.ok) {
1202
- throw new Error(`Service discovery failed: HTTP ${response.status}`);
1203
- }
1204
- const data = await response.json();
1205
- if (data.error) {
1206
- throw new Error(`Service discovery failed: ${data.error.message}`);
1207
- }
1208
1260
  return data.layers || [];
1209
1261
  }
1210
1262
  /** Apply multiple layer operations in a single update */
@@ -1283,56 +1335,7 @@ class DynamicMapService {
1283
1335
  this._updateSource();
1284
1336
  }
1285
1337
  remove() {
1286
- const map = this._map;
1287
- if (!map || typeof map.removeSource !== 'function') {
1288
- return;
1289
- }
1290
- try {
1291
- // Guard against map whose style has already been destroyed
1292
- if (!map.style) return;
1293
- const mapWithStyle = map;
1294
- const mapLayerApi = map;
1295
- const mapSourceApi = map;
1296
- if (typeof mapWithStyle.getStyle === 'function') {
1297
- const style = mapWithStyle.getStyle();
1298
- const layers = style?.layers || [];
1299
- layers.forEach(layer => {
1300
- if (layer.source !== this._sourceId) return;
1301
- if (typeof mapLayerApi.getLayer !== 'function' || typeof mapLayerApi.removeLayer !== 'function') {
1302
- return;
1303
- }
1304
- let hasLayer = false;
1305
- try {
1306
- hasLayer = Boolean(mapLayerApi.getLayer(layer.id));
1307
- } catch {
1308
- hasLayer = false;
1309
- }
1310
- if (!hasLayer) return;
1311
- try {
1312
- mapLayerApi.removeLayer(layer.id);
1313
- } catch (error) {
1314
- console.warn(`Failed to remove layer ${layer.id} for source ${this._sourceId}:`, error);
1315
- }
1316
- });
1317
- }
1318
- if (typeof mapSourceApi.getSource === 'function') {
1319
- let hasSource = false;
1320
- try {
1321
- hasSource = Boolean(mapSourceApi.getSource(this._sourceId));
1322
- } catch {
1323
- hasSource = false;
1324
- }
1325
- if (hasSource) {
1326
- try {
1327
- map.removeSource(this._sourceId);
1328
- } catch (error) {
1329
- console.warn(`Failed to remove source ${this._sourceId}:`, error);
1330
- }
1331
- }
1332
- }
1333
- } catch (error) {
1334
- console.warn(`Failed to remove source ${this._sourceId}:`, error);
1335
- }
1338
+ removeMapSource(this._map, this._sourceId);
1336
1339
  }
1337
1340
  }
1338
1341
 
@@ -1417,8 +1420,17 @@ class TiledMapService {
1417
1420
  }
1418
1421
  getMetadata() {
1419
1422
  if (this._serviceMetadata !== null) return Promise.resolve(this._serviceMetadata);
1423
+ const {
1424
+ token,
1425
+ apiKey,
1426
+ authentication
1427
+ } = this.esriServiceOptions;
1420
1428
  return new Promise((resolve, reject) => {
1421
- getServiceDetails(this.esriServiceOptions.url, this.esriServiceOptions.fetchOptions, this.esriServiceOptions.token).then(data => {
1429
+ getServiceDetails(this.esriServiceOptions.url, {
1430
+ token,
1431
+ apiKey,
1432
+ authentication
1433
+ }).then(data => {
1422
1434
  this._serviceMetadata = data;
1423
1435
  resolve(data);
1424
1436
  }).catch(err => reject(err));
@@ -1429,41 +1441,7 @@ class TiledMapService {
1429
1441
  // This is a no-op to satisfy the common service interface.
1430
1442
  }
1431
1443
  remove() {
1432
- // Guard against disposed or invalid map
1433
- if (!this._map || typeof this._map.removeSource !== 'function') {
1434
- return;
1435
- }
1436
- try {
1437
- // Guard against map whose style has already been destroyed
1438
- if (!this._map.style) return;
1439
- // First, remove any layers that are using this source
1440
- const mapWithStyle = this._map;
1441
- if (mapWithStyle.getStyle && typeof mapWithStyle.getLayer === 'function') {
1442
- const style = mapWithStyle.getStyle();
1443
- const layers = style?.layers || [];
1444
- const getLayer = mapWithStyle.getLayer;
1445
- layers.forEach(layer => {
1446
- if (layer.source === this._sourceId) {
1447
- try {
1448
- if (getLayer(layer.id)) {
1449
- this._map.removeLayer(layer.id);
1450
- }
1451
- } catch {
1452
- // Layer may already be removed
1453
- }
1454
- }
1455
- });
1456
- }
1457
- // Then check if source exists before trying to remove it
1458
- if (typeof this._map.getSource === 'function') {
1459
- const source = this._map.getSource(this._sourceId);
1460
- if (source) {
1461
- this._map.removeSource(this._sourceId);
1462
- }
1463
- }
1464
- } catch (error) {
1465
- console.warn(`Failed to remove source ${this._sourceId}:`, error);
1466
- }
1444
+ removeMapSource(this._map, this._sourceId);
1467
1445
  }
1468
1446
  }
1469
1447
 
@@ -1559,9 +1537,9 @@ class ImageService {
1559
1537
  if (this._time) params.append('time', this._time);
1560
1538
  if (this.options.mosaicRule) params.append('mosaicRule', JSON.stringify(this.options.mosaicRule));
1561
1539
  if (this.options.renderingRule) params.append('renderingRule', JSON.stringify(this.options.renderingRule));
1562
- if (this.esriServiceOptions.token) {
1563
- params.append('token', this.esriServiceOptions.token);
1564
- }
1540
+ // apiKey is sent as the token URL parameter, matching the request-based paths.
1541
+ const auth = this.esriServiceOptions;
1542
+ appendTokenIfExists(params, auth.token ?? auth.apiKey);
1565
1543
  return {
1566
1544
  type: 'raster',
1567
1545
  tiles: [`${this.options.url}/exportImage?bbox={bbox-epsg-3857}&${params.toString()}`],
@@ -1625,8 +1603,17 @@ class ImageService {
1625
1603
  }
1626
1604
  getMetadata() {
1627
1605
  if (this._serviceMetadata !== null) return Promise.resolve(this._serviceMetadata);
1606
+ const {
1607
+ token,
1608
+ apiKey,
1609
+ authentication
1610
+ } = this.esriServiceOptions;
1628
1611
  return new Promise((resolve, reject) => {
1629
- getServiceDetails(this.esriServiceOptions.url, this.esriServiceOptions.fetchOptions, this.esriServiceOptions.token).then(data => {
1612
+ getServiceDetails(this.esriServiceOptions.url, {
1613
+ token,
1614
+ apiKey,
1615
+ authentication
1616
+ }).then(data => {
1630
1617
  this._serviceMetadata = data;
1631
1618
  resolve(this._serviceMetadata);
1632
1619
  }).catch(err => reject(err));
@@ -1637,7 +1624,7 @@ class ImageService {
1637
1624
  const canvas = this._map.getCanvas();
1638
1625
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1639
1626
  const bounds = this._map.getBounds().toArray();
1640
- const params = new URLSearchParams({
1627
+ const params = {
1641
1628
  sr: '4326',
1642
1629
  geometryType: 'esriGeometryPoint',
1643
1630
  geometry: JSON.stringify({
@@ -1650,50 +1637,26 @@ class ImageService {
1650
1637
  tolerance: '3',
1651
1638
  returnGeometry: returnGeometry.toString(),
1652
1639
  imageDisplay: `${canvas.width},${canvas.height},${this.options.dpi}`,
1653
- mapExtent: `${bounds[0][0]},${bounds[0][1]},${bounds[1][0]},${bounds[1][1]}`,
1654
- f: 'json'
1640
+ mapExtent: `${bounds[0][0]},${bounds[0][1]},${bounds[1][0]},${bounds[1][1]}`
1641
+ };
1642
+ if (this._time) params.time = this._time;
1643
+ const {
1644
+ token,
1645
+ apiKey,
1646
+ authentication
1647
+ } = this.esriServiceOptions;
1648
+ return esriRequest(`${this.esriServiceOptions.url}/identify`, {
1649
+ params,
1650
+ token,
1651
+ apiKey,
1652
+ authentication
1655
1653
  });
1656
- if (this._time) params.append('time', this._time);
1657
- if (this.esriServiceOptions.token) {
1658
- params.append('token', this.esriServiceOptions.token);
1659
- }
1660
- const response = await fetch(`${this.esriServiceOptions.url}/identify?${params.toString()}`, this.esriServiceOptions.fetchOptions);
1661
- if (!response.ok) {
1662
- throw new Error(`Identify request failed: HTTP ${response.status}`);
1663
- }
1664
- const data = await response.json();
1665
- if (data.error) {
1666
- throw new Error(`Identify request failed: ${data.error.message}`);
1667
- }
1668
- return data;
1669
1654
  }
1670
1655
  update() {
1671
1656
  this._updateSource();
1672
1657
  }
1673
1658
  remove() {
1674
- if (this._map && typeof this._map.removeSource === 'function') {
1675
- try {
1676
- // Guard against map whose style has already been destroyed
1677
- const mapWithStyle = this._map;
1678
- if (!mapWithStyle.style) return;
1679
- // First, remove any layers that are using this source
1680
- if (mapWithStyle.getStyle) {
1681
- const style = mapWithStyle.getStyle();
1682
- const layers = style?.layers || [];
1683
- layers.forEach(layer => {
1684
- if (layer.source === this._sourceId && this._map.getLayer(layer.id)) {
1685
- this._map.removeLayer(layer.id);
1686
- }
1687
- });
1688
- }
1689
- // Then check if source exists before trying to remove it
1690
- if (this._map.getSource && this._map.getSource(this._sourceId)) {
1691
- this._map.removeSource(this._sourceId);
1692
- }
1693
- } catch (error) {
1694
- console.warn(`Failed to remove source ${this._sourceId}:`, error);
1695
- }
1696
- }
1659
+ removeMapSource(this._map, this._sourceId);
1697
1660
  }
1698
1661
  }
1699
1662
 
@@ -1767,6 +1730,24 @@ class VectorBasemapStyle {
1767
1730
  writable: true,
1768
1731
  value: void 0
1769
1732
  });
1733
+ Object.defineProperty(this, "_useSession", {
1734
+ enumerable: true,
1735
+ configurable: true,
1736
+ writable: true,
1737
+ value: void 0
1738
+ });
1739
+ Object.defineProperty(this, "_sessionDuration", {
1740
+ enumerable: true,
1741
+ configurable: true,
1742
+ writable: true,
1743
+ value: void 0
1744
+ });
1745
+ Object.defineProperty(this, "_session", {
1746
+ enumerable: true,
1747
+ configurable: true,
1748
+ writable: true,
1749
+ value: void 0
1750
+ });
1770
1751
  let opts;
1771
1752
  if (typeof auth === 'string') {
1772
1753
  opts = {
@@ -1783,6 +1764,8 @@ class VectorBasemapStyle {
1783
1764
  this._language = opts.language;
1784
1765
  this._worldview = opts.worldview;
1785
1766
  this._itemId = opts.itemId;
1767
+ this._useSession = opts.useSession ?? false;
1768
+ this._sessionDuration = opts.sessionDuration;
1786
1769
  // Infer version: token => v2, else apiKey => v1
1787
1770
  this._version = opts.version || (this._token ? 'v2' : 'v1');
1788
1771
  // Determine host based on version if not overridden
@@ -1847,6 +1830,71 @@ class VectorBasemapStyle {
1847
1830
  const vectorStyle = new VectorBasemapStyle(styleName, auth);
1848
1831
  map.setStyle(vectorStyle.styleUrl);
1849
1832
  }
1833
+ /**
1834
+ * The style family for the official basemap sessions API, derived from the
1835
+ * canonical style id. esri-gl styles are all in the `arcgis` family.
1836
+ */
1837
+ get _styleFamily() {
1838
+ return this._canonical.startsWith('open/') ? 'open' : 'arcgis';
1839
+ }
1840
+ /**
1841
+ * The token from the active session, if a session has been started. Useful
1842
+ * for inspection / debugging. Returns `undefined` until `startSession()` runs.
1843
+ */
1844
+ get sessionToken() {
1845
+ return this._session?.token;
1846
+ }
1847
+ /**
1848
+ * Start (or reuse) an official basemap style session via
1849
+ * `@esri/arcgis-rest-basemap-sessions`. Requires an apiKey or token. The
1850
+ * resulting session is cached so repeated calls return the same instance.
1851
+ */
1852
+ async startSession() {
1853
+ if (this._session) return this._session;
1854
+ const authentication = resolveAuthentication({
1855
+ apiKey: this._apiKey,
1856
+ token: this._token
1857
+ });
1858
+ if (!authentication) {
1859
+ throw new Error('An Esri API Key or token must be supplied to start a basemap style session');
1860
+ }
1861
+ this._session = await BasemapStyleSession.start({
1862
+ authentication,
1863
+ styleFamily: this._styleFamily,
1864
+ ...(this._sessionDuration !== undefined ? {
1865
+ duration: this._sessionDuration
1866
+ } : {})
1867
+ });
1868
+ return this._session;
1869
+ }
1870
+ /**
1871
+ * Resolve the style URL to apply. When `useSession` is enabled this starts a
1872
+ * session and returns a v2 style URL backed by the session token; otherwise it
1873
+ * returns the synchronous `styleUrl`.
1874
+ */
1875
+ async getStyleUrl() {
1876
+ if (!this._useSession) return this.styleUrl;
1877
+ const session = await this.startSession();
1878
+ const token = await session.getToken();
1879
+ const params = new URLSearchParams();
1880
+ params.set('f', this._format);
1881
+ params.set('token', token);
1882
+ if (this._language) params.set('language', this._language);
1883
+ if (this._worldview) params.set('worldview', this._worldview);
1884
+ return `https://basemapstyles-api.arcgis.com/arcgis/rest/services/styles/v2/styles/${this._canonical}?${params.toString()}`;
1885
+ }
1886
+ /**
1887
+ * Apply a basemap style to a MapLibre/Mapbox map using a session-backed style
1888
+ * URL. Mirrors {@link applyStyle} but starts a basemap style session first.
1889
+ */
1890
+ static async applyStyleWithSession(map, styleName, auth) {
1891
+ const vectorStyle = new VectorBasemapStyle(styleName, {
1892
+ ...auth,
1893
+ useSession: true
1894
+ });
1895
+ const url = await vectorStyle.getStyleUrl();
1896
+ map.setStyle(url);
1897
+ }
1850
1898
  static _toCanonical(name) {
1851
1899
  if (!name) return 'arcgis/streets';
1852
1900
  // Already slash form
@@ -1994,10 +2042,17 @@ class VectorTileService {
1994
2042
  });
1995
2043
  }
1996
2044
  _retrieveStyle() {
2045
+ const {
2046
+ token,
2047
+ apiKey,
2048
+ authentication
2049
+ } = this.esriServiceOptions;
1997
2050
  return new Promise((resolve, reject) => {
1998
- fetch(`${this.options.url}/${this._styleUrl}`, this.esriServiceOptions.fetchOptions).then(response => {
1999
- if (!response.ok) throw new Error(`Failed to fetch style: ${response.status}`);
2000
- return response.json();
2051
+ esriRequest(`${this.options.url}/${this._styleUrl}`, {
2052
+ httpMethod: 'GET',
2053
+ token,
2054
+ apiKey,
2055
+ authentication
2001
2056
  }).then(data => {
2002
2057
  if (!data || !Array.isArray(data.layers) || data.layers.length === 0) {
2003
2058
  throw new Error('VectorTile style document is missing layers.');
@@ -2010,8 +2065,17 @@ class VectorTileService {
2010
2065
  }
2011
2066
  getMetadata() {
2012
2067
  if (this._serviceMetadata !== null) return Promise.resolve(this._serviceMetadata);
2068
+ const {
2069
+ token,
2070
+ apiKey,
2071
+ authentication
2072
+ } = this.esriServiceOptions;
2013
2073
  return new Promise((resolve, reject) => {
2014
- getServiceDetails(this.esriServiceOptions.url, this.esriServiceOptions.fetchOptions).then(data => {
2074
+ getServiceDetails(this.esriServiceOptions.url, {
2075
+ token,
2076
+ apiKey,
2077
+ authentication
2078
+ }).then(data => {
2015
2079
  this._serviceMetadata = data;
2016
2080
  resolve(this._serviceMetadata);
2017
2081
  }).catch(err => reject(err));
@@ -2021,56 +2085,7 @@ class VectorTileService {
2021
2085
  // Vector tile services don't need dynamic updates like dynamic services
2022
2086
  }
2023
2087
  remove() {
2024
- const map = this._map;
2025
- if (!map || typeof map.removeSource !== 'function') {
2026
- return;
2027
- }
2028
- try {
2029
- // Guard against map whose style has already been destroyed
2030
- if (!map.style) return;
2031
- const mapWithStyle = map;
2032
- const mapLayerApi = map;
2033
- const mapSourceApi = map;
2034
- if (typeof mapWithStyle.getStyle === 'function') {
2035
- const style = mapWithStyle.getStyle();
2036
- const layers = style?.layers || [];
2037
- layers.forEach(layer => {
2038
- if (layer.source !== this._sourceId) return;
2039
- if (typeof mapLayerApi.getLayer !== 'function' || typeof mapLayerApi.removeLayer !== 'function') {
2040
- return;
2041
- }
2042
- let hasLayer = false;
2043
- try {
2044
- hasLayer = Boolean(mapLayerApi.getLayer(layer.id));
2045
- } catch {
2046
- hasLayer = false;
2047
- }
2048
- if (!hasLayer) return;
2049
- try {
2050
- mapLayerApi.removeLayer(layer.id);
2051
- } catch (error) {
2052
- console.warn(`Failed to remove layer ${layer.id} for source ${this._sourceId}:`, error);
2053
- }
2054
- });
2055
- }
2056
- if (typeof mapSourceApi.getSource === 'function') {
2057
- let hasSource = false;
2058
- try {
2059
- hasSource = Boolean(mapSourceApi.getSource(this._sourceId));
2060
- } catch {
2061
- hasSource = false;
2062
- }
2063
- if (hasSource) {
2064
- try {
2065
- map.removeSource(this._sourceId);
2066
- } catch (error) {
2067
- console.warn(`Failed to remove source ${this._sourceId}:`, error);
2068
- }
2069
- }
2070
- }
2071
- } catch (error) {
2072
- console.warn(`Failed to remove source ${this._sourceId}:`, error);
2073
- }
2088
+ removeMapSource(this._map, this._sourceId);
2074
2089
  }
2075
2090
  }
2076
2091
 
@@ -2149,6 +2164,12 @@ class FeatureService {
2149
2164
  writable: true,
2150
2165
  value: null
2151
2166
  });
2167
+ Object.defineProperty(this, "_removed", {
2168
+ enumerable: true,
2169
+ configurable: true,
2170
+ writable: true,
2171
+ value: false
2172
+ });
2152
2173
  Object.defineProperty(this, "_format", {
2153
2174
  enumerable: true,
2154
2175
  configurable: true,
@@ -2292,29 +2313,9 @@ class FeatureService {
2292
2313
  * Remove the source and clean up event listeners
2293
2314
  */
2294
2315
  remove() {
2316
+ this._removed = true;
2295
2317
  this.disableRequests();
2296
- if (this._map && typeof this._map.removeSource === 'function') {
2297
- try {
2298
- // Guard against map whose style has already been destroyed
2299
- const mapWithStyle = this._map;
2300
- if (!mapWithStyle.style) return;
2301
- // First, remove any layers that are using this source
2302
- if (mapWithStyle.getStyle) {
2303
- const style = mapWithStyle.getStyle();
2304
- const layers = style?.layers || [];
2305
- layers.forEach(layer => {
2306
- if (layer.source === this._sourceId && this._map.getLayer && this._map.getLayer(layer.id)) {
2307
- this._map.removeLayer(layer.id);
2308
- }
2309
- });
2310
- }
2311
- if (this._map.getSource && this._map.getSource(this._sourceId)) {
2312
- this._map.removeSource(this._sourceId);
2313
- }
2314
- } catch (error) {
2315
- console.warn(`Failed to remove source ${this._sourceId}:`, error);
2316
- }
2317
- }
2318
+ removeMapSource(this._map, this._sourceId);
2318
2319
  }
2319
2320
  /** Alias for remove() for API compatibility */
2320
2321
  destroySource() {
@@ -2485,6 +2486,7 @@ class FeatureService {
2485
2486
  return newFeatureIdIndex;
2486
2487
  }
2487
2488
  async _findAndMapData() {
2489
+ if (this._removed) return;
2488
2490
  const z = this._map.getZoom();
2489
2491
  if (z < this._esriServiceOptions.minZoom) {
2490
2492
  return;
@@ -2583,7 +2585,7 @@ class FeatureService {
2583
2585
  xmax: tileBounds[2],
2584
2586
  ymax: tileBounds[3]
2585
2587
  };
2586
- const params = new URLSearchParams({
2588
+ const params = {
2587
2589
  f: this._format,
2588
2590
  geometry: JSON.stringify(extent),
2589
2591
  where: this._esriServiceOptions.where,
@@ -2601,18 +2603,24 @@ class FeatureService {
2601
2603
  spatialRel: 'esriSpatialRelIntersects',
2602
2604
  geometryType: 'esriGeometryEnvelope',
2603
2605
  inSR: '4326'
2604
- });
2606
+ };
2605
2607
  if (this._time) {
2606
- params.append('time', this._time);
2608
+ params.time = this._time;
2607
2609
  }
2608
- this._appendTokenIfExists(params);
2610
+ const {
2611
+ token,
2612
+ apiKey,
2613
+ authentication
2614
+ } = this._authOptions();
2615
+ const queryUrl = `${this._esriServiceOptions.url}/query`;
2609
2616
  try {
2610
- const response = await fetch(`${this._esriServiceOptions.url}/query?${params.toString()}`, this._esriServiceOptions.fetchOptions);
2611
- if (!response.ok) {
2612
- console.warn(`Tile fetch failed: HTTP ${response.status}`);
2613
- return null;
2614
- }
2615
2617
  if (this._format === 'pbf') {
2618
+ const response = await esriRawRequest(queryUrl, {
2619
+ params,
2620
+ token,
2621
+ apiKey,
2622
+ authentication
2623
+ });
2616
2624
  const buffer = await response.arrayBuffer();
2617
2625
  try {
2618
2626
  const decoded = tileDecode(new Uint8Array(buffer));
@@ -2622,8 +2630,12 @@ class FeatureService {
2622
2630
  return null;
2623
2631
  }
2624
2632
  } else {
2625
- const data = await response.json();
2626
- this._checkAgolError(data);
2633
+ const data = await esriRequest(queryUrl, {
2634
+ params,
2635
+ token,
2636
+ apiKey,
2637
+ authentication
2638
+ });
2627
2639
  return data;
2628
2640
  }
2629
2641
  } catch (error) {
@@ -2633,8 +2645,17 @@ class FeatureService {
2633
2645
  }
2634
2646
  }
2635
2647
  _updateFcOnMap(fc) {
2636
- const source = this._map.getSource(this._sourceId);
2637
- if (source && 'setData' in source) {
2648
+ // Guard against an in-flight tile request resolving after the service/map
2649
+ // was removed (e.g. a moveend handler firing during unmount).
2650
+ const map = this._map;
2651
+ if (this._removed || !map || !map.style || typeof map.getSource !== 'function') return;
2652
+ let source;
2653
+ try {
2654
+ source = map.getSource(this._sourceId);
2655
+ } catch {
2656
+ return;
2657
+ }
2658
+ if (source && typeof source === 'object' && 'setData' in source) {
2638
2659
  source.setData(fc);
2639
2660
  }
2640
2661
  }
@@ -2648,18 +2669,13 @@ class FeatureService {
2648
2669
  }
2649
2670
  async _getServiceMetadata() {
2650
2671
  if (this._serviceMetadata !== null) return this._serviceMetadata;
2651
- const params = new URLSearchParams({
2652
- f: 'json'
2672
+ // The FeatureService url is a layer endpoint, so the layer definition
2673
+ // (supportedQueryFormats, uniqueIdField, extent, geometryType, …) comes
2674
+ // from the typed `getLayer` helper.
2675
+ const data = await getLayer({
2676
+ url: this._esriServiceOptions.url,
2677
+ authentication: this._authentication()
2653
2678
  });
2654
- this._appendTokenIfExists(params);
2655
- const response = await fetch(`${this._esriServiceOptions.url}?${params.toString()}`, this._esriServiceOptions.fetchOptions);
2656
- if (!response.ok) {
2657
- throw new Error(`Failed to fetch service metadata: HTTP ${response.status}`);
2658
- }
2659
- const data = await response.json();
2660
- if (data.error) {
2661
- throw new Error(JSON.stringify(data.error));
2662
- }
2663
2679
  this._serviceMetadata = data;
2664
2680
  return this._serviceMetadata;
2665
2681
  }
@@ -2667,7 +2683,7 @@ class FeatureService {
2667
2683
  * Query features by longitude/latitude with optional radius
2668
2684
  */
2669
2685
  async getFeaturesByLonLat(lnglat, radius = 20, returnGeometry = false) {
2670
- const params = new URLSearchParams({
2686
+ const params = {
2671
2687
  sr: '4326',
2672
2688
  geometryType: 'esriGeometryPoint',
2673
2689
  geometry: JSON.stringify({
@@ -2677,41 +2693,41 @@ class FeatureService {
2677
2693
  wkid: 4326
2678
2694
  }
2679
2695
  }),
2680
- returnGeometry: returnGeometry.toString(),
2696
+ returnGeometry,
2681
2697
  outFields: '*',
2682
2698
  spatialRel: 'esriSpatialRelIntersects',
2683
2699
  units: 'esriSRUnit_Meter',
2684
- distance: radius.toString(),
2700
+ distance: radius,
2685
2701
  f: 'geojson'
2686
- });
2702
+ };
2687
2703
  if (this._time) {
2688
- params.append('time', this._time);
2704
+ params.time = this._time;
2689
2705
  }
2690
- this._appendTokenIfExists(params);
2691
- const response = await fetch(`${this._esriServiceOptions.url}/query?${params.toString()}`, this._esriServiceOptions.fetchOptions);
2692
- if (!response.ok) {
2693
- throw new Error(`Query failed: HTTP ${response.status}`);
2694
- }
2695
- return await response.json();
2706
+ const result = await queryFeatures({
2707
+ url: this._esriServiceOptions.url,
2708
+ params,
2709
+ authentication: this._authentication()
2710
+ });
2711
+ return result;
2696
2712
  }
2697
2713
  /**
2698
2714
  * Query features by object IDs
2699
2715
  */
2700
2716
  async getFeaturesByObjectIds(objectIds, returnGeometry = false) {
2701
2717
  const idsString = Array.isArray(objectIds) ? objectIds.join(',') : objectIds;
2702
- const params = new URLSearchParams({
2718
+ const params = {
2703
2719
  sr: '4326',
2704
2720
  objectIds: idsString,
2705
- returnGeometry: returnGeometry.toString(),
2721
+ returnGeometry,
2706
2722
  outFields: '*',
2707
2723
  f: 'geojson'
2724
+ };
2725
+ const result = await queryFeatures({
2726
+ url: this._esriServiceOptions.url,
2727
+ params,
2728
+ authentication: this._authentication()
2708
2729
  });
2709
- this._appendTokenIfExists(params);
2710
- const response = await fetch(`${this._esriServiceOptions.url}/query?${params.toString()}`, this._esriServiceOptions.fetchOptions);
2711
- if (!response.ok) {
2712
- throw new Error(`Query failed: HTTP ${response.status}`);
2713
- }
2714
- return await response.json();
2730
+ return result;
2715
2731
  }
2716
2732
  /**
2717
2733
  * Query features with custom options
@@ -2721,31 +2737,31 @@ class FeatureService {
2721
2737
  ...this._esriServiceOptions,
2722
2738
  ...options
2723
2739
  };
2724
- const params = new URLSearchParams();
2725
- params.append('f', 'geojson');
2726
- params.append('where', queryOptions.where || '1=1');
2727
- params.append('outFields', typeof queryOptions.outFields === 'string' ? queryOptions.outFields : queryOptions.outFields?.join(',') || '*');
2728
- params.append('returnGeometry', (queryOptions.returnGeometry !== false).toString());
2740
+ const params = {};
2741
+ params.f = 'geojson';
2742
+ params.where = queryOptions.where || '1=1';
2743
+ params.outFields = typeof queryOptions.outFields === 'string' ? queryOptions.outFields : queryOptions.outFields?.join(',') || '*';
2744
+ params.returnGeometry = (queryOptions.returnGeometry !== false).toString();
2729
2745
  if (queryOptions.geometry) {
2730
- params.append('geometry', JSON.stringify(queryOptions.geometry));
2731
- if (queryOptions.geometryType) params.append('geometryType', queryOptions.geometryType);
2732
- if (queryOptions.spatialRel) params.append('spatialRel', queryOptions.spatialRel);
2733
- if (queryOptions.inSR) params.append('inSR', queryOptions.inSR);
2734
- }
2735
- if (queryOptions.outSR) params.append('outSR', queryOptions.outSR);
2736
- if (queryOptions.orderByFields) params.append('orderByFields', queryOptions.orderByFields);
2737
- if (queryOptions.groupByFieldsForStatistics) params.append('groupByFieldsForStatistics', queryOptions.groupByFieldsForStatistics);
2738
- if (queryOptions.outStatistics && queryOptions.outStatistics.length > 0) params.append('outStatistics', JSON.stringify(queryOptions.outStatistics));
2739
- if (queryOptions.having) params.append('having', queryOptions.having);
2740
- if (queryOptions.resultOffset) params.append('resultOffset', queryOptions.resultOffset.toString());
2741
- if (queryOptions.resultRecordCount) params.append('resultRecordCount', queryOptions.resultRecordCount.toString());
2742
- if (queryOptions.token) params.append('token', queryOptions.token);
2746
+ params.geometry = JSON.stringify(queryOptions.geometry);
2747
+ if (queryOptions.geometryType) params.geometryType = queryOptions.geometryType;
2748
+ if (queryOptions.spatialRel) params.spatialRel = queryOptions.spatialRel;
2749
+ if (queryOptions.inSR) params.inSR = queryOptions.inSR;
2750
+ }
2751
+ if (queryOptions.outSR) params.outSR = queryOptions.outSR;
2752
+ if (queryOptions.orderByFields) params.orderByFields = queryOptions.orderByFields;
2753
+ if (queryOptions.groupByFieldsForStatistics) params.groupByFieldsForStatistics = queryOptions.groupByFieldsForStatistics;
2754
+ if (queryOptions.outStatistics && queryOptions.outStatistics.length > 0) params.outStatistics = JSON.stringify(queryOptions.outStatistics);
2755
+ if (queryOptions.having) params.having = queryOptions.having;
2756
+ if (queryOptions.resultOffset) params.resultOffset = queryOptions.resultOffset.toString();
2757
+ if (queryOptions.resultRecordCount) params.resultRecordCount = queryOptions.resultRecordCount.toString();
2743
2758
  try {
2744
- const response = await fetch(`${this._esriServiceOptions.url}/query?${params.toString()}`, this._esriServiceOptions.fetchOptions);
2745
- if (!response.ok) {
2746
- throw new Error(`HTTP error! status: ${response.status}`);
2747
- }
2748
- return await response.json();
2759
+ const result = await queryFeatures({
2760
+ url: this._esriServiceOptions.url,
2761
+ params,
2762
+ authentication: this._authentication()
2763
+ });
2764
+ return result;
2749
2765
  } catch (error) {
2750
2766
  const isTestEnvironment = typeof process !== 'undefined' && process.env?.NODE_ENV === 'test';
2751
2767
  if (!isTestEnvironment) {
@@ -2756,7 +2772,7 @@ class FeatureService {
2756
2772
  }
2757
2773
  async _projectBounds() {
2758
2774
  if (!this._serviceMetadata?.extent) return;
2759
- const params = new URLSearchParams({
2775
+ const params = {
2760
2776
  geometries: JSON.stringify({
2761
2777
  geometryType: 'esriGeometryEnvelope',
2762
2778
  geometries: [this._serviceMetadata.extent]
@@ -2764,22 +2780,15 @@ class FeatureService {
2764
2780
  inSR: (this._serviceMetadata.extent.spatialReference?.wkid || 4326).toString(),
2765
2781
  outSR: '4326',
2766
2782
  f: 'json'
2767
- });
2768
- let fetchOptions = this._esriServiceOptions.fetchOptions;
2769
- if (!this._projectionEndpointIsFallback()) {
2770
- this._appendTokenIfExists(params);
2771
- } else {
2772
- fetchOptions = undefined;
2773
- }
2783
+ };
2784
+ // The public fallback projection endpoint is anonymous; only forward auth
2785
+ // to the service's own projection endpoint.
2786
+ const auth = this._projectionEndpointIsFallback() ? {} : this._authOptions();
2774
2787
  try {
2775
- const response = await fetch(`${this._esriServiceOptions.projectionEndpoint}?${params.toString()}`, fetchOptions);
2776
- if (!response.ok) {
2777
- throw new Error(`Projection failed: HTTP ${response.status}`);
2778
- }
2779
- const data = await response.json();
2780
- if (data.error) {
2781
- throw new Error(JSON.stringify(data.error));
2782
- }
2788
+ const data = await esriRequest(this._esriServiceOptions.projectionEndpoint, {
2789
+ params,
2790
+ ...auth
2791
+ });
2783
2792
  const extent = data.geometries[0];
2784
2793
  this._maxExtent = [extent.xmin, extent.ymin, extent.xmax, extent.ymax];
2785
2794
  } catch (error) {
@@ -2805,28 +2814,22 @@ class FeatureService {
2805
2814
  updateAttribution('', this._sourceId, this._map);
2806
2815
  }
2807
2816
  }
2808
- _appendTokenIfExists(params) {
2809
- const token = this._esriServiceOptions.token;
2810
- if (token) {
2811
- params.append('token', token);
2812
- }
2813
- }
2814
- _getFetchHeaders() {
2815
- if (this._esriServiceOptions.apiKey) {
2816
- return {
2817
- 'X-Esri-Authorization': `Bearer ${this._esriServiceOptions.apiKey}`
2818
- };
2819
- }
2820
- return undefined;
2817
+ /**
2818
+ * Read the authentication-related fields off the service options. The
2819
+ * `authentication` field is not part of the public options type, so the
2820
+ * options object is cast to surface it for {@link resolveAuthentication}.
2821
+ */
2822
+ _authOptions() {
2823
+ const opts = this._esriServiceOptions;
2824
+ return {
2825
+ token: opts.token,
2826
+ apiKey: opts.apiKey,
2827
+ authentication: opts.authentication
2828
+ };
2821
2829
  }
2822
- _checkAgolError(data) {
2823
- if (data && typeof data === 'object' && 'error' in data) {
2824
- const errorData = data.error;
2825
- const err = new Error(errorData.message || 'ArcGIS service error');
2826
- err.code = errorData.code;
2827
- err.details = errorData.details;
2828
- throw err;
2829
- }
2830
+ /** Build an ArcGIS REST JS authentication manager from the service options. */
2831
+ _authentication() {
2832
+ return resolveAuthentication(this._authOptions());
2830
2833
  }
2831
2834
  _handleAuthError(error) {
2832
2835
  if (error && typeof error === 'object' && 'code' in error) {
@@ -2868,104 +2871,101 @@ class FeatureService {
2868
2871
  return this;
2869
2872
  }
2870
2873
  // ========================================
2874
+ // Related Records & Domain Decoding
2875
+ // ========================================
2876
+ /**
2877
+ * Query records related to this layer's features through a relationship
2878
+ * class. Wraps `queryRelated` from `@esri/arcgis-rest-feature-service`.
2879
+ */
2880
+ async queryRelatedRecords(options) {
2881
+ return queryRelated({
2882
+ url: this._esriServiceOptions.url,
2883
+ relationshipId: options.relationshipId,
2884
+ objectIds: options.objectIds,
2885
+ outFields: options.outFields,
2886
+ definitionExpression: options.definitionExpression,
2887
+ // `returnGeometry` is not a top-level queryRelated option; pass via params.
2888
+ params: options.returnGeometry !== undefined ? {
2889
+ returnGeometry: options.returnGeometry
2890
+ } : undefined,
2891
+ authentication: this._authentication()
2892
+ });
2893
+ }
2894
+ /**
2895
+ * Replace coded-value-domain codes with their human-readable names in a
2896
+ * query response. Wraps `decodeValues` from
2897
+ * `@esri/arcgis-rest-feature-service`.
2898
+ *
2899
+ * @param queryResponse A response from a `f=json` feature query.
2900
+ * @param fields Optional subset of field names to decode (defaults to all).
2901
+ */
2902
+ async decodeValues(queryResponse, fields) {
2903
+ return decodeValues({
2904
+ url: this._esriServiceOptions.url,
2905
+ queryResponse: queryResponse,
2906
+ fields: fields,
2907
+ authentication: this._authentication()
2908
+ });
2909
+ }
2910
+ // ========================================
2871
2911
  // Feature Editing Methods
2872
2912
  // ========================================
2873
2913
  /**
2874
2914
  * Add features to the service
2875
2915
  */
2876
2916
  async addFeatures(features, options) {
2877
- const params = new URLSearchParams({
2878
- f: 'json',
2879
- features: JSON.stringify(features)
2880
- });
2881
- if (options?.gdbVersion) params.append('gdbVersion', options.gdbVersion);
2882
- this._appendTokenIfExists(params);
2883
- const response = await fetch(`${this._esriServiceOptions.url}/addFeatures`, {
2884
- method: 'POST',
2885
- body: params,
2886
- headers: this._getFetchHeaders(),
2887
- ...this._esriServiceOptions.fetchOptions
2917
+ const res = await addFeatures({
2918
+ url: this._esriServiceOptions.url,
2919
+ features: features,
2920
+ gdbVersion: options?.gdbVersion,
2921
+ authentication: this._authentication()
2888
2922
  });
2889
- if (!response.ok) {
2890
- throw new Error(`Add features failed: HTTP ${response.status}`);
2891
- }
2892
- const data = await response.json();
2893
- this._checkAgolError(data);
2894
- return data.addResults;
2923
+ return res.addResults;
2895
2924
  }
2896
2925
  /**
2897
2926
  * Update existing features
2898
2927
  */
2899
2928
  async updateFeatures(features, options) {
2900
- const params = new URLSearchParams({
2901
- f: 'json',
2902
- features: JSON.stringify(features)
2929
+ const res = await updateFeatures({
2930
+ url: this._esriServiceOptions.url,
2931
+ features: features,
2932
+ gdbVersion: options?.gdbVersion,
2933
+ authentication: this._authentication()
2903
2934
  });
2904
- if (options?.gdbVersion) params.append('gdbVersion', options.gdbVersion);
2905
- this._appendTokenIfExists(params);
2906
- const response = await fetch(`${this._esriServiceOptions.url}/updateFeatures`, {
2907
- method: 'POST',
2908
- body: params,
2909
- headers: this._getFetchHeaders(),
2910
- ...this._esriServiceOptions.fetchOptions
2911
- });
2912
- if (!response.ok) {
2913
- throw new Error(`Update features failed: HTTP ${response.status}`);
2914
- }
2915
- const data = await response.json();
2916
- this._checkAgolError(data);
2917
- return data.updateResults;
2935
+ return res.updateResults;
2918
2936
  }
2919
2937
  /**
2920
2938
  * Delete features by objectIds or where clause
2921
2939
  */
2922
2940
  async deleteFeatures(deleteParams) {
2923
- const params = new URLSearchParams({
2924
- f: 'json'
2925
- });
2926
- if (deleteParams.objectIds) {
2927
- params.append('objectIds', deleteParams.objectIds.join(','));
2928
- }
2929
- if (deleteParams.where) {
2930
- params.append('where', deleteParams.where);
2931
- }
2932
- this._appendTokenIfExists(params);
2933
- const response = await fetch(`${this._esriServiceOptions.url}/deleteFeatures`, {
2934
- method: 'POST',
2935
- body: params,
2936
- headers: this._getFetchHeaders(),
2937
- ...this._esriServiceOptions.fetchOptions
2941
+ // Pass objectIds / where as first-order fields so arcgis-rest encodes them.
2942
+ // Only include each when supplied: deletion by `where` alone must not send
2943
+ // an empty objectIds list, and a hardcoded `objectIds: []` would (being
2944
+ // truthy) override a supplied `where`/objectIds via appendCustomParams.
2945
+ const res = await deleteFeatures({
2946
+ url: this._esriServiceOptions.url,
2947
+ authentication: this._authentication(),
2948
+ ...(deleteParams.objectIds ? {
2949
+ objectIds: deleteParams.objectIds
2950
+ } : {}),
2951
+ ...(deleteParams.where ? {
2952
+ where: deleteParams.where
2953
+ } : {})
2938
2954
  });
2939
- if (!response.ok) {
2940
- throw new Error(`Delete features failed: HTTP ${response.status}`);
2941
- }
2942
- const data = await response.json();
2943
- this._checkAgolError(data);
2944
- return data.deleteResults;
2955
+ return res.deleteResults;
2945
2956
  }
2946
2957
  /**
2947
2958
  * Apply multiple edits in a single transaction
2948
2959
  */
2949
2960
  async applyEdits(edits, options) {
2950
- const params = new URLSearchParams({
2951
- f: 'json'
2961
+ const data = await applyEdits({
2962
+ url: this._esriServiceOptions.url,
2963
+ adds: edits.adds,
2964
+ updates: edits.updates,
2965
+ deletes: edits.deletes,
2966
+ gdbVersion: options?.gdbVersion,
2967
+ authentication: this._authentication()
2952
2968
  });
2953
- if (edits.adds) params.append('adds', JSON.stringify(edits.adds));
2954
- if (edits.updates) params.append('updates', JSON.stringify(edits.updates));
2955
- if (edits.deletes) params.append('deletes', edits.deletes.join(','));
2956
- if (options?.gdbVersion) params.append('gdbVersion', options.gdbVersion);
2957
- this._appendTokenIfExists(params);
2958
- const response = await fetch(`${this._esriServiceOptions.url}/applyEdits`, {
2959
- method: 'POST',
2960
- body: params,
2961
- headers: this._getFetchHeaders(),
2962
- ...this._esriServiceOptions.fetchOptions
2963
- });
2964
- if (!response.ok) {
2965
- throw new Error(`Apply edits failed: HTTP ${response.status}`);
2966
- }
2967
- const data = await response.json();
2968
- this._checkAgolError(data);
2969
2969
  return data;
2970
2970
  }
2971
2971
  // ========================================
@@ -2975,71 +2975,45 @@ class FeatureService {
2975
2975
  * Query attachments for a feature
2976
2976
  */
2977
2977
  async queryAttachments(objectId, options) {
2978
- const params = new URLSearchParams({
2979
- f: 'json'
2980
- });
2981
- if (options?.globalIds) {
2982
- params.append('globalIds', options.globalIds.join(','));
2983
- }
2984
- this._appendTokenIfExists(params);
2985
- const response = await fetch(`${this._esriServiceOptions.url}/${objectId}/attachments?${params.toString()}`, {
2986
- headers: this._getFetchHeaders(),
2987
- ...this._esriServiceOptions.fetchOptions
2978
+ const res = await getAttachments({
2979
+ url: this._esriServiceOptions.url,
2980
+ featureId: objectId,
2981
+ params: options?.globalIds ? {
2982
+ globalIds: options.globalIds.join(',')
2983
+ } : undefined,
2984
+ authentication: this._authentication()
2988
2985
  });
2989
- if (!response.ok) {
2990
- throw new Error(`Query attachments failed: HTTP ${response.status}`);
2991
- }
2992
- const data = await response.json();
2993
- this._checkAgolError(data);
2994
- return data.attachmentInfos || [];
2986
+ return res.attachmentInfos || [];
2995
2987
  }
2996
2988
  /**
2997
2989
  * Add an attachment to a feature
2998
2990
  */
2999
2991
  async addAttachment(objectId, file, fileName) {
3000
- const formData = new FormData();
3001
- formData.append('f', 'json');
3002
- formData.append('attachment', file, fileName || (file instanceof File ? file.name : 'attachment'));
3003
- if (this._esriServiceOptions.token) {
3004
- formData.append('token', this._esriServiceOptions.token);
3005
- }
3006
- const headers = {};
3007
- if (this._esriServiceOptions.apiKey) {
3008
- headers['X-Esri-Authorization'] = `Bearer ${this._esriServiceOptions.apiKey}`;
3009
- }
3010
- const response = await fetch(`${this._esriServiceOptions.url}/${objectId}/addAttachment`, {
3011
- method: 'POST',
3012
- body: formData,
3013
- headers: Object.keys(headers).length > 0 ? headers : undefined
2992
+ // The package `addAttachment` only accepts a `File`. Preserve the existing
2993
+ // support for bare `Blob`s and an explicit `fileName` by posting the
2994
+ // attachment ourselves; `esriRequest` handles auth and error responses.
2995
+ const name = fileName || (file instanceof File ? file.name : 'attachment');
2996
+ const res = await esriRequest(`${this._esriServiceOptions.url}/${objectId}/addAttachment`, {
2997
+ params: {
2998
+ attachment: file,
2999
+ fileName: name
3000
+ },
3001
+ httpMethod: 'POST',
3002
+ ...this._authOptions()
3014
3003
  });
3015
- if (!response.ok) {
3016
- throw new Error(`Add attachment failed: HTTP ${response.status}`);
3017
- }
3018
- const data = await response.json();
3019
- this._checkAgolError(data);
3020
- return data.addAttachmentResult;
3004
+ return res.addAttachmentResult;
3021
3005
  }
3022
3006
  /**
3023
3007
  * Delete attachments from a feature
3024
3008
  */
3025
3009
  async deleteAttachments(objectId, attachmentIds) {
3026
- const params = new URLSearchParams({
3027
- f: 'json',
3028
- attachmentIds: attachmentIds.join(',')
3029
- });
3030
- this._appendTokenIfExists(params);
3031
- const response = await fetch(`${this._esriServiceOptions.url}/${objectId}/deleteAttachments`, {
3032
- method: 'POST',
3033
- body: params,
3034
- headers: this._getFetchHeaders(),
3035
- ...this._esriServiceOptions.fetchOptions
3010
+ const res = await deleteAttachments({
3011
+ url: this._esriServiceOptions.url,
3012
+ featureId: objectId,
3013
+ attachmentIds,
3014
+ authentication: this._authentication()
3036
3015
  });
3037
- if (!response.ok) {
3038
- throw new Error(`Delete attachments failed: HTTP ${response.status}`);
3039
- }
3040
- const data = await response.json();
3041
- this._checkAgolError(data);
3042
- return data.deleteAttachmentResults;
3016
+ return res.deleteAttachmentResults;
3043
3017
  }
3044
3018
  // Legacy method aliases for backwards compatibility
3045
3019
  /** @deprecated Use setWhere instead */
@@ -3212,58 +3186,31 @@ class Task {
3212
3186
  });
3213
3187
  }
3214
3188
  /**
3215
- * Direct HTTP request (when not using a service)
3189
+ * Direct HTTP request (when not using a service), delegated to
3190
+ * `@esri/arcgis-rest-request`. Token/apiKey supplied either as task options
3191
+ * or as `token`/`apiKey` params are routed through the auth layer rather than
3192
+ * being sent as raw query parameters.
3216
3193
  */
3217
3194
  _request(method, path, params, callback) {
3218
3195
  if (!this.options.url) {
3219
3196
  callback(new Error('URL is required for task execution'));
3220
3197
  return;
3221
3198
  }
3222
- // Ensure proper URL construction with path separator
3223
- const baseUrl = this.options.url.endsWith('/') ? this.options.url.slice(0, -1) : this.options.url;
3199
+ const baseUrl = cleanTrailingSlash(this.options.url);
3224
3200
  const cleanPath = path.startsWith('/') ? path : `/${path}`;
3225
- const fullServiceUrl = `${baseUrl}${cleanPath}`;
3226
- const url = this.options.proxy ? `${this.options.proxy}?${fullServiceUrl}` : fullServiceUrl;
3227
- // Convert params to URLSearchParams
3228
- const searchParams = new URLSearchParams();
3229
- Object.keys(params).forEach(key => {
3230
- const value = params[key];
3231
- if (value !== undefined && value !== null) {
3232
- if (Array.isArray(value)) {
3233
- searchParams.append(key, value.join(','));
3234
- } else if (typeof value === 'object') {
3235
- searchParams.append(key, JSON.stringify(value));
3236
- } else {
3237
- searchParams.append(key, value.toString());
3238
- }
3239
- }
3240
- });
3241
- const fullUrl = method === 'GET' ? `${url}?${searchParams.toString()}` : url;
3242
- const fetchOptions = {
3243
- method,
3244
- headers: {
3245
- 'Content-Type': 'application/x-www-form-urlencoded'
3246
- }
3247
- };
3248
- if (method === 'POST') {
3249
- fetchOptions.body = searchParams.toString();
3250
- }
3251
- fetch(fullUrl, fetchOptions).then(response => {
3252
- if (!response.ok) {
3253
- throw new Error(`HTTP error! status: ${response.status}`);
3254
- }
3255
- return response.json();
3256
- }).then(data => {
3257
- // Check for AGOL JSON-level errors (HTTP 200 with error body)
3258
- if (data && typeof data === 'object' && data.error) {
3259
- const err = new Error(data.error.message || 'ArcGIS service error');
3260
- err.code = data.error.code;
3261
- err.details = data.error.details;
3262
- callback(err);
3263
- return;
3264
- }
3265
- callback(undefined, data);
3266
- }).catch(error => callback(error));
3201
+ const url = `${baseUrl}${cleanPath}`;
3202
+ const {
3203
+ token,
3204
+ apiKey,
3205
+ ...restParams
3206
+ } = params;
3207
+ esriRequest(url, {
3208
+ params: restParams,
3209
+ httpMethod: method === 'GET' ? 'GET' : 'POST',
3210
+ token: token ?? this.options.token,
3211
+ apiKey: apiKey ?? this.options.apikey,
3212
+ authentication: this.options.authentication
3213
+ }).then(data => callback(undefined, data)).catch(error => callback(error));
3267
3214
  }
3268
3215
  }
3269
3216
 
@@ -4276,5 +4223,205 @@ function identifyImage(options) {
4276
4223
  return new IdentifyImage(options);
4277
4224
  }
4278
4225
 
4279
- export { DynamicMapService as D, FeatureService as F, IdentifyFeatures as I, Query as Q, Service as S, Task as T, VectorBasemapStyle as V, Find as a, IdentifyImage as b, ImageService as c, TiledMapService as d, VectorTileService as e, cleanTrailingSlash as f, find as g, getServiceDetails as h, identifyImage as i, query as q, updateAttribution as u };
4280
- //# sourceMappingURL=IdentifyImage-DmyKcbAv.js.map
4226
+ /**
4227
+ * Portal item resolution.
4228
+ *
4229
+ * Turns an ArcGIS portal item id into ready-to-render esri-gl services using
4230
+ * `@esri/arcgis-rest-portal`. Two entry points are provided:
4231
+ *
4232
+ * - {@link serviceFromPortalItem} — resolve a single-layer item (Feature / Map /
4233
+ * Image / Vector Tile service) to the matching esri-gl service.
4234
+ * - {@link servicesFromWebMap} — read a Web Map item's `operationalLayers`
4235
+ * (and optionally its basemap) and instantiate a service per layer.
4236
+ */
4237
+ function requestOptions(options) {
4238
+ const authentication = resolveAuthentication(options);
4239
+ const opts = {};
4240
+ if (authentication) opts.authentication = authentication;
4241
+ if (options?.portal) opts.portal = options.portal;
4242
+ return opts;
4243
+ }
4244
+ /** Shared auth fields forwarded into a constructed service's options. */
4245
+ function authServiceFields(options) {
4246
+ const fields = {};
4247
+ if (options?.authentication !== undefined) fields.authentication = options.authentication;
4248
+ if (options?.apiKey !== undefined) fields.apiKey = options.apiKey;
4249
+ if (options?.token !== undefined) fields.token = options.token;
4250
+ return fields;
4251
+ }
4252
+ /** True when a Feature Service url already points at a specific sublayer. */
4253
+ function urlHasLayerIndex(url) {
4254
+ return /\/\d+$/.test(cleanTrailingSlash(url));
4255
+ }
4256
+ /**
4257
+ * Construct an esri-gl service for a known ArcGIS service `url` and a
4258
+ * normalized service `kind`. Shared by single-item and web map resolution.
4259
+ */
4260
+ function constructService(kind, sourceId, map, url, options) {
4261
+ const auth = authServiceFields(options);
4262
+ const serviceOptions = options?.serviceOptions ?? {};
4263
+ const baseOptions = {
4264
+ url,
4265
+ ...auth,
4266
+ ...serviceOptions
4267
+ };
4268
+ switch (kind) {
4269
+ case 'tiled':
4270
+ return new TiledMapService(sourceId, map, baseOptions, options?.rasterSrcOptions);
4271
+ case 'image':
4272
+ return new ImageService(sourceId, map, baseOptions, options?.rasterSrcOptions);
4273
+ case 'vector-tile':
4274
+ return new VectorTileService(sourceId, map, baseOptions, options?.vectorSrcOptions);
4275
+ case 'feature':
4276
+ {
4277
+ let layerUrl = cleanTrailingSlash(url);
4278
+ if (!urlHasLayerIndex(layerUrl)) {
4279
+ layerUrl = `${layerUrl}/${options?.layerId ?? 0}`;
4280
+ }
4281
+ return new FeatureService(sourceId, map, {
4282
+ ...baseOptions,
4283
+ url: layerUrl
4284
+ }, options?.geojsonSourceOptions);
4285
+ }
4286
+ case 'dynamic':
4287
+ default:
4288
+ return new DynamicMapService(sourceId, map, baseOptions, options?.rasterSrcOptions);
4289
+ }
4290
+ }
4291
+ /** Map a portal item `type` (+ typeKeywords) to an esri-gl service kind. */
4292
+ function kindFromItemType(type, typeKeywords = []) {
4293
+ switch (type) {
4294
+ case 'Feature Service':
4295
+ case 'Feature Layer':
4296
+ return 'feature';
4297
+ case 'Image Service':
4298
+ return 'image';
4299
+ case 'Vector Tile Service':
4300
+ return 'vector-tile';
4301
+ case 'Map Service':
4302
+ {
4303
+ const tiled = typeKeywords.some(k => /tiled|cached/i.test(k));
4304
+ return tiled ? 'tiled' : 'dynamic';
4305
+ }
4306
+ default:
4307
+ return null;
4308
+ }
4309
+ }
4310
+ /**
4311
+ * Resolve a single-layer portal item to the matching esri-gl service and add
4312
+ * its source to the map.
4313
+ *
4314
+ * @example
4315
+ * const { service } = await serviceFromPortalItem('my-source', map, 'a1b2c3', { token });
4316
+ */
4317
+ async function serviceFromPortalItem(sourceId, map, itemId, options) {
4318
+ const item = await getItem(itemId, requestOptions(options));
4319
+ if (!item.url) {
4320
+ throw new Error(`Portal item ${itemId} ("${item.title}") has no service url.`);
4321
+ }
4322
+ const kind = kindFromItemType(item.type, item.typeKeywords);
4323
+ if (!kind) {
4324
+ throw new Error(`Portal item ${itemId} has unsupported type "${item.type}" for esri-gl.`);
4325
+ }
4326
+ const service = constructService(kind, sourceId, map, item.url, options);
4327
+ return {
4328
+ service,
4329
+ kind,
4330
+ sourceId,
4331
+ url: item.url,
4332
+ item,
4333
+ title: item.title
4334
+ };
4335
+ }
4336
+ /** Map a Web Map operationalLayer `layerType` to an esri-gl service kind. */
4337
+ function kindFromLayerType(layerType) {
4338
+ switch (layerType) {
4339
+ case 'ArcGISFeatureLayer':
4340
+ return 'feature';
4341
+ case 'ArcGISMapServiceLayer':
4342
+ return 'dynamic';
4343
+ case 'ArcGISTiledMapServiceLayer':
4344
+ return 'tiled';
4345
+ case 'ArcGISImageServiceLayer':
4346
+ return 'image';
4347
+ case 'VectorTileLayer':
4348
+ return 'vector-tile';
4349
+ default:
4350
+ return null;
4351
+ }
4352
+ }
4353
+ /**
4354
+ * Read a Web Map item's data and instantiate an esri-gl service for each
4355
+ * supported operational layer (optionally including basemap layers).
4356
+ * Unsupported layer types are skipped.
4357
+ *
4358
+ * @example
4359
+ * const layers = await servicesFromWebMap(map, 'webmap-item-id', { token });
4360
+ * layers.forEach(({ service, title }) => { ... });
4361
+ */
4362
+ async function servicesFromWebMap(map, itemId, options) {
4363
+ const data = await getItemData(itemId, requestOptions(options));
4364
+ if (!data) {
4365
+ throw new Error(`Web Map ${itemId} has no item data (is it shared / a Web Map?).`);
4366
+ }
4367
+ const prefix = options?.sourceIdPrefix ?? itemId;
4368
+ const results = [];
4369
+ const layers = [...(data.operationalLayers ?? [])];
4370
+ if (options?.includeBasemap && data.baseMap?.baseMapLayers) {
4371
+ layers.push(...data.baseMap.baseMapLayers);
4372
+ }
4373
+ layers.forEach((layer, index) => {
4374
+ const kind = kindFromLayerType(layer.layerType);
4375
+ const url = layer.url ?? layer.styleUrl;
4376
+ if (!kind || !url) return; // skip unsupported / urlless layers
4377
+ const sourceId = layer.id ? `${prefix}-${layer.id}` : `${prefix}-${index}`;
4378
+ try {
4379
+ const service = constructService(kind, sourceId, map, url, options);
4380
+ results.push({
4381
+ service,
4382
+ kind,
4383
+ sourceId,
4384
+ url,
4385
+ title: layer.title
4386
+ });
4387
+ } catch (error) {
4388
+ console.warn(`Skipping web map layer "${layer.title ?? sourceId}":`, error);
4389
+ }
4390
+ });
4391
+ return results;
4392
+ }
4393
+ // -----------------------------
4394
+ // Portal item search
4395
+ // -----------------------------
4396
+ /**
4397
+ * Search an ArcGIS portal for items, e.g. to discover services to load.
4398
+ * Thin wrapper over `searchItems` from `@esri/arcgis-rest-portal`; accepts a
4399
+ * query string, a {@link SearchQueryBuilder}, or a full `ISearchOptions`.
4400
+ *
4401
+ * @example
4402
+ * const { results } = await searchPortalItems('type:"Feature Service" AND owner:esri');
4403
+ * // or with auth / paging:
4404
+ * await searchPortalItems({ q: 'wildfire', num: 20, authentication }, { token });
4405
+ */
4406
+ async function searchPortalItems(search, options) {
4407
+ if (typeof search === 'string' || search instanceof SearchQueryBuilder) {
4408
+ const {
4409
+ authentication
4410
+ } = requestOptions(options);
4411
+ return searchItems({
4412
+ q: search,
4413
+ authentication
4414
+ });
4415
+ }
4416
+ // Full ISearchOptions: merge in resolved auth/portal unless already provided.
4417
+ const {
4418
+ authentication
4419
+ } = requestOptions(options);
4420
+ return searchItems({
4421
+ authentication,
4422
+ ...search
4423
+ });
4424
+ }
4425
+
4426
+ export { DynamicMapService as D, FeatureService as F, IdentifyFeatures as I, Query as Q, Service as S, Task as T, VectorBasemapStyle as V, Find as a, IdentifyImage as b, ImageService as c, TiledMapService as d, VectorTileService as e, cleanTrailingSlash as f, esriRequest as g, find as h, getServiceDetails as i, identifyImage as j, serviceFromPortalItem as k, servicesFromWebMap as l, query as q, resolveAuthentication as r, searchPortalItems as s, updateAttribution as u };
4427
+ //# sourceMappingURL=index-DsY1_0df.js.map