@eighty4/dank 0.0.5-0 → 0.0.5-2

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/services.ts CHANGED
@@ -1,114 +1,228 @@
1
- import { type ChildProcess, spawn } from 'node:child_process'
1
+ import {
2
+ type ChildProcess,
3
+ type ChildProcessWithoutNullStreams,
4
+ execSync,
5
+ spawn,
6
+ } from 'node:child_process'
7
+ import EventEmitter from 'node:events'
2
8
  import { basename, isAbsolute, resolve } from 'node:path'
3
9
  import type { DevService, ResolvedDankConfig } from './config.ts'
4
10
 
5
- export type DevServices = {
6
- http: HttpServices
7
- }
11
+ export class ManagedServiceLabel {
12
+ #command: string
13
+ #cwd: string
14
+
15
+ constructor(spec: DevService) {
16
+ this.#command = spec.command
17
+ this.#cwd = !spec.cwd
18
+ ? './'
19
+ : spec.cwd.startsWith('/')
20
+ ? `/.../${basename(spec.cwd)}`
21
+ : spec.cwd.startsWith('.')
22
+ ? spec.cwd
23
+ : `./${spec.cwd}`
24
+ }
8
25
 
9
- export type HttpServices = {
10
- running: Array<HttpService>
26
+ get command(): string {
27
+ return this.#command
28
+ }
29
+
30
+ get cwd(): string {
31
+ return this.#cwd
32
+ }
11
33
  }
12
34
 
13
35
  export type HttpService = NonNullable<DevService['http']>
14
36
 
15
- // up to date representation of dank.config.ts services
16
- const running: Array<{ s: DevService; process: ChildProcess | null }> = []
17
-
18
- let signal: AbortSignal
19
-
20
- // batch of services that must be stopped before starting new services
21
- let updating: null | {
22
- stopping: Array<DevService>
23
- starting: Array<DevService>
24
- } = null
25
-
26
- export function startDevServices(
27
- services: ResolvedDankConfig['services'],
28
- _signal: AbortSignal,
29
- ): DevServices {
30
- signal = _signal
31
- if (services?.length) {
32
- for (const s of services) {
33
- running.push({ s, process: startService(s) })
34
- }
37
+ export type DevServiceEvents = {
38
+ error: [label: ManagedServiceLabel, cause: string]
39
+ exit: [label: ManagedServiceLabel, code: number | string]
40
+ launch: [label: ManagedServiceLabel]
41
+ stdout: [label: ManagedServiceLabel, output: Array<string>]
42
+ stderr: [label: ManagedServiceLabel, output: Array<string>]
43
+ }
44
+
45
+ class ManagedService extends EventEmitter<DevServiceEvents> {
46
+ #label: ManagedServiceLabel
47
+ #process: ChildProcess | null
48
+ #spec: DevService
49
+ // #status: ManagedServiceStatus = 'starting'
50
+
51
+ constructor(spec: DevService) {
52
+ super()
53
+ this.#label = new ManagedServiceLabel(spec)
54
+ this.#spec = spec
55
+ this.#process = this.#start()
35
56
  }
36
- return {
37
- http: {
38
- get running(): Array<HttpService> {
39
- return running.map(({ s }) => s.http).filter(http => !!http)
40
- },
41
- },
57
+
58
+ get spec(): DevService {
59
+ return this.#spec
42
60
  }
43
- }
44
61
 
45
- export function updateDevServices(services: ResolvedDankConfig['services']) {
46
- if (!services?.length) {
47
- if (running.length) {
48
- if (updating === null) {
49
- updating = { stopping: [], starting: [] }
50
- }
51
- running.forEach(({ s, process }) => {
52
- if (process) {
53
- stopService(s, process)
54
- } else {
55
- removeFromUpdating(s)
56
- }
57
- })
58
- running.length = 0
59
- }
60
- } else {
61
- if (updating === null) {
62
- updating = { stopping: [], starting: [] }
63
- }
64
- const keep = []
65
- const next: Array<DevService> = []
66
- for (const s of services) {
67
- let found = false
68
- for (let i = 0; i < running.length; i++) {
69
- const p = running[i].s
70
- if (matchingConfig(s, p)) {
71
- found = true
72
- keep.push(i)
73
- break
74
- }
75
- }
76
- if (!found) {
77
- next.push(s)
78
- }
79
- }
80
- for (let i = running.length - 1; i >= 0; i--) {
81
- if (!keep.includes(i)) {
82
- const { s, process } = running[i]
83
- if (process) {
84
- stopService(s, process)
85
- } else {
86
- removeFromUpdating(s)
87
- }
88
- running.splice(i, 1)
62
+ get httpSpec(): HttpService | undefined {
63
+ return this.#spec.http
64
+ }
65
+
66
+ matches(other: DevService): boolean {
67
+ return matchingConfig(this.#spec, other)
68
+ }
69
+
70
+ kill() {
71
+ if (this.#process) killProcess(this.#process)
72
+ }
73
+
74
+ #start(): ChildProcess {
75
+ const { path, args } = parseCommand(this.#spec.command)
76
+ const env = this.#spec.env
77
+ ? { ...process.env, ...this.#spec.env }
78
+ : undefined
79
+ const cwd =
80
+ !this.#spec.cwd || isAbsolute(this.#spec.cwd)
81
+ ? this.#spec.cwd
82
+ : resolve(process.cwd(), this.#spec.cwd)
83
+ const spawned = spawnProcess(path, args, env, cwd)
84
+ this.emit('launch', this.#label)
85
+ spawned.stdout.on('data', chunk =>
86
+ this.emit('stdout', this.#label, parseChunk(chunk)),
87
+ )
88
+ spawned.stderr.on('data', chunk =>
89
+ this.emit('stderr', this.#label, parseChunk(chunk)),
90
+ )
91
+ spawned.on('error', e => {
92
+ if (e.name === 'AbortError') {
93
+ return
89
94
  }
95
+ const cause =
96
+ 'code' in e && e.code === 'ENOENT'
97
+ ? 'program not found'
98
+ : e.message
99
+ this.emit('error', this.#label, cause)
100
+ })
101
+ spawned.on('exit', (code, signal) =>
102
+ this.emit('exit', this.#label, code || signal!),
103
+ )
104
+ return spawned
105
+ }
106
+ }
107
+
108
+ type SpawnProcess = (
109
+ program: string,
110
+ args: Array<string>,
111
+ env: NodeJS.ProcessEnv | undefined,
112
+ cwd: string | undefined,
113
+ ) => ChildProcessWithoutNullStreams
114
+
115
+ type KillProcess = (p: ChildProcess) => void
116
+
117
+ const killProcess: KillProcess =
118
+ process.platform === 'win32'
119
+ ? p => execSync(`taskkill /pid ${p.pid} /T /F`)
120
+ : p => p.kill()
121
+
122
+ const spawnProcess: SpawnProcess =
123
+ process.platform === 'win32'
124
+ ? (
125
+ path: string,
126
+ args: Array<string>,
127
+ env: NodeJS.ProcessEnv | undefined,
128
+ cwd: string | undefined,
129
+ ) =>
130
+ spawn('cmd', ['/c', path, ...args], {
131
+ cwd,
132
+ env,
133
+ detached: false,
134
+ shell: false,
135
+ windowsHide: true,
136
+ })
137
+ : (
138
+ path: string,
139
+ args: Array<string>,
140
+ env: NodeJS.ProcessEnv | undefined,
141
+ cwd: string | undefined,
142
+ ) =>
143
+ spawn(path, args, {
144
+ cwd,
145
+ env,
146
+ detached: false,
147
+ shell: false,
148
+ })
149
+
150
+ export class DevServices extends EventEmitter<DevServiceEvents> {
151
+ #running: Array<ManagedService>
152
+
153
+ constructor(services: ResolvedDankConfig['services']) {
154
+ super()
155
+ this.#running = services ? this.#start(services) : []
156
+ if (process.platform === 'win32') {
157
+ process.once('SIGINT', () => process.exit())
90
158
  }
91
- if (updating.stopping.length) {
92
- for (const s of next) {
93
- if (
94
- !updating.starting.find(queued => matchingConfig(queued, s))
95
- ) {
96
- updating.starting.push(s)
97
- }
98
- }
99
- } else {
100
- updating = null
101
- for (const s of next) {
102
- running.push({ s, process: startService(s) })
103
- }
159
+ process.once('exit', this.shutdown)
160
+ }
161
+
162
+ get httpServices(): Array<HttpService> {
163
+ return this.#running.map(s => s.httpSpec).filter(http => !!http)
164
+ }
165
+
166
+ shutdown = () => {
167
+ this.#running.forEach(s => {
168
+ s.kill()
169
+ s.removeAllListeners()
170
+ })
171
+ this.#running.length = 0
172
+ }
173
+
174
+ update(services: ResolvedDankConfig['services']) {
175
+ if (!services?.length) {
176
+ this.shutdown()
177
+ } else if (
178
+ !matchingConfigs(
179
+ this.#running.map(s => s.spec),
180
+ services,
181
+ )
182
+ ) {
183
+ this.shutdown()
184
+ this.#running = this.#start(services)
104
185
  }
105
186
  }
187
+
188
+ #start(
189
+ services: NonNullable<ResolvedDankConfig['services']>,
190
+ ): Array<ManagedService> {
191
+ return services.map(spec => {
192
+ const service = new ManagedService(spec)
193
+ service.on('error', (label, cause) =>
194
+ this.emit('error', label, cause),
195
+ )
196
+ service.on('exit', (label, code) => this.emit('exit', label, code))
197
+ service.on('launch', label => this.emit('launch', label))
198
+ service.on('stdout', (label, output) =>
199
+ this.emit('stdout', label, output),
200
+ )
201
+ service.on('stderr', (label, output) =>
202
+ this.emit('stderr', label, output),
203
+ )
204
+ return service
205
+ })
206
+ }
106
207
  }
107
208
 
108
- function stopService(s: DevService, process: ChildProcess) {
109
- opPrint(s, 'stopping')
110
- updating!.stopping.push(s)
111
- process.kill()
209
+ function matchingConfigs(
210
+ a: Array<DevService>,
211
+ b: NonNullable<ResolvedDankConfig['services']>,
212
+ ): boolean {
213
+ if (a.length !== b.length) {
214
+ return false
215
+ }
216
+ const crossRef = [...a]
217
+ for (const toFind of b) {
218
+ const found = crossRef.findIndex(spec => matchingConfig(spec, toFind))
219
+ if (found === -1) {
220
+ return false
221
+ } else {
222
+ crossRef.splice(found, 1)
223
+ }
224
+ }
225
+ return true
112
226
  }
113
227
 
114
228
  function matchingConfig(a: DevService, b: DevService): boolean {
@@ -138,72 +252,6 @@ function matchingConfig(a: DevService, b: DevService): boolean {
138
252
  return true
139
253
  }
140
254
 
141
- function startService(s: DevService): ChildProcess {
142
- opPrint(s, 'starting')
143
- const splitCmdAndArgs = s.command.split(/\s+/)
144
- const cmd = splitCmdAndArgs[0]
145
- const args = splitCmdAndArgs.length === 1 ? [] : splitCmdAndArgs.slice(1)
146
- const spawned = spawn(cmd, args, {
147
- cwd: resolveCwd(s.cwd),
148
- env: s.env,
149
- signal,
150
- detached: false,
151
- shell: false,
152
- })
153
-
154
- const stdoutLabel = logLabel(s.cwd, cmd, args, 32)
155
- spawned.stdout.on('data', chunk => printChunk(stdoutLabel, chunk))
156
-
157
- const stderrLabel = logLabel(s.cwd, cmd, args, 31)
158
- spawned.stderr.on('data', chunk => printChunk(stderrLabel, chunk))
159
-
160
- spawned.on('error', e => {
161
- if (e.name !== 'AbortError') {
162
- const cause =
163
- 'code' in e && e.code === 'ENOENT'
164
- ? 'program not found'
165
- : e.message
166
- opPrint(s, 'error: ' + cause)
167
- }
168
- removeFromRunning(s)
169
- })
170
-
171
- spawned.on('exit', () => {
172
- opPrint(s, 'exited')
173
- removeFromRunning(s)
174
- removeFromUpdating(s)
175
- })
176
- return spawned
177
- }
178
-
179
- function removeFromRunning(s: DevService) {
180
- for (let i = 0; i < running.length; i++) {
181
- if (matchingConfig(running[i].s, s)) {
182
- running.splice(i, 1)
183
- return
184
- }
185
- }
186
- }
187
-
188
- function removeFromUpdating(s: DevService) {
189
- if (updating !== null) {
190
- for (let i = 0; i < updating.stopping.length; i++) {
191
- if (matchingConfig(updating.stopping[i], s)) {
192
- updating.stopping.splice(i, 1)
193
- if (!updating.stopping.length) {
194
- updating.starting.forEach(startService)
195
- updating = null
196
- return
197
- }
198
- }
199
- }
200
- }
201
- }
202
-
203
- function printChunk(label: string, c: Buffer) {
204
- for (const l of parseChunk(c)) console.log(label, l)
205
- }
206
-
207
255
  function parseChunk(c: Buffer): Array<string> {
208
256
  return c
209
257
  .toString()
@@ -211,34 +259,50 @@ function parseChunk(c: Buffer): Array<string> {
211
259
  .split(/\r?\n/)
212
260
  }
213
261
 
214
- function resolveCwd(p?: string): string | undefined {
215
- if (!p || isAbsolute(p)) {
216
- return p
217
- } else {
218
- return resolve(process.cwd(), p)
262
+ export function parseCommand(command: string): {
263
+ path: string
264
+ args: Array<string>
265
+ } {
266
+ command = command.trimStart()
267
+ const programSplitIndex = command.indexOf(' ')
268
+ if (programSplitIndex === -1) {
269
+ return { path: command.trim(), args: [] }
219
270
  }
220
- }
221
-
222
- function opPrint(s: DevService, msg: string) {
223
- console.log(opLabel(s), msg)
224
- }
225
-
226
- function opLabel(s: DevService) {
227
- return `\`${s.cwd ? s.cwd + ' ' : ''}${s.command}\``
228
- }
229
-
230
- function logLabel(
231
- cwd: string | undefined,
232
- cmd: string,
233
- args: Array<string>,
234
- ansiColor: number,
235
- ): string {
236
- cwd = !cwd
237
- ? './'
238
- : cwd.startsWith('/')
239
- ? `/.../${basename(cwd)}`
240
- : cwd.startsWith('.')
241
- ? cwd
242
- : `./${cwd}`
243
- return `\u001b[${ansiColor}m[\u001b[1m${cmd}\u001b[22m ${args.join(' ')} \u001b[2;3m${cwd}\u001b[22;23m]\u001b[0m`
271
+ const path = command.substring(0, programSplitIndex)
272
+ const args: Array<string> = []
273
+ let argStart = programSplitIndex + 1
274
+ let withinLiteral: false | "'" | '"' = false
275
+ for (let i = 0; i < command.length; i++) {
276
+ const c = command[i]
277
+ if (!withinLiteral) {
278
+ if (c === "'" || c === '"') {
279
+ withinLiteral = c
280
+ continue
281
+ }
282
+ if (c === '\\') {
283
+ i++
284
+ continue
285
+ }
286
+ }
287
+ if (withinLiteral) {
288
+ if (c === withinLiteral) {
289
+ withinLiteral = false
290
+ args.push(command.substring(argStart + 1, i))
291
+ argStart = i + 1
292
+ }
293
+ continue
294
+ }
295
+ if (c === ' ' && i > argStart) {
296
+ const maybeArg = command.substring(argStart, i).trim()
297
+ if (maybeArg.length) {
298
+ args.push(maybeArg)
299
+ }
300
+ argStart = i + 1
301
+ }
302
+ }
303
+ const maybeArg = command.substring(argStart, command.length).trim()
304
+ if (maybeArg.length) {
305
+ args.push(maybeArg)
306
+ }
307
+ return { path, args }
244
308
  }
package/lib/watch.ts CHANGED
@@ -1,18 +1,49 @@
1
- import { watch as createWatch } from 'node:fs/promises'
1
+ import {
2
+ watch as createWatch,
3
+ type WatchOptionsWithStringEncoding,
4
+ } from 'node:fs/promises'
5
+
6
+ type WatchCallback = (filename: string) => void
7
+
8
+ export async function watch(p: string, fire: WatchCallback): Promise<void>
2
9
 
3
10
  export async function watch(
4
11
  p: string,
5
12
  signal: AbortSignal,
6
- fire: (filename: string) => void,
7
- ) {
13
+ fire: WatchCallback,
14
+ ): Promise<void>
15
+
16
+ export async function watch(
17
+ p: string,
18
+ opts: WatchOptionsWithStringEncoding,
19
+ fire: WatchCallback,
20
+ ): Promise<void>
21
+
22
+ export async function watch(
23
+ p: string,
24
+ signalFireOrOpts:
25
+ | AbortSignal
26
+ | WatchCallback
27
+ | WatchOptionsWithStringEncoding,
28
+ fireOrUndefined?: WatchCallback,
29
+ ): Promise<void> {
30
+ let opts: WatchOptionsWithStringEncoding | undefined
31
+ let fire: WatchCallback
32
+ if (signalFireOrOpts instanceof AbortSignal) {
33
+ opts = { signal: signalFireOrOpts }
34
+ } else if (typeof signalFireOrOpts === 'object') {
35
+ opts = signalFireOrOpts
36
+ } else {
37
+ fire = signalFireOrOpts
38
+ }
39
+ if (opts && typeof fireOrUndefined === 'function') {
40
+ fire = fireOrUndefined
41
+ }
8
42
  const delayFire = 90
9
43
  const timeout = 100
10
44
  let changes: Record<string, number> = {}
11
45
  try {
12
- for await (const { filename } of createWatch(p, {
13
- recursive: true,
14
- signal,
15
- })) {
46
+ for await (const { filename } of createWatch(p, opts)) {
16
47
  if (filename) {
17
48
  if (!changes[filename]) {
18
49
  const now = Date.now()
package/lib_js/dirs.js CHANGED
@@ -2,18 +2,18 @@ import { realpath } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, join, resolve } from "node:path";
3
3
  async function defaultProjectDirs(projectRootAbs) {
4
4
  if (!isAbsolute(projectRootAbs)) {
5
- throw Error();
5
+ throw Error("must use an absolute project root path");
6
+ }
7
+ if (await realpath(projectRootAbs) !== projectRootAbs) {
8
+ throw Error("must use a real project root path");
6
9
  }
7
- const projectResolved = await realpath(projectRootAbs);
8
10
  const pages = "pages";
9
- const pagesResolved = join(projectResolved, pages);
10
11
  return Object.freeze({
11
12
  buildRoot: "build",
12
13
  buildDist: join("build", "dist"),
13
14
  buildWatch: join("build", "watch"),
14
15
  pages,
15
- pagesResolved,
16
- projectResolved,
16
+ pagesAbs: join(projectRootAbs, pages),
17
17
  projectRootAbs,
18
18
  public: "public"
19
19
  });
@@ -40,7 +40,7 @@ class Resolver {
40
40
  }
41
41
  // `p` is expected to be a relative path resolvable from the project dir
42
42
  isProjectSubpathInPagesDir(p) {
43
- return resolve(join(this.#dirs.projectResolved, p)).startsWith(this.#dirs.pagesResolved);
43
+ return resolve(join(this.#dirs.projectRootAbs, p)).startsWith(this.#dirs.pagesAbs);
44
44
  }
45
45
  // `p` is expected to be a relative path resolvable from the pages dir
46
46
  isPagesSubpathInPagesDir(p) {
package/lib_js/http.js CHANGED
@@ -4,7 +4,7 @@ import { createServer } from "node:http";
4
4
  import { extname, join } from "node:path";
5
5
  import { Readable } from "node:stream";
6
6
  import mime from "mime";
7
- function startWebServer(port, flags, dirs, urlRewriteProvider, frontendFetcher, httpServices) {
7
+ function startWebServer(port, flags, dirs, urlRewriteProvider, frontendFetcher, devServices) {
8
8
  const serverAddress = "http://localhost:" + port;
9
9
  const handler = (req, res) => {
10
10
  if (!req.url || !req.method) {
@@ -12,13 +12,13 @@ function startWebServer(port, flags, dirs, urlRewriteProvider, frontendFetcher,
12
12
  } else {
13
13
  const url = new URL(serverAddress + req.url);
14
14
  const headers = convertHeadersToFetch(req.headers);
15
- frontendFetcher(url, headers, res, () => onNotFound(req, url, headers, httpServices, flags, dirs, urlRewriteProvider, res));
15
+ frontendFetcher(url, headers, res, () => onNotFound(req, url, headers, devServices, flags, dirs, urlRewriteProvider, res));
16
16
  }
17
17
  };
18
18
  createServer(flags.logHttp ? createLogWrapper(handler) : handler).listen(port);
19
19
  console.log(flags.preview ? "preview" : "dev", `server is live at http://127.0.0.1:${port}`);
20
20
  }
21
- async function onNotFound(req, url, headers, httpServices, flags, dirs, urlRewriteProvider, res) {
21
+ async function onNotFound(req, url, headers, devServices, flags, dirs, urlRewriteProvider, res) {
22
22
  if (req.method === "GET" && extname(url.pathname) === "") {
23
23
  const urlRewrite = tryUrlRewrites(flags, dirs, urlRewriteProvider.urlRewrites, url);
24
24
  if (urlRewrite) {
@@ -26,7 +26,7 @@ async function onNotFound(req, url, headers, httpServices, flags, dirs, urlRewri
26
26
  return;
27
27
  }
28
28
  }
29
- const fetchResponse = await tryHttpServices(req, url, headers, httpServices);
29
+ const fetchResponse = await tryHttpServices(req, url, headers, devServices);
30
30
  if (fetchResponse) {
31
31
  sendFetchResponse(res, fetchResponse);
32
32
  } else {
@@ -46,13 +46,12 @@ function tryUrlRewrites(flags, dirs, urlRewrites, url) {
46
46
  const urlRewrite = urlRewrites.find((urlRewrite2) => urlRewrite2.pattern.test(url.pathname));
47
47
  return urlRewrite ? join(flags.preview ? dirs.buildDist : dirs.buildWatch, urlRewrite.url, "index.html") : null;
48
48
  }
49
- async function tryHttpServices(req, url, headers, httpServices) {
49
+ async function tryHttpServices(req, url, headers, devServices) {
50
50
  if (url.pathname.startsWith("/.well-known/")) {
51
51
  return null;
52
52
  }
53
53
  const body = await collectReqBody(req);
54
- const { running } = httpServices;
55
- for (const httpService of running) {
54
+ for (const httpService of devServices.httpServices) {
56
55
  const proxyUrl = new URL(url);
57
56
  proxyUrl.port = `${httpService.port}`;
58
57
  try {
@@ -94,9 +93,9 @@ function createLogWrapper(handler) {
94
93
  function createBuiltDistFilesFetcher(dirs, manifest) {
95
94
  return (url, _headers, res, notFound) => {
96
95
  if (manifest.pageUrls.has(url.pathname)) {
97
- streamFile(join(dirs.projectResolved, dirs.buildDist, url.pathname, "index.html"), res);
96
+ streamFile(join(dirs.projectRootAbs, dirs.buildDist, url.pathname, "index.html"), res);
98
97
  } else if (manifest.files.has(url.pathname)) {
99
- streamFile(join(dirs.projectResolved, dirs.buildDist, url.pathname), res);
98
+ streamFile(join(dirs.projectRootAbs, dirs.buildDist, url.pathname), res);
100
99
  } else {
101
100
  notFound();
102
101
  }