@doweljs/vite 0.0.0-stage → 0.2.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.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The project-wide graph assembled from per-module analyses.
3
+ *
4
+ * Nodes are identified by `kind:name`. Names are unique the way the runtime
5
+ * requires: scopes share one name space, and slots, services and executions
6
+ * share another. Dependency references are resolved through each module's
7
+ * imports and re-exports, so edges cross module boundaries and barrels.
8
+ */
9
+
10
+ import type { Declaration, ModuleAnalysis, Ref } from './analyze.ts'
11
+
12
+ export interface GraphEdge {
13
+ readonly key: string
14
+ /** Target node id, or null when the reference could not be resolved statically. */
15
+ readonly target: string | null
16
+ readonly optional: boolean
17
+ }
18
+
19
+ export interface GraphNode {
20
+ readonly id: string
21
+ readonly kind: 'slot' | 'service' | 'scope' | 'execution'
22
+ readonly name: string
23
+ /** The scope's name when known statically, or null when it is inferred at runtime. */
24
+ readonly scope: string | null
25
+ /** For scopes: the parent scope's node id. */
26
+ readonly parent: string | null
27
+ /** Whether a slot is a multi-binding; null when its options cannot be read statically. */
28
+ readonly many: boolean | null
29
+ readonly module: string
30
+ readonly local: string
31
+ /** Offsets of the declaration in the code the plugin received, after earlier transforms. */
32
+ readonly start: number
33
+ readonly end: number
34
+ readonly deps: readonly GraphEdge[]
35
+ }
36
+
37
+ export interface Duplicate {
38
+ /** The name, prefixed with `scope ` for scopes. */
39
+ readonly id: string
40
+ /** Every module declaring it, once per declaration. */
41
+ readonly modules: readonly string[]
42
+ }
43
+
44
+ export interface Graph {
45
+ readonly nodes: readonly GraphNode[]
46
+ readonly duplicates: readonly Duplicate[]
47
+ }
48
+
49
+ interface Entry {
50
+ readonly analysis: ModuleAnalysis
51
+ /** Import and re-export sources to the module ids they resolve to. */
52
+ readonly resolved: ReadonlyMap<string, string>
53
+ }
54
+
55
+ const nodeId = (declaration: Pick<Declaration, 'kind' | 'name'>): string =>
56
+ `${declaration.kind}:${declaration.name}`
57
+
58
+ /** The runtime's name space for a declaration (see `keyOf` in `@doweljs/core/hot`). */
59
+ const nameKey = (declaration: Pick<Declaration, 'kind' | 'name'>): string =>
60
+ declaration.kind === 'scope' ? `scope ${declaration.name}` : declaration.name
61
+
62
+ /** How deep re-export and import chains are followed. */
63
+ const MAX_HOPS = 8
64
+
65
+ export class Registry {
66
+ private readonly modules = new Map<string, Entry>()
67
+ /** Declarations by node id, then by module. */
68
+ private readonly byId = new Map<string, Map<string, Declaration>>()
69
+ /** Declaration counts by name key, then by module. */
70
+ private readonly byName = new Map<string, Map<string, number>>()
71
+
72
+ set(id: string, analysis: ModuleAnalysis, resolved: ReadonlyMap<string, string>): void {
73
+ this.delete(id)
74
+ this.modules.set(id, { analysis, resolved })
75
+ for (const declaration of analysis.declarations) {
76
+ index(this.byId, nodeId(declaration)).set(id, declaration)
77
+ const counts = index(this.byName, nameKey(declaration))
78
+ counts.set(id, (counts.get(id) ?? 0) + 1)
79
+ }
80
+ }
81
+
82
+ delete(id: string): void {
83
+ const entry = this.modules.get(id)
84
+ if (entry === undefined) return
85
+ this.modules.delete(id)
86
+ for (const declaration of entry.analysis.declarations) {
87
+ unindex(this.byId, nodeId(declaration), id)
88
+ unindex(this.byName, nameKey(declaration), id)
89
+ }
90
+ }
91
+
92
+ /** Forgets every module of a deleted file, queries included. */
93
+ deleteFile(file: string): void {
94
+ for (const id of [...this.modules.keys()]) {
95
+ if (id === file || id.startsWith(`${file}?`)) this.delete(id)
96
+ }
97
+ }
98
+
99
+ /** Names a module declares that are also declared elsewhere, or more than once in it. */
100
+ duplicatesOf(id: string): Duplicate[] {
101
+ const entry = this.modules.get(id)
102
+ if (entry === undefined) return []
103
+ const keys = new Set(entry.analysis.declarations.map(nameKey))
104
+ return [...keys].flatMap((key) => duplicate(key, this.byName.get(key)))
105
+ }
106
+
107
+ graph(): Graph {
108
+ const nodes: GraphNode[] = []
109
+ for (const [module, entry] of this.modules) {
110
+ for (const declaration of entry.analysis.declarations) {
111
+ nodes.push({
112
+ id: nodeId(declaration),
113
+ kind: declaration.kind,
114
+ name: declaration.name,
115
+ scope: this.resolveScope(module, declaration),
116
+ parent:
117
+ declaration.kind === 'scope' && declaration.scopeRef !== null
118
+ ? this.resolveRef(module, declaration.scopeRef)
119
+ : null,
120
+ many: declaration.many,
121
+ module,
122
+ local: declaration.local,
123
+ start: declaration.start,
124
+ end: declaration.end,
125
+ deps: declaration.deps.map((dep) => {
126
+ const target = dep.ref === null ? null : this.resolveRef(module, dep.ref)
127
+ return {
128
+ key: dep.key,
129
+ target,
130
+ optional: dep.optional,
131
+ }
132
+ }),
133
+ })
134
+ }
135
+ }
136
+ const duplicates = [...this.byName].flatMap(([key, counts]) => duplicate(key, counts))
137
+ return { nodes, duplicates }
138
+ }
139
+
140
+ /**
141
+ * The scope name of a declaration: literal, or the name of the referenced
142
+ * scope declaration. An execution lives in the parent of the scope its
143
+ * runs open, as it does at runtime.
144
+ */
145
+ private resolveScope(module: string, declaration: Declaration): string | null {
146
+ if (declaration.scope !== null) return declaration.scope
147
+ if (declaration.scopeRef === null) return null
148
+ const id = this.resolveRef(module, declaration.scopeRef)
149
+ const found = id === null ? null : this.find(id)
150
+ if (found === null) return null
151
+ return declaration.kind === 'execution'
152
+ ? this.resolveScope(found.module, found.declaration)
153
+ : found.declaration.name
154
+ }
155
+
156
+ private find(id: string): { module: string; declaration: Declaration } | null {
157
+ const declarations = this.byId.get(id)
158
+ if (declarations === undefined) return null
159
+ for (const [module, declaration] of declarations) return { module, declaration }
160
+ return null
161
+ }
162
+
163
+ /** Resolves a reference in a module to a node id, following imports and re-exports. */
164
+ private resolveRef(module: string, ref: Ref, hops = 0): string | null {
165
+ const entry = this.modules.get(module)
166
+ if (entry === undefined || hops > MAX_HOPS) return null
167
+ const local = entry.analysis.declarations.find((d) => d.local === ref.local)
168
+ if (local !== undefined) return ref.member === null ? nodeId(local) : null
169
+ const imported = entry.analysis.imports.get(ref.local)
170
+ const source = imported === undefined ? undefined : entry.resolved.get(imported.source)
171
+ if (imported === undefined || source === undefined) return null
172
+ if (imported.imported === '*') {
173
+ return ref.member === null ? null : this.resolveExport(source, ref.member, hops + 1)
174
+ }
175
+ return ref.member === null ? this.resolveExport(source, imported.imported, hops + 1) : null
176
+ }
177
+
178
+ /** Resolves a name a module exports to a node id. */
179
+ private resolveExport(module: string, name: string, hops: number): string | null {
180
+ const entry = this.modules.get(module)
181
+ if (entry === undefined || hops > MAX_HOPS) return null
182
+ const local = entry.analysis.exports.get(name)
183
+ if (local !== undefined) return this.resolveRef(module, { local, member: null }, hops + 1)
184
+ const reexport = entry.analysis.reexports.get(name)
185
+ if (reexport !== undefined) {
186
+ const source = entry.resolved.get(reexport.source)
187
+ if (source === undefined || reexport.imported === '*') return null
188
+ return this.resolveExport(source, reexport.imported, hops + 1)
189
+ }
190
+ for (const star of entry.analysis.stars) {
191
+ const source = entry.resolved.get(star)
192
+ const found = source === undefined ? null : this.resolveExport(source, name, hops + 1)
193
+ if (found !== null) return found
194
+ }
195
+ return null
196
+ }
197
+ }
198
+
199
+ /**
200
+ * The import and re-export sources a module's graph edges may lead through:
201
+ * those `Registry` follows when it resolves the module's references and
202
+ * exports. The plugin resolves exactly these to module ids.
203
+ */
204
+ export function sourcesToResolve(analysis: ModuleAnalysis): Set<string> {
205
+ const sources = new Set<string>(analysis.stars)
206
+ for (const reexport of analysis.reexports.values()) sources.add(reexport.source)
207
+ const locals = new Set<string>(analysis.exports.values())
208
+ for (const declaration of analysis.declarations) {
209
+ for (const ref of [declaration.scopeRef, ...declaration.deps.map((dep) => dep.ref)]) {
210
+ if (ref !== null) locals.add(ref.local)
211
+ }
212
+ }
213
+ for (const local of locals) {
214
+ const imported = analysis.imports.get(local)
215
+ if (imported !== undefined) sources.add(imported.source)
216
+ }
217
+ return sources
218
+ }
219
+
220
+ function index<V>(map: Map<string, Map<string, V>>, key: string): Map<string, V> {
221
+ let inner = map.get(key)
222
+ if (inner === undefined) {
223
+ inner = new Map()
224
+ map.set(key, inner)
225
+ }
226
+ return inner
227
+ }
228
+
229
+ function unindex(map: Map<string, Map<string, unknown>>, key: string, module: string): void {
230
+ const inner = map.get(key)
231
+ inner?.delete(module)
232
+ if (inner?.size === 0) map.delete(key)
233
+ }
234
+
235
+ function duplicate(key: string, counts: ReadonlyMap<string, number> | undefined): Duplicate[] {
236
+ if (counts === undefined) return []
237
+ const modules = [...counts].flatMap(([module, count]) => Array<string>(count).fill(module))
238
+ return modules.length > 1 ? [{ id: key, modules }] : []
239
+ }
package/src/index.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `@doweljs/vite`: Vite integration for dowel.
3
+ *
4
+ * - Hot replacement: editing a service swaps it (and its dependents) inside
5
+ * live containers without reloading, keeping unrelated instances such as
6
+ * database connections alive.
7
+ * - Diagnostics: duplicate slot or service names across modules are reported
8
+ * at transform time.
9
+ * - A static graph of every declaration, which `@doweljs/devtools` draws.
10
+ *
11
+ * ```ts
12
+ * import dowel from '@doweljs/vite'
13
+ * export default defineConfig({ plugins: [dowel()] })
14
+ * ```
15
+ */
16
+
17
+ import type { Plugin } from 'vite'
18
+ import { analyze, mayDeclare } from './analysis/analyze.ts'
19
+ import { type Graph, Registry, sourcesToResolve } from './analysis/registry.ts'
20
+ import { inject } from './inject.ts'
21
+ import { showHotErrors } from './overlay.ts'
22
+
23
+ export type { Duplicate, Graph, GraphEdge, GraphNode } from './analysis/registry.ts'
24
+
25
+ export interface DowelPluginOptions {
26
+ /**
27
+ * How modules that declare slots or services take part in hot replacement.
28
+ * - `auto` (default): modules register their declarations on every
29
+ * evaluation, and accept their own updates when every export is a dowel
30
+ * declaration. Mixed modules fall back to Vite's normal propagation.
31
+ * - `always`: every declaring module accepts its own updates.
32
+ * - `false`: no hot replacement code is injected.
33
+ */
34
+ readonly hot?: 'auto' | 'always' | false
35
+ /** Module ids to analyze. Default: every `.js`/`.ts` module. */
36
+ readonly include?: RegExp | readonly RegExp[]
37
+ /** Module ids to skip, in addition to `node_modules`, which is always skipped. */
38
+ readonly exclude?: RegExp | readonly RegExp[]
39
+ }
40
+
41
+ /** What other plugins can ask of the dowel plugin, found by its name in the resolved config. */
42
+ export interface DowelPluginApi {
43
+ /** The graph as currently known from static analysis. */
44
+ graph(): Graph
45
+ /**
46
+ * Has every module given hot code import `id` too, so it runs in each
47
+ * environment that declares nodes. The devtools load their client this way.
48
+ */
49
+ addClientModule(id: string): void
50
+ }
51
+
52
+ export interface DowelPlugin extends Plugin {
53
+ readonly api: DowelPluginApi
54
+ }
55
+
56
+ const DEFAULT_INCLUDE = /\.[cm]?[jt]sx?$/
57
+ const DEFAULT_EXCLUDE = /node_modules/
58
+
59
+ export default function dowel(options: DowelPluginOptions = {}): DowelPlugin {
60
+ const hot = options.hot ?? 'auto'
61
+ const registry = new Registry()
62
+ const clients = new Set<string>()
63
+ let serve = false
64
+ /** Modules given hot code, by environment: each environment's runtime keeps its own registrations. */
65
+ const hotModules = new Set<string>()
66
+ const hotKey = (environment: string | undefined, id: string) => `${environment ?? ''}\0${id}`
67
+ /**
68
+ * A module that registered declarations and now has none still registers
69
+ * the empty set, so @doweljs/core/hot releases its names for other modules.
70
+ */
71
+ const release = (environment: string | undefined, code: string, id: string) => {
72
+ if (!serve || hot === false || !hotModules.has(hotKey(environment, id))) return
73
+ return inject(code, { id, locals: [], accept: false, clients: [] })
74
+ }
75
+
76
+ return {
77
+ name: '@doweljs/vite',
78
+ enforce: 'post',
79
+
80
+ api: {
81
+ graph: () => registry.graph(),
82
+ addClientModule: (id) => void clients.add(id),
83
+ },
84
+
85
+ config(config, { command }) {
86
+ // The injected `import "@doweljs/core/hot"` is invisible to the dependency scanner. Optimizing it
87
+ // together with dowel up front avoids a re-optimization and reload on first load, during
88
+ // which the two could be separate copies.
89
+ if (command !== 'serve' || hot === false) return
90
+ if (config.optimizeDeps?.exclude?.includes('@doweljs/core')) return
91
+ return { optimizeDeps: { include: ['@doweljs/core', '@doweljs/core/hot'] } }
92
+ },
93
+
94
+ configResolved(config) {
95
+ // No hot code where nothing replaces modules: test runners such as Vitest, or HMR turned off.
96
+ serve =
97
+ config.command === 'serve' &&
98
+ config.server.hmr !== false &&
99
+ process.env['VITEST'] === undefined
100
+ },
101
+
102
+ transform: {
103
+ filter: {
104
+ id: {
105
+ include: patterns(options.include ?? DEFAULT_INCLUDE),
106
+ exclude: [DEFAULT_EXCLUDE, ...patterns(options.exclude ?? [])],
107
+ },
108
+ },
109
+ async handler(code, id) {
110
+ const analysis = mayDeclare(code) ? analyze(id, code) : null
111
+ if (analysis === null) {
112
+ registry.delete(id)
113
+ return release(this.environment?.name, code, id)
114
+ }
115
+
116
+ // Only the sources the graph can follow, resolved in parallel.
117
+ const sources = sourcesToResolve(analysis)
118
+ const resolved = new Map<string, string>()
119
+ await Promise.all(
120
+ [...sources].map(async (source) => {
121
+ const target = await this.resolve(source, id)
122
+ if (target !== null) resolved.set(source, target.id)
123
+ }),
124
+ )
125
+ registry.set(id, analysis, resolved)
126
+
127
+ for (const duplicate of registry.duplicatesOf(id)) {
128
+ this.warn(
129
+ `'${duplicate.id}' is declared more than once. Names must be unique; ` +
130
+ `hot replacement treats these as the same node.\n ${duplicate.modules.join('\n ')}`,
131
+ )
132
+ }
133
+
134
+ if (analysis.declarations.length === 0) return release(this.environment?.name, code, id)
135
+ if (!serve || hot === false) return
136
+ hotModules.add(hotKey(this.environment?.name, id))
137
+ return inject(code, {
138
+ id,
139
+ locals: analysis.declarations.map((declaration) => declaration.local),
140
+ accept: hot === 'always' || analysis.selfContained,
141
+ clients: [...clients],
142
+ })
143
+ },
144
+ },
145
+
146
+ configureServer(server) {
147
+ server.watcher.on('unlink', (file) => registry.deleteFile(file))
148
+ if (hot !== false) showHotErrors(server)
149
+ },
150
+ }
151
+ }
152
+
153
+ function patterns(value: RegExp | readonly RegExp[]): RegExp[] {
154
+ return value instanceof RegExp ? [value] : [...value]
155
+ }
package/src/inject.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Appends the development-only hot replacement snippet to a module that
3
+ * declares slots or services. Appending keeps every original line in place,
4
+ * so the generated source map is a plain extension of the original.
5
+ */
6
+
7
+ import { MagicString } from 'magic-string'
8
+ import { HOT_ERROR } from './overlay.ts'
9
+
10
+ export interface InjectOptions {
11
+ /** Module id, used as the registration key for `@doweljs/core/hot`. */
12
+ readonly id: string
13
+ /** Local names of the module's slot and service declarations. */
14
+ readonly locals: readonly string[]
15
+ /** Whether the module should accept its own updates. */
16
+ readonly accept: boolean
17
+ /** Modules other plugins asked every module with hot code to import. */
18
+ readonly clients: readonly string[]
19
+ }
20
+
21
+ export function inject(
22
+ code: string,
23
+ options: InjectOptions,
24
+ ): { code: string; map: ReturnType<MagicString['generateMap']> } {
25
+ const s = new MagicString(code)
26
+ const declarations = options.locals.join(', ')
27
+ s.append(`
28
+
29
+ // @doweljs/vite: hot replacement
30
+ import * as __dowel_hot__ from "@doweljs/core/hot";
31
+ ${options.clients.map((client) => `import ${JSON.stringify(client)};\n`).join('')}if (import.meta.hot) {
32
+ __dowel_hot__.update(${JSON.stringify(options.id)}, { ${declarations} }).then(
33
+ (result) => {
34
+ if (result.changed.length > 0) console.info("[dowel] replaced " + result.changed.join(", "));
35
+ },
36
+ (error) => {
37
+ const message = __dowel_hot__.describeFailure(error);
38
+ console.error(message);
39
+ import.meta.hot.send(${JSON.stringify(HOT_ERROR)}, { message, stack: String((error && error.stack) || "") });
40
+ },
41
+ );
42
+ // Release the module's names once nothing imports it any more, so another module may declare them.
43
+ import.meta.hot.prune(() => __dowel_hot__.forget(${JSON.stringify(options.id)}));
44
+ ${options.accept ? '\timport.meta.hot.accept();\n' : ''}}
45
+ `)
46
+ // Only appended code changes, so a map at token boundaries is exact enough.
47
+ return { code: s.toString(), map: s.generateMap({ hires: 'boundary' }) }
48
+ }
package/src/overlay.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Hot replacement failures in Vite's error overlay. The injected code reports
3
+ * a failed update over its environment's hot channel; the plugin shows it in
4
+ * the browser, whichever environment it came from.
5
+ */
6
+
7
+ import type { ViteDevServer } from 'vite'
8
+
9
+ /** The event injected code sends when an update fails. */
10
+ export const HOT_ERROR = 'dowel:error'
11
+
12
+ export interface HotErrorMessage {
13
+ readonly message: string
14
+ readonly stack: string
15
+ }
16
+
17
+ /** Shows the failures every environment of `server` reports in the browser's error overlay. */
18
+ export function showHotErrors(server: ViteDevServer): void {
19
+ for (const environment of Object.values(server.environments)) {
20
+ environment.hot.on(HOT_ERROR, (data: HotErrorMessage) => {
21
+ server.environments.client.hot.send({
22
+ type: 'error',
23
+ err: { message: data.message, stack: data.stack, plugin: '@doweljs/vite' },
24
+ })
25
+ })
26
+ }
27
+ }