@eighty4/dank 0.0.2 → 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,87 +8,236 @@ 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'
13
+ import type { DankServe } from './flags.ts'
14
+ import type { WebsiteManifest } from './metadata.ts'
15
+ import type { HttpServices } from './services.ts'
12
16
 
13
17
  export type FrontendFetcher = (
14
18
  url: URL,
15
19
  headers: Headers,
16
20
  res: ServerResponse,
21
+ notFound: () => void,
17
22
  ) => void
18
23
 
19
- export function createWebServer(
20
- 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,
21
38
  frontendFetcher: FrontendFetcher,
22
- ): ReturnType<typeof createServer> {
23
- const serverAddress = 'http://localhost:' + port
24
- return createServer((req: IncomingMessage, res: ServerResponse) => {
39
+ httpServices: HttpServices,
40
+ pageRoutes: PageRouteState,
41
+ ) {
42
+ const serverAddress = 'http://localhost:' + serve.dankPort
43
+ const handler = (req: IncomingMessage, res: ServerResponse) => {
25
44
  if (!req.url || !req.method) {
26
45
  res.end()
27
46
  } else {
28
47
  const url = new URL(serverAddress + req.url)
29
- if (req.method !== 'GET') {
30
- res.writeHead(405)
31
- 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
145
+ } else {
146
+ return response
147
+ }
148
+ } catch (e: any) {
149
+ if (e === 'retrytimeout') {
150
+ continue
32
151
  } else {
33
- frontendFetcher(url, convertHeadersToFetch(req.headers), res)
152
+ errorExit(
153
+ `unexpected error http proxying to port ${httpService.port}: ${e.message}`,
154
+ )
34
155
  }
35
156
  }
36
- })
157
+ }
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
+ )
167
+ }
168
+
169
+ type RequestListener = (req: IncomingMessage, res: ServerResponse) => void
170
+ function createLogWrapper(handler: RequestListener): RequestListener {
171
+ return (req, res) => {
172
+ console.log(' > ', req.method, req.url)
173
+ res.on('close', () => {
174
+ console.log('', res.statusCode, req.method, req.url)
175
+ })
176
+ handler(req, res)
177
+ }
37
178
  }
38
179
 
39
180
  export function createBuiltDistFilesFetcher(
40
181
  dir: string,
41
- files: Set<string>,
182
+ manifest: WebsiteManifest,
42
183
  ): FrontendFetcher {
43
- return (url: URL, _headers: Headers, res: ServerResponse) => {
44
- if (!files.has(url.pathname)) {
45
- res.writeHead(404)
46
- 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)
47
194
  } else {
48
- const p =
49
- extname(url.pathname) === ''
50
- ? join(dir, url.pathname, 'index.html')
51
- : join(dir, url.pathname)
52
- streamFile(p, res)
195
+ notFound()
53
196
  }
54
197
  }
55
198
  }
56
199
 
57
- type DevServeOpts = {
58
- // ref of original DankConfig['pages'] mapping
59
- // updated incrementally instead of replacing
60
- pages: Record<string, string>
61
- // dir processed html files are written to
62
- pagesDir: string
63
- // port to esbuild dev server
64
- proxyPort: number
65
- // dir of public assets
66
- publicDir: string
67
- }
68
-
200
+ // todo replace PageRouteState with WebsiteRegistry
69
201
  export function createDevServeFilesFetcher(
70
- opts: DevServeOpts,
202
+ pageRoutes: PageRouteState,
203
+ serve: DankServe,
71
204
  ): FrontendFetcher {
72
- const proxyAddress = 'http://127.0.0.1:' + opts.proxyPort
73
- return (url: URL, _headers: Headers, res: ServerResponse) => {
74
- if (opts.pages[url.pathname]) {
75
- 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
+ )
76
217
  } else {
77
- const maybePublicPath = join(opts.publicDir, url.pathname)
78
- exists(join(opts.publicDir, url.pathname)).then(fromPublic => {
218
+ const maybePublicPath = join(serve.dirs.public, url.pathname)
219
+ exists(maybePublicPath).then(fromPublic => {
79
220
  if (fromPublic) {
80
221
  streamFile(maybePublicPath, res)
81
222
  } else {
82
223
  retryFetchWithTimeout(proxyAddress + url.pathname)
83
224
  .then(fetchResponse => {
84
- res.writeHead(
85
- fetchResponse.status,
86
- convertHeadersFromFetch(fetchResponse.headers),
87
- )
88
- 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
+ }
89
238
  })
90
239
  .catch(e => {
91
- if (e === 'retrytimeout') {
240
+ if (isFetchRetryTimeout(e)) {
92
241
  res.writeHead(504)
93
242
  } else {
94
243
  console.error(
@@ -108,11 +257,14 @@ export function createDevServeFilesFetcher(
108
257
  const PROXY_FETCH_RETRY_INTERVAL = 27
109
258
  const PROXY_FETCH_RETRY_TIMEOUT = 1000
110
259
 
111
- async function retryFetchWithTimeout(url: string): Promise<Response> {
260
+ async function retryFetchWithTimeout(
261
+ url: URL | string,
262
+ requestInit?: RequestInit,
263
+ ): Promise<Response> {
112
264
  let timeout = Date.now() + PROXY_FETCH_RETRY_TIMEOUT
113
265
  while (true) {
114
266
  try {
115
- return await fetch(url)
267
+ return await fetch(url, requestInit)
116
268
  } catch (e: any) {
117
269
  if (isNodeFailedFetch(e) || isBunFailedFetch(e)) {
118
270
  if (timeout < Date.now()) {
@@ -129,6 +281,10 @@ async function retryFetchWithTimeout(url: string): Promise<Response> {
129
281
  }
130
282
  }
131
283
 
284
+ function isFetchRetryTimeout(e: any): boolean {
285
+ return e === 'retrytimeout'
286
+ }
287
+
132
288
  function isBunFailedFetch(e: any): boolean {
133
289
  return e.code === 'ConnectionRefused'
134
290
  }
@@ -176,3 +332,8 @@ function convertHeadersToFetch(from: IncomingHttpHeaders): Headers {
176
332
  }
177
333
  return to
178
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) {