temporal-fmt 0.9.5 → 0.9.6

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/MODS.md ADDED
@@ -0,0 +1,808 @@
1
+ # Mods (advanced, optional)
2
+
3
+ Most people don't need this. Mods are a feature for when you want to fix or
4
+ tweak something in this library without forking the whole thing — not
5
+ something you're expected to reach for day to day. If you never touch
6
+ `mods/`, nothing about normal usage changes for you.
7
+
8
+ `registerLocale`, `createHolidayCalendar`, and `createFormatter` are already
9
+ how you extend this library without forking it — the [README](./README.md)
10
+ covers them under [Locales](./README.md#locales), [Business calendars and
11
+ holidays](./README.md#business-calendars-and-holidays), and [Extending with
12
+ custom tokens](./README.md#extending-with-custom-tokens). Mods are a delivery
13
+ mechanism on top of those same functions: drop a file in a `mods/` folder,
14
+ the CLI picks it up on startup and runs it. No publishing to npm, no build
15
+ step in this repo, no manifest to register anywhere. If you've used a
16
+ Minecraft mods folder, it's the same idea — a file the host looks for and
17
+ loads, not a package the host depends on.
18
+
19
+ This exists so bugfixes and locale corrections don't have to wait on a PR
20
+ merging and a release going out. If en-GB's holiday list is wrong for your
21
+ team, a locale you need isn't covered yet, or you want to shave overhead off
22
+ a hot path, write a mod and drop it in. It's not the right tool for
23
+ genuinely new capability — if you're building something the override surface
24
+ can't express, that's a sign to open an issue or PR the feature into the
25
+ library itself, not to keep stretching a mod to cover it. Whether a given
26
+ fix ever gets upstreamed into this repo is a separate question from whether
27
+ it works today as a mod.
28
+
29
+ ## The sandbox
30
+
31
+ A mod's code never runs in your process. Every mod — loose `.mjs` and
32
+ `.tfmod` alike — runs in its own subprocess started with Node's permission
33
+ model ([`--permission`](https://nodejs.org/api/permissions.html) on Node
34
+ 22.13+, `--experimental-permission` on Node 20 and early 22, picked per
35
+ running version). With no capabilities granted, Node itself refuses the
36
+ subprocess any filesystem read, filesystem write, child process, or worker
37
+ thread access. The only way a mod affects the host is through the
38
+ `ModContext` API, serialized over an inter-process channel.
39
+
40
+ What this means concretely:
41
+
42
+ - A mod can always read its own files — the `.mjs` file itself, or the
43
+ `.tfmod` archive's extracted contents including its `data/` directory. The
44
+ loader has to be able to load the code, so its own location is readable by
45
+ construction.
46
+ - Everything else needs a granted capability (see
47
+ [Permissions](#permissions)). Denied capabilities aren't a polite request
48
+ the mod can ignore — the subprocess literally cannot open the file,
49
+ because Node blocks the syscall.
50
+ - The subprocess does not inherit your environment variables. `process.env`
51
+ inside a mod contains `PATH`, `TZ`, `LANG`, and a few Windows bootstrap
52
+ variables, nothing else. There is no grant that changes this: environment
53
+ variables are where CI tokens and database URLs live, and the permission
54
+ model has no flag that could gate them, so they don't arrive in the first
55
+ place.
56
+ - Mod output still reaches you: `console.log` from a mod goes to stderr
57
+ (the protocol channel is stdout, or on Windows the reply file).
58
+
59
+ The channel itself is platform-dependent, and the difference is visible
60
+ if you look: on macOS and Linux it's the subprocess's pipes, read directly
61
+ by file descriptor. A Windows named pipe carries no file descriptor for
62
+ the host's end — a fact of the platform, not a Node bug — so there each
63
+ mod's subprocess gets a private scratch directory under the system temp
64
+ folder and the same line protocol rides two plain files. Two consequences
65
+ worth knowing: a runtime-override call costs a few extra milliseconds on
66
+ Windows (the subprocess polls for new requests rather than being woken by
67
+ the kernel), and the subprocess necessarily holds filesystem write access
68
+ to its own scratch directory — the one place its protocol replies live —
69
+ even when `fs:write` was denied. That's the whole carve-out: mod code
70
+ still cannot write anywhere else, so the denial isn't weakened anywhere a
71
+ human keeps files.
72
+
73
+ Four things the sandbox does **not** do, said plainly rather than buried:
74
+
75
+ 1. **It does not restrict network access.** Node's permission model cannot
76
+ gate sockets on any supported version, so there is no `net` capability
77
+ to grant or deny. A mod can still import `node:http` and make requests.
78
+ Deleting the global `fetch` removes the most convenient path but not
79
+ `node:http` itself. If your threat model requires no network egress, do
80
+ not run third-party mods.
81
+ 2. **It does not sandbox `createFormatter` token handlers at format time.**
82
+ Custom tokens are rebuilt from handler source and run in the host process
83
+ (see [Overriding functions](#overriding-functions) for the why) — treat a
84
+ mod that registers custom tokens as trusted code for that table.
85
+ 3. **It is a least-privilege control, not a defense against a determined
86
+ attacker.** Node documents its permission model that way and this sandbox
87
+ inherits the ceiling. One concrete gap: cross-process signaling
88
+ (`process._debugProcess` and friends) isn't gated by the permission model
89
+ on any supported version, so any process owned by the same OS user can
90
+ reach any other. Containing that needs an OS-level sandbox, which is
91
+ explicitly out of scope here.
92
+ 4. **It contains runaway mods; it can't prevent them.** A wall-clock
93
+ watchdog (10s for `register()`, 5s per runtime override call) and a
94
+ resident-memory ceiling (512MB per subprocess, polled from the parent)
95
+ kill a mod that hangs or balloons — killed *before* it takes the host
96
+ down, which is containment, not prevention. The memory number is watched
97
+ as actual RSS from the parent, so `ArrayBuffer` allocations count (a V8
98
+ heap limit wouldn't see them) and `--max-old-space-size` set anywhere in
99
+ the process tree can't quietly neuter it.
100
+
101
+ On Node 20 (and Node 22 before 22.13) the permission model is experimental
102
+ and the load report says so; treat sandboxing there as best-effort. On Node
103
+ 22.13+ it's stable.
104
+
105
+ ## Writing a mod
106
+
107
+ A mod is a `.mjs` file that default-exports an object with a `name` and a
108
+ `register(ctx, config)` function. `ctx` is the same registration API
109
+ `index.ts` exports for everyone else — `registerLocale`,
110
+ `registerLocaleVocab`, `registerRelativeGrammar`, `createFormatter`,
111
+ `createHolidayCalendar` — nothing beyond that. A mod that needs more than
112
+ those functions expose is asking for something this library doesn't support
113
+ yet, not something to route around by reaching into internals that could
114
+ shift under it without warning. `config` is `{}` for a loose `.mjs` mod —
115
+ there's no manifest to declare settings in, so there's nothing to resolve;
116
+ see [Mod settings and `config/`](#mod-settings-and-config) for mods that
117
+ need user-adjustable settings, which means packaging as `.tfmod`.
118
+
119
+ ```js
120
+ // mods/en-gb-bank-holidays.mjs
121
+ export default {
122
+ name: 'en-gb-bank-holidays',
123
+ version: '1.0.0',
124
+ register(ctx) {
125
+ ctx.createHolidayCalendar([
126
+ { month: 1, day: 1, name: "New Year's Day" },
127
+ { month: 12, day: 25, name: 'Christmas Day' },
128
+ { month: 12, day: 26, name: 'Boxing Day' },
129
+ ]);
130
+ },
131
+ };
132
+ ```
133
+
134
+ Put that in `mods/` at your project root (the folder the CLI is run from,
135
+ not inside this package's own checkout) and run any CLI command — the loader
136
+ reports what it found on stderr:
137
+
138
+ ```
139
+ $ temporal-fmt validate "yyyy-MM-dd"
140
+ temporal-fmt mods:
141
+ loaded en-gb-bank-holidays@1.0.0 (en-gb-bank-holidays.mjs) [sandboxed: no permissions — a loose .mjs mod can't request any, package as .tfmod to ask for capabilities]
142
+ valid
143
+ ```
144
+
145
+ `version` is optional and only shows up in that report — it's for your own
146
+ tracking, not something the loader checks. That's a different field from
147
+ `temporalFmtVersion`, which *is* checked against the installed
148
+ `temporal-fmt` version, but only exists on `.tfmod` manifests (see [Pinning
149
+ a mod to a `temporal-fmt` version](#pinning-a-mod-to-a-temporal-fmt-version))
150
+ — a loose `.mjs` mod has no manifest to declare it in.
151
+
152
+ A loose `.mjs` mod has no manifest, which also means it has no way to ask
153
+ for capabilities: it runs with **zero** permissions, and a register() that
154
+ touches `node:fs` or spawns anything fails with the access error plus an
155
+ explanation, rather than mysteriously not working. That's the trade for the
156
+ format's simplicity — anything that needs filesystem or process access has
157
+ to be a `.tfmod`.
158
+
159
+ ## Packaging a mod as `.tfmod`
160
+
161
+ A loose `.mjs` file covers the common case, but it's one file — no bundled
162
+ data, and the loader has to import it in a sandbox just to find out its
163
+ `name` before deciding load order. For anything bigger than that, package
164
+ the mod as a `.tfmod` archive instead: a gzipped tar (same format as
165
+ `.tgz`, renamed for identity) containing a manifest the loader can read
166
+ without running any code, plus the mod's actual implementation:
167
+
168
+ ```
169
+ en-gb-bank-holidays.tfmod
170
+ ├── mod.json — name, version, main, requires, priority, permissions, temporalFmtVersion, config
171
+ ├── main.mjs — the mod's entry point (same shape as a loose .mjs mod's default export, minus `name`/`version`/`requires`/`priority` — mod.json owns those)
172
+ └── data/ — optional: JSON files, locale tables, anything main.mjs wants to read at register() time
173
+ ```
174
+
175
+ ```json
176
+ // mod.json
177
+ {
178
+ "name": "en-gb-bank-holidays",
179
+ "version": "1.0.0",
180
+ "main": "main.mjs",
181
+ "requires": ["some-other-mod"],
182
+ "priority": 0,
183
+ "permissions": ["fs:read"],
184
+ "temporalFmtVersion": "^0.9.0"
185
+ }
186
+ ```
187
+
188
+ ```js
189
+ // main.mjs
190
+ export default {
191
+ register(ctx) {
192
+ ctx.createHolidayCalendar([
193
+ { month: 1, day: 1, name: "New Year's Day" },
194
+ { month: 12, day: 25, name: 'Christmas Day' },
195
+ ]);
196
+ },
197
+ };
198
+ ```
199
+
200
+ Build the archive with plain `tar` — no special tooling:
201
+
202
+ ```sh
203
+ tar -czf en-gb-bank-holidays.tfmod mod.json main.mjs data/
204
+ ```
205
+
206
+ Drop that in `mods/` alongside any loose `.mjs` mods you have; the loader
207
+ treats both formats as one pool for load-order and conflict purposes. The
208
+ report shows `mod.json`'s `name`, not anything from `main.mjs` itself:
209
+
210
+ ```
211
+ $ temporal-fmt validate "yyyy-MM-dd"
212
+ temporal-fmt mods:
213
+ loaded en-gb-bank-holidays@1.0.0 (en-gb-bank-holidays.tfmod) [sandboxed: fs:read granted]
214
+ valid
215
+ ```
216
+
217
+ Why bother with an archive format at all instead of just supporting
218
+ multi-file `.mjs` mods directly: `mod.json` is metadata the loader can read
219
+ with zero code execution, which is what makes cross-mod dependency
220
+ resolution work honestly — with a loose `.mjs` mod, the loader has no choice
221
+ but to import the file (in its sandbox) to learn its `name`/`requires`,
222
+ before it even knows whether that mod should run. A `.tfmod`'s manifest is
223
+ checked, and the whole dependency graph is resolved, before `main.mjs` is
224
+ ever imported. It's also the only shape that can declare `permissions`,
225
+ `config`, or `temporalFmtVersion`, for the same reason: no manifest, no
226
+ declaration.
227
+
228
+ Failure modes are per-archive, same as loose mods — one bad `.tfmod` doesn't
229
+ block anything else in `mods/`:
230
+
231
+ - `mod.json` missing or malformed (no `name`, no `main`, or
232
+ `requires`/`priority`/`temporalFmtVersion`/`config`/`permissions` the
233
+ wrong type) — reported with what was expected, `main.mjs` is never
234
+ imported.
235
+ - `mod.json` names a `main` file that isn't actually in the archive —
236
+ reported with the missing filename.
237
+ - `mod.json` `"main"` (or the mod name) escaping the extraction directory —
238
+ absolute paths, `..` segments, and symlinks that resolve outside are
239
+ rejected before anything is imported. A `.tfmod` runs only files from
240
+ inside its own archive; this is a security boundary, not a nicety.
241
+ - The archive isn't a valid gzip/tar (corrupted, wrong format, a `.tfmod`
242
+ extension slapped on some other file) — reported with the extraction
243
+ error.
244
+ - `main.mjs`'s default export doesn't have a `register` function — reported,
245
+ same as a loose mod's malformed export.
246
+ - `temporalFmtVersion` doesn't match the installed `temporal-fmt` version —
247
+ reported with the range and the actual version, `main.mjs` is never
248
+ imported. See [Pinning a mod to a `temporal-fmt`
249
+ version](#pinning-a-mod-to-a-temporal-fmt-version).
250
+ - `permissions` names something that isn't a capability — reported with the
251
+ supported list. See below.
252
+
253
+ Extraction happens to a temporary directory that's cleaned up after the
254
+ load pass — nothing from a `.tfmod` sticks around on disk after the CLI
255
+ command finishes. Extraction shells out to the system `tar` binary rather
256
+ than adding a tar/gzip-parsing dependency, consistent with this package
257
+ staying dependency-free (see the [README](./README.md#providing-temporal)
258
+ for the same call made about the polyfill) — if `tar` isn't on the system
259
+ `PATH`, the archive fails to load with that reason rather than crashing the
260
+ CLI.
261
+
262
+ ## Permissions
263
+
264
+ A `.tfmod` declares what it wants in `mod.json`'s `"permissions"` array,
265
+ marking each entry required or optional:
266
+
267
+ ```json
268
+ {
269
+ "name": "data-reader",
270
+ "version": "1.0.0",
271
+ "main": "main.mjs",
272
+ "permissions": [
273
+ { "capability": "fs:read", "required": true },
274
+ { "capability": "fs:write", "required": false }
275
+ ]
276
+ }
277
+ ```
278
+
279
+ The closed list maps one-to-one onto what Node's permission model can
280
+ actually enforce:
281
+
282
+ | Capability | Grants |
283
+ |---|---|
284
+ | `fs:read` | reading files anywhere on the filesystem |
285
+ | `fs:write` | writing files anywhere on the filesystem |
286
+ | `child-process` | spawning child processes |
287
+ | `worker` | starting worker threads |
288
+
289
+ That's the whole list. `fs:read` and `fs:write` are deliberately coarse —
290
+ path scoping ("fs access to only `./data`") isn't something a yes/no
291
+ terminal answer can express honestly, so v1 doesn't pretend to offer it;
292
+ it's a possible follow-up if the permission model's path-scoped flags turn
293
+ out to be worth building on. `net` and `env` are absent because no flag
294
+ backs them: network access can't be restricted by the permission model (see
295
+ [The sandbox](#the-sandbox)), and environment variables are handled by
296
+ never delivering them to the subprocess at all. `addons` is absent because
297
+ native code escapes every other restriction — a mod that needs native
298
+ addons can't be sandboxed, full stop.
299
+
300
+ A bare string array (`"permissions": ["fs:read"]`) still works — it's the
301
+ format from before required/optional existed, and every entry in it is
302
+ treated as required, so nothing that used to fail on denial silently
303
+ downgrades. Leaving `"required"` out of an object entry means the same
304
+ thing: a mod that doesn't say is asking, not wishing.
305
+
306
+ On first load (and after a version bump), the loader asks about each
307
+ requested capability in the terminal:
308
+
309
+ ```
310
+ temporal-fmt: allow "data-reader" to access fs:read? (y/N)
311
+ ```
312
+
313
+ Empty input is "no". A non-interactive context — CI, piped stdin — can't
314
+ ask anyone, so it denies by default and says so in the report rather than
315
+ quietly granting.
316
+
317
+ **Required is a promise, so don't make it lightly.** Denying a required
318
+ capability fails the mod's load outright, with the reason in the report:
319
+
320
+ ```
321
+ failed data-reader.tfmod: denied required permission: fs:read — "data-reader" won't load without it. Grant it with "node scripts/managePermissions.mjs grant data-reader@1.0.0 fs:read", or delete .temporal-fmt-permissions.json to re-ask everything.
322
+ ```
323
+
324
+ From the mod-author side: only mark a capability required when the mod is
325
+ genuinely useless without it. Every required capability is another y/N
326
+ between the user and your mod doing anything, and another way for the load
327
+ to fail. Anything the mod can live without belongs in an optional entry
328
+ and a `hasPermission` branch.
329
+
330
+ **Optional means the mod runs with less, and the report says so.** Denying
331
+ an optional capability doesn't fail the load — the subprocess starts with
332
+ only what was granted, and the report line reads `downgraded` rather than
333
+ `loaded`, so "this ran, but with less access than it asked for" is visible
334
+ without reading the fine print:
335
+
336
+ ```
337
+ downgraded data-reader@1.0.0 (data-reader.tfmod) [sandboxed: fs:read granted, fs:write denied (optional)]
338
+ ```
339
+
340
+ **`ctx.hasPermission(capability)`** tells a mod what it actually got, so
341
+ it can degrade instead of crashing — skip loading supplementary locale
342
+ data from disk when `fs:read` was denied, rather than assuming it's there
343
+ and throwing:
344
+
345
+ ```js
346
+ register(ctx) {
347
+ let extra = {};
348
+ if (ctx.hasPermission('fs:read')) {
349
+ extra = JSON.parse(readFileSync('supplementary.json', 'utf8'));
350
+ }
351
+ ctx.registerLocaleVocab('xx-extra', baseVocabMergedWith(extra));
352
+ }
353
+ ```
354
+
355
+ Nothing forces this. A mod that doesn't check and touches the missing
356
+ capability anyway fails with the permission model's access error, reported
357
+ like any other `register()` crash — the design offers graceful
358
+ degradation, it can't make an author take it. Two answers are the same
359
+ everywhere: `net` is false (it isn't a capability, see the table above),
360
+ and in a context you built in-process with `buildModContextFor()` — no
361
+ sandbox attached — everything in the table reads as true, because nothing
362
+ is gating it there.
363
+
364
+ Answers are remembered in `.temporal-fmt-permissions.json`, next to
365
+ `mods/` (like the `config/` directory — it's the host project's data about
366
+ what it has agreed to, not part of the mod). The file is keyed by
367
+ `name@version`: bump the version and you're asked again; keep the version
368
+ and the cached answer applies; delete the file and everything is asked
369
+ again. It's plain JSON, safe to edit by hand:
370
+
371
+ ```json
372
+ {
373
+ "data-reader@1.0.0": { "fs:read": true },
374
+ "risky@2.3.0": { "fs:read": true, "fs:write": false }
375
+ }
376
+ ```
377
+
378
+ To change an answer without re-triggering a load, use the management
379
+ script that ships with the package:
380
+
381
+ ```
382
+ node scripts/managePermissions.mjs list
383
+ node scripts/managePermissions.mjs grant data-reader@1.0.0 fs:read
384
+ node scripts/managePermissions.mjs deny data-reader@1.0.0 fs:write
385
+ node scripts/managePermissions.mjs reset data-reader@1.0.0
386
+ ```
387
+
388
+ `grant`/`deny` flip one entry, no prompt. `reset` clears a mod's cached
389
+ answers so the next load asks again — the way to reconsider without
390
+ bumping the mod's version. All of them write the same file the load-time
391
+ prompts use, so there's one cache, not two that can drift apart. Changes
392
+ apply the next time the mod loads; a process already running it is
393
+ unaffected. A mod with no `version` in its `mod.json` is addressed by its
394
+ bare name (`grant data-reader fs:read`), and `list` prints the exact key
395
+ when in doubt.
396
+
397
+ ## Pinning a mod to a `temporal-fmt` version
398
+
399
+ `mod.json` can declare `temporalFmtVersion`, either an exact version
400
+ (`"0.9.32"`) or a caret range (`"^0.9.0"`, meaning ">=0.9.0, <0.10.0" —
401
+ same meaning npm gives `^` in `package.json`). If the installed
402
+ `temporal-fmt` doesn't satisfy it, the mod fails to load with the range and
403
+ the actual version, before `main.mjs` is ever imported:
404
+
405
+ ```
406
+ failed holidays.tfmod: "en-gb-bank-holidays" needs temporal-fmt ^2.0.0 (>=2.0.0 <3.0.0), host is 0.9.32
407
+ ```
408
+
409
+ This exists because nothing else catches the alternative: a mod built
410
+ against one version's override surface (which functions are zero-fanout and
411
+ therefore overridable — see [Overriding functions](#overriding-functions))
412
+ has no way to know if a future release moved a function it depends on, and
413
+ would otherwise fail with whatever confusing error `register()` happens to
414
+ throw, or — worse — silently do nothing if the call it expected to matter
415
+ just no longer has any effect. A declared range turns that into one clear,
416
+ pre-`register()` failure instead.
417
+
418
+ Omitting `temporalFmtVersion` is allowed — the mod loads against whatever
419
+ version is installed, same as before this field existed. Loose `.mjs` mods
420
+ have no manifest to put this in at all, so they can't declare a version
421
+ requirement; that's one real reason to prefer `.tfmod` for anything you
422
+ plan to distribute rather than just run yourself.
423
+
424
+ There's no dependency-resolution logic here, unlike `requires`/`priority` —
425
+ this is a single boolean check (does the host version satisfy the range),
426
+ not something that affects load order.
427
+
428
+ ## Mod settings and `config/`
429
+
430
+ A mod can declare user-adjustable settings in `mod.json`'s `config` array,
431
+ and `register()` receives the resolved values as its second argument:
432
+
433
+ ```json
434
+ // mod.json
435
+ {
436
+ "name": "en-gb-bank-holidays",
437
+ "main": "main.mjs",
438
+ "config": [
439
+ { "key": "includeScottish", "type": "boolean", "default": false },
440
+ { "key": "observedRule", "type": "enum", "default": "nearest-weekday", "choices": ["nearest-weekday", "strict-date"] },
441
+ { "key": "yearsAhead", "type": "number", "default": 5, "min": 1, "max": 20 }
442
+ ]
443
+ }
444
+ ```
445
+
446
+ ```js
447
+ // main.mjs
448
+ export default {
449
+ register(ctx, config) {
450
+ const years = config.yearsAhead; // 5, unless overridden below
451
+ ctx.createHolidayCalendar(buildHolidays({ scottish: config.includeScottish, years }));
452
+ },
453
+ };
454
+ ```
455
+
456
+ Four setting types are supported: `string`, `number` (with optional
457
+ `min`/`max`), `boolean`, and `enum` (a string constrained to `choices`).
458
+ Every entry needs a `key` and a `default` — the default is what
459
+ `register()` gets if the user hasn't overridden that setting, which also
460
+ means a mod with no `config/<name>.json` file on disk at all still runs
461
+ normally, just entirely on defaults.
462
+
463
+ To override a setting, drop a JSON file at `config/<mod-name>.json` —
464
+ **next to `mods/`, not inside it** (so re-downloading or updating the
465
+ `.tfmod` never touches a user's settings, the same reason Forge keeps
466
+ `config/` and `mods/` as siblings rather than bundling settings into the
467
+ jar):
468
+
469
+ ```
470
+ your-project/
471
+ ├── mods/
472
+ │ └── en-gb-bank-holidays.tfmod
473
+ └── config/
474
+ └── en-gb-bank-holidays.json — { "includeScottish": true, "yearsAhead": 10 }
475
+ ```
476
+
477
+ Only keys the schema actually declares can be set — anything else is a
478
+ mistake worth surfacing, not a silent no-op:
479
+
480
+ ```
481
+ temporal-fmt mods:
482
+ loaded en-gb-bank-holidays@1.0.0 (en-gb-bank-holidays.tfmod)
483
+ failed config/en-gb-bank-holidays.json: en-gb-bank-holidays: config key "yearsAhead" must be <= 20, got 50 (using default)
484
+ failed config/en-gb-bank-holidays.json: en-gb-bank-holidays: unknown config key "includeWelsh" (not declared in this mod's schema)
485
+ ```
486
+
487
+ An invalid value for a declared key falls back to that key's default rather
488
+ than failing the whole mod — one typo'd number in a config file shouldn't
489
+ take down a working mod, but it's reported so the mistake doesn't go
490
+ unnoticed either. This is deliberately not JSON Schema: no nesting, no
491
+ `$ref`, no conditional rules — just the handful of primitive shapes an
492
+ actual setting realistically is, kept dependency-free the same way
493
+ `temporalFmtVersion` checking and `.tfmod` extraction are.
494
+
495
+ Loose `.mjs` mods have no manifest to declare a schema in, so
496
+ `register()`'s second argument is always `{}` for them — same as a `.tfmod`
497
+ mod that didn't declare a `config` field at all.
498
+
499
+ ## Load order, dependencies, and conflicts
500
+
501
+ By default mods load in filename order — alphabetical, deterministic, but
502
+ not something you'd want to rely on once two mods actually need to run in a
503
+ specific order relative to each other. Two fields on the mod object
504
+ control that directly:
505
+
506
+ - `requires: string[]` — other mods' `name` fields that must load (and
507
+ finish `register()`) before this one. The loader resolves this as a
508
+ dependency graph, not just "sort requires first" — if A requires B and B
509
+ requires nothing, B always loads first regardless of filename.
510
+ - `priority: number` — tiebreak for mods with no dependency relationship
511
+ to each other. Higher loads later. Defaults to `0`.
512
+
513
+ ```js
514
+ export default {
515
+ name: 'extended-en-gb-holidays',
516
+ requires: ['en-gb-bank-holidays'],
517
+ priority: 10,
518
+ register(ctx) {
519
+ // runs after en-gb-bank-holidays, and after anything else at a lower priority
520
+ },
521
+ };
522
+ ```
523
+
524
+ Two failure modes come out of this, both reported per-mod without blocking
525
+ the rest:
526
+
527
+ - **Missing dependency** — `requires` names a mod that isn't in `mods/`.
528
+ That mod fails to load; whatever it would've registered doesn't happen,
529
+ and other mods that don't depend on it load normally.
530
+ - **Circular dependency** — A requires B requires A (or a longer cycle).
531
+ Every mod in the cycle fails, each reported with what it's still waiting
532
+ on.
533
+
534
+ Registration itself is still last-write-wins, same as calling
535
+ `registerLocale` twice for the same tag outside of mods — that's existing,
536
+ intentional behavior (see [Locales](./README.md#locales)), not something
537
+ mods change. What mods add is *visibility* into it: if two mods register
538
+ the same locale tag, the same relative-time-grammar language, or the same
539
+ custom token name, the load report calls it out as a conflict and says
540
+ which one won:
541
+
542
+ ```
543
+ temporal-fmt mods:
544
+ loaded holiday-pack-a (conflict-1.mjs)
545
+ loaded holiday-pack-b (conflict-2.mjs)
546
+ conflict on locale "cv-CV": holiday-pack-a, holiday-pack-b — "holiday-pack-b" wins (loaded last)
547
+ ```
548
+
549
+ This is informational, not a failure — both mods still loaded, the last one
550
+ to register just took the key, and now you know it happened instead of
551
+ silently getting whichever mod's filename sorted last. If that's not what
552
+ you want, `priority` is the knob: raise the one that should win, or add a
553
+ `requires` so the loser explicitly runs first and the winner's intent is
554
+ unambiguous in the mod itself, not just in a startup log line.
555
+
556
+ Mod names have to be unique across `mods/` — two files claiming the same
557
+ `name` is ambiguous the moment either one shows up in another mod's
558
+ `requires`, so the second one to load fails with which file already claimed
559
+ that name.
560
+
561
+ ## Overriding functions
562
+
563
+ The five registration functions above are additive — they add a locale, a
564
+ holiday set, a token, alongside whatever's already there.
565
+ `ctx.overrideFormat` and `ctx.overrideParse` work differently: they let a
566
+ mod replace the actual `format()`/`parse()` implementation everywhere in
567
+ the library, which is what makes a real bugfix or performance mod possible
568
+ rather than just new data being registered alongside an unfixed bug.
569
+
570
+ ```js
571
+ export default {
572
+ name: 'fast-format',
573
+ register(ctx) {
574
+ ctx.overrideFormat((original, value, formatStr, options) => {
575
+ // Handle the one hot-path format string yourself; fall back to the
576
+ // real implementation for everything else.
577
+ if (formatStr === 'yyyy-MM-dd') {
578
+ return `${value.year}-${String(value.month).padStart(2, '0')}-${String(value.day).padStart(2, '0')}`;
579
+ }
580
+ return original(value, formatStr, options);
581
+ });
582
+ },
583
+ };
584
+ ```
585
+
586
+ `impl` always receives the real built-in as its first argument
587
+ (`original`), regardless of what else is loaded — call it to keep existing
588
+ behavior for cases you're not trying to change, or ignore it to replace the
589
+ behavior outright. The override applies consistently everywhere in the
590
+ library, not just to whoever imports the function from the package root —
591
+ `formatRange()`'s internal use of `format()`, for instance, sees it too.
592
+ Remove the mod and restart, and it's back to the unmodified built-in;
593
+ nothing about this touches the source file on disk.
594
+
595
+ **Only one mod may hold each override point.** A second override call for
596
+ the same function — from any mod, even one that `requires` the first —
597
+ fails immediately with which mod already owns it:
598
+
599
+ ```
600
+ temporal-fmt mods:
601
+ loaded override-1 (a-override1.mjs)
602
+ failed b-override2.mjs: temporal-fmt: "format" is already overridden by mod "override-1" — mod "override-2" can't also override it. [...]
603
+ ```
604
+
605
+ This is a hard failure, not last-write-wins like the registration
606
+ functions — two mods silently fighting over the same function's behavior
607
+ is a correctness bug in whatever depends on this library, not a cosmetic
608
+ surprise. There's no mechanism for two separate mod files to layer through
609
+ the same override point in sequence; if two mods both need to change a
610
+ function's behavior, one has to incorporate the other's fix directly rather
611
+ than composing through the override twice.
612
+
613
+ **How an override runs under the sandbox.** A closure can't cross a process
614
+ boundary, so a mod that installs one keeps its subprocess alive, and every
615
+ `format()`/`parse()` call in the host forwards to it and waits for the
616
+ answer — synchronously, because those are synchronous APIs and their
617
+ result has to come back inside the caller's stack frame. What that costs
618
+ and what's done about it:
619
+
620
+ - Each overridden call is one round trip to the subprocess — a couple
621
+ of milliseconds, not the nanoseconds of an in-process function call, plus
622
+ a few more on Windows where the channel rides files (see
623
+ [The sandbox](#the-sandbox)). An override in a hot loop is *slower than
624
+ no override at all*, never mind faster. If your mod's whole point is
625
+ performance, it has to save more than the bridge costs.
626
+ - The loader learns, per format string, whether your impl just forwards to
627
+ the built-in. Format strings your mod passes through stop paying the
628
+ round trip entirely after the first call; only the strings you actually
629
+ change keep crossing the process boundary. `formatRange()`'s two
630
+ endpoints batch into a single trip.
631
+ - If the subprocess stops answering — the mod's impl hangs, crashes, or
632
+ hits a permission violation mid-call — the host stops waiting after 5
633
+ seconds (shown in the load report), kills the subprocess, warns on
634
+ stderr, and falls back to the built-in behavior for the rest of the
635
+ process. One hung mod doesn't take your formatting down with it.
636
+ - The value your impl receives is rebuilt from its fields on the other
637
+ side. For real Temporal inputs it's rehydrated as a Temporal instance of
638
+ the same type; if your impl mutates the value (don't), the caller won't
639
+ see it — nothing is shared across the boundary. Values and results that
640
+ can't survive JSON serialization degrade the way JSON does: functions
641
+ vanish, and a `bigint` throws.
642
+
643
+ **Which functions are overridable.** `format`, `formatToParts`, and `parse`
644
+ always were. Beyond those, any function in this library that nothing *else*
645
+ in the library calls internally is also overridable — if a function has
646
+ zero internal call sites, there's no risk of some other module holding a
647
+ stale direct reference that a mod's fix would silently fail to reach, so it
648
+ gets the same `overrideXxx()` treatment. As of this version, that's:
649
+
650
+ `compileFormat`, `compileParser`, `parseRelative`, `explainFormat`,
651
+ `tokenizeFormat`, `listTokens`, `tokenInfo`, `isValidFormat`,
652
+ `validateFormat`, `fieldForToken`, `monthsInYear`, `isLeapYear`,
653
+ `isLeapMonth`, `weekOfYear`, `weekYear`, `getMonth`, `getWeekday`,
654
+ `isEqual`, `isBefore`, `isAfter`, `clamp`, `isBetween`, `isToday`,
655
+ `isTomorrow`, `isYesterday`, `isSameDay`, `isSameWeek`, `isSameMonth`,
656
+ `isSameQuarter`, `isSameYear`, `isWeekday`, `floor`, `ceil`, `truncate`,
657
+ `parseRFC3339`, `formatRFC3339`, `parseRFC2822`, `parseHTTPDate`,
658
+ `fromUnixMicroseconds`, `fromUnixNanoseconds`, `toUnixSeconds`,
659
+ `toUnixMilliseconds`, `toUnixMicroseconds`, `toUnixNanoseconds`,
660
+ `parseSQL`, `formatSQL`, `formatDurationToParts`, `parseDuration`,
661
+ `parseISODuration`, `formatISODuration`, `balanceDuration`,
662
+ `compareDuration`, `subtractDuration`, `getLocale`, `hasLocale`,
663
+ `createConfig`, `mergeWithConfig`, `listRegisteredGrammars`, `interval`,
664
+ `overlaps`, `intersection`, `union`, `mergeIntervals`, `formatRangeToParts`,
665
+ `between`, `parseRRule`, `formatRRule`, `createBusinessCalendar`,
666
+ `subtractBusinessDays`, `nextHoliday`, `previousHoliday`, `resolveZoned`,
667
+ `getNextTransition`, `getPreviousTransition`, `possibleInstantsFor`,
668
+ `getAutocompleteData`, `getHoverDocs`, `getInlineDiagnostics`,
669
+ `previewFormat`, `getDocUrl`, `translateDateFnsFormatString`.
670
+
671
+ Each follows the `ctx.overrideXxx((original, ...args) => ...)` shape shown
672
+ above for `overrideFormat`. Functions *not* in this list — `round`,
673
+ `subtract`, `difference`, `formatDistance`, and others that other parts of
674
+ this library call directly — aren't overridable this way: something else
675
+ in the codebase holds its own direct reference to them, so a mod's
676
+ override would silently miss those internal callers, which is worse than
677
+ not offering the override at all. A function moves onto this list only
678
+ when an audit confirms nothing internal still calls it directly. If you
679
+ need to change one of those, that's a real feature request for making it
680
+ internally indirect first, not something `overrideFormat`-style code can
681
+ paper over.
682
+
683
+ **Custom token handlers are the one thing that runs host-side.** A
684
+ `createFormatter()` call inside register() is setup-time: the token table
685
+ (name, field, and each handler's source text) is shipped back to the host
686
+ and the `Formatter` is rebuilt in-process, because a formatter is a hot
687
+ path and every token lookup can't pay a subprocess round trip. The loader
688
+ verifies each handler is self-contained — it revives the handler from
689
+ source and compares its output against the original's before accepting it,
690
+ and refuses the mod with a clear reason if the revival doesn't behave the
691
+ same. So: a token handler must not close over anything outside its own
692
+ body, and its code runs in the host process at format() time. That last
693
+ part is the real trade — treat formatter-token mods as trusted code, same
694
+ as you'd treat anything you run in-process.
695
+
696
+ ## If you're writing the mod in TypeScript
697
+
698
+ Compile it and rename the output before it goes in `mods/` — the loader
699
+ only accepts `.mjs`. It won't run a TS file for you, and it won't skip one
700
+ quietly either: a `.ts` file sitting in `mods/` shows up in the load report
701
+ as a failure with the exact compile command to run, because a mod that
702
+ silently never loads is worse than one that fails loudly.
703
+
704
+ ```sh
705
+ tsc en-gb-bank-holidays.ts --module esnext --target esnext --outDir mods
706
+ mv mods/en-gb-bank-holidays.js mods/en-gb-bank-holidays.mjs
707
+ ```
708
+
709
+ If you're importing `ModContext` or `Mod` for the types while you write it,
710
+ both are exported from `temporal-fmt` itself:
711
+
712
+ ```ts
713
+ import type { Mod, ModContext } from 'temporal-fmt';
714
+
715
+ const mod: Mod = {
716
+ name: 'en-gb-bank-holidays',
717
+ register(ctx: ModContext) {
718
+ ctx.createHolidayCalendar([{ month: 1, day: 1, name: "New Year's Day" }]);
719
+ },
720
+ };
721
+
722
+ export default mod;
723
+ ```
724
+
725
+ ## What happens when a mod is broken
726
+
727
+ Each mod loads independently — one throwing doesn't stop the rest from
728
+ loading, and it doesn't stop the CLI command you actually ran. Every
729
+ failure mode ends up as one line in the report:
730
+
731
+ - Wrong file extension (`.ts`, `.js`, anything but `.mjs`) — reported with
732
+ the compile-and-rename instructions above.
733
+ - Default export isn't shaped right (missing `name`, missing `register`,
734
+ `register` isn't a function, or `requires`/`priority` are the wrong
735
+ type) — reported with what was expected.
736
+ - The file fails to import (a syntax error, a bad import path inside the
737
+ mod) — reported with the underlying error message.
738
+ - Two mods claim the same `name` — reported against whichever file loaded
739
+ second.
740
+ - A `requires` entry names a mod that isn't present, or is part of a
741
+ dependency cycle — see [Load order, dependencies, and
742
+ conflicts](#load-order-dependencies-and-conflicts).
743
+ - `register()` throws — reported with the thrown message, same as any other
744
+ registration call in this library (see [Typed
745
+ errors](./README.md#typed-errors) for what `registerLocale`/
746
+ `createHolidayCalendar` themselves throw on bad input).
747
+ - A required permission was denied — `denied required permission: <caps>`
748
+ plus how to change the answer, before any of the mod's code runs (see
749
+ [Permissions](#permissions)). An *optional* permission being denied
750
+ isn't a failure: the mod runs with what it got and the report says
751
+ `downgraded`.
752
+ - `register()` touches a capability that wasn't granted — the permission
753
+ model's access error, plus (for a loose `.mjs` mod) the reminder that it
754
+ can't request any. A mod that checks `ctx.hasPermission()` first can
755
+ skip this fate; one that doesn't, hits the wall.
756
+ - `register()` doesn't finish within 10 seconds, a runtime override
757
+ stops answering within 5, or the subprocess grows past the 512MB
758
+ resident-memory ceiling — the subprocess is killed, that mod fails
759
+ (or, at runtime, falls back to the built-in with a stderr warning), and
760
+ the rest of the load pass is unaffected. The memory watch is RSS
761
+ polled from the parent, so `ArrayBuffer` bytes count and
762
+ `--max-old-space-size` set in the process tree doesn't neuter it.
763
+
764
+ None of these bring down the CLI. A `mods/` folder that doesn't exist is
765
+ the common case, not a failure — most runs won't have one, and the loader
766
+ stays silent about it rather than printing "no mods found" noise on every
767
+ command.
768
+
769
+ ## Using mods outside the CLI
770
+
771
+ `loadMods()` lives in `scripts/loadMods.mjs` and ships with the published
772
+ package, alongside the sandbox it runs mods in
773
+ (`scripts/modSandbox.mjs`, `scripts/modWorker.mjs`, `scripts/modWire.mjs`),
774
+ the config resolver (`scripts/modConfig.mjs`), and the permission-cache
775
+ editor (`scripts/managePermissions.mjs`). If you're embedding
776
+ `temporal-fmt` in your own app rather than using the CLI, load it at your
777
+ own startup and the same sandboxing, prompting, and reporting apply:
778
+
779
+ ```js
780
+ // A path into node_modules, not a bare specifier — the loader is
781
+ // Node-only ESM and deliberately isn't in package.json "exports".
782
+ import { loadMods, formatModLoadReport } from './node_modules/temporal-fmt/scripts/loadMods.mjs';
783
+
784
+ const report = await loadMods(); // defaults to ./mods
785
+ if (report.loaded.length > 0 || report.downgraded.length > 0 || report.failed.length > 0) {
786
+ console.error('temporal-fmt mods:\n' + formatModLoadReport(report));
787
+ }
788
+ ```
789
+
790
+ Why a path and not `temporal-fmt/scripts/loadMods.mjs`: the `exports` map
791
+ promises every listed subpath has a CommonJS form for `require()`
792
+ consumers, and a mod loader that spawns subprocesses has no honest CJS
793
+ twin. So the scripts ship in the package but stay off the exports map —
794
+ importing by path is the trade. If your bundler chokes on that, the old
795
+ option still works: copy the loader out and vendor it.
796
+
797
+ A mod that installs a runtime override keeps its subprocess alive for as
798
+ long as your process might call `format()`/`parse()` — but it won't keep
799
+ your process alive: the loader drops its event-loop references after
800
+ loading, and the subprocess shuts itself down when your process exits. If
801
+ you want deterministic teardown before then (a server that hot-reloads
802
+ mods, say), `stopModSubprocesses()` from `scripts/modSandbox.mjs` SIGTERMs
803
+ every live one.
804
+
805
+ Mod support (loose `.mjs` mods, `.tfmod` archives, and everything in this
806
+ document) requires `temporal-fmt` 0.9.4 or later — that's the version it
807
+ landed in. Subprocess sandboxing arrived after that; the load report tells
808
+ you when you're on a Node version where it's still experimental.