jq79 0.3.27 → 0.3.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dev/cli.ts +57 -0
- package/dev/dev.ts +252 -0
- package/{src → dev}/vite.ts +10 -16
- package/dist/cli.js +202 -0
- package/dist/dev.cjs +186 -0
- package/dist/dev.cjs.map +1 -0
- package/dist/dev.d.ts +12 -0
- package/dist/dev.js +162 -0
- package/dist/dev.js.map +1 -0
- package/dist/jq79.cjs +8 -8
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +3 -0
- package/dist/jq79.global.js +8 -8
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +8 -8
- package/dist/jq79.js.map +1 -1
- package/dist/reactive.d.ts +1 -0
- package/dist/vite.cjs +5 -11
- package/dist/vite.cjs.map +1 -1
- package/dist/vite.js +5 -11
- package/dist/vite.js.map +1 -1
- package/package.json +14 -2
- package/src/jq79.ts +142 -0
- package/src/reactive.ts +53 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
[](https://jgermade.github.io/jq79/)
|
|
11
11
|
[](https://jgermade.github.io/jq79/)
|
|
12
12
|
|
|
13
|
-
A
|
|
13
|
+
A independent reactive component library that ships as a single file. Svelte-style reactive scripts, fine-grained DOM updates via proxy-based dependency tracking
|
|
14
14
|
|
|
15
15
|
> no compiler required, no virtual DOM, no dependencies.
|
|
16
16
|
|
|
@@ -114,6 +114,7 @@ When the fetch resolves, the assignments to `firstName`/`lastName` re-run the `$
|
|
|
114
114
|
- [Reactive data](docs/reactive-data.md) — the standalone `$reactive` store: `$on`, `$onAny`, `$effect`.
|
|
115
115
|
- [DOM helpers](docs/dom-helpers.md) — `$`, `$$` and `$create`.
|
|
116
116
|
- [Vite plugin](docs/vite-plugin.md) — importing `.html` components as bundled modules, HMR, options.
|
|
117
|
+
- [Dev server](docs/dev-server.md) — `npx jq79 dev`: serve and hot-reload components with no build step.
|
|
117
118
|
- [Development](docs/development.md) — running tests, building, publishing releases.
|
|
118
119
|
|
|
119
120
|
## License
|
package/dev/cli.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { devServer } from "./dev"
|
|
2
|
+
|
|
3
|
+
// the jq79 command. One subcommand so far:
|
|
4
|
+
//
|
|
5
|
+
// npx jq79 dev [dir] [--port 4179] [--host localhost]
|
|
6
|
+
//
|
|
7
|
+
// This is the entry point the no-bundle path deserves: someone who chose jq79
|
|
8
|
+
// to avoid a toolchain shouldn't have to write a script to get a dev server.
|
|
9
|
+
|
|
10
|
+
const USAGE = `jq79 - a mini reactive component library
|
|
11
|
+
|
|
12
|
+
usage:
|
|
13
|
+
jq79 dev [dir] serve dir (default: .) with hot reload
|
|
14
|
+
|
|
15
|
+
options:
|
|
16
|
+
-p, --port <port> port to listen on (default: 4179)
|
|
17
|
+
-H, --host <host> host to bind (default: localhost)
|
|
18
|
+
-h, --help show this message
|
|
19
|
+
`
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2)
|
|
22
|
+
const [command, ...rest] = args
|
|
23
|
+
|
|
24
|
+
if (command !== "dev" || rest.includes("-h") || rest.includes("--help")) {
|
|
25
|
+
const unknown = command && command !== "dev" && !command.startsWith("-")
|
|
26
|
+
if (unknown) console.error(`unknown command: ${command}\n`)
|
|
27
|
+
console.log(USAGE)
|
|
28
|
+
process.exit(unknown ? 1 : 0)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const options: { rootDir?: string; port?: number; host?: string } = {}
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < rest.length; i++) {
|
|
34
|
+
const arg = rest[i]
|
|
35
|
+
if (arg === "-p" || arg === "--port") options.port = Number(rest[++i])
|
|
36
|
+
else if (arg === "-H" || arg === "--host") options.host = rest[++i]
|
|
37
|
+
else if (!arg.startsWith("-")) options.rootDir ??= arg // the directory to serve
|
|
38
|
+
else {
|
|
39
|
+
console.error(`unknown option: ${arg}\n${USAGE}`)
|
|
40
|
+
process.exit(1)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (options.port !== undefined && !Number.isInteger(options.port)) {
|
|
45
|
+
console.error("--port takes a number")
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const server = await devServer(options)
|
|
50
|
+
|
|
51
|
+
console.log(`jq79 dev → ${server.url}`)
|
|
52
|
+
|
|
53
|
+
const stop = () => {
|
|
54
|
+
void server.close().then(() => process.exit(0))
|
|
55
|
+
}
|
|
56
|
+
process.on("SIGINT", stop)
|
|
57
|
+
process.on("SIGTERM", stop)
|
package/dev/dev.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http"
|
|
2
|
+
import { readFile, realpath, stat } from "node:fs/promises"
|
|
3
|
+
import { watch, type FSWatcher } from "node:fs"
|
|
4
|
+
import { basename, extname, join, relative, resolve, sep } from "node:path"
|
|
5
|
+
|
|
6
|
+
// A dev server for the no-bundle path: serve a directory of .html components
|
|
7
|
+
// over HTTP, watch it, and hot-reload the components that changed.
|
|
8
|
+
//
|
|
9
|
+
// npx jq79 dev // the CLI
|
|
10
|
+
// import { devServer } from "jq79/dev" // or from a script
|
|
11
|
+
// await devServer({ rootDir: "." })
|
|
12
|
+
//
|
|
13
|
+
// It is a static file server and nothing else - no transforms, no bundling, no
|
|
14
|
+
// module graph. Which is the point: the files it serves are the files you would
|
|
15
|
+
// deploy, so what you develop against and what a static host serves are the same
|
|
16
|
+
// bytes. The one thing it adds is the hot-reload channel, and it adds it to
|
|
17
|
+
// *documents* only (a component fetched by the runtime is served verbatim).
|
|
18
|
+
//
|
|
19
|
+
// The reload is fine-grained. On a change the server pushes the new source down
|
|
20
|
+
// an SSE channel and the runtime swaps it into every live instance of that file,
|
|
21
|
+
// keeping its data - see hotUpdate in jq79.ts. Anything the runtime can't place
|
|
22
|
+
// (a page, a stylesheet, a component nothing has mounted yet) falls back to a
|
|
23
|
+
// full page reload.
|
|
24
|
+
|
|
25
|
+
export interface DevServerOptions {
|
|
26
|
+
// the directory to serve, and to watch (default: the current directory)
|
|
27
|
+
rootDir?: string
|
|
28
|
+
// default: 4179, or the first free port after it
|
|
29
|
+
port?: number
|
|
30
|
+
// default: localhost
|
|
31
|
+
host?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DevServer {
|
|
35
|
+
url: string
|
|
36
|
+
port: number
|
|
37
|
+
close: () => Promise<void>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
41
|
+
".html": "text/html; charset=utf-8",
|
|
42
|
+
".js": "text/javascript; charset=utf-8",
|
|
43
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
44
|
+
".json": "application/json; charset=utf-8",
|
|
45
|
+
".css": "text/css; charset=utf-8",
|
|
46
|
+
".svg": "image/svg+xml",
|
|
47
|
+
".png": "image/png",
|
|
48
|
+
".jpg": "image/jpeg",
|
|
49
|
+
".jpeg": "image/jpeg",
|
|
50
|
+
".gif": "image/gif",
|
|
51
|
+
".webp": "image/webp",
|
|
52
|
+
".avif": "image/avif",
|
|
53
|
+
".ico": "image/x-icon",
|
|
54
|
+
".woff": "font/woff",
|
|
55
|
+
".woff2": "font/woff2",
|
|
56
|
+
".map": "application/json; charset=utf-8",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CLIENT_URL = "/__jq79/client.js"
|
|
60
|
+
const EVENTS_URL = "/__jq79/events"
|
|
61
|
+
|
|
62
|
+
// Served as a *classic* script, and injected into the <head>: classic scripts
|
|
63
|
+
// run at parse time and module scripts are deferred, so the flag is set before
|
|
64
|
+
// the page's `import ... from "jq79"` evaluates - which is what the runtime
|
|
65
|
+
// waits for before it starts tracking instances. The client can't import the
|
|
66
|
+
// runtime itself: the page's copy may come from a CDN or an import map, and a
|
|
67
|
+
// second copy would have a second, empty registry.
|
|
68
|
+
const CLIENT = `(() => {
|
|
69
|
+
window.__JQ79_HMR_ENABLED__ = true
|
|
70
|
+
|
|
71
|
+
const events = new EventSource(${JSON.stringify(EVENTS_URL)})
|
|
72
|
+
|
|
73
|
+
events.addEventListener("update", event => {
|
|
74
|
+
const { url, src } = JSON.parse(event.data)
|
|
75
|
+
const runtime = window.__JQ79_HMR__
|
|
76
|
+
// no runtime (the page doesn't use jq79), or no live instance from this
|
|
77
|
+
// file (it isn't mounted, or it *is* the page) - nothing to swap into
|
|
78
|
+
const patched = runtime ? runtime.update(url, src) : 0
|
|
79
|
+
if (patched) console.log("[jq79] hot-updated " + url + " (" + patched + (patched === 1 ? " instance)" : " instances)"))
|
|
80
|
+
else location.reload()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
events.addEventListener("reload", () => location.reload())
|
|
84
|
+
})()`
|
|
85
|
+
|
|
86
|
+
const posix = (path: string) => path.split(sep).join("/")
|
|
87
|
+
|
|
88
|
+
const isDocument = (req: IncomingMessage) => req.headers["sec-fetch-dest"] === "document"
|
|
89
|
+
|
|
90
|
+
// the client goes in the <head> so it is the first thing the page runs. A file
|
|
91
|
+
// with neither <head> nor <body> is still a document a browser will render, so
|
|
92
|
+
// fall back to the top of it rather than skipping the injection
|
|
93
|
+
const injectClient = (html: string): string => {
|
|
94
|
+
const tag = `<script src="${CLIENT_URL}"></script>`
|
|
95
|
+
const open = /<head[^>]*>/i.exec(html) ?? /<body[^>]*>/i.exec(html)
|
|
96
|
+
if (!open) return tag + html
|
|
97
|
+
const at = open.index + open[0].length
|
|
98
|
+
return html.slice(0, at) + tag + html.slice(at)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const devServer = async (options: DevServerOptions = {}): Promise<DevServer> => {
|
|
102
|
+
// the *real* path: the watcher reports what changed relative to the directory
|
|
103
|
+
// it actually opened, so a root reached through a symlink (/tmp and /var are
|
|
104
|
+
// symlinks on macOS) would hand back paths that don't line up with it
|
|
105
|
+
const root = await realpath(resolve(options.rootDir ?? "."))
|
|
106
|
+
const host = options.host ?? "localhost"
|
|
107
|
+
|
|
108
|
+
const clients = new Set<ServerResponse>()
|
|
109
|
+
|
|
110
|
+
const send = (event: string, data: unknown) => {
|
|
111
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
112
|
+
clients.forEach(client => client.write(frame))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- serving ---------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
const serveStatic = async (req: IncomingMessage, res: ServerResponse, pathname: string) => {
|
|
118
|
+
// a URL path is not a file path: decode it, then keep the result inside the
|
|
119
|
+
// root (".." in a request must not walk out of the served directory)
|
|
120
|
+
let file: string
|
|
121
|
+
try {
|
|
122
|
+
file = resolve(join(root, decodeURIComponent(pathname)))
|
|
123
|
+
} catch {
|
|
124
|
+
res.writeHead(400).end("bad request")
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (file !== root && !file.startsWith(root + sep)) {
|
|
128
|
+
res.writeHead(403).end("forbidden")
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
if ((await stat(file)).isDirectory()) file = join(file, "index.html")
|
|
134
|
+
const body = await readFile(file)
|
|
135
|
+
const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream"
|
|
136
|
+
|
|
137
|
+
// only a navigation gets the hot-reload client. A component is fetched by
|
|
138
|
+
// the runtime (sec-fetch-dest: empty), and it must arrive as written -
|
|
139
|
+
// injecting a <script> into it would make the runtime parse and run it
|
|
140
|
+
const html = type.startsWith("text/html") && isDocument(req)
|
|
141
|
+
const payload = html ? Buffer.from(injectClient(body.toString("utf8"))) : body
|
|
142
|
+
|
|
143
|
+
res.writeHead(200, {
|
|
144
|
+
"content-type": type,
|
|
145
|
+
"content-length": payload.byteLength,
|
|
146
|
+
"cache-control": "no-store", // the file on disk is always the truth here
|
|
147
|
+
})
|
|
148
|
+
res.end(payload)
|
|
149
|
+
} catch {
|
|
150
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("not found")
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const server: Server = createServer((req, res) => {
|
|
155
|
+
const pathname = (req.url ?? "/").split(/[?#]/)[0]
|
|
156
|
+
|
|
157
|
+
if (pathname === CLIENT_URL) {
|
|
158
|
+
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" })
|
|
159
|
+
res.end(CLIENT)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (pathname === EVENTS_URL) {
|
|
164
|
+
res.writeHead(200, {
|
|
165
|
+
"content-type": "text/event-stream",
|
|
166
|
+
"cache-control": "no-store",
|
|
167
|
+
connection: "keep-alive",
|
|
168
|
+
})
|
|
169
|
+
res.write(": jq79\n\n") // opens the stream, so the browser fires onopen
|
|
170
|
+
clients.add(res)
|
|
171
|
+
req.on("close", () => clients.delete(res))
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
void serveStatic(req, res, pathname)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// --- watching --------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
const ignored = (rel: string) =>
|
|
181
|
+
rel.split(sep).some(part => part.startsWith(".") || part === "node_modules")
|
|
182
|
+
|
|
183
|
+
// one save can arrive as several events (a rename plus a change, an editor's
|
|
184
|
+
// atomic write); collapsing per file keeps that down to one push
|
|
185
|
+
const pending = new Map<string, NodeJS.Timeout>()
|
|
186
|
+
|
|
187
|
+
const changed = async (rel: string) => {
|
|
188
|
+
const file = join(root, rel)
|
|
189
|
+
|
|
190
|
+
let src: string | null = null
|
|
191
|
+
try {
|
|
192
|
+
// a directory changes whenever anything inside it does, and the event for
|
|
193
|
+
// the file itself is already on its way - acting on both would reload the
|
|
194
|
+
// page every time a component is saved
|
|
195
|
+
if ((await stat(file)).isDirectory()) return
|
|
196
|
+
if (rel.endsWith(".html")) src = await readFile(file, "utf8")
|
|
197
|
+
} catch {
|
|
198
|
+
// gone: deleted, or renamed away - and there is nothing to swap in, so the
|
|
199
|
+
// page has to reload. Unless it was never there: macOS reports a change to
|
|
200
|
+
// the watched directory *itself* under its own basename, which resolves to
|
|
201
|
+
// a path inside it that does not exist
|
|
202
|
+
if (rel === basename(root)) return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// the url is the one the component was served from, because that is what the
|
|
206
|
+
// runtime resolves its instances' filenames against
|
|
207
|
+
const url = "/" + posix(rel)
|
|
208
|
+
if (src === null) send("reload", { url })
|
|
209
|
+
else send("update", { url, src })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const watcher: FSWatcher = watch(root, { recursive: true }, (_event, filename) => {
|
|
213
|
+
if (!filename) return
|
|
214
|
+
const rel = relative(root, resolve(root, filename.toString()))
|
|
215
|
+
if (!rel || rel.startsWith("..") || ignored(rel)) return
|
|
216
|
+
|
|
217
|
+
clearTimeout(pending.get(rel))
|
|
218
|
+
pending.set(rel, setTimeout(() => {
|
|
219
|
+
pending.delete(rel)
|
|
220
|
+
void changed(rel)
|
|
221
|
+
}, 30))
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// proxies and load balancers cut an idle stream; a comment every 30s is the
|
|
225
|
+
// conventional way to keep it open. unref'd, so it never holds the process up
|
|
226
|
+
const heartbeat = setInterval(() => clients.forEach(client => client.write(": ping\n\n")), 30_000)
|
|
227
|
+
heartbeat.unref()
|
|
228
|
+
|
|
229
|
+
// --- go --------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
await new Promise<void>((done, fail) => {
|
|
232
|
+
server.once("error", fail)
|
|
233
|
+
server.listen(options.port ?? 4179, host, done)
|
|
234
|
+
})
|
|
235
|
+
const { port } = server.address() as { port: number }
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
url: `http://${host}:${port}`,
|
|
239
|
+
port,
|
|
240
|
+
close: () =>
|
|
241
|
+
new Promise(done => {
|
|
242
|
+
clearInterval(heartbeat)
|
|
243
|
+
watcher.close()
|
|
244
|
+
pending.forEach(clearTimeout)
|
|
245
|
+
clients.forEach(client => client.end())
|
|
246
|
+
clients.clear()
|
|
247
|
+
server.close(() => done())
|
|
248
|
+
}),
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export default devServer
|
package/{src → dev}/vite.ts
RENAMED
|
@@ -118,12 +118,12 @@ const compileStyleBlocks = async (
|
|
|
118
118
|
//
|
|
119
119
|
// In dev, `hot.data` carries the exported instance across updates: importers
|
|
120
120
|
// hold a reference to the *first* module evaluation's instance, so later
|
|
121
|
-
// evaluations patch that same instance in place
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
121
|
+
// evaluations patch that same instance in place instead of exporting a new one
|
|
122
|
+
// nobody sees. The patching itself is the runtime's `hotReplace` - the same
|
|
123
|
+
// swap the jq79/dev server drives, from the one place that can reach a
|
|
124
|
+
// component's markers. An instance only used as a definition has nothing to
|
|
125
|
+
// re-render (nested clones can't be reached from this module), so it falls
|
|
126
|
+
// back to a full reload.
|
|
127
127
|
const componentModule = (source: string, include: RegExp, filename: string): string => {
|
|
128
128
|
const hoisted = hoistableImports(source, include)
|
|
129
129
|
const imports = hoisted
|
|
@@ -147,18 +147,12 @@ let component
|
|
|
147
147
|
|
|
148
148
|
if (import.meta.hot && import.meta.hot.data.component) {
|
|
149
149
|
const prior = import.meta.hot.data.component
|
|
150
|
-
const next = new Component79(src)
|
|
151
|
-
prior.template = next.template
|
|
152
|
-
prior.scripts = next.scripts
|
|
153
|
-
prior.styles = next.styles
|
|
154
150
|
prior.modules = modules
|
|
155
151
|
prior.filename = filename
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
import.meta.hot.invalidate()
|
|
161
|
-
}
|
|
152
|
+
// re-renders it where it stands, keeping its data. false means it was never
|
|
153
|
+
// rendered - a definition used only as a nested component - and a reload is
|
|
154
|
+
// the only way to reach the clones made from it
|
|
155
|
+
if (!prior.hotReplace(src) && !prior.data) import.meta.hot.invalidate()
|
|
162
156
|
component = prior
|
|
163
157
|
} else {
|
|
164
158
|
component = new Component79(src, { modules, filename })
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// dev/dev.ts
|
|
4
|
+
import { createServer } from "http";
|
|
5
|
+
import { readFile, realpath, stat } from "fs/promises";
|
|
6
|
+
import { watch } from "fs";
|
|
7
|
+
import { basename, extname, join, relative, resolve, sep } from "path";
|
|
8
|
+
var CONTENT_TYPES = {
|
|
9
|
+
".html": "text/html; charset=utf-8",
|
|
10
|
+
".js": "text/javascript; charset=utf-8",
|
|
11
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
12
|
+
".json": "application/json; charset=utf-8",
|
|
13
|
+
".css": "text/css; charset=utf-8",
|
|
14
|
+
".svg": "image/svg+xml",
|
|
15
|
+
".png": "image/png",
|
|
16
|
+
".jpg": "image/jpeg",
|
|
17
|
+
".jpeg": "image/jpeg",
|
|
18
|
+
".gif": "image/gif",
|
|
19
|
+
".webp": "image/webp",
|
|
20
|
+
".avif": "image/avif",
|
|
21
|
+
".ico": "image/x-icon",
|
|
22
|
+
".woff": "font/woff",
|
|
23
|
+
".woff2": "font/woff2",
|
|
24
|
+
".map": "application/json; charset=utf-8"
|
|
25
|
+
};
|
|
26
|
+
var CLIENT_URL = "/__jq79/client.js";
|
|
27
|
+
var EVENTS_URL = "/__jq79/events";
|
|
28
|
+
var CLIENT = `(() => {
|
|
29
|
+
window.__JQ79_HMR_ENABLED__ = true
|
|
30
|
+
|
|
31
|
+
const events = new EventSource(${JSON.stringify(EVENTS_URL)})
|
|
32
|
+
|
|
33
|
+
events.addEventListener("update", event => {
|
|
34
|
+
const { url, src } = JSON.parse(event.data)
|
|
35
|
+
const runtime = window.__JQ79_HMR__
|
|
36
|
+
// no runtime (the page doesn't use jq79), or no live instance from this
|
|
37
|
+
// file (it isn't mounted, or it *is* the page) - nothing to swap into
|
|
38
|
+
const patched = runtime ? runtime.update(url, src) : 0
|
|
39
|
+
if (patched) console.log("[jq79] hot-updated " + url + " (" + patched + (patched === 1 ? " instance)" : " instances)"))
|
|
40
|
+
else location.reload()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
events.addEventListener("reload", () => location.reload())
|
|
44
|
+
})()`;
|
|
45
|
+
var posix = (path) => path.split(sep).join("/");
|
|
46
|
+
var isDocument = (req) => req.headers["sec-fetch-dest"] === "document";
|
|
47
|
+
var injectClient = (html) => {
|
|
48
|
+
const tag = `<script src="${CLIENT_URL}"></script>`;
|
|
49
|
+
const open = /<head[^>]*>/i.exec(html) ?? /<body[^>]*>/i.exec(html);
|
|
50
|
+
if (!open) return tag + html;
|
|
51
|
+
const at = open.index + open[0].length;
|
|
52
|
+
return html.slice(0, at) + tag + html.slice(at);
|
|
53
|
+
};
|
|
54
|
+
var devServer = async (options2 = {}) => {
|
|
55
|
+
const root = await realpath(resolve(options2.rootDir ?? "."));
|
|
56
|
+
const host = options2.host ?? "localhost";
|
|
57
|
+
const clients = /* @__PURE__ */ new Set();
|
|
58
|
+
const send = (event, data) => {
|
|
59
|
+
const frame = `event: ${event}
|
|
60
|
+
data: ${JSON.stringify(data)}
|
|
61
|
+
|
|
62
|
+
`;
|
|
63
|
+
clients.forEach((client) => client.write(frame));
|
|
64
|
+
};
|
|
65
|
+
const serveStatic = async (req, res, pathname) => {
|
|
66
|
+
let file;
|
|
67
|
+
try {
|
|
68
|
+
file = resolve(join(root, decodeURIComponent(pathname)));
|
|
69
|
+
} catch {
|
|
70
|
+
res.writeHead(400).end("bad request");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (file !== root && !file.startsWith(root + sep)) {
|
|
74
|
+
res.writeHead(403).end("forbidden");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
if ((await stat(file)).isDirectory()) file = join(file, "index.html");
|
|
79
|
+
const body = await readFile(file);
|
|
80
|
+
const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
81
|
+
const html = type.startsWith("text/html") && isDocument(req);
|
|
82
|
+
const payload = html ? Buffer.from(injectClient(body.toString("utf8"))) : body;
|
|
83
|
+
res.writeHead(200, {
|
|
84
|
+
"content-type": type,
|
|
85
|
+
"content-length": payload.byteLength,
|
|
86
|
+
"cache-control": "no-store"
|
|
87
|
+
// the file on disk is always the truth here
|
|
88
|
+
});
|
|
89
|
+
res.end(payload);
|
|
90
|
+
} catch {
|
|
91
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("not found");
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const server2 = createServer((req, res) => {
|
|
95
|
+
const pathname = (req.url ?? "/").split(/[?#]/)[0];
|
|
96
|
+
if (pathname === CLIENT_URL) {
|
|
97
|
+
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
|
|
98
|
+
res.end(CLIENT);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (pathname === EVENTS_URL) {
|
|
102
|
+
res.writeHead(200, {
|
|
103
|
+
"content-type": "text/event-stream",
|
|
104
|
+
"cache-control": "no-store",
|
|
105
|
+
connection: "keep-alive"
|
|
106
|
+
});
|
|
107
|
+
res.write(": jq79\n\n");
|
|
108
|
+
clients.add(res);
|
|
109
|
+
req.on("close", () => clients.delete(res));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
void serveStatic(req, res, pathname);
|
|
113
|
+
});
|
|
114
|
+
const ignored = (rel) => rel.split(sep).some((part) => part.startsWith(".") || part === "node_modules");
|
|
115
|
+
const pending = /* @__PURE__ */ new Map();
|
|
116
|
+
const changed = async (rel) => {
|
|
117
|
+
const file = join(root, rel);
|
|
118
|
+
let src = null;
|
|
119
|
+
try {
|
|
120
|
+
if ((await stat(file)).isDirectory()) return;
|
|
121
|
+
if (rel.endsWith(".html")) src = await readFile(file, "utf8");
|
|
122
|
+
} catch {
|
|
123
|
+
if (rel === basename(root)) return;
|
|
124
|
+
}
|
|
125
|
+
const url = "/" + posix(rel);
|
|
126
|
+
if (src === null) send("reload", { url });
|
|
127
|
+
else send("update", { url, src });
|
|
128
|
+
};
|
|
129
|
+
const watcher = watch(root, { recursive: true }, (_event, filename) => {
|
|
130
|
+
if (!filename) return;
|
|
131
|
+
const rel = relative(root, resolve(root, filename.toString()));
|
|
132
|
+
if (!rel || rel.startsWith("..") || ignored(rel)) return;
|
|
133
|
+
clearTimeout(pending.get(rel));
|
|
134
|
+
pending.set(rel, setTimeout(() => {
|
|
135
|
+
pending.delete(rel);
|
|
136
|
+
void changed(rel);
|
|
137
|
+
}, 30));
|
|
138
|
+
});
|
|
139
|
+
const heartbeat = setInterval(() => clients.forEach((client) => client.write(": ping\n\n")), 3e4);
|
|
140
|
+
heartbeat.unref();
|
|
141
|
+
await new Promise((done, fail) => {
|
|
142
|
+
server2.once("error", fail);
|
|
143
|
+
server2.listen(options2.port ?? 4179, host, done);
|
|
144
|
+
});
|
|
145
|
+
const { port } = server2.address();
|
|
146
|
+
return {
|
|
147
|
+
url: `http://${host}:${port}`,
|
|
148
|
+
port,
|
|
149
|
+
close: () => new Promise((done) => {
|
|
150
|
+
clearInterval(heartbeat);
|
|
151
|
+
watcher.close();
|
|
152
|
+
pending.forEach(clearTimeout);
|
|
153
|
+
clients.forEach((client) => client.end());
|
|
154
|
+
clients.clear();
|
|
155
|
+
server2.close(() => done());
|
|
156
|
+
})
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// dev/cli.ts
|
|
161
|
+
var USAGE = `jq79 - a mini reactive component library
|
|
162
|
+
|
|
163
|
+
usage:
|
|
164
|
+
jq79 dev [dir] serve dir (default: .) with hot reload
|
|
165
|
+
|
|
166
|
+
options:
|
|
167
|
+
-p, --port <port> port to listen on (default: 4179)
|
|
168
|
+
-H, --host <host> host to bind (default: localhost)
|
|
169
|
+
-h, --help show this message
|
|
170
|
+
`;
|
|
171
|
+
var args = process.argv.slice(2);
|
|
172
|
+
var [command, ...rest] = args;
|
|
173
|
+
if (command !== "dev" || rest.includes("-h") || rest.includes("--help")) {
|
|
174
|
+
const unknown = command && command !== "dev" && !command.startsWith("-");
|
|
175
|
+
if (unknown) console.error(`unknown command: ${command}
|
|
176
|
+
`);
|
|
177
|
+
console.log(USAGE);
|
|
178
|
+
process.exit(unknown ? 1 : 0);
|
|
179
|
+
}
|
|
180
|
+
var options = {};
|
|
181
|
+
for (let i = 0; i < rest.length; i++) {
|
|
182
|
+
const arg = rest[i];
|
|
183
|
+
if (arg === "-p" || arg === "--port") options.port = Number(rest[++i]);
|
|
184
|
+
else if (arg === "-H" || arg === "--host") options.host = rest[++i];
|
|
185
|
+
else if (!arg.startsWith("-")) options.rootDir ??= arg;
|
|
186
|
+
else {
|
|
187
|
+
console.error(`unknown option: ${arg}
|
|
188
|
+
${USAGE}`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (options.port !== void 0 && !Number.isInteger(options.port)) {
|
|
193
|
+
console.error("--port takes a number");
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
var server = await devServer(options);
|
|
197
|
+
console.log(`jq79 dev \u2192 ${server.url}`);
|
|
198
|
+
var stop = () => {
|
|
199
|
+
void server.close().then(() => process.exit(0));
|
|
200
|
+
};
|
|
201
|
+
process.on("SIGINT", stop);
|
|
202
|
+
process.on("SIGTERM", stop);
|