@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.
@@ -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&hellip;</title>
8
+ </head>
9
+ <body>
10
+
11
+ <p>Loading&hellip;</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
+
@@ -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
+ }