dsh-unlag 1.9.111 → 1.9.113

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.
Files changed (2) hide show
  1. package/index.js +58 -1
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -116,6 +116,8 @@ const MAX_TOTAL_SCRIPT_BYTES = 128 * 1024 * 1024
116
116
  * app could boot from, and it is answered as such instead of being read.
117
117
  */
118
118
  const MAX_LIST_BODY_BYTES = 1024 * 1024
119
+ /** Checks that may read the plugin table at the same time. Each one holds a script at a time. */
120
+ const MAX_CONCURRENT_CHECKS = 3
119
121
  /** The body reader's answer for a request past its bound: not JSON, and not an empty list either. */
120
122
  const TOO_LARGE = Symbol('too-large')
121
123
 
@@ -1872,6 +1874,8 @@ function serveButton(ctx, controller, config) {
1872
1874
  return null
1873
1875
  }
1874
1876
  const settings = settingsOf(config)
1877
+ /** One counter for the whole route set: the check is the only body that reads scripts. */
1878
+ let checking = 0
1875
1879
  const handlers = [
1876
1880
  [
1877
1881
  `${BASE_PATH}/state`,
@@ -1916,6 +1920,18 @@ function serveButton(ctx, controller, config) {
1916
1920
  const method = String(req?.method ?? 'GET').toUpperCase()
1917
1921
  if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
1918
1922
  if (buttonAllowed(req) === false) return sendJson(res, 403, { status: 'forbidden' })
1923
+ /**
1924
+ * Each check holds the bytes of one script while it reads them, so a page that asks in a
1925
+ * loop, or several windows asking at once, must not stack those reads without a ceiling: the
1926
+ * app is the process that would run out of memory. Past three the check is refused in its
1927
+ * own words rather than served slowly or not at all.
1928
+ */
1929
+ if (checking >= MAX_CONCURRENT_CHECKS) {
1930
+ log(`a startup check was refused: ${String(checking)} checks are already reading the plugin table`)
1931
+ return sendJson(res, 200, { status: 'blocked', reason: 'busy', detail: 'another startup check is still running' })
1932
+ }
1933
+ checking += 1
1934
+ try {
1919
1935
  const clientModules = ctx.get('clientModules')
1920
1936
  if (typeof clientModules?.fetchBundle !== 'function' || typeof clientModules?.graph !== 'function') {
1921
1937
  return sendJson(res, 200, { status: 'blocked', reason: 'list', detail: 'the plugin table is not running' })
@@ -1943,6 +1959,9 @@ function serveButton(ctx, controller, config) {
1943
1959
  log(`a startup row names a plugin this app no longer serves: ${verdict.leftOut.join(', ')}; the page comes up without it`)
1944
1960
  }
1945
1961
  sendJson(res, 200, verdict)
1962
+ } finally {
1963
+ checking -= 1
1964
+ }
1946
1965
  } catch (error) {
1947
1966
  log(`cannot answer ${BASE_PATH}/verify: ${error?.message ?? error}`)
1948
1967
  sendJson(res, 500, { status: 'error' })
@@ -2032,7 +2051,7 @@ function serveButton(ctx, controller, config) {
2032
2051
  const disposers = []
2033
2052
  for (const [path, handler] of handlers) {
2034
2053
  try {
2035
- const dispose = server.register({ kind: 'exact', path, handler })
2054
+ const dispose = server.register({ kind: 'exact', path, handler: guardRoute(path, handler) })
2036
2055
  if (typeof dispose === 'function') disposers.push(dispose)
2037
2056
  } catch (error) {
2038
2057
  log(`cannot answer ${path}: ${error?.message ?? error}`)
@@ -2043,6 +2062,44 @@ function serveButton(ctx, controller, config) {
2043
2062
  }
2044
2063
  }
2045
2064
 
2065
+ /**
2066
+ * Nothing a route body does may reach the server as a rejection or a throw: an answer that never
2067
+ * settles is a page that waits forever, and a rejected handler can take down the request instead of
2068
+ * saying what happened. Each body already answers from its own guard; this one is the floor under
2069
+ * them, so a future edit that misses a path still costs a status rather than the answer.
2070
+ */
2071
+ export function guardRoute(path, handler) {
2072
+ /**
2073
+ * Nothing a route body does may reach the server as a rejection or a throw: an answer that never
2074
+ * settles is a page that waits forever, and a rejected handler can take the request down instead
2075
+ * of saying what happened. Each body answers from its own guard already; this is the floor under
2076
+ * them, so a future edit that misses a path costs a status rather than the answer.
2077
+ */
2078
+ return (req, res) => {
2079
+ const fallback = (error) => {
2080
+ log(`cannot answer ${path}: ${error?.message ?? error}`)
2081
+ try {
2082
+ if (res?.headersSent !== true) sendJson(res, 503, { status: 'error' })
2083
+ else if (res?.writableEnded !== true && res?.destroyed !== true) res.end()
2084
+ } catch {
2085
+ /* the response is already gone; the log line is what is left to say */
2086
+ }
2087
+ }
2088
+ try {
2089
+ const answer = handler(req, res)
2090
+ // The answer is handed back as it came: a caller that waits on the body still waits on it,
2091
+ // and the rejection is watched here as well rather than instead.
2092
+ if (answer !== null && typeof answer === 'object' && typeof answer.then === 'function') {
2093
+ answer.then(undefined, fallback)
2094
+ }
2095
+ return answer
2096
+ } catch (error) {
2097
+ fallback(error)
2098
+ return undefined
2099
+ }
2100
+ }
2101
+ }
2102
+
2046
2103
  export function apply(ctx, config) {
2047
2104
  try {
2048
2105
  const instance = {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-unlag",
3
- "version": "1.9.111",
3
+ "version": "1.9.113",
4
4
  "type": "module",
5
5
  "description": "A press applies plugin edits right away and brings a laggy interface back; automatic reloading stays off unless you turn it on, so the press is the only trigger, and nothing restarts.",
6
6
  "license": "MIT",