@eighty4/dank 0.0.3 → 0.0.4-0

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,143 +1,258 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
2
- import { dirname, join, relative } from 'node:path';
1
+ import EventEmitter from 'node:events';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { dirname, join, relative, resolve } 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()
23
12
  #fsPath;
24
13
  #partials = [];
25
14
  #scripts = [];
15
+ #update = Object();
26
16
  #url;
27
- constructor(url, html, fsPath) {
17
+ constructor(build, url, fsPath, decorations) {
18
+ super({ captureRejections: true });
19
+ this.#build = build;
20
+ this.#decorations = decorations;
28
21
  this.#url = url;
29
- this.#document = parse(html);
30
22
  this.#fsPath = fsPath;
23
+ this.on('change', this.#onChange);
24
+ this.emit('change');
31
25
  }
32
- async injectPartials() {
33
- this.#collectPartials(this.#document);
34
- await this.#injectPartials();
26
+ get fsPath() {
27
+ return this.#fsPath;
35
28
  }
36
- collectScripts() {
37
- this.#collectScripts(this.#document);
38
- return this.#scripts;
29
+ get url() {
30
+ return this.#url;
39
31
  }
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
- }
32
+ async #html() {
33
+ try {
34
+ return await readFile(join(this.#build.dirs.pages, this.#fsPath), 'utf8');
35
+ }
36
+ catch (e) {
37
+ // todo error handling
38
+ errorExit(this.#fsPath + ' does not exist');
39
+ }
40
+ }
41
+ // todo if partial changes, hot swap content in page
42
+ #onChange = async (_partial) => {
43
+ const update = (this.#update = Object());
44
+ const html = await this.#html();
45
+ const document = parse(html);
46
+ const imports = {
47
+ partials: [],
48
+ scripts: [],
49
+ };
50
+ this.#collectImports(document, imports);
51
+ const partials = await this.#resolvePartialContent(imports.partials);
52
+ if (update !== this.#update) {
53
+ // another update has started so aborting this one
54
+ return;
55
+ }
56
+ this.#addDecorations(document);
57
+ this.#update = update;
58
+ this.#document = document;
59
+ this.#partials = partials;
60
+ this.#scripts = imports.scripts;
61
+ const entrypoints = mergeEntrypoints(imports, ...partials.map(p => p.imports));
62
+ // this.#entrypoints = new Set(entrypoints.map(entrypoint => entrypoint.in))
63
+ this.emit('entrypoints', entrypoints);
64
+ this.emit('partials', this.#partials.map(p => p.fsPath));
65
+ if (this.listenerCount('output')) {
66
+ this.emit('output', this.output());
67
+ }
68
+ };
69
+ // Emits `partial` on detecting a partial reference for `dank serve` file watches
70
+ // to respond to dependent changes
71
+ // todo safeguard recursive partials that cause circular imports
72
+ async #resolvePartialContent(partials) {
73
+ return await Promise.all(partials.map(async (p) => {
74
+ this.emit('partial', p.fsPath);
75
+ const html = await readFile(join(this.#build.dirs.pages, p.fsPath), 'utf8');
76
+ const fragment = parseFragment(html);
77
+ const imports = {
78
+ partials: [],
79
+ scripts: [],
80
+ };
81
+ this.#collectImports(fragment, imports, node => {
82
+ this.#rewritePartialRelativePaths(node, p.fsPath);
83
+ });
84
+ if (imports.partials.length) {
85
+ // todo recursive partials?
86
+ // await this.#resolvePartialContent(imports.partials)
87
+ errorExit(`partial ${p.fsPath} cannot recursively import partials`);
88
+ }
89
+ const content = {
90
+ ...p,
91
+ fragment,
92
+ imports,
93
+ };
94
+ return content;
95
+ }));
96
+ }
97
+ // rewrite hrefs in a partial to be relative to the html entrypoint instead of the partial
98
+ #rewritePartialRelativePaths(elem, partialPath) {
99
+ let rewritePath = null;
100
+ if (elem.nodeName === 'script') {
101
+ rewritePath = 'src';
102
+ }
103
+ else if (elem.nodeName === 'link' &&
104
+ hasAttr(elem, 'rel', 'stylesheet')) {
105
+ rewritePath = 'href';
106
+ }
107
+ if (rewritePath !== null) {
108
+ const attr = getAttr(elem, rewritePath);
109
+ if (attr) {
110
+ attr.value = join(relative(dirname(this.#fsPath), dirname(partialPath)), attr.value);
50
111
  }
51
- else if (importScript.type === 'style') {
52
- importScript.elem.attrs.find(attr => attr.name === 'href').value = rewriteTo || `/${importScript.out}`;
112
+ }
113
+ }
114
+ #addDecorations(document) {
115
+ if (!this.#decorations?.length) {
116
+ return;
117
+ }
118
+ for (const decoration of this.#decorations) {
119
+ switch (decoration.type) {
120
+ case 'script':
121
+ const scriptNode = parseFragment(`<script type="module">${decoration.js}</script>`).childNodes[0];
122
+ const htmlNode = document.childNodes.find(node => node.nodeName === 'html');
123
+ const headNode = htmlNode.childNodes.find(node => node.nodeName === 'head');
124
+ defaultTreeAdapter.appendChild(headNode || htmlNode, scriptNode);
125
+ break;
53
126
  }
54
127
  }
55
128
  }
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);
129
+ output(hrefs) {
130
+ this.#injectPartials();
131
+ this.#rewriteHrefs(hrefs);
132
+ return serialize(this.#document);
61
133
  }
62
- async writeTo(buildDir) {
63
- await writeFile(join(buildDir, this.#url, 'index.html'), serialize(this.#document));
134
+ // rewrites hrefs to content hashed urls
135
+ // call without hrefs to rewrite tsx? ext to js
136
+ #rewriteHrefs(hrefs) {
137
+ rewriteHrefs(this.#scripts, hrefs);
138
+ for (const partial of this.#partials) {
139
+ rewriteHrefs(partial.imports.scripts, hrefs);
140
+ }
64
141
  }
65
142
  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'));
143
+ for (const { commentNode, fragment } of this.#partials) {
144
+ if (!this.#build.production) {
145
+ defaultTreeAdapter.insertBefore(commentNode.parentNode, defaultTreeAdapter.createCommentNode(commentNode.data), commentNode);
146
+ }
71
147
  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
148
  defaultTreeAdapter.insertBefore(commentNode.parentNode, node, commentNode);
80
149
  }
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);
150
+ if (this.#build.production) {
151
+ defaultTreeAdapter.detachNode(commentNode);
152
+ }
89
153
  }
90
154
  }
91
- #collectPartials(node) {
155
+ #collectImports(node, collection, forEach) {
92
156
  for (const childNode of node.childNodes) {
157
+ if (forEach && 'tagName' in childNode) {
158
+ forEach(childNode);
159
+ }
93
160
  if (childNode.nodeName === '#comment' && 'data' in childNode) {
94
- if (/\{\{.+\}\}/.test(childNode.data)) {
95
- this.#partials.push(childNode);
161
+ const partialMatch = childNode.data.match(/\{\{(?<pp>.+)\}\}/);
162
+ if (partialMatch) {
163
+ const partialSpecifier = partialMatch.groups.pp.trim();
164
+ if (partialSpecifier.startsWith('/')) {
165
+ errorExit(`partial ${partialSpecifier} in webpage ${this.#fsPath} cannot be an absolute path`);
166
+ }
167
+ const partialPath = join(dirname(this.#fsPath), partialSpecifier);
168
+ if (!isPagesSubpathInPagesDir(this.#build, partialPath)) {
169
+ errorExit(`partial ${partialSpecifier} in webpage ${this.#fsPath} cannot be outside of the pages directory`);
170
+ }
171
+ collection.partials.push({
172
+ fsPath: partialPath,
173
+ commentNode: childNode,
174
+ });
96
175
  }
97
176
  }
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') {
177
+ else if (childNode.nodeName === 'script') {
106
178
  const srcAttr = childNode.attrs.find(attr => attr.name === 'src');
107
179
  if (srcAttr) {
108
- this.#addScript('script', srcAttr.value, childNode);
180
+ collection.scripts.push(this.#parseImport('script', srcAttr.value, childNode));
109
181
  }
110
182
  }
111
183
  else if (childNode.nodeName === 'link' &&
112
184
  hasAttr(childNode, 'rel', 'stylesheet')) {
113
185
  const hrefAttr = getAttr(childNode, 'href');
114
186
  if (hrefAttr) {
115
- this.#addScript('style', hrefAttr.value, childNode);
187
+ collection.scripts.push(this.#parseImport('style', hrefAttr.value, childNode));
116
188
  }
117
189
  }
118
190
  else if ('childNodes' in childNode) {
119
- this.#collectScripts(childNode);
191
+ this.#collectImports(childNode, collection);
120
192
  }
121
193
  }
122
194
  }
123
- #addScript(type, href, elem) {
124
- const inPath = join(dirname(this.#fsPath), href);
125
- let outPath = inPath.replace(/^pages\//, '');
195
+ #parseImport(type, href, elem) {
196
+ const inPath = join(this.#build.dirs.pages, dirname(this.#fsPath), href);
197
+ if (!isPathInPagesDir(this.#build, inPath)) {
198
+ errorExit(`href ${href} in webpage ${this.#fsPath} cannot reference sources outside of the pages directory`);
199
+ }
200
+ let outPath = join(dirname(this.#fsPath), href);
126
201
  if (type === 'script' && !outPath.endsWith('.js')) {
127
202
  outPath = outPath.replace(new RegExp(extname(outPath).substring(1) + '$'), 'js');
128
203
  }
129
- this.#scripts.push({
204
+ return {
130
205
  type,
131
206
  href,
132
207
  elem,
133
- in: inPath,
134
- out: outPath,
135
- });
208
+ entrypoint: {
209
+ in: inPath,
210
+ out: outPath,
211
+ },
212
+ };
136
213
  }
137
214
  }
215
+ // check if relative dir is a subpath of pages dir when joined with pages dir
216
+ // used if the joined pages dir path is only used for the pages dir check
217
+ function isPagesSubpathInPagesDir(build, subpath) {
218
+ return isPathInPagesDir(build, join(build.dirs.pages, subpath));
219
+ }
220
+ // check if subpath joined with pages dir is a subpath of pages dir
221
+ function isPathInPagesDir(build, p) {
222
+ return resolve(p).startsWith(build.dirs.pagesResolved);
223
+ }
138
224
  function getAttr(elem, name) {
139
225
  return elem.attrs.find(attr => attr.name === name);
140
226
  }
141
227
  function hasAttr(elem, name, value) {
142
228
  return elem.attrs.some(attr => attr.name === name && attr.value === value);
143
229
  }
230
+ function mergeEntrypoints(...imports) {
231
+ const entrypoints = [];
232
+ for (const { scripts } of imports) {
233
+ for (const script of scripts) {
234
+ entrypoints.push(script.entrypoint);
235
+ }
236
+ }
237
+ return entrypoints;
238
+ }
239
+ function rewriteHrefs(scripts, hrefs) {
240
+ for (const { elem, entrypoint, type } of scripts) {
241
+ const rewriteTo = hrefs ? hrefs.mappedHref(entrypoint.in) : null;
242
+ if (type === 'script') {
243
+ if (entrypoint.in.endsWith('.tsx') ||
244
+ entrypoint.in.endsWith('.ts')) {
245
+ elem.attrs.find(attr => attr.name === 'src').value =
246
+ rewriteTo || `/${entrypoint.out}`;
247
+ }
248
+ }
249
+ else if (type === 'style') {
250
+ elem.attrs.find(attr => attr.name === 'href').value =
251
+ rewriteTo || `/${entrypoint.out}`;
252
+ }
253
+ }
254
+ }
255
+ function errorExit(msg) {
256
+ console.log(`\u001b[31merror:\u001b[0m`, msg);
257
+ process.exit(1);
258
+ }
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
+ }