@tileflow/react 0.1.0-alpha.17 → 0.1.0-alpha.19

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.
package/README.md CHANGED
@@ -38,7 +38,7 @@ manifest:
38
38
  Or bypass the manifest and load a hosted style directly:
39
39
 
40
40
  ```tsx
41
- <Map styleUrl="https://api.tileflow.dev/v1/styles/acme/madrid.json" />
41
+ <Map styleUrl="https://api.tileflow.dev/maps/map_1234567890abcdef/style.json" />
42
42
  ```
43
43
 
44
44
  For simple hosted static images, use image mode:
@@ -55,6 +55,35 @@ the production image behavior locally.
55
55
  For full static scenes with bounds, overlays, and server-side precache, use
56
56
  `StaticMap` from `@tileflow/react/static` or helpers from `@tileflow/static`.
57
57
 
58
+ ```tsx
59
+ import {StaticMap} from '@tileflow/react/static';
60
+ import {useState} from 'react';
61
+ import {createStaticMapIdempotencyKey} from '@tileflow/static';
62
+
63
+ export function Preview() {
64
+ const [idempotencyKey] = useState(createStaticMapIdempotencyKey);
65
+
66
+ return (
67
+ <StaticMap
68
+ map="madrid"
69
+ camera={{type: 'center', center: [-3.7038, 40.4168], zoom: 12}}
70
+ size={{width: 1200, height: 800}}
71
+ createUrl="/api/static-maps"
72
+ idempotencyKey={idempotencyKey}
73
+ />
74
+ );
75
+ }
76
+ ```
77
+
78
+ `idempotencyKey` is required in create mode. Keep one key for one intentional create action and
79
+ reuse it across retries and remounts. Concurrent React consumers share work only when URL,
80
+ normalized scene, and idempotency key all identify the same operation; unmounting the final
81
+ consumer aborts its in-flight request.
82
+
83
+ Hosted interactive maps automatically preflight a short-lived commercial session grant before
84
+ eligible resources. Setting `analytics={{enabled: false}}` disables the optional beacon only; it
85
+ does not remove hosted authorization or override a user `mapOptions.transformRequest` callback.
86
+
58
87
  ## Headless capture readiness
59
88
 
60
89
  Use `captureId` to disambiguate multiple maps with the same configured name:
package/dist/index.d.ts CHANGED
@@ -14,7 +14,6 @@ type MapProps = {
14
14
  styleUrl?: string;
15
15
  imageUrl?: string;
16
16
  styleBaseUrl?: string;
17
- tileBaseUrl?: string;
18
17
  themes?: TileflowProjectThemes;
19
18
  manifestUrl?: string;
20
19
  preferLocalDev?: boolean;
@@ -30,6 +29,6 @@ type MapProps = {
30
29
  analytics?: TileflowAnalytics;
31
30
  onLoad?: (map: Map$1) => void;
32
31
  };
33
- declare function Map({ captureId, map: mapName, mode, config, style, styleUrl, imageUrl, styleBaseUrl, tileBaseUrl, themes, manifestUrl, preferLocalDev, alt, center, zoom, className, height, imageLoading, interactive, mapOptions, markers, analytics, onLoad, }: MapProps): react.JSX.Element;
32
+ declare function Map({ captureId, map: mapName, mode, config, style, styleUrl, imageUrl, styleBaseUrl, themes, manifestUrl, preferLocalDev, alt, center, zoom, className, height, imageLoading, interactive, mapOptions, markers, analytics, onLoad, }: MapProps): react.JSX.Element;
34
33
 
35
34
  export { Map, type MapMarker, type MapProps, type TileflowMapOptions };
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  } from "react";
11
11
  import maplibregl from "maplibre-gl";
12
12
  import {
13
- createTileflowSessionId,
13
+ createTileflowSessionController,
14
14
  defaultTileflowManifestUrl,
15
15
  loadTileflowManifest,
16
16
  mergeTileflowAnalytics,
@@ -40,7 +40,6 @@ function Map({
40
40
  styleUrl,
41
41
  imageUrl,
42
42
  styleBaseUrl,
43
- tileBaseUrl,
44
43
  themes,
45
44
  manifestUrl = defaultTileflowManifestUrl,
46
45
  preferLocalDev = true,
@@ -72,11 +71,6 @@ function Map({
72
71
  const mapNameRef = useRef(mapName);
73
72
  const resolvedAnalyticsRef = useRef(void 0);
74
73
  const readinessRunRef = useRef(0);
75
- const sessionId = useMemo(() => createTileflowSessionId(), []);
76
- const sessionStarter = useMemo(
77
- () => createTileflowSessionStarter({ sessionId, source: "react" }),
78
- [sessionId]
79
- );
80
74
  const markerController = useMemo(
81
75
  () => createTileflowMarkerController({
82
76
  attach(markerInstance, map, marker) {
@@ -143,8 +137,7 @@ function Map({
143
137
  style,
144
138
  styleBaseUrl,
145
139
  styleUrl,
146
- themes,
147
- tileBaseUrl
140
+ themes
148
141
  });
149
142
  }, [
150
143
  config,
@@ -155,8 +148,7 @@ function Map({
155
148
  style,
156
149
  styleBaseUrl,
157
150
  styleUrl,
158
- themes,
159
- tileBaseUrl
151
+ themes
160
152
  ]);
161
153
  const resolvedAnalytics = useMemo(
162
154
  () => mergeTileflowAnalytics(analytics, runtimeStyle?.analytics),
@@ -188,16 +180,6 @@ function Map({
188
180
  }) : void 0),
189
181
  [imageSize, imageUrl, isImageMode, manifestMap, resolvedZoom, staticImageCenter]
190
182
  );
191
- const transformRequest = useMemo(
192
- () => createTileflowTransformRequest({
193
- always: true,
194
- asyncAnalyticsTiming: "resolution",
195
- getAnalytics: () => resolvedAnalyticsRef.current,
196
- sessionId,
197
- transformRequest: stableMapOptions?.transformRequest ?? void 0
198
- }),
199
- [sessionId, stableMapOptions?.transformRequest]
200
- );
201
183
  useEffect(() => {
202
184
  if (!isImageMode || imageUrl || !containerRef.current) {
203
185
  setImageSize(null);
@@ -226,6 +208,20 @@ function Map({
226
208
  }
227
209
  readinessRunRef.current += 1;
228
210
  setCaptureState("loading");
211
+ const session = createTileflowSessionController({ source: "react" });
212
+ const sessionStarter = createTileflowSessionStarter({
213
+ getSessionId: () => session.sessionId,
214
+ sessionId: session.sessionId,
215
+ source: "react"
216
+ });
217
+ const transformRequest = createTileflowTransformRequest({
218
+ always: true,
219
+ asyncAnalyticsTiming: "resolution",
220
+ getAnalytics: () => resolvedAnalyticsRef.current,
221
+ sessionController: session,
222
+ sessionId: session.sessionId,
223
+ transformRequest: stableMapOptions?.transformRequest ?? void 0
224
+ });
229
225
  const map = new maplibregl.Map({
230
226
  ...stableMapOptions,
231
227
  container: containerRef.current,
@@ -288,14 +284,7 @@ function Map({
288
284
  }
289
285
  }
290
286
  };
291
- }, [
292
- markerController,
293
- resolvedInteractive,
294
- runtimeStyle,
295
- sessionStarter,
296
- stableMapOptions,
297
- transformRequest
298
- ]);
287
+ }, [markerController, resolvedInteractive, runtimeStyle, stableMapOptions]);
299
288
  useLayoutEffect(() => {
300
289
  if (!isImageMode) return;
301
290
  readinessRunRef.current += 1;
package/dist/static.d.ts CHANGED
@@ -16,13 +16,15 @@ type StaticMapBaseProps = StaticSceneInput & {
16
16
  };
17
17
  type StaticMapImageProps = {
18
18
  createUrl?: never;
19
+ idempotencyKey?: never;
19
20
  imageUrl: string;
20
21
  };
21
22
  type StaticMapCreateProps = {
22
23
  createUrl: string;
24
+ idempotencyKey: string;
23
25
  imageUrl?: never;
24
26
  };
25
27
  type StaticMapProps = StaticMapBaseProps & (StaticMapCreateProps | StaticMapImageProps);
26
- declare function StaticMap({ alt, camera, className, captureId, createUrl, imageStyle, imageUrl, keepPreviousImage, loading, map, onError, onImageLoad, onReady, overlays, size, style, }: StaticMapProps): react.JSX.Element;
28
+ declare function StaticMap({ alt, camera, className, captureId, createUrl, imageStyle, imageUrl, idempotencyKey, keepPreviousImage, loading, map, onError, onImageLoad, onReady, overlays, size, style, }: StaticMapProps): react.JSX.Element;
27
29
 
28
30
  export { StaticMap, type StaticMapProps };
package/dist/static.js CHANGED
@@ -9,8 +9,181 @@ import {
9
9
  useState
10
10
  } from "react";
11
11
  import { normalizeTileflowCaptureId } from "@tileflow/core";
12
- import { jsx } from "react/jsx-runtime";
12
+ import {
13
+ validateStaticMapIdempotencyKey
14
+ } from "@tileflow/static";
15
+
16
+ // src/static-map-request.ts
17
+ import {
18
+ staticMapProcessingResultSchema,
19
+ staticMapReadyResultSchema
20
+ } from "@tileflow/static";
13
21
  var inFlightRequests = /* @__PURE__ */ new Map();
22
+ function createStaticMapRequestKey(input) {
23
+ return stableStringify(input);
24
+ }
25
+ async function resolveStaticMap(input) {
26
+ throwIfAborted(input.signal);
27
+ let entry = inFlightRequests.get(input.requestKey);
28
+ if (!entry) {
29
+ const controller = new AbortController();
30
+ const promise = requestStaticMapUntilReady({ ...input, signal: controller.signal });
31
+ const created = {
32
+ controller,
33
+ consumers: 0,
34
+ promise,
35
+ settled: false
36
+ };
37
+ const settle = () => {
38
+ created.settled = true;
39
+ if (inFlightRequests.get(input.requestKey) === created) {
40
+ inFlightRequests.delete(input.requestKey);
41
+ }
42
+ };
43
+ void promise.then(settle, settle);
44
+ inFlightRequests.set(input.requestKey, created);
45
+ entry = created;
46
+ }
47
+ entry.consumers += 1;
48
+ try {
49
+ return await raceWithAbort(entry.promise, input.signal);
50
+ } finally {
51
+ entry.consumers -= 1;
52
+ if (entry.consumers === 0 && !entry.settled) {
53
+ if (inFlightRequests.get(input.requestKey) === entry) {
54
+ inFlightRequests.delete(input.requestKey);
55
+ }
56
+ entry.controller.abort();
57
+ }
58
+ }
59
+ }
60
+ async function requestStaticMapUntilReady(input) {
61
+ const maxWaitMs = input.maxWaitMs ?? 3e4;
62
+ if (!Number.isSafeInteger(maxWaitMs) || maxWaitMs < 1 || maxWaitMs > 12e4) {
63
+ throw new Error("Static map maxWaitMs must be an integer from 1 to 120000");
64
+ }
65
+ return runWithinBudget(maxWaitMs, input.signal, async (signal) => {
66
+ const body = JSON.stringify(input.scene);
67
+ const fetcher = input.fetch ?? fetch;
68
+ let operationId = null;
69
+ while (true) {
70
+ const response = await fetcher(input.createUrl, {
71
+ body,
72
+ headers: {
73
+ "Content-Type": "application/json",
74
+ "Idempotency-Key": input.idempotencyKey
75
+ },
76
+ method: "POST",
77
+ signal
78
+ });
79
+ throwIfAborted(signal);
80
+ if (!response.ok) throw new Error(`Static map endpoint failed: ${response.status}`);
81
+ let value;
82
+ try {
83
+ value = await response.json();
84
+ } catch {
85
+ throw new Error("Static map endpoint returned invalid JSON");
86
+ }
87
+ throwIfAborted(signal);
88
+ if (response.status !== 202) {
89
+ const ready = staticMapReadyResultSchema.safeParse(value);
90
+ if (!ready.success) throw new Error("Static map endpoint returned an invalid response");
91
+ assertStaticMapOperationIdentity(operationId, ready.data.operationId);
92
+ return ready.data;
93
+ }
94
+ const pending = staticMapProcessingResultSchema.safeParse(value);
95
+ if (!pending.success) throw new Error("Static map endpoint returned an invalid response");
96
+ assertStaticMapOperationIdentity(operationId, pending.data.operationId);
97
+ operationId ??= pending.data.operationId;
98
+ await delay(pending.data.retryAfterMs, signal);
99
+ }
100
+ });
101
+ }
102
+ function stableStringify(value) {
103
+ return JSON.stringify(sortJson(value));
104
+ }
105
+ function assertStaticMapOperationIdentity(expected, actual) {
106
+ if (expected !== null && actual !== expected) {
107
+ throw new Error("Static map endpoint changed operation identity while polling");
108
+ }
109
+ }
110
+ async function runWithinBudget(maxWaitMs, externalSignal, operation) {
111
+ const controller = new AbortController();
112
+ const timeoutError = new Error(`Static map request timed out after ${maxWaitMs}ms`);
113
+ const abortFromConsumer = () => controller.abort(externalSignal?.reason);
114
+ if (externalSignal?.aborted) abortFromConsumer();
115
+ else externalSignal?.addEventListener("abort", abortFromConsumer, { once: true });
116
+ const timeout = setTimeout(() => controller.abort(timeoutError), maxWaitMs);
117
+ try {
118
+ throwIfAborted(controller.signal);
119
+ const pending = operation(controller.signal);
120
+ return await raceWithAbort(pending, controller.signal);
121
+ } finally {
122
+ clearTimeout(timeout);
123
+ externalSignal?.removeEventListener("abort", abortFromConsumer);
124
+ }
125
+ }
126
+ function delay(milliseconds, signal) {
127
+ if (milliseconds === 0) {
128
+ throwIfAborted(signal);
129
+ return Promise.resolve();
130
+ }
131
+ return new Promise((resolve, reject) => {
132
+ const timeout = setTimeout(() => {
133
+ signal.removeEventListener("abort", onAbort);
134
+ resolve();
135
+ }, milliseconds);
136
+ const onAbort = () => {
137
+ clearTimeout(timeout);
138
+ reject(abortReason(signal));
139
+ };
140
+ if (signal.aborted) onAbort();
141
+ else signal.addEventListener("abort", onAbort, { once: true });
142
+ });
143
+ }
144
+ function raceWithAbort(operation, signal) {
145
+ if (!signal) return operation;
146
+ if (signal.aborted) return Promise.reject(abortReason(signal));
147
+ return new Promise((resolve, reject) => {
148
+ const onAbort = () => {
149
+ cleanup();
150
+ reject(abortReason(signal));
151
+ };
152
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
153
+ signal.addEventListener("abort", onAbort, { once: true });
154
+ operation.then(
155
+ (value) => {
156
+ cleanup();
157
+ resolve(value);
158
+ },
159
+ (error) => {
160
+ cleanup();
161
+ reject(error);
162
+ }
163
+ );
164
+ });
165
+ }
166
+ function throwIfAborted(signal) {
167
+ if (signal?.aborted) throw abortReason(signal);
168
+ }
169
+ function abortReason(signal) {
170
+ if (signal.reason instanceof Error) return signal.reason;
171
+ const error = new Error("Static map request was aborted");
172
+ error.name = "AbortError";
173
+ return error;
174
+ }
175
+ function sortJson(value) {
176
+ if (Array.isArray(value)) return value.map(sortJson);
177
+ if (value && typeof value === "object") {
178
+ return Object.fromEntries(
179
+ Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, sortJson(entry)])
180
+ );
181
+ }
182
+ return value;
183
+ }
184
+
185
+ // src/static-map.tsx
186
+ import { jsx } from "react/jsx-runtime";
14
187
  function StaticMap({
15
188
  alt = "",
16
189
  camera,
@@ -19,6 +192,7 @@ function StaticMap({
19
192
  createUrl,
20
193
  imageStyle,
21
194
  imageUrl,
195
+ idempotencyKey,
22
196
  keepPreviousImage = false,
23
197
  loading = "lazy",
24
198
  map,
@@ -43,7 +217,7 @@ function StaticMap({
43
217
  () => stableStringify({ camera, map, overlays, size }),
44
218
  [camera, map, overlays, size]
45
219
  );
46
- const requestKey = imageUrl ? `image:${imageUrl}` : `create:${createUrl}:${sceneKey}`;
220
+ const intentKey = imageUrl ? `image:${imageUrl}` : `create:${createUrl}:${idempotencyKey ?? ""}:${sceneKey}`;
47
221
  useLayoutEffect(() => {
48
222
  imageLoadRunRef.current += 1;
49
223
  setCaptureState("loading");
@@ -52,7 +226,7 @@ function StaticMap({
52
226
  if (image.naturalWidth === 0) setCaptureState("error");
53
227
  else void markImageReady(image);
54
228
  }
55
- }, [requestKey]);
229
+ }, [intentKey]);
56
230
  async function markImageReady(image) {
57
231
  const runId = ++imageLoadRunRef.current;
58
232
  try {
@@ -81,14 +255,31 @@ function StaticMap({
81
255
  if (!createUrl) {
82
256
  return;
83
257
  }
258
+ const validation = validateStaticMapIdempotencyKey(idempotencyKey ?? "");
259
+ if (!validation.ok) {
260
+ const keyError = new Error(`Invalid Static Maps idempotency key: ${validation.error}`);
261
+ if (!keepPreviousImage) setResult(null);
262
+ setError(keyError);
263
+ imageLoadRunRef.current += 1;
264
+ setCaptureState("error");
265
+ onErrorRef.current?.(keyError);
266
+ return;
267
+ }
84
268
  if (!keepPreviousImage) {
85
269
  setResult(null);
86
270
  }
87
271
  setError(null);
272
+ const requestController = new AbortController();
88
273
  resolveStaticMap({
89
274
  createUrl,
90
- requestKey,
91
- scene: JSON.parse(sceneKey)
275
+ idempotencyKey: validation.key,
276
+ requestKey: createStaticMapRequestKey({
277
+ createUrl,
278
+ idempotencyKey: validation.key,
279
+ sceneKey
280
+ }),
281
+ scene: JSON.parse(sceneKey),
282
+ signal: requestController.signal
92
283
  }).then((nextResult) => {
93
284
  if (cancelled) {
94
285
  return;
@@ -111,8 +302,9 @@ function StaticMap({
111
302
  });
112
303
  return () => {
113
304
  cancelled = true;
305
+ requestController.abort();
114
306
  };
115
- }, [createUrl, imageUrl, keepPreviousImage, requestKey, sceneKey]);
307
+ }, [createUrl, idempotencyKey, imageUrl, intentKey, keepPreviousImage, sceneKey]);
116
308
  return /* @__PURE__ */ jsx(
117
309
  "div",
118
310
  {
@@ -159,50 +351,17 @@ function StaticMap({
159
351
  }
160
352
  );
161
353
  }
162
- async function resolveStaticMap(input) {
163
- const existing = inFlightRequests.get(input.requestKey);
164
- if (existing) {
165
- return existing;
166
- }
167
- const promise = fetch(input.createUrl, {
168
- body: JSON.stringify(input.scene),
169
- headers: {
170
- "Content-Type": "application/json"
171
- },
172
- method: "POST"
173
- }).then(async (response) => {
174
- if (!response.ok) {
175
- throw new Error(`Static map endpoint failed: ${response.status}`);
176
- }
177
- return await response.json();
178
- }).finally(() => {
179
- inFlightRequests.delete(input.requestKey);
180
- });
181
- inFlightRequests.set(input.requestKey, promise);
182
- return promise;
183
- }
184
354
  function imageResult(imageUrl) {
185
355
  return {
186
356
  cached: true,
187
357
  hash: "",
188
358
  imageUrl,
189
- status: "ready"
359
+ operationId: null,
360
+ remainingUnits: null,
361
+ status: "ready",
362
+ unitCost: 0
190
363
  };
191
364
  }
192
- function stableStringify(value) {
193
- return JSON.stringify(sortJson(value));
194
- }
195
- function sortJson(value) {
196
- if (Array.isArray(value)) {
197
- return value.map(sortJson);
198
- }
199
- if (value && typeof value === "object") {
200
- return Object.fromEntries(
201
- Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, sortJson(entry)])
202
- );
203
- }
204
- return value;
205
- }
206
365
  export {
207
366
  StaticMap
208
367
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tileflow/react",
3
- "version": "0.1.0-alpha.17",
3
+ "version": "0.1.0-alpha.19",
4
4
  "description": "React components for rendering Tileflow maps with MapLibre.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -41,8 +41,8 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@tileflow/core": ">=0.1.0-alpha.17 <0.1.0-beta.0",
45
- "@tileflow/static": ">=0.1.0-alpha.16 <0.1.0-beta.0"
44
+ "@tileflow/core": ">=0.1.0-alpha.19 <0.1.0-beta.0",
45
+ "@tileflow/static": ">=0.1.0-alpha.17 <0.1.0-beta.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "maplibre-gl": ">=5",