mikser-io 11.12.0 → 11.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -245,7 +245,7 @@ npm install mikser-io
245
245
  ```bash
246
246
  npx mikser # one-shot build
247
247
  npx mikser --watch # incremental dev loop
248
- npx mikser --server # build + serve on a free port (or --server 3001 to name one)
248
+ npx mikser --server # build + serve on the port it used last (or --server 3001 to name one)
249
249
  ```
250
250
 
251
251
  For a working starter — config with a real plugin set, sample `documents/`, expected output — see [Getting Started](./docs/getting-started.md). Or skip straight to "add mikser to this app" via the [Claude Code plugin](#built-for-ai-assisted-development) above.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "11.12.0",
3
+ "version": "11.13.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/manager.js CHANGED
@@ -166,6 +166,18 @@ const AWAIT_WRITE_FINISH = { stabilityThreshold: 500, pollInterval: 100 }
166
166
  const WATCH_DEFAULTS = {
167
167
  ignored: ignoreJunk,
168
168
  ignoreInitial: true,
169
+ }
170
+
171
+ // Only the SOURCE watcher settles. `watchFolder` below is the generic one —
172
+ // mikser-io-live points it at the output folder to push browser reloads, and
173
+ // mikser-io-auth at an htpasswd — and neither has the problem this solves:
174
+ // those files are written by mikser itself or edited in place, not staged
175
+ // under a temporary name by an upload tool. Applying it there only delays a
176
+ // reload, and measurably: an event that arrived in 3ms arrives in 753ms with
177
+ // a 500ms settle, on top of live's own 250ms debounce. A caller that wants it
178
+ // asks, the way files.js does.
179
+ const SOURCE_WATCH_DEFAULTS = {
180
+ ...WATCH_DEFAULTS,
169
181
  awaitWriteFinish: AWAIT_WRITE_FINISH,
170
182
  }
171
183
 
@@ -218,7 +230,7 @@ export function watch(name, folder, options = {}) {
218
230
  }
219
231
  }
220
232
 
221
- chokidar.watch(folder, { ...WATCH_DEFAULTS, ...options })
233
+ chokidar.watch(folder, { ...SOURCE_WATCH_DEFAULTS, ...options })
222
234
  .on('all', () => {
223
235
  clearTimeout(runtime.engine.processTimeout)
224
236
  })
package/src/server.js CHANGED
@@ -24,6 +24,7 @@ import path from 'node:path'
24
24
  import { fileURLToPath } from 'node:url'
25
25
  import { networkInterfaces } from 'node:os'
26
26
  import { createServer } from 'node:net'
27
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
27
28
 
28
29
  import runtime from './runtime.js'
29
30
  import { useLogger } from './engine/index.js'
@@ -84,23 +85,80 @@ export function requestedPort(server) {
84
85
  return Number.isInteger(asked) && asked >= 0 ? asked : DEFAULT_PORT
85
86
  }
86
87
 
88
+ // Where the LAST USED port is remembered, per working folder.
89
+ //
90
+ // A bare `--server` picks a free port, which solved two people colliding on
91
+ // 3001 and introduced a smaller annoyance: the number changed on every
92
+ // restart, so a bookmark, a tab, a terminal scrollback and anything else
93
+ // holding the old one went stale several times an afternoon.
94
+ //
95
+ // Last USED, not last auto-chosen: `--server 3002` once and a bare `--server`
96
+ // after it keeps 3002. Naming a port is the strongest statement available
97
+ // about which one is wanted, and forgetting it the moment the flag is dropped
98
+ // would make the memory useless exactly where it is most deliberate.
99
+ //
100
+ // The runtime folder is the right lifetime. It is per working folder, so two
101
+ // projects still get different ports and two users still do not collide; it
102
+ // survives a restart, which is the whole point; and `--clear` removes the
103
+ // cache database rather than this, so asking for a cold rebuild does not also
104
+ // move the server. Deleting the folder forgets it, which is the correct
105
+ // answer to "give me a different port".
106
+ const PORT_FILE = 'server-port'
107
+
108
+ const portFile = () => path.join(runtime.options.runtimeFolder ?? '.', PORT_FILE)
109
+
110
+ async function rememberedPort() {
111
+ try {
112
+ const port = Number((await readFile(portFile(), 'utf8')).trim())
113
+ // A file someone edited by hand, or a half-written one. Anything that
114
+ // is not a usable port is treated as no preference rather than as an
115
+ // error — the fallback is exactly what this feature replaced.
116
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : null
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
122
+ async function rememberPort(port) {
123
+ try {
124
+ await mkdir(path.dirname(portFile()), { recursive: true })
125
+ await writeFile(portFile(), `${port}\n`)
126
+ } catch (err) {
127
+ // Not worth failing a build over. The cost is the port moving again
128
+ // next time, which is where this started.
129
+ useLogger()?.debug?.('Could not remember the server port: %s', err.message)
130
+ }
131
+ }
132
+
87
133
  // A port nothing is listening on, according to the OS.
88
134
  //
89
- // Binds to 0, reads what it was given, and lets it go. There is a gap between
90
- // releasing it and the real listen below, so this is a strong preference
91
- // rather than a reservation — if something takes the port in between, the
92
- // EADDRINUSE handler further down says so plainly instead of pretending.
93
- // Narrow enough not to matter on the machine this exists for; not narrow
94
- // enough to claim it cannot happen.
95
- export async function freePort() {
96
- return new Promise((resolve, reject) => {
135
+ // Binds to `preferred` when one is given and takes it if it is free —
136
+ // which is how a restart keeps the port it had. Falls back to binding 0,
137
+ // which is the OS choosing.
138
+ //
139
+ // Either way it lets the port go again. There is a gap between releasing it
140
+ // and the real listen below, so this is a strong preference rather than a
141
+ // reservation — if something takes the port in between, the EADDRINUSE
142
+ // handler further down says so plainly instead of pretending. Narrow enough
143
+ // not to matter on the machine this exists for; not narrow enough to claim it
144
+ // cannot happen.
145
+ export async function freePort(preferred = 0) {
146
+ const bind = (port) => new Promise((resolve) => {
97
147
  const probe = createServer()
98
- probe.once('error', reject)
99
- probe.listen(0, () => {
100
- const { port } = probe.address()
101
- probe.close(() => resolve(port))
148
+ probe.once('error', () => resolve(null))
149
+ probe.listen(port, () => {
150
+ const { port: got } = probe.address()
151
+ probe.close(() => resolve(got))
102
152
  })
103
153
  })
154
+
155
+ if (preferred) {
156
+ const kept = await bind(preferred)
157
+ if (kept) return kept
158
+ }
159
+ const found = await bind(0)
160
+ if (found) return found
161
+ throw new Error('Could not find a free port to listen on')
104
162
  }
105
163
 
106
164
  // Wire the server lifecycle hooks. Called by engine.js's setup() AFTER
@@ -143,11 +201,29 @@ export function setupServer() {
143
201
  // So it becomes a real number as early as it can, and everything
144
202
  // downstream sees exactly what it would have seen from
145
203
  // `--server <that number>`.
146
- const found = await freePort()
204
+ const kept = await rememberedPort()
205
+ const found = await freePort(kept)
147
206
  runtime.options.server = found
148
207
  runtime.options.port = found
149
- logger.info('Server port: %d (nothing named one, so this is a free port)', found)
208
+ // Written BEFORE it is announced. Anything reading the log and
209
+ // acting on the port — a wrapper script, a test harness, a person
210
+ // with a fast Ctrl-C — can otherwise beat the write and leave the
211
+ // port unremembered, which looks exactly like the feature not
212
+ // working.
213
+ await rememberPort(found)
214
+ logger.info(found === kept
215
+ ? 'Server port: %d (the same one as last time)'
216
+ : kept
217
+ ? 'Server port: %d (last time it was %d, which is taken)'
218
+ : 'Server port: %d (nothing named one, so this is a free port)', found, kept)
219
+ }
220
+ // A NAMED port is remembered too — that is what "last used" means,
221
+ // and it is the case where the number was chosen on purpose. The auto
222
+ // branch above has already written its own.
223
+ if (requestedPort(runtime.options.server) !== 0) {
224
+ await rememberPort(runtime.options.port)
150
225
  }
226
+
151
227
  logger.debug('Server starting on port %d', runtime.options.port)
152
228
 
153
229
  // Trust-proxy: when mikser is behind a reverse proxy (nginx,