@eighty4/dank 0.0.1-4 → 0.0.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/serve.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { mkdir, readFile, rm } from 'node:fs/promises'
2
- import { join, resolve } from 'node:path'
1
+ import { mkdir, readFile, rm, watch as _watch } from 'node:fs/promises'
2
+ import { extname, join, resolve } from 'node:path'
3
+ import type { BuildContext } from 'esbuild'
3
4
  import { buildWebsite } from './build.ts'
5
+ import { loadConfig } from './config.ts'
4
6
  import type { DankConfig } from './dank.ts'
5
7
  import { createGlobalDefinitions } from './define.ts'
6
8
  import { esbuildDevContext } from './esbuild.ts'
@@ -8,12 +10,10 @@ import { isPreviewBuild } from './flags.ts'
8
10
  import { HtmlEntrypoint } from './html.ts'
9
11
  import {
10
12
  createBuiltDistFilesFetcher,
11
- createLocalProxyFilesFetcher,
13
+ createDevServeFilesFetcher,
12
14
  createWebServer,
13
- type FrontendFetcher,
14
15
  } from './http.ts'
15
- import { copyAssets } from './public.ts'
16
- import { startDevServices } from './services.ts'
16
+ import { startDevServices, updateDevServices } from './services.ts'
17
17
 
18
18
  const isPreview = isPreviewBuild()
19
19
 
@@ -25,66 +25,226 @@ const ESBUILD_PORT = 2999
25
25
 
26
26
  export async function serveWebsite(c: DankConfig): Promise<never> {
27
27
  await rm('build', { force: true, recursive: true })
28
- let frontend: FrontendFetcher
29
28
  if (isPreview) {
30
- const { dir, files } = await buildWebsite(c)
31
- frontend = createBuiltDistFilesFetcher(dir, files)
29
+ await startPreviewMode(c)
32
30
  } else {
33
- const { port } = await startEsbuildWatch(c)
34
- frontend = createLocalProxyFilesFetcher(port)
31
+ const abortController = new AbortController()
32
+ await startDevMode(c, abortController.signal)
35
33
  }
36
- createWebServer(PORT, frontend).listen(PORT)
37
- console.log(
38
- isPreview ? 'preview' : 'dev server',
39
- `is live at http://127.0.0.1:${PORT}`,
40
- )
41
- startDevServices(c)
42
34
  return new Promise(() => {})
43
35
  }
44
36
 
45
- async function startEsbuildWatch(c: DankConfig): Promise<{ port: number }> {
37
+ async function startPreviewMode(c: DankConfig) {
38
+ const { dir, files } = await buildWebsite(c)
39
+ const frontend = createBuiltDistFilesFetcher(dir, files)
40
+ createWebServer(PORT, frontend).listen(PORT)
41
+ console.log(`preview is live at http://127.0.0.1:${PORT}`)
42
+ }
43
+
44
+ // todo changing partials triggers update on html pages
45
+ async function startDevMode(c: DankConfig, signal: AbortSignal) {
46
46
  const watchDir = join('build', 'watch')
47
47
  await mkdir(watchDir, { recursive: true })
48
- await copyAssets(watchDir)
48
+ const clientJS = await loadClientJS()
49
+ const pagesByUrlPath: Record<string, WebpageMetadata> = {}
50
+ const entryPointsByUrlPath: Record<string, Set<string>> = {}
51
+ let buildContext: BuildContext | 'starting' | 'dirty' | 'disposing' | null =
52
+ null
49
53
 
50
- const clientJS = await readFile(
51
- resolve(import.meta.dirname, join('..', 'client', 'esbuild.js')),
52
- 'utf-8',
53
- )
54
+ watch('dank.config.ts', signal, async () => {
55
+ let updated: DankConfig
56
+ try {
57
+ updated = await loadConfig()
58
+ } catch (ignore) {
59
+ return
60
+ }
61
+ const prevPages = new Set(Object.keys(pagesByUrlPath))
62
+ await Promise.all(
63
+ Object.entries(updated.pages).map(async ([urlPath, srcPath]) => {
64
+ c.pages[urlPath as `/${string}`] = srcPath
65
+ if (pagesByUrlPath[urlPath]) {
66
+ prevPages.delete(urlPath)
67
+ if (pagesByUrlPath[urlPath].srcPath !== srcPath) {
68
+ await updatePage(urlPath)
69
+ }
70
+ } else {
71
+ await addPage(urlPath, srcPath)
72
+ }
73
+ }),
74
+ )
75
+ for (const prevPage of Array.from(prevPages)) {
76
+ delete c.pages[prevPage as `/${string}`]
77
+ deletePage(prevPage)
78
+ }
79
+ updateDevServices(updated)
80
+ })
54
81
 
55
- const entryPointUrls: Set<string> = new Set()
56
- const entryPoints: Array<{ in: string; out: string }> = []
82
+ watch('pages', signal, filename => {
83
+ if (extname(filename) === '.html') {
84
+ for (const [urlPath, srcPath] of Object.entries(c.pages)) {
85
+ if (srcPath === filename) {
86
+ updatePage(urlPath)
87
+ }
88
+ }
89
+ }
90
+ })
57
91
 
58
92
  await Promise.all(
59
- Object.entries(c.pages).map(async ([url, srcPath]) => {
60
- const html = await HtmlEntrypoint.readFrom(
61
- url,
62
- join('pages', srcPath),
63
- )
64
- await html.injectPartials()
65
- if (url !== '/') {
66
- await mkdir(join(watchDir, url), { recursive: true })
67
- }
68
- html.collectScripts()
69
- .filter(scriptImport => !entryPointUrls.has(scriptImport.in))
70
- .forEach(scriptImport => {
71
- entryPointUrls.add(scriptImport.in)
72
- entryPoints.push({
73
- in: scriptImport.in,
74
- out: scriptImport.out,
75
- })
76
- })
77
- html.rewriteHrefs()
78
- html.appendScript(clientJS)
79
- await html.writeTo(watchDir)
80
- return html
81
- }),
93
+ Object.entries(c.pages).map(([urlPath, srcPath]) =>
94
+ addPage(urlPath, srcPath),
95
+ ),
96
+ )
97
+
98
+ async function addPage(urlPath: string, srcPath: string) {
99
+ const metadata = await processWebpage({
100
+ clientJS,
101
+ outDir: watchDir,
102
+ pagesDir: 'pages',
103
+ srcPath,
104
+ urlPath,
105
+ })
106
+ pagesByUrlPath[urlPath] = metadata
107
+ entryPointsByUrlPath[urlPath] = new Set(
108
+ metadata.entryPoints.map(e => e.in),
109
+ )
110
+ if (buildContext !== null) {
111
+ resetBuildContext()
112
+ }
113
+ }
114
+
115
+ function deletePage(urlPath: string) {
116
+ delete pagesByUrlPath[urlPath]
117
+ delete entryPointsByUrlPath[urlPath]
118
+ resetBuildContext()
119
+ }
120
+
121
+ async function updatePage(urlPath: string) {
122
+ const update = await processWebpage({
123
+ clientJS,
124
+ outDir: watchDir,
125
+ pagesDir: 'pages',
126
+ srcPath: c.pages[urlPath as `/${string}`],
127
+ urlPath,
128
+ })
129
+ const entryPointUrls = new Set(update.entryPoints.map(e => e.in))
130
+ if (!hasSameValues(entryPointUrls, entryPointsByUrlPath[urlPath])) {
131
+ entryPointsByUrlPath[urlPath] = entryPointUrls
132
+ resetBuildContext()
133
+ }
134
+ }
135
+
136
+ function collectEntrypoints(): Array<{ in: string; out: string }> {
137
+ const sources: Set<string> = new Set()
138
+ return Object.values(pagesByUrlPath)
139
+ .flatMap(({ entryPoints }) => entryPoints)
140
+ .filter(entryPoint => {
141
+ if (sources.has(entryPoint.in)) {
142
+ return false
143
+ } else {
144
+ sources.add(entryPoint.in)
145
+ return true
146
+ }
147
+ })
148
+ }
149
+
150
+ function resetBuildContext() {
151
+ if (buildContext === 'starting' || buildContext === 'dirty') {
152
+ buildContext = 'dirty'
153
+ return
154
+ }
155
+ if (buildContext === 'disposing') {
156
+ return
157
+ }
158
+ if (buildContext !== null) {
159
+ const prev = buildContext
160
+ buildContext = 'disposing'
161
+ prev.dispose().then(() => {
162
+ buildContext = null
163
+ resetBuildContext()
164
+ })
165
+ } else {
166
+ startEsbuildWatch(collectEntrypoints()).then(ctx => {
167
+ if (buildContext === 'dirty') {
168
+ buildContext = null
169
+ resetBuildContext()
170
+ } else {
171
+ buildContext = ctx
172
+ }
173
+ })
174
+ }
175
+ }
176
+
177
+ buildContext = await startEsbuildWatch(collectEntrypoints())
178
+ const frontend = createDevServeFilesFetcher({
179
+ pages: c.pages,
180
+ pagesDir: watchDir,
181
+ proxyPort: ESBUILD_PORT,
182
+ publicDir: 'public',
183
+ })
184
+ createWebServer(PORT, frontend).listen(PORT)
185
+ console.log(`dev server is live at http://127.0.0.1:${PORT}`)
186
+ startDevServices(c, signal)
187
+ }
188
+
189
+ function hasSameValues(a: Set<string>, b: Set<string>): boolean {
190
+ if (a.size !== b.size) {
191
+ return false
192
+ }
193
+ for (const v in a) {
194
+ if (!b.has(v)) {
195
+ return false
196
+ }
197
+ }
198
+ return true
199
+ }
200
+
201
+ type WebpageInputs = {
202
+ clientJS: string
203
+ outDir: string
204
+ pagesDir: string
205
+ srcPath: string
206
+ urlPath: string
207
+ }
208
+
209
+ type WebpageMetadata = {
210
+ entryPoints: Array<{ in: string; out: string }>
211
+ srcPath: string
212
+ urlPath: string
213
+ }
214
+
215
+ async function processWebpage(inputs: WebpageInputs): Promise<WebpageMetadata> {
216
+ const html = await HtmlEntrypoint.readFrom(
217
+ inputs.urlPath,
218
+ join(inputs.pagesDir, inputs.srcPath),
82
219
  )
220
+ await html.injectPartials()
221
+ if (inputs.urlPath !== '/') {
222
+ await mkdir(join(inputs.outDir, inputs.urlPath), { recursive: true })
223
+ }
224
+ const entryPoints: Array<{ in: string; out: string }> = []
225
+ html.collectScripts().forEach(scriptImport => {
226
+ entryPoints.push({
227
+ in: scriptImport.in,
228
+ out: scriptImport.out,
229
+ })
230
+ })
231
+ html.rewriteHrefs()
232
+ html.appendScript(inputs.clientJS)
233
+ await html.writeTo(inputs.outDir)
234
+ return {
235
+ entryPoints,
236
+ srcPath: inputs.srcPath,
237
+ urlPath: inputs.urlPath,
238
+ }
239
+ }
83
240
 
241
+ async function startEsbuildWatch(
242
+ entryPoints: Array<{ in: string; out: string }>,
243
+ ): Promise<BuildContext> {
84
244
  const ctx = await esbuildDevContext(
85
245
  createGlobalDefinitions(),
86
246
  entryPoints,
87
- watchDir,
247
+ 'build/watch',
88
248
  )
89
249
 
90
250
  await ctx.watch()
@@ -92,11 +252,55 @@ async function startEsbuildWatch(c: DankConfig): Promise<{ port: number }> {
92
252
  await ctx.serve({
93
253
  host: '127.0.0.1',
94
254
  port: ESBUILD_PORT,
95
- servedir: watchDir,
96
255
  cors: {
97
256
  origin: 'http://127.0.0.1:' + PORT,
98
257
  },
99
258
  })
100
259
 
101
- return { port: ESBUILD_PORT }
260
+ return ctx
261
+ }
262
+
263
+ async function loadClientJS() {
264
+ return await readFile(
265
+ resolve(import.meta.dirname, join('..', 'client', 'esbuild.js')),
266
+ 'utf-8',
267
+ )
268
+ }
269
+
270
+ async function watch(
271
+ p: string,
272
+ signal: AbortSignal,
273
+ fire: (filename: string) => void,
274
+ ) {
275
+ const delayFire = 90
276
+ const timeout = 100
277
+ let changes: Record<string, number> = {}
278
+ try {
279
+ for await (const { filename } of _watch(p, {
280
+ recursive: true,
281
+ signal,
282
+ })) {
283
+ if (filename) {
284
+ if (!changes[filename]) {
285
+ const now = Date.now()
286
+ changes[filename] = now + delayFire
287
+ setTimeout(() => {
288
+ const now = Date.now()
289
+ for (const [filename, then] of Object.entries(
290
+ changes,
291
+ )) {
292
+ if (then <= now) {
293
+ fire(filename)
294
+ delete changes[filename]
295
+ }
296
+ }
297
+ }, timeout)
298
+ }
299
+ }
300
+ }
301
+ } catch (e: any) {
302
+ if (e.name !== 'AbortError') {
303
+ throw e
304
+ }
305
+ }
102
306
  }
package/lib/services.ts CHANGED
@@ -1,22 +1,125 @@
1
- import { spawn } from 'node:child_process'
1
+ import { type ChildProcess, spawn } from 'node:child_process'
2
2
  import { basename, isAbsolute, resolve } from 'node:path'
3
3
  import type { DankConfig, DevService } from './dank.ts'
4
4
 
5
- export function startDevServices(c: DankConfig) {
5
+ // up to date representation of dank.config.ts services
6
+ const running: Array<{ s: DevService; process: ChildProcess | null }> = []
7
+
8
+ let signal: AbortSignal
9
+
10
+ // batch of services that must be stopped before starting new services
11
+ let updating: null | {
12
+ stopping: Array<DevService>
13
+ starting: Array<DevService>
14
+ } = null
15
+
16
+ export function startDevServices(c: DankConfig, _signal: AbortSignal) {
17
+ signal = _signal
6
18
  if (c.services?.length) {
7
- const ac = new AbortController()
8
- try {
9
- for (const s of c.services) {
10
- startService(s, ac.signal)
19
+ for (const s of c.services) {
20
+ running.push({ s, process: startService(s) })
21
+ }
22
+ }
23
+ }
24
+
25
+ export function updateDevServices(c: DankConfig) {
26
+ if (!c.services?.length) {
27
+ if (running.length) {
28
+ if (updating === null) {
29
+ updating = { stopping: [], starting: [] }
30
+ }
31
+ running.forEach(({ s, process }) => {
32
+ if (process) {
33
+ stopService(s, process)
34
+ } else {
35
+ removeFromUpdating(s)
36
+ }
37
+ })
38
+ running.length = 0
39
+ }
40
+ } else {
41
+ if (updating === null) {
42
+ updating = { stopping: [], starting: [] }
43
+ }
44
+ const keep = []
45
+ const next: Array<DevService> = []
46
+ for (const s of c.services) {
47
+ let found = false
48
+ for (let i = 0; i < running.length; i++) {
49
+ const p = running[i].s
50
+ if (matchingConfig(s, p)) {
51
+ found = true
52
+ keep.push(i)
53
+ break
54
+ }
55
+ }
56
+ if (!found) {
57
+ next.push(s)
58
+ }
59
+ }
60
+ for (let i = running.length - 1; i >= 0; i--) {
61
+ if (!keep.includes(i)) {
62
+ const { s, process } = running[i]
63
+ if (process) {
64
+ stopService(s, process)
65
+ } else {
66
+ removeFromUpdating(s)
67
+ }
68
+ running.splice(i, 1)
69
+ }
70
+ }
71
+ if (updating.stopping.length) {
72
+ for (const s of next) {
73
+ if (
74
+ !updating.starting.find(queued => matchingConfig(queued, s))
75
+ ) {
76
+ updating.starting.push(s)
77
+ }
78
+ }
79
+ } else {
80
+ updating = null
81
+ for (const s of next) {
82
+ running.push({ s, process: startService(s) })
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ function stopService(s: DevService, process: ChildProcess) {
89
+ opPrint(s, 'stopping')
90
+ updating!.stopping.push(s)
91
+ process.kill()
92
+ }
93
+
94
+ function matchingConfig(a: DevService, b: DevService): boolean {
95
+ if (a.command !== b.command) {
96
+ return false
97
+ }
98
+ if (a.cwd !== b.cwd) {
99
+ return false
100
+ }
101
+ if (!a.env && !b.env) {
102
+ return true
103
+ } else if (a.env && !b.env) {
104
+ return false
105
+ } else if (!a.env && b.env) {
106
+ return false
107
+ } else if (Object.keys(a.env!).length !== Object.keys(b.env!).length) {
108
+ return false
109
+ } else {
110
+ for (const k of Object.keys(a.env!)) {
111
+ if (!b.env![k]) {
112
+ return false
113
+ } else if (a.env![k] !== b.env![k]) {
114
+ return false
11
115
  }
12
- } catch (e) {
13
- ac.abort()
14
- throw e
15
116
  }
16
117
  }
118
+ return true
17
119
  }
18
120
 
19
- function startService(s: DevService, signal: AbortSignal) {
121
+ function startService(s: DevService): ChildProcess {
122
+ opPrint(s, 'starting')
20
123
  const splitCmdAndArgs = s.command.split(/\s+/)
21
124
  const cmd = splitCmdAndArgs[0]
22
125
  const args = splitCmdAndArgs.length === 1 ? [] : splitCmdAndArgs.slice(1)
@@ -34,9 +137,43 @@ function startService(s: DevService, signal: AbortSignal) {
34
137
  const stderrLabel = logLabel(s.cwd, cmd, args, 31)
35
138
  spawned.stderr.on('data', chunk => printChunk(stderrLabel, chunk))
36
139
 
140
+ spawned.on('error', e => {
141
+ const cause =
142
+ 'code' in e && e.code === 'ENOENT' ? 'program not found' : e.message
143
+ opPrint(s, 'error: ' + cause)
144
+ removeFromRunning(s)
145
+ })
146
+
37
147
  spawned.on('exit', () => {
38
- console.log(`[${s.command}]`, 'exit')
148
+ opPrint(s, 'exited')
149
+ removeFromRunning(s)
150
+ removeFromUpdating(s)
39
151
  })
152
+ return spawned
153
+ }
154
+
155
+ function removeFromRunning(s: DevService) {
156
+ for (let i = 0; i < running.length; i++) {
157
+ if (matchingConfig(running[i].s, s)) {
158
+ running.splice(i, 1)
159
+ return
160
+ }
161
+ }
162
+ }
163
+
164
+ function removeFromUpdating(s: DevService) {
165
+ if (updating !== null) {
166
+ for (let i = 0; i < updating.stopping.length; i++) {
167
+ if (matchingConfig(updating.stopping[i], s)) {
168
+ updating.stopping.splice(i, 1)
169
+ if (!updating.stopping.length) {
170
+ updating.starting.forEach(startService)
171
+ updating = null
172
+ return
173
+ }
174
+ }
175
+ }
176
+ }
40
177
  }
41
178
 
42
179
  function printChunk(label: string, c: Buffer) {
@@ -58,6 +195,14 @@ function resolveCwd(p?: string): string | undefined {
58
195
  }
59
196
  }
60
197
 
198
+ function opPrint(s: DevService, msg: string) {
199
+ console.log(opLabel(s), msg)
200
+ }
201
+
202
+ function opLabel(s: DevService) {
203
+ return `\`${s.cwd ? s.cwd + ' ' : ''}${s.command}\``
204
+ }
205
+
61
206
  function logLabel(
62
207
  cwd: string | undefined,
63
208
  cmd: string,
package/lib_js/config.js CHANGED
@@ -9,7 +9,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { isAbsolute, resolve } from 'node:path';
10
10
  const CFG_P = './dank.config.ts';
11
11
  export async function loadConfig(path = CFG_P) {
12
- const module = await import(__rewriteRelativeImportExtension(resolveConfigPath(path)));
12
+ const modulePath = `${resolveConfigPath(path)}?${Date.now()}`;
13
+ const module = await import(__rewriteRelativeImportExtension(modulePath));
13
14
  return await module.default;
14
15
  }
15
16
  export function resolveConfigPath(path) {
package/lib_js/dank.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export async function defineConfig(c) {
2
2
  validatePages(c.pages);
3
3
  validateDevServices(c.services);
4
+ normalizePagePaths(c.pages);
4
5
  return c;
5
6
  }
6
7
  function validatePages(pages) {
@@ -47,3 +48,8 @@ function validateDevServices(services) {
47
48
  }
48
49
  }
49
50
  }
51
+ function normalizePagePaths(pages) {
52
+ for (const urlPath of Object.keys(pages)) {
53
+ pages[urlPath] = pages[urlPath].replace(/^\.\//, '');
54
+ }
55
+ }
package/lib_js/esbuild.js CHANGED
@@ -26,6 +26,8 @@ export async function esbuildDevContext(define, entryPoints, outdir) {
26
26
  entryPoints: removeEntryPointOutExt(entryPoints),
27
27
  outdir,
28
28
  ...webpageBuildOptions,
29
+ metafile: false,
30
+ write: false,
29
31
  });
30
32
  }
31
33
  export async function esbuildWebpages(define, entryPoints, outdir) {