@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_js/http.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createReadStream } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
2
3
  import { createServer, } from 'node:http';
3
- import { extname, join as fsJoin } from 'node:path';
4
+ import { extname, join } from 'node:path';
4
5
  import { isProductionBuild } from "./flags.js";
5
6
  export function createWebServer(port, frontendFetcher) {
6
7
  const serverAddress = 'http://localhost:' + port;
@@ -27,32 +28,98 @@ export function createBuiltDistFilesFetcher(dir, files) {
27
28
  res.end();
28
29
  }
29
30
  else {
30
- const mimeType = resolveMimeType(url);
31
- res.setHeader('Content-Type', mimeType);
32
- const reading = createReadStream(mimeType === 'text/html'
33
- ? fsJoin(dir, url.pathname, 'index.html')
34
- : fsJoin(dir, url.pathname));
35
- reading.pipe(res);
36
- reading.on('error', err => {
37
- console.error(`${url.pathname} file read ${reading.path} error ${err.message}`);
38
- res.statusCode = 500;
39
- res.end();
40
- });
31
+ const p = extname(url.pathname) === ''
32
+ ? join(dir, url.pathname, 'index.html')
33
+ : join(dir, url.pathname);
34
+ streamFile(p, res);
41
35
  }
42
36
  };
43
37
  }
44
- export function createLocalProxyFilesFetcher(port) {
45
- const proxyAddress = 'http://127.0.0.1:' + port;
38
+ export function createDevServeFilesFetcher(opts) {
39
+ const proxyAddress = 'http://127.0.0.1:' + opts.proxyPort;
46
40
  return (url, _headers, res) => {
47
- fetch(proxyAddress + url.pathname).then(fetchResponse => {
48
- res.writeHead(fetchResponse.status, convertHeadersFromFetch(fetchResponse.headers));
49
- fetchResponse.bytes().then(data => res.end(data));
50
- });
41
+ if (opts.pages[url.pathname]) {
42
+ streamFile(join(opts.pagesDir, url.pathname + 'index.html'), res);
43
+ }
44
+ else {
45
+ const maybePublicPath = join(opts.publicDir, url.pathname);
46
+ exists(join(opts.publicDir, url.pathname)).then(fromPublic => {
47
+ if (fromPublic) {
48
+ streamFile(maybePublicPath, res);
49
+ }
50
+ else {
51
+ retryFetchWithTimeout(proxyAddress + url.pathname)
52
+ .then(fetchResponse => {
53
+ res.writeHead(fetchResponse.status, convertHeadersFromFetch(fetchResponse.headers));
54
+ fetchResponse.bytes().then(data => res.end(data));
55
+ })
56
+ .catch(e => {
57
+ if (e === 'retrytimeout') {
58
+ res.writeHead(504);
59
+ }
60
+ else {
61
+ console.error('unknown frontend proxy fetch error:', e);
62
+ res.writeHead(502);
63
+ }
64
+ res.end();
65
+ });
66
+ }
67
+ });
68
+ }
51
69
  };
52
70
  }
53
- function resolveMimeType(url) {
54
- switch (extname(url.pathname)) {
55
- case '':
71
+ const PROXY_FETCH_RETRY_INTERVAL = 27;
72
+ const PROXY_FETCH_RETRY_TIMEOUT = 1000;
73
+ async function retryFetchWithTimeout(url) {
74
+ let timeout = Date.now() + PROXY_FETCH_RETRY_TIMEOUT;
75
+ while (true) {
76
+ try {
77
+ return await fetch(url);
78
+ }
79
+ catch (e) {
80
+ if (isNodeFailedFetch(e) || isBunFailedFetch(e)) {
81
+ if (timeout < Date.now()) {
82
+ throw 'retrytimeout';
83
+ }
84
+ else {
85
+ await new Promise(res => setTimeout(res, PROXY_FETCH_RETRY_INTERVAL));
86
+ }
87
+ }
88
+ else {
89
+ throw e;
90
+ }
91
+ }
92
+ }
93
+ }
94
+ function isBunFailedFetch(e) {
95
+ return e.code === 'ConnectionRefused';
96
+ }
97
+ function isNodeFailedFetch(e) {
98
+ return e.message === 'fetch failed';
99
+ }
100
+ async function exists(p) {
101
+ try {
102
+ const maybe = stat(p);
103
+ return (await maybe).isFile();
104
+ }
105
+ catch (ignore) {
106
+ return false;
107
+ }
108
+ }
109
+ function streamFile(p, res) {
110
+ const mimeType = resolveMimeType(p);
111
+ res.setHeader('Content-Type', mimeType);
112
+ const reading = createReadStream(p);
113
+ reading.pipe(res);
114
+ reading.on('error', err => {
115
+ console.error(`file read ${reading.path} error ${err.message}`);
116
+ res.statusCode = 500;
117
+ res.end();
118
+ });
119
+ }
120
+ function resolveMimeType(p) {
121
+ switch (extname(p)) {
122
+ case '.html':
56
123
  return 'text/html';
57
124
  case '.js':
58
125
  return 'text/javascript';
@@ -71,7 +138,7 @@ function resolveMimeType(url) {
71
138
  case '.woff2':
72
139
  return 'font/woff2';
73
140
  default:
74
- console.warn('? mime type for', url.pathname);
141
+ console.warn('? mime type for', p);
75
142
  if (!isProductionBuild())
76
143
  process.exit(1);
77
144
  return 'application/octet-stream';
package/lib_js/serve.js CHANGED
@@ -1,13 +1,13 @@
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
3
  import { buildWebsite } from "./build.js";
4
+ import { loadConfig } from "./config.js";
4
5
  import { createGlobalDefinitions } from "./define.js";
5
6
  import { esbuildDevContext } from "./esbuild.js";
6
7
  import { isPreviewBuild } from "./flags.js";
7
8
  import { HtmlEntrypoint } from "./html.js";
8
- import { createBuiltDistFilesFetcher, createLocalProxyFilesFetcher, createWebServer, } from "./http.js";
9
- import { copyAssets } from "./public.js";
10
- import { startDevServices } from "./services.js";
9
+ import { createBuiltDistFilesFetcher, createDevServeFilesFetcher, createWebServer, } from "./http.js";
10
+ import { startDevServices, updateDevServices } from "./services.js";
11
11
  const isPreview = isPreviewBuild();
12
12
  // alternate port for --preview bc of service worker
13
13
  const PORT = isPreview ? 4000 : 3000;
@@ -15,56 +15,229 @@ const PORT = isPreview ? 4000 : 3000;
15
15
  const ESBUILD_PORT = 2999;
16
16
  export async function serveWebsite(c) {
17
17
  await rm('build', { force: true, recursive: true });
18
- let frontend;
19
18
  if (isPreview) {
20
- const { dir, files } = await buildWebsite(c);
21
- frontend = createBuiltDistFilesFetcher(dir, files);
19
+ await startPreviewMode(c);
22
20
  }
23
21
  else {
24
- const { port } = await startEsbuildWatch(c);
25
- frontend = createLocalProxyFilesFetcher(port);
22
+ const abortController = new AbortController();
23
+ await startDevMode(c, abortController.signal);
26
24
  }
27
- createWebServer(PORT, frontend).listen(PORT);
28
- console.log(isPreview ? 'preview' : 'dev server', `is live at http://127.0.0.1:${PORT}`);
29
- startDevServices(c);
30
25
  return new Promise(() => { });
31
26
  }
32
- async function startEsbuildWatch(c) {
27
+ async function startPreviewMode(c) {
28
+ const { dir, files } = await buildWebsite(c);
29
+ const frontend = createBuiltDistFilesFetcher(dir, files);
30
+ createWebServer(PORT, frontend).listen(PORT);
31
+ console.log(`preview is live at http://127.0.0.1:${PORT}`);
32
+ }
33
+ // todo changing partials triggers update on html pages
34
+ async function startDevMode(c, signal) {
33
35
  const watchDir = join('build', 'watch');
34
36
  await mkdir(watchDir, { recursive: true });
35
- await copyAssets(watchDir);
36
- const clientJS = await readFile(resolve(import.meta.dirname, join('..', 'client', 'esbuild.js')), 'utf-8');
37
- const entryPointUrls = new Set();
38
- const entryPoints = [];
39
- await Promise.all(Object.entries(c.pages).map(async ([url, srcPath]) => {
40
- const html = await HtmlEntrypoint.readFrom(url, join('pages', srcPath));
41
- await html.injectPartials();
42
- if (url !== '/') {
43
- await mkdir(join(watchDir, url), { recursive: true });
37
+ const clientJS = await loadClientJS();
38
+ const pagesByUrlPath = {};
39
+ const entryPointsByUrlPath = {};
40
+ let buildContext = null;
41
+ watch('dank.config.ts', signal, async () => {
42
+ let updated;
43
+ try {
44
+ updated = await loadConfig();
45
+ }
46
+ catch (ignore) {
47
+ return;
48
+ }
49
+ const prevPages = new Set(Object.keys(pagesByUrlPath));
50
+ await Promise.all(Object.entries(updated.pages).map(async ([urlPath, srcPath]) => {
51
+ c.pages[urlPath] = srcPath;
52
+ if (pagesByUrlPath[urlPath]) {
53
+ prevPages.delete(urlPath);
54
+ if (pagesByUrlPath[urlPath].srcPath !== srcPath) {
55
+ await updatePage(urlPath);
56
+ }
57
+ }
58
+ else {
59
+ await addPage(urlPath, srcPath);
60
+ }
61
+ }));
62
+ for (const prevPage of Array.from(prevPages)) {
63
+ delete c.pages[prevPage];
64
+ deletePage(prevPage);
65
+ }
66
+ updateDevServices(updated);
67
+ });
68
+ watch('pages', signal, filename => {
69
+ if (extname(filename) === '.html') {
70
+ for (const [urlPath, srcPath] of Object.entries(c.pages)) {
71
+ if (srcPath === filename) {
72
+ updatePage(urlPath);
73
+ }
74
+ }
75
+ }
76
+ });
77
+ await Promise.all(Object.entries(c.pages).map(([urlPath, srcPath]) => addPage(urlPath, srcPath)));
78
+ async function addPage(urlPath, srcPath) {
79
+ const metadata = await processWebpage({
80
+ clientJS,
81
+ outDir: watchDir,
82
+ pagesDir: 'pages',
83
+ srcPath,
84
+ urlPath,
85
+ });
86
+ pagesByUrlPath[urlPath] = metadata;
87
+ entryPointsByUrlPath[urlPath] = new Set(metadata.entryPoints.map(e => e.in));
88
+ if (buildContext !== null) {
89
+ resetBuildContext();
44
90
  }
45
- html.collectScripts()
46
- .filter(scriptImport => !entryPointUrls.has(scriptImport.in))
47
- .forEach(scriptImport => {
48
- entryPointUrls.add(scriptImport.in);
49
- entryPoints.push({
50
- in: scriptImport.in,
51
- out: scriptImport.out,
91
+ }
92
+ function deletePage(urlPath) {
93
+ delete pagesByUrlPath[urlPath];
94
+ delete entryPointsByUrlPath[urlPath];
95
+ resetBuildContext();
96
+ }
97
+ async function updatePage(urlPath) {
98
+ const update = await processWebpage({
99
+ clientJS,
100
+ outDir: watchDir,
101
+ pagesDir: 'pages',
102
+ srcPath: c.pages[urlPath],
103
+ urlPath,
104
+ });
105
+ const entryPointUrls = new Set(update.entryPoints.map(e => e.in));
106
+ if (!hasSameValues(entryPointUrls, entryPointsByUrlPath[urlPath])) {
107
+ entryPointsByUrlPath[urlPath] = entryPointUrls;
108
+ resetBuildContext();
109
+ }
110
+ }
111
+ function collectEntrypoints() {
112
+ const sources = new Set();
113
+ return Object.values(pagesByUrlPath)
114
+ .flatMap(({ entryPoints }) => entryPoints)
115
+ .filter(entryPoint => {
116
+ if (sources.has(entryPoint.in)) {
117
+ return false;
118
+ }
119
+ else {
120
+ sources.add(entryPoint.in);
121
+ return true;
122
+ }
123
+ });
124
+ }
125
+ function resetBuildContext() {
126
+ if (buildContext === 'starting' || buildContext === 'dirty') {
127
+ buildContext = 'dirty';
128
+ return;
129
+ }
130
+ if (buildContext === 'disposing') {
131
+ return;
132
+ }
133
+ if (buildContext !== null) {
134
+ const prev = buildContext;
135
+ buildContext = 'disposing';
136
+ prev.dispose().then(() => {
137
+ buildContext = null;
138
+ resetBuildContext();
52
139
  });
140
+ }
141
+ else {
142
+ startEsbuildWatch(collectEntrypoints()).then(ctx => {
143
+ if (buildContext === 'dirty') {
144
+ buildContext = null;
145
+ resetBuildContext();
146
+ }
147
+ else {
148
+ buildContext = ctx;
149
+ }
150
+ });
151
+ }
152
+ }
153
+ buildContext = await startEsbuildWatch(collectEntrypoints());
154
+ const frontend = createDevServeFilesFetcher({
155
+ pages: c.pages,
156
+ pagesDir: watchDir,
157
+ proxyPort: ESBUILD_PORT,
158
+ publicDir: 'public',
159
+ });
160
+ createWebServer(PORT, frontend).listen(PORT);
161
+ console.log(`dev server is live at http://127.0.0.1:${PORT}`);
162
+ startDevServices(c, signal);
163
+ }
164
+ function hasSameValues(a, b) {
165
+ if (a.size !== b.size) {
166
+ return false;
167
+ }
168
+ for (const v in a) {
169
+ if (!b.has(v)) {
170
+ return false;
171
+ }
172
+ }
173
+ return true;
174
+ }
175
+ async function processWebpage(inputs) {
176
+ const html = await HtmlEntrypoint.readFrom(inputs.urlPath, join(inputs.pagesDir, inputs.srcPath));
177
+ await html.injectPartials();
178
+ if (inputs.urlPath !== '/') {
179
+ await mkdir(join(inputs.outDir, inputs.urlPath), { recursive: true });
180
+ }
181
+ const entryPoints = [];
182
+ html.collectScripts().forEach(scriptImport => {
183
+ entryPoints.push({
184
+ in: scriptImport.in,
185
+ out: scriptImport.out,
53
186
  });
54
- html.rewriteHrefs();
55
- html.appendScript(clientJS);
56
- await html.writeTo(watchDir);
57
- return html;
58
- }));
59
- const ctx = await esbuildDevContext(createGlobalDefinitions(), entryPoints, watchDir);
187
+ });
188
+ html.rewriteHrefs();
189
+ html.appendScript(inputs.clientJS);
190
+ await html.writeTo(inputs.outDir);
191
+ return {
192
+ entryPoints,
193
+ srcPath: inputs.srcPath,
194
+ urlPath: inputs.urlPath,
195
+ };
196
+ }
197
+ async function startEsbuildWatch(entryPoints) {
198
+ const ctx = await esbuildDevContext(createGlobalDefinitions(), entryPoints, 'build/watch');
60
199
  await ctx.watch();
61
200
  await ctx.serve({
62
201
  host: '127.0.0.1',
63
202
  port: ESBUILD_PORT,
64
- servedir: watchDir,
65
203
  cors: {
66
204
  origin: 'http://127.0.0.1:' + PORT,
67
205
  },
68
206
  });
69
- return { port: ESBUILD_PORT };
207
+ return ctx;
208
+ }
209
+ async function loadClientJS() {
210
+ return await readFile(resolve(import.meta.dirname, join('..', 'client', 'esbuild.js')), 'utf-8');
211
+ }
212
+ async function watch(p, signal, fire) {
213
+ const delayFire = 90;
214
+ const timeout = 100;
215
+ let changes = {};
216
+ try {
217
+ for await (const { filename } of _watch(p, {
218
+ recursive: true,
219
+ signal,
220
+ })) {
221
+ if (filename) {
222
+ if (!changes[filename]) {
223
+ const now = Date.now();
224
+ changes[filename] = now + delayFire;
225
+ setTimeout(() => {
226
+ const now = Date.now();
227
+ for (const [filename, then] of Object.entries(changes)) {
228
+ if (then <= now) {
229
+ fire(filename);
230
+ delete changes[filename];
231
+ }
232
+ }
233
+ }, timeout);
234
+ }
235
+ }
236
+ }
237
+ }
238
+ catch (e) {
239
+ if (e.name !== 'AbortError') {
240
+ throw e;
241
+ }
242
+ }
70
243
  }
@@ -1,20 +1,120 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { basename, isAbsolute, resolve } from 'node:path';
3
- export function startDevServices(c) {
3
+ // up to date representation of dank.config.ts services
4
+ const running = [];
5
+ let signal;
6
+ // batch of services that must be stopped before starting new services
7
+ let updating = null;
8
+ export function startDevServices(c, _signal) {
9
+ signal = _signal;
4
10
  if (c.services?.length) {
5
- const ac = new AbortController();
6
- try {
7
- for (const s of c.services) {
8
- startService(s, ac.signal);
11
+ for (const s of c.services) {
12
+ running.push({ s, process: startService(s) });
13
+ }
14
+ }
15
+ }
16
+ export function updateDevServices(c) {
17
+ if (!c.services?.length) {
18
+ if (running.length) {
19
+ if (updating === null) {
20
+ updating = { stopping: [], starting: [] };
21
+ }
22
+ running.forEach(({ s, process }) => {
23
+ if (process) {
24
+ stopService(s, process);
25
+ }
26
+ else {
27
+ removeFromUpdating(s);
28
+ }
29
+ });
30
+ running.length = 0;
31
+ }
32
+ }
33
+ else {
34
+ if (updating === null) {
35
+ updating = { stopping: [], starting: [] };
36
+ }
37
+ const keep = [];
38
+ const next = [];
39
+ for (const s of c.services) {
40
+ let found = false;
41
+ for (let i = 0; i < running.length; i++) {
42
+ const p = running[i].s;
43
+ if (matchingConfig(s, p)) {
44
+ found = true;
45
+ keep.push(i);
46
+ break;
47
+ }
48
+ }
49
+ if (!found) {
50
+ next.push(s);
9
51
  }
10
52
  }
11
- catch (e) {
12
- ac.abort();
13
- throw e;
53
+ for (let i = running.length - 1; i >= 0; i--) {
54
+ if (!keep.includes(i)) {
55
+ const { s, process } = running[i];
56
+ if (process) {
57
+ stopService(s, process);
58
+ }
59
+ else {
60
+ removeFromUpdating(s);
61
+ }
62
+ running.splice(i, 1);
63
+ }
64
+ }
65
+ if (updating.stopping.length) {
66
+ for (const s of next) {
67
+ if (!updating.starting.find(queued => matchingConfig(queued, s))) {
68
+ updating.starting.push(s);
69
+ }
70
+ }
71
+ }
72
+ else {
73
+ updating = null;
74
+ for (const s of next) {
75
+ running.push({ s, process: startService(s) });
76
+ }
14
77
  }
15
78
  }
16
79
  }
17
- function startService(s, signal) {
80
+ function stopService(s, process) {
81
+ opPrint(s, 'stopping');
82
+ updating.stopping.push(s);
83
+ process.kill();
84
+ }
85
+ function matchingConfig(a, b) {
86
+ if (a.command !== b.command) {
87
+ return false;
88
+ }
89
+ if (a.cwd !== b.cwd) {
90
+ return false;
91
+ }
92
+ if (!a.env && !b.env) {
93
+ return true;
94
+ }
95
+ else if (a.env && !b.env) {
96
+ return false;
97
+ }
98
+ else if (!a.env && b.env) {
99
+ return false;
100
+ }
101
+ else if (Object.keys(a.env).length !== Object.keys(b.env).length) {
102
+ return false;
103
+ }
104
+ else {
105
+ for (const k of Object.keys(a.env)) {
106
+ if (!b.env[k]) {
107
+ return false;
108
+ }
109
+ else if (a.env[k] !== b.env[k]) {
110
+ return false;
111
+ }
112
+ }
113
+ }
114
+ return true;
115
+ }
116
+ function startService(s) {
117
+ opPrint(s, 'starting');
18
118
  const splitCmdAndArgs = s.command.split(/\s+/);
19
119
  const cmd = splitCmdAndArgs[0];
20
120
  const args = splitCmdAndArgs.length === 1 ? [] : splitCmdAndArgs.slice(1);
@@ -29,9 +129,39 @@ function startService(s, signal) {
29
129
  spawned.stdout.on('data', chunk => printChunk(stdoutLabel, chunk));
30
130
  const stderrLabel = logLabel(s.cwd, cmd, args, 31);
31
131
  spawned.stderr.on('data', chunk => printChunk(stderrLabel, chunk));
132
+ spawned.on('error', e => {
133
+ const cause = 'code' in e && e.code === 'ENOENT' ? 'program not found' : e.message;
134
+ opPrint(s, 'error: ' + cause);
135
+ removeFromRunning(s);
136
+ });
32
137
  spawned.on('exit', () => {
33
- console.log(`[${s.command}]`, 'exit');
138
+ opPrint(s, 'exited');
139
+ removeFromRunning(s);
140
+ removeFromUpdating(s);
34
141
  });
142
+ return spawned;
143
+ }
144
+ function removeFromRunning(s) {
145
+ for (let i = 0; i < running.length; i++) {
146
+ if (matchingConfig(running[i].s, s)) {
147
+ running.splice(i, 1);
148
+ return;
149
+ }
150
+ }
151
+ }
152
+ function removeFromUpdating(s) {
153
+ if (updating !== null) {
154
+ for (let i = 0; i < updating.stopping.length; i++) {
155
+ if (matchingConfig(updating.stopping[i], s)) {
156
+ updating.stopping.splice(i, 1);
157
+ if (!updating.stopping.length) {
158
+ updating.starting.forEach(startService);
159
+ updating = null;
160
+ return;
161
+ }
162
+ }
163
+ }
164
+ }
35
165
  }
36
166
  function printChunk(label, c) {
37
167
  for (const l of parseChunk(c))
@@ -51,6 +181,12 @@ function resolveCwd(p) {
51
181
  return resolve(process.cwd(), p);
52
182
  }
53
183
  }
184
+ function opPrint(s, msg) {
185
+ console.log(opLabel(s), msg);
186
+ }
187
+ function opLabel(s) {
188
+ return `\`${s.cwd ? s.cwd + ' ' : ''}${s.command}\``;
189
+ }
54
190
  function logLabel(cwd, cmd, args, ansiColor) {
55
191
  cwd = !cwd
56
192
  ? './'
@@ -1,5 +1,5 @@
1
1
  export type DankConfig = {
2
- pages: Record<`/${string}`, `./${string}.html`>;
2
+ pages: Record<`/${string}`, `${string}.html`>;
3
3
  services?: Array<DevService>;
4
4
  };
5
5
  export type DevService = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eighty4/dank",
3
- "version": "0.0.1-4",
3
+ "version": "0.0.1",
4
4
  "type": "module",
5
5
  "description": "Multi-page development system for CDN-deployed websites",
6
6
  "keywords": [