vanilla-jet 1.4.3 → 1.5.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/CHANGELOG.md +61 -0
- package/bin.js +25 -0
- package/framework/dipper.js +31 -0
- package/framework/router.js +36 -1
- package/framework/server.js +19 -6
- package/framework/sw.template.js +82 -0
- package/gulpfile.js +19 -4
- package/master.md +450 -0
- package/package.json +2 -3
- package/scripts/compile_html.js +14 -6
- package/scripts/generate_sw.js +182 -0
- package/test/config.test.js +47 -0
- package/test/dipper.test.js +76 -0
- package/test/helpers.js +66 -0
- package/test/router.test.js +58 -0
- package/test/server.test.js +103 -0
- package/test/service-worker.test.js +118 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Generates public/sw.js from framework/sw.template.js.
|
|
2
|
+
//
|
|
3
|
+
// Opt-in: only runs when the active profile sets `enable_service_worker: true`.
|
|
4
|
+
// The precache list is derived from the compiled core assets, any LOCAL resources
|
|
5
|
+
// the Dipper has enqueued, and the explicit `service_worker.precache` config.
|
|
6
|
+
// The cache name is pinned to a content hash so any asset change rotates the cache.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
|
|
12
|
+
const TEMPLATE_PATH = path.join(__dirname, '..', 'framework', 'sw.template.js');
|
|
13
|
+
|
|
14
|
+
// Core bundles that every VanillaJet app ships; precached when present.
|
|
15
|
+
const CORE_PRECACHE = [
|
|
16
|
+
'/public/styles/app.min.css',
|
|
17
|
+
'/public/scripts/vanilla.min.js',
|
|
18
|
+
'/public/scripts/core/vanillaJet.min.js'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const DEFAULT_ON_DEMAND_PREFIXES = ['/public/animations/', '/public/images/'];
|
|
22
|
+
|
|
23
|
+
function processCwd() {
|
|
24
|
+
return process.cwd()
|
|
25
|
+
.replace('/scripts', '')
|
|
26
|
+
.replace('/gulp', '')
|
|
27
|
+
.replace('/node_modules/vanilla-jet', '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ENV_ALIASES = { dev: 'development', prod: 'production', 'build:qa': 'qa', 'build:staging': 'staging', 'build:prod': 'production' };
|
|
31
|
+
|
|
32
|
+
function resolveEnv(config) {
|
|
33
|
+
let env = process.argv[2] || (config && config.profile) || 'development';
|
|
34
|
+
return ENV_ALIASES[env] || env;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadConfig(root) {
|
|
38
|
+
try {
|
|
39
|
+
const config = require(path.join(root, 'config.js'));
|
|
40
|
+
const settings = config.settings || {};
|
|
41
|
+
const env = resolveEnv(config);
|
|
42
|
+
const opts = settings[env] || settings[config.profile] || settings['profile'] || {};
|
|
43
|
+
return { opts, shared: settings['shared'] || {} };
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return { opts: {}, shared: {} };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function slugify(value) {
|
|
50
|
+
return String(value || '')
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.trim()
|
|
53
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
54
|
+
.replace(/(^-|-$)/g, '') || 'vanillajet';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stripQuery(url) {
|
|
58
|
+
const queryIndex = url.indexOf('?');
|
|
59
|
+
return queryIndex === -1 ? url : url.slice(0, queryIndex);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isLocalPublicPath(url) {
|
|
63
|
+
return typeof url === 'string' && url.startsWith('/public/') && !url.startsWith('//');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Source of truth = vanillaJet.package.json. We hydrate the Dipper and read its full
|
|
67
|
+
// registry (coreDependencies + dependencies + styles), keeping every LOCAL resource.
|
|
68
|
+
// This way the precache list is derived from the declared deps, with no raw paths in
|
|
69
|
+
// the consumer config. Any failure falls back to core only.
|
|
70
|
+
function deriveLocalAssets(root, opts, shared) {
|
|
71
|
+
try {
|
|
72
|
+
const Dipper = require('../framework/dipper.js');
|
|
73
|
+
const Functions = require('../framework/functions.js');
|
|
74
|
+
const dipper = new Dipper(opts, shared);
|
|
75
|
+
Functions.hydrate(dipper);
|
|
76
|
+
|
|
77
|
+
const assets = [];
|
|
78
|
+
const collect = (registry) => {
|
|
79
|
+
Object.keys(registry || {}).forEach((name) => {
|
|
80
|
+
const entry = registry[name];
|
|
81
|
+
if (entry && isLocalPublicPath(entry.resource)) {
|
|
82
|
+
assets.push(stripQuery(entry.resource));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
collect(dipper.styles);
|
|
87
|
+
collect(dipper.scripts);
|
|
88
|
+
return assets;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildPrecacheList(root, opts, shared) {
|
|
95
|
+
const sw = opts.service_worker || {};
|
|
96
|
+
// `precache` = optional extras NOT declared in vanillaJet.package.json.
|
|
97
|
+
const configured = Array.isArray(sw.precache) ? sw.precache : [];
|
|
98
|
+
// `precache_exclude` = opt-out for declared-but-don't-cache assets (heavy/rare).
|
|
99
|
+
const exclude = (Array.isArray(sw.precache_exclude) ? sw.precache_exclude : []).map(stripQuery);
|
|
100
|
+
|
|
101
|
+
const candidates = CORE_PRECACHE
|
|
102
|
+
.concat(deriveLocalAssets(root, opts, shared))
|
|
103
|
+
.concat(configured)
|
|
104
|
+
.map(stripQuery)
|
|
105
|
+
.filter(isLocalPublicPath)
|
|
106
|
+
.filter((assetPath) => !exclude.includes(assetPath));
|
|
107
|
+
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
const precache = [];
|
|
110
|
+
candidates.forEach((assetPath) => {
|
|
111
|
+
if (seen.has(assetPath)) return;
|
|
112
|
+
const absolute = path.join(root, assetPath.replace(/^\//, ''));
|
|
113
|
+
try {
|
|
114
|
+
if (fs.statSync(absolute).isFile()) {
|
|
115
|
+
seen.add(assetPath);
|
|
116
|
+
precache.push(assetPath);
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// Missing file: skip it so the SW never precaches a 404.
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return precache;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function computeCacheHash(root, precache) {
|
|
126
|
+
const hash = crypto.createHash('md5');
|
|
127
|
+
precache.forEach((assetPath) => {
|
|
128
|
+
const absolute = path.join(root, assetPath.replace(/^\//, ''));
|
|
129
|
+
try {
|
|
130
|
+
const stats = fs.statSync(absolute);
|
|
131
|
+
hash.update(`${assetPath}:${stats.size}-${Math.floor(stats.mtimeMs)}`);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
hash.update(`${assetPath}:missing`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
return hash.digest('hex').slice(0, 12);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function main() {
|
|
140
|
+
const root = processCwd();
|
|
141
|
+
const { opts, shared } = loadConfig(root);
|
|
142
|
+
|
|
143
|
+
if (!opts.enable_service_worker) {
|
|
144
|
+
// Feature disabled: remove any previously generated SW so it stops controlling clients.
|
|
145
|
+
const existing = path.join(root, 'public', 'sw.js');
|
|
146
|
+
if (fs.existsSync(existing)) {
|
|
147
|
+
fs.unlinkSync(existing);
|
|
148
|
+
console.log('VanillaJet - service worker disabled; removed public/sw.js');
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const swOptions = opts.service_worker || {};
|
|
154
|
+
const baseSlug = slugify(swOptions.cache_prefix || shared.site_name || 'vanillajet');
|
|
155
|
+
// activate() purges every cache whose name starts with this base (and != current),
|
|
156
|
+
// so a rebuild rotates the cache AND legacy schemes (e.g. "<base>-<appVersion>") get cleaned up.
|
|
157
|
+
const cachePrefix = baseSlug;
|
|
158
|
+
const onDemandPrefixes = Array.isArray(swOptions.on_demand_prefixes)
|
|
159
|
+
? swOptions.on_demand_prefixes
|
|
160
|
+
: DEFAULT_ON_DEMAND_PREFIXES;
|
|
161
|
+
|
|
162
|
+
const precache = buildPrecacheList(root, opts, shared);
|
|
163
|
+
const cacheName = `${baseSlug}-sw-${computeCacheHash(root, precache)}`;
|
|
164
|
+
|
|
165
|
+
let template = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
|
166
|
+
template = template
|
|
167
|
+
.replace(/__CACHE_NAME__/g, cacheName)
|
|
168
|
+
.replace(/__CACHE_PREFIX__/g, cachePrefix)
|
|
169
|
+
.replace('__PRECACHE_ASSETS__', JSON.stringify(precache, null, '\t'))
|
|
170
|
+
.replace('__ON_DEMAND_PREFIXES__', JSON.stringify(onDemandPrefixes, null, '\t'));
|
|
171
|
+
|
|
172
|
+
const publicDir = path.join(root, 'public');
|
|
173
|
+
fs.mkdirSync(publicDir, { recursive: true });
|
|
174
|
+
const outputPath = path.join(publicDir, 'sw.js');
|
|
175
|
+
fs.writeFileSync(outputPath, template, 'utf8');
|
|
176
|
+
|
|
177
|
+
console.log(`VanillaJet - service worker generated at public/sw.js (cache: ${cacheName}, ${precache.length} precached)`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
main();
|
|
181
|
+
|
|
182
|
+
module.exports = main;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const { test, after } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
const Server = require('../framework/server.js');
|
|
5
|
+
const { waitForListening, closeServer } = require('./helpers.js');
|
|
6
|
+
|
|
7
|
+
// Guards the profile-resolution regression: 1.3.x consumers key settings by the
|
|
8
|
+
// active profile name (settings[options.profile]); the newer shape uses a single
|
|
9
|
+
// nested 'profile'. Both must resolve to obj.options.
|
|
10
|
+
const servers = [];
|
|
11
|
+
|
|
12
|
+
async function boot(options) {
|
|
13
|
+
const server = new Server(options, []);
|
|
14
|
+
servers.push(server);
|
|
15
|
+
await waitForListening(server);
|
|
16
|
+
return server;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
after(async () => {
|
|
20
|
+
for (const server of servers) {
|
|
21
|
+
await closeServer(server);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('legacy config shape: settings keyed by active profile name', async () => {
|
|
26
|
+
const server = await boot({
|
|
27
|
+
profile: 'qa',
|
|
28
|
+
settings: {
|
|
29
|
+
qa: { port: 0, marker: 'legacy' },
|
|
30
|
+
development: { port: 0, marker: 'dev' },
|
|
31
|
+
shared: {},
|
|
32
|
+
security: {}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
assert.equal(server.options.marker, 'legacy');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('nested config shape: single profile object', async () => {
|
|
39
|
+
const server = await boot({
|
|
40
|
+
settings: {
|
|
41
|
+
profile: { port: 0, marker: 'nested' },
|
|
42
|
+
shared: {},
|
|
43
|
+
security: {}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
assert.equal(server.options.marker, 'nested');
|
|
47
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
const Dipper = require('../framework/dipper.js');
|
|
5
|
+
|
|
6
|
+
// Dipper reads vanillaJet.package.json from cwd; when absent it degrades gracefully.
|
|
7
|
+
function makeDipper() {
|
|
8
|
+
return new Dipper({}, { site_name: 'Test', description: 'desc' });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test('urlTo: normalizes local routes and leaves absolute URLs untouched', () => {
|
|
12
|
+
const dipper = makeDipper();
|
|
13
|
+
assert.equal(dipper.urlTo('foo/bar'), '/foo/bar');
|
|
14
|
+
assert.equal(dipper.urlTo('/already'), '/already');
|
|
15
|
+
assert.equal(dipper.urlTo('https://cdn.example.com/a.js'), 'https://cdn.example.com/a.js');
|
|
16
|
+
assert.equal(dipper.urlTo('//cdn.example.com/a.js'), '//cdn.example.com/a.js');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('versionedUrl: fingerprints existing local files and passes through externals', () => {
|
|
20
|
+
const dipper = makeDipper();
|
|
21
|
+
// package.json exists at the repo root (cwd during `npm test`).
|
|
22
|
+
const versioned = dipper.versionedUrl('package.json');
|
|
23
|
+
assert.match(versioned, /^\/package\.json\?v=\d+-\d+$/);
|
|
24
|
+
assert.equal(
|
|
25
|
+
dipper.versionedUrl('https://cdn.example.com/a.js'),
|
|
26
|
+
'https://cdn.example.com/a.js'
|
|
27
|
+
);
|
|
28
|
+
// Non-existent local file keeps legacy behavior (no version param).
|
|
29
|
+
assert.equal(dipper.versionedUrl('does/not/exist.js'), '/does/not/exist.js');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('registerScript + includeScript: renders a script tag, with defer when requested', () => {
|
|
33
|
+
const dipper = makeDipper();
|
|
34
|
+
dipper.registerScript('plain', '/public/scripts/plain.js');
|
|
35
|
+
assert.match(dipper.includeScript('plain'), /<script src="\/public\/scripts\/plain\.js"><\/script>/);
|
|
36
|
+
|
|
37
|
+
// signature: registerScript(name, url, requires, cdn, async, defer, ...)
|
|
38
|
+
dipper.registerScript('deferred', '/public/scripts/deferred.js', undefined, false, false, true);
|
|
39
|
+
assert.match(dipper.includeScript('deferred'), / defer/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('registerStyle + includeStyle: renders a stylesheet link', () => {
|
|
43
|
+
const dipper = makeDipper();
|
|
44
|
+
dipper.registerStyle('main', '/public/styles/app.min.css');
|
|
45
|
+
const tag = dipper.includeStyle('main');
|
|
46
|
+
assert.match(tag, /rel="stylesheet"/);
|
|
47
|
+
assert.match(tag, /href="\/public\/styles\/app\.min\.css"/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('includeServiceWorker: empty when disabled, registers when enabled', () => {
|
|
51
|
+
const disabled = new Dipper({}, {});
|
|
52
|
+
assert.equal(disabled.includeServiceWorker(), '');
|
|
53
|
+
|
|
54
|
+
const enabled = new Dipper({ enable_service_worker: true }, {});
|
|
55
|
+
const tag = enabled.includeServiceWorker();
|
|
56
|
+
assert.match(tag, /serviceWorker/);
|
|
57
|
+
assert.match(tag, /register\('\/sw\.js'\)/);
|
|
58
|
+
assert.match(tag, /__VJ_DISABLE_SW__/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('enqueue/dequeue: dependencies resolve and clear correctly', () => {
|
|
62
|
+
const dipper = makeDipper();
|
|
63
|
+
dipper.registerScript('dep', '/dep.js');
|
|
64
|
+
dipper.registerScript('main', '/main.js', ['dep']);
|
|
65
|
+
|
|
66
|
+
dipper.enqueueScript('main');
|
|
67
|
+
const enqueued = Object.keys(dipper.enqueued_scripts);
|
|
68
|
+
assert.ok(enqueued.includes('main'), 'main should be enqueued');
|
|
69
|
+
assert.ok(enqueued.includes('dep'), 'dependency should be enqueued transitively');
|
|
70
|
+
|
|
71
|
+
// Dequeue with dependencies flag clears both.
|
|
72
|
+
dipper.dequeueScript('main', true);
|
|
73
|
+
const remaining = Object.keys(dipper.enqueued_scripts);
|
|
74
|
+
assert.ok(!remaining.includes('main'));
|
|
75
|
+
assert.ok(!remaining.includes('dep'));
|
|
76
|
+
});
|
package/test/helpers.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Shared test utilities for the VanillaJet smoke harness.
|
|
2
|
+
// No external dependencies: only Node built-ins + the framework under test.
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a throwaway workspace and writes the given files into it.
|
|
10
|
+
* @param {Object<string,string|Buffer>} files map of relative path -> content
|
|
11
|
+
* @returns {string} absolute path to the temp workspace
|
|
12
|
+
*/
|
|
13
|
+
function makeTempWorkspace(files) {
|
|
14
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vanillajet-test-'));
|
|
15
|
+
for (const [relativePath, content] of Object.entries(files || {})) {
|
|
16
|
+
const fullPath = path.join(dir, relativePath);
|
|
17
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
18
|
+
fs.writeFileSync(fullPath, content);
|
|
19
|
+
}
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Removes a temp workspace created by makeTempWorkspace. */
|
|
24
|
+
function removeWorkspace(dir) {
|
|
25
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Builds the options object that `Server` expects. Use port 0 for an ephemeral port. */
|
|
29
|
+
function serverOptions(port, profile) {
|
|
30
|
+
return {
|
|
31
|
+
settings: {
|
|
32
|
+
profile: Object.assign({ port: port, https_server: false }, profile || {}),
|
|
33
|
+
shared: {},
|
|
34
|
+
security: {}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Resolves once the server socket is actually listening. */
|
|
40
|
+
function waitForListening(server) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
if (server.httpx && server.httpx.listening) {
|
|
43
|
+
return resolve();
|
|
44
|
+
}
|
|
45
|
+
server.httpx.once('listening', resolve);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Returns the ephemeral port the server bound to. */
|
|
50
|
+
function boundPort(server) {
|
|
51
|
+
return server.httpx.address().port;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Closes the server socket. */
|
|
55
|
+
function closeServer(server) {
|
|
56
|
+
return new Promise((resolve) => server.httpx.close(() => resolve()));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
makeTempWorkspace,
|
|
61
|
+
removeWorkspace,
|
|
62
|
+
serverOptions,
|
|
63
|
+
waitForListening,
|
|
64
|
+
boundPort,
|
|
65
|
+
closeServer
|
|
66
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
const Router = require('../framework/router.js');
|
|
5
|
+
|
|
6
|
+
// Router only needs `server.options` for the precompressed flag.
|
|
7
|
+
function makeRouter() {
|
|
8
|
+
return new Router({ options: { enable_precompressed_negotiation: false } });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test('routeToRegExp: named parameter captures the segment', () => {
|
|
12
|
+
const router = makeRouter();
|
|
13
|
+
const re = router.routeToRegExp('/users/:id');
|
|
14
|
+
assert.ok(re.test('/users/123'));
|
|
15
|
+
assert.equal('/users/abc'.match(re)[1], 'abc');
|
|
16
|
+
assert.ok(!re.test('/users'));
|
|
17
|
+
assert.ok(!re.test('/users/123/edit'));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('routeToRegExp: optional segment matches with and without it', () => {
|
|
21
|
+
const router = makeRouter();
|
|
22
|
+
const re = router.routeToRegExp('/items(/:id)');
|
|
23
|
+
assert.ok(re.test('/items'));
|
|
24
|
+
assert.ok(re.test('/items/123'));
|
|
25
|
+
assert.ok(!re.test('/items/123/extra'));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('routeToRegExp: splat matches any number of segments', () => {
|
|
29
|
+
const router = makeRouter();
|
|
30
|
+
const re = router.routeToRegExp('/files/*path');
|
|
31
|
+
assert.ok(re.test('/files/a'));
|
|
32
|
+
assert.ok(re.test('/files/a/b/c.txt'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('routeToRegExp: tolerates a trailing query string', () => {
|
|
36
|
+
const router = makeRouter();
|
|
37
|
+
const re = router.routeToRegExp('/search');
|
|
38
|
+
assert.ok(re.test('/search'));
|
|
39
|
+
assert.ok(re.test('/search?q=test'));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('isProtectedFile: blocks framework/external/node_modules and top-level files', () => {
|
|
43
|
+
const router = makeRouter();
|
|
44
|
+
assert.equal(router.isProtectedFile('/framework/server.js'), true);
|
|
45
|
+
assert.equal(router.isProtectedFile('/node_modules/x/index.js'), true);
|
|
46
|
+
assert.equal(router.isProtectedFile('/external/lib.js'), true);
|
|
47
|
+
assert.equal(router.isProtectedFile('/favicon.ico'), true); // first-level file
|
|
48
|
+
assert.equal(router.isProtectedFile('/public/scripts/vanilla.min.js'), false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('supportsEncoding: honors q-values and array shape', () => {
|
|
52
|
+
const router = makeRouter();
|
|
53
|
+
assert.equal(router.supportsEncoding(['gzip'], 'gzip'), true);
|
|
54
|
+
assert.equal(router.supportsEncoding(['gzip;q=1.0'], 'gzip'), true);
|
|
55
|
+
assert.equal(router.supportsEncoding(['gzip;q=0'], 'gzip'), false);
|
|
56
|
+
assert.equal(router.supportsEncoding(['br'], 'gzip'), false);
|
|
57
|
+
assert.equal(router.supportsEncoding('not-an-array', 'gzip'), false);
|
|
58
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const { test, before, after } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const Server = require('../framework/server.js');
|
|
6
|
+
const {
|
|
7
|
+
makeTempWorkspace,
|
|
8
|
+
removeWorkspace,
|
|
9
|
+
serverOptions,
|
|
10
|
+
waitForListening,
|
|
11
|
+
boundPort,
|
|
12
|
+
closeServer
|
|
13
|
+
} = require('./helpers.js');
|
|
14
|
+
|
|
15
|
+
// A minimal endpoint. Handlers must return truthy to mark the request as handled.
|
|
16
|
+
class PingEndpoint {
|
|
17
|
+
constructor(router) {
|
|
18
|
+
this.name = 'PingEndpoint';
|
|
19
|
+
router.addRoute('get', '/ping', 'PingEndpoint.ping');
|
|
20
|
+
router.addRoute('get', '/home', 'PingEndpoint.home');
|
|
21
|
+
}
|
|
22
|
+
ping(request, response) {
|
|
23
|
+
response.setBody('pong');
|
|
24
|
+
response.respond();
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
home(request, response) {
|
|
28
|
+
response.render(request, 'home.html');
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let server;
|
|
34
|
+
let workspace;
|
|
35
|
+
let previousCwd;
|
|
36
|
+
let baseUrl;
|
|
37
|
+
|
|
38
|
+
before(async () => {
|
|
39
|
+
workspace = makeTempWorkspace({
|
|
40
|
+
'public/scripts/test.min.js': 'console.log("static asset");',
|
|
41
|
+
'public/pages/home.html': '<!doctype html><title>home</title><h1>HOME_OK</h1>'
|
|
42
|
+
});
|
|
43
|
+
previousCwd = process.cwd();
|
|
44
|
+
process.chdir(workspace);
|
|
45
|
+
|
|
46
|
+
server = new Server(serverOptions(0), [PingEndpoint]);
|
|
47
|
+
await waitForListening(server);
|
|
48
|
+
baseUrl = `http://127.0.0.1:${boundPort(server)}`;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
after(async () => {
|
|
52
|
+
if (server) {
|
|
53
|
+
await closeServer(server);
|
|
54
|
+
}
|
|
55
|
+
process.chdir(previousCwd);
|
|
56
|
+
removeWorkspace(workspace);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('dynamic route returns the endpoint body', async () => {
|
|
60
|
+
const res = await fetch(`${baseUrl}/ping`);
|
|
61
|
+
assert.equal(res.status, 200);
|
|
62
|
+
assert.equal(await res.text(), 'pong');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('render() streams a precompiled page', async () => {
|
|
66
|
+
const res = await fetch(`${baseUrl}/home`);
|
|
67
|
+
assert.equal(res.status, 200);
|
|
68
|
+
assert.match(res.headers.get('content-type') || '', /text\/html/);
|
|
69
|
+
assert.match(await res.text(), /HOME_OK/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('static asset is served with caching validators', async () => {
|
|
73
|
+
const res = await fetch(`${baseUrl}/public/scripts/test.min.js`);
|
|
74
|
+
assert.equal(res.status, 200);
|
|
75
|
+
assert.match(res.headers.get('content-type') || '', /javascript/);
|
|
76
|
+
assert.ok(res.headers.get('etag'), 'ETag should be present');
|
|
77
|
+
assert.ok(res.headers.get('cache-control'), 'Cache-Control should be present');
|
|
78
|
+
assert.ok(res.headers.get('last-modified'), 'Last-Modified should be present');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('conditional request returns 304 when ETag matches', async () => {
|
|
82
|
+
const first = await fetch(`${baseUrl}/public/scripts/test.min.js`);
|
|
83
|
+
const etag = first.headers.get('etag');
|
|
84
|
+
await first.arrayBuffer(); // drain
|
|
85
|
+
assert.ok(etag);
|
|
86
|
+
|
|
87
|
+
const second = await fetch(`${baseUrl}/public/scripts/test.min.js`, {
|
|
88
|
+
headers: { 'If-None-Match': etag }
|
|
89
|
+
});
|
|
90
|
+
assert.equal(second.status, 304);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('missing static file returns 404', async () => {
|
|
94
|
+
const res = await fetch(`${baseUrl}/public/scripts/missing.js`);
|
|
95
|
+
await res.arrayBuffer();
|
|
96
|
+
assert.equal(res.status, 404);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('protected path returns 404', async () => {
|
|
100
|
+
const res = await fetch(`${baseUrl}/framework/server.js`);
|
|
101
|
+
await res.arrayBuffer();
|
|
102
|
+
assert.equal(res.status, 404);
|
|
103
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const { test, before, after, describe } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const { execFileSync } = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
const Server = require('../framework/server.js');
|
|
8
|
+
const {
|
|
9
|
+
makeTempWorkspace,
|
|
10
|
+
removeWorkspace,
|
|
11
|
+
serverOptions,
|
|
12
|
+
waitForListening,
|
|
13
|
+
boundPort,
|
|
14
|
+
closeServer
|
|
15
|
+
} = require('./helpers.js');
|
|
16
|
+
|
|
17
|
+
const GENERATOR = path.join(__dirname, '..', 'framework', '..', 'scripts', 'generate_sw.js');
|
|
18
|
+
|
|
19
|
+
const PUBLIC_FIXTURE = {
|
|
20
|
+
'public/styles/app.min.css': 'body{}',
|
|
21
|
+
'public/scripts/vanilla.min.js': 'console.log(1)',
|
|
22
|
+
'public/scripts/core/vanillaJet.min.js': 'console.log(2)'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('service worker generation', () => {
|
|
26
|
+
test('enabled: writes public/sw.js with cache name and core precache', () => {
|
|
27
|
+
const ws = makeTempWorkspace(Object.assign({}, PUBLIC_FIXTURE, {
|
|
28
|
+
'config.js':
|
|
29
|
+
"module.exports = { profile: 'qa', settings: { " +
|
|
30
|
+
"qa: { enable_service_worker: true, service_worker: { cache_prefix: 'testapp' } }, " +
|
|
31
|
+
"shared: { site_name: 'Test' }, security: {} } };"
|
|
32
|
+
}));
|
|
33
|
+
try {
|
|
34
|
+
execFileSync('node', [GENERATOR], { cwd: ws, stdio: 'pipe' });
|
|
35
|
+
const swPath = path.join(ws, 'public', 'sw.js');
|
|
36
|
+
assert.ok(fs.existsSync(swPath), 'sw.js should be generated');
|
|
37
|
+
const sw = fs.readFileSync(swPath, 'utf8');
|
|
38
|
+
assert.match(sw, /testapp-sw-[a-f0-9]{12}/, 'cache name should be content-pinned');
|
|
39
|
+
assert.match(sw, /\/public\/scripts\/vanilla\.min\.js/);
|
|
40
|
+
assert.match(sw, /\/public\/styles\/app\.min\.css/);
|
|
41
|
+
assert.ok(!sw.includes('__PRECACHE_ASSETS__'), 'no placeholders should remain');
|
|
42
|
+
assert.ok(!sw.includes('__CACHE_NAME__'), 'no placeholders should remain');
|
|
43
|
+
} finally {
|
|
44
|
+
removeWorkspace(ws);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('disabled: does not generate sw.js', () => {
|
|
49
|
+
const ws = makeTempWorkspace(Object.assign({}, PUBLIC_FIXTURE, {
|
|
50
|
+
'config.js':
|
|
51
|
+
"module.exports = { profile: 'qa', settings: { qa: {}, shared: {}, security: {} } };"
|
|
52
|
+
}));
|
|
53
|
+
try {
|
|
54
|
+
execFileSync('node', [GENERATOR], { cwd: ws, stdio: 'pipe' });
|
|
55
|
+
assert.ok(!fs.existsSync(path.join(ws, 'public', 'sw.js')), 'sw.js must not exist when disabled');
|
|
56
|
+
} finally {
|
|
57
|
+
removeWorkspace(ws);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('service worker serving', () => {
|
|
63
|
+
let server;
|
|
64
|
+
let ws;
|
|
65
|
+
let previousCwd;
|
|
66
|
+
let baseUrl;
|
|
67
|
+
|
|
68
|
+
before(async () => {
|
|
69
|
+
ws = makeTempWorkspace({ 'public/sw.js': '/* sw */' });
|
|
70
|
+
previousCwd = process.cwd();
|
|
71
|
+
process.chdir(ws);
|
|
72
|
+
server = new Server(serverOptions(0, { enable_service_worker: true }), []);
|
|
73
|
+
await waitForListening(server);
|
|
74
|
+
baseUrl = `http://127.0.0.1:${boundPort(server)}`;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
after(async () => {
|
|
78
|
+
if (server) await closeServer(server);
|
|
79
|
+
process.chdir(previousCwd);
|
|
80
|
+
removeWorkspace(ws);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('GET /sw.js returns the worker with root scope header', async () => {
|
|
84
|
+
const res = await fetch(`${baseUrl}/sw.js`);
|
|
85
|
+
assert.equal(res.status, 200);
|
|
86
|
+
assert.match(res.headers.get('content-type') || '', /javascript/);
|
|
87
|
+
assert.equal(res.headers.get('service-worker-allowed'), '/');
|
|
88
|
+
assert.match(await res.text(), /sw/);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('service worker disabled at runtime', () => {
|
|
93
|
+
let server;
|
|
94
|
+
let ws;
|
|
95
|
+
let previousCwd;
|
|
96
|
+
let baseUrl;
|
|
97
|
+
|
|
98
|
+
before(async () => {
|
|
99
|
+
ws = makeTempWorkspace({ 'public/sw.js': '/* sw */' });
|
|
100
|
+
previousCwd = process.cwd();
|
|
101
|
+
process.chdir(ws);
|
|
102
|
+
server = new Server(serverOptions(0), []); // flag off
|
|
103
|
+
await waitForListening(server);
|
|
104
|
+
baseUrl = `http://127.0.0.1:${boundPort(server)}`;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
after(async () => {
|
|
108
|
+
if (server) await closeServer(server);
|
|
109
|
+
process.chdir(previousCwd);
|
|
110
|
+
removeWorkspace(ws);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('GET /sw.js is 404 when the feature is off', async () => {
|
|
114
|
+
const res = await fetch(`${baseUrl}/sw.js`);
|
|
115
|
+
await res.arrayBuffer();
|
|
116
|
+
assert.equal(res.status, 404);
|
|
117
|
+
});
|
|
118
|
+
});
|