@eighty4/dank 0.0.1-1 → 0.0.1-3

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.
@@ -0,0 +1,77 @@
1
+ new EventSource('http://127.0.0.1:2999/esbuild').addEventListener('change', (e) => {
2
+ const change = JSON.parse(e.data);
3
+ const cssUpdates = change.updated.filter(p => p.endsWith('.css'));
4
+ if (cssUpdates.length) {
5
+ console.log('esbuild css updates', cssUpdates);
6
+ const cssLinks = {};
7
+ for (const elem of document.getElementsByTagName('link')) {
8
+ if (elem.getAttribute('rel') === 'stylesheet') {
9
+ const url = new URL(elem.href);
10
+ if ((url.host = location.host)) {
11
+ cssLinks[url.pathname] = elem;
12
+ }
13
+ }
14
+ }
15
+ let swappedCss = false;
16
+ for (const cssUpdate of cssUpdates) {
17
+ const cssLink = cssLinks[cssUpdate];
18
+ if (cssLink) {
19
+ const next = cssLink.cloneNode();
20
+ next.href = `${cssUpdate}?${Math.random().toString(36).slice(2)}`;
21
+ next.onload = () => cssLink.remove();
22
+ cssLink.parentNode.insertBefore(next, cssLink.nextSibling);
23
+ swappedCss = true;
24
+ }
25
+ }
26
+ if (swappedCss) {
27
+ addCssUpdateIndicator();
28
+ }
29
+ }
30
+ if (cssUpdates.length < change.updated.length) {
31
+ const jsUpdates = change.updated.filter(p => !p.endsWith('.css'));
32
+ const jsScripts = new Set();
33
+ for (const elem of document.getElementsByTagName('script')) {
34
+ if (elem.src.length) {
35
+ const url = new URL(elem.src);
36
+ if ((url.host = location.host)) {
37
+ jsScripts.add(url.pathname);
38
+ }
39
+ }
40
+ }
41
+ if (jsUpdates.some(jsUpdate => jsScripts.has(jsUpdate))) {
42
+ console.log('esbuild js updates require reload');
43
+ addJsReloadIndicator();
44
+ }
45
+ }
46
+ });
47
+ function addCssUpdateIndicator() {
48
+ const indicator = createUpdateIndicator('green', '9999');
49
+ indicator.style.transition = 'opacity ease-in-out .38s';
50
+ indicator.style.opacity = '0';
51
+ indicator.ontransitionend = () => {
52
+ if (indicator.style.opacity === '1') {
53
+ indicator.style.opacity = '0';
54
+ }
55
+ else {
56
+ indicator.remove();
57
+ indicator.onload = null;
58
+ indicator.ontransitionend = null;
59
+ }
60
+ };
61
+ document.body.appendChild(indicator);
62
+ setTimeout(() => (indicator.style.opacity = '1'), 0);
63
+ }
64
+ function addJsReloadIndicator() {
65
+ document.body.appendChild(createUpdateIndicator('orange', '9000'));
66
+ }
67
+ function createUpdateIndicator(color, zIndex) {
68
+ const indicator = document.createElement('div');
69
+ indicator.style.border = '6px dashed ' + color;
70
+ indicator.style.zIndex = zIndex;
71
+ indicator.style.position = 'fixed';
72
+ indicator.style.top = indicator.style.left = '1px';
73
+ indicator.style.height = indicator.style.width = 'calc(100% - 2px)';
74
+ indicator.style.boxSizing = 'border-box';
75
+ return indicator;
76
+ }
77
+ export {};
package/lib/dank.ts CHANGED
@@ -3,20 +3,77 @@ export type DankConfig = {
3
3
  // buildTag?: (() => Promise<string> | string) | string
4
4
  // mapping url to fs paths of webpages to build
5
5
  pages: Record<`/${string}`, `./${string}.html`>
6
+
7
+ services?: Array<DevService>
8
+ }
9
+
10
+ export type DevService = {
11
+ command: string
12
+ cwd?: string
13
+ env?: Record<string, string>
6
14
  }
7
15
 
8
16
  export async function defineConfig(
9
17
  c: Partial<DankConfig>,
10
18
  ): Promise<DankConfig> {
11
- if (typeof c.pages === 'undefined' || Object.keys(c.pages).length === 0) {
19
+ validatePages(c.pages)
20
+ validateDevServices(c.services)
21
+ return c as DankConfig
22
+ }
23
+
24
+ function validatePages(pages?: DankConfig['pages']) {
25
+ if (
26
+ pages === null ||
27
+ typeof pages === 'undefined' ||
28
+ Object.keys(pages).length === 0
29
+ ) {
12
30
  throw Error('DankConfig.pages is required')
13
31
  }
14
- for (const [urlPath, htmlPath] of Object.entries(c.pages)) {
32
+ for (const [urlPath, htmlPath] of Object.entries(pages)) {
15
33
  if (typeof htmlPath !== 'string' || !htmlPath.endsWith('.html')) {
16
34
  throw Error(
17
35
  `DankConfig.pages['${urlPath}'] must configure an html file`,
18
36
  )
19
37
  }
20
38
  }
21
- return c as DankConfig
39
+ }
40
+
41
+ function validateDevServices(services: DankConfig['services']) {
42
+ if (services === null || typeof services === 'undefined') {
43
+ return
44
+ }
45
+ if (!Array.isArray(services)) {
46
+ throw Error(`DankConfig.services must be an array`)
47
+ }
48
+ for (let i = 0; i < services.length; i++) {
49
+ const s = services[i]
50
+ if (s.command === null || typeof s.command === 'undefined') {
51
+ throw Error(`DankConfig.services[${i}].command is required`)
52
+ } else if (typeof s.command !== 'string' || s.command.length === 0) {
53
+ throw Error(
54
+ `DankConfig.services[${i}].command must be a non-empty string`,
55
+ )
56
+ }
57
+ if (s.cwd !== null && typeof s.cwd !== 'undefined') {
58
+ if (typeof s.cwd !== 'string' || s.cwd.trim().length === 0) {
59
+ throw Error(
60
+ `DankConfig.services[${i}].cwd must be a non-empty string`,
61
+ )
62
+ }
63
+ }
64
+ if (s.env !== null && typeof s.env !== 'undefined') {
65
+ if (typeof s.env !== 'object') {
66
+ throw Error(
67
+ `DankConfig.services[${i}].env must be an env variable map`,
68
+ )
69
+ }
70
+ for (const [k, v] of Object.entries(s.env)) {
71
+ if (typeof v !== 'string') {
72
+ throw Error(
73
+ `DankConfig.services[${i}].env[${k}] must be a string`,
74
+ )
75
+ }
76
+ }
77
+ }
78
+ }
22
79
  }
package/lib/esbuild.ts CHANGED
@@ -32,7 +32,7 @@ export async function esbuildDevContext(
32
32
  return await esbuild.context({
33
33
  define,
34
34
  entryNames: '[dir]/[name]',
35
- entryPoints,
35
+ entryPoints: removeEntryPointOutExt(entryPoints),
36
36
  outdir,
37
37
  ...webpageBuildOptions,
38
38
  })
@@ -54,6 +54,9 @@ export async function esbuildWebpages(
54
54
  return buildResult.metafile
55
55
  }
56
56
 
57
+ // esbuild will append the .js or .css to output filenames
58
+ // keeping extension on entryPoints data for consistency
59
+ // and removing and mapping entryPoints to pass to esbuild
57
60
  function removeEntryPointOutExt(
58
61
  entryPoints: Array<{ in: string; out: string }>,
59
62
  ) {
package/lib/html.ts CHANGED
@@ -85,6 +85,19 @@ export class HtmlEntrypoint {
85
85
  }
86
86
  }
87
87
 
88
+ appendScript(clientJS: string) {
89
+ const scriptNode = parseFragment(
90
+ `<script type="module">${clientJS}</script>`,
91
+ ).childNodes[0]
92
+ const htmlNode = this.#document.childNodes.find(
93
+ node => node.nodeName === 'html',
94
+ ) as ParentNode
95
+ const headNode = htmlNode.childNodes.find(
96
+ node => node.nodeName === 'head',
97
+ ) as ParentNode | undefined
98
+ defaultTreeAdapter.appendChild(headNode || htmlNode, scriptNode)
99
+ }
100
+
88
101
  async writeTo(buildDir: string): Promise<void> {
89
102
  await writeFile(
90
103
  join(buildDir, this.#url, 'index.html'),
package/lib/serve.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { mkdir, rm } from 'node:fs/promises'
2
- import { join } from 'node:path'
1
+ import { mkdir, readFile, rm } from 'node:fs/promises'
2
+ import { join, resolve } from 'node:path'
3
3
  import { buildWebsite } from './build.ts'
4
4
  import type { DankConfig } from './dank.ts'
5
5
  import { createGlobalDefinitions } from './define.ts'
@@ -13,6 +13,7 @@ import {
13
13
  type FrontendFetcher,
14
14
  } from './http.ts'
15
15
  import { copyAssets } from './public.ts'
16
+ import { startDevServices } from './services.ts'
16
17
 
17
18
  const isPreview = isPreviewBuild()
18
19
 
@@ -37,6 +38,7 @@ export async function serveWebsite(c: DankConfig): Promise<never> {
37
38
  isPreview ? 'preview' : 'dev server',
38
39
  `is live at http://127.0.0.1:${PORT}`,
39
40
  )
41
+ startDevServices(c)
40
42
  return new Promise(() => {})
41
43
  }
42
44
 
@@ -45,6 +47,11 @@ async function startEsbuildWatch(c: DankConfig): Promise<{ port: number }> {
45
47
  await mkdir(watchDir, { recursive: true })
46
48
  await copyAssets(watchDir)
47
49
 
50
+ const clientJS = await readFile(
51
+ resolve(import.meta.dirname, join('..', 'client', 'esbuild.js')),
52
+ 'utf-8',
53
+ )
54
+
48
55
  const entryPointUrls: Set<string> = new Set()
49
56
  const entryPoints: Array<{ in: string; out: string }> = []
50
57
 
@@ -68,13 +75,12 @@ async function startEsbuildWatch(c: DankConfig): Promise<{ port: number }> {
68
75
  })
69
76
  })
70
77
  html.rewriteHrefs()
78
+ html.appendScript(clientJS)
71
79
  await html.writeTo(watchDir)
72
80
  return html
73
81
  }),
74
82
  )
75
83
 
76
- console.log(entryPoints)
77
-
78
84
  const ctx = await esbuildDevContext(
79
85
  createGlobalDefinitions(),
80
86
  entryPoints,
@@ -0,0 +1,75 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { basename, isAbsolute, resolve } from 'node:path'
3
+ import type { DankConfig, DevService } from './dank.ts'
4
+
5
+ export function startDevServices(c: DankConfig) {
6
+ if (c.services?.length) {
7
+ const ac = new AbortController()
8
+ try {
9
+ for (const s of c.services) {
10
+ startService(s, ac.signal)
11
+ }
12
+ } catch (e) {
13
+ ac.abort()
14
+ throw e
15
+ }
16
+ }
17
+ }
18
+
19
+ function startService(s: DevService, signal: AbortSignal) {
20
+ const splitCmdAndArgs = s.command.split(/\s+/)
21
+ const cmd = splitCmdAndArgs[0]
22
+ const args = splitCmdAndArgs.length === 1 ? [] : splitCmdAndArgs.slice(1)
23
+ const spawned = spawn(cmd, args, {
24
+ cwd: resolveCwd(s.cwd),
25
+ env: s.env,
26
+ signal,
27
+ detached: false,
28
+ shell: false,
29
+ })
30
+
31
+ const stdoutLabel = logLabel(s.cwd, cmd, args, 32)
32
+ spawned.stdout.on('data', chunk => printChunk(stdoutLabel, chunk))
33
+
34
+ const stderrLabel = logLabel(s.cwd, cmd, args, 31)
35
+ spawned.stderr.on('data', chunk => printChunk(stderrLabel, chunk))
36
+
37
+ spawned.on('exit', () => {
38
+ console.log(`[${s.command}]`, 'exit')
39
+ })
40
+ }
41
+
42
+ function printChunk(label: string, c: Buffer) {
43
+ for (const l of parseChunk(c)) console.log(label, l)
44
+ }
45
+
46
+ function parseChunk(c: Buffer): Array<string> {
47
+ return c
48
+ .toString()
49
+ .replace(/\r?\n$/, '')
50
+ .split(/\r?\n/)
51
+ }
52
+
53
+ function resolveCwd(p?: string): string | undefined {
54
+ if (!p || isAbsolute(p)) {
55
+ return p
56
+ } else {
57
+ return resolve(process.cwd(), p)
58
+ }
59
+ }
60
+
61
+ function logLabel(
62
+ cwd: string | undefined,
63
+ cmd: string,
64
+ args: Array<string>,
65
+ ansiColor: number,
66
+ ): string {
67
+ cwd = !cwd
68
+ ? './'
69
+ : cwd.startsWith('/')
70
+ ? `/.../${basename(cwd)}`
71
+ : cwd.startsWith('.')
72
+ ? cwd
73
+ : `./${cwd}`
74
+ return `\u001b[${ansiColor}m[\u001b[1m${cmd}\u001b[22m ${args.join(' ')} \u001b[2;3m${cwd}\u001b[22;23m]\u001b[0m`
75
+ }
package/lib_js/dank.js CHANGED
@@ -1,11 +1,49 @@
1
1
  export async function defineConfig(c) {
2
- if (typeof c.pages === 'undefined' || Object.keys(c.pages).length === 0) {
2
+ validatePages(c.pages);
3
+ validateDevServices(c.services);
4
+ return c;
5
+ }
6
+ function validatePages(pages) {
7
+ if (pages === null ||
8
+ typeof pages === 'undefined' ||
9
+ Object.keys(pages).length === 0) {
3
10
  throw Error('DankConfig.pages is required');
4
11
  }
5
- for (const [urlPath, htmlPath] of Object.entries(c.pages)) {
12
+ for (const [urlPath, htmlPath] of Object.entries(pages)) {
6
13
  if (typeof htmlPath !== 'string' || !htmlPath.endsWith('.html')) {
7
14
  throw Error(`DankConfig.pages['${urlPath}'] must configure an html file`);
8
15
  }
9
16
  }
10
- return c;
17
+ }
18
+ function validateDevServices(services) {
19
+ if (services === null || typeof services === 'undefined') {
20
+ return;
21
+ }
22
+ if (!Array.isArray(services)) {
23
+ throw Error(`DankConfig.services must be an array`);
24
+ }
25
+ for (let i = 0; i < services.length; i++) {
26
+ const s = services[i];
27
+ if (s.command === null || typeof s.command === 'undefined') {
28
+ throw Error(`DankConfig.services[${i}].command is required`);
29
+ }
30
+ else if (typeof s.command !== 'string' || s.command.length === 0) {
31
+ throw Error(`DankConfig.services[${i}].command must be a non-empty string`);
32
+ }
33
+ if (s.cwd !== null && typeof s.cwd !== 'undefined') {
34
+ if (typeof s.cwd !== 'string' || s.cwd.trim().length === 0) {
35
+ throw Error(`DankConfig.services[${i}].cwd must be a non-empty string`);
36
+ }
37
+ }
38
+ if (s.env !== null && typeof s.env !== 'undefined') {
39
+ if (typeof s.env !== 'object') {
40
+ throw Error(`DankConfig.services[${i}].env must be an env variable map`);
41
+ }
42
+ for (const [k, v] of Object.entries(s.env)) {
43
+ if (typeof v !== 'string') {
44
+ throw Error(`DankConfig.services[${i}].env[${k}] must be a string`);
45
+ }
46
+ }
47
+ }
48
+ }
11
49
  }
package/lib_js/esbuild.js CHANGED
@@ -18,7 +18,7 @@ export async function esbuildDevContext(define, entryPoints, outdir) {
18
18
  return await esbuild.context({
19
19
  define,
20
20
  entryNames: '[dir]/[name]',
21
- entryPoints,
21
+ entryPoints: removeEntryPointOutExt(entryPoints),
22
22
  outdir,
23
23
  ...webpageBuildOptions,
24
24
  });
@@ -34,6 +34,9 @@ export async function esbuildWebpages(define, entryPoints, outdir) {
34
34
  esbuildResultChecks(buildResult);
35
35
  return buildResult.metafile;
36
36
  }
37
+ // esbuild will append the .js or .css to output filenames
38
+ // keeping extension on entryPoints data for consistency
39
+ // and removing and mapping entryPoints to pass to esbuild
37
40
  function removeEntryPointOutExt(entryPoints) {
38
41
  return entryPoints.map(entryPoint => {
39
42
  return {
package/lib_js/html.js CHANGED
@@ -52,6 +52,12 @@ export class HtmlEntrypoint {
52
52
  }
53
53
  }
54
54
  }
55
+ appendScript(clientJS) {
56
+ const scriptNode = parseFragment(`<script type="module">${clientJS}</script>`).childNodes[0];
57
+ const htmlNode = this.#document.childNodes.find(node => node.nodeName === 'html');
58
+ const headNode = htmlNode.childNodes.find(node => node.nodeName === 'head');
59
+ defaultTreeAdapter.appendChild(headNode || htmlNode, scriptNode);
60
+ }
55
61
  async writeTo(buildDir) {
56
62
  await writeFile(join(buildDir, this.#url, 'index.html'), serialize(this.#document));
57
63
  }
package/lib_js/serve.js CHANGED
@@ -1,5 +1,5 @@
1
- import { mkdir, rm } from 'node:fs/promises';
2
- import { join } from 'node:path';
1
+ import { mkdir, readFile, rm } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
3
  import { buildWebsite } from "./build.js";
4
4
  import { createGlobalDefinitions } from "./define.js";
5
5
  import { esbuildDevContext } from "./esbuild.js";
@@ -7,6 +7,7 @@ import { isPreviewBuild } from "./flags.js";
7
7
  import { HtmlEntrypoint } from "./html.js";
8
8
  import { createBuiltDistFilesFetcher, createLocalProxyFilesFetcher, createWebServer, } from "./http.js";
9
9
  import { copyAssets } from "./public.js";
10
+ import { startDevServices } from "./services.js";
10
11
  const isPreview = isPreviewBuild();
11
12
  // alternate port for --preview bc of service worker
12
13
  const PORT = isPreview ? 4000 : 3000;
@@ -25,12 +26,14 @@ export async function serveWebsite(c) {
25
26
  }
26
27
  createWebServer(PORT, frontend).listen(PORT);
27
28
  console.log(isPreview ? 'preview' : 'dev server', `is live at http://127.0.0.1:${PORT}`);
29
+ startDevServices(c);
28
30
  return new Promise(() => { });
29
31
  }
30
32
  async function startEsbuildWatch(c) {
31
33
  const watchDir = join('build', 'watch');
32
34
  await mkdir(watchDir, { recursive: true });
33
35
  await copyAssets(watchDir);
36
+ const clientJS = await readFile(resolve(import.meta.dirname, join('..', 'client', 'esbuild.js')), 'utf-8');
34
37
  const entryPointUrls = new Set();
35
38
  const entryPoints = [];
36
39
  await Promise.all(Object.entries(c.pages).map(async ([url, srcPath]) => {
@@ -49,10 +52,10 @@ async function startEsbuildWatch(c) {
49
52
  });
50
53
  });
51
54
  html.rewriteHrefs();
55
+ html.appendScript(clientJS);
52
56
  await html.writeTo(watchDir);
53
57
  return html;
54
58
  }));
55
- console.log(entryPoints);
56
59
  const ctx = await esbuildDevContext(createGlobalDefinitions(), entryPoints, watchDir);
57
60
  await ctx.watch();
58
61
  await ctx.serve({
@@ -0,0 +1,63 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { basename, isAbsolute, resolve } from 'node:path';
3
+ export function startDevServices(c) {
4
+ if (c.services?.length) {
5
+ const ac = new AbortController();
6
+ try {
7
+ for (const s of c.services) {
8
+ startService(s, ac.signal);
9
+ }
10
+ }
11
+ catch (e) {
12
+ ac.abort();
13
+ throw e;
14
+ }
15
+ }
16
+ }
17
+ function startService(s, signal) {
18
+ const splitCmdAndArgs = s.command.split(/\s+/);
19
+ const cmd = splitCmdAndArgs[0];
20
+ const args = splitCmdAndArgs.length === 1 ? [] : splitCmdAndArgs.slice(1);
21
+ const spawned = spawn(cmd, args, {
22
+ cwd: resolveCwd(s.cwd),
23
+ env: s.env,
24
+ signal,
25
+ detached: false,
26
+ shell: false,
27
+ });
28
+ const stdoutLabel = logLabel(s.cwd, cmd, args, 32);
29
+ spawned.stdout.on('data', chunk => printChunk(stdoutLabel, chunk));
30
+ const stderrLabel = logLabel(s.cwd, cmd, args, 31);
31
+ spawned.stderr.on('data', chunk => printChunk(stderrLabel, chunk));
32
+ spawned.on('exit', () => {
33
+ console.log(`[${s.command}]`, 'exit');
34
+ });
35
+ }
36
+ function printChunk(label, c) {
37
+ for (const l of parseChunk(c))
38
+ console.log(label, l);
39
+ }
40
+ function parseChunk(c) {
41
+ return c
42
+ .toString()
43
+ .replace(/\r?\n$/, '')
44
+ .split(/\r?\n/);
45
+ }
46
+ function resolveCwd(p) {
47
+ if (!p || isAbsolute(p)) {
48
+ return p;
49
+ }
50
+ else {
51
+ return resolve(process.cwd(), p);
52
+ }
53
+ }
54
+ function logLabel(cwd, cmd, args, ansiColor) {
55
+ cwd = !cwd
56
+ ? './'
57
+ : cwd.startsWith('/')
58
+ ? `/.../${basename(cwd)}`
59
+ : cwd.startsWith('.')
60
+ ? cwd
61
+ : `./${cwd}`;
62
+ return `\u001b[${ansiColor}m[\u001b[1m${cmd}\u001b[22m ${args.join(' ')} \u001b[2;3m${cwd}\u001b[22;23m]\u001b[0m`;
63
+ }
@@ -1,4 +1,10 @@
1
1
  export type DankConfig = {
2
2
  pages: Record<`/${string}`, `./${string}.html`>;
3
+ services?: Array<DevService>;
4
+ };
5
+ export type DevService = {
6
+ command: string;
7
+ cwd?: string;
8
+ env?: Record<string, string>;
3
9
  };
4
10
  export declare function defineConfig(c: Partial<DankConfig>): Promise<DankConfig>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eighty4/dank",
3
- "version": "0.0.1-1",
3
+ "version": "0.0.1-3",
4
4
  "type": "module",
5
5
  "bin": "./lib_js/bin.js",
6
6
  "exports": {
@@ -20,12 +20,13 @@
20
20
  "typescript": "^5.9.2"
21
21
  },
22
22
  "files": [
23
+ "client/esbuild.js",
23
24
  "lib/*.ts",
24
25
  "lib_js/*.js",
25
26
  "lib_types/*.d.ts"
26
27
  ],
27
28
  "scripts": {
28
- "build": "tsc && tsc -p tsconfig.exports.json",
29
+ "build": "tsc && tsc -p tsconfig.client.json && tsc -p tsconfig.exports.json",
29
30
  "fmt": "prettier --write .",
30
31
  "fmtcheck": "prettier --check .",
31
32
  "typecheck": "tsc --noEmit"