1/**
2 * Browser geolocation integration for Effect programs.
3 *
4 * This module defines a `Geolocation` service backed by
5 * `navigator.geolocation`. The service can read one current position or stream
6 * watched position updates with a sliding buffer. Browser callback failures are
7 * represented as `GeolocationError` values with `PositionUnavailable`,
8 * `PermissionDenied`, or `Timeout` reasons. The module also provides the
9 * browser-backed layer and a `watchPosition` accessor.
10 *
11 * @since 4.0.0
12 */
13import * as Cause from "effect/Cause"
14import * as Context from "effect/Context"
15import * as Data from "effect/Data"
16import * as Effect from "effect/Effect"
17import * as Layer from "effect/Layer"
18import * as Queue from "effect/Queue"
19import * as Stream from "effect/Stream"
20
21const TypeId = "~@effect/platform-browser/Geolocation"
22const ErrorTypeId = "~@effect/platform-browser/Geolocation/GeolocationError"
23
24/**
25 * Defines the service interface for browser geolocation, providing effects for the current position and streams of watched positions.
26 *
27 * **When to use**
28 *
29 * Use when browser code needs a typed Effect service for one-shot location
30 * reads or streamed location updates.
31 *
32 * **Details**
33 *
34 * `getCurrentPosition` returns one position effect. `watchPosition` returns a
35 * stream and accepts the browser `PositionOptions` plus an optional sliding
36 * `bufferSize`.
37 *
38 * **Gotchas**
39 *
40 * Browser permission prompts, denied permissions, timeouts, unavailable
41 * position data, secure-context restrictions, and policy restrictions are
42 * surfaced as `GeolocationError`.
43 *
44 * @see {@link GeolocationError} for represented browser geolocation failures
45 * @see {@link layer} for the browser-backed service implementation
46 *
47 * @category services
48 * @since 4.0.0
49 */
50export interface Geolocation {
51 readonly [TypeId]: typeof TypeId
52 readonly getCurrentPosition: (
53 options?: PositionOptions | undefined
54 ) => Effect.Effect<GeolocationPosition, GeolocationError>
55 readonly watchPosition: (
56 options?:
57 | PositionOptions & {
58 readonly bufferSize?: number | undefined
59 }
60 | undefined
61 ) => Stream.Stream<GeolocationPosition, GeolocationError>
62}
63
64/**
65 * Service tag for browser geolocation capabilities.
66 *
67 * **When to use**
68 *
69 * Use when you need to access or provide geolocation capabilities through
70 * Effect's context.
71 *
72 * @see {@link layer} for providing the browser-backed geolocation service
73 *
74 * @category services
75 * @since 4.0.0
76 */
77export const Geolocation: Context.Service<Geolocation, Geolocation> = Context.Service<Geolocation>(TypeId)
78
79/**
80 * Tagged error wrapping a browser geolocation failure reason.
81 *
82 * @category errors
83 * @since 4.0.0
84 */
85export class GeolocationError extends Data.TaggedError("GeolocationError")<{
86 readonly reason: GeolocationErrorReason
87}> {
88 constructor(props: {
89 readonly reason: GeolocationErrorReason
90 }) {
91 super({
92 ...props,
93 cause: props.reason.cause
94 } as any)
95 }
96
97 readonly [ErrorTypeId] = ErrorTypeId
98
99 override get message(): string {
100 return this.reason.message
101 }
102}
103
104/**
105 * Error reason for the browser geolocation `POSITION_UNAVAILABLE` failure.
106 *
107 * @category errors
108 * @since 4.0.0
109 */
110export class PositionUnavailable extends Data.TaggedError("PositionUnavailable")<{
111 readonly cause: unknown
112}> {
113 override get message(): string {
114 return this._tag
115 }
116}
117
118/**
119 * Error reason for the browser geolocation `PERMISSION_DENIED` failure.
120 *
121 * @category errors
122 * @since 4.0.0
123 */
124export class PermissionDenied extends Data.TaggedError("PermissionDenied")<{
125 readonly cause: unknown
126}> {
127 override get message(): string {
128 return this._tag
129 }
130}
131
132/**
133 * Error reason for the browser geolocation `TIMEOUT` failure.
134 *
135 * @category errors
136 * @since 4.0.0
137 */
138export class Timeout extends Data.TaggedError("Timeout")<{
139 readonly cause: unknown
140}> {
141 override get message(): string {
142 return this._tag
143 }
144}
145
146/**
147 * Union of browser geolocation error reasons represented by the service.
148 *
149 * @category errors
150 * @since 4.0.0
151 */
152export type GeolocationErrorReason = PositionUnavailable | PermissionDenied | Timeout
153
154const makeQueue = (
155 options:
156 | PositionOptions & {
157 readonly bufferSize?: number | undefined
158 }
159 | undefined
160) =>
161 Queue.sliding<GeolocationPosition, GeolocationError>(options?.bufferSize ?? 16).pipe(
162 Effect.tap((queue) =>
163 Effect.acquireRelease(
164 Effect.sync(() =>
165 navigator.geolocation.watchPosition(
166 (position) => Queue.offerUnsafe(queue, position),
167 (cause) => {
168 if (cause.code === cause.PERMISSION_DENIED) {
169 const error = new GeolocationError({
170 reason: new PermissionDenied({ cause })
171 })
172 Queue.failCauseUnsafe(queue, Cause.fail(error))
173 } else if (cause.code === cause.TIMEOUT) {
174 const error = new GeolocationError({
175 reason: new Timeout({ cause })
176 })
177 Queue.failCauseUnsafe(queue, Cause.fail(error))
178 } else if (cause.code === cause.POSITION_UNAVAILABLE) {
179 const error = new GeolocationError({
180 reason: new PositionUnavailable({ cause })
181 })
182 Queue.failCauseUnsafe(queue, Cause.fail(error))
183 }
184 },
185 options
186 )
187 ),
188 (handleId) => Effect.sync(() => navigator.geolocation.clearWatch(handleId))
189 )
190 )
191 )
192
193/**
194 * Layer that provides `Geolocation` using `navigator.geolocation`, with watched positions buffered in a sliding queue.
195 *
196 * @category layers
197 * @since 4.0.0
198 */
199export const layer: Layer.Layer<Geolocation> = Layer.succeed(
200 Geolocation,
201 Geolocation.of({
202 [TypeId]: TypeId,
203 getCurrentPosition: (options) =>
204 makeQueue(options).pipe(
205 Effect.flatMap(Queue.take),
206 Effect.scoped
207 ),
208 watchPosition: (options) =>
209 makeQueue(options).pipe(
210 Effect.map(Stream.fromQueue),
211 Stream.unwrap
212 )
213 })
214)
215
216/**
217 * Reads geolocation positions from the `Geolocation` service as a stream, with
218 * an optional sliding buffer size.
219 *
220 * @category accessors
221 * @since 4.0.0
222 */
223export const watchPosition = (
224 options?:
225 | PositionOptions & {
226 readonly bufferSize?: number | undefined
227 }
228 | undefined
229): Stream.Stream<GeolocationPosition, GeolocationError, Geolocation> =>
230 Stream.unwrap(Effect.map(
231 Effect.service(Geolocation),
232 (geolocation) => geolocation.watchPosition(options)
233 ))