@eighty4/dank 0.0.3 → 0.0.4-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/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,284 @@
1
+ import EventEmitter from 'node:events'
2
+ import { writeFile } from 'node:fs/promises'
3
+ import { dirname, join, resolve, sep } from 'node:path'
4
+ import type { BuildResult } from 'esbuild'
5
+ import type { EntryPoint } from './esbuild.ts'
6
+ import type { DankBuild } from './flags.ts'
7
+
8
+ export type Resolver = {
9
+ resolve(from: string, href: string): string | 'outofbounds'
10
+ }
11
+
12
+ // summary of a website build
13
+ export type WebsiteManifest = {
14
+ buildTag: string
15
+ files: Set<string>
16
+ pageUrls: Set<string>
17
+ }
18
+
19
+ // result of an esbuild build from the context of the config's entrypoints
20
+ // path of entrypoint is the reference point to lookup from a dependent page
21
+ export type BuildManifest = {
22
+ // css and js bundles mapping output path to
23
+ // entrypoint path or null for code splitting chunks
24
+ bundles: Record<string, string | null>
25
+
26
+ // web worker urls mapped by dependent entrypoints
27
+ // to be built with a subsequent esbuild context
28
+ workers: Array<WorkerManifest> | null
29
+ }
30
+
31
+ type OnBuildComplete = (manifest: BuildManifest) => void
32
+
33
+ type WorkerManifest = {
34
+ // path to module dependent on worker
35
+ clientScript: string
36
+ // path to bundled entrypoint dependent on `clientScript`
37
+ dependentEntryPoint: string
38
+ workerEntryPoint: string
39
+ workerUrl: string
40
+ workerUrlPlaceholder: string
41
+ }
42
+
43
+ export type WebsiteRegistryEvents = {
44
+ workers: []
45
+ }
46
+
47
+ // manages website resources during `dank build` and `dank serve`
48
+ export class WebsiteRegistry
49
+ extends EventEmitter<WebsiteRegistryEvents>
50
+ implements Resolver
51
+ {
52
+ #build: DankBuild
53
+ // paths of bundled esbuild outputs
54
+ #bundles: Set<string> = new Set()
55
+ // public dir assets
56
+ #copiedAssets: Set<string> | null = null
57
+ // map of entrypoints to their output path
58
+ #entrypointHrefs: Record<string, string | null> = {}
59
+ #pageUrls: Array<string> = []
60
+ #workers: Array<WorkerManifest> | null = null
61
+
62
+ constructor(build: DankBuild) {
63
+ super()
64
+ this.#build = build
65
+ }
66
+
67
+ // bundleOutputs(type?: 'css' | 'js'): Array<string> {
68
+ // if (!type) {
69
+ // return Array.from(this.#bundles)
70
+ // } else {
71
+ // return Array.from(this.#bundles).filter(p => p.endsWith(type))
72
+ // }
73
+ // }
74
+
75
+ buildRegistry(): BuildRegistry {
76
+ return new BuildRegistry(this.#build, this.#onBuildManifest)
77
+ }
78
+
79
+ files(): Set<string> {
80
+ const files = new Set<string>()
81
+ for (const pageUrl of this.#pageUrls)
82
+ files.add(pageUrl === '/' ? '/index.html' : `${pageUrl}/index.html`)
83
+ for (const f of this.#bundles) files.add(f)
84
+ if (this.#copiedAssets) for (const f of this.#copiedAssets) files.add(f)
85
+ return files
86
+ }
87
+
88
+ mappedHref(lookup: string): string {
89
+ const found = this.#entrypointHrefs[lookup]
90
+ if (found) {
91
+ return found
92
+ } else {
93
+ throw Error(`mapped href for ${lookup} not found`)
94
+ }
95
+ }
96
+
97
+ resolve(from: string, href: string): string {
98
+ return resolveImpl(this.#build, from, href)
99
+ }
100
+
101
+ workerEntryPoints(): Array<EntryPoint> | null {
102
+ return (
103
+ this.#workers?.map(({ workerEntryPoint }) => ({
104
+ in: workerEntryPoint,
105
+ out: workerEntryPoint
106
+ .replace(/^pages[\//]/, '')
107
+ .replace(/\.(mj|t)s$/, '.js'),
108
+ })) || null
109
+ )
110
+ }
111
+
112
+ workers(): Array<WorkerManifest> | null {
113
+ return this.#workers
114
+ }
115
+
116
+ async writeManifest(buildTag: string): Promise<WebsiteManifest> {
117
+ const manifest = this.#manifest(buildTag)
118
+ await writeFile(
119
+ join(
120
+ this.#build.dirs.projectRootAbs,
121
+ this.#build.dirs.buildRoot,
122
+ 'website.json',
123
+ ),
124
+ JSON.stringify(
125
+ {
126
+ buildTag,
127
+ files: Array.from(manifest.files),
128
+ pageUrls: Array.from(manifest.pageUrls),
129
+ },
130
+ null,
131
+ 4,
132
+ ),
133
+ )
134
+ return manifest
135
+ }
136
+
137
+ set copiedAssets(copiedAssets: Array<string> | null) {
138
+ this.#copiedAssets =
139
+ copiedAssets === null ? null : new Set(copiedAssets)
140
+ }
141
+
142
+ set pageUrls(pageUrls: Array<string>) {
143
+ this.#pageUrls = pageUrls
144
+ }
145
+
146
+ #manifest(buildTag: string): WebsiteManifest {
147
+ return {
148
+ buildTag,
149
+ files: this.files(),
150
+ pageUrls: new Set(this.#pageUrls),
151
+ }
152
+ }
153
+
154
+ #onBuildManifest: OnBuildComplete = (build: BuildManifest) => {
155
+ // collect built bundle entrypoint hrefs
156
+ for (const [outPath, entrypoint] of Object.entries(build.bundles)) {
157
+ this.#bundles.add(outPath)
158
+ if (entrypoint) {
159
+ this.#entrypointHrefs[entrypoint] = outPath
160
+ }
161
+ }
162
+
163
+ // determine if worker entrypoints have changed
164
+ let updatedWorkerEntrypoints = false
165
+ const previousWorkers =
166
+ this.#workers === null
167
+ ? null
168
+ : new Set(this.#workers.map(w => w.workerEntryPoint))
169
+ if (build.workers) {
170
+ if (
171
+ !previousWorkers ||
172
+ previousWorkers.size !==
173
+ new Set(build.workers.map(w => w.workerEntryPoint)).size
174
+ ) {
175
+ updatedWorkerEntrypoints = true
176
+ } else {
177
+ updatedWorkerEntrypoints = !build.workers.every(w =>
178
+ previousWorkers.has(w.workerEntryPoint),
179
+ )
180
+ }
181
+ } else if (previousWorkers) {
182
+ updatedWorkerEntrypoints = true
183
+ }
184
+
185
+ // merge unique entrypoints from built workers with registry state
186
+ // todo filtering out unique occurrences of clientScript and workerUrl
187
+ // drops reporting/summary/debugging capabilities, but currently
188
+ // this.#workers is used for unique worker/client entrypoints
189
+ if (build.workers) {
190
+ if (!this.#workers) {
191
+ this.#workers = build.workers
192
+ } else {
193
+ for (const w of build.workers) {
194
+ const found = this.#workers.find(w2 => {
195
+ return (
196
+ w.dependentEntryPoint === w2.dependentEntryPoint &&
197
+ w.workerEntryPoint === w2.workerEntryPoint
198
+ )
199
+ })
200
+ if (!found) {
201
+ this.#workers.push(w)
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ if (updatedWorkerEntrypoints) {
208
+ this.emit('workers')
209
+ }
210
+ }
211
+ }
212
+
213
+ // result accumulator of an esbuild `build` or `Context.rebuild`
214
+ export class BuildRegistry implements Resolver {
215
+ #build: DankBuild
216
+ #onComplete: OnBuildComplete
217
+ #workers: Array<Omit<WorkerManifest, 'dependentEntryPoint'>> | null = null
218
+
219
+ constructor(
220
+ build: DankBuild,
221
+ onComplete: (manifest: BuildManifest) => void,
222
+ ) {
223
+ this.#build = build
224
+ this.#onComplete = onComplete
225
+ }
226
+
227
+ // resolve web worker imported by a webpage module
228
+ addWorker(worker: Omit<WorkerManifest, 'dependentEntryPoint'>) {
229
+ // todo normalize path
230
+ if (!this.#workers) {
231
+ this.#workers = [worker]
232
+ } else {
233
+ this.#workers.push(worker)
234
+ }
235
+ }
236
+
237
+ completeBuild(result: BuildResult<{ metafile: true }>) {
238
+ const bundles: Record<string, string | null> = {}
239
+ for (const [outPath, output] of Object.entries(
240
+ result.metafile.outputs,
241
+ )) {
242
+ bundles[outPath.replace(/^build[/\\]dist/, '')] =
243
+ output.entryPoint || null
244
+ }
245
+ let workers: BuildManifest['workers'] = null
246
+ if (this.#workers) {
247
+ workers = []
248
+ for (const output of Object.values(result.metafile.outputs)) {
249
+ if (!output.entryPoint) continue
250
+ const inputs = Object.keys(output.inputs)
251
+ for (const worker of this.#workers) {
252
+ if (inputs.includes(worker.clientScript)) {
253
+ workers.push({
254
+ ...worker,
255
+ dependentEntryPoint: output.entryPoint,
256
+ })
257
+ }
258
+ }
259
+ }
260
+ }
261
+
262
+ this.#onComplete({
263
+ bundles,
264
+ workers,
265
+ })
266
+ }
267
+
268
+ resolve(from: string, href: string): string {
269
+ return resolveImpl(this.#build, from, href)
270
+ }
271
+ }
272
+
273
+ function resolveImpl(build: DankBuild, from: string, href: string): string {
274
+ const { pagesResolved, projectRootAbs } = build.dirs
275
+ const fromDir = dirname(from)
276
+ const resolvedFromProjectRoot = join(projectRootAbs, fromDir, href)
277
+ if (!resolve(resolvedFromProjectRoot).startsWith(pagesResolved)) {
278
+ throw Error(
279
+ `href ${href} cannot be resolved from pages${sep}${from} to a path outside of the pages directory`,
280
+ )
281
+ } else {
282
+ return join(fromDir, href)
283
+ }
284
+ }
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) {