@typeonce/effect-machine-devtools 0.23.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.
- package/LICENSE +21 -0
- package/NOTICE +3 -0
- package/README.md +81 -0
- package/dist/DevServer.d.ts +42 -0
- package/dist/DevServer.d.ts.map +1 -0
- package/dist/DevServer.js +28 -0
- package/dist/DevServer.js.map +1 -0
- package/dist/DevToolsProtocol.d.ts +954 -0
- package/dist/DevToolsProtocol.d.ts.map +1 -0
- package/dist/DevToolsProtocol.js +96 -0
- package/dist/DevToolsProtocol.js.map +1 -0
- package/dist/MachineDocument.d.ts +396 -0
- package/dist/MachineDocument.d.ts.map +1 -0
- package/dist/MachineDocument.js +172 -0
- package/dist/MachineDocument.js.map +1 -0
- package/dist/MachineRegistry.d.ts +349 -0
- package/dist/MachineRegistry.d.ts.map +1 -0
- package/dist/MachineRegistry.js +46 -0
- package/dist/MachineRegistry.js.map +1 -0
- package/dist/MachineSimulator.d.ts +169 -0
- package/dist/MachineSimulator.d.ts.map +1 -0
- package/dist/MachineSimulator.js +98 -0
- package/dist/MachineSimulator.js.map +1 -0
- package/dist/ProjectInspector.d.ts +116 -0
- package/dist/ProjectInspector.d.ts.map +1 -0
- package/dist/ProjectInspector.js +82 -0
- package/dist/ProjectInspector.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +23 -0
- package/dist/bin.js.map +1 -0
- package/dist/client/assets/index-BGOhE3Ng.css +1 -0
- package/dist/client/assets/index-DiqGhsGR.js +14 -0
- package/dist/client/index.html +14 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/devServer.d.ts +9 -0
- package/dist/internal/devServer.d.ts.map +1 -0
- package/dist/internal/devServer.js +119 -0
- package/dist/internal/devServer.js.map +1 -0
- package/dist/internal/evaluationWorker.d.ts +3 -0
- package/dist/internal/evaluationWorker.d.ts.map +1 -0
- package/dist/internal/evaluationWorker.js +69 -0
- package/dist/internal/evaluationWorker.js.map +1 -0
- package/dist/internal/evaluationWorkerBootstrap.d.ts +2 -0
- package/dist/internal/evaluationWorkerBootstrap.d.ts.map +1 -0
- package/dist/internal/evaluationWorkerBootstrap.js +21 -0
- package/dist/internal/evaluationWorkerBootstrap.js.map +1 -0
- package/dist/internal/machineDocument.d.ts +4 -0
- package/dist/internal/machineDocument.d.ts.map +1 -0
- package/dist/internal/machineDocument.js +122 -0
- package/dist/internal/machineDocument.js.map +1 -0
- package/dist/internal/machineRegistry.d.ts +12 -0
- package/dist/internal/machineRegistry.d.ts.map +1 -0
- package/dist/internal/machineRegistry.js +97 -0
- package/dist/internal/machineRegistry.js.map +1 -0
- package/dist/internal/machineSimulator.d.ts +5 -0
- package/dist/internal/machineSimulator.d.ts.map +1 -0
- package/dist/internal/machineSimulator.js +156 -0
- package/dist/internal/machineSimulator.js.map +1 -0
- package/dist/internal/projectInspector.d.ts +11 -0
- package/dist/internal/projectInspector.d.ts.map +1 -0
- package/dist/internal/projectInspector.js +150 -0
- package/dist/internal/projectInspector.js.map +1 -0
- package/index.html +13 -0
- package/package.json +69 -0
- package/src/DevServer.ts +45 -0
- package/src/DevToolsProtocol.ts +146 -0
- package/src/MachineDocument.ts +264 -0
- package/src/MachineRegistry.ts +69 -0
- package/src/MachineSimulator.ts +154 -0
- package/src/ProjectInspector.ts +140 -0
- package/src/bin.ts +56 -0
- package/src/index.ts +20 -0
- package/src/internal/browser/example-machine.ts +134 -0
- package/src/internal/browser/machine-index.ts +81 -0
- package/src/internal/browser/main.ts +42 -0
- package/src/internal/browser/styles.css +817 -0
- package/src/internal/browser/text-tree.ts +214 -0
- package/src/internal/browser/visualizer-app.ts +659 -0
- package/src/internal/browser/visualizer-model.ts +173 -0
- package/src/internal/browser/visualizer.ts +31 -0
- package/src/internal/devServer.ts +171 -0
- package/src/internal/evaluationWorker.ts +138 -0
- package/src/internal/evaluationWorkerBootstrap.js +21 -0
- package/src/internal/machineDocument.ts +138 -0
- package/src/internal/machineRegistry.ts +128 -0
- package/src/internal/machineSimulator.ts +199 -0
- package/src/internal/projectInspector.ts +226 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Layer from "effect/Layer"
|
|
3
|
+
import * as SubscriptionRef from "effect/SubscriptionRef"
|
|
4
|
+
import * as DevToolsProtocol from "../DevToolsProtocol.js"
|
|
5
|
+
import type * as MachineRegistry from "../MachineRegistry.js"
|
|
6
|
+
import * as ProjectInspector from "../ProjectInspector.js"
|
|
7
|
+
|
|
8
|
+
interface PublicApi {
|
|
9
|
+
readonly MachineRegistry: typeof MachineRegistry.MachineRegistry
|
|
10
|
+
readonly RegistryError: typeof MachineRegistry.RegistryError
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const staleDiagnostic = (failed: DevToolsProtocol.Failed): DevToolsProtocol.Diagnostic => ({
|
|
14
|
+
severity: "warning",
|
|
15
|
+
code: "stale-document",
|
|
16
|
+
message: "Showing the last valid machine document while this module is incomplete.",
|
|
17
|
+
location: failed.source === null
|
|
18
|
+
? null
|
|
19
|
+
: { file: failed.source.file, line: null, column: null },
|
|
20
|
+
statePath: null
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export const reconcile = (
|
|
24
|
+
previous: ReadonlyArray<DevToolsProtocol.MachineResult>,
|
|
25
|
+
next: ReadonlyArray<DevToolsProtocol.MachineResult>
|
|
26
|
+
): ReadonlyArray<DevToolsProtocol.MachineResult> => {
|
|
27
|
+
const validPrevious = previous.filter((result): result is DevToolsProtocol.Ready | DevToolsProtocol.Partial =>
|
|
28
|
+
result._tag === "Ready" || result._tag === "Partial"
|
|
29
|
+
)
|
|
30
|
+
const previousByKey = new Map(validPrevious.map((result) => [result.key, result]))
|
|
31
|
+
const previousByFile = new Map<string, Array<DevToolsProtocol.Ready | DevToolsProtocol.Partial>>()
|
|
32
|
+
for (const result of validPrevious) {
|
|
33
|
+
const file = result.document.source?.file
|
|
34
|
+
if (file === undefined) continue
|
|
35
|
+
const matches = previousByFile.get(file)
|
|
36
|
+
if (matches === undefined) previousByFile.set(file, [result])
|
|
37
|
+
else matches.push(result)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const emitted = new Set(next.filter((result) => result._tag !== "Failed").map((result) => result.key))
|
|
41
|
+
return next.flatMap((result): ReadonlyArray<DevToolsProtocol.MachineResult> => {
|
|
42
|
+
if (result._tag !== "Failed") return [result]
|
|
43
|
+
|
|
44
|
+
const moduleFailure = result.diagnostics.some((diagnostic) =>
|
|
45
|
+
diagnostic.code === "module-load-failed" || diagnostic.code === "machine-export-not-found"
|
|
46
|
+
)
|
|
47
|
+
const exact = previousByKey.get(result.key)
|
|
48
|
+
const candidates = moduleFailure || exact === undefined
|
|
49
|
+
? previousByFile.get(result.source.file) ?? []
|
|
50
|
+
: [exact]
|
|
51
|
+
const retained = candidates.filter((candidate) => !emitted.has(candidate.key))
|
|
52
|
+
if (retained.length === 0) return [result]
|
|
53
|
+
|
|
54
|
+
for (const candidate of retained) emitted.add(candidate.key)
|
|
55
|
+
return retained.map((candidate): DevToolsProtocol.Partial => ({
|
|
56
|
+
_tag: "Partial",
|
|
57
|
+
protocolVersion: DevToolsProtocol.protocolVersion,
|
|
58
|
+
key: candidate.key,
|
|
59
|
+
document: candidate.document,
|
|
60
|
+
diagnostics: [staleDiagnostic(result), ...result.diagnostics]
|
|
61
|
+
}))
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const retainedCandidates = (
|
|
66
|
+
results: ReadonlyArray<DevToolsProtocol.MachineResult>
|
|
67
|
+
): ReadonlyArray<ProjectInspector.Candidate> => {
|
|
68
|
+
const exportsByFile = new Map<string, Set<string>>()
|
|
69
|
+
for (const result of results) {
|
|
70
|
+
if (result._tag !== "Ready" && result._tag !== "Partial") continue
|
|
71
|
+
const source = result.document.source
|
|
72
|
+
if (source === null) continue
|
|
73
|
+
const exportNames = exportsByFile.get(source.file) ?? new Set<string>()
|
|
74
|
+
if (source.exportName !== null) exportNames.add(source.exportName)
|
|
75
|
+
exportsByFile.set(source.file, exportNames)
|
|
76
|
+
}
|
|
77
|
+
return [...exportsByFile].map(([file, exportNames]) => ({
|
|
78
|
+
file,
|
|
79
|
+
exportNames: [...exportNames].sort()
|
|
80
|
+
}))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const make = (api: PublicApi, options: MachineRegistry.Options) =>
|
|
84
|
+
Effect.gen(function*() {
|
|
85
|
+
const inspector = yield* ProjectInspector.ProjectInspector
|
|
86
|
+
const state = yield* SubscriptionRef.make<MachineRegistry.Snapshot>({
|
|
87
|
+
protocolVersion: DevToolsProtocol.protocolVersion,
|
|
88
|
+
revision: 0,
|
|
89
|
+
results: []
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const refresh = SubscriptionRef.get(state).pipe(
|
|
93
|
+
Effect.flatMap((current) =>
|
|
94
|
+
inspector.inspect({
|
|
95
|
+
...options,
|
|
96
|
+
revision: current.revision + 1,
|
|
97
|
+
retainedCandidates: retainedCandidates(current.results)
|
|
98
|
+
}).pipe(
|
|
99
|
+
Effect.map((results): MachineRegistry.Snapshot => ({
|
|
100
|
+
protocolVersion: DevToolsProtocol.protocolVersion,
|
|
101
|
+
revision: current.revision + 1,
|
|
102
|
+
results: reconcile(current.results, results)
|
|
103
|
+
}))
|
|
104
|
+
)
|
|
105
|
+
),
|
|
106
|
+
Effect.tap((next) => SubscriptionRef.set(state, next)),
|
|
107
|
+
Effect.mapError((cause) =>
|
|
108
|
+
new api.RegistryError({
|
|
109
|
+
message: `Could not inspect Effect Machine definitions under ${options.root}`,
|
|
110
|
+
cause
|
|
111
|
+
})
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
yield* refresh
|
|
116
|
+
|
|
117
|
+
return api.MachineRegistry.of({
|
|
118
|
+
get: SubscriptionRef.get(state),
|
|
119
|
+
changes: SubscriptionRef.changes(state),
|
|
120
|
+
refresh
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
export const layer = (
|
|
125
|
+
api: PublicApi,
|
|
126
|
+
options: MachineRegistry.Options
|
|
127
|
+
): Layer.Layer<MachineRegistry.MachineRegistry, MachineRegistry.RegistryError, ProjectInspector.ProjectInspector> =>
|
|
128
|
+
Layer.effect(api.MachineRegistry, make(api, options))
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type * as MachineDocument from "../MachineDocument.js"
|
|
2
|
+
import type * as MachineSimulator from "../MachineSimulator.js"
|
|
3
|
+
|
|
4
|
+
type State = MachineDocument.State
|
|
5
|
+
type Transition = MachineDocument.Transition
|
|
6
|
+
|
|
7
|
+
interface Model {
|
|
8
|
+
readonly states: ReadonlyMap<string, State>
|
|
9
|
+
readonly order: ReadonlyMap<string, number>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const makeModel = (document: MachineDocument.MachineDocument): Model => ({
|
|
13
|
+
states: new Map(document.states.map((state) => [state.path, state])),
|
|
14
|
+
order: new Map(document.states.map((state) => [state.path, state.order]))
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const ordered = (model: Model, paths: Iterable<string>): ReadonlyArray<string> =>
|
|
18
|
+
[...new Set(paths)].sort((left, right) => (model.order.get(left) ?? 0) - (model.order.get(right) ?? 0))
|
|
19
|
+
|
|
20
|
+
const enter = (model: Model, path: string, active: Set<string>): void => {
|
|
21
|
+
const state = model.states.get(path)
|
|
22
|
+
if (state === undefined) return
|
|
23
|
+
active.add(path)
|
|
24
|
+
if (state.type === "parallel") {
|
|
25
|
+
state.children.forEach((child) => enter(model, child, active))
|
|
26
|
+
} else if (state.type === "compound" && state.initial !== null) {
|
|
27
|
+
enter(model, state.initial, active)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ancestors = (model: Model, path: string): ReadonlyArray<string> => {
|
|
32
|
+
const result: Array<string> = []
|
|
33
|
+
let current = model.states.get(path)
|
|
34
|
+
while (current !== undefined) {
|
|
35
|
+
result.unshift(current.path)
|
|
36
|
+
current = current.parent === null ? undefined : model.states.get(current.parent)
|
|
37
|
+
}
|
|
38
|
+
return result
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const leastCommonAncestor = (model: Model, left: string, right: string): string | null => {
|
|
42
|
+
const leftAncestors = ancestors(model, left)
|
|
43
|
+
const rightAncestors = ancestors(model, right)
|
|
44
|
+
let result: string | null = null
|
|
45
|
+
const length = Math.min(leftAncestors.length, rightAncestors.length)
|
|
46
|
+
for (let index = 0; index < length; index++) {
|
|
47
|
+
if (leftAncestors[index] !== rightAncestors[index]) break
|
|
48
|
+
result = leftAncestors[index] ?? null
|
|
49
|
+
}
|
|
50
|
+
return result
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isDescendant = (model: Model, path: string, ancestor: string): boolean =>
|
|
54
|
+
path !== ancestor && ancestors(model, path).includes(ancestor)
|
|
55
|
+
|
|
56
|
+
const candidateEvents = (
|
|
57
|
+
document: MachineDocument.MachineDocument,
|
|
58
|
+
activePaths: ReadonlyArray<string>
|
|
59
|
+
): ReadonlyArray<string> => {
|
|
60
|
+
const active = new Set(activePaths)
|
|
61
|
+
return [
|
|
62
|
+
...new Set(
|
|
63
|
+
document.transitions.flatMap((transition): ReadonlyArray<string> =>
|
|
64
|
+
transition.trigger.type === "event" && active.has(transition.source) ? [transition.trigger.event] : []
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
].sort()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const snapshot = (
|
|
71
|
+
document: MachineDocument.MachineDocument,
|
|
72
|
+
model: Model,
|
|
73
|
+
step: number,
|
|
74
|
+
activePaths: Iterable<string>
|
|
75
|
+
): MachineSimulator.Snapshot => {
|
|
76
|
+
const active = ordered(model, activePaths)
|
|
77
|
+
return {
|
|
78
|
+
step,
|
|
79
|
+
activePaths: active,
|
|
80
|
+
candidateEvents: candidateEvents(document, active)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const start = (document: MachineDocument.MachineDocument): MachineSimulator.Session => {
|
|
85
|
+
const model = makeModel(document)
|
|
86
|
+
const active = new Set<string>()
|
|
87
|
+
if (document.snapshot === null) {
|
|
88
|
+
const targetAncestors = ancestors(model, document.initial.target)
|
|
89
|
+
targetAncestors.forEach((path) => active.add(path))
|
|
90
|
+
enter(model, document.initial.target, active)
|
|
91
|
+
} else {
|
|
92
|
+
document.snapshot.activePaths.forEach((path) => active.add(path))
|
|
93
|
+
}
|
|
94
|
+
return { document, snapshot: snapshot(document, model, 0, active) }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const indeterminate = (
|
|
98
|
+
session: MachineSimulator.Session,
|
|
99
|
+
event: string,
|
|
100
|
+
transitions: ReadonlyArray<Transition>,
|
|
101
|
+
reason: MachineSimulator.Indeterminate["reason"]
|
|
102
|
+
): MachineSimulator.Indeterminate => ({
|
|
103
|
+
_tag: "Indeterminate",
|
|
104
|
+
event,
|
|
105
|
+
transitionIds: transitions.map((transition) => transition.id),
|
|
106
|
+
session: session.snapshot,
|
|
107
|
+
reason
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const notesFor = (
|
|
111
|
+
document: MachineDocument.MachineDocument,
|
|
112
|
+
transition: Transition,
|
|
113
|
+
nextActive: ReadonlyArray<string>
|
|
114
|
+
): ReadonlyArray<MachineSimulator.Note> => {
|
|
115
|
+
const notes = new Set<MachineSimulator.Note>(["runtime-effects-skipped"])
|
|
116
|
+
if (transition.reenter) notes.add("reentry-lifecycles-skipped")
|
|
117
|
+
if (transition.branches.some((branch) => branch.updates.length > 0)) notes.add("state-updates-skipped")
|
|
118
|
+
const active = new Set(nextActive)
|
|
119
|
+
if (
|
|
120
|
+
document.transitions.some((candidate) =>
|
|
121
|
+
active.has(candidate.source) &&
|
|
122
|
+
(candidate.trigger.type === "always" || candidate.trigger.type === "choice" || candidate.trigger.type === "done")
|
|
123
|
+
)
|
|
124
|
+
) {
|
|
125
|
+
notes.add("automatic-transitions-skipped")
|
|
126
|
+
}
|
|
127
|
+
return [...notes]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const nextConfiguration = (
|
|
131
|
+
session: MachineSimulator.Session,
|
|
132
|
+
transition: Transition,
|
|
133
|
+
target: string | null
|
|
134
|
+
): MachineSimulator.Session | undefined => {
|
|
135
|
+
const document = session.document
|
|
136
|
+
const model = makeModel(document)
|
|
137
|
+
if (target === null) {
|
|
138
|
+
return {
|
|
139
|
+
document,
|
|
140
|
+
snapshot: snapshot(document, model, session.snapshot.step + 1, session.snapshot.activePaths)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!model.states.has(target)) return undefined
|
|
144
|
+
|
|
145
|
+
const naturalBoundary = leastCommonAncestor(model, transition.source, target)
|
|
146
|
+
const sourceParent = model.states.get(transition.source)?.parent ?? null
|
|
147
|
+
const boundary = transition.reenter
|
|
148
|
+
? sourceParent === null || naturalBoundary === null
|
|
149
|
+
? null
|
|
150
|
+
: ancestors(model, naturalBoundary).length <= ancestors(model, sourceParent).length
|
|
151
|
+
? naturalBoundary
|
|
152
|
+
: sourceParent
|
|
153
|
+
: naturalBoundary
|
|
154
|
+
const active = new Set(session.snapshot.activePaths)
|
|
155
|
+
for (const path of active) {
|
|
156
|
+
if (boundary === null || isDescendant(model, path, boundary)) active.delete(path)
|
|
157
|
+
}
|
|
158
|
+
ancestors(model, target).forEach((path) => {
|
|
159
|
+
if (boundary === null || path === boundary || isDescendant(model, path, boundary)) active.add(path)
|
|
160
|
+
})
|
|
161
|
+
enter(model, target, active)
|
|
162
|
+
return { document, snapshot: snapshot(document, model, session.snapshot.step + 1, active) }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const send = (session: MachineSimulator.Session, event: string): MachineSimulator.StepResult => {
|
|
166
|
+
const active = new Set(session.snapshot.activePaths)
|
|
167
|
+
const transitions = session.document.transitions.filter((transition) =>
|
|
168
|
+
transition.trigger.type === "event" && transition.trigger.event === event && active.has(transition.source)
|
|
169
|
+
)
|
|
170
|
+
if (transitions.length === 0) {
|
|
171
|
+
return {
|
|
172
|
+
_tag: "Blocked",
|
|
173
|
+
event,
|
|
174
|
+
transitionIds: [],
|
|
175
|
+
session: session.snapshot,
|
|
176
|
+
reason: "event-not-enabled"
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (transitions.length > 1) return indeterminate(session, event, transitions, "multiple-transitions")
|
|
180
|
+
const transition = transitions[0]!
|
|
181
|
+
if (transition.acceptance === "declinable") {
|
|
182
|
+
return indeterminate(session, event, transitions, "declinable-transition")
|
|
183
|
+
}
|
|
184
|
+
if (transition.branches.length !== 1 || transition.branches[0]?.type === "branch") {
|
|
185
|
+
return indeterminate(session, event, transitions, "conditional-branches")
|
|
186
|
+
}
|
|
187
|
+
const branch = transition.branches[0]!
|
|
188
|
+
if (branch.selection.kind === "history") return indeterminate(session, event, transitions, "history-target")
|
|
189
|
+
if (branch.selection.kind === "choice") return indeterminate(session, event, transitions, "choice-target")
|
|
190
|
+
const next = nextConfiguration(session, transition, branch.target)
|
|
191
|
+
if (next === undefined) return indeterminate(session, event, transitions, "missing-target")
|
|
192
|
+
return {
|
|
193
|
+
_tag: "Applied",
|
|
194
|
+
event,
|
|
195
|
+
transitionIds: [transition.id],
|
|
196
|
+
session: next.snapshot,
|
|
197
|
+
notes: notesFor(session.document, transition, next.snapshot.activePaths)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
|
|
2
|
+
import * as NodePath from "@effect/platform-node/NodePath"
|
|
3
|
+
import * as NodeWorker from "@effect/platform-node/NodeWorker"
|
|
4
|
+
import * as Deferred from "effect/Deferred"
|
|
5
|
+
import * as Effect from "effect/Effect"
|
|
6
|
+
import * as Fiber from "effect/Fiber"
|
|
7
|
+
import * as FileSystem from "effect/FileSystem"
|
|
8
|
+
import * as Layer from "effect/Layer"
|
|
9
|
+
import * as Path from "effect/Path"
|
|
10
|
+
import * as Schema from "effect/Schema"
|
|
11
|
+
import * as Worker from "effect/unstable/workers/Worker"
|
|
12
|
+
import { Worker as NodeWorkerThread } from "node:worker_threads"
|
|
13
|
+
import ts from "typescript"
|
|
14
|
+
import * as DevToolsProtocol from "../DevToolsProtocol.js"
|
|
15
|
+
import type * as ProjectInspector from "../ProjectInspector.js"
|
|
16
|
+
|
|
17
|
+
const defaultInclude = "**/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"
|
|
18
|
+
|
|
19
|
+
const defaultExclude = [
|
|
20
|
+
"**/node_modules/**",
|
|
21
|
+
"**/.git/**",
|
|
22
|
+
"**/dist/**",
|
|
23
|
+
"**/build/**",
|
|
24
|
+
"**/coverage/**",
|
|
25
|
+
"**/.data/**",
|
|
26
|
+
"**/references/**",
|
|
27
|
+
"**/*.d.ts"
|
|
28
|
+
] as const
|
|
29
|
+
|
|
30
|
+
interface EvaluationRequest {
|
|
31
|
+
readonly root: string
|
|
32
|
+
readonly revision: number
|
|
33
|
+
readonly candidates: ReadonlyArray<ProjectInspector.Candidate>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface EvaluationResponse {
|
|
37
|
+
readonly results: ReadonlyArray<unknown>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const scriptKind = (file: string): ts.ScriptKind => {
|
|
41
|
+
if (file.endsWith(".tsx")) return ts.ScriptKind.TSX
|
|
42
|
+
if (file.endsWith(".jsx")) return ts.ScriptKind.JSX
|
|
43
|
+
if (file.endsWith(".js") || file.endsWith(".mjs") || file.endsWith(".cjs")) return ts.ScriptKind.JS
|
|
44
|
+
return ts.ScriptKind.TS
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const hasExportModifier = (node: ts.Node): boolean =>
|
|
48
|
+
ts.canHaveModifiers(node) &&
|
|
49
|
+
ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true
|
|
50
|
+
|
|
51
|
+
const containsHandleCall = (node: ts.Node): boolean => {
|
|
52
|
+
let found = false
|
|
53
|
+
const visit = (current: ts.Node): void => {
|
|
54
|
+
if (found) return
|
|
55
|
+
if (
|
|
56
|
+
ts.isCallExpression(current) &&
|
|
57
|
+
((ts.isPropertyAccessExpression(current.expression) && current.expression.name.text === "handle") ||
|
|
58
|
+
(ts.isElementAccessExpression(current.expression) &&
|
|
59
|
+
ts.isStringLiteral(current.expression.argumentExpression) &&
|
|
60
|
+
current.expression.argumentExpression.text === "handle"))
|
|
61
|
+
) {
|
|
62
|
+
found = true
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
ts.forEachChild(current, visit)
|
|
66
|
+
}
|
|
67
|
+
visit(node)
|
|
68
|
+
return found
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const parseCandidate = (file: string, source: string): ProjectInspector.Candidate | undefined => {
|
|
72
|
+
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind(file))
|
|
73
|
+
if (!containsHandleCall(sourceFile)) return undefined
|
|
74
|
+
|
|
75
|
+
const exportNames = new Set<string>()
|
|
76
|
+
for (const statement of sourceFile.statements) {
|
|
77
|
+
if (ts.isExportAssignment(statement) && containsHandleCall(statement.expression)) {
|
|
78
|
+
exportNames.add("default")
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (ts.isVariableStatement(statement) && hasExportModifier(statement)) {
|
|
82
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
83
|
+
if (
|
|
84
|
+
ts.isIdentifier(declaration.name) && declaration.initializer && containsHandleCall(declaration.initializer)
|
|
85
|
+
) {
|
|
86
|
+
exportNames.add(declaration.name.text)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
file,
|
|
94
|
+
exportNames: [...exportNames].sort()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const hasSyntacticErrors = (file: string, source: string): boolean =>
|
|
99
|
+
ts.transpileModule(source, {
|
|
100
|
+
fileName: file,
|
|
101
|
+
reportDiagnostics: true,
|
|
102
|
+
compilerOptions: {
|
|
103
|
+
allowJs: true,
|
|
104
|
+
jsx: ts.JsxEmit.Preserve,
|
|
105
|
+
target: ts.ScriptTarget.Latest
|
|
106
|
+
}
|
|
107
|
+
}).diagnostics?.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error) === true
|
|
108
|
+
|
|
109
|
+
interface PublicApi {
|
|
110
|
+
readonly ProjectInspector: typeof ProjectInspector.ProjectInspector
|
|
111
|
+
readonly DiscoveryError: typeof ProjectInspector.DiscoveryError
|
|
112
|
+
readonly EvaluationError: typeof ProjectInspector.EvaluationError
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const makeDiscovery = (api: PublicApi) =>
|
|
116
|
+
Effect.gen(function*() {
|
|
117
|
+
const fs = yield* FileSystem.FileSystem
|
|
118
|
+
const path = yield* Path.Path
|
|
119
|
+
|
|
120
|
+
return (options: ProjectInspector.InspectOptions): Effect.Effect<
|
|
121
|
+
ReadonlyArray<ProjectInspector.Candidate>,
|
|
122
|
+
ProjectInspector.DiscoveryError
|
|
123
|
+
> =>
|
|
124
|
+
Effect.gen(function*() {
|
|
125
|
+
const root = path.resolve(options.root)
|
|
126
|
+
const retainedByFile = new Map(
|
|
127
|
+
(options.retainedCandidates ?? []).map((candidate) => [candidate.file, candidate])
|
|
128
|
+
)
|
|
129
|
+
const files = yield* fs.glob(options.include ?? defaultInclude, {
|
|
130
|
+
root,
|
|
131
|
+
exclude: [...defaultExclude, ...(options.exclude ?? [])]
|
|
132
|
+
})
|
|
133
|
+
const candidates = yield* Effect.forEach(
|
|
134
|
+
files.sort(),
|
|
135
|
+
(file) =>
|
|
136
|
+
fs.readFileString(path.join(root, file)).pipe(
|
|
137
|
+
Effect.map((source) => {
|
|
138
|
+
const candidate = parseCandidate(file, source)
|
|
139
|
+
if (candidate !== undefined) return candidate
|
|
140
|
+
const retained = retainedByFile.get(file)
|
|
141
|
+
return retained !== undefined && hasSyntacticErrors(file, source) ? retained : undefined
|
|
142
|
+
})
|
|
143
|
+
),
|
|
144
|
+
{ concurrency: "unbounded" }
|
|
145
|
+
)
|
|
146
|
+
return candidates.filter((candidate): candidate is ProjectInspector.Candidate => candidate !== undefined)
|
|
147
|
+
}).pipe(
|
|
148
|
+
Effect.mapError((cause) =>
|
|
149
|
+
new api.DiscoveryError({
|
|
150
|
+
message: `Could not discover Effect Machine definitions under ${options.root}`,
|
|
151
|
+
cause
|
|
152
|
+
})
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const workerBootstrap = new URL("./evaluationWorkerBootstrap.js", import.meta.url)
|
|
158
|
+
const workerRuntime = new URL(
|
|
159
|
+
import.meta.url.endsWith(".ts") ? "./evaluationWorker.ts" : "./evaluationWorker.js",
|
|
160
|
+
import.meta.url
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const WorkerLayer = NodeWorker.layer(() =>
|
|
164
|
+
new NodeWorkerThread(workerBootstrap, {
|
|
165
|
+
execArgv: [],
|
|
166
|
+
workerData: {
|
|
167
|
+
runtimeModuleUrl: workerRuntime.href
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const evaluate = (
|
|
173
|
+
api: PublicApi,
|
|
174
|
+
candidates: ReadonlyArray<ProjectInspector.Candidate>,
|
|
175
|
+
options: ProjectInspector.InspectOptions
|
|
176
|
+
): Effect.Effect<ReadonlyArray<DevToolsProtocol.MachineResult>, ProjectInspector.EvaluationError> => {
|
|
177
|
+
if (candidates.length === 0) return Effect.succeed([])
|
|
178
|
+
|
|
179
|
+
const request: EvaluationRequest = {
|
|
180
|
+
root: options.root,
|
|
181
|
+
revision: options.revision ?? 0,
|
|
182
|
+
candidates
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return Effect.scoped(
|
|
186
|
+
Effect.gen(function*() {
|
|
187
|
+
const platform = yield* Worker.WorkerPlatform
|
|
188
|
+
const worker = yield* platform.spawn<EvaluationResponse, EvaluationRequest>(0)
|
|
189
|
+
const response = yield* Deferred.make<EvaluationResponse>()
|
|
190
|
+
const runner = yield* Effect.forkScoped(
|
|
191
|
+
worker.run((message) => Deferred.succeed(response, message))
|
|
192
|
+
)
|
|
193
|
+
yield* worker.send(request)
|
|
194
|
+
const message = yield* Effect.raceFirst(Deferred.await(response), Fiber.join(runner))
|
|
195
|
+
return yield* Effect.forEach(
|
|
196
|
+
message.results,
|
|
197
|
+
(result) => Schema.decodeUnknownEffect(DevToolsProtocol.MachineResult)(result)
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
).pipe(
|
|
201
|
+
Effect.provide(WorkerLayer),
|
|
202
|
+
Effect.mapError((cause) =>
|
|
203
|
+
new api.EvaluationError({
|
|
204
|
+
message: "The isolated machine evaluator failed",
|
|
205
|
+
cause
|
|
206
|
+
})
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const make = (api: PublicApi) =>
|
|
212
|
+
Effect.gen(function*() {
|
|
213
|
+
const discover = yield* makeDiscovery(api)
|
|
214
|
+
const inspect = (options: ProjectInspector.InspectOptions) =>
|
|
215
|
+
Effect.flatMap(discover(options), (candidates) => evaluate(api, candidates, options))
|
|
216
|
+
return api.ProjectInspector.of({
|
|
217
|
+
discover,
|
|
218
|
+
evaluate: (candidates, options) => evaluate(api, candidates, options),
|
|
219
|
+
inspect
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const PlatformLayer = Layer.merge(NodeFileSystem.layer, NodePath.layer)
|
|
224
|
+
|
|
225
|
+
export const layer = (api: PublicApi): Layer.Layer<ProjectInspector.ProjectInspector> =>
|
|
226
|
+
Layer.effect(api.ProjectInspector, make(api)).pipe(Layer.provide(PlatformLayer))
|