@eighty4/dank 0.0.3 → 0.0.4-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/html.js CHANGED
@@ -1,138 +1,218 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
1
+ import EventEmitter from 'node:events';
2
+ import { readFile } from 'node:fs/promises';
2
3
  import { dirname, join, relative } from 'node:path';
3
4
  import { extname } from 'node:path/posix';
4
5
  import { defaultTreeAdapter, parse, parseFragment, serialize, } from 'parse5';
5
- // unenforced but necessary sequence:
6
- // injectPartials
7
- // collectScripts
8
- // rewriteHrefs
9
- // writeTo
10
- export class HtmlEntrypoint {
11
- static async readFrom(urlPath, fsPath) {
12
- let html;
13
- try {
14
- html = await readFile(fsPath, 'utf-8');
15
- }
16
- catch (e) {
17
- console.log(`\u001b[31merror:\u001b[0m`, fsPath, 'does not exist');
18
- process.exit(1);
19
- }
20
- return new HtmlEntrypoint(urlPath, html, fsPath);
21
- }
22
- #document;
6
+ export class HtmlEntrypoint extends EventEmitter {
7
+ #build;
8
+ #decorations;
9
+ #document = defaultTreeAdapter.createDocument();
10
+ // todo cache entrypoints set for quicker diffing
11
+ // #entrypoints: Set<string> = new Set()
12
+ // path within pages dir omitting pages/ segment
23
13
  #fsPath;
24
14
  #partials = [];
15
+ #resolver;
25
16
  #scripts = [];
17
+ #update = Object();
26
18
  #url;
27
- constructor(url, html, fsPath) {
19
+ constructor(build, resolver, url, fsPath, decorations) {
20
+ super({ captureRejections: true });
21
+ this.#build = build;
22
+ this.#resolver = resolver;
23
+ this.#decorations = decorations;
28
24
  this.#url = url;
29
- this.#document = parse(html);
30
25
  this.#fsPath = fsPath;
26
+ this.on('change', this.#onChange);
27
+ this.emit('change');
31
28
  }
32
- async injectPartials() {
33
- this.#collectPartials(this.#document);
34
- await this.#injectPartials();
29
+ get fsPath() {
30
+ return this.#fsPath;
35
31
  }
36
- collectScripts() {
37
- this.#collectScripts(this.#document);
38
- return this.#scripts;
32
+ get url() {
33
+ return this.#url;
39
34
  }
40
- // rewrites hrefs to content hashed urls
41
- // call without hrefs to rewrite tsx? ext to js
42
- rewriteHrefs(hrefs) {
43
- for (const importScript of this.#scripts) {
44
- const rewriteTo = hrefs ? hrefs[importScript.in] : null;
45
- if (importScript.type === 'script') {
46
- if (importScript.in.endsWith('.tsx') ||
47
- importScript.in.endsWith('.ts')) {
48
- importScript.elem.attrs.find(attr => attr.name === 'src').value = rewriteTo || `/${importScript.out}`;
49
- }
35
+ async #html() {
36
+ try {
37
+ return await readFile(join(this.#build.dirs.pages, this.#fsPath), 'utf8');
38
+ }
39
+ catch (e) {
40
+ // todo error handling
41
+ errorExit(this.#fsPath + ' does not exist');
42
+ }
43
+ }
44
+ // todo if partial changes, hot swap content in page
45
+ #onChange = async (_partial) => {
46
+ const update = (this.#update = Object());
47
+ const html = await this.#html();
48
+ const document = parse(html);
49
+ const imports = {
50
+ partials: [],
51
+ scripts: [],
52
+ };
53
+ this.#collectImports(document, imports);
54
+ const partials = await this.#resolvePartialContent(imports.partials);
55
+ if (update !== this.#update) {
56
+ // another update has started so aborting this one
57
+ return;
58
+ }
59
+ this.#addDecorations(document);
60
+ this.#update = update;
61
+ this.#document = document;
62
+ this.#partials = partials;
63
+ this.#scripts = imports.scripts;
64
+ const entrypoints = mergeEntrypoints(imports, ...partials.map(p => p.imports));
65
+ // this.#entrypoints = new Set(entrypoints.map(entrypoint => entrypoint.in))
66
+ this.emit('entrypoints', entrypoints);
67
+ this.emit('partials', this.#partials.map(p => p.fsPath));
68
+ if (this.listenerCount('output')) {
69
+ this.emit('output', this.output());
70
+ }
71
+ };
72
+ // Emits `partial` on detecting a partial reference for `dank serve` file watches
73
+ // to respond to dependent changes
74
+ // todo safeguard recursive partials that cause circular imports
75
+ async #resolvePartialContent(partials) {
76
+ return await Promise.all(partials.map(async (p) => {
77
+ this.emit('partial', p.fsPath);
78
+ const html = await readFile(join(this.#build.dirs.pages, p.fsPath), 'utf8');
79
+ const fragment = parseFragment(html);
80
+ const imports = {
81
+ partials: [],
82
+ scripts: [],
83
+ };
84
+ this.#collectImports(fragment, imports, node => {
85
+ this.#rewritePartialRelativePaths(node, p.fsPath);
86
+ });
87
+ if (imports.partials.length) {
88
+ // todo recursive partials?
89
+ // await this.#resolvePartialContent(imports.partials)
90
+ errorExit(`partial ${p.fsPath} cannot recursively import partials`);
50
91
  }
51
- else if (importScript.type === 'style') {
52
- importScript.elem.attrs.find(attr => attr.name === 'href').value = rewriteTo || `/${importScript.out}`;
92
+ const content = {
93
+ ...p,
94
+ fragment,
95
+ imports,
96
+ };
97
+ return content;
98
+ }));
99
+ }
100
+ // rewrite hrefs in a partial to be relative to the html entrypoint instead of the partial
101
+ #rewritePartialRelativePaths(elem, partialPath) {
102
+ let rewritePath = null;
103
+ if (elem.nodeName === 'script') {
104
+ rewritePath = 'src';
105
+ }
106
+ else if (elem.nodeName === 'link' &&
107
+ hasAttr(elem, 'rel', 'stylesheet')) {
108
+ rewritePath = 'href';
109
+ }
110
+ if (rewritePath !== null) {
111
+ const attr = getAttr(elem, rewritePath);
112
+ if (attr) {
113
+ attr.value = join(relative(dirname(this.#fsPath), dirname(partialPath)), attr.value);
53
114
  }
54
115
  }
55
116
  }
56
- appendScript(clientJS) {
57
- const scriptNode = parseFragment(`<script type="module">${clientJS}</script>`).childNodes[0];
58
- const htmlNode = this.#document.childNodes.find(node => node.nodeName === 'html');
59
- const headNode = htmlNode.childNodes.find(node => node.nodeName === 'head');
60
- defaultTreeAdapter.appendChild(headNode || htmlNode, scriptNode);
117
+ #addDecorations(document) {
118
+ if (!this.#decorations?.length) {
119
+ return;
120
+ }
121
+ for (const decoration of this.#decorations) {
122
+ switch (decoration.type) {
123
+ case 'script':
124
+ const scriptNode = parseFragment(`<script type="module">${decoration.js}</script>`).childNodes[0];
125
+ const htmlNode = document.childNodes.find(node => node.nodeName === 'html');
126
+ const headNode = htmlNode.childNodes.find(node => node.nodeName === 'head');
127
+ defaultTreeAdapter.appendChild(headNode || htmlNode, scriptNode);
128
+ break;
129
+ }
130
+ }
61
131
  }
62
- async writeTo(buildDir) {
63
- await writeFile(join(buildDir, this.#url, 'index.html'), serialize(this.#document));
132
+ output(hrefs) {
133
+ this.#injectPartials();
134
+ this.#rewriteHrefs(hrefs);
135
+ return serialize(this.#document);
136
+ }
137
+ // rewrites hrefs to content hashed urls
138
+ // call without hrefs to rewrite tsx? ext to js
139
+ #rewriteHrefs(hrefs) {
140
+ rewriteHrefs(this.#scripts, hrefs);
141
+ for (const partial of this.#partials) {
142
+ rewriteHrefs(partial.imports.scripts, hrefs);
143
+ }
64
144
  }
65
145
  async #injectPartials() {
66
- for (const commentNode of this.#partials) {
67
- const pp = commentNode.data
68
- .match(/\{\{(?<pp>.+)\}\}/)
69
- .groups.pp.trim();
70
- const fragment = parseFragment(await readFile(pp, 'utf-8'));
146
+ for (const { commentNode, fragment } of this.#partials) {
147
+ if (!this.#build.production) {
148
+ defaultTreeAdapter.insertBefore(commentNode.parentNode, defaultTreeAdapter.createCommentNode(commentNode.data), commentNode);
149
+ }
71
150
  for (const node of fragment.childNodes) {
72
- if (node.nodeName === 'script') {
73
- this.#rewritePathFromPartial(pp, node, 'src');
74
- }
75
- else if (node.nodeName === 'link' &&
76
- hasAttr(node, 'rel', 'stylesheet')) {
77
- this.#rewritePathFromPartial(pp, node, 'href');
78
- }
79
151
  defaultTreeAdapter.insertBefore(commentNode.parentNode, node, commentNode);
80
152
  }
81
- defaultTreeAdapter.detachNode(commentNode);
82
- }
83
- }
84
- // rewrite a ts or css href relative to an html partial to be relative to the html entrypoint
85
- #rewritePathFromPartial(pp, elem, attrName) {
86
- const attr = getAttr(elem, attrName);
87
- if (attr) {
88
- attr.value = join(relative(dirname(this.#fsPath), dirname(pp)), attr.value);
153
+ if (this.#build.production) {
154
+ defaultTreeAdapter.detachNode(commentNode);
155
+ }
89
156
  }
90
157
  }
91
- #collectPartials(node) {
158
+ #collectImports(node, collection, forEach) {
92
159
  for (const childNode of node.childNodes) {
160
+ if (forEach && 'tagName' in childNode) {
161
+ forEach(childNode);
162
+ }
93
163
  if (childNode.nodeName === '#comment' && 'data' in childNode) {
94
- if (/\{\{.+\}\}/.test(childNode.data)) {
95
- this.#partials.push(childNode);
164
+ const partialMatch = childNode.data.match(/\{\{(?<pp>.+)\}\}/);
165
+ if (partialMatch) {
166
+ const partialSpecifier = partialMatch.groups.pp.trim();
167
+ if (partialSpecifier.startsWith('/')) {
168
+ errorExit(`partial ${partialSpecifier} in webpage ${this.#fsPath} cannot be an absolute path`);
169
+ }
170
+ const partialPath = join(dirname(this.#fsPath), partialSpecifier);
171
+ if (!this.#resolver.isPagesSubpathInPagesDir(partialPath)) {
172
+ errorExit(`partial ${partialSpecifier} in webpage ${this.#fsPath} cannot be outside of the pages directory`);
173
+ }
174
+ collection.partials.push({
175
+ fsPath: partialPath,
176
+ commentNode: childNode,
177
+ });
96
178
  }
97
179
  }
98
- else if ('childNodes' in childNode) {
99
- this.#collectPartials(childNode);
100
- }
101
- }
102
- }
103
- #collectScripts(node) {
104
- for (const childNode of node.childNodes) {
105
- if (childNode.nodeName === 'script') {
180
+ else if (childNode.nodeName === 'script') {
106
181
  const srcAttr = childNode.attrs.find(attr => attr.name === 'src');
107
182
  if (srcAttr) {
108
- this.#addScript('script', srcAttr.value, childNode);
183
+ collection.scripts.push(this.#parseImport('script', srcAttr.value, childNode));
109
184
  }
110
185
  }
111
186
  else if (childNode.nodeName === 'link' &&
112
187
  hasAttr(childNode, 'rel', 'stylesheet')) {
113
188
  const hrefAttr = getAttr(childNode, 'href');
114
189
  if (hrefAttr) {
115
- this.#addScript('style', hrefAttr.value, childNode);
190
+ collection.scripts.push(this.#parseImport('style', hrefAttr.value, childNode));
116
191
  }
117
192
  }
118
193
  else if ('childNodes' in childNode) {
119
- this.#collectScripts(childNode);
194
+ this.#collectImports(childNode, collection);
120
195
  }
121
196
  }
122
197
  }
123
- #addScript(type, href, elem) {
124
- const inPath = join(dirname(this.#fsPath), href);
125
- let outPath = inPath.replace(/^pages\//, '');
198
+ #parseImport(type, href, elem) {
199
+ const inPath = join(this.#build.dirs.pages, dirname(this.#fsPath), href);
200
+ if (!this.#resolver.isProjectSubpathInPagesDir(inPath)) {
201
+ errorExit(`href ${href} in webpage ${this.#fsPath} cannot reference sources outside of the pages directory`);
202
+ }
203
+ let outPath = join(dirname(this.#fsPath), href);
126
204
  if (type === 'script' && !outPath.endsWith('.js')) {
127
205
  outPath = outPath.replace(new RegExp(extname(outPath).substring(1) + '$'), 'js');
128
206
  }
129
- this.#scripts.push({
207
+ return {
130
208
  type,
131
209
  href,
132
210
  elem,
133
- in: inPath,
134
- out: outPath,
135
- });
211
+ entrypoint: {
212
+ in: inPath,
213
+ out: outPath,
214
+ },
215
+ };
136
216
  }
137
217
  }
138
218
  function getAttr(elem, name) {
@@ -141,3 +221,33 @@ function getAttr(elem, name) {
141
221
  function hasAttr(elem, name, value) {
142
222
  return elem.attrs.some(attr => attr.name === name && attr.value === value);
143
223
  }
224
+ function mergeEntrypoints(...imports) {
225
+ const entrypoints = [];
226
+ for (const { scripts } of imports) {
227
+ for (const script of scripts) {
228
+ entrypoints.push(script.entrypoint);
229
+ }
230
+ }
231
+ return entrypoints;
232
+ }
233
+ function rewriteHrefs(scripts, hrefs) {
234
+ for (const { elem, entrypoint, type } of scripts) {
235
+ const rewriteTo = hrefs ? hrefs.mappedHref(entrypoint.in) : null;
236
+ if (type === 'script') {
237
+ if (entrypoint.in.endsWith('.tsx') ||
238
+ entrypoint.in.endsWith('.ts')) {
239
+ elem.attrs.find(attr => attr.name === 'src').value =
240
+ rewriteTo || `/${entrypoint.out}`;
241
+ }
242
+ }
243
+ else if (type === 'style') {
244
+ elem.attrs.find(attr => attr.name === 'href').value =
245
+ rewriteTo || `/${entrypoint.out}`;
246
+ }
247
+ }
248
+ }
249
+ // todo evented error handling so HtmlEntrypoint can be unit tested
250
+ function errorExit(msg) {
251
+ console.log(`\u001b[31merror:\u001b[0m`, msg);
252
+ process.exit(1);
253
+ }
package/lib_js/http.js CHANGED
@@ -2,26 +2,93 @@ import { createReadStream } from 'node:fs';
2
2
  import { stat } from 'node:fs/promises';
3
3
  import { createServer, } from 'node:http';
4
4
  import { extname, join } from 'node:path';
5
+ import { Readable } from 'node:stream';
5
6
  import mime from 'mime';
6
- import { isLogHttp } from "./flags.js";
7
- export function createWebServer(port, frontendFetcher) {
8
- const serverAddress = 'http://localhost:' + port;
7
+ export function startWebServer(serve, frontendFetcher, httpServices, pageRoutes) {
8
+ const serverAddress = 'http://localhost:' + serve.dankPort;
9
9
  const handler = (req, res) => {
10
10
  if (!req.url || !req.method) {
11
11
  res.end();
12
12
  }
13
13
  else {
14
14
  const url = new URL(serverAddress + req.url);
15
- if (req.method !== 'GET') {
16
- res.writeHead(405);
17
- res.end();
15
+ const headers = convertHeadersToFetch(req.headers);
16
+ frontendFetcher(url, headers, res, () => onNotFound(req, url, headers, httpServices, pageRoutes, serve, res));
17
+ }
18
+ };
19
+ createServer(serve.logHttp ? createLogWrapper(handler) : handler).listen(serve.dankPort);
20
+ console.log(serve.preview ? 'preview' : 'dev', `server is live at http://127.0.0.1:${serve.dankPort}`);
21
+ }
22
+ async function onNotFound(req, url, headers, httpServices, pageRoutes, serve, res) {
23
+ if (req.method === 'GET' && extname(url.pathname) === '') {
24
+ const urlRewrite = tryUrlRewrites(url, pageRoutes, serve);
25
+ if (urlRewrite) {
26
+ streamFile(urlRewrite, res);
27
+ return;
28
+ }
29
+ }
30
+ const fetchResponse = await tryHttpServices(req, url, headers, httpServices);
31
+ if (fetchResponse) {
32
+ sendFetchResponse(res, fetchResponse);
33
+ }
34
+ else {
35
+ res.writeHead(404);
36
+ res.end();
37
+ }
38
+ }
39
+ async function sendFetchResponse(res, fetchResponse) {
40
+ res.writeHead(fetchResponse.status, undefined, convertHeadersFromFetch(fetchResponse.headers));
41
+ if (fetchResponse.body) {
42
+ Readable.fromWeb(fetchResponse.body).pipe(res);
43
+ }
44
+ else {
45
+ res.end();
46
+ }
47
+ }
48
+ function tryUrlRewrites(url, pageRoutes, serve) {
49
+ const urlRewrite = pageRoutes.urlRewrites.find(urlRewrite => urlRewrite.pattern.test(url.pathname));
50
+ return urlRewrite
51
+ ? join(serve.dirs.buildWatch, urlRewrite.url, 'index.html')
52
+ : null;
53
+ }
54
+ async function tryHttpServices(req, url, headers, httpServices) {
55
+ if (url.pathname.startsWith('/.well-known/')) {
56
+ return null;
57
+ }
58
+ const body = await collectReqBody(req);
59
+ const { running } = httpServices;
60
+ for (const httpService of running) {
61
+ const proxyUrl = new URL(url);
62
+ proxyUrl.port = `${httpService.port}`;
63
+ try {
64
+ const response = await retryFetchWithTimeout(proxyUrl, {
65
+ body,
66
+ headers,
67
+ method: req.method,
68
+ redirect: 'manual',
69
+ });
70
+ if (response.status === 404 || response.status === 405) {
71
+ continue;
18
72
  }
19
73
  else {
20
- frontendFetcher(url, convertHeadersToFetch(req.headers), res);
74
+ return response;
21
75
  }
22
76
  }
23
- };
24
- return createServer(isLogHttp() ? createLogWrapper(handler) : handler);
77
+ catch (e) {
78
+ if (e === 'retrytimeout') {
79
+ continue;
80
+ }
81
+ else {
82
+ errorExit(`unexpected error http proxying to port ${httpService.port}: ${e.message}`);
83
+ }
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ function collectReqBody(req) {
89
+ let body = '';
90
+ req.on('data', data => (body += data.toString()));
91
+ return new Promise(res => req.on('end', () => res(body.length ? body : null)));
25
92
  }
26
93
  function createLogWrapper(handler) {
27
94
  return (req, res) => {
@@ -32,40 +99,47 @@ function createLogWrapper(handler) {
32
99
  handler(req, res);
33
100
  };
34
101
  }
35
- export function createBuiltDistFilesFetcher(dir, files) {
36
- return (url, _headers, res) => {
37
- if (!files.has(url.pathname)) {
38
- res.writeHead(404);
39
- res.end();
102
+ export function createBuiltDistFilesFetcher(dir, manifest) {
103
+ return (url, _headers, res, notFound) => {
104
+ if (manifest.pageUrls.has(url.pathname)) {
105
+ streamFile(join(dir, url.pathname, 'index.html'), res);
106
+ }
107
+ else if (manifest.files.has(url.pathname)) {
108
+ streamFile(join(dir, url.pathname), res);
40
109
  }
41
110
  else {
42
- const p = extname(url.pathname) === ''
43
- ? join(dir, url.pathname, 'index.html')
44
- : join(dir, url.pathname);
45
- streamFile(p, res);
111
+ notFound();
46
112
  }
47
113
  };
48
114
  }
49
- export function createDevServeFilesFetcher(opts) {
50
- const proxyAddress = 'http://127.0.0.1:' + opts.proxyPort;
51
- return (url, _headers, res) => {
52
- if (opts.pages[url.pathname]) {
53
- streamFile(join(opts.pagesDir, url.pathname, 'index.html'), res);
115
+ // todo replace PageRouteState with WebsiteRegistry
116
+ export function createDevServeFilesFetcher(pageRoutes, serve) {
117
+ const proxyAddress = 'http://127.0.0.1:' + serve.esbuildPort;
118
+ return (url, _headers, res, notFound) => {
119
+ if (pageRoutes.urls.includes(url.pathname)) {
120
+ streamFile(join(serve.dirs.buildWatch, url.pathname, 'index.html'), res);
54
121
  }
55
122
  else {
56
- const maybePublicPath = join(opts.publicDir, url.pathname);
57
- exists(join(opts.publicDir, url.pathname)).then(fromPublic => {
123
+ const maybePublicPath = join(serve.dirs.public, url.pathname);
124
+ exists(maybePublicPath).then(fromPublic => {
58
125
  if (fromPublic) {
59
126
  streamFile(maybePublicPath, res);
60
127
  }
61
128
  else {
62
129
  retryFetchWithTimeout(proxyAddress + url.pathname)
63
130
  .then(fetchResponse => {
64
- res.writeHead(fetchResponse.status, convertHeadersFromFetch(fetchResponse.headers));
65
- fetchResponse.bytes().then(data => res.end(data));
131
+ if (fetchResponse.status === 404) {
132
+ notFound();
133
+ }
134
+ else {
135
+ res.writeHead(fetchResponse.status, convertHeadersFromFetch(fetchResponse.headers));
136
+ fetchResponse
137
+ .bytes()
138
+ .then(data => res.end(data));
139
+ }
66
140
  })
67
141
  .catch(e => {
68
- if (e === 'retrytimeout') {
142
+ if (isFetchRetryTimeout(e)) {
69
143
  res.writeHead(504);
70
144
  }
71
145
  else {
@@ -81,11 +155,11 @@ export function createDevServeFilesFetcher(opts) {
81
155
  }
82
156
  const PROXY_FETCH_RETRY_INTERVAL = 27;
83
157
  const PROXY_FETCH_RETRY_TIMEOUT = 1000;
84
- async function retryFetchWithTimeout(url) {
158
+ async function retryFetchWithTimeout(url, requestInit) {
85
159
  let timeout = Date.now() + PROXY_FETCH_RETRY_TIMEOUT;
86
160
  while (true) {
87
161
  try {
88
- return await fetch(url);
162
+ return await fetch(url, requestInit);
89
163
  }
90
164
  catch (e) {
91
165
  if (isNodeFailedFetch(e) || isBunFailedFetch(e)) {
@@ -102,6 +176,9 @@ async function retryFetchWithTimeout(url) {
102
176
  }
103
177
  }
104
178
  }
179
+ function isFetchRetryTimeout(e) {
180
+ return e === 'retrytimeout';
181
+ }
105
182
  function isBunFailedFetch(e) {
106
183
  return e.code === 'ConnectionRefused';
107
184
  }
@@ -147,3 +224,7 @@ function convertHeadersToFetch(from) {
147
224
  }
148
225
  return to;
149
226
  }
227
+ function errorExit(msg) {
228
+ console.log(`\u001b[31merror:\u001b[0m`, msg);
229
+ process.exit(1);
230
+ }