gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commita3a7d21aa3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmrea3a7d21a/plugins/time/server.js

16.1 KB

  1. // hl:time — the clock AND time as an event source. JS twin of
  2. // plugins/time/time.zig, and the one file BOTH other realms run: the JavaScript
  3. // transpiler target copies it into `hl-modules/time.js`, and hl:web serves it to
  4. // the BROWSER as `hl-time.js` (hl:time declares no `"realm": "server"`, so a
  5. // client handler may reach it — plugins/web/web_framework.hl `packagePaths`).
  6. //
  7. // Parity contract with the native plugin:
  8. // now() → epoch milliseconds, integer-valued
  9. // timestamp(ms?) → "YYYY-MM-DDTHH:MM:SS.mmmZ", exactly 24 chars, UTC.
  10. // No argument (or a non-number) means "now"; an epoch-ms
  11. // argument renders THAT instant, which is what makes the
  12. // call testable — timestamp(0) is always the epoch.
  13. // monotonic() → nanoseconds off a monotonic counter. Origin arbitrary;
  14. // only differences are meaningful.
  15. // every(s) → a repeating Timer; `on t.tick()` until `t.stop()`
  16. // after(s) → a one-shot Timer, `s` seconds from now
  17. // until(epochMs) → a one-shot Timer at that instant
  18. // sleep(s) → BLOCKING. Yes, in a browser too — see below.
  19. // offset / local / fromLocal / localTimestamp / isZone → time zones, ticket #5
  20. // (the section below says how Intl stands in for TZif)
  21. export function now() { return Date.now(); }
  22. export function timestamp(ms) {
  23. // Date#toISOString already emits exactly the native format for in-range
  24. // instants. Pre-epoch values are clamped the same way the native plugin
  25. // clamps them (std.time.epoch is u64-only, so 1970 is the floor on both
  26. // sides and the two targets must agree on what happens below it).
  27. let t = typeof ms === 'number' && Number.isFinite(ms) ? Math.trunc(ms) : Date.now();
  28. if (t < 0) t = 0;
  29. return new Date(t).toISOString();
  30. }
  31. export function monotonic() {
  32. // process.hrtime.bigint() is Node's CLOCK_MONOTONIC. Converted to a plain
  33. // Number because Hybriel has no bigint; same f64 caveat as the native side
  34. // (exact for the first ~104 days of counter uptime).
  35. if (typeof process !== 'undefined' && process.hrtime && process.hrtime.bigint) {
  36. return Number(process.hrtime.bigint());
  37. }
  38. // Browser/other host fallback: performance.now() is ms with sub-ms
  39. // resolution off a monotonic origin.
  40. return Math.round(performance.now() * 1e6);
  41. }
  42. // ═══════════════════════════════════════════════════════════════════════════
  43. // TIME ZONES (ticket #5) — the twin of the TIME ZONES half of time.zig
  44. // ═══════════════════════════════════════════════════════════════════════════
  45. //
  46. // The native plugin reads the system's TZif files; this half asks Intl, which
  47. // every browser and Node carry. The ONLY question put to Intl is the offset in
  48. // force at an instant; `local`, `fromLocal` and `localTimestamp` are the same
  49. // arithmetic time.zig runs, step for step, so the two realms cannot drift on
  50. // anything but the zone data itself.
  51. //
  52. // THE OFFSET FROM INTL. `formatToParts` hands back the wall clock; the offset is
  53. // that reading minus UTC. Only the DAY and the time of day are read, never the
  54. // year: Intl's `en-US` calendar is Julian before 1582 and prints an era-less
  55. // |year| before year 1, and an offset is always under a day, so "same day of
  56. // the month, next, or previous" is all the date there is to know.
  57. const MAX_MS = 8.64e15;
  58. const BAD_INSTANT = 'hl:time: pass the instant in epoch milliseconds (what now() returns)';
  59. const zoneFormats = new Map();
  60. function floorDiv(a, b) { return Math.floor(a / b); }
  61. function mod(a, b) { return a - Math.floor(a / b) * b; }
  62. function daysFromCivil(y0, m, d) {
  63. const y = m <= 2 ? y0 - 1 : y0;
  64. const era = floorDiv(y, 400);
  65. const yoe = y - era * 400;
  66. const mp = mod(m + 9, 12);
  67. const doy = floorDiv(153 * mp + 2, 5);
  68. const doe = yoe * 365 + floorDiv(yoe, 4) - floorDiv(yoe, 100) + doy;
  69. return era * 146097 + doe - 719468 + (d - 1);
  70. }
  71. function civilFromDays(z0) {
  72. const z = z0 + 719468;
  73. const era = floorDiv(z, 146097);
  74. const doe = z - era * 146097;
  75. const yoe = floorDiv(doe - floorDiv(doe, 1460) + floorDiv(doe, 36524) - floorDiv(doe, 146096), 365);
  76. const doy = doe - (365 * yoe + floorDiv(yoe, 4) - floorDiv(yoe, 100));
  77. const mp = floorDiv(5 * doy + 2, 153);
  78. const d = doy - floorDiv(153 * mp + 2, 5) + 1;
  79. const m = mp < 10 ? mp + 3 : mp - 9;
  80. return { y: m <= 2 ? yoe + era * 400 + 1 : yoe + era * 400, m, d };
  81. }
  82. /** The same shape rule as time.zig's `plausibleName`: Intl also takes offsets
  83. * ('+01:00') and is case-blind, and the system database is neither. */
  84. function plausibleName(name) {
  85. if (typeof name !== 'string' || name.length === 0 || name.length > 64) return false;
  86. if (!/^[A-Za-z][A-Za-z0-9/_+-]*$/.test(name)) return false;
  87. if (name.includes('//') || name.endsWith('/')) return false;
  88. if (name.startsWith('posix/') || name.startsWith('right/')) return false;
  89. return name !== 'posixrules' && name !== 'localtime' && name !== 'Factory';
  90. }
  91. function zoneFormat(name) {
  92. if (zoneFormats.has(name)) return zoneFormats.get(name);
  93. let f = null;
  94. if (plausibleName(name)) {
  95. try {
  96. f = new Intl.DateTimeFormat('en-US', {
  97. timeZone: name, hourCycle: 'h23',
  98. day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric',
  99. });
  100. // a case variant of a real name ('europe/vienna') is accepted by Intl
  101. // and unknown to the file system — refuse it on this side as well
  102. const resolved = f.resolvedOptions().timeZone;
  103. if (resolved !== name && resolved.toLowerCase() === name.toLowerCase()) f = null;
  104. } catch { f = null; }
  105. }
  106. if (zoneFormats.size < 4096) zoneFormats.set(name, f);
  107. return f;
  108. }
  109. function zoneArg(zone) {
  110. if (typeof zone !== 'string') throw new Error("hl:time: pass the time zone as an IANA name, e.g. 'Europe/Vienna'");
  111. const f = zoneFormat(zone);
  112. if (!f) throw new Error(`hl:time: unknown time zone '${zone.length > 64 ? zone.slice(0, 64) : zone}'`);
  113. return f;
  114. }
  115. function instantArg(ms) {
  116. if (typeof ms !== 'number' || !(ms <= MAX_MS && ms >= -MAX_MS)) throw new Error(BAD_INSTANT);
  117. return Math.floor(ms);
  118. }
  119. function offsetAtMs(f, ms) {
  120. const secs = floorDiv(ms, 1000);
  121. let day = 0, wall = 0;
  122. for (const p of f.formatToParts(secs * 1000)) {
  123. const v = Number(p.value);
  124. if (p.type === 'day') day = v;
  125. else if (p.type === 'hour') wall += v * 3600;
  126. else if (p.type === 'minute') wall += v * 60;
  127. else if (p.type === 'second') wall += v;
  128. }
  129. const utcDays = floorDiv(secs, 86400);
  130. let off = wall - (secs - utcDays * 86400);
  131. if (day !== civilFromDays(utcDays).d) off += off < 0 ? 86400 : -86400;
  132. return off;
  133. }
  134. /** Seconds east of UTC in force in `zone` at the instant `ms`. */
  135. export function offset(ms, zone) {
  136. const t = instantArg(ms);
  137. return offsetAtMs(zoneArg(zone), t);
  138. }
  139. /** The wall-clock reading in `zone` at `ms` — time.zig `hl_time_local`. */
  140. export function local(ms, zone) {
  141. const t = instantArg(ms);
  142. const off = offsetAtMs(zoneArg(zone), t);
  143. const lt = t + off * 1000;
  144. const days = floorDiv(lt, 86400000);
  145. const inDay = lt - days * 86400000;
  146. const c = civilFromDays(days);
  147. return {
  148. year: c.y, month: c.m, day: c.d,
  149. hour: floorDiv(inDay, 3600000), minute: mod(floorDiv(inDay, 60000), 60), second: mod(floorDiv(inDay, 1000), 60),
  150. millisecond: mod(inDay, 1000), weekday: mod(days + 3, 7) + 1,
  151. offset: off,
  152. };
  153. }
  154. const BAD_FIELDS = 'hl:time fromLocal: every field must be a Number (year is required)';
  155. function fieldNumber(o, key, dflt) {
  156. const v = o[key];
  157. if (v === undefined || v === null) return dflt;
  158. if (typeof v !== 'number' || !(Math.abs(v) < 1e12)) throw new Error(BAD_FIELDS);
  159. return Math.floor(v);
  160. }
  161. /** The instant a wall-clock reading in `zone` names — time.zig `fromWall`:
  162. * a FOLD answers the earlier instant, a GAP moves forward by its length. */
  163. export function fromLocal(fields, zone) {
  164. if (fields === null || typeof fields !== 'object') {
  165. throw new Error('hl:time fromLocal: pass the fields as { year, month, day, hour, minute, second, millisecond }');
  166. }
  167. const f = zoneArg(zone);
  168. const y = fieldNumber(fields, 'year', NaN);
  169. if (Number.isNaN(y)) throw new Error(BAD_FIELDS);
  170. const mo = fieldNumber(fields, 'month', 1), d = fieldNumber(fields, 'day', 1);
  171. const h = fieldNumber(fields, 'hour', 0), mi = fieldNumber(fields, 'minute', 0);
  172. const s = fieldNumber(fields, 'second', 0), milli = fieldNumber(fields, 'millisecond', 0);
  173. const m0 = mo - 1;
  174. const wall = daysFromCivil(y + floorDiv(m0, 12), mod(m0, 12) + 1, d) * 86400000 +
  175. h * 3600000 + mi * 60000 + s * 1000 + milli;
  176. if (Math.abs(wall) > MAX_MS + 86400000) throw new Error(BAD_INSTANT);
  177. const before = offsetAtMs(f, wall - 86400000);
  178. const after = offsetAtMs(f, wall + 86400000);
  179. const a = wall - before * 1000, b = wall - after * 1000;
  180. const aOk = offsetAtMs(f, a) === before, bOk = offsetAtMs(f, b) === after;
  181. const ms = aOk && bOk ? Math.min(a, b) : aOk ? a : bOk ? b : a;
  182. if (Math.abs(ms) > MAX_MS) throw new Error(BAD_INSTANT);
  183. return ms;
  184. }
  185. const pad = (n, w) => String(n).padStart(w, '0');
  186. /** ISO-8601 in `zone`: "YYYY-MM-DDTHH:MM:SS.mmm+HH:MM" (":SS" on the offset only
  187. * when it has seconds, i.e. local mean time). */
  188. export function localTimestamp(ms, zone) {
  189. const t = instantArg(ms);
  190. const off = offsetAtMs(zoneArg(zone), t);
  191. const lt = t + off * 1000;
  192. const days = floorDiv(lt, 86400000);
  193. const inDay = lt - days * 86400000;
  194. const c = civilFromDays(days);
  195. const year = c.y >= 0 && c.y <= 9999 ? pad(c.y, 4) : (c.y < 0 ? '-' : '+') + pad(Math.abs(c.y), 6);
  196. const a = Math.abs(off);
  197. return year + '-' + pad(c.m, 2) + '-' + pad(c.d, 2) + 'T' +
  198. pad(floorDiv(inDay, 3600000), 2) + ':' + pad(floorDiv(inDay, 60000) % 60, 2) + ':' +
  199. pad(floorDiv(inDay, 1000) % 60, 2) + '.' + pad(inDay % 1000, 3) +
  200. (off < 0 ? '-' : '+') + pad(floorDiv(a, 3600), 2) + ':' + pad(floorDiv(a, 60) % 60, 2) +
  201. (a % 60 !== 0 ? ':' + pad(a % 60, 2) : '');
  202. }
  203. /** True when this realm's zone database (Intl) knows the name. */
  204. export function isZone(name) {
  205. return typeof name === 'string' && zoneFormat(name) !== null;
  206. }
  207. // ═══════════════════════════════════════════════════════════════════════════
  208. // TIME AS AN EVENT SOURCE (mission 254)
  209. // ═══════════════════════════════════════════════════════════════════════════
  210. //
  211. // THE FLOOR IS THE SAME NUMBER AS THE NATIVE PLUGIN'S, and this is the realm
  212. // that sets it. Server-side a timerfd carries nanoseconds and the event loop
  213. // blocks on it, so there is no floor there at all; the browser has one, and it
  214. // was MEASURED rather than quoted from the spec — tests/browser/tests/66-timers.mjs
  215. // drives a real page and prints the number it measured on every run. HTML's
  216. // timer nesting rule clamps a chained `setTimeout(…, 0)` to 4ms from the fifth
  217. // nesting level, and a `setInterval` asked for 1ms delivers ~4ms periods.
  218. //
  219. // The same `on t.tick()` handler is meant to run in both realms, so `every(0.001)`
  220. // must not mean two different things depending on where it ran: both sides raise
  221. // anything below the floor to it. (plugins/time/time.zig FLOOR_NS = 4_000_000.)
  222. export const TIMER_FLOOR_SECONDS = 0.004;
  223. /** Monotonic milliseconds, fractional. `performance` is a global in Node ≥16 and
  224. * in every browser; `Date.now()` is the last-resort fallback and is not
  225. * monotonic, which only costs accuracy across a clock adjustment. */
  226. function monoMs() {
  227. if (typeof performance !== 'undefined' && performance.now) return performance.now();
  228. return Date.now();
  229. }
  230. /** ONE ARMED TIMER — the twin of plugins/time/Timer.hl.
  231. *
  232. * `on t.tick()` reaches this through `__hlOn`, which is the ONE registration
  233. * protocol both JS realms use: the transpiler target's `hlScopedBus`
  234. * (js/src/runtime/runtime.js) and hl:web's client runtime (hl-core.js
  235. * `__hlBindSources`) each end up calling it with the event name and a handler.
  236. * A plugin object is not an hl class instance and has no `constructor.__events__`,
  237. * so it could not otherwise be the target of an instance-scoped handler.
  238. *
  239. * SCHEDULED FROM THE ORIGIN, like the native half: each round's delay is
  240. * computed against `origin + n × period`, never `period` from where the last
  241. * handler finished, so the handler's own runtime does not accumulate. A round
  242. * the host was too busy to deliver is SKIPPED rather than queued — a periodic
  243. * timer that owes you a backlog is a stampede.
  244. *
  245. * A CHAINED `setTimeout` RATHER THAN `setInterval`, for the same reason: the
  246. * chain lets each delay be recomputed from the origin, and `setInterval`'s
  247. * behaviour when a callback overruns its period differs between hosts. */
  248. class HlTimer {
  249. constructor(seconds, repeating) {
  250. this.seconds = Math.max(TIMER_FLOOR_SECONDS, Number(seconds) || 0);
  251. this.repeating = !!repeating;
  252. this.running = true;
  253. this.count = 0;
  254. this.__handlers = new Map();
  255. this.__origin = monoMs();
  256. this.__n = 0;
  257. this.__h = null;
  258. this.__arm();
  259. }
  260. __arm() {
  261. const period = this.seconds * 1000;
  262. this.__n += 1;
  263. const due = this.__origin + this.__n * period;
  264. this.__h = setTimeout(() => this.__fire(), Math.max(0, due - monoMs()));
  265. }
  266. __fire() {
  267. this.__h = null;
  268. if (!this.running) return;
  269. this.count += 1;
  270. const ev = { count: this.count, at: Date.now() };
  271. if (this.repeating) {
  272. // skip whatever the host was too busy to deliver, keeping the phase
  273. const period = this.seconds * 1000;
  274. const now = monoMs();
  275. while (this.__origin + this.__n * period <= now) this.__n += 1;
  276. this.__n -= 1;
  277. this.__arm();
  278. } else {
  279. // A SPENT ONE-SHOT RETIRES ITSELF before the tick is delivered — the
  280. // same rule as the native half (mission 249: a source that will never
  281. // speak again must not keep the program alive; in Node that is
  282. // literally true, an un-cleared timer keeps the process running).
  283. this.running = false;
  284. }
  285. const list = this.__handlers.get('tick');
  286. if (list) for (const fn of list.slice()) fn(ev);
  287. }
  288. /** The registration protocol — see the class comment. */
  289. __hlOn(event, fn) {
  290. let list = this.__handlers.get(event);
  291. if (!list) { list = []; this.__handlers.set(event, list); }
  292. list.push(fn);
  293. return this;
  294. }
  295. /** Stop an interval. True when this call was the one that stopped it. */
  296. stop() {
  297. if (!this.running) return false;
  298. this.running = false;
  299. if (this.__h !== null) { clearTimeout(this.__h); this.__h = null; }
  300. return true;
  301. }
  302. }
  303. /** A REPEATING timer: `on t.tick()` fires every `seconds` until `t.stop()`. */
  304. export function every(seconds) { return new HlTimer(seconds, true); }
  305. /** A ONE-SHOT: `on t.tick()` fires once, `seconds` from now, and retires. */
  306. export function after(seconds) { return new HlTimer(seconds, false); }
  307. /** A ONE-SHOT AT AN INSTANT. `epochMs` is what `now()` answers; an instant
  308. * already past fires immediately (at the floor). */
  309. export function until(epochMs) {
  310. const delay = (Number(epochMs) - Date.now()) / 1000;
  311. return new HlTimer(delay > 0 ? delay : 0, false);
  312. }
  313. /** BLOCK for `seconds`. The creator's ruling: a basic tool for tests and
  314. * debugging, and it blocks — inside a server, `after()` is the thing to use.
  315. *
  316. * `Atomics.wait` is the only real block JavaScript has, and it is FORBIDDEN on
  317. * a browser's main thread (it throws TypeError). So the browser gets a spin on
  318. * the monotonic clock instead, which is the same observable behaviour — the tab
  319. * is frozen for the duration — reached a different way. That is what a blocking
  320. * sleep IS; the docs say so beside it rather than pretending otherwise. */
  321. export function sleep(seconds) {
  322. const ms = Number(seconds) * 1000;
  323. if (!(ms > 0)) return false;
  324. try {
  325. if (typeof SharedArrayBuffer !== 'undefined' && typeof Atomics !== 'undefined') {
  326. Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
  327. return true;
  328. }
  329. } catch { /* main thread — fall through to the spin */ }
  330. const until_ = monoMs() + ms;
  331. while (monoMs() < until_) { /* the block */ }
  332. return true;
  333. }

Branches

Latest commits

  • a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
  • 98226b41antcolony#40: mission references point to the moved missionsmre
  • ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
  • 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
  • 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
  • 81b15b7bState of 2026-09-27, before the move to gitoriamre