Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit a50b968

Browse files
committed
Return fixed time from new Date()/Date.now(), closes #225
...unless `--actual-time`/`actualTime: true` option set.
1 parent 516dcc5 commit a50b968

File tree

20 files changed

+236
-14
lines changed

20 files changed

+236
-14
lines changed

packages/cache/src/cache.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,8 @@ export class Cache implements CacheInterface {
165165
res: BaseResponse | Response
166166
): Promise<undefined> {
167167
if (this.#blockGlobalAsyncIO) assertInRequest();
168-
getRequestContext()?.incrementExternalSubrequests();
168+
const ctx = getRequestContext();
169+
ctx?.incrementExternalSubrequests();
169170
req = normaliseRequest(req);
170171

171172
if (res instanceof Response && res.webSocket) {
@@ -201,14 +202,16 @@ export class Cache implements CacheInterface {
201202
metadata,
202203
});
203204
await waitForOpenInputGate();
205+
ctx?.advanceCurrentTime();
204206
}
205207

206208
async match(
207209
req: RequestInfo,
208210
options?: CacheMatchOptions
209211
): Promise<Response | undefined> {
210212
if (this.#blockGlobalAsyncIO) assertInRequest();
211-
getRequestContext()?.incrementExternalSubrequests();
213+
const ctx = getRequestContext();
214+
ctx?.incrementExternalSubrequests();
212215
req = normaliseRequest(req);
213216
// Cloudflare only caches GET requests
214217
if (req.method !== "GET" && !options?.ignoreMethod) return;
@@ -218,6 +221,7 @@ export class Cache implements CacheInterface {
218221
const storage = await this.#storage;
219222
const cached = await storage.get<CachedMeta>(key);
220223
await waitForOpenInputGate();
224+
ctx?.advanceCurrentTime();
221225
if (!cached) return;
222226

223227
// Check we're not trying to load cached data created in Miniflare 1.
@@ -250,7 +254,8 @@ export class Cache implements CacheInterface {
250254
options?: CacheMatchOptions
251255
): Promise<boolean> {
252256
if (this.#blockGlobalAsyncIO) assertInRequest();
253-
getRequestContext()?.incrementExternalSubrequests();
257+
const ctx = getRequestContext();
258+
ctx?.incrementExternalSubrequests();
254259
req = normaliseRequest(req);
255260
// Cloudflare only caches GET requests
256261
if (req.method !== "GET" && !options?.ignoreMethod) return false;
@@ -261,6 +266,7 @@ export class Cache implements CacheInterface {
261266
await waitForOpenOutputGate();
262267
const result = storage.delete(key);
263268
await waitForOpenInputGate();
269+
ctx?.advanceCurrentTime();
264270
return result;
265271
}
266272
}

packages/cache/test/cache.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Storage,
1010
} from "@miniflare/shared";
1111
import {
12+
advancesTime,
1213
getObjectProperties,
1314
utf8Decode,
1415
utf8Encode,
@@ -439,3 +440,11 @@ test("Cache: operations throw outside request handler", async (t) => {
439440
await ctx.runWith(() => cache.match("http://localhost:8787/"));
440441
await ctx.runWith(() => cache.delete("http://localhost:8787/"));
441442
});
443+
test("Cache: operations advance current time", async (t) => {
444+
const { cache } = t.context;
445+
await advancesTime(t, () =>
446+
cache.put("http://localhost:8787/", testResponse())
447+
);
448+
await advancesTime(t, () => cache.match("http://localhost:8787/"));
449+
await advancesTime(t, () => cache.delete("http://localhost:8787/"));
450+
});

packages/cli-parser/test/help.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ Core Options:
5757
--global-timers Allow setting timers outside handlers [boolean]
5858
--global-random Allow secure random generation outside [boolean]
5959
handlers
60+
--actual-time Always return correct time from Date [boolean]
61+
methods
6062
6163
Test Options:
6264
-b, --boolean-option Boolean option [boolean]

packages/core/src/plugins/core.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
btoa,
5353
createCompatFetch,
5454
createCrypto,
55+
createDate,
5556
createTimer,
5657
withStringFormDataFiles,
5758
} from "../standards";
@@ -105,6 +106,7 @@ export interface CoreOptions {
105106
globalAsyncIO?: boolean;
106107
globalTimers?: boolean;
107108
globalRandom?: boolean;
109+
actualTime?: boolean;
108110
}
109111

110112
function mapMountEntries(
@@ -350,6 +352,13 @@ export class CorePlugin extends Plugin<CoreOptions> implements CoreOptions {
350352
})
351353
globalRandom?: boolean;
352354

355+
@Option({
356+
type: OptionType.BOOLEAN,
357+
description: "Always return correct time from Date methods",
358+
fromWrangler: ({ miniflare }) => miniflare?.actual_time,
359+
})
360+
actualTime?: boolean;
361+
353362
readonly processedModuleRules: ProcessedModuleRule[] = [];
354363

355364
readonly upstreamURL?: URL;
@@ -465,6 +474,8 @@ export class CorePlugin extends Plugin<CoreOptions> implements CoreOptions {
465474
// Approximate with serialize/deserialize if not there.
466475
structuredClone: globalThis.structuredClone ?? structuredCloneBuffer,
467476

477+
Date: createDate(this.actualTime),
478+
468479
...compatGlobals,
469480

470481
// The types below would be included automatically, but it's not possible
@@ -477,7 +488,6 @@ export class CorePlugin extends Plugin<CoreOptions> implements CoreOptions {
477488
BigInt64Array,
478489
BigUint64Array,
479490
DataView,
480-
Date,
481491
Float32Array,
482492
Float64Array,
483493
Int8Array,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { getRequestContext } from "@miniflare/shared";
2+
3+
function requestContextNow() {
4+
// If there's no request context, just fallback to actual time
5+
return getRequestContext()?.currentTime ?? Date.now();
6+
}
7+
8+
export function createDate(actualTime = false): typeof Date {
9+
// If we always want the actual time, return Date as-is
10+
if (actualTime) return Date;
11+
// Otherwise, proxy it to use the request context's time
12+
return new Proxy(Date, {
13+
construct(target, args, newTarget) {
14+
return Reflect.construct(
15+
target,
16+
// If `args` is empty, this is `new Date()`
17+
args.length === 0 ? [requestContextNow()] : args,
18+
newTarget
19+
);
20+
},
21+
get(target, propertyKey, receiver) {
22+
if (propertyKey === "now") return requestContextNow;
23+
return Reflect.get(target, propertyKey, receiver);
24+
},
25+
});
26+
}

packages/core/src/standards/http.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,7 @@ export async function fetch(
832832
res.headers[fetchSymbols.kGuard] = "immutable";
833833

834834
await waitForOpenInputGate();
835+
ctx?.advanceCurrentTime();
835836
return withInputGating(res);
836837
}
837838

packages/core/src/standards/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./cf";
22
export * from "./crypto";
3+
export * from "./date";
34
export * from "./domexception";
45
export * from "./encoding";
56
export * from "./event";

packages/core/test/plugins/core.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import assert from "assert";
22
import fs from "fs/promises";
3+
34
import path from "path";
45
import { CorePlugin, Request, Response, Scheduler } from "@miniflare/core";
56
import {
67
Compatibility,
78
NoOpLog,
89
PluginContext,
10+
RequestContext,
911
STRING_SCRIPT_PATH,
1012
} from "@miniflare/shared";
1113
import {
@@ -43,6 +45,8 @@ test("CorePlugin: parses options from argv", (t) => {
4345
"fetch_refuses_unknown_protocols",
4446
"--compat-flag",
4547
"durable_object_fetch_allows_relative_url",
48+
"--usage-model",
49+
"unbound",
4650
"--upstream",
4751
"https://github.com/mrbbot",
4852
"--watch",
@@ -63,6 +67,7 @@ test("CorePlugin: parses options from argv", (t) => {
6367
"--global-async-io",
6468
"--global-timers",
6569
"--global-random",
70+
"--actual-time",
6671
]);
6772
t.deepEqual(options, {
6873
scriptPath: "script.js",
@@ -79,6 +84,7 @@ test("CorePlugin: parses options from argv", (t) => {
7984
"fetch_refuses_unknown_protocols",
8085
"durable_object_fetch_allows_relative_url",
8186
],
87+
usageModel: "unbound",
8288
upstream: "https://github.com/mrbbot",
8389
watch: true,
8490
debug: true,
@@ -105,6 +111,7 @@ test("CorePlugin: parses options from argv", (t) => {
105111
globalAsyncIO: true,
106112
globalTimers: true,
107113
globalRandom: true,
114+
actualTime: true,
108115
});
109116
options = parsePluginArgv(CorePlugin, [
110117
"-c",
@@ -158,6 +165,7 @@ test("CorePlugin: parses options from wrangler config", async (t) => {
158165
global_async_io: true,
159166
global_timers: true,
160167
global_random: true,
168+
actual_time: true,
161169
},
162170
},
163171
configDir
@@ -212,6 +220,7 @@ test("CorePlugin: parses options from wrangler config", async (t) => {
212220
globalAsyncIO: true,
213221
globalTimers: true,
214222
globalRandom: true,
223+
actualTime: true,
215224
});
216225
// Check build upload dir defaults to dist
217226
options = parsePluginWranglerConfig(
@@ -246,6 +255,7 @@ test("CorePlugin: logs options", (t) => {
246255
globalAsyncIO: true,
247256
globalTimers: true,
248257
globalRandom: true,
258+
actualTime: true,
249259
});
250260
t.deepEqual(logs, [
251261
// script is OptionType.NONE so omitted
@@ -267,6 +277,7 @@ test("CorePlugin: logs options", (t) => {
267277
"Allow Global Async I/O: true",
268278
"Allow Global Timers: true",
269279
"Allow Global Secure Random: true",
280+
"Actual Time: true",
270281
]);
271282
// Check logs default wrangler config/package paths
272283
logs = logPluginOptions(CorePlugin, {
@@ -511,6 +522,23 @@ test("CorePlugin: setup: includes navigator only if compatibility flag enabled",
511522
globals = (await plugin.setup()).globals;
512523
t.is(globals?.navigator.userAgent, "Cloudflare-Workers");
513524
});
525+
test("CorePlugin: setup: uses actual time if option enabled", async (t) => {
526+
let plugin = new CorePlugin(ctx);
527+
let DateImpl: typeof Date = (await plugin.setup()).globals?.Date;
528+
await new RequestContext().runWith(async () => {
529+
const previous = DateImpl.now();
530+
await new Promise((resolve) => setTimeout(resolve, 100));
531+
t.is(DateImpl.now(), previous);
532+
});
533+
534+
plugin = new CorePlugin(ctx, { actualTime: true });
535+
DateImpl = (await plugin.setup()).globals?.Date;
536+
await new RequestContext().runWith(async () => {
537+
const previous = DateImpl.now();
538+
await new Promise((resolve) => setTimeout(resolve, 100));
539+
t.not(DateImpl.now(), previous);
540+
});
541+
});
514542

515543
test("CorePlugin: setup: structuredClone: creates deep-copy of value", async (t) => {
516544
const plugin = new CorePlugin(ctx);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { setTimeout } from "timers/promises";
2+
import { createDate } from "@miniflare/core";
3+
import { RequestContext } from "@miniflare/shared";
4+
import test from "ava";
5+
6+
test("Date: uses regular Date if actual time requested", (t) => {
7+
const DateImpl = createDate(true);
8+
t.is(DateImpl, Date);
9+
});
10+
11+
test("Date: new Date() returns fixed time if no parameters provided", async (t) => {
12+
// Check inside request context
13+
const DateImpl = createDate();
14+
const ctx = new RequestContext();
15+
await ctx.runWith(async () => {
16+
const previous = new DateImpl().getTime();
17+
await setTimeout(100);
18+
t.is(new DateImpl().getTime(), previous);
19+
ctx.advanceCurrentTime();
20+
t.not(new DateImpl().getTime(), previous);
21+
});
22+
23+
// Check outside request context (should return actual time here)
24+
const previous = new DateImpl().getTime();
25+
await setTimeout(100);
26+
t.not(new DateImpl().getTime(), previous);
27+
});
28+
test("Date: new Date() accepts regular constructor parameters", (t) => {
29+
const DateImpl = createDate();
30+
const date = new DateImpl(1000);
31+
t.is(date.getTime(), 1000);
32+
});
33+
test("Date: new Date() passes instanceof checks", (t) => {
34+
const DateImpl = createDate();
35+
t.not(DateImpl, Date);
36+
t.true(new DateImpl() instanceof Date);
37+
t.true(new Date() instanceof DateImpl);
38+
});
39+
40+
test("Date: Date.now() returns fixed time", async (t) => {
41+
// Check inside request context
42+
const DateImpl = createDate();
43+
const ctx = new RequestContext();
44+
await ctx.runWith(async () => {
45+
const previous = DateImpl.now();
46+
await setTimeout(100);
47+
t.is(DateImpl.now(), previous);
48+
ctx.advanceCurrentTime();
49+
t.not(DateImpl.now(), previous);
50+
});
51+
52+
// Check outside request context (should return actual time here)
53+
const previous = DateImpl.now();
54+
await setTimeout(100);
55+
t.not(DateImpl.now(), previous);
56+
});

packages/core/test/standards/http.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
} from "@miniflare/shared";
3030
import {
3131
TestLog,
32+
advancesTime,
3233
triggerPromise,
3334
useServer,
3435
utf8Decode,
@@ -937,6 +938,10 @@ test("fetch: increments subrequest count for each redirect", async (t) => {
937938
await ctx.runWith(() => fetch(url));
938939
t.is(ctx.externalSubrequests, 4);
939940
});
941+
test("fetch: advances current time", async (t) => {
942+
const upstream = (await useServer(t, (req, res) => res.end("upstream"))).http;
943+
await advancesTime(t, () => fetch(upstream));
944+
});
940945
test("fetch: waits for output gate to open before fetching", async (t) => {
941946
let fetched = false;
942947
const upstream = (

0 commit comments

Comments
 (0)