@typed/navigation 0.1.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.
Files changed (141) hide show
  1. package/dist/DOM.d.ts +12 -0
  2. package/dist/DOM.d.ts.map +1 -0
  3. package/dist/DOM.js +89 -0
  4. package/dist/DOM.js.map +1 -0
  5. package/dist/Memory.d.ts +10 -0
  6. package/dist/Memory.d.ts.map +1 -0
  7. package/dist/Memory.js +55 -0
  8. package/dist/Memory.js.map +1 -0
  9. package/dist/Navigation.d.ts +117 -0
  10. package/dist/Navigation.d.ts.map +1 -0
  11. package/dist/Navigation.js +36 -0
  12. package/dist/Navigation.js.map +1 -0
  13. package/dist/_makeServerWindow.d.ts +16 -0
  14. package/dist/_makeServerWindow.d.ts.map +1 -0
  15. package/dist/_makeServerWindow.js +9 -0
  16. package/dist/_makeServerWindow.js.map +1 -0
  17. package/dist/cjs/DOM.d.ts +12 -0
  18. package/dist/cjs/DOM.d.ts.map +1 -0
  19. package/dist/cjs/DOM.js +118 -0
  20. package/dist/cjs/DOM.js.map +1 -0
  21. package/dist/cjs/Memory.d.ts +10 -0
  22. package/dist/cjs/Memory.d.ts.map +1 -0
  23. package/dist/cjs/Memory.js +82 -0
  24. package/dist/cjs/Memory.js.map +1 -0
  25. package/dist/cjs/Navigation.d.ts +117 -0
  26. package/dist/cjs/Navigation.d.ts.map +1 -0
  27. package/dist/cjs/Navigation.js +69 -0
  28. package/dist/cjs/Navigation.js.map +1 -0
  29. package/dist/cjs/_makeServerWindow.d.ts +16 -0
  30. package/dist/cjs/_makeServerWindow.d.ts.map +1 -0
  31. package/dist/cjs/_makeServerWindow.js +36 -0
  32. package/dist/cjs/_makeServerWindow.js.map +1 -0
  33. package/dist/cjs/constant.d.ts +2 -0
  34. package/dist/cjs/constant.d.ts.map +1 -0
  35. package/dist/cjs/constant.js +30 -0
  36. package/dist/cjs/constant.js.map +1 -0
  37. package/dist/cjs/dom-intent.d.ts +29 -0
  38. package/dist/cjs/dom-intent.d.ts.map +1 -0
  39. package/dist/cjs/dom-intent.js +173 -0
  40. package/dist/cjs/dom-intent.js.map +1 -0
  41. package/dist/cjs/history.d.ts +31 -0
  42. package/dist/cjs/history.d.ts.map +1 -0
  43. package/dist/cjs/history.js +115 -0
  44. package/dist/cjs/history.js.map +1 -0
  45. package/dist/cjs/index.d.ts +4 -0
  46. package/dist/cjs/index.d.ts.map +1 -0
  47. package/dist/cjs/index.js +20 -0
  48. package/dist/cjs/index.js.map +1 -0
  49. package/dist/cjs/json.d.ts +13 -0
  50. package/dist/cjs/json.d.ts.map +1 -0
  51. package/dist/cjs/json.js +24 -0
  52. package/dist/cjs/json.js.map +1 -0
  53. package/dist/cjs/memory-intent.d.ts +28 -0
  54. package/dist/cjs/memory-intent.d.ts.map +1 -0
  55. package/dist/cjs/memory-intent.js +149 -0
  56. package/dist/cjs/memory-intent.js.map +1 -0
  57. package/dist/cjs/model.d.ts +22 -0
  58. package/dist/cjs/model.d.ts.map +1 -0
  59. package/dist/cjs/model.js +48 -0
  60. package/dist/cjs/model.js.map +1 -0
  61. package/dist/cjs/shared-intent.d.ts +15 -0
  62. package/dist/cjs/shared-intent.d.ts.map +1 -0
  63. package/dist/cjs/shared-intent.js +82 -0
  64. package/dist/cjs/shared-intent.js.map +1 -0
  65. package/dist/cjs/storage.d.ts +19 -0
  66. package/dist/cjs/storage.d.ts.map +1 -0
  67. package/dist/cjs/storage.js +101 -0
  68. package/dist/cjs/storage.js.map +1 -0
  69. package/dist/cjs/util.d.ts +5 -0
  70. package/dist/cjs/util.d.ts.map +1 -0
  71. package/dist/cjs/util.js +39 -0
  72. package/dist/cjs/util.js.map +1 -0
  73. package/dist/constant.d.ts +2 -0
  74. package/dist/constant.d.ts.map +1 -0
  75. package/dist/constant.js +4 -0
  76. package/dist/constant.js.map +1 -0
  77. package/dist/dom-intent.d.ts +29 -0
  78. package/dist/dom-intent.d.ts.map +1 -0
  79. package/dist/dom-intent.js +141 -0
  80. package/dist/dom-intent.js.map +1 -0
  81. package/dist/history.d.ts +31 -0
  82. package/dist/history.d.ts.map +1 -0
  83. package/dist/history.js +88 -0
  84. package/dist/history.js.map +1 -0
  85. package/dist/index.d.ts +4 -0
  86. package/dist/index.d.ts.map +1 -0
  87. package/dist/index.js +4 -0
  88. package/dist/index.js.map +1 -0
  89. package/dist/intent.d.ts +31 -0
  90. package/dist/intent.d.ts.map +1 -0
  91. package/dist/intent.js +157 -0
  92. package/dist/intent.js.map +1 -0
  93. package/dist/json.d.ts +13 -0
  94. package/dist/json.d.ts.map +1 -0
  95. package/dist/json.js +17 -0
  96. package/dist/json.js.map +1 -0
  97. package/dist/memory-intent.d.ts +28 -0
  98. package/dist/memory-intent.d.ts.map +1 -0
  99. package/dist/memory-intent.js +117 -0
  100. package/dist/memory-intent.js.map +1 -0
  101. package/dist/model.d.ts +22 -0
  102. package/dist/model.d.ts.map +1 -0
  103. package/dist/model.js +21 -0
  104. package/dist/model.js.map +1 -0
  105. package/dist/shared-intent.d.ts +15 -0
  106. package/dist/shared-intent.d.ts.map +1 -0
  107. package/dist/shared-intent.js +51 -0
  108. package/dist/shared-intent.js.map +1 -0
  109. package/dist/storage.d.ts +19 -0
  110. package/dist/storage.d.ts.map +1 -0
  111. package/dist/storage.js +73 -0
  112. package/dist/storage.js.map +1 -0
  113. package/dist/tsconfig.cjs.build.tsbuildinfo +1 -0
  114. package/dist/util.d.ts +5 -0
  115. package/dist/util.d.ts.map +1 -0
  116. package/dist/util.js +12 -0
  117. package/dist/util.js.map +1 -0
  118. package/eslintrc.json +3 -0
  119. package/package.json +38 -0
  120. package/project.json +43 -0
  121. package/src/DOM.test.ts +704 -0
  122. package/src/DOM.ts +165 -0
  123. package/src/Memory.test.ts +464 -0
  124. package/src/Memory.ts +102 -0
  125. package/src/Navigation.ts +192 -0
  126. package/src/_makeServerWindow.ts +28 -0
  127. package/src/constant.ts +5 -0
  128. package/src/dom-intent.ts +276 -0
  129. package/src/history.ts +141 -0
  130. package/src/index.ts +3 -0
  131. package/src/json.ts +31 -0
  132. package/src/memory-intent.ts +221 -0
  133. package/src/model.ts +54 -0
  134. package/src/shared-intent.ts +120 -0
  135. package/src/storage.ts +101 -0
  136. package/src/util.ts +20 -0
  137. package/tsconfig.build.json +4 -0
  138. package/tsconfig.build.tsbuildinfo +1 -0
  139. package/tsconfig.cjs.build.json +22 -0
  140. package/tsconfig.json +27 -0
  141. package/vite.config.js +3 -0
package/src/history.ts ADDED
@@ -0,0 +1,141 @@
1
+ import * as Cause from '@effect/io/Cause'
2
+ import * as Effect from '@effect/io/Effect'
3
+ import * as Fiber from '@effect/io/Fiber'
4
+ import * as Runtime from '@effect/io/Runtime'
5
+ import * as Scope from '@effect/io/Scope'
6
+ import { History } from '@typed/dom'
7
+ import * as Fx from '@typed/fx'
8
+
9
+ import { Destination, NavigationError } from './Navigation.js'
10
+ import { ServiceId } from './constant.js'
11
+ import { DomIntent } from './dom-intent.js'
12
+
13
+ export type HistoryEvent = PushStateEvent | ReplaceStateEvent | GoEvent | BackEvent | ForwardEvent
14
+
15
+ export interface PushStateEvent {
16
+ readonly _tag: 'PushState'
17
+ readonly state: unknown
18
+ readonly url: string
19
+ }
20
+
21
+ export interface ReplaceStateEvent {
22
+ readonly _tag: 'ReplaceState'
23
+ readonly state: unknown
24
+ readonly url: string
25
+ }
26
+
27
+ export interface GoEvent {
28
+ readonly _tag: 'Go'
29
+ readonly delta: number
30
+ }
31
+
32
+ export interface BackEvent {
33
+ readonly _tag: 'Back'
34
+ }
35
+
36
+ export interface ForwardEvent {
37
+ readonly _tag: 'Forward'
38
+ }
39
+
40
+ export const patchHistory: Effect.Effect<
41
+ History | Scope.Scope,
42
+ never,
43
+ Fx.Subject<never, HistoryEvent>
44
+ > = Effect.gen(function* ($) {
45
+ const history = yield* $(History)
46
+ const scope = yield* $(Effect.scope)
47
+ const historyEvents = Fx.makeSubject<never, HistoryEvent>()
48
+ const runtime = yield* $(Effect.runtime<never>())
49
+ const runFork = Runtime.runFork(runtime)
50
+ const cleanup = patchHistory_(history, (event: HistoryEvent) =>
51
+ runFork(Effect.flatMap(Effect.forkIn(historyEvents.event(event), scope), Fiber.join)),
52
+ )
53
+
54
+ // unpatch history upon finalization
55
+ yield* $(Effect.addFinalizer(() => Effect.sync(cleanup)))
56
+
57
+ return historyEvents
58
+ })
59
+
60
+ function patchHistory_(history: History, sendEvent: (event: HistoryEvent) => void) {
61
+ const pushState = history.pushState.bind(history)
62
+ const replaceState = history.replaceState.bind(history)
63
+ const go = history.go.bind(history)
64
+ const back = history.back.bind(history)
65
+ const forward = history.forward.bind(history)
66
+
67
+ history.pushState = function (state, title, url) {
68
+ pushState(state, title, url)
69
+
70
+ if (url && this !== ServiceId) sendEvent({ _tag: 'PushState', state, url: url.toString() })
71
+ }
72
+
73
+ history.replaceState = function (state, title, url) {
74
+ replaceState(state, title, url)
75
+
76
+ if (url && this !== ServiceId) sendEvent({ _tag: 'ReplaceState', state, url: url.toString() })
77
+ }
78
+
79
+ history.go = function (delta) {
80
+ if (!delta) return
81
+
82
+ go(delta)
83
+ if (this !== ServiceId) sendEvent({ _tag: 'Go', delta })
84
+ }
85
+
86
+ history.back = function () {
87
+ back()
88
+ if (this !== ServiceId) sendEvent({ _tag: 'Back' })
89
+ }
90
+
91
+ history.forward = function () {
92
+ forward()
93
+ if (this !== ServiceId) sendEvent({ _tag: 'Forward' })
94
+ }
95
+
96
+ const stateDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(history), 'state')
97
+ Object.defineProperty(history, 'state', {
98
+ ...stateDescriptor,
99
+ get() {
100
+ return stateDescriptor?.get?.()?.state
101
+ },
102
+ })
103
+ Object.defineProperty(history, 'originalState', {
104
+ ...stateDescriptor,
105
+ })
106
+
107
+ // Reset history to original state
108
+ return () => {
109
+ history.pushState = pushState
110
+ history.replaceState = replaceState
111
+ history.go = go
112
+ history.back = back
113
+ history.forward = forward
114
+
115
+ if (stateDescriptor) Object.defineProperty(history, 'state', stateDescriptor)
116
+ }
117
+ }
118
+
119
+ export function onHistoryEvent(
120
+ event: HistoryEvent,
121
+ intent: DomIntent,
122
+ ): Effect.Effect<
123
+ History | Location | Storage,
124
+ Cause.NoSuchElementException | NavigationError,
125
+ Destination
126
+ > {
127
+ return Effect.gen(function* ($) {
128
+ switch (event._tag) {
129
+ case 'PushState':
130
+ return yield* $(intent.push(event.url, { state: event.state }, true))
131
+ case 'ReplaceState':
132
+ return yield* $(intent.replace(event.url, { state: event.state }, true))
133
+ case 'Back':
134
+ return yield* $(intent.back(true))
135
+ case 'Forward':
136
+ return yield* $(intent.forward(true))
137
+ case 'Go':
138
+ return yield* $(intent.go(event.delta, true))
139
+ }
140
+ })
141
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './Navigation.js'
2
+ export * from './DOM.js'
3
+ export * from './Memory.js'
package/src/json.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { Destination, NavigationEvent } from './Navigation.js'
2
+
3
+ export type NavigationEventJson = {
4
+ readonly [K in keyof NavigationEvent]: NavigationEvent[K] extends Destination
5
+ ? DestinationJson
6
+ : NavigationEvent[K]
7
+ }
8
+
9
+ type DestinationJson = {
10
+ readonly [K in keyof Destination]: Destination[K] extends URL ? string : Destination[K]
11
+ }
12
+
13
+ export const decodeDestination = (d: DestinationJson): Destination => ({
14
+ ...d,
15
+ url: new URL(d.url),
16
+ })
17
+
18
+ export const decodeNavigationEvent = (event: NavigationEventJson): NavigationEvent => ({
19
+ ...event,
20
+ destination: decodeDestination(event.destination),
21
+ })
22
+
23
+ export const encodeEvent = (event: NavigationEvent): NavigationEventJson => ({
24
+ ...event,
25
+ destination: encodeDestination(event.destination),
26
+ })
27
+
28
+ export const encodeDestination = (destination: Destination): DestinationJson => ({
29
+ ...destination,
30
+ url: destination.url.href,
31
+ })
@@ -0,0 +1,221 @@
1
+ import { Option } from '@effect/data/Option'
2
+ import * as Cause from '@effect/io/Cause'
3
+ import * as Effect from '@effect/io/Effect'
4
+
5
+ import type { MemoryNavigationOptions } from './Memory.js'
6
+ import {
7
+ Destination,
8
+ NavigateOptions,
9
+ NavigationError,
10
+ NavigationEvent,
11
+ NavigationType,
12
+ } from './Navigation.js'
13
+ import { Model } from './model.js'
14
+ import {
15
+ Notify,
16
+ Save,
17
+ makeGoTo,
18
+ makeNotify,
19
+ makeOnNavigation,
20
+ makeOnNavigationEnd,
21
+ } from './shared-intent.js'
22
+ import { createKey, getUrl } from './util.js'
23
+
24
+ // Roughly the number of History entries in a browser anyways
25
+ const DEFAULT_MAX_ENTRIES = 50
26
+
27
+ export type MemoryIntent = {
28
+ readonly back: ReturnType<ReturnType<typeof makeGo>>
29
+
30
+ readonly forward: ReturnType<ReturnType<typeof makeGo>>
31
+
32
+ readonly push: ReturnType<typeof makePush>
33
+
34
+ readonly replace: ReturnType<typeof makeReplace>
35
+
36
+ readonly navigate: (
37
+ url: string,
38
+ options?: NavigateOptions,
39
+ ) => ReturnType<ReturnType<typeof makePush | typeof makeReplace>>
40
+
41
+ readonly notify: Notify
42
+
43
+ readonly go: ReturnType<typeof makeGo>
44
+
45
+ readonly goTo: (
46
+ key: string,
47
+ ) => Effect.Effect<never, Cause.NoSuchElementException | NavigationError, Option<Destination>>
48
+
49
+ readonly reload: ReturnType<typeof makeReload>
50
+
51
+ readonly onNavigation: ReturnType<typeof makeOnNavigation>
52
+
53
+ readonly onNavigationEnd: ReturnType<typeof makeOnNavigationEnd>
54
+ }
55
+
56
+ export function makeIntent(model: Model, options: MemoryNavigationOptions): MemoryIntent {
57
+ const { origin } = options.initialUrl
58
+ const base = options.base ?? '/'
59
+ const maxEntries = Math.abs(options.maxEntries ?? DEFAULT_MAX_ENTRIES)
60
+ const notify = makeNotify(model)
61
+ const save = makeSave(model)
62
+ const go = makeGo(model, notify, save)
63
+ const replace = makeReplace(model, notify, save, base, origin)
64
+ const push = makePush(model, notify, save, base, origin, maxEntries)
65
+
66
+ return {
67
+ back: go(-1),
68
+ forward: go(1),
69
+ push,
70
+ replace,
71
+ navigate: (url: string, options: NavigateOptions = {}) =>
72
+ options.history === 'replace' ? replace(url, options) : push(url, options),
73
+ go: go,
74
+ goTo: makeGoTo(model, go),
75
+ reload: makeReload(model, notify, save),
76
+ onNavigation: makeOnNavigation(model),
77
+ onNavigationEnd: makeOnNavigationEnd(model),
78
+ notify,
79
+ } as const
80
+ }
81
+
82
+ export type Intent = ReturnType<typeof makeIntent>
83
+
84
+ export const makeSave: (
85
+ model: Model,
86
+ ) => (event: NavigationEvent) => Effect.Effect<never, Cause.NoSuchElementException, void> =
87
+ (model: Model) => (event: NavigationEvent) =>
88
+ Effect.gen(function* ($) {
89
+ const events = yield* $(model.events)
90
+ const index = yield* $(model.index)
91
+
92
+ // Update current entry
93
+ yield* $(model.currentEntry.set(event.destination))
94
+
95
+ // Update canGoBack
96
+ yield* $(model.canGoBack.set(index > 0))
97
+
98
+ // Update canGoForward
99
+ yield* $(model.canGoForward.set(index < events.length - 1))
100
+ })
101
+
102
+ export const makeReload = (model: Model, notify: Notify, save: Save<never>) =>
103
+ Effect.gen(function* ($) {
104
+ const i = yield* $(model.index.get)
105
+ const e = yield* $(model.events)
106
+ const event = e[i]
107
+ const reloadEvent = { ...event, navigationType: NavigationType.Reload }
108
+
109
+ yield* $(notify(reloadEvent))
110
+ yield* $(save(reloadEvent))
111
+
112
+ return event.destination
113
+ })
114
+
115
+ export const makeReplace =
116
+ (model: Model, notify: Notify, save: Save<never>, base: string, origin: string) =>
117
+ (url: string, options: NavigateOptions = {}) =>
118
+ Effect.gen(function* ($) {
119
+ const entry = yield* $(model.currentEntry.get)
120
+ const destination: Destination = {
121
+ key: entry.key,
122
+ url: getUrl(url, base, origin),
123
+ state: options.state,
124
+ }
125
+ const event: NavigationEvent = {
126
+ destination,
127
+ hashChange: entry.url.hash !== destination.url.hash,
128
+ navigationType: NavigationType.Replace,
129
+ }
130
+
131
+ yield* $(notify(event))
132
+
133
+ const currentIndex = yield* $(model.index)
134
+
135
+ yield* $(
136
+ model.events.update((entries) => {
137
+ const updated = entries.slice(0)
138
+ updated[currentIndex] = event
139
+ return updated
140
+ }),
141
+ )
142
+
143
+ yield* $(save(event))
144
+
145
+ return destination
146
+ })
147
+
148
+ export const makePush =
149
+ (
150
+ model: Model,
151
+ notify: Notify,
152
+ save: Save<never>,
153
+ base: string,
154
+ origin: string,
155
+ maxEntries: number,
156
+ ) =>
157
+ (url: string, options: NavigateOptions = {}) =>
158
+ Effect.gen(function* ($) {
159
+ const entry = yield* $(model.currentEntry.get)
160
+ const destination: Destination = {
161
+ key: yield* $(createKey),
162
+ url: getUrl(url, base, origin),
163
+ state: options.state,
164
+ }
165
+ const event: NavigationEvent = {
166
+ destination,
167
+ hashChange: entry.url.hash !== destination.url.hash,
168
+ navigationType: NavigationType.Push,
169
+ }
170
+
171
+ // Notify event handlers
172
+ yield* $(notify(event))
173
+
174
+ const currentIndex = yield* $(model.index)
175
+
176
+ // Remove all entries after the current index
177
+ // and add the new destination to the end
178
+ yield* $(
179
+ model.events.update((entries) => {
180
+ const updated = entries.slice(0, currentIndex + 1)
181
+ updated.push(event)
182
+ return updated.slice(-maxEntries)
183
+ }),
184
+ )
185
+
186
+ // Update the index to the new destination
187
+ yield* $(model.index.update((i) => i + 1))
188
+
189
+ yield* $(save(event))
190
+
191
+ return destination
192
+ })
193
+
194
+ export const makeGo = (model: Model, notify: Notify, save: Save<never>) => (delta: number) =>
195
+ Effect.gen(function* ($) {
196
+ const currentEntries = yield* $(model.events)
197
+ const totalEntries = currentEntries.length
198
+ const currentIndex = yield* $(model.index)
199
+
200
+ // Nothing to do here
201
+ if (delta === 0) return currentEntries[currentIndex].destination
202
+
203
+ const nextIndex =
204
+ delta > 0
205
+ ? Math.min(currentIndex + delta, totalEntries - 1)
206
+ : Math.max(currentIndex + delta, 0)
207
+ const nextEntry = currentEntries[nextIndex]
208
+
209
+ yield* $(
210
+ notify({
211
+ ...nextEntry,
212
+ navigationType: nextIndex > currentIndex ? NavigationType.Forward : NavigationType.Back,
213
+ }),
214
+ )
215
+
216
+ yield* $(model.index.set(nextIndex))
217
+
218
+ yield* $(save(nextEntry))
219
+
220
+ return nextEntry.destination
221
+ })
package/src/model.ts ADDED
@@ -0,0 +1,54 @@
1
+ import * as Effect from '@effect/io/Effect'
2
+ import * as Scope from '@effect/io/Scope'
3
+ import * as Fx from '@typed/fx'
4
+
5
+ import { Destination, NavigationError, NavigationEvent, OnNavigationOptions } from './Navigation.js'
6
+
7
+ export interface Model {
8
+ readonly onNavigationHandlers: Set<
9
+ readonly [
10
+ (event: NavigationEvent) => Effect.Effect<never, NavigationError, unknown>,
11
+ OnNavigationOptions?,
12
+ ]
13
+ >
14
+ readonly onNavigationEndHandlers: Set<
15
+ readonly [
16
+ (event: NavigationEvent) => Effect.Effect<never, never, unknown>,
17
+ OnNavigationOptions?,
18
+ ]
19
+ >
20
+ readonly events: Fx.RefSubject<never, readonly NavigationEvent[]>
21
+ readonly index: Fx.RefSubject<never, number>
22
+ readonly entries: Fx.Computed<never, never, Destination[]>
23
+ readonly currentEntry: Fx.RefSubject<never, Destination>
24
+ readonly canGoBack: Fx.RefSubject<never, boolean>
25
+ readonly canGoForward: Fx.RefSubject<never, boolean>
26
+ }
27
+
28
+ export const makeModel = (
29
+ initialEntries: readonly NavigationEvent[],
30
+ initialIndex: number,
31
+ ): Effect.Effect<Scope.Scope, never, Model> =>
32
+ Effect.gen(function* ($) {
33
+ const events = yield* $(Fx.makeRef(Effect.succeed(initialEntries)))
34
+ const index = yield* $(Fx.makeRef(Effect.succeed(initialIndex)))
35
+ const entries = events.map((es) => es.map((e) => e.destination))
36
+ const currentEntry = yield* $(
37
+ Fx.makeRef(Effect.succeed(initialEntries[initialIndex].destination)),
38
+ )
39
+ const canGoBack = yield* $(Fx.makeRef(Effect.succeed(initialIndex > 0)))
40
+ const canGoForward = yield* $(
41
+ Fx.makeRef(Effect.succeed(initialIndex < initialEntries.length - 1)),
42
+ )
43
+
44
+ return {
45
+ onNavigationHandlers: new Set(),
46
+ onNavigationEndHandlers: new Set(),
47
+ events,
48
+ index,
49
+ entries,
50
+ currentEntry,
51
+ canGoBack,
52
+ canGoForward,
53
+ }
54
+ })
@@ -0,0 +1,120 @@
1
+ import * as Option from '@effect/data/Option'
2
+ import * as Cause from '@effect/io/Cause'
3
+ import * as Effect from '@effect/io/Effect'
4
+ import * as Scope from '@effect/io/Scope'
5
+
6
+ import { Destination, NavigationError, NavigationEvent, OnNavigationOptions } from './Navigation.js'
7
+ import { Model } from './model.js'
8
+
9
+ export type Notify = (event: NavigationEvent) => Effect.Effect<never, NavigationError, void>
10
+ export type NotifyEnd = (event: NavigationEvent) => Effect.Effect<never, never, void>
11
+
12
+ export type Save<R> = (
13
+ event: NavigationEvent,
14
+ ) => Effect.Effect<R, Cause.NoSuchElementException, void>
15
+
16
+ // Anytime there are changes to the model, we need to notify all event handlers
17
+ export const makeNotify = (model: Model) => (event: NavigationEvent) =>
18
+ Effect.gen(function* ($) {
19
+ // Notify event handlers
20
+ if (model.onNavigationHandlers.size > 0)
21
+ yield* $(
22
+ Effect.forEach(
23
+ model.onNavigationHandlers,
24
+ ([handler, options]) => (options?.passive ? Effect.fork(handler(event)) : handler(event)),
25
+ { discard: true },
26
+ ),
27
+ )
28
+ })
29
+
30
+ export const makeOnNavigation =
31
+ (model: Model) =>
32
+ <R>(
33
+ handler: (event: NavigationEvent) => Effect.Effect<R, NavigationError, unknown>,
34
+ options?: OnNavigationOptions,
35
+ ): Effect.Effect<R | Scope.Scope, NavigationError, void> =>
36
+ Effect.uninterruptibleMask((restore) =>
37
+ Effect.gen(function* ($) {
38
+ const context = yield* $(Effect.context<R>())
39
+ const handler_ = (event: NavigationEvent) =>
40
+ restore(Effect.provideContext(handler(event), context))
41
+ const entry = [handler_, options] as const
42
+
43
+ model.onNavigationHandlers.add(entry)
44
+
45
+ yield* $(
46
+ Effect.addFinalizer(() => Effect.sync(() => model.onNavigationHandlers.delete(entry))),
47
+ )
48
+
49
+ // Send the latest navigation event on subscription
50
+ const events = yield* $(model.events)
51
+ const index = yield* $(model.index)
52
+ const event = events[index]
53
+
54
+ yield* $(restore(handler(event)))
55
+ }),
56
+ )
57
+
58
+ // Anytime there are changes to the model, we need to notify all event handlers
59
+ export const makeNotifyEnd = (model: Model) => (event: NavigationEvent) =>
60
+ Effect.gen(function* ($) {
61
+ // Notify event handlers
62
+ if (model.onNavigationEndHandlers.size > 0)
63
+ yield* $(
64
+ Effect.forEach(
65
+ model.onNavigationEndHandlers,
66
+ ([handler, options]) => (options?.passive ? Effect.fork(handler(event)) : handler(event)),
67
+ { discard: true },
68
+ ),
69
+ )
70
+ })
71
+
72
+ export const makeOnNavigationEnd =
73
+ (model: Model) =>
74
+ <R>(
75
+ handler: (event: NavigationEvent) => Effect.Effect<R, never, unknown>,
76
+ options?: OnNavigationOptions,
77
+ ): Effect.Effect<R | Scope.Scope, never, void> =>
78
+ Effect.uninterruptibleMask((restore) =>
79
+ Effect.gen(function* ($) {
80
+ const context = yield* $(Effect.context<R>())
81
+ const handler_ = (event: NavigationEvent) =>
82
+ restore(Effect.provideContext(handler(event), context))
83
+ const entry = [handler_, options] as const
84
+
85
+ model.onNavigationEndHandlers.add(entry)
86
+
87
+ yield* $(
88
+ Effect.addFinalizer(() => Effect.sync(() => model.onNavigationEndHandlers.delete(entry))),
89
+ )
90
+
91
+ // Send the latest navigation event on subscription
92
+ const events = yield* $(model.events)
93
+ const index = yield* $(model.index)
94
+ const event = events[index]
95
+
96
+ yield* $(restore(handler(event)))
97
+ }),
98
+ )
99
+
100
+ export const makeGoTo =
101
+ <R, E>(
102
+ model: Model,
103
+ go: (delta: number, skipHistory?: boolean) => Effect.Effect<R, E, Destination>,
104
+ ) =>
105
+ (key: string): Effect.Effect<R, Cause.NoSuchElementException | E, Option.Option<Destination>> =>
106
+ Effect.gen(function* ($) {
107
+ const entries = yield* $(model.entries)
108
+ const currentIndex = yield* $(model.index)
109
+ const nextIndex = entries.findIndex((destination) => destination.key === key)
110
+
111
+ if (nextIndex === -1) return Option.none()
112
+
113
+ const delta = nextIndex - currentIndex
114
+
115
+ if (delta !== 0) {
116
+ return Option.some(yield* $(go(delta)))
117
+ }
118
+
119
+ return Option.some(entries[nextIndex])
120
+ })
package/src/storage.ts ADDED
@@ -0,0 +1,101 @@
1
+ import * as Option from '@effect/data/Option'
2
+ import * as Effect from '@effect/io/Effect'
3
+ import { History, Location, getItem, setItem } from '@typed/dom'
4
+
5
+ import type { DomNavigationOptions } from './DOM.js'
6
+ import { NavigationEvent, Destination, NavigationType } from './Navigation.js'
7
+ import { NavigationEventJson, decodeNavigationEvent } from './json.js'
8
+ import { createKey, getUrl } from './util.js'
9
+
10
+ const TYPED_NAVIGATION_ENTRIES_KEY = '@typed/navigation/entries'
11
+ const TYPED_NAVIGATION_INDEX_KEY = '@typed/navigation/index'
12
+
13
+ /**
14
+ * @internal
15
+ */
16
+ export const getStoredEvents: Effect.Effect<Storage, never, readonly NavigationEvent[]> =
17
+ Effect.gen(function* ($) {
18
+ const option = yield* $(getItem(TYPED_NAVIGATION_ENTRIES_KEY))
19
+
20
+ if (Option.isNone(option)) {
21
+ return []
22
+ }
23
+
24
+ return (JSON.parse(option.value) as readonly NavigationEventJson[]).map(decodeNavigationEvent)
25
+ })
26
+
27
+ /**
28
+ * @internal
29
+ */
30
+ export const getStoredIndex = Effect.gen(function* ($) {
31
+ const option = yield* $(getItem(TYPED_NAVIGATION_INDEX_KEY))
32
+
33
+ if (Option.isNone(option)) {
34
+ return Option.none()
35
+ }
36
+
37
+ const n = JSON.parse(option.value) as number
38
+
39
+ if (Number.isNaN(n)) {
40
+ return Option.none()
41
+ }
42
+
43
+ return Option.some(n)
44
+ })
45
+
46
+ /**
47
+ * @internal
48
+ */
49
+ export const getInitialValues = (
50
+ base: string,
51
+ options: DomNavigationOptions,
52
+ ): Effect.Effect<
53
+ Storage | History | Location,
54
+ never,
55
+ readonly [readonly NavigationEvent[], number]
56
+ > =>
57
+ Effect.gen(function* ($) {
58
+ // Get Resources
59
+ const history = yield* $(History)
60
+ const location = yield* $(Location)
61
+
62
+ // Read the stored entries and index
63
+ const storedEntries = yield* $(getStoredEvents)
64
+ const storedIndex = Option.getOrElse(yield* $(getStoredIndex), () => storedEntries.length - 1)
65
+ const storedEntry = storedEntries[storedIndex]
66
+
67
+ // Read the initial url from the location
68
+ const initialUrl = getUrl(location.href, base, location.origin)
69
+ const initial: Destination = {
70
+ key: options.initialKey ?? (yield* $(createKey)),
71
+ url: initialUrl,
72
+ state: history.state?.state,
73
+ }
74
+ const initialEvent: NavigationEvent = {
75
+ destination: initial,
76
+ hashChange: false,
77
+ navigationType: NavigationType.Push,
78
+ }
79
+
80
+ // If there are no stored entries then we can just use the initial entry
81
+ if (!storedEntry) {
82
+ return [[initialEvent], 0] as const
83
+ }
84
+
85
+ // If we're starting on the same page as the initial entry
86
+ // then we can just use the initial entries
87
+ if (storedEntry.destination.url.href === initialUrl.href) {
88
+ return [storedEntries, storedIndex] as const
89
+ }
90
+
91
+ // Otherwise, we need to push the initial entry with the current page
92
+ const entries = [...storedEntries.slice(0, storedIndex + 1), initialEvent]
93
+
94
+ return [entries, entries.length - 1] as const
95
+ })
96
+
97
+ export const saveToStorage = (entries: readonly NavigationEvent[], index: number) =>
98
+ Effect.gen(function* ($) {
99
+ yield* $(setItem(TYPED_NAVIGATION_ENTRIES_KEY, JSON.stringify(entries)))
100
+ yield* $(setItem(TYPED_NAVIGATION_INDEX_KEY, JSON.stringify(index)))
101
+ })
package/src/util.ts ADDED
@@ -0,0 +1,20 @@
1
+ import * as Effect from '@effect/io/Effect'
2
+ import { pathJoin } from '@typed/path'
3
+
4
+ import { DestinationKey } from './Navigation.js'
5
+
6
+ export function getUrl(href: string, base: string, origin: string) {
7
+ const url = new URL(href, origin)
8
+
9
+ if (!url.pathname.startsWith(base)) {
10
+ url.pathname = pathJoin(base, url.pathname)
11
+ }
12
+
13
+ return url
14
+ }
15
+
16
+ export const createKey = Effect.randomWith((random) =>
17
+ Effect.map(random.nextIntBetween(0, Number.MAX_SAFE_INTEGER), (n) =>
18
+ DestinationKey(n.toString(36).slice(2, 10)),
19
+ ),
20
+ )
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "exclude": ["src/**/*.test.ts"]
4
+ }