mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(core): Generalize QuickJS Intl host delegation (no-changelog) (#37213)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Luigi Gubello <luigi.gubello@n8n.io> Co-authored-by: Luigi Gubello <27034386+luigigubello@users.noreply.github.com>
This commit is contained in:
parent
4cda10ebc6
commit
3d23aaddcd
|
|
@ -592,6 +592,74 @@ describe(`Integration: ExpressionEvaluator (${engineName})`, () => {
|
|||
).toBe('1/1/70, 12:00 AM');
|
||||
});
|
||||
|
||||
it('should support unbound Intl format and construction-time options snapshot', () => {
|
||||
const data = { $json: {} };
|
||||
|
||||
// V8 exposes format as a bound-function getter, so it must work unbound.
|
||||
expect(
|
||||
evaluator.evaluate(
|
||||
'{{ [new Date(0), new Date(86400000)].map(new Intl.DateTimeFormat("en-US", { timeZone: "UTC" }).format).join("|") }}',
|
||||
data,
|
||||
caller,
|
||||
),
|
||||
).toBe('1/1/1970|1/2/1970');
|
||||
|
||||
// V8 snapshots options at construction; later mutation must not change output.
|
||||
expect(
|
||||
evaluator.evaluate(
|
||||
'{{ (() => { var o = { minimumFractionDigits: 2, maximumFractionDigits: 2 }; var nf = new Intl.NumberFormat("en-US", o); o.minimumFractionDigits = 5; o.maximumFractionDigits = 5; return nf.format(1); })() }}',
|
||||
data,
|
||||
caller,
|
||||
),
|
||||
).toBe('1.00');
|
||||
});
|
||||
|
||||
it('should delegate Collator, PluralRules, DisplayNames and Intl statics to host Intl', () => {
|
||||
const data = { $json: {} };
|
||||
|
||||
expect(evaluator.evaluate('{{ new Intl.PluralRules("en-US").select(1) }}', data, caller)).toBe(
|
||||
'one',
|
||||
);
|
||||
expect(
|
||||
evaluator.evaluate('{{ new Intl.Collator("en-US").compare("a", "b") }}', data, caller),
|
||||
).toBe(-1);
|
||||
expect(
|
||||
evaluator.evaluate(
|
||||
'{{ new Intl.DisplayNames(["en"], { type: "region" }).of("DE") }}',
|
||||
data,
|
||||
caller,
|
||||
),
|
||||
).toBe('Germany');
|
||||
expect(evaluator.evaluate('{{ Intl.getCanonicalLocales("EN-us")[0] }}', data, caller)).toBe(
|
||||
'en-US',
|
||||
);
|
||||
expect(
|
||||
evaluator.evaluate(
|
||||
'{{ Intl.supportedValuesOf("calendar").includes("gregory") }}',
|
||||
data,
|
||||
caller,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose real Intl.Locale properties including weekInfo', () => {
|
||||
const data = { $json: {} };
|
||||
|
||||
expect(evaluator.evaluate('{{ new Intl.Locale("de-DE").language }}', data, caller)).toBe('de');
|
||||
expect(evaluator.evaluate('{{ new Intl.Locale("de-DE").baseName }}', data, caller)).toBe(
|
||||
'de-DE',
|
||||
);
|
||||
// Luxon reads getWeekInfo() where present, the weekInfo getter otherwise —
|
||||
// both engines report ISO first day 1 (Monday) for de-DE.
|
||||
expect(
|
||||
evaluator.evaluate(
|
||||
'{{ (l => ("getWeekInfo" in l ? l.getWeekInfo() : l.weekInfo).firstDay)(new Intl.Locale("de-DE")) }}',
|
||||
data,
|
||||
caller,
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('should round-trip an invalid Date return value', () => {
|
||||
const data = { $json: {} };
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
TRANSFER_OPAQUE_KEY,
|
||||
TRANSFER_TYPE_KEY,
|
||||
} from '../runtime/transfer';
|
||||
import { LruCache } from '../evaluator/lru-cache';
|
||||
import {
|
||||
dispatchHostCall,
|
||||
getArrayElement,
|
||||
|
|
@ -307,6 +308,137 @@ function hostValueToJson(value: unknown): string {
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Intl host delegation
|
||||
//
|
||||
// QuickJS ships without ECMA-402, so the guest's Intl API is a set of thin
|
||||
// wrapper classes that delegate to the host's native Intl via the single
|
||||
// `__intl(ctorName, op, locales, options, ...args)` callback. The table below
|
||||
// maps constructor name → how to construct the host object and which ops the
|
||||
// guest may invoke on it. Anything not in the table is rejected.
|
||||
// ============================================================================
|
||||
|
||||
type IntlLocales = string | string[] | undefined;
|
||||
|
||||
/** Formatter args arrive as dumped guest values; DateTimeFormat dates travel as timestamps. */
|
||||
function toDateArg(ts: unknown): Date {
|
||||
return typeof ts === 'number' ? new Date(ts) : new Date();
|
||||
}
|
||||
|
||||
interface IntlDispatchEntry {
|
||||
create: (locales: IntlLocales, options: unknown) => unknown;
|
||||
supportedLocalesOf?: (locales: IntlLocales) => string[];
|
||||
ops: Record<string, (instance: unknown, args: unknown[]) => unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the properties Luxon and user code read from Intl.Locale into a
|
||||
* plain object the guest copies onto its wrapper instance. weekInfo moved
|
||||
* from a getter to getWeekInfo() across V8 versions — read both shapes.
|
||||
*/
|
||||
function dumpLocale(loc: Intl.Locale): Record<string, unknown> {
|
||||
const withWeekInfo = loc as Intl.Locale & { weekInfo?: unknown; getWeekInfo?: () => unknown };
|
||||
return {
|
||||
tag: loc.toString(),
|
||||
baseName: loc.baseName,
|
||||
language: loc.language,
|
||||
script: loc.script,
|
||||
region: loc.region,
|
||||
calendar: loc.calendar,
|
||||
caseFirst: loc.caseFirst,
|
||||
collation: loc.collation,
|
||||
hourCycle: loc.hourCycle,
|
||||
numberingSystem: loc.numberingSystem,
|
||||
numeric: loc.numeric,
|
||||
weekInfo: withWeekInfo.getWeekInfo?.() ?? withWeekInfo.weekInfo,
|
||||
};
|
||||
}
|
||||
|
||||
const INTL_DISPATCH: Record<string, IntlDispatchEntry> = {
|
||||
DateTimeFormat: {
|
||||
create: (locales, options) =>
|
||||
new Intl.DateTimeFormat(locales, options as Intl.DateTimeFormatOptions | undefined),
|
||||
supportedLocalesOf: (locales) => Intl.DateTimeFormat.supportedLocalesOf(locales ?? []),
|
||||
ops: {
|
||||
format: (fmt, args) => (fmt as Intl.DateTimeFormat).format(toDateArg(args[0])),
|
||||
formatToParts: (fmt, args) => (fmt as Intl.DateTimeFormat).formatToParts(toDateArg(args[0])),
|
||||
resolvedOptions: (fmt) => (fmt as Intl.DateTimeFormat).resolvedOptions(),
|
||||
},
|
||||
},
|
||||
NumberFormat: {
|
||||
create: (locales, options) =>
|
||||
new Intl.NumberFormat(locales, options as Intl.NumberFormatOptions | undefined),
|
||||
supportedLocalesOf: (locales) => Intl.NumberFormat.supportedLocalesOf(locales ?? []),
|
||||
ops: {
|
||||
format: (fmt, args) => (fmt as Intl.NumberFormat).format(args[0] as number),
|
||||
formatToParts: (fmt, args) => (fmt as Intl.NumberFormat).formatToParts(args[0] as number),
|
||||
resolvedOptions: (fmt) => (fmt as Intl.NumberFormat).resolvedOptions(),
|
||||
},
|
||||
},
|
||||
RelativeTimeFormat: {
|
||||
create: (locales, options) =>
|
||||
new Intl.RelativeTimeFormat(locales, options as Intl.RelativeTimeFormatOptions | undefined),
|
||||
supportedLocalesOf: (locales) => Intl.RelativeTimeFormat.supportedLocalesOf(locales ?? []),
|
||||
ops: {
|
||||
format: (fmt, args) =>
|
||||
(fmt as Intl.RelativeTimeFormat).format(
|
||||
args[0] as number,
|
||||
args[1] as Intl.RelativeTimeFormatUnit,
|
||||
),
|
||||
formatToParts: (fmt, args) =>
|
||||
(fmt as Intl.RelativeTimeFormat).formatToParts(
|
||||
args[0] as number,
|
||||
args[1] as Intl.RelativeTimeFormatUnit,
|
||||
),
|
||||
resolvedOptions: (fmt) => (fmt as Intl.RelativeTimeFormat).resolvedOptions(),
|
||||
},
|
||||
},
|
||||
ListFormat: {
|
||||
create: (locales, options) =>
|
||||
new Intl.ListFormat(locales, options as Intl.ListFormatOptions | undefined),
|
||||
supportedLocalesOf: (locales) => Intl.ListFormat.supportedLocalesOf(locales ?? []),
|
||||
ops: {
|
||||
format: (fmt, args) => (fmt as Intl.ListFormat).format(args[0] as string[]),
|
||||
formatToParts: (fmt, args) => (fmt as Intl.ListFormat).formatToParts(args[0] as string[]),
|
||||
resolvedOptions: (fmt) => (fmt as Intl.ListFormat).resolvedOptions(),
|
||||
},
|
||||
},
|
||||
Collator: {
|
||||
create: (locales, options) =>
|
||||
new Intl.Collator(locales, options as Intl.CollatorOptions | undefined),
|
||||
supportedLocalesOf: (locales) => Intl.Collator.supportedLocalesOf(locales ?? []),
|
||||
ops: {
|
||||
compare: (col, args) => (col as Intl.Collator).compare(args[0] as string, args[1] as string),
|
||||
resolvedOptions: (col) => (col as Intl.Collator).resolvedOptions(),
|
||||
},
|
||||
},
|
||||
PluralRules: {
|
||||
create: (locales, options) =>
|
||||
new Intl.PluralRules(locales, options as Intl.PluralRulesOptions | undefined),
|
||||
supportedLocalesOf: (locales) => Intl.PluralRules.supportedLocalesOf(locales ?? []),
|
||||
ops: {
|
||||
select: (pr, args) => (pr as Intl.PluralRules).select(args[0] as number),
|
||||
resolvedOptions: (pr) => (pr as Intl.PluralRules).resolvedOptions(),
|
||||
},
|
||||
},
|
||||
DisplayNames: {
|
||||
create: (locales, options) =>
|
||||
new Intl.DisplayNames(locales as string | string[], options as Intl.DisplayNamesOptions),
|
||||
supportedLocalesOf: (locales) => Intl.DisplayNames.supportedLocalesOf(locales ?? []),
|
||||
ops: {
|
||||
of: (dn, args) => (dn as Intl.DisplayNames).of(args[0] as string),
|
||||
resolvedOptions: (dn) => (dn as Intl.DisplayNames).resolvedOptions(),
|
||||
},
|
||||
},
|
||||
Locale: {
|
||||
create: (locales, options) =>
|
||||
new Intl.Locale(locales as string, options as Intl.LocaleOptions | undefined),
|
||||
ops: {
|
||||
dump: (loc) => dumpLocale(loc as Intl.Locale),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* QuickJsBridge - Runtime bridge using quickjs-emscripten for expression evaluation.
|
||||
*
|
||||
|
|
@ -332,6 +464,12 @@ export class QuickJsBridge implements RuntimeBridge {
|
|||
// Long-lived host-callback handles (Intl polyfills) — disposed on dispose()
|
||||
private intlHandles: Array<import('quickjs-emscripten').QuickJSHandle> = [];
|
||||
|
||||
// Memoized host Intl formatters keyed by ctor + locales + options.
|
||||
// Construction dominates per-call cost (~39µs for a DateTimeFormat vs ~1µs
|
||||
// for format on an existing one). Capped because locales/options are
|
||||
// user-influenced — unbounded, this would be a memory leak by user input.
|
||||
private intlFormatterCache = new LruCache<string, unknown>(200);
|
||||
|
||||
// Wall-clock deadlines of the execute() calls currently in flight, outer to
|
||||
// inner. execute() can re-enter (e.g. $evaluateExpression), and the runtime
|
||||
// has a single interrupt handler; the handler interrupts once the earliest
|
||||
|
|
@ -443,153 +581,38 @@ export class QuickJsBridge implements RuntimeBridge {
|
|||
/**
|
||||
* Inject a polyfill for the Intl API.
|
||||
*
|
||||
* QuickJS doesn't include Intl, but Luxon (bundled in the runtime) uses
|
||||
* Intl.DateTimeFormat, Intl.NumberFormat, Intl.RelativeTimeFormat, and
|
||||
* Intl.ListFormat extensively. Rather than shimming all of this in pure JS,
|
||||
* we register host callback functions that delegate to Node.js's real Intl
|
||||
* implementation, then build lightweight JS wrapper classes that call them.
|
||||
* QuickJS doesn't include Intl, but Luxon (bundled in the runtime) and user
|
||||
* expressions use it extensively. Rather than shimming ECMA-402 in pure JS,
|
||||
* we register a single `__intl` host callback that dispatches to Node.js's
|
||||
* real Intl implementation (see INTL_DISPATCH), then build lightweight JS
|
||||
* wrapper classes that call it.
|
||||
*/
|
||||
private injectIntlPolyfill(): void {
|
||||
if (!this.vm) throw new Error('Context not initialized');
|
||||
|
||||
const vm = this.vm;
|
||||
|
||||
const dtfFn = vm.newFunction('__intl_dtf', (...handles) => {
|
||||
const intlFn = vm.newFunction('__intl', (...handles) => {
|
||||
const args = handles.map((h) => vm.dump(h));
|
||||
const op = args[0] as string;
|
||||
const locales = args[1] as string | string[] | undefined;
|
||||
const options = args[2] as Intl.DateTimeFormatOptions | undefined;
|
||||
|
||||
try {
|
||||
if (op === 'resolvedOptions') {
|
||||
const fmt = new Intl.DateTimeFormat(locales, options);
|
||||
return this.hostValueToQuickJSHandle(fmt.resolvedOptions());
|
||||
}
|
||||
if (op === 'format') {
|
||||
const fmt = new Intl.DateTimeFormat(locales, options);
|
||||
const ts = args[3] as number | undefined;
|
||||
const date = ts !== undefined ? new Date(ts) : new Date();
|
||||
return this.hostValueToQuickJSHandle(fmt.format(date));
|
||||
}
|
||||
if (op === 'formatToParts') {
|
||||
const fmt = new Intl.DateTimeFormat(locales, options);
|
||||
const ts = args[3] as number | undefined;
|
||||
const date = ts !== undefined ? new Date(ts) : new Date();
|
||||
return this.hostValueToQuickJSHandle(fmt.formatToParts(date));
|
||||
}
|
||||
if (op === 'supportedLocalesOf') {
|
||||
return this.hostValueToQuickJSHandle(
|
||||
Intl.DateTimeFormat.supportedLocalesOf(locales ?? []),
|
||||
);
|
||||
}
|
||||
return this.hostValueToQuickJSHandle(
|
||||
this.dispatchIntl(
|
||||
args[0] as string,
|
||||
args[1] as string,
|
||||
args[2] as IntlLocales,
|
||||
args[3],
|
||||
args.slice(4),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
return this.hostValueToQuickJSHandle({
|
||||
__intlError: true,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return vm.undefined;
|
||||
});
|
||||
vm.setProp(vm.global, '__intl_dtf', dtfFn);
|
||||
this.intlHandles.push(dtfFn);
|
||||
|
||||
const nfFn = vm.newFunction('__intl_nf', (...handles) => {
|
||||
const args = handles.map((h) => vm.dump(h));
|
||||
const op = args[0] as string;
|
||||
const locales = args[1] as string | string[] | undefined;
|
||||
const options = args[2] as Intl.NumberFormatOptions | undefined;
|
||||
|
||||
try {
|
||||
if (op === 'resolvedOptions') {
|
||||
const fmt = new Intl.NumberFormat(locales, options);
|
||||
return this.hostValueToQuickJSHandle(fmt.resolvedOptions());
|
||||
}
|
||||
if (op === 'format') {
|
||||
const fmt = new Intl.NumberFormat(locales, options);
|
||||
const num = args[3] as number;
|
||||
return this.hostValueToQuickJSHandle(fmt.format(num));
|
||||
}
|
||||
if (op === 'formatToParts') {
|
||||
const fmt = new Intl.NumberFormat(locales, options);
|
||||
const num = args[3] as number;
|
||||
return this.hostValueToQuickJSHandle(fmt.formatToParts(num));
|
||||
}
|
||||
} catch (err) {
|
||||
return this.hostValueToQuickJSHandle({
|
||||
__intlError: true,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return vm.undefined;
|
||||
});
|
||||
vm.setProp(vm.global, '__intl_nf', nfFn);
|
||||
this.intlHandles.push(nfFn);
|
||||
|
||||
const rtfFn = vm.newFunction('__intl_rtf', (...handles) => {
|
||||
const args = handles.map((h) => vm.dump(h));
|
||||
const op = args[0] as string;
|
||||
const locales = args[1] as string | string[] | undefined;
|
||||
const options = args[2] as Intl.RelativeTimeFormatOptions | undefined;
|
||||
|
||||
try {
|
||||
if (op === 'resolvedOptions') {
|
||||
const fmt = new Intl.RelativeTimeFormat(locales, options);
|
||||
return this.hostValueToQuickJSHandle(fmt.resolvedOptions());
|
||||
}
|
||||
if (op === 'format') {
|
||||
const fmt = new Intl.RelativeTimeFormat(locales, options);
|
||||
const value = args[3] as number;
|
||||
const unit = args[4] as Intl.RelativeTimeFormatUnit;
|
||||
return this.hostValueToQuickJSHandle(fmt.format(value, unit));
|
||||
}
|
||||
if (op === 'formatToParts') {
|
||||
const fmt = new Intl.RelativeTimeFormat(locales, options);
|
||||
const value = args[3] as number;
|
||||
const unit = args[4] as Intl.RelativeTimeFormatUnit;
|
||||
return this.hostValueToQuickJSHandle(fmt.formatToParts(value, unit));
|
||||
}
|
||||
} catch (err) {
|
||||
return this.hostValueToQuickJSHandle({
|
||||
__intlError: true,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return vm.undefined;
|
||||
});
|
||||
vm.setProp(vm.global, '__intl_rtf', rtfFn);
|
||||
this.intlHandles.push(rtfFn);
|
||||
|
||||
const lfFn = vm.newFunction('__intl_lf', (...handles) => {
|
||||
const args = handles.map((h) => vm.dump(h));
|
||||
const op = args[0] as string;
|
||||
const locales = args[1] as string | string[] | undefined;
|
||||
const options = args[2] as Intl.ListFormatOptions | undefined;
|
||||
|
||||
try {
|
||||
if (op === 'format') {
|
||||
const fmt = new Intl.ListFormat(locales, options);
|
||||
const list = args[3] as string[];
|
||||
return this.hostValueToQuickJSHandle(fmt.format(list));
|
||||
}
|
||||
if (op === 'formatToParts') {
|
||||
const fmt = new Intl.ListFormat(locales, options);
|
||||
const list = args[3] as string[];
|
||||
return this.hostValueToQuickJSHandle(fmt.formatToParts(list));
|
||||
}
|
||||
if (op === 'resolvedOptions') {
|
||||
const fmt = new Intl.ListFormat(locales, options);
|
||||
return this.hostValueToQuickJSHandle(fmt.resolvedOptions());
|
||||
}
|
||||
} catch (err) {
|
||||
return this.hostValueToQuickJSHandle({
|
||||
__intlError: true,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return vm.undefined;
|
||||
});
|
||||
vm.setProp(vm.global, '__intl_lf', lfFn);
|
||||
this.intlHandles.push(lfFn);
|
||||
vm.setProp(vm.global, '__intl', intlFn);
|
||||
this.intlHandles.push(intlFn);
|
||||
|
||||
const shimCode = `
|
||||
(function() {
|
||||
|
|
@ -600,73 +623,107 @@ export class QuickJsBridge implements RuntimeBridge {
|
|||
return result;
|
||||
}
|
||||
|
||||
function DateTimeFormat(locales, options) {
|
||||
this._locales = locales;
|
||||
this._options = options || {};
|
||||
// Shallow-copy locales/options at construction — V8 snapshots them when a
|
||||
// formatter is created, so mutating them afterwards must not change output.
|
||||
// The stable snapshot also keys the host-side formatter memo.
|
||||
function snapshotOptions(options) {
|
||||
if (options === null || options === undefined) return undefined;
|
||||
var copy = {};
|
||||
for (var k in options) copy[k] = options[k];
|
||||
return copy;
|
||||
}
|
||||
function snapshotLocales(locales) {
|
||||
return Array.isArray(locales) ? locales.slice() : locales;
|
||||
}
|
||||
DateTimeFormat.prototype.resolvedOptions = function() {
|
||||
return checkError(__intl_dtf('resolvedOptions', this._locales, this._options));
|
||||
};
|
||||
DateTimeFormat.prototype.format = function(date) {
|
||||
var ts = date instanceof Date ? date.getTime() : (typeof date === 'number' ? date : Date.now());
|
||||
return checkError(__intl_dtf('format', this._locales, this._options, ts));
|
||||
};
|
||||
DateTimeFormat.prototype.formatToParts = function(date) {
|
||||
var ts = date instanceof Date ? date.getTime() : (typeof date === 'number' ? date : Date.now());
|
||||
return checkError(__intl_dtf('formatToParts', this._locales, this._options, ts));
|
||||
};
|
||||
DateTimeFormat.supportedLocalesOf = function(locales) {
|
||||
return checkError(__intl_dtf('supportedLocalesOf', locales));
|
||||
};
|
||||
|
||||
function NumberFormat(locales, options) {
|
||||
this._locales = locales;
|
||||
this._options = options || {};
|
||||
function callIntl(inst, ctorName, op, args, transform) {
|
||||
var callArgs = [ctorName, op, inst._locales, inst._options];
|
||||
var i = 0;
|
||||
if (transform) {
|
||||
callArgs.push(transform(args[0]));
|
||||
i = 1;
|
||||
}
|
||||
for (; i < args.length; i++) callArgs.push(args[i]);
|
||||
return checkError(__intl.apply(null, callArgs));
|
||||
}
|
||||
NumberFormat.prototype.resolvedOptions = function() {
|
||||
return checkError(__intl_nf('resolvedOptions', this._locales, this._options));
|
||||
};
|
||||
NumberFormat.prototype.format = function(num) {
|
||||
return checkError(__intl_nf('format', this._locales, this._options, num));
|
||||
};
|
||||
NumberFormat.prototype.formatToParts = function(num) {
|
||||
return checkError(__intl_nf('formatToParts', this._locales, this._options, num));
|
||||
};
|
||||
|
||||
function RelativeTimeFormat(locales, options) {
|
||||
this._locales = locales;
|
||||
this._options = options || {};
|
||||
// Build a wrapper class that delegates each op to the host dispatcher.
|
||||
// \`ops\` maps op name → optional first-arg transform. \`boundOp\` is also
|
||||
// installed as an instance function: V8 models format/compare as
|
||||
// bound-function getters, so e.g. dates.map(dtf.format) must work unbound.
|
||||
function makeWrapper(ctorName, ops, boundOp) {
|
||||
function Wrapper(locales, options) {
|
||||
this._locales = snapshotLocales(locales);
|
||||
this._options = snapshotOptions(options);
|
||||
if (boundOp) {
|
||||
var self = this;
|
||||
this[boundOp] = function () {
|
||||
return callIntl(self, ctorName, boundOp, arguments, ops[boundOp]);
|
||||
};
|
||||
}
|
||||
}
|
||||
var opNames = Object.keys(ops);
|
||||
for (var i = 0; i < opNames.length; i++) {
|
||||
(function (op) {
|
||||
Wrapper.prototype[op] = function () {
|
||||
return callIntl(this, ctorName, op, arguments, ops[op]);
|
||||
};
|
||||
})(opNames[i]);
|
||||
}
|
||||
Wrapper.supportedLocalesOf = function (locales) {
|
||||
return checkError(__intl(ctorName, 'supportedLocalesOf', locales));
|
||||
};
|
||||
return Wrapper;
|
||||
}
|
||||
RelativeTimeFormat.prototype.resolvedOptions = function() {
|
||||
return checkError(__intl_rtf('resolvedOptions', this._locales, this._options));
|
||||
};
|
||||
RelativeTimeFormat.prototype.format = function(value, unit) {
|
||||
return checkError(__intl_rtf('format', this._locales, this._options, value, unit));
|
||||
};
|
||||
RelativeTimeFormat.prototype.formatToParts = function(value, unit) {
|
||||
return checkError(__intl_rtf('formatToParts', this._locales, this._options, value, unit));
|
||||
};
|
||||
|
||||
function ListFormat(locales, options) {
|
||||
this._locales = locales;
|
||||
this._options = options || {};
|
||||
// DateTimeFormat dates travel as timestamps — guest Dates don't survive vm.dump().
|
||||
function toTs(date) {
|
||||
return date instanceof Date ? date.getTime() : (typeof date === 'number' ? date : Date.now());
|
||||
}
|
||||
ListFormat.prototype.format = function(list) {
|
||||
return checkError(__intl_lf('format', this._locales, this._options, list));
|
||||
};
|
||||
ListFormat.prototype.formatToParts = function(list) {
|
||||
return checkError(__intl_lf('formatToParts', this._locales, this._options, list));
|
||||
};
|
||||
ListFormat.prototype.resolvedOptions = function() {
|
||||
return checkError(__intl_lf('resolvedOptions', this._locales, this._options));
|
||||
};
|
||||
|
||||
var DateTimeFormat = makeWrapper('DateTimeFormat', { format: toTs, formatToParts: toTs, resolvedOptions: null }, 'format');
|
||||
var NumberFormat = makeWrapper('NumberFormat', { format: null, formatToParts: null, resolvedOptions: null }, 'format');
|
||||
var RelativeTimeFormat = makeWrapper('RelativeTimeFormat', { format: null, formatToParts: null, resolvedOptions: null }, null);
|
||||
var ListFormat = makeWrapper('ListFormat', { format: null, formatToParts: null, resolvedOptions: null }, null);
|
||||
var Collator = makeWrapper('Collator', { compare: null, resolvedOptions: null }, 'compare');
|
||||
var PluralRules = makeWrapper('PluralRules', { select: null, resolvedOptions: null }, null);
|
||||
var DisplayNames = makeWrapper('DisplayNames', { of: null, resolvedOptions: null }, null);
|
||||
|
||||
// Host-backed Intl.Locale: the host dumps the locale's properties once at
|
||||
// construction. Luxon feature-detects week support via ('weekInfo' in
|
||||
// Intl.Locale.prototype || 'getWeekInfo' in Intl.Locale.prototype) and
|
||||
// prefers getWeekInfo() — expose both, like current V8.
|
||||
function Locale(tag, options) {
|
||||
var dumped = checkError(__intl('Locale', 'dump', tag, snapshotOptions(options)));
|
||||
var keys = Object.keys(dumped);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var k = keys[i];
|
||||
if (k === 'weekInfo') this._weekInfo = dumped[k];
|
||||
else if (k === 'tag') this._tag = dumped[k];
|
||||
else this[k] = dumped[k];
|
||||
}
|
||||
}
|
||||
Object.defineProperty(Locale.prototype, 'weekInfo', {
|
||||
get: function () { return this._weekInfo; },
|
||||
});
|
||||
Locale.prototype.getWeekInfo = function () { return this._weekInfo; };
|
||||
Locale.prototype.toString = function () { return this._tag; };
|
||||
|
||||
globalThis.Intl = {
|
||||
DateTimeFormat: DateTimeFormat,
|
||||
NumberFormat: NumberFormat,
|
||||
RelativeTimeFormat: RelativeTimeFormat,
|
||||
ListFormat: ListFormat,
|
||||
Locale: function Locale(tag) { this.baseName = tag; this.language = tag.split('-')[0]; }
|
||||
Collator: Collator,
|
||||
PluralRules: PluralRules,
|
||||
DisplayNames: DisplayNames,
|
||||
Locale: Locale,
|
||||
getCanonicalLocales: function (locales) {
|
||||
return checkError(__intl('Intl', 'getCanonicalLocales', locales));
|
||||
},
|
||||
supportedValuesOf: function (key) {
|
||||
return checkError(__intl('Intl', 'supportedValuesOf', undefined, undefined, key));
|
||||
},
|
||||
};
|
||||
|
||||
// QuickJS's built-in toLocale* methods are locale-unaware (no ECMA-402) and
|
||||
|
|
@ -727,6 +784,47 @@ export class QuickJsBridge implements RuntimeBridge {
|
|||
this.logger.debug('[QuickJsBridge] Intl polyfill injected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Host side of the guest's `__intl(ctorName, op, locales, options, ...args)`
|
||||
* callback. Resolves the constructor and op against INTL_DISPATCH (rejecting
|
||||
* anything not in the table) and memoizes constructed formatters.
|
||||
*/
|
||||
private dispatchIntl(
|
||||
ctorName: string,
|
||||
op: string,
|
||||
locales: IntlLocales,
|
||||
options: unknown,
|
||||
args: unknown[],
|
||||
): unknown {
|
||||
// Pure static Intl functions — no constructed instance involved.
|
||||
if (ctorName === 'Intl') {
|
||||
if (op === 'getCanonicalLocales') return Intl.getCanonicalLocales(locales);
|
||||
if (op === 'supportedValuesOf') {
|
||||
return Intl.supportedValuesOf(args[0] as Parameters<typeof Intl.supportedValuesOf>[0]);
|
||||
}
|
||||
throw new TypeError(`Unsupported Intl operation: ${op}`);
|
||||
}
|
||||
const entry = INTL_DISPATCH[ctorName];
|
||||
if (!entry) throw new TypeError(`Unsupported Intl constructor: ${ctorName}`);
|
||||
if (op === 'supportedLocalesOf') {
|
||||
if (!entry.supportedLocalesOf) {
|
||||
throw new TypeError(`Intl.${ctorName} has no supportedLocalesOf`);
|
||||
}
|
||||
return entry.supportedLocalesOf(locales);
|
||||
}
|
||||
const opFn = entry.ops[op];
|
||||
if (!opFn) throw new TypeError(`Unsupported Intl.${ctorName} operation: ${op}`);
|
||||
// Guest wrappers snapshot options at construction, so the JSON key is
|
||||
// stable across calls on the same wrapper instance.
|
||||
const key = `${ctorName} | ||||