Skip to content

Commit f10bae9

Browse files
JGAntunesSkn0tt
andauthored
fix(dev): reloading edge functions for child dependencies (#6276)
* fix(dev): reloading edge functions for child dependencies * chore: add test * refactor: if linter tells me to change this, I do it! * fix: watch project directory for changes, even if there's no netlify toml * refactor: remove some duplication * refactor: extract multimap * refactor: dont keep variables with same name as class variables * refactor: create functionPaths closer to where the underlying data is updated * refactor: remove useless initialisers * chore: prettier --------- Co-authored-by: Simon Knott <[email protected]>
1 parent a4b7449 commit f10bae9

File tree

3 files changed

+130
-49
lines changed

3 files changed

+130
-49
lines changed

src/lib/edge-functions/registry.ts

Lines changed: 75 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
watchDebounced,
1616
isNodeError,
1717
} from '../../utils/command-helpers.js'
18+
import { MultiMap } from '../../utils/multimap.js'
1819
import { getPathInProject } from '../settings.js'
1920

2021
import { INTERNAL_EDGE_FUNCTIONS_FOLDER } from './consts.js'
@@ -28,6 +29,8 @@ type EdgeFunctionEvent = 'buildError' | 'loaded' | 'reloaded' | 'reloading' | 'r
2829
type Route = Omit<Manifest['routes'][0], 'pattern'> & { pattern: RegExp }
2930
type RunIsolate = Awaited<ReturnType<typeof import('@netlify/edge-bundler').serve>>
3031

32+
type ModuleJson = ModuleGraph['modules'][number]
33+
3134
const featureFlags = { edge_functions_correct_order: true }
3235

3336
interface EdgeFunctionsRegistryOptions {
@@ -44,6 +47,38 @@ interface EdgeFunctionsRegistryOptions {
4447
importMapFromTOML?: string
4548
}
4649

50+
/**
51+
* Helper method which, given a edge bundler graph module and an index of modules by path, traverses its dependency tree
52+
* and returns an array of all of ist local dependencies
53+
*/
54+
function traverseLocalDependencies(
55+
{ dependencies = [] }: ModuleJson,
56+
modulesByPath: Map<string, ModuleJson>,
57+
): string[] {
58+
return dependencies.flatMap((dependency) => {
59+
// We're interested in tracking local dependencies, so we only look at
60+
// specifiers with the `file:` protocol.
61+
if (
62+
dependency.code === undefined ||
63+
typeof dependency.code.specifier !== 'string' ||
64+
!dependency.code.specifier.startsWith('file://')
65+
) {
66+
return []
67+
}
68+
const { specifier: dependencyURL } = dependency.code
69+
const dependencyPath = fileURLToPath(dependencyURL)
70+
const dependencyModule = modulesByPath.get(dependencyPath)
71+
72+
// No module indexed for this dependency
73+
if (dependencyModule === undefined) {
74+
return [dependencyPath]
75+
}
76+
77+
// Keep traversing the child dependencies and return the current dependency path
78+
return [...traverseLocalDependencies(dependencyModule, modulesByPath), dependencyPath]
79+
})
80+
}
81+
4782
export class EdgeFunctionsRegistry {
4883
private buildError: Error | null = null
4984
private bundler: typeof import('@netlify/edge-bundler')
@@ -52,19 +87,28 @@ export class EdgeFunctionsRegistry {
5287
private importMapFromTOML?: string
5388
private declarationsFromDeployConfig: Declaration[] = []
5489
private declarationsFromTOML: Declaration[]
55-
private dependencyPaths = new Map<string, string[]>()
90+
91+
// Mapping file URLs to names of functions that use them as dependencies.
92+
private dependencyPaths = new MultiMap<string, string>()
93+
5694
private directories: string[]
5795
private directoryWatchers = new Map<string, import('chokidar').FSWatcher>()
5896
private env: Record<string, string>
97+
98+
private userFunctions: EdgeFunction[] = []
99+
private internalFunctions: EdgeFunction[] = []
100+
101+
// a Map from `this.functions` that maps function paths to function
102+
// names. This allows us to match modules against functions in O(1) time as
103+
// opposed to O(n).
59104
private functionPaths = new Map<string, string>()
105+
60106
private getUpdatedConfig: () => Promise<Config>
61107
private initialScan: Promise<void>
62-
private internalFunctions: EdgeFunction[] = []
63108
private manifest: Manifest | null = null
64109
private routes: Route[] = []
65110
private runIsolate: RunIsolate
66111
private servePath: string
67-
private userFunctions: EdgeFunction[] = []
68112
private projectDir: string
69113

70114
constructor({
@@ -91,13 +135,6 @@ export class EdgeFunctionsRegistry {
91135
this.declarationsFromTOML = EdgeFunctionsRegistry.getDeclarationsFromTOML(config)
92136
this.env = EdgeFunctionsRegistry.getEnvironmentVariables(env)
93137

94-
this.buildError = null
95-
this.directoryWatchers = new Map()
96-
this.dependencyPaths = new Map()
97-
this.functionPaths = new Map()
98-
this.userFunctions = []
99-
this.internalFunctions = []
100-
101138
this.initialScan = this.doInitialScan()
102139

103140
this.setupWatchers()
@@ -416,50 +453,37 @@ export class EdgeFunctionsRegistry {
416453
return
417454
}
418455

419-
// Creating a Map from `this.functions` that maps function paths to function
420-
// names. This allows us to match modules against functions in O(1) time as
421-
// opposed to O(n).
422-
// eslint-disable-next-line unicorn/prefer-spread
423-
const functionPaths = new Map(Array.from(this.functions, (func) => [func.path, func.name]))
456+
this.dependencyPaths = new MultiMap<string, string>()
424457

425-
// Mapping file URLs to names of functions that use them as dependencies.
426-
const dependencyPaths = new Map<string, string[]>()
458+
// Mapping file URLs to modules. Used by the traversal function.
459+
const modulesByPath = new Map<string, ModuleJson>()
427460

428-
const { modules } = graph
429-
430-
modules.forEach(({ dependencies = [], specifier }) => {
461+
// a set of edge function modules that we'll use to start traversing the dependency tree from
462+
const functionModules = new Set<{ functionName: string; module: ModuleJson }>()
463+
graph.modules.forEach((module) => {
464+
// We're interested in tracking local dependencies, so we only look at
465+
// specifiers with the `file:` protocol.
466+
const { specifier } = module
431467
if (!specifier.startsWith('file://')) {
432468
return
433469
}
434470

435471
const path = fileURLToPath(specifier)
436-
const functionMatch = functionPaths.get(path)
472+
modulesByPath.set(path, module)
437473

438-
if (!functionMatch) {
439-
return
474+
const functionName = this.functionPaths.get(path)
475+
if (functionName) {
476+
functionModules.add({ functionName, module })
440477
}
478+
})
441479

442-
dependencies.forEach((dependency) => {
443-
// We're interested in tracking local dependencies, so we only look at
444-
// specifiers with the `file:` protocol.
445-
if (
446-
dependency.code === undefined ||
447-
typeof dependency.code.specifier !== 'string' ||
448-
!dependency.code.specifier.startsWith('file://')
449-
) {
450-
return
451-
}
452-
453-
const { specifier: dependencyURL } = dependency.code
454-
const dependencyPath = fileURLToPath(dependencyURL)
455-
const functions = dependencyPaths.get(dependencyPath) || []
456-
457-
dependencyPaths.set(dependencyPath, [...functions, functionMatch])
480+
// We start from our functions and we traverse through their dependency tree
481+
functionModules.forEach(({ functionName, module }) => {
482+
const traversedPaths = traverseLocalDependencies(module, modulesByPath)
483+
traversedPaths.forEach((dependencyPath) => {
484+
this.dependencyPaths.add(dependencyPath, functionName)
458485
})
459486
})
460-
461-
this.dependencyPaths = dependencyPaths
462-
this.functionPaths = functionPaths
463487
}
464488

465489
/**
@@ -541,10 +565,19 @@ export class EdgeFunctionsRegistry {
541565
this.internalFunctions = internalFunctions
542566
this.userFunctions = userFunctions
543567

568+
// eslint-disable-next-line unicorn/prefer-spread
569+
this.functionPaths = new Map(Array.from(this.functions, (func) => [func.path, func.name]))
570+
544571
return { all: functions, new: newFunctions, deleted: deletedFunctions }
545572
}
546573

547574
private async setupWatchers() {
575+
// While functions are guaranteed to be inside one of the configured
576+
// directories, they might be importing files that are located in
577+
// parent directories. So we watch the entire project directory for
578+
// changes.
579+
await this.setupWatcherForDirectory()
580+
548581
if (!this.configPath) {
549582
return
550583
}
@@ -560,12 +593,6 @@ export class EdgeFunctionsRegistry {
560593
await this.checkForAddedOrDeletedFunctions()
561594
},
562595
})
563-
564-
// While functions are guaranteed to be inside one of the configured
565-
// directories, they might be importing files that are located in
566-
// parent directories. So we watch the entire project directory for
567-
// changes.
568-
await this.setupWatcherForDirectory()
569596
}
570597

571598
private async setupWatcherForDirectory() {

src/utils/multimap.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export class MultiMap<K, V> {
2+
private map = new Map<K, V[]>()
3+
4+
add(key: K, value: V) {
5+
this.map.set(key, [...(this.map.get(key) ?? []), value])
6+
}
7+
8+
get(key: K): readonly V[] {
9+
return this.map.get(key) ?? []
10+
}
11+
}

tests/integration/commands/dev/edge-functions.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { rename } from 'fs/promises'
33
import { join } from 'path'
44

55
import execa from 'execa'
6+
import fetch from 'node-fetch'
67
import { describe, expect, expectTypeOf, test } from 'vitest'
78

9+
import { withDevServer } from '../../utils/dev-server.js'
810
import { FixtureTestContext, setupFixtureTests } from '../../utils/fixture.js'
9-
import fetch from 'node-fetch'
1011
import { pause } from '../../utils/pause.js'
12+
import { withSiteBuilder } from '../../utils/site-builder.js'
1113

1214
// Skipping tests on Windows because of an issue with the Deno CLI throwing IO
1315
// errors when running in the CI.
@@ -164,6 +166,47 @@ describe.skipIf(isWindows)('edge functions', () => {
164166
})
165167
})
166168

169+
test('should reload on change to transitive dependency', async (t) => {
170+
await withSiteBuilder(t, async (builder) => {
171+
await builder
172+
.withContentFile({
173+
path: 'parent.js',
174+
content: "export { foo } from './child.js'",
175+
})
176+
.withContentFile({
177+
path: 'child.js',
178+
content: "export const foo = 'foo'",
179+
})
180+
.withContentFile({
181+
path: 'netlify/edge-functions/func.js',
182+
content: `
183+
import { foo } from '../../parent.js'
184+
export default async () => new Response(foo)
185+
export const config = { path: '/' }
186+
`,
187+
})
188+
.build()
189+
190+
await withDevServer({ cwd: builder.directory }, async (server) => {
191+
const response = await fetch(server.url, {}).then((res) => res.text())
192+
t.expect(response).toEqual('foo')
193+
194+
// update file
195+
await builder
196+
.withContentFile({
197+
path: 'child.js',
198+
content: "export const foo = 'bar'",
199+
})
200+
.build()
201+
202+
await pause(500)
203+
204+
const response2 = await fetch(server.url, {}).then((res) => res.text())
205+
t.expect(response2).toEqual('bar')
206+
})
207+
})
208+
})
209+
167210
setupFixtureTests(
168211
'dev-server-with-edge-functions-and-npm-modules',
169212
{ devServer: true, mockApi: { routes }, setup },

0 commit comments

Comments
 (0)