@eighty4/dank 0.0.3 → 0.0.4-1

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/lib/http.ts CHANGED
@@ -8,34 +8,162 @@ import {
8
8
  type ServerResponse,
9
9
  } from 'node:http'
10
10
  import { extname, join } from 'node:path'
11
+ import { Readable } from 'node:stream'
11
12
  import mime from 'mime'
12
- import { isLogHttp } from './flags.ts'
13
+ import type { DankServe } from './flags.ts'
14
+ import type { WebsiteManifest } from './metadata.ts'
15
+ import type { HttpServices } from './services.ts'
13
16
 
14
17
  export type FrontendFetcher = (
15
18
  url: URL,
16
19
  headers: Headers,
17
20
  res: ServerResponse,
21
+ notFound: () => void,
18
22
  ) => void
19
23
 
20
- export function createWebServer(
21
- port: number,
24
+ // state needed to eval url rewriting after FrontendFetcher and before HttpServices
25
+ export type PageRouteState = {
26
+ // urls of html entrypoints
27
+ urls: Array<string>
28
+ urlRewrites: Array<UrlRewrite>
29
+ }
30
+
31
+ export type UrlRewrite = {
32
+ pattern: RegExp
33
+ url: string
34
+ }
35
+
36
+ export function startWebServer(
37
+ serve: DankServe,
22
38
  frontendFetcher: FrontendFetcher,
23
- ): ReturnType<typeof createServer> {
24
- const serverAddress = 'http://localhost:' + port
39
+ httpServices: HttpServices,
40
+ pageRoutes: PageRouteState,
41
+ ) {
42
+ const serverAddress = 'http://localhost:' + serve.dankPort
25
43
  const handler = (req: IncomingMessage, res: ServerResponse) => {
26
44
  if (!req.url || !req.method) {
27
45
  res.end()
28
46
  } else {
29
47
  const url = new URL(serverAddress + req.url)
30
- if (req.method !== 'GET') {
31
- res.writeHead(405)
32
- res.end()
48
+ const headers = convertHeadersToFetch(req.headers)
49
+ frontendFetcher(url, headers, res, () =>
50
+ onNotFound(
51
+ req,
52
+ url,
53
+ headers,
54
+ httpServices,
55
+ pageRoutes,
56
+ serve,
57
+ res,
58
+ ),
59
+ )
60
+ }
61
+ }
62
+ createServer(serve.logHttp ? createLogWrapper(handler) : handler).listen(
63
+ serve.dankPort,
64
+ )
65
+ console.log(
66
+ serve.preview ? 'preview' : 'dev',
67
+ `server is live at http://127.0.0.1:${serve.dankPort}`,
68
+ )
69
+ }
70
+
71
+ async function onNotFound(
72
+ req: IncomingMessage,
73
+ url: URL,
74
+ headers: Headers,
75
+ httpServices: HttpServices,
76
+ pageRoutes: PageRouteState,
77
+ serve: DankServe,
78
+ res: ServerResponse,
79
+ ) {
80
+ if (req.method === 'GET' && extname(url.pathname) === '') {
81
+ const urlRewrite = tryUrlRewrites(url, pageRoutes, serve)
82
+ if (urlRewrite) {
83
+ streamFile(urlRewrite, res)
84
+ return
85
+ }
86
+ }
87
+ const fetchResponse = await tryHttpServices(req, url, headers, httpServices)
88
+ if (fetchResponse) {
89
+ sendFetchResponse(res, fetchResponse)
90
+ } else {
91
+ res.writeHead(404)
92
+ res.end()
93
+ }
94
+ }
95
+
96
+ async function sendFetchResponse(res: ServerResponse, fetchResponse: Response) {
97
+ res.writeHead(
98
+ fetchResponse.status,
99
+ undefined,
100
+ convertHeadersFromFetch(fetchResponse.headers),
101
+ )
102
+ if (fetchResponse.body) {
103
+ Readable.fromWeb(fetchResponse.body).pipe(res)
104
+ } else {
105
+ res.end()
106
+ }
107
+ }
108
+
109
+ function tryUrlRewrites(
110
+ url: URL,
111
+ pageRoutes: PageRouteState,
112
+ serve: DankServe,
113
+ ): string | null {
114
+ const urlRewrite = pageRoutes.urlRewrites.find(urlRewrite =>
115
+ urlRewrite.pattern.test(url.pathname),
116
+ )
117
+ return urlRewrite
118
+ ? join(serve.dirs.buildWatch, urlRewrite.url, 'index.html')
119
+ : null
120
+ }
121
+
122
+ async function tryHttpServices(
123
+ req: IncomingMessage,
124
+ url: URL,
125
+ headers: Headers,
126
+ httpServices: HttpServices,
127
+ ): Promise<Response | null> {
128
+ if (url.pathname.startsWith('/.well-known/')) {
129
+ return null
130
+ }
131
+ const body = await collectReqBody(req)
132
+ const { running } = httpServices
133
+ for (const httpService of running) {
134
+ const proxyUrl = new URL(url)
135
+ proxyUrl.port = `${httpService.port}`
136
+ try {
137
+ const response = await retryFetchWithTimeout(proxyUrl, {
138
+ body,
139
+ headers,
140
+ method: req.method,
141
+ redirect: 'manual',
142
+ })
143
+ if (response.status === 404 || response.status === 405) {
144
+ continue
33
145
  } else {
34
- frontendFetcher(url, convertHeadersToFetch(req.headers), res)
146
+ return response
147
+ }
148
+ } catch (e: any) {
149
+ if (e === 'retrytimeout') {
150
+ continue
151
+ } else {
152
+ errorExit(
153
+ `unexpected error http proxying to port ${httpService.port}: ${e.message}`,
154
+ )
35
155
  }
36
156
  }
37
157
  }
38
- return createServer(isLogHttp() ? createLogWrapper(handler) : handler)
158
+ return null
159
+ }
160
+
161
+ function collectReqBody(req: IncomingMessage): Promise<string | null> {
162
+ let body = ''
163
+ req.on('data', data => (body += data.toString()))
164
+ return new Promise(res =>
165
+ req.on('end', () => res(body.length ? body : null)),
166
+ )
39
167
  }
40
168
 
41
169
  type RequestListener = (req: IncomingMessage, res: ServerResponse) => void
@@ -51,57 +179,65 @@ function createLogWrapper(handler: RequestListener): RequestListener {
51
179
 
52
180
  export function createBuiltDistFilesFetcher(
53
181
  dir: string,
54
- files: Set<string>,
182
+ manifest: WebsiteManifest,
55
183
  ): FrontendFetcher {
56
- return (url: URL, _headers: Headers, res: ServerResponse) => {
57
- if (!files.has(url.pathname)) {
58
- res.writeHead(404)
59
- res.end()
184
+ return (
185
+ url: URL,
186
+ _headers: Headers,
187
+ res: ServerResponse,
188
+ notFound: () => void,
189
+ ) => {
190
+ if (manifest.pageUrls.has(url.pathname)) {
191
+ streamFile(join(dir, url.pathname, 'index.html'), res)
192
+ } else if (manifest.files.has(url.pathname)) {
193
+ streamFile(join(dir, url.pathname), res)
60
194
  } else {
61
- const p =
62
- extname(url.pathname) === ''
63
- ? join(dir, url.pathname, 'index.html')
64
- : join(dir, url.pathname)
65
- streamFile(p, res)
195
+ notFound()
66
196
  }
67
197
  }
68
198
  }
69
199
 
70
- type DevServeOpts = {
71
- // ref of original DankConfig['pages'] mapping
72
- // updated incrementally instead of replacing
73
- pages: Record<string, string>
74
- // dir processed html files are written to
75
- pagesDir: string
76
- // port to esbuild dev server
77
- proxyPort: number
78
- // dir of public assets
79
- publicDir: string
80
- }
81
-
200
+ // todo replace PageRouteState with WebsiteRegistry
82
201
  export function createDevServeFilesFetcher(
83
- opts: DevServeOpts,
202
+ pageRoutes: PageRouteState,
203
+ serve: DankServe,
84
204
  ): FrontendFetcher {
85
- const proxyAddress = 'http://127.0.0.1:' + opts.proxyPort
86
- return (url: URL, _headers: Headers, res: ServerResponse) => {
87
- if (opts.pages[url.pathname]) {
88
- streamFile(join(opts.pagesDir, url.pathname, 'index.html'), res)
205
+ const proxyAddress = 'http://127.0.0.1:' + serve.esbuildPort
206
+ return (
207
+ url: URL,
208
+ _headers: Headers,
209
+ res: ServerResponse,
210
+ notFound: () => void,
211
+ ) => {
212
+ if (pageRoutes.urls.includes(url.pathname)) {
213
+ streamFile(
214
+ join(serve.dirs.buildWatch, url.pathname, 'index.html'),
215
+ res,
216
+ )
89
217
  } else {
90
- const maybePublicPath = join(opts.publicDir, url.pathname)
91
- exists(join(opts.publicDir, url.pathname)).then(fromPublic => {
218
+ const maybePublicPath = join(serve.dirs.public, url.pathname)
219
+ exists(maybePublicPath).then(fromPublic => {
92
220
  if (fromPublic) {
93
221
  streamFile(maybePublicPath, res)
94
222
  } else {
95
223
  retryFetchWithTimeout(proxyAddress + url.pathname)
96
224
  .then(fetchResponse => {
97
- res.writeHead(
98
- fetchResponse.status,
99
- convertHeadersFromFetch(fetchResponse.headers),
100
- )
101
- fetchResponse.bytes().then(data => res.end(data))
225
+ if (fetchResponse.status === 404) {
226
+ notFound()
227
+ } else {
228
+ res.writeHead(
229
+ fetchResponse.status,
230
+ convertHeadersFromFetch(
231
+ fetchResponse.headers,
232
+ ),
233
+ )
234
+ fetchResponse
235
+ .bytes()
236
+ .then(data => res.end(data))
237
+ }
102
238
  })
103
239
  .catch(e => {
104
- if (e === 'retrytimeout') {
240
+ if (isFetchRetryTimeout(e)) {
105
241
  res.writeHead(504)
106
242
  } else {
107
243
  console.error(
@@ -121,11 +257,14 @@ export function createDevServeFilesFetcher(
121
257
  const PROXY_FETCH_RETRY_INTERVAL = 27
122
258
  const PROXY_FETCH_RETRY_TIMEOUT = 1000
123
259
 
124
- async function retryFetchWithTimeout(url: string): Promise<Response> {
260
+ async function retryFetchWithTimeout(
261
+ url: URL | string,
262
+ requestInit?: RequestInit,
263
+ ): Promise<Response> {
125
264
  let timeout = Date.now() + PROXY_FETCH_RETRY_TIMEOUT
126
265
  while (true) {
127
266
  try {
128
- return await fetch(url)
267
+ return await fetch(url, requestInit)
129
268
  } catch (e: any) {
130
269
  if (isNodeFailedFetch(e) || isBunFailedFetch(e)) {
131
270
  if (timeout < Date.now()) {
@@ -142,6 +281,10 @@ async function retryFetchWithTimeout(url: string): Promise<Response> {
142
281
  }
143
282
  }
144
283
 
284
+ function isFetchRetryTimeout(e: any): boolean {
285
+ return e === 'retrytimeout'
286
+ }
287
+
145
288
  function isBunFailedFetch(e: any): boolean {
146
289
  return e.code === 'ConnectionRefused'
147
290
  }
@@ -189,3 +332,8 @@ function convertHeadersToFetch(from: IncomingHttpHeaders): Headers {
189
332
  }
190
333
  return to
191
334
  }
335
+
336
+ function errorExit(msg: string): never {
337
+ console.log(`\u001b[31merror:\u001b[0m`, msg)
338
+ process.exit(1)
339
+ }
@@ -0,0 +1,309 @@
1
+ import EventEmitter from 'node:events'
2
+ import { writeFile } from 'node:fs/promises'
3
+ import { dirname, join, resolve } from 'node:path'
4
+ import type { BuildResult } from 'esbuild'
5
+ import type { EntryPoint } from './esbuild.ts'
6
+ import type { DankBuild, ProjectDirs } from './flags.ts'
7
+
8
+ export type ResolveError = 'outofbounds'
9
+
10
+ export type Resolver = {
11
+ // `p` is expected to be a relative path resolvable from the project dir
12
+ isProjectSubpathInPagesDir(p: string): boolean
13
+
14
+ // `p` is expected to be a relative path resolvable from the pages dir
15
+ isPagesSubpathInPagesDir(p: string): boolean
16
+
17
+ // resolve a pages subpath from a resource within the pages directory by a relative href
18
+ // `from` is expected to be a pages resource fs path starting with `pages/` and ending with filename
19
+ // the result will be a pages subpath and will not have the pages dir prefix
20
+ // returns 'outofbounds' if the relative path does not resolve to a file within the pages dir
21
+ resolveHrefInPagesDir(from: string, href: string): string | ResolveError
22
+ }
23
+
24
+ class ResolverImpl implements Resolver {
25
+ #dirs: ProjectDirs
26
+
27
+ constructor(dirs: ProjectDirs) {
28
+ this.#dirs = dirs
29
+ }
30
+
31
+ isProjectSubpathInPagesDir(p: string): boolean {
32
+ return resolve(join(this.#dirs.projectResolved, p)).startsWith(
33
+ this.#dirs.pagesResolved,
34
+ )
35
+ }
36
+
37
+ isPagesSubpathInPagesDir(p: string): boolean {
38
+ return this.isProjectSubpathInPagesDir(join(this.#dirs.pages, p))
39
+ }
40
+
41
+ resolveHrefInPagesDir(from: string, href: string): string | ResolveError {
42
+ const p = join(dirname(from), href)
43
+ if (this.isProjectSubpathInPagesDir(p)) {
44
+ return p
45
+ } else {
46
+ return 'outofbounds'
47
+ }
48
+ }
49
+ }
50
+
51
+ // summary of a website build
52
+ export type WebsiteManifest = {
53
+ buildTag: string
54
+ files: Set<string>
55
+ pageUrls: Set<string>
56
+ }
57
+
58
+ // result of an esbuild build from the context of the config's entrypoints
59
+ // path of entrypoint is the reference point to lookup from a dependent page
60
+ export type BuildManifest = {
61
+ // css and js bundles mapping output path to
62
+ // entrypoint path or null for code splitting chunks
63
+ bundles: Record<string, string | null>
64
+
65
+ // web worker urls mapped by dependent entrypoints
66
+ // to be built with a subsequent esbuild context
67
+ workers: Array<WorkerManifest> | null
68
+ }
69
+
70
+ type OnBuildComplete = (manifest: BuildManifest) => void
71
+
72
+ type WorkerManifest = {
73
+ // path to module dependent on worker
74
+ clientScript: string
75
+ // path to bundled entrypoint dependent on `clientScript`
76
+ dependentEntryPoint: string
77
+ workerEntryPoint: string
78
+ workerUrl: string
79
+ workerUrlPlaceholder: string
80
+ }
81
+
82
+ export type WebsiteRegistryEvents = {
83
+ workers: []
84
+ }
85
+
86
+ // manages website resources during `dank build` and `dank serve`
87
+ export class WebsiteRegistry extends EventEmitter<WebsiteRegistryEvents> {
88
+ #build: DankBuild
89
+ // paths of bundled esbuild outputs
90
+ #bundles: Set<string> = new Set()
91
+ // public dir assets
92
+ #copiedAssets: Set<string> | null = null
93
+ // map of entrypoints to their output path
94
+ #entrypointHrefs: Record<string, string | null> = {}
95
+ #pageUrls: Array<string> = []
96
+ #resolver: Resolver
97
+ #workers: Array<WorkerManifest> | null = null
98
+
99
+ constructor(build: DankBuild) {
100
+ super()
101
+ this.#build = build
102
+ this.#resolver = new ResolverImpl(build.dirs)
103
+ }
104
+
105
+ set copiedAssets(copiedAssets: Array<string> | null) {
106
+ this.#copiedAssets =
107
+ copiedAssets === null ? null : new Set(copiedAssets)
108
+ }
109
+
110
+ set pageUrls(pageUrls: Array<string>) {
111
+ this.#pageUrls = pageUrls
112
+ }
113
+
114
+ get resolver(): Resolver {
115
+ return this.#resolver
116
+ }
117
+
118
+ // bundleOutputs(type?: 'css' | 'js'): Array<string> {
119
+ // if (!type) {
120
+ // return Array.from(this.#bundles)
121
+ // } else {
122
+ // return Array.from(this.#bundles).filter(p => p.endsWith(type))
123
+ // }
124
+ // }
125
+
126
+ buildRegistry(): BuildRegistry {
127
+ return new BuildRegistry(this.#build, this.#onBuildManifest)
128
+ }
129
+
130
+ files(): Set<string> {
131
+ const files = new Set<string>()
132
+ for (const pageUrl of this.#pageUrls)
133
+ files.add(pageUrl === '/' ? '/index.html' : `${pageUrl}/index.html`)
134
+ for (const f of this.#bundles) files.add(f)
135
+ if (this.#copiedAssets) for (const f of this.#copiedAssets) files.add(f)
136
+ return files
137
+ }
138
+
139
+ mappedHref(lookup: string): string {
140
+ const found = this.#entrypointHrefs[lookup]
141
+ if (found) {
142
+ return found
143
+ } else {
144
+ throw Error(`mapped href for ${lookup} not found`)
145
+ }
146
+ }
147
+
148
+ workerEntryPoints(): Array<EntryPoint> | null {
149
+ return (
150
+ this.#workers?.map(({ workerEntryPoint }) => ({
151
+ in: workerEntryPoint,
152
+ out: workerEntryPoint
153
+ .replace(/^pages[\//]/, '')
154
+ .replace(/\.(mj|t)s$/, '.js'),
155
+ })) || null
156
+ )
157
+ }
158
+
159
+ workers(): Array<WorkerManifest> | null {
160
+ return this.#workers
161
+ }
162
+
163
+ async writeManifest(buildTag: string): Promise<WebsiteManifest> {
164
+ const manifest = this.#manifest(buildTag)
165
+ await writeFile(
166
+ join(
167
+ this.#build.dirs.projectRootAbs,
168
+ this.#build.dirs.buildRoot,
169
+ 'website.json',
170
+ ),
171
+ JSON.stringify(
172
+ {
173
+ buildTag,
174
+ files: Array.from(manifest.files),
175
+ pageUrls: Array.from(manifest.pageUrls),
176
+ },
177
+ null,
178
+ 4,
179
+ ),
180
+ )
181
+ return manifest
182
+ }
183
+
184
+ #manifest(buildTag: string): WebsiteManifest {
185
+ return {
186
+ buildTag,
187
+ files: this.files(),
188
+ pageUrls: new Set(this.#pageUrls),
189
+ }
190
+ }
191
+
192
+ #onBuildManifest: OnBuildComplete = (build: BuildManifest) => {
193
+ // collect built bundle entrypoint hrefs
194
+ for (const [outPath, entrypoint] of Object.entries(build.bundles)) {
195
+ this.#bundles.add(outPath)
196
+ if (entrypoint) {
197
+ this.#entrypointHrefs[entrypoint] = outPath
198
+ }
199
+ }
200
+
201
+ // determine if worker entrypoints have changed
202
+ let updatedWorkerEntrypoints = false
203
+ const previousWorkers =
204
+ this.#workers === null
205
+ ? null
206
+ : new Set(this.#workers.map(w => w.workerEntryPoint))
207
+ if (build.workers) {
208
+ if (
209
+ !previousWorkers ||
210
+ previousWorkers.size !==
211
+ new Set(build.workers.map(w => w.workerEntryPoint)).size
212
+ ) {
213
+ updatedWorkerEntrypoints = true
214
+ } else {
215
+ updatedWorkerEntrypoints = !build.workers.every(w =>
216
+ previousWorkers.has(w.workerEntryPoint),
217
+ )
218
+ }
219
+ } else if (previousWorkers) {
220
+ updatedWorkerEntrypoints = true
221
+ }
222
+
223
+ // merge unique entrypoints from built workers with registry state
224
+ // todo filtering out unique occurrences of clientScript and workerUrl
225
+ // drops reporting/summary/debugging capabilities, but currently
226
+ // this.#workers is used for unique worker/client entrypoints
227
+ if (build.workers) {
228
+ if (!this.#workers) {
229
+ this.#workers = build.workers
230
+ } else {
231
+ for (const w of build.workers) {
232
+ const found = this.#workers.find(w2 => {
233
+ return (
234
+ w.dependentEntryPoint === w2.dependentEntryPoint &&
235
+ w.workerEntryPoint === w2.workerEntryPoint
236
+ )
237
+ })
238
+ if (!found) {
239
+ this.#workers.push(w)
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ if (updatedWorkerEntrypoints) {
246
+ this.emit('workers')
247
+ }
248
+ }
249
+ }
250
+
251
+ // result accumulator of an esbuild `build` or `Context.rebuild`
252
+ export class BuildRegistry {
253
+ #onComplete: OnBuildComplete
254
+ #resolver: Resolver
255
+ #workers: Array<Omit<WorkerManifest, 'dependentEntryPoint'>> | null = null
256
+
257
+ constructor(
258
+ build: DankBuild,
259
+ onComplete: (manifest: BuildManifest) => void,
260
+ ) {
261
+ this.#onComplete = onComplete
262
+ this.#resolver = new ResolverImpl(build.dirs)
263
+ }
264
+
265
+ get resolver(): Resolver {
266
+ return this.#resolver
267
+ }
268
+
269
+ // resolve web worker imported by a webpage module
270
+ addWorker(worker: Omit<WorkerManifest, 'dependentEntryPoint'>) {
271
+ // todo normalize path
272
+ if (!this.#workers) {
273
+ this.#workers = [worker]
274
+ } else {
275
+ this.#workers.push(worker)
276
+ }
277
+ }
278
+
279
+ completeBuild(result: BuildResult<{ metafile: true }>) {
280
+ const bundles: Record<string, string | null> = {}
281
+ for (const [outPath, output] of Object.entries(
282
+ result.metafile.outputs,
283
+ )) {
284
+ bundles[outPath.replace(/^build[/\\]dist/, '')] =
285
+ output.entryPoint || null
286
+ }
287
+ let workers: BuildManifest['workers'] = null
288
+ if (this.#workers) {
289
+ workers = []
290
+ for (const output of Object.values(result.metafile.outputs)) {
291
+ if (!output.entryPoint) continue
292
+ const inputs = Object.keys(output.inputs)
293
+ for (const worker of this.#workers) {
294
+ if (inputs.includes(worker.clientScript)) {
295
+ workers.push({
296
+ ...worker,
297
+ dependentEntryPoint: output.entryPoint,
298
+ })
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ this.#onComplete({
305
+ bundles,
306
+ workers,
307
+ })
308
+ }
309
+ }
package/lib/public.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  import { copyFile, mkdir, readdir, stat } from 'node:fs/promises'
2
+ import { platform } from 'node:os'
2
3
  import { join } from 'node:path'
4
+ import type { DankBuild } from './flags.ts'
3
5
 
4
6
  export async function copyAssets(
5
- outRoot: string,
7
+ build: DankBuild,
6
8
  ): Promise<Array<string> | null> {
7
9
  try {
8
- const stats = await stat('public')
10
+ const stats = await stat(build.dirs.public)
9
11
  if (stats.isDirectory()) {
10
- await mkdir(outRoot, { recursive: true })
11
- return await recursiveCopyAssets(outRoot)
12
+ await mkdir(build.dirs.buildDist, { recursive: true })
13
+ return await recursiveCopyAssets(build)
12
14
  } else {
13
15
  throw Error('./public cannot be a file')
14
16
  }
@@ -17,26 +19,32 @@ export async function copyAssets(
17
19
  }
18
20
  }
19
21
 
22
+ const IGNORE = platform() === 'darwin' ? ['.DS_Store'] : []
23
+
20
24
  async function recursiveCopyAssets(
21
- outRoot: string,
25
+ build: DankBuild,
22
26
  dir: string = '',
23
27
  ): Promise<Array<string>> {
24
28
  const copied: Array<string> = []
25
- const to = join(outRoot, dir)
29
+ const to = join(build.dirs.buildDist, dir)
26
30
  let madeDir = dir === ''
27
- for (const p of await readdir(join('public', dir))) {
31
+ const listingDir = join(build.dirs.public, dir)
32
+ for (const p of await readdir(listingDir)) {
33
+ if (IGNORE.includes(p)) {
34
+ continue
35
+ }
28
36
  try {
29
- const stats = await stat(join('public', dir, p))
37
+ const stats = await stat(join(listingDir, p))
30
38
  if (stats.isDirectory()) {
31
- copied.push(
32
- ...(await recursiveCopyAssets(outRoot, join(dir, p))),
33
- )
39
+ copied.push(...(await recursiveCopyAssets(build, join(dir, p))))
34
40
  } else {
35
41
  if (!madeDir) {
36
- await mkdir(join(outRoot, dir))
42
+ await mkdir(join(build.dirs.buildDist, dir), {
43
+ recursive: true,
44
+ })
37
45
  madeDir = true
38
46
  }
39
- await copyFile(join('public', dir, p), join(to, p))
47
+ await copyFile(join(listingDir, p), join(to, p))
40
48
  copied.push('/' + join(dir, p).replaceAll('\\', '/'))
41
49
  }
42
50
  } catch (e) {