@jon49/sw 0.12.9
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/bin/lib/arguments.ts +46 -0
- package/bin/lib/esbuild-plugins.ts +114 -0
- package/bin/lib/file-mapper.ts +83 -0
- package/bin/lib/system.ts +71 -0
- package/bin/start.ts +154 -0
- package/lib/app-loader.html +35 -0
- package/lib/arguments.ts +46 -0
- package/lib/db.ts +69 -0
- package/lib/new-app-notifier.ts +51 -0
- package/lib/routes.ts +366 -0
- package/lib/utils.ts +26 -0
- package/lib/validation.ts +143 -0
- package/package.json +44 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import argvGenerator from "minimist"
|
|
2
|
+
|
|
3
|
+
export class Arguments {
|
|
4
|
+
argv: Argv
|
|
5
|
+
constructor() {
|
|
6
|
+
this.argv = argvGenerator(process.argv.slice(2)) as Argv
|
|
7
|
+
}
|
|
8
|
+
get _() {
|
|
9
|
+
return this.argv._
|
|
10
|
+
}
|
|
11
|
+
get environment() {
|
|
12
|
+
return this.argv.e || this.argv.env || "dev"
|
|
13
|
+
}
|
|
14
|
+
get port() {
|
|
15
|
+
return this.argv.p || this.argv.port || 3000
|
|
16
|
+
}
|
|
17
|
+
get targetDirectory() {
|
|
18
|
+
return this.argv.t || this.argv.target || "./public"
|
|
19
|
+
}
|
|
20
|
+
get help() {
|
|
21
|
+
return this.argv.h || this.argv.help
|
|
22
|
+
}
|
|
23
|
+
get isHelp() {
|
|
24
|
+
return this.help
|
|
25
|
+
}
|
|
26
|
+
get isProd() {
|
|
27
|
+
return this.environment === "prod"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Argv {
|
|
32
|
+
_: string[]
|
|
33
|
+
// Environment
|
|
34
|
+
e: "dev" | "server" | "prod"
|
|
35
|
+
env: "dev" | "server" | "prod"
|
|
36
|
+
// Port
|
|
37
|
+
p: number
|
|
38
|
+
port: number
|
|
39
|
+
// Target directory
|
|
40
|
+
t: string
|
|
41
|
+
target: string
|
|
42
|
+
// Help
|
|
43
|
+
h: boolean
|
|
44
|
+
help: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { cp, readFile, writeFile } from "node:fs/promises"
|
|
3
|
+
|
|
4
|
+
import type { BuildResult, PluginBuild } from "esbuild"
|
|
5
|
+
|
|
6
|
+
import { findHashedFile, glob, makeUrlPath, write } from "./system.ts"
|
|
7
|
+
import { fileMapper } from "./file-mapper.ts"
|
|
8
|
+
|
|
9
|
+
export function transformExportToReturn(targetDirectory: string) {
|
|
10
|
+
return {
|
|
11
|
+
name: 'transform-export-to-return',
|
|
12
|
+
setup(build: PluginBuild) {
|
|
13
|
+
build.onEnd(async result => {
|
|
14
|
+
if (result.errors.length !== 0) {
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
await setPagesToReturn(targetDirectory)
|
|
18
|
+
})
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function setPagesToReturn(targetDirectory: string) {
|
|
24
|
+
let files = await glob("**/*.js", targetDirectory)
|
|
25
|
+
await Promise.all(files.map(async (file: string) => {
|
|
26
|
+
let filename = path.join(targetDirectory, file)
|
|
27
|
+
let content = await readFile(filename, "utf-8")
|
|
28
|
+
let matched = content.match(/,(\w*)=(\w*);export/)
|
|
29
|
+
if (matched) {
|
|
30
|
+
let [, name, value] = matched
|
|
31
|
+
content = content.replace(`,${name}=${value}`, "")
|
|
32
|
+
content = content.replace(
|
|
33
|
+
`export{${name} as default}`,
|
|
34
|
+
`return ${value}`)
|
|
35
|
+
} else {
|
|
36
|
+
let matched = content.match(/var (\w*) = (\w*);\sexport \{\s.*\s.*/)
|
|
37
|
+
if (!matched) {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
content = content.replace(matched[0], `return ${matched[2]};`)
|
|
41
|
+
}
|
|
42
|
+
await writeFile(filename, content, 'utf-8')
|
|
43
|
+
}))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function updateFileMapper(targetDirectory: string) {
|
|
47
|
+
return {
|
|
48
|
+
name: 'update-file-mapper',
|
|
49
|
+
setup(build: PluginBuild) {
|
|
50
|
+
build.onEnd(async (result: BuildResult) => {
|
|
51
|
+
if (result.errors.length !== 0) {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
await fileMapper(targetDirectory)
|
|
55
|
+
})
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function updateHTML(targetDirectory: string) {
|
|
61
|
+
return {
|
|
62
|
+
name: 'update-html-files',
|
|
63
|
+
setup(build: PluginBuild) {
|
|
64
|
+
build.onEnd(async result => {
|
|
65
|
+
if (result.errors.length !== 0) {
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
await handleHTML(targetDirectory)
|
|
69
|
+
})
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function handleHTML(targetDirectory: string) {
|
|
75
|
+
console.time("Copying HTML")
|
|
76
|
+
await cp(
|
|
77
|
+
"./node_modules/@jon49/sw/lib/app-loader.html",
|
|
78
|
+
`${targetDirectory}/web/index.html`,
|
|
79
|
+
{ recursive: true }
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
let files = await glob("**/*.html", "./src")
|
|
83
|
+
|
|
84
|
+
let mappableFiles =
|
|
85
|
+
(await Promise.all(
|
|
86
|
+
(await glob("**/{css,js,images}/*.*", "./src"))
|
|
87
|
+
.map(async x => {
|
|
88
|
+
let ext = path.extname(x)
|
|
89
|
+
if (ext === ".ts") {
|
|
90
|
+
ext = ".js"
|
|
91
|
+
}
|
|
92
|
+
let hashedFile = await findHashedFile(x, ext, targetDirectory)
|
|
93
|
+
if (!hashedFile) {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
url: makeUrlPath(x),
|
|
98
|
+
file: makeUrlPath(hashedFile),
|
|
99
|
+
}
|
|
100
|
+
})))
|
|
101
|
+
.filter(x => x) as { url: string, file: string }[]
|
|
102
|
+
|
|
103
|
+
await Promise.all(files.map(async (filename: string) => {
|
|
104
|
+
let content = await readFile(`./src/${filename}`, "utf-8")
|
|
105
|
+
for (let mappableFile of mappableFiles) {
|
|
106
|
+
content = content.replace(mappableFile.url, mappableFile.file)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await write(`${targetDirectory}/${filename}`, content)
|
|
110
|
+
}))
|
|
111
|
+
|
|
112
|
+
console.timeEnd("Copying HTML")
|
|
113
|
+
}
|
|
114
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { getHash, glob, write } from "./system.ts"
|
|
5
|
+
|
|
6
|
+
export interface FileMapper {
|
|
7
|
+
url: string
|
|
8
|
+
file: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isFileMapper(x: any | undefined): x is FileMapper {
|
|
12
|
+
return x?.url && x?.file
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let option: { isRunning: boolean, isWaiting: NodeJS.Timeout | null } = {
|
|
16
|
+
isRunning: false,
|
|
17
|
+
isWaiting: null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function fileMapper(targetDirectory: string, force = false) {
|
|
21
|
+
if (option.isRunning || option.isWaiting || !force) {
|
|
22
|
+
if (option.isWaiting) {
|
|
23
|
+
clearTimeout(option.isWaiting)
|
|
24
|
+
}
|
|
25
|
+
option.isWaiting = setTimeout(() => {
|
|
26
|
+
option.isWaiting = null
|
|
27
|
+
fileMapper(targetDirectory, true)
|
|
28
|
+
}, 30)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
option.isRunning = true
|
|
33
|
+
console.time("File Mapper")
|
|
34
|
+
|
|
35
|
+
let oldFileMapperFiles = await glob("**/file-map.*.js", targetDirectory)
|
|
36
|
+
Promise.all(oldFileMapperFiles.map(async x => {
|
|
37
|
+
await rm(`${targetDirectory}/${x}`)
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
let files: string[] = await glob("**/*.{js,css,json,ico,svg,png}", targetDirectory)
|
|
41
|
+
|
|
42
|
+
let mapper = files.map(x => {
|
|
43
|
+
let parsed = path.parse(x)
|
|
44
|
+
let parsed2 = path.parse(parsed.name)
|
|
45
|
+
let url = `/${parsed.dir}/${parsed2.name}${parsed.ext}`
|
|
46
|
+
let file = `/${x}`
|
|
47
|
+
return {
|
|
48
|
+
url,
|
|
49
|
+
file,
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
.filter(isFileMapper)
|
|
53
|
+
|
|
54
|
+
// Write mapper to file in src/web/file-map.js
|
|
55
|
+
if (option.isWaiting) return
|
|
56
|
+
let fileMapContent = `(() => { self.app = { links: ${JSON.stringify(mapper)} } })()`
|
|
57
|
+
let hash = getHash(fileMapContent)
|
|
58
|
+
let fileMapUrl = `/web/file-map.${hash}.js`
|
|
59
|
+
await write(`${targetDirectory}${fileMapUrl}`, fileMapContent)
|
|
60
|
+
|
|
61
|
+
let globalFiles =
|
|
62
|
+
mapper
|
|
63
|
+
.filter(x => x.url.includes(".global."))
|
|
64
|
+
.map(x => x.file)
|
|
65
|
+
.join(`","`)
|
|
66
|
+
|
|
67
|
+
let swFile = mapper.find(x => x.url === "/web/sw.js")?.file
|
|
68
|
+
|
|
69
|
+
let globals = `${globalFiles && `"`}${globalFiles}${globalFiles && `",`}`
|
|
70
|
+
// Create service worker central file
|
|
71
|
+
if (option.isWaiting) return
|
|
72
|
+
await write(
|
|
73
|
+
`${targetDirectory}/web/sw.js`,
|
|
74
|
+
`importScripts("${fileMapUrl}",${globals}"${swFile}")`)
|
|
75
|
+
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(error)
|
|
78
|
+
} finally {
|
|
79
|
+
option.isRunning = false
|
|
80
|
+
console.timeEnd("File Mapper")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { glob as Glob, mkdir, writeFile } from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import crypto from 'crypto'
|
|
4
|
+
import { createReadStream, existsSync } from "node:fs"
|
|
5
|
+
|
|
6
|
+
function getHashOfFile(path: string): Promise<string> {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const hash = crypto.createHash('sha1')
|
|
9
|
+
const rs = createReadStream(path)
|
|
10
|
+
rs.on('error', reject)
|
|
11
|
+
rs.on('data', chunk => hash.update(chunk))
|
|
12
|
+
rs.on('end', () => resolve(hash.digest('hex').slice(0, 8)))
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function addHash(filename: string) {
|
|
17
|
+
let hash = await getHashOfFile(`./src/${filename}`)
|
|
18
|
+
let ext = path.extname(filename)
|
|
19
|
+
|
|
20
|
+
return insertString(filename, `.${hash}`, -ext.length)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function insertString(original: string, insert: string, index: number) {
|
|
24
|
+
let result = original.slice(0, index) + insert + original.slice(index)
|
|
25
|
+
return result
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function glob(globString: string, targetDirectory: string) {
|
|
29
|
+
let files: string[] = []
|
|
30
|
+
for await (const file of Glob(globString, { cwd: targetDirectory })) {
|
|
31
|
+
files.push(file)
|
|
32
|
+
}
|
|
33
|
+
return files
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function findHashedFile(
|
|
37
|
+
filename: string,
|
|
38
|
+
ext: string,
|
|
39
|
+
targetDirectory: string) {
|
|
40
|
+
|
|
41
|
+
let parsed = path.parse(filename)
|
|
42
|
+
for await (const file of Glob(`**/${parsed.dir}/${parsed.name}.*${ext}`, { cwd: targetDirectory })) {
|
|
43
|
+
return file
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getHash(content: string) {
|
|
48
|
+
return crypto.createHash('sha1').update(content).digest('hex').slice(0, 8)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function write(filename: string, text: string) {
|
|
52
|
+
let directory = path.dirname(filename)
|
|
53
|
+
if (!existsSync(directory)) {
|
|
54
|
+
await mkdir(directory, { recursive: true })
|
|
55
|
+
}
|
|
56
|
+
await writeFile(filename, text, 'utf-8')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function makeUrlPath(filename: string | undefined) {
|
|
60
|
+
if (filename == undefined) {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if (filename.startsWith("/")) {
|
|
64
|
+
return filename
|
|
65
|
+
}
|
|
66
|
+
if (filename.startsWith("./")) {
|
|
67
|
+
return filename.slice(1)
|
|
68
|
+
}
|
|
69
|
+
return `/${filename}`
|
|
70
|
+
}
|
|
71
|
+
|
package/bin/start.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { cp, rm, mkdir } from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import esbuild from "esbuild"
|
|
5
|
+
|
|
6
|
+
import { transformExportToReturn, updateFileMapper, updateHTML } from "./lib/esbuild-plugins.ts"
|
|
7
|
+
import { addHash, glob } from "./lib/system.ts"
|
|
8
|
+
import { Arguments } from "./lib/arguments.ts"
|
|
9
|
+
|
|
10
|
+
let argv = new Arguments()
|
|
11
|
+
|
|
12
|
+
if (argv.isHelp) {
|
|
13
|
+
console.log("Usage: node --experimental-strip-types start.ts [options]")
|
|
14
|
+
console.log("Options:")
|
|
15
|
+
console.log(" -e, --env, --environment Environment (dev, server, prod)")
|
|
16
|
+
console.log(" -p, --port Port (default: 3000)")
|
|
17
|
+
console.log(" -t, --target Target directory (default: ./public)")
|
|
18
|
+
console.log(" -h, --help Help")
|
|
19
|
+
process.exit(0)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let targetDirectory = argv.targetDirectory
|
|
23
|
+
let isProd = argv.isProd
|
|
24
|
+
|
|
25
|
+
console.time("Cleaning")
|
|
26
|
+
await rm(targetDirectory, { recursive: true, force: true })
|
|
27
|
+
console.timeEnd("Cleaning")
|
|
28
|
+
|
|
29
|
+
console.time("Copying Files")
|
|
30
|
+
await mkdir(targetDirectory, { recursive: true })
|
|
31
|
+
let filesToCopy = await glob("**/*.{ico,png,svg,json}", "./src")
|
|
32
|
+
let copyFiles = await glob("**/*.min.*", "./src")
|
|
33
|
+
filesToCopy.push(...copyFiles)
|
|
34
|
+
let hashedFiles = await Promise.all(filesToCopy.map(x => addHash(x)))
|
|
35
|
+
await Promise.all(filesToCopy.map((filename, i) => {
|
|
36
|
+
let source = path.join("src", filename)
|
|
37
|
+
let target = path.join(targetDirectory, hashedFiles[i])
|
|
38
|
+
return cp(source, target, { recursive: true })
|
|
39
|
+
}))
|
|
40
|
+
console.timeEnd("Copying Files")
|
|
41
|
+
|
|
42
|
+
// Bundled modules
|
|
43
|
+
const bundleConfig = {
|
|
44
|
+
entryPoints: [
|
|
45
|
+
"./src/**/*.bundle.ts",
|
|
46
|
+
],
|
|
47
|
+
entryNames: "[dir]/[name].[hash]",
|
|
48
|
+
bundle: true,
|
|
49
|
+
format: "esm",
|
|
50
|
+
minify: isProd,
|
|
51
|
+
outbase: "src",
|
|
52
|
+
outdir: targetDirectory,
|
|
53
|
+
plugins: [
|
|
54
|
+
updateFileMapper(targetDirectory),
|
|
55
|
+
],
|
|
56
|
+
target: "es2023",
|
|
57
|
+
// logLevel: "debug",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// IIFEs
|
|
61
|
+
const iifeConfig = {
|
|
62
|
+
entryPoints: [
|
|
63
|
+
"./src/**/*.global.ts",
|
|
64
|
+
"./src/web/sw.ts",
|
|
65
|
+
],
|
|
66
|
+
entryNames: "[dir]/[name].[hash]",
|
|
67
|
+
bundle: true,
|
|
68
|
+
format: "iife",
|
|
69
|
+
minify: isProd,
|
|
70
|
+
outbase: "src",
|
|
71
|
+
outdir: targetDirectory,
|
|
72
|
+
plugins: [
|
|
73
|
+
updateFileMapper(targetDirectory),
|
|
74
|
+
],
|
|
75
|
+
target: "es2023",
|
|
76
|
+
// logLevel: "debug",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Static JS files
|
|
80
|
+
let staticFiles = await glob("**/js/*.{js,ts}", "./src")
|
|
81
|
+
let isStaticFile = /.*\/[0-9a-zA-Z\-]+.[jt]s/
|
|
82
|
+
let staticEntryPoints =
|
|
83
|
+
staticFiles
|
|
84
|
+
.filter(x =>
|
|
85
|
+
isStaticFile.test(x)
|
|
86
|
+
&& !x.includes(".bundle.")
|
|
87
|
+
&& !x.includes(".min.")
|
|
88
|
+
&& !x.includes("sw.ts"))
|
|
89
|
+
.map(x => `./src/${x}`)
|
|
90
|
+
|
|
91
|
+
const staticFileConfig = {
|
|
92
|
+
entryPoints: [
|
|
93
|
+
"./src/**/*.css",
|
|
94
|
+
...staticEntryPoints,
|
|
95
|
+
],
|
|
96
|
+
entryNames: "[dir]/[name].[hash]",
|
|
97
|
+
bundle: true,
|
|
98
|
+
format: "esm",
|
|
99
|
+
minify: isProd,
|
|
100
|
+
outbase: "src",
|
|
101
|
+
outdir: targetDirectory,
|
|
102
|
+
plugins: [
|
|
103
|
+
updateHTML(targetDirectory),
|
|
104
|
+
updateFileMapper(targetDirectory),
|
|
105
|
+
],
|
|
106
|
+
target: "es2023",
|
|
107
|
+
external: ["*"],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Pages
|
|
111
|
+
const pagesConfig = {
|
|
112
|
+
entryPoints: [
|
|
113
|
+
"./src/**/*.page.ts",
|
|
114
|
+
],
|
|
115
|
+
entryNames: "[dir]/[name].[hash]",
|
|
116
|
+
bundle: true,
|
|
117
|
+
format: "esm",
|
|
118
|
+
minify: isProd,
|
|
119
|
+
outbase: "src",
|
|
120
|
+
outdir: targetDirectory,
|
|
121
|
+
plugins: [
|
|
122
|
+
transformExportToReturn(targetDirectory),
|
|
123
|
+
updateFileMapper(targetDirectory),
|
|
124
|
+
],
|
|
125
|
+
target: "es2023",
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const configs = [
|
|
129
|
+
bundleConfig,
|
|
130
|
+
iifeConfig,
|
|
131
|
+
staticFileConfig,
|
|
132
|
+
pagesConfig,
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
if (isProd) {
|
|
136
|
+
console.time("Building")
|
|
137
|
+
// @ts-ignore
|
|
138
|
+
await Promise.all(configs.map(x => esbuild.build(x)))
|
|
139
|
+
console.timeEnd("Building")
|
|
140
|
+
} else {
|
|
141
|
+
console.time("Watching")
|
|
142
|
+
// @ts-ignore
|
|
143
|
+
let contexts = await Promise.all(configs.map(x => esbuild.context(x)))
|
|
144
|
+
for (let i = 0; i < contexts.length; i++) {
|
|
145
|
+
let ctx = contexts[i]
|
|
146
|
+
if (i === 0) {
|
|
147
|
+
ctx.serve({ port: argv.port, servedir: targetDirectory, host: "localhost" })
|
|
148
|
+
} else {
|
|
149
|
+
ctx.watch()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
console.timeEnd("Watching")
|
|
153
|
+
}
|
|
154
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<title>Loading…</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
|
|
11
|
+
<p>Loading…</p>
|
|
12
|
+
|
|
13
|
+
<script>
|
|
14
|
+
|
|
15
|
+
(async () => {
|
|
16
|
+
if ('serviceWorker' in navigator) {
|
|
17
|
+
let registration = await navigator.serviceWorker.register(`/web/sw.js`)
|
|
18
|
+
registration.installing.addEventListener('statechange', event => {
|
|
19
|
+
if (event.target.state === 'installed') {
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
document.location.reload()
|
|
22
|
+
}, 100)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
} else {
|
|
26
|
+
document.body.innerHTML = `<p>Service worker is not supported. Please
|
|
27
|
+
use a browser which supports service workers.</p>`
|
|
28
|
+
}
|
|
29
|
+
})()
|
|
30
|
+
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
35
|
+
|
package/lib/arguments.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import argvGenerator from "minimist"
|
|
2
|
+
|
|
3
|
+
export class Arguments {
|
|
4
|
+
argv: Argv
|
|
5
|
+
constructor() {
|
|
6
|
+
this.argv = argvGenerator(process.argv.slice(2)) as Argv
|
|
7
|
+
}
|
|
8
|
+
get _() {
|
|
9
|
+
return this.argv._
|
|
10
|
+
}
|
|
11
|
+
get environment() {
|
|
12
|
+
return this.argv.e || this.argv.env || "dev"
|
|
13
|
+
}
|
|
14
|
+
get port() {
|
|
15
|
+
return this.argv.p || this.argv.port || 3000
|
|
16
|
+
}
|
|
17
|
+
get targetDirectory() {
|
|
18
|
+
return this.argv.t || this.argv.target || "./public"
|
|
19
|
+
}
|
|
20
|
+
get help() {
|
|
21
|
+
return this.argv.h || this.argv.help
|
|
22
|
+
}
|
|
23
|
+
get isHelp() {
|
|
24
|
+
return this.help
|
|
25
|
+
}
|
|
26
|
+
get isProd() {
|
|
27
|
+
return this.environment === "prod"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Argv {
|
|
32
|
+
_: string[]
|
|
33
|
+
// Environment
|
|
34
|
+
e: "dev" | "server" | "prod"
|
|
35
|
+
env: "dev" | "server" | "prod"
|
|
36
|
+
// Port
|
|
37
|
+
p: number
|
|
38
|
+
port: number
|
|
39
|
+
// Target directory
|
|
40
|
+
t: string
|
|
41
|
+
target: string
|
|
42
|
+
// Help
|
|
43
|
+
h: boolean
|
|
44
|
+
help: boolean
|
|
45
|
+
}
|
|
46
|
+
|
package/lib/db.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { get, getMany, setMany, set as set1, update as update1, clear } from "idb-keyval"
|
|
2
|
+
|
|
3
|
+
const _updated =
|
|
4
|
+
async (key: IDBValidKey) => {
|
|
5
|
+
await update1("updated", (val?: Updated) => {
|
|
6
|
+
if (Array.isArray(key)) {
|
|
7
|
+
key = JSON.stringify(key)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// If key is not string or number then make it a string.
|
|
11
|
+
if (typeof key !== "string" && typeof key !== "number") {
|
|
12
|
+
key = key.toString()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return (val || new Set).add(key)
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DBSet<DBAccessors extends any> {
|
|
20
|
+
<K extends keyof DBAccessors>(key: K, value: DBAccessors[K], sync?: boolean): Promise<void>
|
|
21
|
+
<T>(key: string, value: T, sync?: boolean): Promise<void>
|
|
22
|
+
(key: string | any[], value: any, sync?: boolean): Promise<void>
|
|
23
|
+
}
|
|
24
|
+
const set: DBSet<any> =
|
|
25
|
+
async function(key: any, value: any, sync = true) {
|
|
26
|
+
if (sync && "_rev" in value) {
|
|
27
|
+
if ("_rev" in value) {
|
|
28
|
+
await _updated(key)
|
|
29
|
+
} else {
|
|
30
|
+
return Promise.reject(`Revision number not specified! For "${key}".`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
await set1(key, value)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DBUpdate<DBAccessors extends any> {
|
|
38
|
+
<K extends keyof DBAccessors>(key: K, f: (val: DBAccessors[K]) => DBAccessors[K], options?: { sync: boolean }): Promise<void>
|
|
39
|
+
<T>(key: string, f: (val: T) => T, options?: { sync: boolean }): Promise<void>
|
|
40
|
+
(key: string, f: (v: any) => any, options?: { sync: boolean }): Promise<void>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const update: DBUpdate<any> =
|
|
44
|
+
async function update(key: any, f: any, options = { sync: true }) {
|
|
45
|
+
await update1(key, f)
|
|
46
|
+
if (options.sync) {
|
|
47
|
+
let o: any = await get<any>(key)
|
|
48
|
+
if (o && "_rev" in o) {
|
|
49
|
+
await _updated(key)
|
|
50
|
+
} else {
|
|
51
|
+
Promise.reject(`Revision number not found for "${key}".`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { update, set, get, getMany, setMany, clear }
|
|
57
|
+
|
|
58
|
+
export interface DBGet<DBAccessors extends any> {
|
|
59
|
+
<K extends keyof DBAccessors>(key: K): Promise<DBAccessors[K] | undefined>
|
|
60
|
+
<T>(key: string): Promise<T | undefined>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type Updated = Set<IDBValidKey>
|
|
64
|
+
|
|
65
|
+
export interface Revision { _rev: number }
|
|
66
|
+
|
|
67
|
+
export type FormReturn<T> = { [key in keyof T]: string|undefined }
|
|
68
|
+
|
|
69
|
+
export type Theme = "light" | "dark" | "system"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// https://deanhume.com/displaying-a-new-version-available-progressive-web-app/
|
|
2
|
+
let refreshing = false
|
|
3
|
+
// The event listener that is fired when the service worker updates
|
|
4
|
+
// Here we reload the page
|
|
5
|
+
navigator.serviceWorker.addEventListener('controllerchange', function () {
|
|
6
|
+
if (refreshing) return
|
|
7
|
+
window.location.reload()
|
|
8
|
+
refreshing = true
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
/// Checks if there is a new version of the app available
|
|
12
|
+
/// @param fn - Callback function to be called when a new version is available
|
|
13
|
+
/// @returns void
|
|
14
|
+
/// @example
|
|
15
|
+
/// notifier((state, worker) => {
|
|
16
|
+
/// if (state !== "waiting") {
|
|
17
|
+
/// // Show notification
|
|
18
|
+
/// }
|
|
19
|
+
/// if (/* user confirms update */) {
|
|
20
|
+
/// worker.postMessage("skipWaiting")
|
|
21
|
+
/// }
|
|
22
|
+
/// })
|
|
23
|
+
export function notifier(fn: (state: "" | "waiting", worker: ServiceWorker) => void) {
|
|
24
|
+
let newWorker: ServiceWorker | undefined | null
|
|
25
|
+
|
|
26
|
+
if ('serviceWorker' in navigator) {
|
|
27
|
+
// Register the service worker
|
|
28
|
+
navigator.serviceWorker.register('/web/sw.js').then(reg => {
|
|
29
|
+
if ((newWorker = reg.waiting)?.state === 'installed') {
|
|
30
|
+
// @ts-ignore
|
|
31
|
+
fn("waiting", newWorker)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
reg.addEventListener('updatefound', () => {
|
|
35
|
+
// An updated service worker has appeared in reg.installing!
|
|
36
|
+
newWorker = reg.installing
|
|
37
|
+
|
|
38
|
+
newWorker?.addEventListener('statechange', () => {
|
|
39
|
+
|
|
40
|
+
// There is a new service worker available, show the notification
|
|
41
|
+
if (newWorker?.state === "installed" && navigator.serviceWorker.controller) {
|
|
42
|
+
navigator.serviceWorker.controller.postMessage("installed")
|
|
43
|
+
fn("", newWorker)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
package/lib/routes.ts
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// @ts-ignore
|
|
2
|
+
let links: { file: string, url: string }[] | undefined = self.app?.links
|
|
3
|
+
|
|
4
|
+
if (!links) {
|
|
5
|
+
console.error("Expecting links defined with `self.app.links`, but found none.")
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function redirect(req: Request) {
|
|
9
|
+
return Response.redirect(req.referrer, 303)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const searchParamsHandler = {
|
|
13
|
+
get(obj: any, prop: string) {
|
|
14
|
+
if (prop === "_url") {
|
|
15
|
+
return obj
|
|
16
|
+
}
|
|
17
|
+
return obj.searchParams.get(prop)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function searchParams<TReturn>(req: Request) : TReturn & {_url: URL} {
|
|
22
|
+
let url = new URL(req.url)
|
|
23
|
+
return new Proxy(url, searchParamsHandler)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ResponseOptions {
|
|
27
|
+
handleErrors?: Function
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export let options: ResponseOptions = {}
|
|
31
|
+
|
|
32
|
+
// Test if value is Async Generator
|
|
33
|
+
let isHtml = (value: any) =>
|
|
34
|
+
value?.next instanceof Function
|
|
35
|
+
&& value.throw instanceof Function
|
|
36
|
+
|
|
37
|
+
// @ts-ignore
|
|
38
|
+
export async function getResponse(event: FetchEvent): Promise<Response> {
|
|
39
|
+
try {
|
|
40
|
+
const req : Request = event.request
|
|
41
|
+
const url = normalizeUrl(req.url)
|
|
42
|
+
return (
|
|
43
|
+
!url.pathname.startsWith("/web/")
|
|
44
|
+
? fetch(req)
|
|
45
|
+
: executeHandler({ url, req, event }))
|
|
46
|
+
} catch(error) {
|
|
47
|
+
console.error("Get Response Error", error)
|
|
48
|
+
return new Response("Oops something happened which shouldn't have!")
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getErrors(errors: any): string[] {
|
|
53
|
+
return typeof errors === "string"
|
|
54
|
+
? [errors]
|
|
55
|
+
: call(options.handleErrors, errors) ?? []
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isHtmf(req: Request) {
|
|
59
|
+
return req.headers.has("HF-Request")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function htmfHeader(req: Request, events: any = {}, messages: string[] = [])
|
|
63
|
+
: Record<string, string> {
|
|
64
|
+
if (!isHtmf(req)) return {}
|
|
65
|
+
let userMessages =
|
|
66
|
+
messages?.length > 0
|
|
67
|
+
? { "user-messages": messages }
|
|
68
|
+
: null
|
|
69
|
+
return {
|
|
70
|
+
"hf-events": JSON.stringify({
|
|
71
|
+
...userMessages,
|
|
72
|
+
...(events || {})
|
|
73
|
+
}) ?? "{}" }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function call(fn: Function | undefined, args: any) {
|
|
77
|
+
return fn instanceof Function && fn(args)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const methodTypes = ['get', 'post'] as const
|
|
81
|
+
type MethodTypes = typeof methodTypes[number] | null
|
|
82
|
+
|
|
83
|
+
function isMethod(method: unknown) {
|
|
84
|
+
if (typeof method === "string" && methodTypes.includes(<any>method)) {
|
|
85
|
+
return method as MethodTypes
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let cache = new Map<string, any>()
|
|
91
|
+
export async function findRoute(url: URL, method: unknown) {
|
|
92
|
+
let validMethod : MethodTypes = isMethod(method)
|
|
93
|
+
if (validMethod) {
|
|
94
|
+
// @ts-ignore
|
|
95
|
+
if (!self.app?.routes) {
|
|
96
|
+
console.error("Expecting routes defined with `self.app.routes`, but found none.")
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
// @ts-ignore
|
|
100
|
+
for (const r of self.app.routes) {
|
|
101
|
+
if (r.file
|
|
102
|
+
&& (r.route instanceof RegExp && r.route.test(url.pathname)
|
|
103
|
+
|| (r.route instanceof Function && r.route(url)))) {
|
|
104
|
+
let file = links?.find(x => x.url === r.file)?.file
|
|
105
|
+
// Load file
|
|
106
|
+
if (!file) {
|
|
107
|
+
console.error(`"${r.file}" not found in links!`)
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
if (!cache.has(file)) {
|
|
111
|
+
let cachedResponse = await cacheResponse(file)
|
|
112
|
+
if (!cacheResponse) {
|
|
113
|
+
console.error(`"${file}" not found in cache!`)
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
let text = await cachedResponse.text()
|
|
117
|
+
let func = new Function(text)()
|
|
118
|
+
cache.set(file, func)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let routeDef = cache.get(file)
|
|
122
|
+
if (routeDef[validMethod]) {
|
|
123
|
+
return routeDef[validMethod]
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (r[validMethod]
|
|
128
|
+
&& (r.route instanceof RegExp && r.route.test(url.pathname)
|
|
129
|
+
|| (r.route instanceof Function && r.route(url)))) {
|
|
130
|
+
return r[validMethod]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface ExectuteHandlerOptions {
|
|
138
|
+
url: URL
|
|
139
|
+
req: Request
|
|
140
|
+
// @ts-ignore
|
|
141
|
+
event: FetchEvent
|
|
142
|
+
}
|
|
143
|
+
async function executeHandler({ url, req, event }: ExectuteHandlerOptions) : Promise<Response> {
|
|
144
|
+
let method = req.method.toLowerCase()
|
|
145
|
+
let isPost = method === "post"
|
|
146
|
+
if (!isPost && !url.pathname.endsWith("/")) return cacheResponse(url.pathname, event)
|
|
147
|
+
|
|
148
|
+
let handlers =
|
|
149
|
+
<RouteHandler<RouteGetArgs | RoutePostArgs> | null>
|
|
150
|
+
await findRoute(url, method)
|
|
151
|
+
|
|
152
|
+
// @ts-ignore
|
|
153
|
+
if (handlers) {
|
|
154
|
+
try {
|
|
155
|
+
let messages: string[] = []
|
|
156
|
+
const data = await getData(req)
|
|
157
|
+
let query = searchParams<{ handler?: string }>(req)
|
|
158
|
+
let args = { req, data, query }
|
|
159
|
+
let result = await (
|
|
160
|
+
handlers instanceof Function
|
|
161
|
+
? handlers(args)
|
|
162
|
+
: (call(handlers[query.handler ?? ""], args)
|
|
163
|
+
|| call(handlers[method], args)
|
|
164
|
+
|| Promise.reject("I'm sorry, I didn't understand where to route your request.")))
|
|
165
|
+
|
|
166
|
+
if (!result) {
|
|
167
|
+
return isPost
|
|
168
|
+
? redirect(req)
|
|
169
|
+
: new Response("Not Found!", { status: 404 })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (isPost && result.message == null) {
|
|
173
|
+
messages.push("Saved!")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (isHtml(result)) {
|
|
177
|
+
return streamResponse({
|
|
178
|
+
body: result,
|
|
179
|
+
headers: htmfHeader(req, null, messages)
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (result.message?.length > 0) {
|
|
184
|
+
messages.push(result.message)
|
|
185
|
+
} else if (result.messages?.length > 0) {
|
|
186
|
+
messages.push(...result.messages)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
result.headers = {
|
|
190
|
+
...htmfHeader(req, result.events, messages),
|
|
191
|
+
...result.headers
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (isHtml(result.body)) {
|
|
195
|
+
return streamResponse(result)
|
|
196
|
+
} else {
|
|
197
|
+
if ("json" in result) {
|
|
198
|
+
result.body = JSON.stringify(result.json)
|
|
199
|
+
result.headers = {
|
|
200
|
+
...result.headers,
|
|
201
|
+
"content-type": "application/json"
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return new Response(result.body, {
|
|
205
|
+
status: result.status ?? 200,
|
|
206
|
+
headers: result.headers
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error(`"${method}" error:`, error, "\nURL:", url);
|
|
212
|
+
if (!isHtmf(req)) {
|
|
213
|
+
if (isPost) {
|
|
214
|
+
return redirect(req)
|
|
215
|
+
} else {
|
|
216
|
+
return new Response("Not Found!", { status: 404 })
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
let errors : string[] = getErrors(error)
|
|
220
|
+
let headers = htmfHeader(req, {}, errors)
|
|
221
|
+
return new Response(null, {
|
|
222
|
+
status: isPost ? 400 : 500,
|
|
223
|
+
headers
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return new Response(null, {
|
|
230
|
+
status: 404,
|
|
231
|
+
headers: htmfHeader(req, {}, ["Not Found!"])
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function getData(req: Request) {
|
|
236
|
+
let o : any = {}
|
|
237
|
+
if (req.headers.get("content-type")?.includes("application/x-www-form-urlencoded")) {
|
|
238
|
+
const formData = await req.formData()
|
|
239
|
+
for (let [key, val] of formData.entries()) {
|
|
240
|
+
if (key.endsWith("[]")) {
|
|
241
|
+
key = key.slice(0, -2)
|
|
242
|
+
if (key in o) {
|
|
243
|
+
o[key].push(val)
|
|
244
|
+
} else {
|
|
245
|
+
o[key] = [val]
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
o[key] = val
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} else if (req.headers.get("Content-Type")?.includes("json")) {
|
|
252
|
+
o = await req.json()
|
|
253
|
+
}
|
|
254
|
+
return o
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function cacheResponse(url: string, event?: { request: string | Request } | undefined) : Promise<Response> {
|
|
258
|
+
url = links?.find(x => x.url === url)?.file || url
|
|
259
|
+
const match = await caches.match(url)
|
|
260
|
+
if (match) return match
|
|
261
|
+
const res = await fetch(event?.request || url)
|
|
262
|
+
if (!res || res.status !== 200 || res.type !== "basic") return res
|
|
263
|
+
const responseToCache = res.clone()
|
|
264
|
+
// @ts-ignore
|
|
265
|
+
let version: string = self.app?.version
|
|
266
|
+
?? (console.warn("The version number is not available, expected glboal value `self.app.version`."), "")
|
|
267
|
+
const cache = await caches.open(version)
|
|
268
|
+
cache.put(url, responseToCache)
|
|
269
|
+
return res
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const encoder = new TextEncoder()
|
|
273
|
+
function streamResponse(response: { body: Generator, headers?: any }) : Response {
|
|
274
|
+
let { body, headers } = response
|
|
275
|
+
const stream = new ReadableStream({
|
|
276
|
+
async start(controller : ReadableStreamDefaultController<any>) {
|
|
277
|
+
try {
|
|
278
|
+
for await (let x of body) {
|
|
279
|
+
if (typeof x === "string")
|
|
280
|
+
controller.enqueue(encoder.encode(x))
|
|
281
|
+
}
|
|
282
|
+
controller.close()
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.error(error)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
return new Response(stream, {
|
|
290
|
+
headers: {
|
|
291
|
+
"content-type": "text/html; charset=utf-8",
|
|
292
|
+
...headers,
|
|
293
|
+
}})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* /my/url -> /my/url/
|
|
298
|
+
* /my/script.js -> /my/script.js
|
|
299
|
+
*/
|
|
300
|
+
function normalizeUrl(url: string) : URL {
|
|
301
|
+
let uri = new URL(url)
|
|
302
|
+
let path = uri.pathname
|
|
303
|
+
!uri.pathname.endsWith("/") && (uri.pathname = isFile(path) ? path : path+"/")
|
|
304
|
+
return uri
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isFile(s: string) {
|
|
308
|
+
return s.lastIndexOf("/") < s.lastIndexOf(".")
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface RouteGetArgs {
|
|
312
|
+
req: Request
|
|
313
|
+
query: any
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface RoutePostArgs {
|
|
317
|
+
query: any
|
|
318
|
+
data: any
|
|
319
|
+
req: Request
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface RouteObjectReturn {
|
|
323
|
+
body?: string | AsyncGenerator | null
|
|
324
|
+
status?: number
|
|
325
|
+
headers?: any
|
|
326
|
+
events?: any
|
|
327
|
+
message?: string
|
|
328
|
+
messages?: string[]
|
|
329
|
+
json?: any
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export interface RouteHandler<T> {
|
|
333
|
+
(options: T): Promise<
|
|
334
|
+
AsyncGenerator
|
|
335
|
+
| Response
|
|
336
|
+
| RouteObjectReturn
|
|
337
|
+
| undefined
|
|
338
|
+
| null
|
|
339
|
+
| void >
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface RoutePostHandler {
|
|
343
|
+
[handler: string]: RouteHandler<RoutePostArgs>
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface RouteGetHandler {
|
|
347
|
+
[handler: string]: RouteHandler<RouteGetArgs>
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
interface Route_ {
|
|
351
|
+
route: RegExp | ((a: URL) => boolean)
|
|
352
|
+
file?: string
|
|
353
|
+
get?: RouteHandler<RouteGetArgs> | RouteGetHandler
|
|
354
|
+
post?: RouteHandler<RoutePostArgs> | RoutePostHandler
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
|
|
358
|
+
Pick<T, Exclude<keyof T, Keys>>
|
|
359
|
+
& {
|
|
360
|
+
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>
|
|
361
|
+
}[Keys]
|
|
362
|
+
|
|
363
|
+
export type Route = RequireAtLeastOne<Route_, "file" | "get" | "post">
|
|
364
|
+
export type RoutePage = Pick<Route_, "get" | "post">
|
|
365
|
+
|
|
366
|
+
|
package/lib/utils.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function when<S, T>(b: S | undefined, s: (a: S) => T): T | undefined
|
|
2
|
+
export function when<T>(b: any, s: T): T | undefined
|
|
3
|
+
export function when(b: any, s: any) {
|
|
4
|
+
return b
|
|
5
|
+
? (s instanceof Function && s.length ? s(b) : s)
|
|
6
|
+
: typeof s === "string"
|
|
7
|
+
? ""
|
|
8
|
+
: undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class DbCache {
|
|
12
|
+
#cache: Map<string, any>
|
|
13
|
+
constructor() {
|
|
14
|
+
this.#cache = new Map()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async get<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
18
|
+
if (this.#cache.has(key)) {
|
|
19
|
+
return this.#cache.get(key)
|
|
20
|
+
}
|
|
21
|
+
let value = await fn()
|
|
22
|
+
this.#cache.set(key, value)
|
|
23
|
+
return value
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
export async function reject(s: string | string[]) {
|
|
2
|
+
return Promise.reject(s)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
interface Value<T> {
|
|
6
|
+
value: T
|
|
7
|
+
}
|
|
8
|
+
interface String_ extends Value<string> {
|
|
9
|
+
}
|
|
10
|
+
export interface String100 extends String_ {}
|
|
11
|
+
export interface String50 extends String_ {}
|
|
12
|
+
export type TableType = string
|
|
13
|
+
export interface IDType<T extends TableType> extends Value<number> {
|
|
14
|
+
_id: T
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const notFalsey = async (error: string, val: string | undefined) =>
|
|
18
|
+
!val ? reject(error) : val
|
|
19
|
+
|
|
20
|
+
const maxLength = async (error: string, val: string, maxLength: number) =>
|
|
21
|
+
(val.length > maxLength)
|
|
22
|
+
? reject(error)
|
|
23
|
+
: val
|
|
24
|
+
|
|
25
|
+
const createString = async (name: string, maxLength_: number, val?: string | undefined) => {
|
|
26
|
+
const trimmed = await notFalsey(`"${name}" is required.`, val?.trim())
|
|
27
|
+
const s = await maxLength(`'${name}' must be less than ${maxLength_} characters.`, trimmed, maxLength_)
|
|
28
|
+
return <string>s
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isInteger(val: number) {
|
|
32
|
+
try {
|
|
33
|
+
BigInt(val)
|
|
34
|
+
return true
|
|
35
|
+
} catch(e) {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function createNumber(name: string, val: number | string) : Promise<number> {
|
|
41
|
+
let num = +val
|
|
42
|
+
if (isNaN(num)) {
|
|
43
|
+
return reject(`'${name}' was expecting a number but was given ${val}`)
|
|
44
|
+
}
|
|
45
|
+
return num
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createPositiveNumber(name: string) {
|
|
49
|
+
return async function (val: number | string | undefined | null) {
|
|
50
|
+
if (val == null) return reject(`'${name}' is required.`)
|
|
51
|
+
let num = await createNumber(name, val)
|
|
52
|
+
if (num < 0) return reject(`'${name}' must be 0 or greater. But was given '${val}'.`)
|
|
53
|
+
return num
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createPositiveWholeNumber(name: string) : (val: number | string | undefined | null) => Promise<number> {
|
|
58
|
+
return async (val: number | string | undefined | null) => {
|
|
59
|
+
let num = await createPositiveNumber(name)(val)
|
|
60
|
+
if (!isInteger(num)) return reject(`${name} must be a whole number. But was given '${num}' and was expecting '${num|0}'.`)
|
|
61
|
+
return num
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createArrayOf<S, T>(fn: (val: S) => Promise<T>) : (val: S[]) => Promise<T[]> {
|
|
66
|
+
return async (values: S[]) => {
|
|
67
|
+
let result = Promise.all(values.map(fn))
|
|
68
|
+
return result
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createIdNumber(name: string) : (val: number | string) => Promise<number> {
|
|
73
|
+
return async (val: number | string) => {
|
|
74
|
+
let wholeNumber = await createPositiveWholeNumber(name)(val)
|
|
75
|
+
if (wholeNumber < 1) return reject(`'${name}' must be 1 or greater. But was given '${val}'.`)
|
|
76
|
+
return wholeNumber
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const maybe =
|
|
81
|
+
<T, S>(f: (val: T) => Promise<S>) =>
|
|
82
|
+
(val: T | undefined | null) =>
|
|
83
|
+
val == null || val === "" ? Promise.resolve(void 0) : f(val)
|
|
84
|
+
|
|
85
|
+
export const createDateString =
|
|
86
|
+
(name: string) =>
|
|
87
|
+
(val: string | undefined) : Promise<string> =>
|
|
88
|
+
/\d{4}-[01]\d-[0123]\d/.test(val ?? "")
|
|
89
|
+
? Promise.resolve(<string>val)
|
|
90
|
+
: reject(`'${name}' must be a valid date string. But was given '${val}'.`)
|
|
91
|
+
|
|
92
|
+
export const createTimeString =
|
|
93
|
+
(name: string) =>
|
|
94
|
+
(val: string | undefined) : Promise<string> =>
|
|
95
|
+
/\d{2}:\d{2}/.test(val ?? "")
|
|
96
|
+
? Promise.resolve(<string>val)
|
|
97
|
+
: reject(`'${name}' must be a valid time string. But was given '${val}'.`)
|
|
98
|
+
|
|
99
|
+
export const createDateTimeString =
|
|
100
|
+
(name: string) =>
|
|
101
|
+
(val: string | undefined) : Promise<string> => {
|
|
102
|
+
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(val ?? "")) {
|
|
103
|
+
return Promise.resolve(<string>val)
|
|
104
|
+
}
|
|
105
|
+
return reject(`'${name}' must be a valid date time string. But was given '${val}'.`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const createString25 =
|
|
109
|
+
(name: string) =>
|
|
110
|
+
(val: string | undefined) =>
|
|
111
|
+
createString(name, 25, val)
|
|
112
|
+
|
|
113
|
+
export const createString50 =
|
|
114
|
+
(name: string) =>
|
|
115
|
+
(val: string | undefined) =>
|
|
116
|
+
createString(name, 50, val)
|
|
117
|
+
|
|
118
|
+
export const createStringInfinity =
|
|
119
|
+
(name: string) =>
|
|
120
|
+
(val: string | undefined) =>
|
|
121
|
+
createString(name, Infinity, val)
|
|
122
|
+
|
|
123
|
+
export function createCheckbox(val: string | undefined) {
|
|
124
|
+
return Promise.resolve(val === "on")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type Nullable<T> = T | undefined | null
|
|
128
|
+
export async function required<T>(o: Nullable<T>, message: string): Promise<T> {
|
|
129
|
+
if (!o) return reject(message)
|
|
130
|
+
return o
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class Assert {
|
|
134
|
+
isFalse(value: boolean, message: string) {
|
|
135
|
+
return !value ? Promise.resolve() : reject(message)
|
|
136
|
+
}
|
|
137
|
+
isTrue(value: boolean, message: string) {
|
|
138
|
+
return this.isFalse(!value, message)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export const assert = new Assert()
|
|
142
|
+
|
|
143
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jon49/sw",
|
|
3
|
+
"version": "0.12.9",
|
|
4
|
+
"description": "Packages for MVC service workers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"lib",
|
|
8
|
+
"bin"
|
|
9
|
+
],
|
|
10
|
+
"exports": {
|
|
11
|
+
"./routes.js": "./lib/routes.ts",
|
|
12
|
+
"./new-app-notifier.js": "./lib/new-app-notifier.ts",
|
|
13
|
+
"./validation.js": "./lib/validation.ts",
|
|
14
|
+
"./utils.js": "./lib/utils.ts",
|
|
15
|
+
"./db.js": "./lib/db.ts"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"service-worker",
|
|
22
|
+
"MVC"
|
|
23
|
+
],
|
|
24
|
+
"author": "Jon Nyman",
|
|
25
|
+
"license": "ISC",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/jon49/jon49-sw.git"
|
|
29
|
+
},
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/jon49/jon49-sw/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/jon49/jon49-sw#readme",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"esbuild": "^0.24.0",
|
|
36
|
+
"idb-keyval": "^6.2.1",
|
|
37
|
+
"minimist": "^1.2.8"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/minimist": "^1.2.5",
|
|
41
|
+
"@types/node": "^22.7.7",
|
|
42
|
+
"typescript": "^5.6.3"
|
|
43
|
+
}
|
|
44
|
+
}
|