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

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.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,
@@ -72,11 +72,6 @@ function Map({
72
72
  const mapNameRef = useRef(mapName);
73
73
  const resolvedAnalyticsRef = useRef(void 0);
74
74
  const readinessRunRef = useRef(0);
75
- const sessionId = useMemo(() => createTileflowSessionId(), []);
76
- const sessionStarter = useMemo(
77
- () => createTileflowSessionStarter({ sessionId, source: "react" }),
78
- [sessionId]
79
- );
80
75
  const markerController = useMemo(
81
76
  () => createTileflowMarkerController({
82
77
  attach(markerInstance, map, marker) {
@@ -188,16 +183,6 @@ function Map({
188
183
  }) : void 0),
189
184
  [imageSize, imageUrl, isImageMode, manifestMap, resolvedZoom, staticImageCenter]
190
185
  );
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
186
  useEffect(() => {
202
187
  if (!isImageMode || imageUrl || !containerRef.current) {
203
188
  setImageSize(null);
@@ -226,6 +211,20 @@ function Map({
226
211
  }
227
212
  readinessRunRef.current += 1;
228
213
  setCaptureState("loading");
214
+ const session = createTileflowSessionController({ source: "react" });
215
+ const sessionStarter = createTileflowSessionStarter({
216
+ getSessionId: () => session.sessionId,
217
+ sessionId: session.sessionId,
218
+ source: "react"
219
+ });
220
+ const transformRequest = createTileflowTransformRequest({
221
+ always: true,
222
+ asyncAnalyticsTiming: "resolution",
223
+ getAnalytics: () => resolvedAnalyticsRef.current,
224
+ sessionController: session,
225
+ sessionId: session.sessionId,
226
+ transformRequest: stableMapOptions?.transformRequest ?? void 0
227
+ });
229
228
  const map = new maplibregl.Map({
230
229
  ...stableMapOptions,
231
230
  container: containerRef.current,
@@ -288,14 +287,7 @@ function Map({
288
287
  }
289
288
  }
290
289
  };
291
- }, [
292
- markerController,
293
- resolvedInteractive,
294
- runtimeStyle,
295
- sessionStarter,
296
- stableMapOptions,
297
- transformRequest
298
- ]);
290
+ }, [markerController, resolvedInteractive, runtimeStyle, stableMapOptions]);
299
291
  useLayoutEffect(() => {
300
292
  if (!isImageMode) return;
301
293
  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.18",
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.18 <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",