@webhandle/external-resource-manager 1.0.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/README.md ADDED
@@ -0,0 +1,203 @@
1
+ # @webhandle/external-resource-manager
2
+
3
+ Coordinates which external resources like scripts and css get put on
4
+ a page.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install @webhandle/external-resource-manager
10
+ ```
11
+
12
+ ## Purpose
13
+
14
+ Integrating components written by different people at different times is messy.
15
+
16
+ One approach is the one Wordpress uses: every component includes its own css files
17
+ and scripts and there's a dependency system to make sure dependencies get loaded and
18
+ get loaded before the dependents. This is kinda complicated and results in every page
19
+ having a million little files that the browser has to fetch.
20
+
21
+ Another approach is to use Webpack or Browserify to compile all the small files into
22
+ one larger file, and just have the page load that. This also works, but now the complication
23
+ is that you have to figure out all of the files to be compiled and if a compoment adds a
24
+ file, now the page is broken.
25
+
26
+ External Resource Manager represents a third approach, allowing the component flexibility
27
+ to include what it wants and the ability to combine files for performance.
28
+
29
+ ## Usage
30
+
31
+ The two main setup functions are:
32
+
33
+ ```js
34
+ externalResourceManager.includeResource({
35
+ mimeType: type
36
+ , url: url
37
+ })
38
+ ```
39
+
40
+ and
41
+
42
+ ```js
43
+ externalResourceManager.provideResource({
44
+ mimeType: type
45
+ , url: url
46
+ , name: name
47
+ , resourceType: subType
48
+ })
49
+ ```
50
+
51
+ `includeResource` is a directive to include a resource in the page. It will result in a
52
+ `link` element or `script` element or whatever is appropriate for its type.
53
+
54
+ `provideResource` lets components state that a resource is available for use, if somebody
55
+ wants to use it. It does not necessarily result in anything getting loaded by the browser.
56
+
57
+ Components all given a chance to call these function with middleware that they add to the
58
+ express routers.
59
+
60
+ Later, when html for the page is being created, `externalResourceManager.render()` can be
61
+ called, which creates html to include the resource. It's safe to call `render` multiple
62
+ times because subsequent calls will only create html for resources added since the last call
63
+ to `render`. This means templates themselves can include resesources on the page, so long
64
+ as those resources can tolerate being included in the body.
65
+
66
+ To specify the additional attributes that are sometimes used with elements, you can call like:
67
+
68
+ ```js
69
+ externalResourceManager.includeResource({
70
+ mimeType: 'application/javascript'
71
+ , url: '/js/pages.mjs'
72
+ , attributes: {
73
+ defer: undefined
74
+ }
75
+ , cachable: false
76
+ })
77
+ ```
78
+
79
+ Any attribute with a `null` or `undefined` value will/should be rendered to the page as an
80
+ attribute without a value like:
81
+
82
+ ```html
83
+ <script src="/js/pages.mjs" defer ></script>
84
+ ```
85
+
86
+ As you can see in the example above, resources may also have a `cachable` attribute, which is
87
+ true by default. Whether or not this resource ever actually has cache headers or version
88
+ url parameters applied to it depends on the renderers being used. The attribute just signifies
89
+ this resource is eligible to be used with whatever caching mechanism the site is using and isn't
90
+ some sort of url which resolves to dynamic code and only looks like a file URL.
91
+
92
+ ## How does this help with dependencies?
93
+
94
+ `ExternalResourceManager` guarantees that it will not include a url twice, so any component
95
+ is free to explicitly add a resource or call a function from the dependency which adds a
96
+ resource without fear that it will be included multiple times. Resources are rendered in the
97
+ order added, so as long as a component includes its dependencies first, everything should work
98
+ out. This avoids having named resources and a dependency tree at the small cost of having code
99
+ which should understand its dependencies call a function on them.
100
+
101
+ Further, `ExternalResourceManager` makes use of javascript modules and importmap scripts. This
102
+ allows a component to provide functionality to the page which will get loaded ONLY if it's needed.
103
+ To provide a module to a page, we call like:
104
+
105
+ ```js
106
+ externalResourceManager.provideResource({
107
+ url: '/js/one.js'
108
+ , mimeType: 'application/javascript'
109
+ , resourceType: 'module'
110
+ , name: '@webhandle/moduleone'
111
+ })
112
+ ```
113
+
114
+ This will produce an entry in an importmap so that any javascript which makes a call like:
115
+
116
+ ```js
117
+ import myImportantFunction from "@webhandle/moduleone"
118
+ ```
119
+ will cause the browser to load the module from the server. In this way you can add substantial
120
+ libraries of modules to the page with a performance hit since they're only loaded at need.
121
+ Additionally, because the importmap is parsed by the browser before any of the modules are actually
122
+ loaded, it doesn't matter what order the modules are added via `provideResource`.
123
+
124
+ A note: at the time of this writing, Firefox does not support multiple importmaps, so if possible,
125
+ components which provide resources should try to do so before rendering starts. Since providing a
126
+ resource does not result in any extra requests to the server, this should be safe to do in middle
127
+ for any request which might even potentially make use of the component.
128
+
129
+ ## How does this get rid of the million requests problem?
130
+
131
+ Inlcuded resources are able to say that they "satisfy" other included URLs. For instance, let's say
132
+ individual components have included css files that they need.
133
+
134
+ ```js
135
+ externalResourceManager.includeResource({
136
+ mimeType: 'text/css'
137
+ , url: '/small/package/level.css'
138
+ })
139
+
140
+ externalResourceManager.includeResource({
141
+ mimeType: 'text/css'
142
+ , url: '/small/package/level-too.css'
143
+ })
144
+ ```
145
+ We can at any time, either before those components include their resources or after, have code
146
+ like the following:
147
+
148
+ ```js
149
+ externalResourceManager.includeResource({
150
+ mimeType: 'text/css'
151
+ , url: '/css/compiled.css'
152
+ , satisfies: [
153
+ '/small/package/level.css'
154
+ , '/small/package/level-too.css'
155
+ ]
156
+ })
157
+ ```
158
+
159
+ This will cause `/css/compiled.css` to get included on the page while `/small/package/level.css`
160
+ and `/small/package/level-too.css` will not.
161
+
162
+ This is "satisfies" instead of "contains" because `/css/compiled.css` may not actually have the
163
+ code from `level.css` in it at all. However, it claims to be functionally equivalent. This lets
164
+ us replace styles we don't like with styles we do like, as well as just bundling thing up for
165
+ performance reasons.
166
+
167
+ This works so long as all these call are made before `render` is called.
168
+
169
+ In this way, we can get the best of both worlds for very little cost. We can add a new component
170
+ to the site and just have it work. After adding too many new components, we can set up builds to
171
+ combine those small files and just have those loaded (and not even have to worry about trying to
172
+ tell the component not to load its resources). And when we add a new component, no problem,
173
+ this small dependencies will get added to the page until we include them in the build.
174
+
175
+
176
+ ## Usage in Webhandle
177
+
178
+ In Webhandle, every response has its own `ExternalResourceManager` at `res.locals.externalResourceManager`
179
+
180
+ That's kind of a weird place to put it, I know, but this allows any template to include a resource
181
+ and allows any template to call `render`.
182
+
183
+ ### Resource Type Support
184
+
185
+ Currently there's code to include mime types `application/javascript` and `text/css`. There's also
186
+ code to create an importmap based on mime type `application/javascript` with resource type `module`.
187
+ `ExternalResourceManager` works internally by having code registered to handle different mime types,
188
+ so extensability is really easy if you wanted to add `meta` element or `title` element handling
189
+ (or something).
190
+
191
+
192
+ ### Integration
193
+
194
+ As you'd expect, this can be integrated into webhandle by calling:
195
+
196
+ ```js
197
+ import setup from "@webhandle/external-resource-manager/initialize-webhandle-component.mjs"
198
+ await setup(myWebhandleInstance, options)
199
+ ```
200
+
201
+
202
+
203
+
@@ -0,0 +1,23 @@
1
+ import createAttributes from "./create-attributes.mjs"
2
+ import escapeAttributeValue from "./escape-attribute-value.mjs"
3
+
4
+ export default function createApplicationJavascriptRenderer(webhandle) {
5
+ function renderApplicationJS(resource) {
6
+
7
+ let vrsc = ''
8
+ if (!webhandle.development && resource.cachable) {
9
+ vrsc = '/vrsc/' + webhandle.resourceVersion
10
+ }
11
+
12
+ let html = `<script src="${vrsc}${escapeAttributeValue(resource.url)}" `
13
+
14
+ html += createAttributes(resource.attributes)
15
+ if ('type' in resource.attributes === false && resource.resourceType === 'module') {
16
+ html += ' type="module"'
17
+ }
18
+ html += '></script>'
19
+ return html
20
+ }
21
+
22
+ return renderApplicationJS
23
+ }
@@ -0,0 +1,17 @@
1
+ import escapeAttributeValue from "./escape-attribute-value.mjs"
2
+
3
+ /**
4
+ * Creates an html attributes string based on the members of an object
5
+ * @param {object} attributes
6
+ */
7
+ export default function createAttributes(attributes) {
8
+ let html = Object.entries(attributes).map(entries => {
9
+ if (entries[1] === null || entries[1] === undefined) {
10
+ return entries[0]
11
+ }
12
+ else {
13
+ return entries[0] + '="' + escapeAttributeValue(entries[1]) + '"'
14
+ }
15
+ }).join(' ')
16
+ return html
17
+ }
@@ -0,0 +1,19 @@
1
+ import createAttributes from "./create-attributes.mjs"
2
+ import escapeAttributeValue from "./escape-attribute-value.mjs"
3
+ export default function createTextCssRenderer(webhandle) {
4
+ function renderTextCss(resource) {
5
+
6
+ let vrsc = ''
7
+ if(!webhandle.development && resource.cachable) {
8
+ vrsc = '/vrsc/' + webhandle.resourceVersion
9
+ }
10
+
11
+ let html = `<link href="${vrsc}${escapeAttributeValue(resource.url)}" rel="stylesheet" `
12
+
13
+ html += createAttributes(resource.attributes)
14
+ html += '/>'
15
+ return html
16
+ }
17
+
18
+ return renderTextCss
19
+ }
@@ -0,0 +1,23 @@
1
+
2
+ /**
3
+ *
4
+ * @param {string} s the value to be escaped
5
+ * @param {boolen} preserveCR If true '&#13;' will be used instead of \n
6
+ * @returns
7
+ */
8
+ export default function escapeAttributeValue(s, preserveCR) {
9
+ preserveCR = preserveCR ? '&#13;' : '\n';
10
+ return ('' + s) /* Forces the conversion to string. */
11
+ .replace(/&/g, '&amp;') /* This MUST be the 1st replacement. */
12
+ .replace(/'/g, '&apos;') /* The 4 other predefined entities, required. */
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/</g, '&lt;')
15
+ .replace(/>/g, '&gt;')
16
+ /*
17
+ You may add other replacements here for HTML only
18
+ (but it's not necessary).
19
+ Or for XML, only if the named entities are defined in its DTD.
20
+ */
21
+ .replace(/\r\n/g, preserveCR) /* Must be before the next replacement. */
22
+ .replace(/[\r\n]/g, preserveCR)
23
+ }
@@ -0,0 +1,138 @@
1
+ import Resource from "./resource.mjs"
2
+
3
+ export default class ExternalResourceManager {
4
+
5
+ /**
6
+ * Resources for which we have a directive to include on the page
7
+ */
8
+ includedResources = new Set()
9
+
10
+ /**
11
+ * Resources which are available to provide content
12
+ */
13
+ providedResources = []
14
+
15
+ /**
16
+ * Code which can generate html based on all of the entries of the mananger.
17
+ * Good place form importmap generation or preload generation.
18
+ */
19
+ preTypeRenderers = []
20
+
21
+ /**
22
+ * Renderers by mime type
23
+ */
24
+ renderers = {}
25
+
26
+ /**
27
+ * The set of urls which are known to have been satisfied by some other resource
28
+ */
29
+ alreadySatisfied = new Set()
30
+
31
+ /**
32
+ * Resources that we were directed to include, but are not going to, because
33
+ * some other resource satisfies the url.
34
+ */
35
+ differentlySatisfiedResource = []
36
+
37
+ /**
38
+ * To make sure we don't render a resource to the page twice, we'll keep track
39
+ * of the URLs we've already rendered here.
40
+ */
41
+ alreadyRenderedUrls = new Set()
42
+
43
+ constructor(options) {
44
+ Object.assign(this, options)
45
+ }
46
+
47
+
48
+ _noteSatisfies(resource) {
49
+ if(resource.satisfies && Array.isArray(resource.satisfies)) {
50
+ for(let url of resource.satisfies) {
51
+ this.alreadySatisfied.add(url)
52
+ }
53
+ }
54
+ }
55
+
56
+ includeResource(resource) {
57
+ if(!resource) {
58
+ return
59
+ }
60
+ if(typeof resource === 'object' && resource instanceof Resource === false) {
61
+ resource = new Resource(resource)
62
+ }
63
+ this._noteSatisfies(resource)
64
+ if(this.alreadySatisfied.has(resource.url)) {
65
+ // We've already got a resource which satisfies this url
66
+ this.differentlySatisfiedResource.push(resource)
67
+ return
68
+ }
69
+ if(resource.satisfies && Array.isArray(resource.satisfies)) {
70
+ // We're going to remove any previously included resource which is satisfied
71
+ // by this resource
72
+ for(let included of this.includedResources) {
73
+ if(resource.satisfies.includes(included.url)) {
74
+ this.includedResources.delete(included)
75
+ this.differentlySatisfiedResource.push(resource)
76
+ }
77
+ }
78
+ this._noteSatisfies(resource)
79
+ }
80
+ this.includedResources.add(resource)
81
+ }
82
+
83
+ provideResource(resource) {
84
+ this.providedResources.push(resource)
85
+ }
86
+
87
+ /**
88
+ * Adds a piece of code which knows how to turn a resource into html.
89
+ * @param {string} mimeType
90
+ * @param {function} handler Takes a `Resource` object and turns it into a bit of html that can
91
+ * be included on the page.
92
+ */
93
+ addTypeHandler(mimeType, handler) {
94
+ this.renderers[mimeType] = handler
95
+ }
96
+
97
+ /**
98
+ * Adds code which will generate html needed before the types are processed individually. Good
99
+ * place for code which generates importmaps or preload statements.
100
+ * @param {function} handler Takes a `ExternalResourceManager` object and turns it into a bit of html that can
101
+ * be included on the page.
102
+ */
103
+ addPreTypeRender(handler) {
104
+ this.preTypeRenderers.push(handler)
105
+ }
106
+
107
+ render() {
108
+ let result = ''
109
+ for(let renderer of this.preTypeRenderers) {
110
+ result += renderer(this)
111
+ }
112
+
113
+ for(let resource of this.includedResources) {
114
+ if(this.alreadyRenderedUrls.has(resource.url) || this.alreadySatisfied.has(resource.url)) {
115
+ // A late in the game catch to make sure we don't output to the page a url that has
116
+ // already been used or a url which we know gets satisfied elsewhere
117
+ resource.rendered = true
118
+ continue
119
+ }
120
+ let renderer = this.renderers[resource.mimeType]
121
+ if(renderer && !resource.rendered) {
122
+ result += '\n'
123
+ result += renderer(resource)
124
+ resource.rendered = true
125
+ }
126
+ this.alreadyRenderedUrls.add(resource.url)
127
+ }
128
+
129
+ result += '\n'
130
+ for(let provided of this.providedResources) {
131
+ provided.rendered = true
132
+ }
133
+
134
+ return result
135
+ }
136
+
137
+
138
+ }
@@ -0,0 +1,36 @@
1
+
2
+ export default function createImportmapGenerator(webhandle) {
3
+ function importmapGenerator(manager) {
4
+
5
+ let imports = {}
6
+
7
+ let found = false
8
+ for (let resource of manager.providedResources) {
9
+ if(resource.rendered) {
10
+ continue
11
+ }
12
+ found = true
13
+ let vrsc = ''
14
+ if(!webhandle.development && resource.cachable) {
15
+ vrsc = '/vrsc/' + webhandle.resourceVersion
16
+ }
17
+ if (resource.mimeType === 'application/javascript' && resource.resourceType === 'module') {
18
+ imports[resource.name] = vrsc + resource.url
19
+ }
20
+ }
21
+
22
+ let data = {
23
+ imports
24
+ }
25
+
26
+ let dataText = webhandle.development ? JSON.stringify(data, null, '\t') : JSON.stringify(data)
27
+
28
+ let template = found ?
29
+ `<script type="importmap">
30
+ ${dataText}
31
+ </script>` : ''
32
+ return template
33
+ }
34
+
35
+ return importmapGenerator
36
+ }
@@ -0,0 +1,42 @@
1
+ import createInitializeWebhandleComponent from "@webhandle/initialize-webhandle-component/create-initialize-webhandle-component.mjs"
2
+ import ComponentManager from "@webhandle/initialize-webhandle-component/component-manager.mjs"
3
+ import path from "node:path"
4
+ import ExternalResourceManager from "./external-resource-manager.mjs"
5
+ import createImportmapGenerator from "./importmap-generator-creator.mjs"
6
+ import createTextCssRenderer from "./create-css-renderer.mjs"
7
+ import createApplicationJavascriptRenderer from "./create-application-javascript-renderer.mjs"
8
+
9
+ let initializeWebhandleComponent = createInitializeWebhandleComponent()
10
+
11
+ initializeWebhandleComponent.componentName = 'externalResourceManager'
12
+ initializeWebhandleComponent.componentDir = import.meta.dirname
13
+ initializeWebhandleComponent.defaultConfig = {}
14
+ initializeWebhandleComponent.staticFilePaths = ['public']
15
+ initializeWebhandleComponent.templatePaths = ['views']
16
+
17
+ initializeWebhandleComponent.setup = async function (webhandle, config) {
18
+ let compmanager = new ComponentManager()
19
+
20
+ let generator = createImportmapGenerator(webhandle)
21
+ let cssRender = createTextCssRenderer(webhandle)
22
+ let jsRender = createApplicationJavascriptRenderer(webhandle)
23
+
24
+ webhandle.routers.preParmParse.use((req, res, next) => {
25
+ let externalResourceManager = new ExternalResourceManager()
26
+
27
+ externalResourceManager.preTypeRenderers.push(generator)
28
+ externalResourceManager.renderers['text/css'] = cssRender
29
+ externalResourceManager.renderers['application/javascript'] = jsRender
30
+
31
+ res.locals.externalResourceManager = externalResourceManager
32
+
33
+ next()
34
+ })
35
+
36
+ compmanager.handlers = {
37
+ generator, cssRender, jsRender
38
+ }
39
+ return compmanager
40
+ }
41
+
42
+ export default initializeWebhandleComponent
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@webhandle/external-resource-manager",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "external-resource-manager.js",
6
+ "scripts": {
7
+ "test": "node test/all.mjs"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/EmergentIdeas/webhandle-external-resource-manager.git"
12
+ },
13
+ "keywords": [],
14
+ "author": "",
15
+ "license": "ISC",
16
+ "type": "module",
17
+ "bugs": {
18
+ "url": "https://github.com/EmergentIdeas/webhandle-external-resource-manager/issues"
19
+ },
20
+ "homepage": "https://github.com/EmergentIdeas/webhandle-external-resource-manager#readme",
21
+ "dependencies": {
22
+ "@webhandle/initialize-webhandle-component": "^1.0.1"
23
+ },
24
+ "files": [
25
+ "/*.mjs",
26
+ "README.md",
27
+ "*.mjs"
28
+ ]
29
+ }
package/resource.mjs ADDED
@@ -0,0 +1,50 @@
1
+
2
+ export default class Resource {
3
+
4
+ /**
5
+ * The mime type of this resource e.g. application/javascript, text/css
6
+ */
7
+ mimeType
8
+
9
+ /**
10
+ * This might be a sub-type or some sort of indication of how to handle this
11
+ * resource
12
+ */
13
+ resourceType
14
+
15
+ /**
16
+ * The name by which this resource is known, if there is one
17
+ */
18
+ name
19
+
20
+ /**
21
+ * The URL of the resource
22
+ */
23
+ url
24
+
25
+ /**
26
+ * Other URLs unnecessary if this resource is included
27
+ */
28
+ satisfies = []
29
+
30
+ /**
31
+ * Atributes that may be needed for the html element
32
+ */
33
+ attributes = []
34
+
35
+
36
+ /**
37
+ * True if this resource can be set as indefinitely cachable in line with
38
+ * webhandle's resource version scheme
39
+ */
40
+ cachable = true
41
+
42
+ /**
43
+ * True if the resource has been added to the page
44
+ */
45
+ rendered = false
46
+
47
+ constructor(options) {
48
+ Object.assign(this, options)
49
+ }
50
+ }