@q32/core 0.1.4 → 0.1.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/dist/env.d.ts +5 -1
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +4 -2
- package/dist/env.js.map +1 -1
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +2 -8
- package/dist/ids.js.map +1 -1
- package/package.json +2 -1
- package/src/ai.ts +42 -0
- package/src/api.ts +100 -0
- package/src/billing.ts +46 -0
- package/src/cloudflare.ts +36 -0
- package/src/crypto.ts +56 -0
- package/src/d1.ts +86 -0
- package/src/email.ts +49 -0
- package/src/encoding.ts +17 -0
- package/src/env.ts +63 -0
- package/src/hash.ts +57 -0
- package/src/http.ts +78 -0
- package/src/ids.ts +55 -0
- package/src/index.ts +22 -0
- package/src/jobs.ts +276 -0
- package/src/mcp.ts +52 -0
- package/src/oauth.ts +44 -0
- package/src/ops-events.ts +75 -0
- package/src/pg.ts +81 -0
- package/src/r2-json.ts +32 -0
- package/src/rate-limit.ts +77 -0
- package/src/seo.ts +73 -0
- package/src/session.ts +81 -0
- package/src/testing.ts +119 -0
- package/src/time.ts +18 -0
package/src/r2-json.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { sha256Hex } from "./ids.js";
|
|
2
|
+
|
|
3
|
+
export type R2JsonPutOptions = {
|
|
4
|
+
contentType?: string;
|
|
5
|
+
customMetadata?: Record<string, string>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export async function putR2Json(
|
|
9
|
+
bucket: R2Bucket,
|
|
10
|
+
key: string,
|
|
11
|
+
value: unknown,
|
|
12
|
+
options: R2JsonPutOptions = {},
|
|
13
|
+
): Promise<R2Object> {
|
|
14
|
+
return bucket.put(key, JSON.stringify(value), {
|
|
15
|
+
httpMetadata: { contentType: options.contentType ?? "application/json; charset=utf-8" },
|
|
16
|
+
customMetadata: options.customMetadata,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function getR2Json<T>(bucket: R2Bucket, key: string): Promise<T | null> {
|
|
21
|
+
const object = await bucket.get(key);
|
|
22
|
+
if (!object) return null;
|
|
23
|
+
return (await object.json()) as T;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function digestR2Key(prefix: string, value: string | Uint8Array, extension = "json"): Promise<string> {
|
|
27
|
+
const input = typeof value === "string" ? value : [...value].map((byte) => String.fromCharCode(byte)).join("");
|
|
28
|
+
const digest = await sha256Hex(input);
|
|
29
|
+
const cleanPrefix = prefix.replace(/^\/+|\/+$/g, "");
|
|
30
|
+
const cleanExtension = extension.replace(/^\./, "");
|
|
31
|
+
return `${cleanPrefix}/${digest.slice(0, 2)}/${digest}.${cleanExtension}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { D1DatabaseLike } from "./d1.js";
|
|
2
|
+
import { sha256Hex } from "./ids.js";
|
|
3
|
+
|
|
4
|
+
export type RateLimitDecision = {
|
|
5
|
+
allowed: boolean;
|
|
6
|
+
keyHash: string;
|
|
7
|
+
count: number;
|
|
8
|
+
limit: number;
|
|
9
|
+
resetAt: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type FixedWindowRateLimitInput = {
|
|
13
|
+
namespace: string;
|
|
14
|
+
key: string;
|
|
15
|
+
limit: number;
|
|
16
|
+
windowSeconds: number;
|
|
17
|
+
now?: Date;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function checkD1FixedWindowRateLimit(
|
|
21
|
+
db: D1DatabaseLike,
|
|
22
|
+
input: FixedWindowRateLimitInput,
|
|
23
|
+
): Promise<RateLimitDecision> {
|
|
24
|
+
const now = input.now ?? new Date();
|
|
25
|
+
const windowSeconds = Math.max(1, Math.floor(input.windowSeconds));
|
|
26
|
+
const limit = Math.max(1, Math.floor(input.limit));
|
|
27
|
+
const bucketStartMs = Math.floor(now.getTime() / (windowSeconds * 1000)) * windowSeconds * 1000;
|
|
28
|
+
const bucket = new Date(bucketStartMs).toISOString();
|
|
29
|
+
const resetAt = new Date(bucketStartMs + windowSeconds * 1000).toISOString();
|
|
30
|
+
const keyHash = await sha256Hex(`${input.namespace}:${input.key}`);
|
|
31
|
+
|
|
32
|
+
await db
|
|
33
|
+
.prepare(
|
|
34
|
+
`INSERT INTO rate_limits (namespace, key_hash, bucket, count, reset_at)
|
|
35
|
+
VALUES (?, ?, ?, 1, ?)
|
|
36
|
+
ON CONFLICT(namespace, key_hash, bucket)
|
|
37
|
+
DO UPDATE SET count = count + 1, reset_at = excluded.reset_at`,
|
|
38
|
+
)
|
|
39
|
+
.bind(input.namespace, keyHash, bucket, resetAt)
|
|
40
|
+
.run();
|
|
41
|
+
|
|
42
|
+
const row = await db
|
|
43
|
+
.prepare(
|
|
44
|
+
`SELECT count, reset_at AS resetAt
|
|
45
|
+
FROM rate_limits
|
|
46
|
+
WHERE namespace = ? AND key_hash = ? AND bucket = ?
|
|
47
|
+
LIMIT 1`,
|
|
48
|
+
)
|
|
49
|
+
.bind(input.namespace, keyHash, bucket)
|
|
50
|
+
.first<{ count: number; resetAt: string }>();
|
|
51
|
+
|
|
52
|
+
const count = Number(row?.count ?? 1);
|
|
53
|
+
return {
|
|
54
|
+
allowed: count <= limit,
|
|
55
|
+
keyHash,
|
|
56
|
+
count,
|
|
57
|
+
limit,
|
|
58
|
+
resetAt: row?.resetAt ?? resetAt,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function deleteExpiredD1RateLimitBuckets(db: D1DatabaseLike, now = new Date()): Promise<number> {
|
|
63
|
+
const result = await db.prepare("DELETE FROM rate_limits WHERE reset_at < ?").bind(now.toISOString()).run();
|
|
64
|
+
return Number(result.meta.changes ?? 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const D1_RATE_LIMITS_SCHEMA = `
|
|
68
|
+
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
69
|
+
namespace TEXT NOT NULL,
|
|
70
|
+
key_hash TEXT NOT NULL,
|
|
71
|
+
bucket TEXT NOT NULL,
|
|
72
|
+
count INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
reset_at TEXT NOT NULL,
|
|
74
|
+
PRIMARY KEY (namespace, key_hash, bucket)
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS rate_limits_reset_at_idx ON rate_limits(reset_at);
|
|
77
|
+
`;
|
package/src/seo.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type SitemapUrl = {
|
|
2
|
+
loc: string;
|
|
3
|
+
lastmod?: string;
|
|
4
|
+
changefreq?: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
|
|
5
|
+
priority?: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type PageMeta = {
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
canonical?: string;
|
|
12
|
+
image?: string;
|
|
13
|
+
type?: string;
|
|
14
|
+
noindex?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function xmlEscape(value: string): string {
|
|
18
|
+
return value
|
|
19
|
+
.replace(/&/g, "&")
|
|
20
|
+
.replace(/</g, "<")
|
|
21
|
+
.replace(/>/g, ">")
|
|
22
|
+
.replace(/"/g, """)
|
|
23
|
+
.replace(/'/g, "'");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function renderSitemapXml(urls: SitemapUrl[]): string {
|
|
27
|
+
const body = urls
|
|
28
|
+
.map((url) => {
|
|
29
|
+
const fields = [
|
|
30
|
+
`<loc>${xmlEscape(url.loc)}</loc>`,
|
|
31
|
+
url.lastmod ? `<lastmod>${xmlEscape(url.lastmod)}</lastmod>` : "",
|
|
32
|
+
url.changefreq ? `<changefreq>${url.changefreq}</changefreq>` : "",
|
|
33
|
+
url.priority === undefined ? "" : `<priority>${clampPriority(url.priority).toFixed(1)}</priority>`,
|
|
34
|
+
].filter(Boolean);
|
|
35
|
+
return ` <url>\n ${fields.join("\n ")}\n </url>`;
|
|
36
|
+
})
|
|
37
|
+
.join("\n");
|
|
38
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderRobotsTxt(options: { allow?: string[]; disallow?: string[]; sitemap?: string | string[] } = {}): string {
|
|
42
|
+
const lines = ["User-agent: *"];
|
|
43
|
+
for (const path of options.allow ?? []) lines.push(`Allow: ${path}`);
|
|
44
|
+
for (const path of options.disallow ?? []) lines.push(`Disallow: ${path}`);
|
|
45
|
+
for (const sitemap of Array.isArray(options.sitemap) ? options.sitemap : options.sitemap ? [options.sitemap] : []) {
|
|
46
|
+
lines.push(`Sitemap: ${sitemap}`);
|
|
47
|
+
}
|
|
48
|
+
return `${lines.join("\n")}\n`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function metaTags(meta: PageMeta): Record<string, string> {
|
|
52
|
+
const tags: Record<string, string> = {
|
|
53
|
+
title: meta.title,
|
|
54
|
+
"og:title": meta.title,
|
|
55
|
+
};
|
|
56
|
+
if (meta.description) {
|
|
57
|
+
tags.description = meta.description;
|
|
58
|
+
tags["og:description"] = meta.description;
|
|
59
|
+
}
|
|
60
|
+
if (meta.canonical) {
|
|
61
|
+
tags.canonical = meta.canonical;
|
|
62
|
+
tags["og:url"] = meta.canonical;
|
|
63
|
+
}
|
|
64
|
+
if (meta.image) tags["og:image"] = meta.image;
|
|
65
|
+
if (meta.type) tags["og:type"] = meta.type;
|
|
66
|
+
if (meta.noindex) tags.robots = "noindex,nofollow";
|
|
67
|
+
return tags;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function clampPriority(priority: number): number {
|
|
71
|
+
if (!Number.isFinite(priority)) return 0.5;
|
|
72
|
+
return Math.max(0, Math.min(1, priority));
|
|
73
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { fromBase64Url, toBase64Url } from "./ids.js";
|
|
2
|
+
import { epochSeconds } from "./time.js";
|
|
3
|
+
|
|
4
|
+
export type SignedSessionOptions = {
|
|
5
|
+
expiresInSeconds?: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type SessionEnvelope<T> = {
|
|
9
|
+
payload: T;
|
|
10
|
+
iat: number;
|
|
11
|
+
exp: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function signSession<T>(payload: T, secret: string, options: SignedSessionOptions = {}): Promise<string> {
|
|
15
|
+
if (!secret) throw new Error("Session secret is required.");
|
|
16
|
+
const now = epochSeconds();
|
|
17
|
+
const envelope: SessionEnvelope<T> = {
|
|
18
|
+
payload,
|
|
19
|
+
iat: now,
|
|
20
|
+
exp: now + (options.expiresInSeconds ?? 30 * 24 * 60 * 60),
|
|
21
|
+
};
|
|
22
|
+
const encoded = toBase64Url(JSON.stringify(envelope));
|
|
23
|
+
const signature = await hmacSha256(encoded, secret);
|
|
24
|
+
return `${encoded}.${signature}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function createSignedPayload(payload: string, secret: string): Promise<string> {
|
|
28
|
+
const encodedPayload = toBase64Url(payload);
|
|
29
|
+
const signature = await hmacSha256(encodedPayload, secret);
|
|
30
|
+
return `${encodedPayload}.${signature}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function verifySignedPayload(
|
|
34
|
+
token: string | null | undefined,
|
|
35
|
+
secret: string,
|
|
36
|
+
): Promise<string | null> {
|
|
37
|
+
if (!token) return null;
|
|
38
|
+
const [encodedPayload, providedSignature] = token.split(".");
|
|
39
|
+
if (!encodedPayload || !providedSignature) return null;
|
|
40
|
+
const expectedSignature = await hmacSha256(encodedPayload, secret);
|
|
41
|
+
if (!constantTimeEqual(expectedSignature, providedSignature)) return null;
|
|
42
|
+
return new TextDecoder().decode(fromBase64Url(encodedPayload));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function verifySession<T>(token: string | null | undefined, secret: string): Promise<T | null> {
|
|
46
|
+
if (!token || !secret) return null;
|
|
47
|
+
const [encoded, signature] = token.split(".");
|
|
48
|
+
if (!encoded || !signature) return null;
|
|
49
|
+
const expected = await hmacSha256(encoded, secret);
|
|
50
|
+
if (!constantTimeEqual(signature, expected)) return null;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const envelope = JSON.parse(new TextDecoder().decode(fromBase64Url(encoded))) as SessionEnvelope<T>;
|
|
54
|
+
if (!envelope || typeof envelope.exp !== "number" || envelope.exp < epochSeconds()) return null;
|
|
55
|
+
return envelope.payload;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function sessionCookie(name: string, value: string, options: { maxAgeSeconds?: number; secure?: boolean } = {}): string {
|
|
62
|
+
const secure = options.secure ? "; Secure" : "";
|
|
63
|
+
return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${options.maxAgeSeconds ?? 30 * 24 * 60 * 60}${secure}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function expiredSessionCookie(name: string, secure = false): string {
|
|
67
|
+
return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? "; Secure" : ""}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function hmacSha256(input: string, secret: string): Promise<string> {
|
|
71
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
72
|
+
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(input));
|
|
73
|
+
return toBase64Url(new Uint8Array(signature));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
77
|
+
if (a.length !== b.length) return false;
|
|
78
|
+
let diff = 0;
|
|
79
|
+
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
80
|
+
return diff === 0;
|
|
81
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export type SentQueueMessage<T> = {
|
|
2
|
+
body: T;
|
|
3
|
+
options?: QueueSendOptions;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type MemoryQueue<T = unknown> = Pick<Queue<T>, "send" | "sendBatch"> & {
|
|
7
|
+
sent: SentQueueMessage<T>[];
|
|
8
|
+
clear(): void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function createMemoryQueue<T = unknown>(): MemoryQueue<T> {
|
|
12
|
+
const sent: SentQueueMessage<T>[] = [];
|
|
13
|
+
return {
|
|
14
|
+
sent,
|
|
15
|
+
async send(body: T, options?: QueueSendOptions): Promise<QueueSendResponse> {
|
|
16
|
+
sent.push({ body, options });
|
|
17
|
+
return queueSendResponse();
|
|
18
|
+
},
|
|
19
|
+
async sendBatch(messages: Iterable<MessageSendRequest<T>>): Promise<QueueSendBatchResponse> {
|
|
20
|
+
for (const message of messages) sent.push({ body: message.body, options: message });
|
|
21
|
+
return queueSendBatchResponse();
|
|
22
|
+
},
|
|
23
|
+
clear(): void {
|
|
24
|
+
sent.length = 0;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type MemoryR2Object = {
|
|
30
|
+
key: string;
|
|
31
|
+
body: Uint8Array;
|
|
32
|
+
httpMetadata?: R2HTTPMetadata;
|
|
33
|
+
customMetadata?: Record<string, string>;
|
|
34
|
+
text(): Promise<string>;
|
|
35
|
+
json<T = unknown>(): Promise<T>;
|
|
36
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type MemoryR2Bucket = Pick<R2Bucket, "put" | "get" | "delete"> & {
|
|
40
|
+
objects: Map<string, MemoryR2Object>;
|
|
41
|
+
clear(): void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function createMemoryR2Bucket(): MemoryR2Bucket {
|
|
45
|
+
const objects = new Map<string, MemoryR2Object>();
|
|
46
|
+
return {
|
|
47
|
+
objects,
|
|
48
|
+
async put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob, options?: R2PutOptions): Promise<R2Object> {
|
|
49
|
+
const body = await toBytes(value);
|
|
50
|
+
const object = memoryR2Object(key, body, normalizeR2HttpMetadata(options?.httpMetadata), options?.customMetadata);
|
|
51
|
+
objects.set(key, object);
|
|
52
|
+
return object as unknown as R2Object;
|
|
53
|
+
},
|
|
54
|
+
async get(key: string): Promise<R2ObjectBody | null> {
|
|
55
|
+
return (objects.get(key) as unknown as R2ObjectBody | undefined) ?? null;
|
|
56
|
+
},
|
|
57
|
+
async delete(keys: string | string[]): Promise<void> {
|
|
58
|
+
for (const key of Array.isArray(keys) ? keys : [keys]) objects.delete(key);
|
|
59
|
+
},
|
|
60
|
+
clear(): void {
|
|
61
|
+
objects.clear();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function expectJsonResponse<T = unknown>(response: Response, status = 200): Promise<T> {
|
|
67
|
+
if (response.status !== status) throw new Error(`Expected response status ${status}, got ${response.status}.`);
|
|
68
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
69
|
+
if (!contentType.includes("application/json")) throw new Error(`Expected JSON response, got ${contentType || "no content-type"}.`);
|
|
70
|
+
return (await response.json()) as T;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function toBytes(value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob): Promise<Uint8Array> {
|
|
74
|
+
if (value === null) return new Uint8Array();
|
|
75
|
+
if (typeof value === "string") return new TextEncoder().encode(value);
|
|
76
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
77
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
|
|
78
|
+
if (value instanceof Blob) return new Uint8Array(await value.arrayBuffer());
|
|
79
|
+
return new Uint8Array(await new Response(value).arrayBuffer());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function queueSendResponse(): QueueSendResponse {
|
|
83
|
+
return { metadata: { metrics: { backlogCount: 0, backlogBytes: 0 } } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function queueSendBatchResponse(): QueueSendBatchResponse {
|
|
87
|
+
return { metadata: { metrics: { backlogCount: 0, backlogBytes: 0 } } };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeR2HttpMetadata(value: R2HTTPMetadata | Headers | undefined): R2HTTPMetadata | undefined {
|
|
91
|
+
if (!value) return undefined;
|
|
92
|
+
if (!(value instanceof Headers)) return value;
|
|
93
|
+
const contentType = value.get("content-type") ?? undefined;
|
|
94
|
+
const cacheControl = value.get("cache-control") ?? undefined;
|
|
95
|
+
return { contentType, cacheControl };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function memoryR2Object(
|
|
99
|
+
key: string,
|
|
100
|
+
body: Uint8Array,
|
|
101
|
+
httpMetadata?: R2HTTPMetadata,
|
|
102
|
+
customMetadata?: Record<string, string>,
|
|
103
|
+
): MemoryR2Object {
|
|
104
|
+
return {
|
|
105
|
+
key,
|
|
106
|
+
body,
|
|
107
|
+
httpMetadata,
|
|
108
|
+
customMetadata,
|
|
109
|
+
async text(): Promise<string> {
|
|
110
|
+
return new TextDecoder().decode(body);
|
|
111
|
+
},
|
|
112
|
+
async json<T = unknown>(): Promise<T> {
|
|
113
|
+
return JSON.parse(await this.text()) as T;
|
|
114
|
+
},
|
|
115
|
+
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
116
|
+
return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
package/src/time.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function nowIso(date = new Date()): string {
|
|
2
|
+
return date.toISOString();
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function epochSeconds(date = new Date()): number {
|
|
6
|
+
return Math.floor(date.getTime() / 1000);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function addSeconds(date: Date | string, seconds: number): string {
|
|
10
|
+
const base = typeof date === "string" ? new Date(date) : date;
|
|
11
|
+
return new Date(base.getTime() + seconds * 1000).toISOString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isFutureIso(value: string | null | undefined, now = new Date()): boolean {
|
|
15
|
+
if (!value) return false;
|
|
16
|
+
const timestamp = Date.parse(value);
|
|
17
|
+
return Number.isFinite(timestamp) && timestamp > now.getTime();
|
|
18
|
+
}
|